diff --git a/.gitignore b/.gitignore index 3b52ad7..cfcc0bd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,10 @@ __pycache__ # Misc issues +.DS_Store # Home Assistant configuration config/* !config/configuration.yaml custom_components/* -!custom_components/ev_smart_charging \ No newline at end of file +!custom_components/ev_smart_charging diff --git a/README.md b/README.md index 944beaf..9cb094d 100644 --- a/README.md +++ b/README.md @@ -10,17 +10,17 @@ ![Icon](assets/icon.png) -The EV Smart Charging integration will automatically charge the electric vehicle (EV) when the electricity price is the lowest. The integration natively supports the [Nordpool](https://github.com/custom-components/nordpool), the [Energi Data Service](https://github.com/MTrab/energidataservice) and the [Entso-e](https://github.com/JaccoR/hass-entso-e) integrations. The integration also supports a [generic price format](https://github.com/jonasbkarlsson/ev_smart_charging/wiki/Price-sensor). Together with using a template sensor, many different price integrations should be possible to use. +The EV Smart Charging integration will automatically charge the electric vehicle (EV) when the electricity price is the lowest. The integration natively supports the [Nordpool](https://github.com/custom-components/nordpool), the [Energi Data Service](https://github.com/MTrab/energidataservice), the [Entso-e](https://github.com/JaccoR/hass-entso-e) and the [TGE](https://github.com/PiotrMachowski/Home-Assistant-custom-components-TGE) integrations. The integration also supports a [generic price format](https://github.com/jonasbkarlsson/ev_smart_charging/wiki/Price-sensor). Together with using a template sensor, many different price integrations should be possible to use. The integration calculates the set of hours that will give the lowest price, by default restricted to a continuous set. This calculation is done when the electricity prices for tomorrow is available (typically between shortly after 13:00 CET/CEST and midnight) or when the time of the day is before the configured charge completion time. When the automatic charging has started, changes of settings will not have any effect. ## Requirements -- The [Nordpool](https://github.com/custom-components/nordpool), the [Energi Data Service](https://github.com/MTrab/energidataservice), the [Entso-e](https://github.com/JaccoR/hass-entso-e) integration or a template sensor that generates price data using the supported [generic price format](https://github.com/jonasbkarlsson/ev_smart_charging/wiki/Price-sensor). +- The [Nordpool](https://github.com/custom-components/nordpool), the [Energi Data Service](https://github.com/MTrab/energidataservice), the [Entso-e](https://github.com/JaccoR/hass-entso-e), the [TGE](https://github.com/PiotrMachowski/Home-Assistant-custom-components-TGE) integration or a template sensor that generates price data using the supported [generic price format](https://github.com/jonasbkarlsson/ev_smart_charging/wiki/Price-sensor). - Home Assistant version 2023.4 or newer. ## Features - Automatic EV charging control based on electricity prices. -- Native support of the [Nordpool](https://github.com/custom-components/nordpool), [Energi Data Service](https://github.com/MTrab/energidataservice) and [Entso-e](https://github.com/JaccoR/hass-entso-e) integrations. +- Native support of the [Nordpool](https://github.com/custom-components/nordpool), [Energi Data Service](https://github.com/MTrab/energidataservice), [Entso-e](https://github.com/JaccoR/hass-entso-e) and [TGE](https://github.com/PiotrMachowski/Home-Assistant-custom-components-TGE) integrations. - Support of a [generic price format](https://github.com/jonasbkarlsson/ev_smart_charging/wiki/Price-sensor). A template sensor can be used to get price information from many price integrations. - Configuration of the latest time of the day when the charging should be completed, and the earliest time the charging can start. - Selection of preference between one continuous charging session or several (possibly more price optimized) non-continuous charging sessions. @@ -63,7 +63,7 @@ The configuration form contains the entities that the integration is interacting Parameter | Required | Description -- | -- | -- Name | Yes | The name of the instance. -Electricity price entity | Yes | The Nordpool, the Energi Data Service, the Entso-e integration sensor entity or a template sensor providing the price in the [generic price format](https://github.com/jonasbkarlsson/ev_smart_charging/wiki/Price-sensor). For the Entso-e integration, the entity called `sensor.average_electricity_price` should be used. +Electricity price entity | Yes | The (HACS) Nordpool, the Energi Data Service, the Entso-e, the TGE integration sensor entity or a template sensor providing the price in the [generic price format](https://github.com/jonasbkarlsson/ev_smart_charging/wiki/Price-sensor). For the Entso-e integration, the entity called `sensor.average_electricity_price` should be used. EV SOC entity | Yes | Entity with the car's State-of-Charge. A value between 0 and 100. Note that this entity is crucial for the integration. If live information about he SOC is not available, please carefully read the section below with more information about the EV SOC entity. EV target SOC entity | No | Entity with the target value for the State-of-Charge. A value between 0 and 100. If not provided, 100 is assumed. Charger control switch entity | No | If provided, the integration will directly control the charger by setting the state of this entity to 'on' or 'off'. diff --git a/custom_components/ev_smart_charging/const.py b/custom_components/ev_smart_charging/const.py index 3b21ffc..3e80d5b 100644 --- a/custom_components/ev_smart_charging/const.py +++ b/custom_components/ev_smart_charging/const.py @@ -29,6 +29,7 @@ PLATFORM_NORDPOOL = "nordpool" PLATFORM_ENERGIDATASERVICE = "energidataservice" PLATFORM_ENTSOE = "entsoe" +PLATFORM_TGE = "tge" PLATFORM_VW = "volkswagen_we_connect_id" PLATFORM_OCPP = "ocpp" PLATFORM_GENERIC = "generic" diff --git a/custom_components/ev_smart_charging/helpers/config_flow.py b/custom_components/ev_smart_charging/helpers/config_flow.py index 306cd2a..62f5695 100644 --- a/custom_components/ev_smart_charging/helpers/config_flow.py +++ b/custom_components/ev_smart_charging/helpers/config_flow.py @@ -25,6 +25,7 @@ PLATFORM_ENTSOE, PLATFORM_GENERIC, PLATFORM_NORDPOOL, + PLATFORM_TGE, PLATFORM_OCPP, PLATFORM_VW, SWITCH, @@ -112,6 +113,8 @@ def find_price_sensor(hass: HomeAssistant) -> str: sensor = FindEntity.find_nordpool_sensor(hass) if len(sensor) == 0: sensor = FindEntity.find_energidataservice_sensor(hass) + if len(sensor) == 0: + sensor = FindEntity.find_tge_sensor(hass) if len(sensor) == 0: sensor = FindEntity.find_entsoe_sensor(hass) return sensor @@ -168,6 +171,18 @@ def find_generic_sensor(hass: HomeAssistant) -> str: return entity_id return "" + @staticmethod + def find_tge_sensor(hass: HomeAssistant) -> str: + """Search for TGE sensor""" + entity_registry: EntityRegistry = async_entity_registry_get(hass) + registry_entries: UserDict[str, RegistryEntry] = ( + entity_registry.entities.items() + ) + for entry in registry_entries: + if entry[1].platform == PLATFORM_TGE and entry[1].unique_id == 'tge_sensor_fixing1_rate': + return entry[1].entity_id + return "" + @staticmethod def find_vw_soc_sensor(hass: HomeAssistant) -> str: """Search for Volkswagen SOC sensor""" diff --git a/custom_components/ev_smart_charging/helpers/coordinator.py b/custom_components/ev_smart_charging/helpers/coordinator.py old mode 100644 new mode 100755 index 89d2cd4..cc7ca38 --- a/custom_components/ev_smart_charging/helpers/coordinator.py +++ b/custom_components/ev_smart_charging/helpers/coordinator.py @@ -10,6 +10,7 @@ from custom_components.ev_smart_charging.const import ( PLATFORM_ENERGIDATASERVICE, PLATFORM_ENTSOE, + PLATFORM_TGE, PLATFORM_GENERIC, PLATFORM_NORDPOOL, READY_HOUR_NONE, @@ -67,6 +68,21 @@ def convert_raw_item( item_new["end"] = item_new["start"] + timedelta(hours=1) return item_new + # Array of item = { + # "time": datetime, + # "price": float, + # } + # {'time': datetime.datetime(2023, 3, 6, 0, 0, + # tzinfo=), + # 'price': 146.96} + if platform == PLATFORM_TGE: + if item["price"] is not None and isinstance(item["time"], datetime): + item_new = {} + item_new["value"] = item["price"] + item_new["start"] = item["time"] + item_new["end"] = item["time"] + timedelta(hours=1) + return item_new + # Array of item = { # "time": string, # "price": float, diff --git a/custom_components/ev_smart_charging/helpers/price_adaptor.py b/custom_components/ev_smart_charging/helpers/price_adaptor.py index 43c5100..3686557 100644 --- a/custom_components/ev_smart_charging/helpers/price_adaptor.py +++ b/custom_components/ev_smart_charging/helpers/price_adaptor.py @@ -11,6 +11,7 @@ CONF_PRICE_SENSOR, PLATFORM_ENERGIDATASERVICE, PLATFORM_ENTSOE, + PLATFORM_TGE, PLATFORM_GENERIC, PLATFORM_NORDPOOL, ) @@ -65,7 +66,7 @@ def get_raw_today_local(self, state) -> Raw: if self._price_platform in (PLATFORM_NORDPOOL, PLATFORM_ENERGIDATASERVICE): return Raw(state.attributes["raw_today"], self._price_platform) - if self._price_platform in (PLATFORM_ENTSOE, PLATFORM_GENERIC): + if self._price_platform in (PLATFORM_ENTSOE, PLATFORM_TGE, PLATFORM_GENERIC): return Raw(state.attributes["prices_today"], self._price_platform) return Raw([]) @@ -76,7 +77,7 @@ def get_raw_tomorrow_local(self, state) -> Raw: if self._price_platform in (PLATFORM_NORDPOOL, PLATFORM_ENERGIDATASERVICE): return Raw(state.attributes["raw_tomorrow"], self._price_platform) - if self._price_platform in (PLATFORM_ENTSOE, PLATFORM_GENERIC): + if self._price_platform in (PLATFORM_ENTSOE, PLATFORM_TGE, PLATFORM_GENERIC): return Raw(state.attributes["prices_tomorrow"], self._price_platform) return Raw([]) @@ -87,7 +88,7 @@ def get_current_price(self, state) -> float: if self._price_platform in (PLATFORM_NORDPOOL, PLATFORM_ENERGIDATASERVICE): return state.attributes["current_price"] - if self._price_platform in (PLATFORM_ENTSOE, PLATFORM_GENERIC): + if self._price_platform in (PLATFORM_ENTSOE, PLATFORM_TGE, PLATFORM_GENERIC): time_now = dt.now() return self.get_raw_today_local(state).get_value(time_now) @@ -117,7 +118,7 @@ def validate_price_entity( _LOGGER.debug("No attribute raw_tomorrow in price sensor") return ("base", "sensor_is_not_price") - if price_platform in (PLATFORM_ENTSOE, PLATFORM_GENERIC): + if price_platform in (PLATFORM_ENTSOE, PLATFORM_TGE, PLATFORM_GENERIC): if not "prices_today" in price_state.attributes.keys(): _LOGGER.debug("No attribute prices today in price sensor") return ("base", "sensor_is_not_price") diff --git a/tests/helpers/helpers.py b/tests/helpers/helpers.py index 9072da0..cdd4fb3 100644 --- a/tests/helpers/helpers.py +++ b/tests/helpers/helpers.py @@ -11,6 +11,7 @@ PLATFORM_GENERIC, PLATFORM_NORDPOOL, PLATFORM_OCPP, + PLATFORM_TGE, PLATFORM_VW, SENSOR, SWITCH, @@ -152,6 +153,52 @@ def set_state( }, ) +class MockPriceEntityTGE: + """Mockup for price entity TGE""" + + @staticmethod + def create( + hass: HomeAssistant, entity_registry: EntityRegistry, price: float = 123 + ): + """Create a correct price entity""" + entity_registry.async_get_or_create( + domain=SENSOR, + platform=PLATFORM_TGE, + unique_id="tge_sensor_fixing1_rate", + ) + MockPriceEntityTGE.set_state(hass, None, None, price) + + @staticmethod + def set_state( + hass: HomeAssistant, + new_raw_today: list, + new_raw_tomorrow: list, + new_price: float = None, + ): + """Set state of MockPriceEntity""" + + # Find current price + if new_price is None: + new_price = "unavailable" + if price := Raw(new_raw_today, PLATFORM_TGE).get_value( + dt_util.now() + ): + new_price = price + if price := Raw(new_raw_tomorrow, PLATFORM_TGE).get_value( + dt_util.now() + ): + new_price = price + + # Set state + hass.states.async_set( + "sensor.tge_sensor_fixing1_rate", + f"{new_price}", + { + "prices_today": new_raw_today, + "prices_tomorrow": new_raw_tomorrow, + }, + ) + class MockPriceEntityGeneric: """Mockup for a generic price entity""" diff --git a/tests/helpers/test_config_flow.py b/tests/helpers/test_config_flow.py index 4c1c577..62c16f0 100644 --- a/tests/helpers/test_config_flow.py +++ b/tests/helpers/test_config_flow.py @@ -38,6 +38,7 @@ MockPriceEntityEnergiDataService, MockPriceEntityEntsoe, MockPriceEntityGeneric, + MockPriceEntityTGE, MockSOCEntity, MockTargetSOCEntity, ) @@ -299,6 +300,10 @@ async def test_find_entity(hass: HomeAssistant): MockPriceEntityEntsoe.create(hass, entity_registry) assert FindEntity.find_price_sensor(hass).startswith("sensor.entsoe") + assert FindEntity.find_tge_sensor(hass) == "" + MockPriceEntityTGE.create(hass, entity_registry) + assert FindEntity.find_price_sensor(hass).startswith("sensor.tge") + assert FindEntity.find_energidataservice_sensor(hass) == "" MockPriceEntityEnergiDataService.create(hass, entity_registry) assert FindEntity.find_price_sensor(hass).startswith("sensor.energidataservice") diff --git a/tests/helpers/test_coordinator.py b/tests/helpers/test_coordinator.py index 355685b..097df7b 100644 --- a/tests/helpers/test_coordinator.py +++ b/tests/helpers/test_coordinator.py @@ -5,6 +5,7 @@ from custom_components.ev_smart_charging.const import ( PLATFORM_ENERGIDATASERVICE, PLATFORM_ENTSOE, + PLATFORM_TGE, READY_HOUR_NONE, START_HOUR_NONE, ) @@ -24,9 +25,11 @@ PRICE_20220930, PRICE_20220930_ENERGIDATASERVICE, PRICE_20220930_ENTSOE, + PRICE_20220930_TGE, PRICE_20221001, PRICE_20221001_ENERGIDATASERVICE, PRICE_20221001_ENTSOE, + PRICE_20221001_TGE, ) from tests.schedule import MOCK_SCHEDULE_20220930 @@ -203,6 +206,57 @@ async def test_raw_entsoe(hass, set_cet_timezone, freezer): assert not price.is_valid() assert price.last_value() is None +async def test_raw_tge(hass, set_cet_timezone): + """Test Raw""" + + price = Raw(PRICE_20220930_TGE, PLATFORM_TGE) + assert price.get_raw() == PRICE_20220930 + assert price.is_valid() + assert price.copy().get_raw() == PRICE_20220930 + assert price.max_value() == 388.65 + assert price.last_value() == 49.64 + assert price.number_of_nonzero() == 24 + + time = datetime( + 2022, 9, 30, 8, 0, 0, tzinfo=dt_util.get_time_zone("Europe/Stockholm") + ) + assert price.get_value(time) == 388.65 + assert price.get_item(time) == { + "start": datetime( + 2022, 9, 30, 8, 0, tzinfo=dt_util.get_time_zone("Europe/Stockholm") + ), + "end": datetime( + 2022, 9, 30, 9, 0, tzinfo=dt_util.get_time_zone("Europe/Stockholm") + ), + "value": 388.65, + } + time = datetime( + 2022, 9, 29, 8, 0, 0, tzinfo=dt_util.get_time_zone("Europe/Stockholm") + ) + assert price.get_value(time) is None + assert price.get_item(time) is None + + price2 = Raw(PRICE_20221001_TGE, PLATFORM_TGE) + price.extend(None) + assert price.get_raw() == PRICE_20220930 + price.extend(price2) + assert price.number_of_nonzero() == 48 + + start = price.data[0]["start"] + assert start.tzinfo == dt_util.get_time_zone("Europe/Stockholm") + assert start.hour == 0 + price_utc = price.copy().to_utc() + start = price_utc.data[0]["start"] + assert start.tzinfo == dt_util.UTC + assert start.hour == 22 + price_local = price_utc.copy().to_local() + start = price_local.data[0]["start"] + assert start.tzinfo == dt_util.get_time_zone("Europe/Stockholm") + assert start.hour == 0 + + price = Raw([], PLATFORM_TGE) + assert not price.is_valid() + assert price.last_value() is None async def test_get_lowest_hours_non_continuous(hass, set_cet_timezone, freezer): """Test get_lowest_hours()""" diff --git a/tests/price.py b/tests/price.py index 3a56a70..ce6ea9d 100644 --- a/tests/price.py +++ b/tests/price.py @@ -1109,3 +1109,201 @@ "price": 72.19, }, ] + +PRICE_20220930_TGE = [ + { + "time": datetime(2022, 9, 30, 0, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 104.95, + }, + { + "time": datetime(2022, 9, 30, 1, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 99.74, + }, + { + "time": datetime(2022, 9, 30, 2, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 93.42, + }, + { + "time": datetime(2022, 9, 30, 3, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 93.25, + }, + { + "time": datetime(2022, 9, 30, 4, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 96.9, + }, + { + "time": datetime(2022, 9, 30, 5, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 137.17, + }, + { + "time": datetime(2022, 9, 30, 6, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 343.4, + }, + { + "time": datetime(2022, 9, 30, 7, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 372.56, + }, + { + "time": datetime(2022, 9, 30, 8, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 388.65, + }, + { + "time": datetime(2022, 9, 30, 9, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 382.75, + }, + { + "time": datetime(2022, 9, 30, 10, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 371.33, + }, + { + "time": datetime(2022, 9, 30, 11, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 347.16, + }, + { + "time": datetime(2022, 9, 30, 12, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 306.14, + }, + { + "time": datetime(2022, 9, 30, 13, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 276.73, + }, + { + "time": datetime(2022, 9, 30, 14, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 219.48, + }, + { + "time": datetime(2022, 9, 30, 15, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 209.21, + }, + { + "time": datetime(2022, 9, 30, 16, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 233.7, + }, + { + "time": datetime(2022, 9, 30, 17, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 322.5, + }, + { + "time": datetime(2022, 9, 30, 18, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 289.67, + }, + { + "time": datetime(2022, 9, 30, 19, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 121.58, + }, + { + "time": datetime(2022, 9, 30, 20, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 89.57, + }, + { + "time": datetime(2022, 9, 30, 21, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 76.63, + }, + { + "time": datetime(2022, 9, 30, 22, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 54.27, + }, + { + "time": datetime(2022, 9, 30, 23, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 49.64, + }, +] + +PRICE_20221001_TGE = [ + { + "time": datetime(2022, 10, 1, 0, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 68.63, + }, + { + "time": datetime(2022, 10, 1, 1, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 67.75, + }, + { + "time": datetime(2022, 10, 1, 2, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 54.58, + }, + { + "time": datetime(2022, 10, 1, 3, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 24.2, + }, + { + "time": datetime(2022, 10, 1, 4, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 19.05, + }, + { + "time": datetime(2022, 10, 1, 5, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 18.03, + }, + { + "time": datetime(2022, 10, 1, 6, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 17.63, + }, + { + "time": datetime(2022, 10, 1, 7, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 26.67, + }, + { + "time": datetime(2022, 10, 1, 8, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 49.15, + }, + { + "time": datetime(2022, 10, 1, 9, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 70.31, + }, + { + "time": datetime(2022, 10, 1, 10, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 70.12, + }, + { + "time": datetime(2022, 10, 1, 11, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 68.09, + }, + { + "time": datetime(2022, 10, 1, 12, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 59.96, + }, + { + "time": datetime(2022, 10, 1, 13, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 63.87, + }, + { + "time": datetime(2022, 10, 1, 14, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 66.27, + }, + { + "time": datetime(2022, 10, 1, 15, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 70.16, + }, + { + "time": datetime(2022, 10, 1, 16, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 74.4, + }, + { + "time": datetime(2022, 10, 1, 17, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 81.39, + }, + { + "time": datetime(2022, 10, 1, 18, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 88.17, + }, + { + "time": datetime(2022, 10, 1, 19, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 84.94, + }, + { + "time": datetime(2022, 10, 1, 20, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 73.95, + }, + { + "time": datetime(2022, 10, 1, 21, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 70.38, + }, + { + "time": datetime(2022, 10, 1, 22, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 72.88, + }, + { + "time": datetime(2022, 10, 1, 23, 0, tzinfo=ZoneInfo(key="Europe/Stockholm")), + "price": 72.19, + }, +]