From ff828cc34ec71041ed6ab004b21e879e94638266 Mon Sep 17 00:00:00 2001 From: Roeland Date: Sat, 31 Aug 2024 15:47:40 +0200 Subject: [PATCH 01/10] rework data update --- custom_components/entsoe/coordinator.py | 50 +++++++++---------------- 1 file changed, 17 insertions(+), 33 deletions(-) diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 4770cb7..6bcbc03 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -87,48 +87,32 @@ async def _async_update_data(self) -> dict: self.logger.debug(self.area) # We request data for yesterday up until tomorrow. - yesterday = pd.Timestamp.now(tz=self.__TIMEZONE).replace(hour=0, minute=0, second=0) - pd.Timedelta(days = 1) - tomorrow = yesterday + pd.Timedelta(hours = 71) + today = pd.Timestamp.now(tz=self.__TIMEZONE).normalize() + yesterday = today - pd.Timedelta(days = 1) + tomorrow_evening = yesterday + pd.Timedelta(hours = 71) - self.logger.debug(f"fetching prices for start date: {yesterday} to end date: {tomorrow}") - data = await self.fetch_prices(yesterday, tomorrow) + self.logger.debug(f"fetching prices for start date: {yesterday} to end date: {tomorrow_evening}") + data = await self.fetch_prices(yesterday, tomorrow_evening) self.logger.debug(f"received data = {data}") + if data is not None: parsed_data = self.parse_hourprices(data) - data_all = parsed_data[-48:].to_dict() - if parsed_data.size > 48: - self.logger.debug(f"received data for yesterday, today and tomorrow") - data_today = parsed_data[-48:-24].to_dict() - data_tomorrow = parsed_data[-24:].to_dict() - else: - self.logger.debug(f"received data for yesterday and today") - data_today = parsed_data[-24:].to_dict() - data_tomorrow = {} - + self.logger.debug(f"received data for {data.count()} hours") + return { - "data": data_all, - "dataToday": data_today, - "dataTomorrow": data_tomorrow, + "data": parsed_data, + "dataToday": parsed_data[today: today + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)], + "dataTomorrow": parsed_data[today + pd.Timedelta(days=1) : tomorrow_evening], } elif self.data is not None: self.logger.debug(f"received no data so fallback on existing data.") - newest_timestamp_today = pd.Timestamp(list(self.data["dataToday"])[-1]) - if any(self.data["dataTomorrow"]) and newest_timestamp_today < pd.Timestamp.now(newest_timestamp_today.tzinfo): - self.logger.debug(f"detected midnight switch values dataTomorrow to dataToday") - self.data["dataToday"] = self.data["dataTomorrow"] - self.data["dataTomorrow"] = {} - data_list = list(self.data["data"]) - new_data_dict = {} - if len(data_list) >= 24: - for hour, price in self.data["data"].items()[-24:]: - new_data_dict[hour] = price - self.data["data"] = new_data_dict - + return { "data": self.data["data"], - "dataToday": self.data["dataToday"], - "dataTomorrow": self.data["dataTomorrow"], + "dataToday": self.data[today: today + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)], + "dataTomorrow": self.data[today + pd.Timedelta(days=1) : tomorrow_evening], } + async def fetch_prices(self, start_date, end_date): try: @@ -178,10 +162,10 @@ def _filter_calculated_hourprices(self, data) -> list: time_zone = dt.now().tzinfo hourprices = data["data"] if self.calculation_mode == CALCULATION_MODE["rotation"]: - now = pd.Timestamp.now(tz=str(time_zone)).replace(hour=0, minute=0, second=0, microsecond=0) + now = pd.Timestamp.now(tz=str(time_zone)).normalize() return { hour: price for hour, price in hourprices.items() if pd.to_datetime(hour) >= now and pd.to_datetime(hour) < now + timedelta(days=1) } elif self.calculation_mode == CALCULATION_MODE["sliding"]: - now = pd.Timestamp.now(tz=str(time_zone)).replace(minute=0, second=0, microsecond=0) + now = pd.Timestamp.now(tz=str(time_zone)).normalize() return { hour: price for hour, price in hourprices.items() if pd.to_datetime(hour) >= now } elif self.calculation_mode == CALCULATION_MODE["publish"]: return data["data"] From aa62d373070585f77171cdb7871b9efa0c069b03 Mon Sep 17 00:00:00 2001 From: Roeland Date: Tue, 3 Sep 2024 17:16:44 +0200 Subject: [PATCH 02/10] Refactored to be less error prone. Cleanup state restore logic. --- custom_components/entsoe/coordinator.py | 66 ++++++++++-------- custom_components/entsoe/sensor.py | 92 +++---------------------- 2 files changed, 44 insertions(+), 114 deletions(-) diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 6bcbc03..8433b1d 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -18,9 +18,9 @@ from .const import DEFAULT_MODIFYER, AREA_INFO, CALCULATION_MODE - class EntsoeCoordinator(DataUpdateCoordinator): """Get the latest data and update the states.""" + today = None def __init__(self, hass: HomeAssistant, api_key, area, modifyer, calculation_mode = CALCULATION_MODE["default"], VAT = 0) -> None: """Initialize the data object.""" @@ -48,7 +48,7 @@ def __init__(self, hass: HomeAssistant, api_key, area, modifyer, calculation_mod hass, logger, name="ENTSO-e coordinator", - update_interval=timedelta(minutes=60), + update_interval=timedelta(minutes=10), ) def calc_price(self, value, fake_dt=None, no_template=False) -> float: @@ -87,8 +87,13 @@ async def _async_update_data(self) -> dict: self.logger.debug(self.area) # We request data for yesterday up until tomorrow. - today = pd.Timestamp.now(tz=self.__TIMEZONE).normalize() - yesterday = today - pd.Timedelta(days = 1) + self.today = pd.Timestamp.now(tz=self.__TIMEZONE).normalize() + + if self.check_update_needed() is False: + self.logger.debug(f"Skipping api fetch. All data is already available") + return self.data + + yesterday = self.today - pd.Timedelta(days = 1) tomorrow_evening = yesterday + pd.Timedelta(hours = 71) self.logger.debug(f"fetching prices for start date: {yesterday} to end date: {tomorrow_evening}") @@ -99,28 +104,21 @@ async def _async_update_data(self) -> dict: parsed_data = self.parse_hourprices(data) self.logger.debug(f"received data for {data.count()} hours") - return { - "data": parsed_data, - "dataToday": parsed_data[today: today + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)], - "dataTomorrow": parsed_data[today + pd.Timedelta(days=1) : tomorrow_evening], - } - elif self.data is not None: - self.logger.debug(f"received no data so fallback on existing data.") - - return { - "data": self.data["data"], - "dataToday": self.data[today: today + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)], - "dataTomorrow": self.data[today + pd.Timedelta(days=1) : tomorrow_evening], - } - - + return parsed_data + + def check_update_needed(self): + if self.data is None: + return True + if self.get_data_today().count() != 24 or self.get_data_tomorrow().count() != 24: + return True + return False + async def fetch_prices(self, start_date, end_date): try: # run api_update in async job resp = await self.hass.async_add_executor_job( self.api_update, start_date, end_date, self.api_key ) - return resp except (HTTPError) as exc: @@ -144,23 +142,31 @@ def api_update(self, start_date, end_date, api_key): ) def processed_data(self): - filtered_hourprices = self._filter_calculated_hourprices(self.data) + filtered_hourprices = self._filter_calculated_hourprices() + today = pd.Timestamp.now(tz=self.__TIMEZONE).normalize() + return { - "current_price": self.get_current_hourprice(self.data["data"]), - "next_hour_price": self.get_next_hourprice(self.data["data"]), + "current_price": self.get_current_hourprice(self.data.to_dict()), + "next_hour_price": self.get_next_hourprice(self.data.to_dict()), "min_price": self.get_min_price(filtered_hourprices), "max_price": self.get_max_price(filtered_hourprices), "avg_price": self.get_avg_price(filtered_hourprices), "time_min": self.get_min_time(filtered_hourprices), "time_max": self.get_max_time(filtered_hourprices), - "prices_today": self.get_timestamped_prices(self.data["dataToday"]), - "prices_tomorrow": self.get_timestamped_prices(self.data["dataTomorrow"]), - "prices": self.get_timestamped_prices(self.data["data"]), + "prices_today": self.get_timestamped_prices(self.get_data_today().to_dict()), + "prices_tomorrow": self.get_timestamped_prices(self.get_data_tomorrow().to_dict()), + "prices": self.get_timestamped_prices(self.data.to_dict()), } - - def _filter_calculated_hourprices(self, data) -> list: + + def get_data_today(self): + return self.data[self.today: self.today + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)] + + def get_data_tomorrow(self): + return self.data[self.today + pd.Timedelta(days=1) : self.today + pd.Timedelta(days=1) + pd.Timedelta(hours=23)] + + def _filter_calculated_hourprices(self) -> list: time_zone = dt.now().tzinfo - hourprices = data["data"] + hourprices = self.data.to_dict() if self.calculation_mode == CALCULATION_MODE["rotation"]: now = pd.Timestamp.now(tz=str(time_zone)).normalize() return { hour: price for hour, price in hourprices.items() if pd.to_datetime(hour) >= now and pd.to_datetime(hour) < now + timedelta(days=1) } @@ -168,7 +174,7 @@ def _filter_calculated_hourprices(self, data) -> list: now = pd.Timestamp.now(tz=str(time_zone)).normalize() return { hour: price for hour, price in hourprices.items() if pd.to_datetime(hour) >= now } elif self.calculation_mode == CALCULATION_MODE["publish"]: - return data["data"] + return hourprices def get_next_hourprice(self, hourprices) -> int: for hour, price in hourprices.items(): diff --git a/custom_components/entsoe/sensor.py b/custom_components/entsoe/sensor.py index 1154edc..b17299d 100644 --- a/custom_components/entsoe/sensor.py +++ b/custom_components/entsoe/sensor.py @@ -40,31 +40,35 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: key="current_price", name="Current electricity market price", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", - value_fn=lambda data: data["current_price"], - state_class=SensorStateClass.MEASUREMENT + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["current_price"] ), EntsoeEntityDescription( key="next_hour_price", name="Next hour electricity market price", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data["next_hour_price"], ), EntsoeEntityDescription( key="min_price", name="Lowest energy price today", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data["min_price"], ), EntsoeEntityDescription( key="max_price", name="Highest energy price today", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data["max_price"], ), EntsoeEntityDescription( key="avg_price", name="Average electricity price today", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data["avg_price"], ), EntsoeEntityDescription( @@ -72,6 +76,7 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: name="Current percentage of highest electricity price today", native_unit_of_measurement=f"{PERCENTAGE}", icon="mdi:percent", + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: round( data["current_price"] / data["max_price"] * 100, 1 ), @@ -113,42 +118,11 @@ async def async_setup_entry( # Add an entity for each sensor type async_add_entities(entities, True) -class EntsoeSensorExtraStoredData(SensorExtraStoredData): - """Object to hold extra stored data.""" - _attr_extra_state_attributes: any - - def __init__(self, native_value, native_unit_of_measurement, _attr_extra_state_attributes) -> None: - super().__init__(native_value, native_unit_of_measurement) - self._attr_extra_state_attributes = _attr_extra_state_attributes - - def as_dict(self) -> dict[str, any]: - """Return a dict representation of the utility sensor data.""" - data = super().as_dict() - data["_attr_extra_state_attributes"] = self._attr_extra_state_attributes if self._attr_extra_state_attributes is not None else None - - return data - - @classmethod - def from_dict(cls, restored: dict[str, Any]) -> EntsoeSensorExtraStoredData | None: - """Initialize a stored sensor state from a dict.""" - extra = SensorExtraStoredData.from_dict(restored) - if extra is None: - return None - - _attr_extra_state_attributes: any = restored["_attr_extra_state_attributes"] if "_attr_extra_state_attributes" in restored else None - - return cls( - extra.native_value, - extra.native_unit_of_measurement, - _attr_extra_state_attributes - ) - class EntsoeSensor(CoordinatorEntity, RestoreSensor): """Representation of a ENTSO-e sensor.""" _attr_attribution = ATTRIBUTION _attr_icon = ICON - _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, coordinator: EntsoeCoordinator, description: EntsoeEntityDescription, name: str = "") -> None: """Initialize the sensor.""" @@ -165,8 +139,6 @@ def __init__(self, coordinator: EntsoeCoordinator, description: EntsoeEntityDesc self._attr_unique_id = f"entsoe.{description.key}" self._attr_name = f"{description.name}" - self._attr_device_class = SensorDeviceClass.MONETARY if description.device_class is None else description.device_class - self._attr_state_class = None if self._attr_device_class in [SensorDeviceClass.TIMESTAMP, SensorDeviceClass.MONETARY] else SensorStateClass.MEASUREMENT self.entity_description: EntsoeEntityDescription = description self._update_job = HassJob(self.async_schedule_update_ha_state) @@ -174,23 +146,13 @@ def __init__(self, coordinator: EntsoeCoordinator, description: EntsoeEntityDesc super().__init__(coordinator) - async def async_added_to_hass(self): - """Handle entity which will be added.""" - await super().async_added_to_hass() - # if (last_sensor_data := await self.async_get_last_sensor_data()) is not None: - # # new introduced in 2022.04 - # if last_sensor_data.native_value is not None: - # self._attr_native_value = last_sensor_data.native_value - # if last_sensor_data._attr_extra_state_attributes is not None: - # self._attr_extra_state_attributes = dict(last_sensor_data._attr_extra_state_attributes) - async def async_update(self) -> None: """Get the latest data and updates the states.""" _LOGGER.debug(f"update function for '{self.entity_id} called.'") value: Any = None if self.coordinator.data is not None: try: - _LOGGER.debug(f"current coordinator.data value: {self.coordinator.data}") + #_LOGGER.debug(f"current coordinator.data value: {self.coordinator.data}") value = self.entity_description.value_fn(self.coordinator.processed_data()) #Check if value if a panda timestamp and if so convert to an HA compatible format if isinstance(value, pd._libs.tslibs.timestamps.Timestamp): @@ -221,41 +183,3 @@ async def async_update(self) -> None: self._update_job, utcnow().replace(minute=0, second=0) + timedelta(hours=1), ) - - @property - def extra_restore_state_data(self) -> EntsoeSensorExtraStoredData: - """Return sensor specific state data to be restored.""" - return EntsoeSensorExtraStoredData(self._attr_native_value, None, self._attr_extra_state_attributes if hasattr(self, "_attr_extra_state_attributes") else None) - - async def async_get_last_sensor_data(self): - """Restore Entsoe-e Sensor Extra Stored Data.""" - _LOGGER.debug("restoring sensor data") - if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: - return None - - if self.description.key == "avg_price" and self.coordinator.data is None: - _LOGGER.debug("fallback on stored state to fill coordinator.data object") - newest_stored_timestamp = pd.Timestamp(restored_last_extra_data.as_dict()["_attr_extra_state_attributes"].get("prices")[-1]["time"]) - current_timestamp = pd.Timestamp.now(newest_stored_timestamp.tzinfo) - if newest_stored_timestamp < current_timestamp: - self.coordinator.data = self.parse_attribute_data_to_coordinator_data(restored_last_extra_data.as_dict()["_attr_extra_state_attributes"]) - else: - _LOGGER.debug("Stored state dit not contain data of today. Skipped restoring coordinator data.") - - return EntsoeSensorExtraStoredData.from_dict( - restored_last_extra_data.as_dict() - ) - - def parse_attribute_data_to_coordinator_data(self, attributes): - data_all = { pd.Timestamp(item["time"]) : item["price"] for item in attributes.get("prices")[-48:] } - if len(attributes.get("prices")) > 48: - data_today = { pd.Timestamp(item["time"]) : item["price"] for item in attributes.get("prices")[-48:-24] } - data_tomorrow = { pd.Timestamp(item["time"]) : item["price"] for item in attributes.get("prices")[-24:] } - else: - data_today = { pd.Timestamp(item["time"]) : item["price"] for item in attributes.get("prices")[-24:]} - data_tomorrow = {} - return { - "data": data_all, - "dataToday": data_today, - "dataTomorrow": data_tomorrow, - } From fb8ab7f90334c239a4acd8afd9018afbb95bbb71 Mon Sep 17 00:00:00 2001 From: Roeland Date: Tue, 3 Sep 2024 17:16:44 +0200 Subject: [PATCH 03/10] Refactored to be less error prone. Cleanup state restore logic. --- .github/dependabot.yml | 10 +- custom_components/entsoe/coordinator.py | 71 +++++++------- custom_components/entsoe/sensor.py | 118 ++++++------------------ 3 files changed, 74 insertions(+), 125 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f84f1ce..20ea043 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,4 +4,12 @@ updates: directory: "/" schedule: interval: weekly - time: "06:00" \ No newline at end of file + time: "06:00" + + - package-ecosystem: "pip" + directory: "/" + target-branch: "main" + labels: + - "dependency-update" + schedule: + interval: "monthly" \ No newline at end of file diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 6bcbc03..e55e265 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -18,9 +18,9 @@ from .const import DEFAULT_MODIFYER, AREA_INFO, CALCULATION_MODE - class EntsoeCoordinator(DataUpdateCoordinator): """Get the latest data and update the states.""" + today = None def __init__(self, hass: HomeAssistant, api_key, area, modifyer, calculation_mode = CALCULATION_MODE["default"], VAT = 0) -> None: """Initialize the data object.""" @@ -83,12 +83,16 @@ def parse_hourprices(self, hourprices): async def _async_update_data(self) -> dict: """Get the latest data from ENTSO-e""" - self.logger.debug("Fetching ENTSO-e data") + self.logger.debug("ENTSO-e DataUpdateCoordinator data update") self.logger.debug(self.area) - # We request data for yesterday up until tomorrow. - today = pd.Timestamp.now(tz=self.__TIMEZONE).normalize() - yesterday = today - pd.Timedelta(days = 1) + now = pd.Timestamp.now(tz=self.__TIMEZONE) + self.today = now.normalize() + if self.check_update_needed(now) is False: + self.logger.debug(f"Skipping api fetch. All data is already available") + return self.data + + yesterday = self.today - pd.Timedelta(days = 1) tomorrow_evening = yesterday + pd.Timedelta(hours = 71) self.logger.debug(f"fetching prices for start date: {yesterday} to end date: {tomorrow_evening}") @@ -97,30 +101,25 @@ async def _async_update_data(self) -> dict: if data is not None: parsed_data = self.parse_hourprices(data) - self.logger.debug(f"received data for {data.count()} hours") + self.logger.debug(f"received pricing data from entso-e for {data.count()} hours") - return { - "data": parsed_data, - "dataToday": parsed_data[today: today + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)], - "dataTomorrow": parsed_data[today + pd.Timedelta(days=1) : tomorrow_evening], - } - elif self.data is not None: - self.logger.debug(f"received no data so fallback on existing data.") - - return { - "data": self.data["data"], - "dataToday": self.data[today: today + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)], - "dataTomorrow": self.data[today + pd.Timedelta(days=1) : tomorrow_evening], - } - - + return parsed_data + + def check_update_needed(self, now): + if self.data is None: + return True + if self.get_data_today().count() != 24: + return True + if self.get_data_tomorrow().count() != 24 and now.hour > 12: + return True + return False + async def fetch_prices(self, start_date, end_date): try: # run api_update in async job resp = await self.hass.async_add_executor_job( self.api_update, start_date, end_date, self.api_key ) - return resp except (HTTPError) as exc: @@ -144,23 +143,31 @@ def api_update(self, start_date, end_date, api_key): ) def processed_data(self): - filtered_hourprices = self._filter_calculated_hourprices(self.data) + filtered_hourprices = self._filter_calculated_hourprices() + today = pd.Timestamp.now(tz=self.__TIMEZONE).normalize() + return { - "current_price": self.get_current_hourprice(self.data["data"]), - "next_hour_price": self.get_next_hourprice(self.data["data"]), + "current_price": self.get_current_hourprice(self.data.to_dict()), + "next_hour_price": self.get_next_hourprice(self.data.to_dict()), "min_price": self.get_min_price(filtered_hourprices), "max_price": self.get_max_price(filtered_hourprices), "avg_price": self.get_avg_price(filtered_hourprices), "time_min": self.get_min_time(filtered_hourprices), "time_max": self.get_max_time(filtered_hourprices), - "prices_today": self.get_timestamped_prices(self.data["dataToday"]), - "prices_tomorrow": self.get_timestamped_prices(self.data["dataTomorrow"]), - "prices": self.get_timestamped_prices(self.data["data"]), + "prices_today": self.get_timestamped_prices(self.get_data_today().to_dict()), + "prices_tomorrow": self.get_timestamped_prices(self.get_data_tomorrow().to_dict()), + "prices": self.get_timestamped_prices(self.data.to_dict()), } - - def _filter_calculated_hourprices(self, data) -> list: + + def get_data_today(self): + return self.data[self.today: self.today + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)] + + def get_data_tomorrow(self): + return self.data[self.today + pd.Timedelta(days=1) : self.today + pd.Timedelta(days=1) + pd.Timedelta(hours=23)] + + def _filter_calculated_hourprices(self) -> list: time_zone = dt.now().tzinfo - hourprices = data["data"] + hourprices = self.data.to_dict() if self.calculation_mode == CALCULATION_MODE["rotation"]: now = pd.Timestamp.now(tz=str(time_zone)).normalize() return { hour: price for hour, price in hourprices.items() if pd.to_datetime(hour) >= now and pd.to_datetime(hour) < now + timedelta(days=1) } @@ -168,7 +175,7 @@ def _filter_calculated_hourprices(self, data) -> list: now = pd.Timestamp.now(tz=str(time_zone)).normalize() return { hour: price for hour, price in hourprices.items() if pd.to_datetime(hour) >= now } elif self.calculation_mode == CALCULATION_MODE["publish"]: - return data["data"] + return hourprices def get_next_hourprice(self, hourprices) -> int: for hour, price in hourprices.items(): diff --git a/custom_components/entsoe/sensor.py b/custom_components/entsoe/sensor.py index 1154edc..1ab28b8 100644 --- a/custom_components/entsoe/sensor.py +++ b/custom_components/entsoe/sensor.py @@ -40,31 +40,35 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: key="current_price", name="Current electricity market price", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", - value_fn=lambda data: data["current_price"], - state_class=SensorStateClass.MEASUREMENT + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["current_price"] ), EntsoeEntityDescription( key="next_hour_price", name="Next hour electricity market price", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data["next_hour_price"], ), EntsoeEntityDescription( key="min_price", name="Lowest energy price today", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data["min_price"], ), EntsoeEntityDescription( key="max_price", name="Highest energy price today", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data["max_price"], ), EntsoeEntityDescription( key="avg_price", name="Average electricity price today", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data["avg_price"], ), EntsoeEntityDescription( @@ -72,6 +76,7 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: name="Current percentage of highest electricity price today", native_unit_of_measurement=f"{PERCENTAGE}", icon="mdi:percent", + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: round( data["current_price"] / data["max_price"] * 100, 1 ), @@ -113,46 +118,16 @@ async def async_setup_entry( # Add an entity for each sensor type async_add_entities(entities, True) -class EntsoeSensorExtraStoredData(SensorExtraStoredData): - """Object to hold extra stored data.""" - _attr_extra_state_attributes: any - - def __init__(self, native_value, native_unit_of_measurement, _attr_extra_state_attributes) -> None: - super().__init__(native_value, native_unit_of_measurement) - self._attr_extra_state_attributes = _attr_extra_state_attributes - - def as_dict(self) -> dict[str, any]: - """Return a dict representation of the utility sensor data.""" - data = super().as_dict() - data["_attr_extra_state_attributes"] = self._attr_extra_state_attributes if self._attr_extra_state_attributes is not None else None - - return data - - @classmethod - def from_dict(cls, restored: dict[str, Any]) -> EntsoeSensorExtraStoredData | None: - """Initialize a stored sensor state from a dict.""" - extra = SensorExtraStoredData.from_dict(restored) - if extra is None: - return None - - _attr_extra_state_attributes: any = restored["_attr_extra_state_attributes"] if "_attr_extra_state_attributes" in restored else None - - return cls( - extra.native_value, - extra.native_unit_of_measurement, - _attr_extra_state_attributes - ) - class EntsoeSensor(CoordinatorEntity, RestoreSensor): """Representation of a ENTSO-e sensor.""" _attr_attribution = ATTRIBUTION _attr_icon = ICON - _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, coordinator: EntsoeCoordinator, description: EntsoeEntityDescription, name: str = "") -> None: """Initialize the sensor.""" self.description = description + self.last_update_success = True if name not in (None, ""): #The Id used for addressing the entity in the ui, recorder history etc. @@ -165,8 +140,6 @@ def __init__(self, coordinator: EntsoeCoordinator, description: EntsoeEntityDesc self._attr_unique_id = f"entsoe.{description.key}" self._attr_name = f"{description.name}" - self._attr_device_class = SensorDeviceClass.MONETARY if description.device_class is None else description.device_class - self._attr_state_class = None if self._attr_device_class in [SensorDeviceClass.TIMESTAMP, SensorDeviceClass.MONETARY] else SensorStateClass.MEASUREMENT self.entity_description: EntsoeEntityDescription = description self._update_job = HassJob(self.async_schedule_update_ha_state) @@ -174,42 +147,36 @@ def __init__(self, coordinator: EntsoeCoordinator, description: EntsoeEntityDesc super().__init__(coordinator) - async def async_added_to_hass(self): - """Handle entity which will be added.""" - await super().async_added_to_hass() - # if (last_sensor_data := await self.async_get_last_sensor_data()) is not None: - # # new introduced in 2022.04 - # if last_sensor_data.native_value is not None: - # self._attr_native_value = last_sensor_data.native_value - # if last_sensor_data._attr_extra_state_attributes is not None: - # self._attr_extra_state_attributes = dict(last_sensor_data._attr_extra_state_attributes) - async def async_update(self) -> None: """Get the latest data and updates the states.""" - _LOGGER.debug(f"update function for '{self.entity_id} called.'") + #_LOGGER.debug(f"update function for '{self.entity_id} called.'") value: Any = None if self.coordinator.data is not None: try: - _LOGGER.debug(f"current coordinator.data value: {self.coordinator.data}") - value = self.entity_description.value_fn(self.coordinator.processed_data()) + processed = self.coordinator.processed_data() + #_LOGGER.debug(f"current coordinator.data value: {self.coordinator.data}") + value = self.entity_description.value_fn(processed) #Check if value if a panda timestamp and if so convert to an HA compatible format if isinstance(value, pd._libs.tslibs.timestamps.Timestamp): value = value.to_pydatetime() self._attr_native_value = value + + if self.description.key == "avg_price" and self._attr_native_value is not None: + self._attr_extra_state_attributes = { + "prices_today": processed["prices_today"], + "prices_tomorrow": processed["prices_tomorrow"], + "prices": processed["prices"] + } + + self.last_update_success = True _LOGGER.debug(f"updated '{self.entity_id}' to value: {value}") + except Exception as exc: # No data available + self.last_update_success = False _LOGGER.warning(f"Unable to update entity '{self.entity_id}' due to data processing error: {value} and error: {exc} , data: {self.coordinator.data}") - # These return pd.timestamp objects and are therefore not able to get into attributes - invalid_keys = {"time_min", "time_max"} - # Currency is immaterial to the entity key - existing_entities = [type.key for type in sensor_descriptions(currency = "")] - if self.description.key == "avg_price" and self._attr_native_value is not None: - self._attr_extra_state_attributes = {x: self.coordinator.processed_data()[x] for x in self.coordinator.processed_data() if x not in invalid_keys and x not in existing_entities} - - # Cancel the currently scheduled event if there is any if self._unsub_update: self._unsub_update() @@ -223,39 +190,6 @@ async def async_update(self) -> None: ) @property - def extra_restore_state_data(self) -> EntsoeSensorExtraStoredData: - """Return sensor specific state data to be restored.""" - return EntsoeSensorExtraStoredData(self._attr_native_value, None, self._attr_extra_state_attributes if hasattr(self, "_attr_extra_state_attributes") else None) - - async def async_get_last_sensor_data(self): - """Restore Entsoe-e Sensor Extra Stored Data.""" - _LOGGER.debug("restoring sensor data") - if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: - return None - - if self.description.key == "avg_price" and self.coordinator.data is None: - _LOGGER.debug("fallback on stored state to fill coordinator.data object") - newest_stored_timestamp = pd.Timestamp(restored_last_extra_data.as_dict()["_attr_extra_state_attributes"].get("prices")[-1]["time"]) - current_timestamp = pd.Timestamp.now(newest_stored_timestamp.tzinfo) - if newest_stored_timestamp < current_timestamp: - self.coordinator.data = self.parse_attribute_data_to_coordinator_data(restored_last_extra_data.as_dict()["_attr_extra_state_attributes"]) - else: - _LOGGER.debug("Stored state dit not contain data of today. Skipped restoring coordinator data.") - - return EntsoeSensorExtraStoredData.from_dict( - restored_last_extra_data.as_dict() - ) - - def parse_attribute_data_to_coordinator_data(self, attributes): - data_all = { pd.Timestamp(item["time"]) : item["price"] for item in attributes.get("prices")[-48:] } - if len(attributes.get("prices")) > 48: - data_today = { pd.Timestamp(item["time"]) : item["price"] for item in attributes.get("prices")[-48:-24] } - data_tomorrow = { pd.Timestamp(item["time"]) : item["price"] for item in attributes.get("prices")[-24:] } - else: - data_today = { pd.Timestamp(item["time"]) : item["price"] for item in attributes.get("prices")[-24:]} - data_tomorrow = {} - return { - "data": data_all, - "dataToday": data_today, - "dataTomorrow": data_tomorrow, - } + def available(self) -> bool: + """Return if entity is available.""" + return self.last_update_success From 54998e3759525be29bbd79a3d5c5e385a2d60334 Mon Sep 17 00:00:00 2001 From: Roeland Date: Wed, 4 Sep 2024 19:09:40 +0200 Subject: [PATCH 04/10] more refactoring --- custom_components/entsoe/coordinator.py | 90 ++++++++++++------------- custom_components/entsoe/sensor.py | 75 ++++++++++----------- 2 files changed, 80 insertions(+), 85 deletions(-) diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 40ab5ca..1d2afb3 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -20,7 +20,6 @@ class EntsoeCoordinator(DataUpdateCoordinator): """Get the latest data and update the states.""" - today = None def __init__(self, hass: HomeAssistant, api_key, area, modifyer, calculation_mode = CALCULATION_MODE["default"], VAT = 0) -> None: """Initialize the data object.""" @@ -31,6 +30,9 @@ def __init__(self, hass: HomeAssistant, api_key, area, modifyer, calculation_mod self.calculation_mode = calculation_mode self.vat = VAT self.__TIMEZONE = dt.now().tzinfo + self.today = None + self.filtered_hourprices = [] + self.counter = 0 # Check incase the sensor was setup using config flow. # This blow up if the template isnt valid. @@ -48,7 +50,7 @@ def __init__(self, hass: HomeAssistant, api_key, area, modifyer, calculation_mod hass, logger, name="ENTSO-e coordinator", - update_interval=timedelta(minutes=10), + update_interval=timedelta(minutes=60), ) def calc_price(self, value, fake_dt=None, no_template=False) -> float: @@ -98,11 +100,11 @@ async def _async_update_data(self) -> dict: self.logger.debug(f"fetching prices for start date: {yesterday} to end date: {tomorrow_evening}") data = await self.fetch_prices(yesterday, tomorrow_evening) self.logger.debug(f"received data = {data}") - + if data is not None: parsed_data = self.parse_hourprices(data) self.logger.debug(f"received pricing data from entso-e for {data.count()} hours") - + self.filtered_hourprices = self._filter_calculated_hourprices(parsed_data) return parsed_data def check_update_needed(self, now): @@ -127,12 +129,11 @@ async def fetch_prices(self, start_date, end_date): raise UpdateFailed("Unauthorized: Please check your API-key.") from exc except Exception as exc: if self.data is not None: - newest_timestamp = pd.Timestamp(list(self.data["data"])[-1]) + newest_timestamp = self.data.index[-1] if(newest_timestamp) > pd.Timestamp.now(newest_timestamp.tzinfo): self.logger.warning(f"Warning the integration is running in degraded mode (falling back on stored data) since fetching the latest ENTSOE-e prices failed with exception: {exc}.") else: - self.logger.error(f"Error the latest available data is older than the current time. Therefore entities will no longer update. {exc}") - raise UpdateFailed(f"Unexcpected error when fetching ENTSO-e prices: {exc}") from exc + raise UpdateFailed(f"The latest available data is older than the current time. Therefore entities will no longer update. Error: {exc}") from exc else: self.logger.warning(f"Warning the integration doesn't have any up to date local data this means that entities won't get updated but access remains to restorable entities: {exc}.") @@ -142,32 +143,12 @@ def api_update(self, start_date, end_date, api_key): country_code=self.area, start=start_date, end=end_date ) - def processed_data(self): - filtered_hourprices = self._filter_calculated_hourprices() - today = pd.Timestamp.now(tz=self.__TIMEZONE).normalize() - - return { - "current_price": self.get_current_hourprice(self.data.to_dict()), - "next_hour_price": self.get_next_hourprice(self.data.to_dict()), - "min_price": self.get_min_price(filtered_hourprices), - "max_price": self.get_max_price(filtered_hourprices), - "avg_price": self.get_avg_price(filtered_hourprices), - "time_min": self.get_min_time(filtered_hourprices), - "time_max": self.get_max_time(filtered_hourprices), - "prices_today": self.get_timestamped_prices(self.get_data_today().to_dict()), - "prices_tomorrow": self.get_timestamped_prices(self.get_data_tomorrow().to_dict()), - "prices": self.get_timestamped_prices(self.data.to_dict()), - } - - def get_data_today(self): - return self.data[self.today: self.today + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)] - - def get_data_tomorrow(self): - return self.data[self.today + pd.Timedelta(days=1) : self.today + pd.Timedelta(days=1) + pd.Timedelta(hours=23)] + def today_data_available(self): + return self.get_data_today().count() == 24 - def _filter_calculated_hourprices(self) -> list: + def _filter_calculated_hourprices(self, data) -> list: time_zone = dt.now().tzinfo - hourprices = self.data.to_dict() + hourprices = data.to_dict() if self.calculation_mode == CALCULATION_MODE["rotation"]: now = pd.Timestamp.now(tz=str(time_zone)).normalize() return { hour: price for hour, price in hourprices.items() if pd.to_datetime(hour) >= now and pd.to_datetime(hour) < now + timedelta(days=1) } @@ -176,34 +157,49 @@ def _filter_calculated_hourprices(self) -> list: return { hour: price for hour, price in hourprices.items() if pd.to_datetime(hour) >= now } elif self.calculation_mode == CALCULATION_MODE["publish"]: return hourprices + + def get_prices_today(self): + return self.get_timestamped_prices(self.get_data_today().to_dict()) + + def get_prices_tomorrow(self): + return self.get_timestamped_prices(self.get_data_tomorrow().to_dict()) + + def get_prices(self): + return self.get_timestamped_prices(self.data.to_dict()) + + def get_data_today(self): + return self.data[self.today: self.today + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)] + + def get_data_tomorrow(self): + return self.data[self.today + pd.Timedelta(days=1) : self.today + pd.Timedelta(days=1) + pd.Timedelta(hours=23)] - def get_next_hourprice(self, hourprices) -> int: - for hour, price in hourprices.items(): + def get_next_hourprice(self) -> int: + for hour, price in self.data.to_dict().items(): if hour - timedelta(hours=1) <= dt.utcnow() < hour: return price - def get_current_hourprice(self, hourprices) -> int: - for hour, price in hourprices.items(): + def get_current_hourprice(self) -> int: + for hour, price in self.data.to_dict().items(): if hour <= dt.utcnow() < hour + timedelta(hours=1): return price - def get_hourprices(self, hourprices) -> list: - return [a for a in hourprices.values()] + def get_avg_price(self): + return round(sum(self.filtered_hourprices.values()) / len(self.filtered_hourprices.values()), 5) - def get_avg_price(self, hourprices): - return round(sum(hourprices.values()) / len(hourprices.values()), 5) + def get_max_price(self): + return max(self.filtered_hourprices.values()) - def get_max_price(self, hourprices): - return max(hourprices.values()) + def get_min_price(self): + return min(self.filtered_hourprices.values()) - def get_min_price(self, hourprices): - return min(hourprices.values()) + def get_max_time(self): + return max(self.filtered_hourprices, key=self.filtered_hourprices.get).to_pydatetime() - def get_max_time(self, hourprices): - return max(hourprices, key=hourprices.get) + def get_min_time(self): + return min(self.filtered_hourprices, key=self.filtered_hourprices.get).to_pydatetime() - def get_min_time(self, hourprices): - return min(hourprices, key=hourprices.get) + def get_percentage_of_max(self): + return round(self.get_current_hourprice() / self.get_max_price() * 100, 1) def get_timestamped_prices(self, hourprices): list = [] diff --git a/custom_components/entsoe/sensor.py b/custom_components/entsoe/sensor.py index 1ab28b8..6f34fef 100644 --- a/custom_components/entsoe/sensor.py +++ b/custom_components/entsoe/sensor.py @@ -7,8 +7,6 @@ import logging from typing import Any -import pandas as pd - from homeassistant.components.sensor import DOMAIN, RestoreSensor, SensorDeviceClass, SensorEntityDescription, SensorExtraStoredData, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -21,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import utcnow + from .const import ATTRIBUTION, CONF_COORDINATOR, CONF_ENTITY_NAME, DOMAIN, ICON, DEFAULT_CURRENCY, CONF_CURRENCY from .coordinator import EntsoeCoordinator @@ -41,35 +40,35 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: name="Current electricity market price", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data["current_price"] + value_fn=lambda coordinator: coordinator.get_current_hourprice() ), EntsoeEntityDescription( key="next_hour_price", name="Next hour electricity market price", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data["next_hour_price"], + value_fn=lambda coordinator: coordinator.get_next_hourprice() ), EntsoeEntityDescription( key="min_price", name="Lowest energy price today", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data["min_price"], + value_fn=lambda coordinator: coordinator.get_min_price() ), EntsoeEntityDescription( key="max_price", name="Highest energy price today", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data["max_price"], + value_fn=lambda coordinator: coordinator.get_max_price() ), EntsoeEntityDescription( key="avg_price", name="Average electricity price today", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data["avg_price"], + value_fn=lambda coordinator: coordinator.get_avg_price() ), EntsoeEntityDescription( key="percentage_of_max", @@ -77,21 +76,19 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: native_unit_of_measurement=f"{PERCENTAGE}", icon="mdi:percent", state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: round( - data["current_price"] / data["max_price"] * 100, 1 - ), + value_fn=lambda coordinator: coordinator.get_percentage_of_max(), ), EntsoeEntityDescription( key="highest_price_time_today", name="Time of highest price today", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda data: data["time_max"], + value_fn=lambda coordinator: coordinator.get_max_time() ), EntsoeEntityDescription( key="lowest_price_time_today", name="Time of lowest price today", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda data: data["time_min"], + value_fn=lambda coordinator: coordinator.get_min_time() ), ) @@ -150,32 +147,6 @@ def __init__(self, coordinator: EntsoeCoordinator, description: EntsoeEntityDesc async def async_update(self) -> None: """Get the latest data and updates the states.""" #_LOGGER.debug(f"update function for '{self.entity_id} called.'") - value: Any = None - if self.coordinator.data is not None: - try: - processed = self.coordinator.processed_data() - #_LOGGER.debug(f"current coordinator.data value: {self.coordinator.data}") - value = self.entity_description.value_fn(processed) - #Check if value if a panda timestamp and if so convert to an HA compatible format - if isinstance(value, pd._libs.tslibs.timestamps.Timestamp): - value = value.to_pydatetime() - - self._attr_native_value = value - - if self.description.key == "avg_price" and self._attr_native_value is not None: - self._attr_extra_state_attributes = { - "prices_today": processed["prices_today"], - "prices_tomorrow": processed["prices_tomorrow"], - "prices": processed["prices"] - } - - self.last_update_success = True - _LOGGER.debug(f"updated '{self.entity_id}' to value: {value}") - - except Exception as exc: - # No data available - self.last_update_success = False - _LOGGER.warning(f"Unable to update entity '{self.entity_id}' due to data processing error: {value} and error: {exc} , data: {self.coordinator.data}") # Cancel the currently scheduled event if there is any if self._unsub_update: @@ -189,6 +160,34 @@ async def async_update(self) -> None: utcnow().replace(minute=0, second=0) + timedelta(hours=1), ) + if self.coordinator.data is not None and self.coordinator.today_data_available(): + value: Any = None + try: + #_LOGGER.debug(f"current coordinator.data value: {self.coordinator.data}") + value = self.entity_description.value_fn(self.coordinator) + + self._attr_native_value = value + self.last_update_success = True + _LOGGER.debug(f"updated '{self.entity_id}' to value: {value}") + + except Exception as exc: + # No data available + self.last_update_success = False + _LOGGER.warning(f"Unable to update entity '{self.entity_id}', value: {value} and error: {exc}, data: {self.coordinator.data}") + else: + _LOGGER.warning(f"Unable to update entity '{self.entity_id}': No valid data for today available.") + self.last_update_success = False + + try: + if self.description.key == "avg_price" and self._attr_native_value is not None and self.coordinator.data is not None: + self._attr_extra_state_attributes = { + "prices_today": self.coordinator.get_prices_today(), + "prices_tomorrow": self.coordinator.get_prices_tomorrow(), + "prices": self.coordinator.get_prices() + } + except Exception as exc: + _LOGGER.warning(f"Unable to update attributes of the average entity, error: {exc}, data: {self.coordinator.data}") + @property def available(self) -> bool: """Return if entity is available.""" From 29c44167cc3956be6e1a87599f4dbcaf48e5a583 Mon Sep 17 00:00:00 2001 From: Roeland Date: Thu, 5 Sep 2024 15:02:07 +0200 Subject: [PATCH 05/10] updates icons, names and add sensors to services. --- custom_components/entsoe/const.py | 1 - custom_components/entsoe/coordinator.py | 7 ++--- custom_components/entsoe/sensor.py | 38 +++++++++++++++++++------ 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/custom_components/entsoe/const.py b/custom_components/entsoe/const.py index f050460..eeff5fa 100644 --- a/custom_components/entsoe/const.py +++ b/custom_components/entsoe/const.py @@ -2,7 +2,6 @@ ATTRIBUTION = "Data provided by ENTSO-e Transparency Platform" DOMAIN = "entsoe" -ICON = "mdi:currency-eur" UNIQUE_ID = f"{DOMAIN}_component" COMPONENT_TITLE = "ENTSO-e Transparency Platform" diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 1d2afb3..295c652 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -147,14 +147,11 @@ def today_data_available(self): return self.get_data_today().count() == 24 def _filter_calculated_hourprices(self, data) -> list: - time_zone = dt.now().tzinfo hourprices = data.to_dict() if self.calculation_mode == CALCULATION_MODE["rotation"]: - now = pd.Timestamp.now(tz=str(time_zone)).normalize() - return { hour: price for hour, price in hourprices.items() if pd.to_datetime(hour) >= now and pd.to_datetime(hour) < now + timedelta(days=1) } + return { hour: price for hour, price in hourprices.items() if pd.to_datetime(hour) >= self.today and pd.to_datetime(hour) < self.today + timedelta(days=1) } elif self.calculation_mode == CALCULATION_MODE["sliding"]: - now = pd.Timestamp.now(tz=str(time_zone)).normalize() - return { hour: price for hour, price in hourprices.items() if pd.to_datetime(hour) >= now } + return { hour: price for hour, price in hourprices.items() if pd.to_datetime(hour) >= self.today } elif self.calculation_mode == CALCULATION_MODE["publish"]: return hourprices diff --git a/custom_components/entsoe/sensor.py b/custom_components/entsoe/sensor.py index 6f34fef..5681ea0 100644 --- a/custom_components/entsoe/sensor.py +++ b/custom_components/entsoe/sensor.py @@ -15,12 +15,13 @@ ) from homeassistant.core import HassJob, HomeAssistant from homeassistant.helpers import event +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.typing import StateType from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import utcnow -from .const import ATTRIBUTION, CONF_COORDINATOR, CONF_ENTITY_NAME, DOMAIN, ICON, DEFAULT_CURRENCY, CONF_CURRENCY +from .const import ATTRIBUTION, CONF_COORDINATOR, CONF_ENTITY_NAME, DOMAIN, DEFAULT_CURRENCY, CONF_CURRENCY from .coordinator import EntsoeCoordinator _LOGGER = logging.getLogger(__name__) @@ -40,6 +41,7 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: name="Current electricity market price", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, + icon="mdi:currency-eur", value_fn=lambda coordinator: coordinator.get_current_hourprice() ), EntsoeEntityDescription( @@ -47,32 +49,36 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: name="Next hour electricity market price", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, + icon="mdi:currency-eur", value_fn=lambda coordinator: coordinator.get_next_hourprice() ), EntsoeEntityDescription( key="min_price", - name="Lowest energy price today", + name="Lowest energy price", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, + icon="mdi:currency-eur", value_fn=lambda coordinator: coordinator.get_min_price() ), EntsoeEntityDescription( key="max_price", - name="Highest energy price today", + name="Highest energy price", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, + icon="mdi:currency-eur", value_fn=lambda coordinator: coordinator.get_max_price() ), EntsoeEntityDescription( key="avg_price", - name="Average electricity price today", + name="Average electricity price", native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, + icon="mdi:currency-eur", value_fn=lambda coordinator: coordinator.get_avg_price() ), EntsoeEntityDescription( key="percentage_of_max", - name="Current percentage of highest electricity price today", + name="Current percentage of highest electricity price", native_unit_of_measurement=f"{PERCENTAGE}", icon="mdi:percent", state_class=SensorStateClass.MEASUREMENT, @@ -80,14 +86,16 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: ), EntsoeEntityDescription( key="highest_price_time_today", - name="Time of highest price today", + name="Time of highest price", device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:clock", value_fn=lambda coordinator: coordinator.get_max_time() ), EntsoeEntityDescription( key="lowest_price_time_today", - name="Time of lowest price today", + name="Time of lowest price", device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:clock", value_fn=lambda coordinator: coordinator.get_min_time() ), ) @@ -119,7 +127,7 @@ class EntsoeSensor(CoordinatorEntity, RestoreSensor): """Representation of a ENTSO-e sensor.""" _attr_attribution = ATTRIBUTION - _attr_icon = ICON + def __init__(self, coordinator: EntsoeCoordinator, description: EntsoeEntityDescription, name: str = "") -> None: """Initialize the sensor.""" @@ -138,6 +146,20 @@ def __init__(self, coordinator: EntsoeCoordinator, description: EntsoeEntityDesc self._attr_name = f"{description.name}" self.entity_description: EntsoeEntityDescription = description + self._attr_icon = description.icon + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={ + ( + DOMAIN, + f"{coordinator.config_entry.entry_id}_entsoe", + ) + }, + manufacturer="entso-e", + model="", + name="entsoe", + ) self._update_job = HassJob(self.async_schedule_update_ha_state) self._unsub_update = None From 5d7014cc74e9152b5ff3c3313d4c38858a4bd2a2 Mon Sep 17 00:00:00 2001 From: Roeland Date: Sun, 8 Sep 2024 19:40:30 +0200 Subject: [PATCH 06/10] refactor: remove dependency on entsoe-py library --- custom_components/entsoe/api_client.py | 222 ++++++++++++++++++++++++ custom_components/entsoe/coordinator.py | 64 +++---- custom_components/entsoe/manifest.json | 4 +- custom_components/entsoe/sensor.py | 9 +- 4 files changed, 260 insertions(+), 39 deletions(-) create mode 100644 custom_components/entsoe/api_client.py diff --git a/custom_components/entsoe/api_client.py b/custom_components/entsoe/api_client.py new file mode 100644 index 0000000..741d991 --- /dev/null +++ b/custom_components/entsoe/api_client.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import logging +import requests +import enum +import xml.etree.ElementTree as ET +import pytz + +from datetime import datetime +from typing import Dict, Union + +_LOGGER = logging.getLogger(__name__) +URL = 'https://web-api.tp.entsoe.eu/api' +DATETIMEFORMAT='%Y%m%d%H00' + +class EntsoeClient: + + def __init__(self, api_key: str): + if api_key == "": + raise TypeError("API key cannot be empty") + self.api_key = api_key + + def _base_request(self, params: Dict, start: datetime, + end: datetime) -> requests.Response: + + base_params = { + 'securityToken': self.api_key, + 'periodStart': start.strftime(DATETIMEFORMAT), + 'periodEnd': end.strftime(DATETIMEFORMAT) + } + params.update(base_params) + + _LOGGER.debug(f'Performing request to {URL} with params {params}') + response = requests.get(url=URL, params=params) + response.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx) + + return response + + def query_day_ahead_prices(self, country_code: Union[Area, str], + start: datetime, end: datetime) -> str: + """ + Parameters + ---------- + country_code : Area|str + start : datetime + end : datetime + + Returns + ------- + str + """ + area = Area[country_code.upper()] + params = { + 'documentType': 'A44', + 'in_Domain': area.code, + 'out_Domain': area.code + } + response = self._base_request(params=params, start=start, end=end) + + if response.status_code == 200: + try: + xml_data = response.content + root = ET.fromstring(xml_data) + ns = {'ns': 'urn:iec62325.351:tc57wg16:451-3:publicationdocument:7:0'} + series = {} + + # Extract TimeSeries data + for timeseries in root.findall('ns:TimeSeries', ns): + period = timeseries.find('ns:Period', ns) + start_time = period.find('ns:timeInterval/ns:start', ns).text + + date = datetime.strptime(start_time, "%Y-%m-%dT%H:%MZ").replace(tzinfo=pytz.UTC).astimezone() + + for point in period.findall('ns:Point', ns): + position = point.find('ns:position', ns).text + price = point.find('ns:price.amount', ns).text + hour = int(position) - 1 + series[date.replace(hour=hour)] = float(price) + + return series + except Exception as exc: + _LOGGER.debug(f'Failed to parse response content:{response.content}') + raise exc + else: + print(f"Failed to retrieve data: {response.status_code}") + return None + +class Area(enum.Enum): + """ + ENUM containing 3 things about an Area: CODE, Meaning, Timezone + """ + def __new__(cls, *args, **kwds): + obj = object.__new__(cls) + obj._value_ = args[0] + return obj + + # ignore the first param since it's already set by __new__ + def __init__(self, _: str, meaning: str, tz: str): + self._meaning = meaning + self._tz = tz + + def __str__(self): + return self.value + + @property + def meaning(self): + return self._meaning + + @property + def tz(self): + return self._tz + + @property + def code(self): + return self.value + + @classmethod + def has_code(cls, code:str)->bool: + return code in cls.__members__ + + # List taken directly from the API Docs + DE_50HZ = '10YDE-VE-------2', '50Hertz CA, DE(50HzT) BZA', 'Europe/Berlin', + AL = '10YAL-KESH-----5', 'Albania, OST BZ / CA / MBA', 'Europe/Tirane', + DE_AMPRION = '10YDE-RWENET---I', 'Amprion CA', 'Europe/Berlin', + AT = '10YAT-APG------L', 'Austria, APG BZ / CA / MBA', 'Europe/Vienna', + BY = '10Y1001A1001A51S', 'Belarus BZ / CA / MBA', 'Europe/Minsk', + BE = '10YBE----------2', 'Belgium, Elia BZ / CA / MBA', 'Europe/Brussels', + BA = '10YBA-JPCC-----D', 'Bosnia Herzegovina, NOS BiH BZ / CA / MBA', 'Europe/Sarajevo', + BG = '10YCA-BULGARIA-R', 'Bulgaria, ESO BZ / CA / MBA', 'Europe/Sofia', + CZ_DE_SK = '10YDOM-CZ-DE-SKK', 'BZ CZ+DE+SK BZ / BZA', 'Europe/Prague', + HR = '10YHR-HEP------M', 'Croatia, HOPS BZ / CA / MBA', 'Europe/Zagreb', + CWE = '10YDOM-REGION-1V', 'CWE Region', 'Europe/Brussels', + CY = '10YCY-1001A0003J', 'Cyprus, Cyprus TSO BZ / CA / MBA', 'Asia/Nicosia', + CZ = '10YCZ-CEPS-----N', 'Czech Republic, CEPS BZ / CA/ MBA', 'Europe/Prague', + DE_AT_LU = '10Y1001A1001A63L', 'DE-AT-LU BZ', 'Europe/Berlin', + DE_LU = '10Y1001A1001A82H', 'DE-LU BZ / MBA', 'Europe/Berlin', + DK = '10Y1001A1001A65H', 'Denmark', 'Europe/Copenhagen', + DK_1 = '10YDK-1--------W', 'DK1 BZ / MBA', 'Europe/Copenhagen', + DK_1_NO_1 = '46Y000000000007M', 'DK1 NO1 BZ', 'Europe/Copenhagen', + DK_2 = '10YDK-2--------M', 'DK2 BZ / MBA', 'Europe/Copenhagen', + DK_CA = '10Y1001A1001A796', 'Denmark, Energinet CA', 'Europe/Copenhagen', + EE = '10Y1001A1001A39I', 'Estonia, Elering BZ / CA / MBA', 'Europe/Tallinn', + FI = '10YFI-1--------U', 'Finland, Fingrid BZ / CA / MBA', 'Europe/Helsinki', + MK = '10YMK-MEPSO----8', 'Former Yugoslav Republic of Macedonia, MEPSO BZ / CA / MBA', 'Europe/Skopje', + FR = '10YFR-RTE------C', 'France, RTE BZ / CA / MBA', 'Europe/Paris', + DE = '10Y1001A1001A83F', 'Germany', 'Europe/Berlin' + GR = '10YGR-HTSO-----Y', 'Greece, IPTO BZ / CA/ MBA', 'Europe/Athens', + HU = '10YHU-MAVIR----U', 'Hungary, MAVIR CA / BZ / MBA', 'Europe/Budapest', + IS = 'IS', 'Iceland', 'Atlantic/Reykjavik', + IE_SEM = '10Y1001A1001A59C', 'Ireland (SEM) BZ / MBA', 'Europe/Dublin', + IE = '10YIE-1001A00010', 'Ireland, EirGrid CA', 'Europe/Dublin', + IT = '10YIT-GRTN-----B', 'Italy, IT CA / MBA', 'Europe/Rome', + IT_SACO_AC = '10Y1001A1001A885', 'Italy_Saco_AC', 'Europe/Rome', + IT_CALA = '10Y1001C--00096J', 'IT-Calabria BZ', 'Europe/Rome', + IT_SACO_DC = '10Y1001A1001A893', 'Italy_Saco_DC', 'Europe/Rome', + IT_BRNN = '10Y1001A1001A699', 'IT-Brindisi BZ', 'Europe/Rome', + IT_CNOR = '10Y1001A1001A70O', 'IT-Centre-North BZ', 'Europe/Rome', + IT_CSUD = '10Y1001A1001A71M', 'IT-Centre-South BZ', 'Europe/Rome', + IT_FOGN = '10Y1001A1001A72K', 'IT-Foggia BZ', 'Europe/Rome', + IT_GR = '10Y1001A1001A66F', 'IT-GR BZ', 'Europe/Rome', + IT_MACRO_NORTH = '10Y1001A1001A84D', 'IT-MACROZONE NORTH MBA', 'Europe/Rome', + IT_MACRO_SOUTH = '10Y1001A1001A85B', 'IT-MACROZONE SOUTH MBA', 'Europe/Rome', + IT_MALTA = '10Y1001A1001A877', 'IT-Malta BZ', 'Europe/Rome', + IT_NORD = '10Y1001A1001A73I', 'IT-North BZ', 'Europe/Rome', + IT_NORD_AT = '10Y1001A1001A80L', 'IT-North-AT BZ', 'Europe/Rome', + IT_NORD_CH = '10Y1001A1001A68B', 'IT-North-CH BZ', 'Europe/Rome', + IT_NORD_FR = '10Y1001A1001A81J', 'IT-North-FR BZ', 'Europe/Rome', + IT_NORD_SI = '10Y1001A1001A67D', 'IT-North-SI BZ', 'Europe/Rome', + IT_PRGP = '10Y1001A1001A76C', 'IT-Priolo BZ', 'Europe/Rome', + IT_ROSN = '10Y1001A1001A77A', 'IT-Rossano BZ', 'Europe/Rome', + IT_SARD = '10Y1001A1001A74G', 'IT-Sardinia BZ', 'Europe/Rome', + IT_SICI = '10Y1001A1001A75E', 'IT-Sicily BZ', 'Europe/Rome', + IT_SUD = '10Y1001A1001A788', 'IT-South BZ', 'Europe/Rome', + RU_KGD = '10Y1001A1001A50U', 'Kaliningrad BZ / CA / MBA', 'Europe/Kaliningrad', + LV = '10YLV-1001A00074', 'Latvia, AST BZ / CA / MBA', 'Europe/Riga', + LT = '10YLT-1001A0008Q', 'Lithuania, Litgrid BZ / CA / MBA', 'Europe/Vilnius', + LU = '10YLU-CEGEDEL-NQ', 'Luxembourg, CREOS CA', 'Europe/Luxembourg', + LU_BZN = '10Y1001A1001A82H', 'Luxembourg', 'Europe/Luxembourg', + MT = '10Y1001A1001A93C', 'Malta, Malta BZ / CA / MBA', 'Europe/Malta', + ME = '10YCS-CG-TSO---S', 'Montenegro, CGES BZ / CA / MBA', 'Europe/Podgorica', + GB = '10YGB----------A', 'National Grid BZ / CA/ MBA', 'Europe/London', + GE = '10Y1001A1001B012', 'Georgia', 'Asia/Tbilisi', + GB_IFA = '10Y1001C--00098F', 'GB(IFA) BZN', 'Europe/London', + GB_IFA2 = '17Y0000009369493', 'GB(IFA2) BZ', 'Europe/London', + GB_ELECLINK = '11Y0-0000-0265-K', 'GB(ElecLink) BZN', 'Europe/London', + UK = '10Y1001A1001A92E', 'United Kingdom', 'Europe/London', + NL = '10YNL----------L', 'Netherlands, TenneT NL BZ / CA/ MBA', 'Europe/Amsterdam', + NO_1 = '10YNO-1--------2', 'NO1 BZ / MBA', 'Europe/Oslo', + NO_1A = '10Y1001A1001A64J', 'NO1 A BZ', 'Europe/Oslo', + NO_2 = '10YNO-2--------T', 'NO2 BZ / MBA', 'Europe/Oslo', + NO_2_NSL = '50Y0JVU59B4JWQCU', 'NO2 NSL BZ / MBA', 'Europe/Oslo', + NO_2A = '10Y1001C--001219', 'NO2 A BZ', 'Europe/Oslo', + NO_3 = '10YNO-3--------J', 'NO3 BZ / MBA', 'Europe/Oslo', + NO_4 = '10YNO-4--------9', 'NO4 BZ / MBA', 'Europe/Oslo', + NO_5 = '10Y1001A1001A48H', 'NO5 BZ / MBA', 'Europe/Oslo', + NO = '10YNO-0--------C', 'Norway, Norway MBA, Stattnet CA', 'Europe/Oslo', + PL_CZ = '10YDOM-1001A082L', 'PL-CZ BZA / CA', 'Europe/Warsaw', + PL = '10YPL-AREA-----S', 'Poland, PSE SA BZ / BZA / CA / MBA', 'Europe/Warsaw', + PT = '10YPT-REN------W', 'Portugal, REN BZ / CA / MBA', 'Europe/Lisbon', + MD = '10Y1001A1001A990', 'Republic of Moldova, Moldelectica BZ/CA/MBA', 'Europe/Chisinau', + RO = '10YRO-TEL------P', 'Romania, Transelectrica BZ / CA/ MBA', 'Europe/Bucharest', + RU = '10Y1001A1001A49F', 'Russia BZ / CA / MBA', 'Europe/Moscow', + SE_1 = '10Y1001A1001A44P', 'SE1 BZ / MBA', 'Europe/Stockholm', + SE_2 = '10Y1001A1001A45N', 'SE2 BZ / MBA', 'Europe/Stockholm', + SE_3 = '10Y1001A1001A46L', 'SE3 BZ / MBA', 'Europe/Stockholm', + SE_4 = '10Y1001A1001A47J', 'SE4 BZ / MBA', 'Europe/Stockholm', + RS = '10YCS-SERBIATSOV', 'Serbia, EMS BZ / CA / MBA', 'Europe/Belgrade', + SK = '10YSK-SEPS-----K', 'Slovakia, SEPS BZ / CA / MBA', 'Europe/Bratislava', + SI = '10YSI-ELES-----O', 'Slovenia, ELES BZ / CA / MBA', 'Europe/Ljubljana', + GB_NIR = '10Y1001A1001A016', 'Northern Ireland, SONI CA', 'Europe/Belfast', + ES = '10YES-REE------0', 'Spain, REE BZ / CA / MBA', 'Europe/Madrid', + SE = '10YSE-1--------K', 'Sweden, Sweden MBA, SvK CA', 'Europe/Stockholm', + CH = '10YCH-SWISSGRIDZ', 'Switzerland, Swissgrid BZ / CA / MBA', 'Europe/Zurich', + DE_TENNET = '10YDE-EON------1', 'TenneT GER CA', 'Europe/Berlin', + DE_TRANSNET = '10YDE-ENBW-----N', 'TransnetBW CA', 'Europe/Berlin', + TR = '10YTR-TEIAS----W', 'Turkey BZ / CA / MBA', 'Europe/Istanbul', + UA = '10Y1001C--00003F', 'Ukraine, Ukraine BZ, MBA', 'Europe/Kiev', + UA_DOBTPP = '10Y1001A1001A869', 'Ukraine-DobTPP CTA', 'Europe/Kiev', + UA_BEI = '10YUA-WEPS-----0', 'Ukraine BEI CTA', 'Europe/Kiev', + UA_IPS = '10Y1001C--000182', 'Ukraine IPS CTA', 'Europe/Kiev', + XK = '10Y1001C--00100H', 'Kosovo/ XK CA / XK BZN', 'Europe/Rome', + DE_AMP_LU = '10Y1001C--00002H', 'Amprion LU CA', 'Europe/Berlin' \ No newline at end of file diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 295c652..2db3aac 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -1,12 +1,8 @@ from __future__ import annotations -from datetime import timedelta -import pandas as pd -from entsoe import EntsoePandasClient from requests.exceptions import HTTPError -from datetime import datetime +from datetime import datetime, timedelta -import tzdata # for timezone conversions in panda import logging from homeassistant.core import HomeAssistant @@ -17,6 +13,7 @@ from jinja2 import pass_context from .const import DEFAULT_MODIFYER, AREA_INFO, CALCULATION_MODE +from .api_client import EntsoeClient class EntsoeCoordinator(DataUpdateCoordinator): """Get the latest data and update the states.""" @@ -29,10 +26,8 @@ def __init__(self, hass: HomeAssistant, api_key, area, modifyer, calculation_mod self.area = AREA_INFO[area]["code"] self.calculation_mode = calculation_mode self.vat = VAT - self.__TIMEZONE = dt.now().tzinfo self.today = None self.filtered_hourprices = [] - self.counter = 0 # Check incase the sensor was setup using config flow. # This blow up if the template isnt valid. @@ -88,31 +83,31 @@ async def _async_update_data(self) -> dict: self.logger.debug("ENTSO-e DataUpdateCoordinator data update") self.logger.debug(self.area) - now = pd.Timestamp.now(tz=self.__TIMEZONE) - self.today = now.normalize() + now = dt.now() + self.today = now.replace(hour=0, minute=0, second=0, microsecond=0) if self.check_update_needed(now) is False: self.logger.debug(f"Skipping api fetch. All data is already available") return self.data - yesterday = self.today - pd.Timedelta(days = 1) - tomorrow_evening = yesterday + pd.Timedelta(hours = 71) + yesterday = self.today - timedelta(days = 1) + tomorrow_evening = yesterday + timedelta(hours = 71) self.logger.debug(f"fetching prices for start date: {yesterday} to end date: {tomorrow_evening}") data = await self.fetch_prices(yesterday, tomorrow_evening) self.logger.debug(f"received data = {data}") if data is not None: - parsed_data = self.parse_hourprices(data) - self.logger.debug(f"received pricing data from entso-e for {data.count()} hours") + parsed_data = self.parse_hourprices(dict(list(data.items())[-48:])) + self.logger.debug(f"received pricing data from entso-e for {len(data)} hours") self.filtered_hourprices = self._filter_calculated_hourprices(parsed_data) return parsed_data def check_update_needed(self, now): if self.data is None: return True - if self.get_data_today().count() != 24: + if len(self.get_data_today()) != 24: return True - if self.get_data_tomorrow().count() != 24 and now.hour > 12: + if len(self.get_data_tomorrow()) != 24 and now.hour > 12: return True return False @@ -129,8 +124,8 @@ async def fetch_prices(self, start_date, end_date): raise UpdateFailed("Unauthorized: Please check your API-key.") from exc except Exception as exc: if self.data is not None: - newest_timestamp = self.data.index[-1] - if(newest_timestamp) > pd.Timestamp.now(newest_timestamp.tzinfo): + newest_timestamp = self.data[max(self.data.keys())] + if(newest_timestamp) > dt.now(): self.logger.warning(f"Warning the integration is running in degraded mode (falling back on stored data) since fetching the latest ENTSOE-e prices failed with exception: {exc}.") else: raise UpdateFailed(f"The latest available data is older than the current time. Therefore entities will no longer update. Error: {exc}") from exc @@ -138,47 +133,44 @@ async def fetch_prices(self, start_date, end_date): self.logger.warning(f"Warning the integration doesn't have any up to date local data this means that entities won't get updated but access remains to restorable entities: {exc}.") def api_update(self, start_date, end_date, api_key): - client = EntsoePandasClient(api_key=api_key) + client = EntsoeClient(api_key=api_key) return client.query_day_ahead_prices( country_code=self.area, start=start_date, end=end_date ) def today_data_available(self): - return self.get_data_today().count() == 24 + return len(self.get_data_today()) == 24 - def _filter_calculated_hourprices(self, data) -> list: - hourprices = data.to_dict() + def _filter_calculated_hourprices(self, data): + hourprices = data if self.calculation_mode == CALCULATION_MODE["rotation"]: - return { hour: price for hour, price in hourprices.items() if pd.to_datetime(hour) >= self.today and pd.to_datetime(hour) < self.today + timedelta(days=1) } + return { hour: price for hour, price in hourprices.items() if hour >= self.today and hour < self.today + timedelta(days=1) } elif self.calculation_mode == CALCULATION_MODE["sliding"]: - return { hour: price for hour, price in hourprices.items() if pd.to_datetime(hour) >= self.today } + now = dt.now().replace(minute=0, second=0, microsecond=0) + return { hour: price for hour, price in hourprices.items() if hour >= now } elif self.calculation_mode == CALCULATION_MODE["publish"]: return hourprices def get_prices_today(self): - return self.get_timestamped_prices(self.get_data_today().to_dict()) + return self.get_timestamped_prices(self.get_data_today()) def get_prices_tomorrow(self): - return self.get_timestamped_prices(self.get_data_tomorrow().to_dict()) + return self.get_timestamped_prices(self.get_data_tomorrow()) def get_prices(self): - return self.get_timestamped_prices(self.data.to_dict()) + return self.get_timestamped_prices(self.data) def get_data_today(self): - return self.data[self.today: self.today + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)] + return {k: v for k, v in self.data.items() if k.date() == self.today.date()} def get_data_tomorrow(self): - return self.data[self.today + pd.Timedelta(days=1) : self.today + pd.Timedelta(days=1) + pd.Timedelta(hours=23)] + return {k: v for k, v in self.data.items() if k.date() == self.today.date() + timedelta(days=1)} def get_next_hourprice(self) -> int: - for hour, price in self.data.to_dict().items(): - if hour - timedelta(hours=1) <= dt.utcnow() < hour: - return price + return self.data[dt.now().replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)] def get_current_hourprice(self) -> int: - for hour, price in self.data.to_dict().items(): - if hour <= dt.utcnow() < hour + timedelta(hours=1): - return price + return self.data[dt.now().replace(minute=0, second=0, microsecond=0)] def get_avg_price(self): return round(sum(self.filtered_hourprices.values()) / len(self.filtered_hourprices.values()), 5) @@ -190,10 +182,10 @@ def get_min_price(self): return min(self.filtered_hourprices.values()) def get_max_time(self): - return max(self.filtered_hourprices, key=self.filtered_hourprices.get).to_pydatetime() + return max(self.filtered_hourprices, key=self.filtered_hourprices.get) def get_min_time(self): - return min(self.filtered_hourprices, key=self.filtered_hourprices.get).to_pydatetime() + return min(self.filtered_hourprices, key=self.filtered_hourprices.get) def get_percentage_of_max(self): return round(self.get_current_hourprice() / self.get_max_price() * 100, 1) diff --git a/custom_components/entsoe/manifest.json b/custom_components/entsoe/manifest.json index 550d2e7..49c8627 100644 --- a/custom_components/entsoe/manifest.json +++ b/custom_components/entsoe/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://github.com/JaccoR/hass-entso-e", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/JaccoR/hass-entso-e/issues", - "requirements": ["entsoe-py==0.6.2"], - "version": "0.4.0" + "requirements": ["requests"], + "version": "0.5.0" } diff --git a/custom_components/entsoe/sensor.py b/custom_components/entsoe/sensor.py index 5681ea0..e424e28 100644 --- a/custom_components/entsoe/sensor.py +++ b/custom_components/entsoe/sensor.py @@ -42,6 +42,7 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, icon="mdi:currency-eur", + suggested_display_precision=3, value_fn=lambda coordinator: coordinator.get_current_hourprice() ), EntsoeEntityDescription( @@ -50,6 +51,7 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, icon="mdi:currency-eur", + suggested_display_precision=3, value_fn=lambda coordinator: coordinator.get_next_hourprice() ), EntsoeEntityDescription( @@ -58,6 +60,7 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, icon="mdi:currency-eur", + suggested_display_precision=3, value_fn=lambda coordinator: coordinator.get_min_price() ), EntsoeEntityDescription( @@ -66,6 +69,7 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, icon="mdi:currency-eur", + suggested_display_precision=3, value_fn=lambda coordinator: coordinator.get_max_price() ), EntsoeEntityDescription( @@ -74,6 +78,7 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: native_unit_of_measurement=f"{currency}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, icon="mdi:currency-eur", + suggested_display_precision=3, value_fn=lambda coordinator: coordinator.get_avg_price() ), EntsoeEntityDescription( @@ -81,6 +86,7 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: name="Current percentage of highest electricity price", native_unit_of_measurement=f"{PERCENTAGE}", icon="mdi:percent", + suggested_display_precision=1, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda coordinator: coordinator.get_percentage_of_max(), ), @@ -147,6 +153,7 @@ def __init__(self, coordinator: EntsoeCoordinator, description: EntsoeEntityDesc self.entity_description: EntsoeEntityDescription = description self._attr_icon = description.icon + self._attr_suggested_display_precision= description.suggested_display_precision if description.suggested_display_precision is not None else 2 self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -158,7 +165,7 @@ def __init__(self, coordinator: EntsoeCoordinator, description: EntsoeEntityDesc }, manufacturer="entso-e", model="", - name="entsoe", + name="entso-e" + ((" (" + name + ")") if name != "" else "") ) self._update_job = HassJob(self.async_schedule_update_ha_state) From 60a00b3b2cd711b68b19aaedc90d899c595c24ca Mon Sep 17 00:00:00 2001 From: Roeland Date: Tue, 10 Sep 2024 18:25:28 +0200 Subject: [PATCH 07/10] Add service to fetch prices --- custom_components/entsoe/__init__.py | 20 ++-- custom_components/entsoe/const.py | 1 - custom_components/entsoe/coordinator.py | 21 ++-- custom_components/entsoe/sensor.py | 4 +- custom_components/entsoe/services.py | 133 ++++++++++++++++++++++++ custom_components/entsoe/services.yaml | 17 +++ 6 files changed, 179 insertions(+), 17 deletions(-) create mode 100644 custom_components/entsoe/services.py create mode 100644 custom_components/entsoe/services.yaml diff --git a/custom_components/entsoe/__init__.py b/custom_components/entsoe/__init__.py index b149cc1..79b4b87 100644 --- a/custom_components/entsoe/__init__.py +++ b/custom_components/entsoe/__init__.py @@ -4,14 +4,23 @@ import logging from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import ConfigType from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import CONF_COORDINATOR, CONF_VAT_VALUE, DOMAIN, CONF_API_KEY, CONF_AREA, CONF_MODIFYER, DEFAULT_MODIFYER, CALCULATION_MODE, CONF_CALCULATION_MODE + +from .const import CONF_VAT_VALUE, DOMAIN, CONF_API_KEY, CONF_AREA, CONF_MODIFYER, DEFAULT_MODIFYER, CALCULATION_MODE, CONF_CALCULATION_MODE from .coordinator import EntsoeCoordinator +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up ENTSO-e services.""" + + async_setup_services(hass) + + return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the ENTSO-e prices component from a config entry.""" @@ -24,10 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: calculation_mode = entry.options.get(CONF_CALCULATION_MODE, CALCULATION_MODE["default"]) entsoe_coordinator = EntsoeCoordinator(hass, api_key=api_key, area = area, modifyer = modifyer, calculation_mode=calculation_mode, VAT=vat) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - CONF_COORDINATOR: entsoe_coordinator, - } + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entsoe_coordinator # Fetch initial data, so we have data when entities subscribe and set up the platform await entsoe_coordinator.async_config_entry_first_refresh() @@ -39,10 +45,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok diff --git a/custom_components/entsoe/const.py b/custom_components/entsoe/const.py index eeff5fa..c2db406 100644 --- a/custom_components/entsoe/const.py +++ b/custom_components/entsoe/const.py @@ -8,7 +8,6 @@ CONF_API_KEY = "api_key" CONF_ENTITY_NAME = "name" CONF_AREA = "area" -CONF_COORDINATOR = "coordinator" CONF_MODIFYER = "modifyer" CONF_CURRENCY = "currency" CONF_ADVANCED_OPTIONS = "advanced_options" diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 2db3aac..9341a8d 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -97,7 +97,7 @@ async def _async_update_data(self) -> dict: self.logger.debug(f"received data = {data}") if data is not None: - parsed_data = self.parse_hourprices(dict(list(data.items())[-48:])) + parsed_data = self.parse_hourprices(data) self.logger.debug(f"received pricing data from entso-e for {len(data)} hours") self.filtered_hourprices = self._filter_calculated_hourprices(parsed_data) return parsed_data @@ -137,19 +137,25 @@ def api_update(self, start_date, end_date, api_key): return client.query_day_ahead_prices( country_code=self.area, start=start_date, end=end_date ) + + async def get_energy_prices(self, start_date, end_date): + #check if we have the data already + if len(self.get_data(start_date)) == 24 and len(self.get_data(end_date)) == 24: + self.logger.debug(f'return prices from coordinator cache.') + return {k: v for k, v in self.data.items() if k.date() >= start_date.date() and k.date() <= end_date.date()} + return await self.fetch_prices(start_date, end_date) def today_data_available(self): return len(self.get_data_today()) == 24 def _filter_calculated_hourprices(self, data): - hourprices = data if self.calculation_mode == CALCULATION_MODE["rotation"]: - return { hour: price for hour, price in hourprices.items() if hour >= self.today and hour < self.today + timedelta(days=1) } + return { hour: price for hour, price in data.items() if hour >= self.today and hour < self.today + timedelta(days=1) } elif self.calculation_mode == CALCULATION_MODE["sliding"]: now = dt.now().replace(minute=0, second=0, microsecond=0) - return { hour: price for hour, price in hourprices.items() if hour >= now } + return { hour: price for hour, price in data.items() if hour >= now } elif self.calculation_mode == CALCULATION_MODE["publish"]: - return hourprices + return dict(list(data.items())[-48:]) def get_prices_today(self): return self.get_timestamped_prices(self.get_data_today()) @@ -158,8 +164,11 @@ def get_prices_tomorrow(self): return self.get_timestamped_prices(self.get_data_tomorrow()) def get_prices(self): - return self.get_timestamped_prices(self.data) + return self.get_timestamped_prices(dict(list(self.data.items())[-48:])) + def get_data(self, date): + return {k: v for k, v in self.data.items() if k.date() == date.date()} + def get_data_today(self): return {k: v for k, v in self.data.items() if k.date() == self.today.date()} diff --git a/custom_components/entsoe/sensor.py b/custom_components/entsoe/sensor.py index e424e28..5d85239 100644 --- a/custom_components/entsoe/sensor.py +++ b/custom_components/entsoe/sensor.py @@ -21,7 +21,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import utcnow -from .const import ATTRIBUTION, CONF_COORDINATOR, CONF_ENTITY_NAME, DOMAIN, DEFAULT_CURRENCY, CONF_CURRENCY +from .const import ATTRIBUTION, CONF_ENTITY_NAME, DOMAIN, DEFAULT_CURRENCY, CONF_CURRENCY from .coordinator import EntsoeCoordinator _LOGGER = logging.getLogger(__name__) @@ -113,7 +113,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up ENTSO-e price sensor entries.""" - entsoe_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] + entsoe_coordinator = hass.data[DOMAIN][config_entry.entry_id] entities = [] entity = {} diff --git a/custom_components/entsoe/services.py b/custom_components/entsoe/services.py new file mode 100644 index 0000000..6c1714d --- /dev/null +++ b/custom_components/entsoe/services.py @@ -0,0 +1,133 @@ +"""The Entso-e services.""" + +from __future__ import annotations + +from datetime import date, datetime +from functools import partial +from typing import Final + +import voluptuous as vol +import logging + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import selector +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .coordinator import EntsoeCoordinator + +_LOGGER = logging.getLogger(__name__) + +ATTR_CONFIG_ENTRY: Final = "config_entry" +ATTR_START: Final = "start" +ATTR_END: Final = "end" + +ENERGY_SERVICE_NAME: Final = "get_energy_prices" +SERVICE_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Optional(ATTR_START): str, + vol.Optional(ATTR_END): str, + } +) + + +def __get_date(date_input: str | None) -> date | datetime: + """Get date.""" + if not date_input: + return dt_util.now().date() + + if value := dt_util.parse_datetime(date_input): + return value + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_date", + translation_placeholders={ + "date": date_input, + }, + ) + + +def __serialize_prices(prices) -> ServiceResponse: + """Serialize prices.""" + return { + "prices": [ + { + "timestamp": dt.isoformat(), + "price": price + } + for dt, price in prices.items() + ] + } + + +def __get_coordinator( + hass: HomeAssistant, call: ServiceCall +) -> EntsoeCoordinator: + """Get the coordinator from the entry.""" + entry_id: str = call.data[ATTR_CONFIG_ENTRY] + entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) + + if not entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={ + "config_entry": entry_id, + }, + ) + if entry.state != ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unloaded_config_entry", + translation_placeholders={ + "config_entry": entry.title, + }, + ) + + coordinator: EntsoeCoordinator = hass.data[DOMAIN][entry_id] + return coordinator + + +async def __get_prices( + call: ServiceCall, + *, + hass: HomeAssistant, +) -> ServiceResponse: + coordinator = __get_coordinator(hass, call) + + start = __get_date(call.data.get(ATTR_START)) + end = __get_date(call.data.get(ATTR_END)) + + data = await coordinator.get_energy_prices( + start_date=start, + end_date=end, + ) + + return __serialize_prices(data) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up Entso-e services.""" + + hass.services.async_register( + DOMAIN, + ENERGY_SERVICE_NAME, + partial(__get_prices, hass=hass), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) \ No newline at end of file diff --git a/custom_components/entsoe/services.yaml b/custom_components/entsoe/services.yaml new file mode 100644 index 0000000..fe99abc --- /dev/null +++ b/custom_components/entsoe/services.yaml @@ -0,0 +1,17 @@ +get_energy_prices: + fields: + config_entry: + required: true + selector: + config_entry: + integration: entsoe + start: + required: false + example: "2023-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2023-01-01 00:00:00" + selector: + datetime: \ No newline at end of file From e6dc872fb178e9243e916b37f9bbc1000b3d74ee Mon Sep 17 00:00:00 2001 From: Roeland Date: Tue, 10 Sep 2024 20:42:48 +0200 Subject: [PATCH 08/10] using black formatter and isort for sorting imports --- custom_components/entsoe/__init__.py | 29 +- custom_components/entsoe/api_client.py | 669 +++++++++++++++++++----- custom_components/entsoe/config_flow.py | 72 ++- custom_components/entsoe/const.py | 205 ++++++-- custom_components/entsoe/coordinator.py | 104 ++-- custom_components/entsoe/sensor.py | 110 ++-- custom_components/entsoe/services.py | 16 +- 7 files changed, 901 insertions(+), 304 deletions(-) diff --git a/custom_components/entsoe/__init__.py b/custom_components/entsoe/__init__.py index 79b4b87..059e2c3 100644 --- a/custom_components/entsoe/__init__.py +++ b/custom_components/entsoe/__init__.py @@ -1,20 +1,31 @@ """The ENTSO-e prices component.""" + from __future__ import annotations import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.typing import ConfigType from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType -from .const import CONF_VAT_VALUE, DOMAIN, CONF_API_KEY, CONF_AREA, CONF_MODIFYER, DEFAULT_MODIFYER, CALCULATION_MODE, CONF_CALCULATION_MODE +from .const import ( + CALCULATION_MODE, + CONF_API_KEY, + CONF_AREA, + CONF_CALCULATION_MODE, + CONF_MODIFYER, + CONF_VAT_VALUE, + DEFAULT_MODIFYER, + DOMAIN, +) from .coordinator import EntsoeCoordinator from .services import async_setup_services _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up ENTSO-e services.""" @@ -22,6 +33,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the ENTSO-e prices component from a config entry.""" @@ -30,8 +42,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: area = entry.options[CONF_AREA] modifyer = entry.options.get(CONF_MODIFYER, DEFAULT_MODIFYER) vat = entry.options.get(CONF_VAT_VALUE, 0) - calculation_mode = entry.options.get(CONF_CALCULATION_MODE, CALCULATION_MODE["default"]) - entsoe_coordinator = EntsoeCoordinator(hass, api_key=api_key, area = area, modifyer = modifyer, calculation_mode=calculation_mode, VAT=vat) + calculation_mode = entry.options.get( + CONF_CALCULATION_MODE, CALCULATION_MODE["default"] + ) + entsoe_coordinator = EntsoeCoordinator( + hass, + api_key=api_key, + area=area, + modifyer=modifyer, + calculation_mode=calculation_mode, + VAT=vat, + ) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entsoe_coordinator diff --git a/custom_components/entsoe/api_client.py b/custom_components/entsoe/api_client.py index 741d991..7c29b8d 100644 --- a/custom_components/entsoe/api_client.py +++ b/custom_components/entsoe/api_client.py @@ -1,17 +1,18 @@ from __future__ import annotations -import logging -import requests import enum +import logging import xml.etree.ElementTree as ET -import pytz - from datetime import datetime from typing import Dict, Union +import pytz +import requests + _LOGGER = logging.getLogger(__name__) -URL = 'https://web-api.tp.entsoe.eu/api' -DATETIMEFORMAT='%Y%m%d%H00' +URL = "https://web-api.tp.entsoe.eu/api" +DATETIMEFORMAT = "%Y%m%d%H00" + class EntsoeClient: @@ -19,25 +20,27 @@ def __init__(self, api_key: str): if api_key == "": raise TypeError("API key cannot be empty") self.api_key = api_key - - def _base_request(self, params: Dict, start: datetime, - end: datetime) -> requests.Response: + + def _base_request( + self, params: Dict, start: datetime, end: datetime + ) -> requests.Response: base_params = { - 'securityToken': self.api_key, - 'periodStart': start.strftime(DATETIMEFORMAT), - 'periodEnd': end.strftime(DATETIMEFORMAT) + "securityToken": self.api_key, + "periodStart": start.strftime(DATETIMEFORMAT), + "periodEnd": end.strftime(DATETIMEFORMAT), } params.update(base_params) - _LOGGER.debug(f'Performing request to {URL} with params {params}') + _LOGGER.debug(f"Performing request to {URL} with params {params}") response = requests.get(url=URL, params=params) response.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx) return response - - def query_day_ahead_prices(self, country_code: Union[Area, str], - start: datetime, end: datetime) -> str: + + def query_day_ahead_prices( + self, country_code: Union[Area, str], start: datetime, end: datetime + ) -> str: """ Parameters ---------- @@ -51,9 +54,9 @@ def query_day_ahead_prices(self, country_code: Union[Area, str], """ area = Area[country_code.upper()] params = { - 'documentType': 'A44', - 'in_Domain': area.code, - 'out_Domain': area.code + "documentType": "A44", + "in_Domain": area.code, + "out_Domain": area.code, } response = self._base_request(params=params, start=start, end=end) @@ -61,34 +64,40 @@ def query_day_ahead_prices(self, country_code: Union[Area, str], try: xml_data = response.content root = ET.fromstring(xml_data) - ns = {'ns': 'urn:iec62325.351:tc57wg16:451-3:publicationdocument:7:0'} + ns = {"ns": "urn:iec62325.351:tc57wg16:451-3:publicationdocument:7:0"} series = {} # Extract TimeSeries data - for timeseries in root.findall('ns:TimeSeries', ns): - period = timeseries.find('ns:Period', ns) - start_time = period.find('ns:timeInterval/ns:start', ns).text + for timeseries in root.findall("ns:TimeSeries", ns): + period = timeseries.find("ns:Period", ns) + start_time = period.find("ns:timeInterval/ns:start", ns).text - date = datetime.strptime(start_time, "%Y-%m-%dT%H:%MZ").replace(tzinfo=pytz.UTC).astimezone() + date = ( + datetime.strptime(start_time, "%Y-%m-%dT%H:%MZ") + .replace(tzinfo=pytz.UTC) + .astimezone() + ) - for point in period.findall('ns:Point', ns): - position = point.find('ns:position', ns).text - price = point.find('ns:price.amount', ns).text + for point in period.findall("ns:Point", ns): + position = point.find("ns:position", ns).text + price = point.find("ns:price.amount", ns).text hour = int(position) - 1 series[date.replace(hour=hour)] = float(price) - + return series except Exception as exc: - _LOGGER.debug(f'Failed to parse response content:{response.content}') + _LOGGER.debug(f"Failed to parse response content:{response.content}") raise exc else: print(f"Failed to retrieve data: {response.status_code}") return None + class Area(enum.Enum): """ ENUM containing 3 things about an Area: CODE, Meaning, Timezone """ + def __new__(cls, *args, **kwds): obj = object.__new__(cls) obj._value_ = args[0] @@ -115,108 +124,504 @@ def code(self): return self.value @classmethod - def has_code(cls, code:str)->bool: - return code in cls.__members__ + def has_code(cls, code: str) -> bool: + return code in cls.__members__ # List taken directly from the API Docs - DE_50HZ = '10YDE-VE-------2', '50Hertz CA, DE(50HzT) BZA', 'Europe/Berlin', - AL = '10YAL-KESH-----5', 'Albania, OST BZ / CA / MBA', 'Europe/Tirane', - DE_AMPRION = '10YDE-RWENET---I', 'Amprion CA', 'Europe/Berlin', - AT = '10YAT-APG------L', 'Austria, APG BZ / CA / MBA', 'Europe/Vienna', - BY = '10Y1001A1001A51S', 'Belarus BZ / CA / MBA', 'Europe/Minsk', - BE = '10YBE----------2', 'Belgium, Elia BZ / CA / MBA', 'Europe/Brussels', - BA = '10YBA-JPCC-----D', 'Bosnia Herzegovina, NOS BiH BZ / CA / MBA', 'Europe/Sarajevo', - BG = '10YCA-BULGARIA-R', 'Bulgaria, ESO BZ / CA / MBA', 'Europe/Sofia', - CZ_DE_SK = '10YDOM-CZ-DE-SKK', 'BZ CZ+DE+SK BZ / BZA', 'Europe/Prague', - HR = '10YHR-HEP------M', 'Croatia, HOPS BZ / CA / MBA', 'Europe/Zagreb', - CWE = '10YDOM-REGION-1V', 'CWE Region', 'Europe/Brussels', - CY = '10YCY-1001A0003J', 'Cyprus, Cyprus TSO BZ / CA / MBA', 'Asia/Nicosia', - CZ = '10YCZ-CEPS-----N', 'Czech Republic, CEPS BZ / CA/ MBA', 'Europe/Prague', - DE_AT_LU = '10Y1001A1001A63L', 'DE-AT-LU BZ', 'Europe/Berlin', - DE_LU = '10Y1001A1001A82H', 'DE-LU BZ / MBA', 'Europe/Berlin', - DK = '10Y1001A1001A65H', 'Denmark', 'Europe/Copenhagen', - DK_1 = '10YDK-1--------W', 'DK1 BZ / MBA', 'Europe/Copenhagen', - DK_1_NO_1 = '46Y000000000007M', 'DK1 NO1 BZ', 'Europe/Copenhagen', - DK_2 = '10YDK-2--------M', 'DK2 BZ / MBA', 'Europe/Copenhagen', - DK_CA = '10Y1001A1001A796', 'Denmark, Energinet CA', 'Europe/Copenhagen', - EE = '10Y1001A1001A39I', 'Estonia, Elering BZ / CA / MBA', 'Europe/Tallinn', - FI = '10YFI-1--------U', 'Finland, Fingrid BZ / CA / MBA', 'Europe/Helsinki', - MK = '10YMK-MEPSO----8', 'Former Yugoslav Republic of Macedonia, MEPSO BZ / CA / MBA', 'Europe/Skopje', - FR = '10YFR-RTE------C', 'France, RTE BZ / CA / MBA', 'Europe/Paris', - DE = '10Y1001A1001A83F', 'Germany', 'Europe/Berlin' - GR = '10YGR-HTSO-----Y', 'Greece, IPTO BZ / CA/ MBA', 'Europe/Athens', - HU = '10YHU-MAVIR----U', 'Hungary, MAVIR CA / BZ / MBA', 'Europe/Budapest', - IS = 'IS', 'Iceland', 'Atlantic/Reykjavik', - IE_SEM = '10Y1001A1001A59C', 'Ireland (SEM) BZ / MBA', 'Europe/Dublin', - IE = '10YIE-1001A00010', 'Ireland, EirGrid CA', 'Europe/Dublin', - IT = '10YIT-GRTN-----B', 'Italy, IT CA / MBA', 'Europe/Rome', - IT_SACO_AC = '10Y1001A1001A885', 'Italy_Saco_AC', 'Europe/Rome', - IT_CALA = '10Y1001C--00096J', 'IT-Calabria BZ', 'Europe/Rome', - IT_SACO_DC = '10Y1001A1001A893', 'Italy_Saco_DC', 'Europe/Rome', - IT_BRNN = '10Y1001A1001A699', 'IT-Brindisi BZ', 'Europe/Rome', - IT_CNOR = '10Y1001A1001A70O', 'IT-Centre-North BZ', 'Europe/Rome', - IT_CSUD = '10Y1001A1001A71M', 'IT-Centre-South BZ', 'Europe/Rome', - IT_FOGN = '10Y1001A1001A72K', 'IT-Foggia BZ', 'Europe/Rome', - IT_GR = '10Y1001A1001A66F', 'IT-GR BZ', 'Europe/Rome', - IT_MACRO_NORTH = '10Y1001A1001A84D', 'IT-MACROZONE NORTH MBA', 'Europe/Rome', - IT_MACRO_SOUTH = '10Y1001A1001A85B', 'IT-MACROZONE SOUTH MBA', 'Europe/Rome', - IT_MALTA = '10Y1001A1001A877', 'IT-Malta BZ', 'Europe/Rome', - IT_NORD = '10Y1001A1001A73I', 'IT-North BZ', 'Europe/Rome', - IT_NORD_AT = '10Y1001A1001A80L', 'IT-North-AT BZ', 'Europe/Rome', - IT_NORD_CH = '10Y1001A1001A68B', 'IT-North-CH BZ', 'Europe/Rome', - IT_NORD_FR = '10Y1001A1001A81J', 'IT-North-FR BZ', 'Europe/Rome', - IT_NORD_SI = '10Y1001A1001A67D', 'IT-North-SI BZ', 'Europe/Rome', - IT_PRGP = '10Y1001A1001A76C', 'IT-Priolo BZ', 'Europe/Rome', - IT_ROSN = '10Y1001A1001A77A', 'IT-Rossano BZ', 'Europe/Rome', - IT_SARD = '10Y1001A1001A74G', 'IT-Sardinia BZ', 'Europe/Rome', - IT_SICI = '10Y1001A1001A75E', 'IT-Sicily BZ', 'Europe/Rome', - IT_SUD = '10Y1001A1001A788', 'IT-South BZ', 'Europe/Rome', - RU_KGD = '10Y1001A1001A50U', 'Kaliningrad BZ / CA / MBA', 'Europe/Kaliningrad', - LV = '10YLV-1001A00074', 'Latvia, AST BZ / CA / MBA', 'Europe/Riga', - LT = '10YLT-1001A0008Q', 'Lithuania, Litgrid BZ / CA / MBA', 'Europe/Vilnius', - LU = '10YLU-CEGEDEL-NQ', 'Luxembourg, CREOS CA', 'Europe/Luxembourg', - LU_BZN = '10Y1001A1001A82H', 'Luxembourg', 'Europe/Luxembourg', - MT = '10Y1001A1001A93C', 'Malta, Malta BZ / CA / MBA', 'Europe/Malta', - ME = '10YCS-CG-TSO---S', 'Montenegro, CGES BZ / CA / MBA', 'Europe/Podgorica', - GB = '10YGB----------A', 'National Grid BZ / CA/ MBA', 'Europe/London', - GE = '10Y1001A1001B012', 'Georgia', 'Asia/Tbilisi', - GB_IFA = '10Y1001C--00098F', 'GB(IFA) BZN', 'Europe/London', - GB_IFA2 = '17Y0000009369493', 'GB(IFA2) BZ', 'Europe/London', - GB_ELECLINK = '11Y0-0000-0265-K', 'GB(ElecLink) BZN', 'Europe/London', - UK = '10Y1001A1001A92E', 'United Kingdom', 'Europe/London', - NL = '10YNL----------L', 'Netherlands, TenneT NL BZ / CA/ MBA', 'Europe/Amsterdam', - NO_1 = '10YNO-1--------2', 'NO1 BZ / MBA', 'Europe/Oslo', - NO_1A = '10Y1001A1001A64J', 'NO1 A BZ', 'Europe/Oslo', - NO_2 = '10YNO-2--------T', 'NO2 BZ / MBA', 'Europe/Oslo', - NO_2_NSL = '50Y0JVU59B4JWQCU', 'NO2 NSL BZ / MBA', 'Europe/Oslo', - NO_2A = '10Y1001C--001219', 'NO2 A BZ', 'Europe/Oslo', - NO_3 = '10YNO-3--------J', 'NO3 BZ / MBA', 'Europe/Oslo', - NO_4 = '10YNO-4--------9', 'NO4 BZ / MBA', 'Europe/Oslo', - NO_5 = '10Y1001A1001A48H', 'NO5 BZ / MBA', 'Europe/Oslo', - NO = '10YNO-0--------C', 'Norway, Norway MBA, Stattnet CA', 'Europe/Oslo', - PL_CZ = '10YDOM-1001A082L', 'PL-CZ BZA / CA', 'Europe/Warsaw', - PL = '10YPL-AREA-----S', 'Poland, PSE SA BZ / BZA / CA / MBA', 'Europe/Warsaw', - PT = '10YPT-REN------W', 'Portugal, REN BZ / CA / MBA', 'Europe/Lisbon', - MD = '10Y1001A1001A990', 'Republic of Moldova, Moldelectica BZ/CA/MBA', 'Europe/Chisinau', - RO = '10YRO-TEL------P', 'Romania, Transelectrica BZ / CA/ MBA', 'Europe/Bucharest', - RU = '10Y1001A1001A49F', 'Russia BZ / CA / MBA', 'Europe/Moscow', - SE_1 = '10Y1001A1001A44P', 'SE1 BZ / MBA', 'Europe/Stockholm', - SE_2 = '10Y1001A1001A45N', 'SE2 BZ / MBA', 'Europe/Stockholm', - SE_3 = '10Y1001A1001A46L', 'SE3 BZ / MBA', 'Europe/Stockholm', - SE_4 = '10Y1001A1001A47J', 'SE4 BZ / MBA', 'Europe/Stockholm', - RS = '10YCS-SERBIATSOV', 'Serbia, EMS BZ / CA / MBA', 'Europe/Belgrade', - SK = '10YSK-SEPS-----K', 'Slovakia, SEPS BZ / CA / MBA', 'Europe/Bratislava', - SI = '10YSI-ELES-----O', 'Slovenia, ELES BZ / CA / MBA', 'Europe/Ljubljana', - GB_NIR = '10Y1001A1001A016', 'Northern Ireland, SONI CA', 'Europe/Belfast', - ES = '10YES-REE------0', 'Spain, REE BZ / CA / MBA', 'Europe/Madrid', - SE = '10YSE-1--------K', 'Sweden, Sweden MBA, SvK CA', 'Europe/Stockholm', - CH = '10YCH-SWISSGRIDZ', 'Switzerland, Swissgrid BZ / CA / MBA', 'Europe/Zurich', - DE_TENNET = '10YDE-EON------1', 'TenneT GER CA', 'Europe/Berlin', - DE_TRANSNET = '10YDE-ENBW-----N', 'TransnetBW CA', 'Europe/Berlin', - TR = '10YTR-TEIAS----W', 'Turkey BZ / CA / MBA', 'Europe/Istanbul', - UA = '10Y1001C--00003F', 'Ukraine, Ukraine BZ, MBA', 'Europe/Kiev', - UA_DOBTPP = '10Y1001A1001A869', 'Ukraine-DobTPP CTA', 'Europe/Kiev', - UA_BEI = '10YUA-WEPS-----0', 'Ukraine BEI CTA', 'Europe/Kiev', - UA_IPS = '10Y1001C--000182', 'Ukraine IPS CTA', 'Europe/Kiev', - XK = '10Y1001C--00100H', 'Kosovo/ XK CA / XK BZN', 'Europe/Rome', - DE_AMP_LU = '10Y1001C--00002H', 'Amprion LU CA', 'Europe/Berlin' \ No newline at end of file + DE_50HZ = ( + "10YDE-VE-------2", + "50Hertz CA, DE(50HzT) BZA", + "Europe/Berlin", + ) + AL = ( + "10YAL-KESH-----5", + "Albania, OST BZ / CA / MBA", + "Europe/Tirane", + ) + DE_AMPRION = ( + "10YDE-RWENET---I", + "Amprion CA", + "Europe/Berlin", + ) + AT = ( + "10YAT-APG------L", + "Austria, APG BZ / CA / MBA", + "Europe/Vienna", + ) + BY = ( + "10Y1001A1001A51S", + "Belarus BZ / CA / MBA", + "Europe/Minsk", + ) + BE = ( + "10YBE----------2", + "Belgium, Elia BZ / CA / MBA", + "Europe/Brussels", + ) + BA = ( + "10YBA-JPCC-----D", + "Bosnia Herzegovina, NOS BiH BZ / CA / MBA", + "Europe/Sarajevo", + ) + BG = ( + "10YCA-BULGARIA-R", + "Bulgaria, ESO BZ / CA / MBA", + "Europe/Sofia", + ) + CZ_DE_SK = ( + "10YDOM-CZ-DE-SKK", + "BZ CZ+DE+SK BZ / BZA", + "Europe/Prague", + ) + HR = ( + "10YHR-HEP------M", + "Croatia, HOPS BZ / CA / MBA", + "Europe/Zagreb", + ) + CWE = ( + "10YDOM-REGION-1V", + "CWE Region", + "Europe/Brussels", + ) + CY = ( + "10YCY-1001A0003J", + "Cyprus, Cyprus TSO BZ / CA / MBA", + "Asia/Nicosia", + ) + CZ = ( + "10YCZ-CEPS-----N", + "Czech Republic, CEPS BZ / CA/ MBA", + "Europe/Prague", + ) + DE_AT_LU = ( + "10Y1001A1001A63L", + "DE-AT-LU BZ", + "Europe/Berlin", + ) + DE_LU = ( + "10Y1001A1001A82H", + "DE-LU BZ / MBA", + "Europe/Berlin", + ) + DK = ( + "10Y1001A1001A65H", + "Denmark", + "Europe/Copenhagen", + ) + DK_1 = ( + "10YDK-1--------W", + "DK1 BZ / MBA", + "Europe/Copenhagen", + ) + DK_1_NO_1 = ( + "46Y000000000007M", + "DK1 NO1 BZ", + "Europe/Copenhagen", + ) + DK_2 = ( + "10YDK-2--------M", + "DK2 BZ / MBA", + "Europe/Copenhagen", + ) + DK_CA = ( + "10Y1001A1001A796", + "Denmark, Energinet CA", + "Europe/Copenhagen", + ) + EE = ( + "10Y1001A1001A39I", + "Estonia, Elering BZ / CA / MBA", + "Europe/Tallinn", + ) + FI = ( + "10YFI-1--------U", + "Finland, Fingrid BZ / CA / MBA", + "Europe/Helsinki", + ) + MK = ( + "10YMK-MEPSO----8", + "Former Yugoslav Republic of Macedonia, MEPSO BZ / CA / MBA", + "Europe/Skopje", + ) + FR = ( + "10YFR-RTE------C", + "France, RTE BZ / CA / MBA", + "Europe/Paris", + ) + DE = "10Y1001A1001A83F", "Germany", "Europe/Berlin" + GR = ( + "10YGR-HTSO-----Y", + "Greece, IPTO BZ / CA/ MBA", + "Europe/Athens", + ) + HU = ( + "10YHU-MAVIR----U", + "Hungary, MAVIR CA / BZ / MBA", + "Europe/Budapest", + ) + IS = ( + "IS", + "Iceland", + "Atlantic/Reykjavik", + ) + IE_SEM = ( + "10Y1001A1001A59C", + "Ireland (SEM) BZ / MBA", + "Europe/Dublin", + ) + IE = ( + "10YIE-1001A00010", + "Ireland, EirGrid CA", + "Europe/Dublin", + ) + IT = ( + "10YIT-GRTN-----B", + "Italy, IT CA / MBA", + "Europe/Rome", + ) + IT_SACO_AC = ( + "10Y1001A1001A885", + "Italy_Saco_AC", + "Europe/Rome", + ) + IT_CALA = ( + "10Y1001C--00096J", + "IT-Calabria BZ", + "Europe/Rome", + ) + IT_SACO_DC = ( + "10Y1001A1001A893", + "Italy_Saco_DC", + "Europe/Rome", + ) + IT_BRNN = ( + "10Y1001A1001A699", + "IT-Brindisi BZ", + "Europe/Rome", + ) + IT_CNOR = ( + "10Y1001A1001A70O", + "IT-Centre-North BZ", + "Europe/Rome", + ) + IT_CSUD = ( + "10Y1001A1001A71M", + "IT-Centre-South BZ", + "Europe/Rome", + ) + IT_FOGN = ( + "10Y1001A1001A72K", + "IT-Foggia BZ", + "Europe/Rome", + ) + IT_GR = ( + "10Y1001A1001A66F", + "IT-GR BZ", + "Europe/Rome", + ) + IT_MACRO_NORTH = ( + "10Y1001A1001A84D", + "IT-MACROZONE NORTH MBA", + "Europe/Rome", + ) + IT_MACRO_SOUTH = ( + "10Y1001A1001A85B", + "IT-MACROZONE SOUTH MBA", + "Europe/Rome", + ) + IT_MALTA = ( + "10Y1001A1001A877", + "IT-Malta BZ", + "Europe/Rome", + ) + IT_NORD = ( + "10Y1001A1001A73I", + "IT-North BZ", + "Europe/Rome", + ) + IT_NORD_AT = ( + "10Y1001A1001A80L", + "IT-North-AT BZ", + "Europe/Rome", + ) + IT_NORD_CH = ( + "10Y1001A1001A68B", + "IT-North-CH BZ", + "Europe/Rome", + ) + IT_NORD_FR = ( + "10Y1001A1001A81J", + "IT-North-FR BZ", + "Europe/Rome", + ) + IT_NORD_SI = ( + "10Y1001A1001A67D", + "IT-North-SI BZ", + "Europe/Rome", + ) + IT_PRGP = ( + "10Y1001A1001A76C", + "IT-Priolo BZ", + "Europe/Rome", + ) + IT_ROSN = ( + "10Y1001A1001A77A", + "IT-Rossano BZ", + "Europe/Rome", + ) + IT_SARD = ( + "10Y1001A1001A74G", + "IT-Sardinia BZ", + "Europe/Rome", + ) + IT_SICI = ( + "10Y1001A1001A75E", + "IT-Sicily BZ", + "Europe/Rome", + ) + IT_SUD = ( + "10Y1001A1001A788", + "IT-South BZ", + "Europe/Rome", + ) + RU_KGD = ( + "10Y1001A1001A50U", + "Kaliningrad BZ / CA / MBA", + "Europe/Kaliningrad", + ) + LV = ( + "10YLV-1001A00074", + "Latvia, AST BZ / CA / MBA", + "Europe/Riga", + ) + LT = ( + "10YLT-1001A0008Q", + "Lithuania, Litgrid BZ / CA / MBA", + "Europe/Vilnius", + ) + LU = ( + "10YLU-CEGEDEL-NQ", + "Luxembourg, CREOS CA", + "Europe/Luxembourg", + ) + LU_BZN = ( + "10Y1001A1001A82H", + "Luxembourg", + "Europe/Luxembourg", + ) + MT = ( + "10Y1001A1001A93C", + "Malta, Malta BZ / CA / MBA", + "Europe/Malta", + ) + ME = ( + "10YCS-CG-TSO---S", + "Montenegro, CGES BZ / CA / MBA", + "Europe/Podgorica", + ) + GB = ( + "10YGB----------A", + "National Grid BZ / CA/ MBA", + "Europe/London", + ) + GE = ( + "10Y1001A1001B012", + "Georgia", + "Asia/Tbilisi", + ) + GB_IFA = ( + "10Y1001C--00098F", + "GB(IFA) BZN", + "Europe/London", + ) + GB_IFA2 = ( + "17Y0000009369493", + "GB(IFA2) BZ", + "Europe/London", + ) + GB_ELECLINK = ( + "11Y0-0000-0265-K", + "GB(ElecLink) BZN", + "Europe/London", + ) + UK = ( + "10Y1001A1001A92E", + "United Kingdom", + "Europe/London", + ) + NL = ( + "10YNL----------L", + "Netherlands, TenneT NL BZ / CA/ MBA", + "Europe/Amsterdam", + ) + NO_1 = ( + "10YNO-1--------2", + "NO1 BZ / MBA", + "Europe/Oslo", + ) + NO_1A = ( + "10Y1001A1001A64J", + "NO1 A BZ", + "Europe/Oslo", + ) + NO_2 = ( + "10YNO-2--------T", + "NO2 BZ / MBA", + "Europe/Oslo", + ) + NO_2_NSL = ( + "50Y0JVU59B4JWQCU", + "NO2 NSL BZ / MBA", + "Europe/Oslo", + ) + NO_2A = ( + "10Y1001C--001219", + "NO2 A BZ", + "Europe/Oslo", + ) + NO_3 = ( + "10YNO-3--------J", + "NO3 BZ / MBA", + "Europe/Oslo", + ) + NO_4 = ( + "10YNO-4--------9", + "NO4 BZ / MBA", + "Europe/Oslo", + ) + NO_5 = ( + "10Y1001A1001A48H", + "NO5 BZ / MBA", + "Europe/Oslo", + ) + NO = ( + "10YNO-0--------C", + "Norway, Norway MBA, Stattnet CA", + "Europe/Oslo", + ) + PL_CZ = ( + "10YDOM-1001A082L", + "PL-CZ BZA / CA", + "Europe/Warsaw", + ) + PL = ( + "10YPL-AREA-----S", + "Poland, PSE SA BZ / BZA / CA / MBA", + "Europe/Warsaw", + ) + PT = ( + "10YPT-REN------W", + "Portugal, REN BZ / CA / MBA", + "Europe/Lisbon", + ) + MD = ( + "10Y1001A1001A990", + "Republic of Moldova, Moldelectica BZ/CA/MBA", + "Europe/Chisinau", + ) + RO = ( + "10YRO-TEL------P", + "Romania, Transelectrica BZ / CA/ MBA", + "Europe/Bucharest", + ) + RU = ( + "10Y1001A1001A49F", + "Russia BZ / CA / MBA", + "Europe/Moscow", + ) + SE_1 = ( + "10Y1001A1001A44P", + "SE1 BZ / MBA", + "Europe/Stockholm", + ) + SE_2 = ( + "10Y1001A1001A45N", + "SE2 BZ / MBA", + "Europe/Stockholm", + ) + SE_3 = ( + "10Y1001A1001A46L", + "SE3 BZ / MBA", + "Europe/Stockholm", + ) + SE_4 = ( + "10Y1001A1001A47J", + "SE4 BZ / MBA", + "Europe/Stockholm", + ) + RS = ( + "10YCS-SERBIATSOV", + "Serbia, EMS BZ / CA / MBA", + "Europe/Belgrade", + ) + SK = ( + "10YSK-SEPS-----K", + "Slovakia, SEPS BZ / CA / MBA", + "Europe/Bratislava", + ) + SI = ( + "10YSI-ELES-----O", + "Slovenia, ELES BZ / CA / MBA", + "Europe/Ljubljana", + ) + GB_NIR = ( + "10Y1001A1001A016", + "Northern Ireland, SONI CA", + "Europe/Belfast", + ) + ES = ( + "10YES-REE------0", + "Spain, REE BZ / CA / MBA", + "Europe/Madrid", + ) + SE = ( + "10YSE-1--------K", + "Sweden, Sweden MBA, SvK CA", + "Europe/Stockholm", + ) + CH = ( + "10YCH-SWISSGRIDZ", + "Switzerland, Swissgrid BZ / CA / MBA", + "Europe/Zurich", + ) + DE_TENNET = ( + "10YDE-EON------1", + "TenneT GER CA", + "Europe/Berlin", + ) + DE_TRANSNET = ( + "10YDE-ENBW-----N", + "TransnetBW CA", + "Europe/Berlin", + ) + TR = ( + "10YTR-TEIAS----W", + "Turkey BZ / CA / MBA", + "Europe/Istanbul", + ) + UA = ( + "10Y1001C--00003F", + "Ukraine, Ukraine BZ, MBA", + "Europe/Kiev", + ) + UA_DOBTPP = ( + "10Y1001A1001A869", + "Ukraine-DobTPP CTA", + "Europe/Kiev", + ) + UA_BEI = ( + "10YUA-WEPS-----0", + "Ukraine BEI CTA", + "Europe/Kiev", + ) + UA_IPS = ( + "10Y1001C--000182", + "Ukraine IPS CTA", + "Europe/Kiev", + ) + XK = ( + "10Y1001C--00100H", + "Kosovo/ XK CA / XK BZN", + "Europe/Rome", + ) + DE_AMP_LU = "10Y1001C--00002H", "Amprion LU CA", "Europe/Berlin" diff --git a/custom_components/entsoe/config_flow.py b/custom_components/entsoe/config_flow.py index fc28607..d2a1014 100644 --- a/custom_components/entsoe/config_flow.py +++ b/custom_components/entsoe/config_flow.py @@ -1,41 +1,40 @@ """Config flow for Forecast.Solar integration.""" + from __future__ import annotations -from typing import Any +import logging import re +from typing import Any import voluptuous as vol - -import logging - from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.selector import ( - SelectSelectorConfig, - SelectSelector, SelectOptionDict, - TemplateSelectorConfig, + SelectSelector, + SelectSelectorConfig, TemplateSelector, + TemplateSelectorConfig, ) from homeassistant.helpers.template import Template from .const import ( - CONF_MODIFYER, - CONF_CURRENCY, + AREA_INFO, + CALCULATION_MODE, + COMPONENT_TITLE, + CONF_ADVANCED_OPTIONS, CONF_API_KEY, - CONF_ENTITY_NAME, CONF_AREA, - CONF_ADVANCED_OPTIONS, - CONF_VAT_VALUE, CONF_CALCULATION_MODE, + CONF_CURRENCY, + CONF_ENTITY_NAME, + CONF_MODIFYER, + CONF_VAT_VALUE, + DEFAULT_CURRENCY, + DEFAULT_MODIFYER, DOMAIN, - COMPONENT_TITLE, UNIQUE_ID, - AREA_INFO, - DEFAULT_MODIFYER, - DEFAULT_CURRENCY, - CALCULATION_MODE ) @@ -102,7 +101,7 @@ async def async_step_user( CONF_ADVANCED_OPTIONS: user_input[CONF_ADVANCED_OPTIONS], CONF_VAT_VALUE: user_input[CONF_VAT_VALUE], CONF_ENTITY_NAME: user_input[CONF_ENTITY_NAME], - CONF_CALCULATION_MODE: user_input[CONF_CALCULATION_MODE] + CONF_CALCULATION_MODE: user_input[CONF_CALCULATION_MODE], }, ) @@ -111,7 +110,9 @@ async def async_step_user( errors=errors, data_schema=vol.Schema( { - vol.Optional(CONF_ENTITY_NAME, default=""): vol.All(vol.Coerce(str)), + vol.Optional(CONF_ENTITY_NAME, default=""): vol.All( + vol.Coerce(str) + ), vol.Required(CONF_API_KEY): vol.All(vol.Coerce(str)), vol.Required(CONF_AREA): SelectSelector( SelectSelectorConfig( @@ -138,7 +139,6 @@ async def async_step_extra(self, user_input=None): user_input[CONF_API_KEY] = self.api_key user_input[CONF_ENTITY_NAME] = self.name - if user_input[CONF_ENTITY_NAME] not in (None, ""): self.name = user_input[CONF_ENTITY_NAME] NAMED_UNIQUE_ID = self.name + UNIQUE_ID @@ -176,14 +176,15 @@ async def async_step_extra(self, user_input=None): CONF_CURRENCY: user_input[CONF_CURRENCY], CONF_VAT_VALUE: user_input[CONF_VAT_VALUE], CONF_ENTITY_NAME: user_input[CONF_ENTITY_NAME], - CONF_CALCULATION_MODE: user_input[CONF_CALCULATION_MODE] + CONF_CALCULATION_MODE: user_input[ + CONF_CALCULATION_MODE + ], }, ) errors["base"] = "missing_current_price" else: errors["base"] = "invalid_template" - return self.async_show_form( step_id="extra", errors=errors, @@ -192,13 +193,20 @@ async def async_step_extra(self, user_input=None): vol.Optional( CONF_VAT_VALUE, default=AREA_INFO[self.area]["VAT"] ): vol.All(vol.Coerce(float, "must be a number")), - vol.Optional(CONF_MODIFYER, default=""): TemplateSelector(TemplateSelectorConfig()), - vol.Optional(CONF_CURRENCY, default=DEFAULT_CURRENCY): vol.All(vol.Coerce(str)), - vol.Optional(CONF_CALCULATION_MODE, default=CALCULATION_MODE["default"]): SelectSelector( + vol.Optional(CONF_MODIFYER, default=""): TemplateSelector( + TemplateSelectorConfig() + ), + vol.Optional(CONF_CURRENCY, default=DEFAULT_CURRENCY): vol.All( + vol.Coerce(str) + ), + vol.Optional( + CONF_CALCULATION_MODE, default=CALCULATION_MODE["default"] + ): SelectSelector( SelectSelectorConfig( options=[ SelectOptionDict(value=value, label=key) - for key, value in CALCULATION_MODE.items() if key != "default" + for key, value in CALCULATION_MODE.items() + if key != "default" ] ), ), @@ -222,6 +230,7 @@ async def _valid_template(self, user_template): pass return False + class EntsoeOptionFlowHandler(OptionsFlow): """Handle options.""" @@ -261,7 +270,9 @@ async def async_step_init( else: errors["base"] = "invalid_template" - calculation_mode_default = self.config_entry.options.get(CONF_CALCULATION_MODE, CALCULATION_MODE["default"]) + calculation_mode_default = self.config_entry.options.get( + CONF_CALCULATION_MODE, CALCULATION_MODE["default"] + ) return self.async_show_form( step_id="init", @@ -291,7 +302,9 @@ async def async_step_init( ): TemplateSelector(TemplateSelectorConfig()), vol.Optional( CONF_CURRENCY, - default=self.config_entry.options.get(CONF_CURRENCY, DEFAULT_CURRENCY) + default=self.config_entry.options.get( + CONF_CURRENCY, DEFAULT_CURRENCY + ), ): vol.All(vol.Coerce(str)), vol.Optional( CONF_CALCULATION_MODE, @@ -300,7 +313,8 @@ async def async_step_init( SelectSelectorConfig( options=[ SelectOptionDict(value=value, label=key) - for key, value in CALCULATION_MODE.items() if key != "default" + for key, value in CALCULATION_MODE.items() + if key != "default" ] ), ), diff --git a/custom_components/entsoe/const.py b/custom_components/entsoe/const.py index c2db406..f486450 100644 --- a/custom_components/entsoe/const.py +++ b/custom_components/entsoe/const.py @@ -17,62 +17,153 @@ DEFAULT_MODIFYER = "{{current_price}}" DEFAULT_CURRENCY = CURRENCY_EURO -#default is only for internal use / backwards compatibility -CALCULATION_MODE = { "default": "publish", "rotation": "rotation", "sliding": "sliding", "publish": "publish" } +# default is only for internal use / backwards compatibility +CALCULATION_MODE = { + "default": "publish", + "rotation": "rotation", + "sliding": "sliding", + "publish": "publish", +} # Commented ones are not working at entsoe -AREA_INFO = {"AT":{"code":"AT", "name":"Austria", "VAT":0.21, "Currency":"EUR"}, - "BE":{"code":"BE", "name":"Belgium", "VAT":0.06, "Currency":"EUR"}, - "BG":{"code":"BG", "name":"Bulgaria", "VAT":0.21, "Currency":"EUR"}, - "HR":{"code":"HR", "name":"Croatia", "VAT":0.21, "Currency":"EUR"}, - "CZ":{"code":"CZ", "name":"Czech Republic", "VAT":0.21, "Currency":"EUR"}, - "DK_1":{"code":"DK_1", "name":"Denmark Western (DK1)", "VAT":0.21, "Currency":"EUR"}, - "DK_2":{"code":"DK_2", "name":"Denmark Eastern (DK2)", "VAT":0.21, "Currency":"EUR"}, - "EE":{"code":"EE", "name":"Estonia", "VAT":0.21, "Currency":"EUR"}, - "FI":{"code":"FI", "name":"Finland", "VAT":0.255, "Currency":"EUR"}, - "FR":{"code":"FR", "name":"France", "VAT":0.21, "Currency":"EUR"}, - "LU":{"code":"DE_LU", "name":"Luxembourg", "VAT":0.21, "Currency":"EUR"}, - "DE":{"code":"DE_LU", "name":"Germany", "VAT":0.21, "Currency":"EUR"}, - "GR":{"code":"GR", "name":"Greece", "VAT":0.21, "Currency":"EUR"}, - "HU":{"code":"HU", "name":"Hungary", "VAT":0.21, "Currency":"EUR"}, - "IT_CNOR":{"code":"IT_CNOR", "name":"Italy Centre North", "VAT":0.21, "Currency":"EUR"}, - "IT_CSUD":{"code":"IT_CSUD", "name":"Italy Centre South", "VAT":0.21, "Currency":"EUR"}, - "IT_NORD":{"code":"IT_NORD", "name":"Italy North", "VAT":0.21, "Currency":"EUR"}, - "IT_SUD":{"code":"IT_SUD", "name":"Italy South", "VAT":0.21, "Currency":"EUR"}, - "IT_SICI":{"code":"IT_SICI", "name":"Italy Sicilia", "VAT":0.21, "Currency":"EUR"}, - "IT_SARD":{"code":"IT_SARD", "name":"Italy Sardinia", "VAT":0.21, "Currency":"EUR"}, - "IT_CALA":{"code":"IT_CALA", "name":"Italy Calabria", "VAT":0.21, "Currency":"EUR"}, - "LV":{"code":"LV", "name":"Latvia", "VAT":0.21, "Currency":"EUR"}, - "LT":{"code":"LT", "name":"Lithuania", "VAT":0.21, "Currency":"EUR"}, - "NL":{"code":"NL", "name":"Netherlands", "VAT":0.21, "Currency":"EUR"}, - "NO_1":{"code":"NO_1", "name":"Norway Oslo (NO1)", "VAT":0.25, "Currency":"EUR"}, - "NO_2":{"code":"NO_2", "name":"Norway Kr.Sand (NO2)", "VAT":0.25, "Currency":"EUR"}, - "NO_3":{"code":"NO_3", "name":"Norway Tr.heim (NO3)", "VAT":0.25, "Currency":"EUR"}, - "NO_4":{"code":"NO_4", "name":"Norway Tromsø (NO4)", "VAT":0, "Currency":"EUR"}, - "NO_5":{"code":"NO_5", "name":"Norway Bergen (NO5)", "VAT":0.25, "Currency":"EUR"}, - "PL":{"code":"PL", "name":"Poland", "VAT":0.21, "Currency":"EUR"}, - "PT":{"code":"PT", "name":"Portugal", "VAT":0.21, "Currency":"EUR"}, - "RO":{"code":"RO", "name":"Romania", "VAT":0.21, "Currency":"EUR"}, - "RS":{"code":"RS", "name":"Serbia", "VAT":0.21, "Currency":"EUR"}, - "SK":{"code":"SK", "name":"Slovakia", "VAT":0.21, "Currency":"EUR"}, - "SI":{"code":"SI", "name":"Slovenia", "VAT":0.21, "Currency":"EUR"}, - "ES":{"code":"ES", "name":"Spain", "VAT":0.21, "Currency":"EUR"}, - "SE_1":{"code":"SE_1", "name":"Sweden Luleå (SE1)", "VAT":0.25, "Currency":"EUR"}, - "SE_2":{"code":"SE_2", "name":"Sweden Sundsvall (SE2)", "VAT":0.25, "Currency":"EUR"}, - "SE_3":{"code":"SE_3", "name":"Sweden Stockholm (SE3)", "VAT":0.25, "Currency":"EUR"}, - "SE_4":{"code":"SE_4", "name":"Sweden Malmö (SE4)", "VAT":0.25, "Currency":"EUR"}, - "CH":{"code":"CH", "name":"Switzerland", "VAT":0.21, "Currency":"EUR"}, - # "UK":{"code":"UK", "name":"United Kingdom", "VAT":0.21, "Currency":"EUR"}, - # "AL":{"code":"AL", "name":"Albania", "VAT":0.21, "Currency":"EUR"}, - # "BA":{"code":"BA", "name":"Bosnia and Herz.", "VAT":0.21, "Currency":"EUR"}, - # "CY":{"code":"CY", "name":"Cyprus", "VAT":0.21, "Currency":"EUR"}, - # "GE":{"code":"GE", "name":"Georgia", "VAT":0.21, "Currency":"EUR"}, - # "IE":{"code":"IE", "name":"Ireland", "VAT":0.21, "Currency":"EUR"}, - # "XK":{"code":"XK", "name":"Kosovo", "VAT":0.21, "Currency":"EUR"}, - # "MT":{"code":"MT", "name":"Malta", "VAT":0.21, "Currency":"EUR"}, - # "MD":{"code":"MD", "name":"Moldova", "VAT":0.21, "Currency":"EUR"}, - # "ME":{"code":"ME", "name":"Montenegro", "VAT":0.21, "Currency":"EUR"}, - # "MK":{"code":"MK", "name":"North Macedonia", "VAT":0.21, "Currency":"EUR"}, - # "TR":{"code":"TR", "name":"Turkey", "VAT":0.21, "Currency":"EUR"}, - # "UA":{"code":"UA", "name":"Ukraine", "VAT":0.21, "Currency":"EUR"}, - } +AREA_INFO = { + "AT": {"code": "AT", "name": "Austria", "VAT": 0.21, "Currency": "EUR"}, + "BE": {"code": "BE", "name": "Belgium", "VAT": 0.06, "Currency": "EUR"}, + "BG": {"code": "BG", "name": "Bulgaria", "VAT": 0.21, "Currency": "EUR"}, + "HR": {"code": "HR", "name": "Croatia", "VAT": 0.21, "Currency": "EUR"}, + "CZ": {"code": "CZ", "name": "Czech Republic", "VAT": 0.21, "Currency": "EUR"}, + "DK_1": { + "code": "DK_1", + "name": "Denmark Western (DK1)", + "VAT": 0.21, + "Currency": "EUR", + }, + "DK_2": { + "code": "DK_2", + "name": "Denmark Eastern (DK2)", + "VAT": 0.21, + "Currency": "EUR", + }, + "EE": {"code": "EE", "name": "Estonia", "VAT": 0.21, "Currency": "EUR"}, + "FI": {"code": "FI", "name": "Finland", "VAT": 0.255, "Currency": "EUR"}, + "FR": {"code": "FR", "name": "France", "VAT": 0.21, "Currency": "EUR"}, + "LU": {"code": "DE_LU", "name": "Luxembourg", "VAT": 0.21, "Currency": "EUR"}, + "DE": {"code": "DE_LU", "name": "Germany", "VAT": 0.21, "Currency": "EUR"}, + "GR": {"code": "GR", "name": "Greece", "VAT": 0.21, "Currency": "EUR"}, + "HU": {"code": "HU", "name": "Hungary", "VAT": 0.21, "Currency": "EUR"}, + "IT_CNOR": { + "code": "IT_CNOR", + "name": "Italy Centre North", + "VAT": 0.21, + "Currency": "EUR", + }, + "IT_CSUD": { + "code": "IT_CSUD", + "name": "Italy Centre South", + "VAT": 0.21, + "Currency": "EUR", + }, + "IT_NORD": { + "code": "IT_NORD", + "name": "Italy North", + "VAT": 0.21, + "Currency": "EUR", + }, + "IT_SUD": {"code": "IT_SUD", "name": "Italy South", "VAT": 0.21, "Currency": "EUR"}, + "IT_SICI": { + "code": "IT_SICI", + "name": "Italy Sicilia", + "VAT": 0.21, + "Currency": "EUR", + }, + "IT_SARD": { + "code": "IT_SARD", + "name": "Italy Sardinia", + "VAT": 0.21, + "Currency": "EUR", + }, + "IT_CALA": { + "code": "IT_CALA", + "name": "Italy Calabria", + "VAT": 0.21, + "Currency": "EUR", + }, + "LV": {"code": "LV", "name": "Latvia", "VAT": 0.21, "Currency": "EUR"}, + "LT": {"code": "LT", "name": "Lithuania", "VAT": 0.21, "Currency": "EUR"}, + "NL": {"code": "NL", "name": "Netherlands", "VAT": 0.21, "Currency": "EUR"}, + "NO_1": { + "code": "NO_1", + "name": "Norway Oslo (NO1)", + "VAT": 0.25, + "Currency": "EUR", + }, + "NO_2": { + "code": "NO_2", + "name": "Norway Kr.Sand (NO2)", + "VAT": 0.25, + "Currency": "EUR", + }, + "NO_3": { + "code": "NO_3", + "name": "Norway Tr.heim (NO3)", + "VAT": 0.25, + "Currency": "EUR", + }, + "NO_4": { + "code": "NO_4", + "name": "Norway Tromsø (NO4)", + "VAT": 0, + "Currency": "EUR", + }, + "NO_5": { + "code": "NO_5", + "name": "Norway Bergen (NO5)", + "VAT": 0.25, + "Currency": "EUR", + }, + "PL": {"code": "PL", "name": "Poland", "VAT": 0.21, "Currency": "EUR"}, + "PT": {"code": "PT", "name": "Portugal", "VAT": 0.21, "Currency": "EUR"}, + "RO": {"code": "RO", "name": "Romania", "VAT": 0.21, "Currency": "EUR"}, + "RS": {"code": "RS", "name": "Serbia", "VAT": 0.21, "Currency": "EUR"}, + "SK": {"code": "SK", "name": "Slovakia", "VAT": 0.21, "Currency": "EUR"}, + "SI": {"code": "SI", "name": "Slovenia", "VAT": 0.21, "Currency": "EUR"}, + "ES": {"code": "ES", "name": "Spain", "VAT": 0.21, "Currency": "EUR"}, + "SE_1": { + "code": "SE_1", + "name": "Sweden Luleå (SE1)", + "VAT": 0.25, + "Currency": "EUR", + }, + "SE_2": { + "code": "SE_2", + "name": "Sweden Sundsvall (SE2)", + "VAT": 0.25, + "Currency": "EUR", + }, + "SE_3": { + "code": "SE_3", + "name": "Sweden Stockholm (SE3)", + "VAT": 0.25, + "Currency": "EUR", + }, + "SE_4": { + "code": "SE_4", + "name": "Sweden Malmö (SE4)", + "VAT": 0.25, + "Currency": "EUR", + }, + "CH": {"code": "CH", "name": "Switzerland", "VAT": 0.21, "Currency": "EUR"}, + # "UK":{"code":"UK", "name":"United Kingdom", "VAT":0.21, "Currency":"EUR"}, + # "AL":{"code":"AL", "name":"Albania", "VAT":0.21, "Currency":"EUR"}, + # "BA":{"code":"BA", "name":"Bosnia and Herz.", "VAT":0.21, "Currency":"EUR"}, + # "CY":{"code":"CY", "name":"Cyprus", "VAT":0.21, "Currency":"EUR"}, + # "GE":{"code":"GE", "name":"Georgia", "VAT":0.21, "Currency":"EUR"}, + # "IE":{"code":"IE", "name":"Ireland", "VAT":0.21, "Currency":"EUR"}, + # "XK":{"code":"XK", "name":"Kosovo", "VAT":0.21, "Currency":"EUR"}, + # "MT":{"code":"MT", "name":"Malta", "VAT":0.21, "Currency":"EUR"}, + # "MD":{"code":"MD", "name":"Moldova", "VAT":0.21, "Currency":"EUR"}, + # "ME":{"code":"ME", "name":"Montenegro", "VAT":0.21, "Currency":"EUR"}, + # "MK":{"code":"MK", "name":"North Macedonia", "VAT":0.21, "Currency":"EUR"}, + # "TR":{"code":"TR", "name":"Turkey", "VAT":0.21, "Currency":"EUR"}, + # "UA":{"code":"UA", "name":"Ukraine", "VAT":0.21, "Currency":"EUR"}, +} diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 9341a8d..6b13da0 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -1,24 +1,32 @@ from __future__ import annotations -from requests.exceptions import HTTPError -from datetime import datetime, timedelta - import logging +from datetime import datetime, timedelta +import homeassistant.helpers.config_validation as cv from homeassistant.core import HomeAssistant +from homeassistant.helpers.template import Template from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.template import Template from jinja2 import pass_context +from requests.exceptions import HTTPError -from .const import DEFAULT_MODIFYER, AREA_INFO, CALCULATION_MODE from .api_client import EntsoeClient +from .const import AREA_INFO, CALCULATION_MODE, DEFAULT_MODIFYER + class EntsoeCoordinator(DataUpdateCoordinator): """Get the latest data and update the states.""" - def __init__(self, hass: HomeAssistant, api_key, area, modifyer, calculation_mode = CALCULATION_MODE["default"], VAT = 0) -> None: + def __init__( + self, + hass: HomeAssistant, + api_key, + area, + modifyer, + calculation_mode=CALCULATION_MODE["default"], + VAT=0, + ) -> None: """Initialize the data object.""" self.hass = hass self.api_key = api_key @@ -65,7 +73,9 @@ def inner(*args, **kwargs): return pass_context(inner) - template_value = self.modifyer.async_render(now=faker(), current_price=price) + template_value = self.modifyer.async_render( + now=faker(), current_price=price + ) else: template_value = self.modifyer.async_render() @@ -89,16 +99,20 @@ async def _async_update_data(self) -> dict: self.logger.debug(f"Skipping api fetch. All data is already available") return self.data - yesterday = self.today - timedelta(days = 1) - tomorrow_evening = yesterday + timedelta(hours = 71) + yesterday = self.today - timedelta(days=1) + tomorrow_evening = yesterday + timedelta(hours=71) - self.logger.debug(f"fetching prices for start date: {yesterday} to end date: {tomorrow_evening}") + self.logger.debug( + f"fetching prices for start date: {yesterday} to end date: {tomorrow_evening}" + ) data = await self.fetch_prices(yesterday, tomorrow_evening) self.logger.debug(f"received data = {data}") - + if data is not None: parsed_data = self.parse_hourprices(data) - self.logger.debug(f"received pricing data from entso-e for {len(data)} hours") + self.logger.debug( + f"received pricing data from entso-e for {len(data)} hours" + ) self.filtered_hourprices = self._filter_calculated_hourprices(parsed_data) return parsed_data @@ -110,7 +124,7 @@ def check_update_needed(self, now): if len(self.get_data_tomorrow()) != 24 and now.hour > 12: return True return False - + async def fetch_prices(self, start_date, end_date): try: # run api_update in async job @@ -119,30 +133,40 @@ async def fetch_prices(self, start_date, end_date): ) return resp - except (HTTPError) as exc: + except HTTPError as exc: if exc.response.status_code == 401: raise UpdateFailed("Unauthorized: Please check your API-key.") from exc except Exception as exc: if self.data is not None: newest_timestamp = self.data[max(self.data.keys())] - if(newest_timestamp) > dt.now(): - self.logger.warning(f"Warning the integration is running in degraded mode (falling back on stored data) since fetching the latest ENTSOE-e prices failed with exception: {exc}.") + if (newest_timestamp) > dt.now(): + self.logger.warning( + f"Warning the integration is running in degraded mode (falling back on stored data) since fetching the latest ENTSOE-e prices failed with exception: {exc}." + ) else: - raise UpdateFailed(f"The latest available data is older than the current time. Therefore entities will no longer update. Error: {exc}") from exc + raise UpdateFailed( + f"The latest available data is older than the current time. Therefore entities will no longer update. Error: {exc}" + ) from exc else: - self.logger.warning(f"Warning the integration doesn't have any up to date local data this means that entities won't get updated but access remains to restorable entities: {exc}.") + self.logger.warning( + f"Warning the integration doesn't have any up to date local data this means that entities won't get updated but access remains to restorable entities: {exc}." + ) def api_update(self, start_date, end_date, api_key): client = EntsoeClient(api_key=api_key) return client.query_day_ahead_prices( country_code=self.area, start=start_date, end=end_date ) - + async def get_energy_prices(self, start_date, end_date): - #check if we have the data already + # check if we have the data already if len(self.get_data(start_date)) == 24 and len(self.get_data(end_date)) == 24: - self.logger.debug(f'return prices from coordinator cache.') - return {k: v for k, v in self.data.items() if k.date() >= start_date.date() and k.date() <= end_date.date()} + self.logger.debug(f"return prices from coordinator cache.") + return { + k: v + for k, v in self.data.items() + if k.date() >= start_date.date() and k.date() <= end_date.date() + } return await self.fetch_prices(start_date, end_date) def today_data_available(self): @@ -150,39 +174,53 @@ def today_data_available(self): def _filter_calculated_hourprices(self, data): if self.calculation_mode == CALCULATION_MODE["rotation"]: - return { hour: price for hour, price in data.items() if hour >= self.today and hour < self.today + timedelta(days=1) } + return { + hour: price + for hour, price in data.items() + if hour >= self.today and hour < self.today + timedelta(days=1) + } elif self.calculation_mode == CALCULATION_MODE["sliding"]: now = dt.now().replace(minute=0, second=0, microsecond=0) - return { hour: price for hour, price in data.items() if hour >= now } + return {hour: price for hour, price in data.items() if hour >= now} elif self.calculation_mode == CALCULATION_MODE["publish"]: return dict(list(data.items())[-48:]) - + def get_prices_today(self): return self.get_timestamped_prices(self.get_data_today()) - + def get_prices_tomorrow(self): return self.get_timestamped_prices(self.get_data_tomorrow()) - + def get_prices(self): return self.get_timestamped_prices(dict(list(self.data.items())[-48:])) - + def get_data(self, date): return {k: v for k, v in self.data.items() if k.date() == date.date()} def get_data_today(self): return {k: v for k, v in self.data.items() if k.date() == self.today.date()} - + def get_data_tomorrow(self): - return {k: v for k, v in self.data.items() if k.date() == self.today.date() + timedelta(days=1)} + return { + k: v + for k, v in self.data.items() + if k.date() == self.today.date() + timedelta(days=1) + } def get_next_hourprice(self) -> int: - return self.data[dt.now().replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)] + return self.data[ + dt.now().replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) + ] def get_current_hourprice(self) -> int: return self.data[dt.now().replace(minute=0, second=0, microsecond=0)] def get_avg_price(self): - return round(sum(self.filtered_hourprices.values()) / len(self.filtered_hourprices.values()), 5) + return round( + sum(self.filtered_hourprices.values()) + / len(self.filtered_hourprices.values()), + 5, + ) def get_max_price(self): return max(self.filtered_hourprices.values()) diff --git a/custom_components/entsoe/sensor.py b/custom_components/entsoe/sensor.py index 5d85239..33273fd 100644 --- a/custom_components/entsoe/sensor.py +++ b/custom_components/entsoe/sensor.py @@ -1,31 +1,42 @@ """ENTSO-e current electricity and gas price information service.""" + from __future__ import annotations +import logging from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta -import logging from typing import Any -from homeassistant.components.sensor import DOMAIN, RestoreSensor, SensorDeviceClass, SensorEntityDescription, SensorExtraStoredData, SensorStateClass -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - PERCENTAGE, - UnitOfEnergy, +from homeassistant.components.sensor import ( + DOMAIN, + RestoreSensor, + SensorDeviceClass, + SensorEntityDescription, + SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfEnergy from homeassistant.core import HassJob, HomeAssistant from homeassistant.helpers import event from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.typing import StateType from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import utcnow -from .const import ATTRIBUTION, CONF_ENTITY_NAME, DOMAIN, DEFAULT_CURRENCY, CONF_CURRENCY +from .const import ( + ATTRIBUTION, + CONF_CURRENCY, + CONF_ENTITY_NAME, + DEFAULT_CURRENCY, + DOMAIN, +) from .coordinator import EntsoeCoordinator _LOGGER = logging.getLogger(__name__) + @dataclass class EntsoeEntityDescription(SensorEntityDescription): """Describes ENTSO-e sensor entity.""" @@ -43,7 +54,7 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: state_class=SensorStateClass.MEASUREMENT, icon="mdi:currency-eur", suggested_display_precision=3, - value_fn=lambda coordinator: coordinator.get_current_hourprice() + value_fn=lambda coordinator: coordinator.get_current_hourprice(), ), EntsoeEntityDescription( key="next_hour_price", @@ -52,7 +63,7 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: state_class=SensorStateClass.MEASUREMENT, icon="mdi:currency-eur", suggested_display_precision=3, - value_fn=lambda coordinator: coordinator.get_next_hourprice() + value_fn=lambda coordinator: coordinator.get_next_hourprice(), ), EntsoeEntityDescription( key="min_price", @@ -61,7 +72,7 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: state_class=SensorStateClass.MEASUREMENT, icon="mdi:currency-eur", suggested_display_precision=3, - value_fn=lambda coordinator: coordinator.get_min_price() + value_fn=lambda coordinator: coordinator.get_min_price(), ), EntsoeEntityDescription( key="max_price", @@ -70,7 +81,7 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: state_class=SensorStateClass.MEASUREMENT, icon="mdi:currency-eur", suggested_display_precision=3, - value_fn=lambda coordinator: coordinator.get_max_price() + value_fn=lambda coordinator: coordinator.get_max_price(), ), EntsoeEntityDescription( key="avg_price", @@ -79,7 +90,7 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: state_class=SensorStateClass.MEASUREMENT, icon="mdi:currency-eur", suggested_display_precision=3, - value_fn=lambda coordinator: coordinator.get_avg_price() + value_fn=lambda coordinator: coordinator.get_avg_price(), ), EntsoeEntityDescription( key="percentage_of_max", @@ -95,14 +106,14 @@ def sensor_descriptions(currency: str) -> tuple[EntsoeEntityDescription, ...]: name="Time of highest price", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:clock", - value_fn=lambda coordinator: coordinator.get_max_time() + value_fn=lambda coordinator: coordinator.get_max_time(), ), EntsoeEntityDescription( key="lowest_price_time_today", name="Time of lowest price", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:clock", - value_fn=lambda coordinator: coordinator.get_min_time() + value_fn=lambda coordinator: coordinator.get_min_time(), ), ) @@ -117,33 +128,39 @@ async def async_setup_entry( entities = [] entity = {} - for description in sensor_descriptions(currency = config_entry.options.get(CONF_CURRENCY, DEFAULT_CURRENCY)): + for description in sensor_descriptions( + currency=config_entry.options.get(CONF_CURRENCY, DEFAULT_CURRENCY) + ): entity = description entities.append( EntsoeSensor( - entsoe_coordinator, - entity, - config_entry.options[CONF_ENTITY_NAME] - )) + entsoe_coordinator, entity, config_entry.options[CONF_ENTITY_NAME] + ) + ) # Add an entity for each sensor type async_add_entities(entities, True) + class EntsoeSensor(CoordinatorEntity, RestoreSensor): """Representation of a ENTSO-e sensor.""" _attr_attribution = ATTRIBUTION - - def __init__(self, coordinator: EntsoeCoordinator, description: EntsoeEntityDescription, name: str = "") -> None: + def __init__( + self, + coordinator: EntsoeCoordinator, + description: EntsoeEntityDescription, + name: str = "", + ) -> None: """Initialize the sensor.""" self.description = description self.last_update_success = True if name not in (None, ""): - #The Id used for addressing the entity in the ui, recorder history etc. + # The Id used for addressing the entity in the ui, recorder history etc. self.entity_id = f"{DOMAIN}.{name}_{description.name}" - #unique id in .storage file for ui configuration. + # unique id in .storage file for ui configuration. self._attr_unique_id = f"entsoe.{name}_{description.key}" self._attr_name = f"{description.name} ({name})" else: @@ -153,7 +170,11 @@ def __init__(self, coordinator: EntsoeCoordinator, description: EntsoeEntityDesc self.entity_description: EntsoeEntityDescription = description self._attr_icon = description.icon - self._attr_suggested_display_precision= description.suggested_display_precision if description.suggested_display_precision is not None else 2 + self._attr_suggested_display_precision = ( + description.suggested_display_precision + if description.suggested_display_precision is not None + else 2 + ) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -165,7 +186,7 @@ def __init__(self, coordinator: EntsoeCoordinator, description: EntsoeEntityDesc }, manufacturer="entso-e", model="", - name="entso-e" + ((" (" + name + ")") if name != "" else "") + name="entso-e" + ((" (" + name + ")") if name != "" else ""), ) self._update_job = HassJob(self.async_schedule_update_ha_state) @@ -175,7 +196,7 @@ def __init__(self, coordinator: EntsoeCoordinator, description: EntsoeEntityDesc async def async_update(self) -> None: """Get the latest data and updates the states.""" - #_LOGGER.debug(f"update function for '{self.entity_id} called.'") + # _LOGGER.debug(f"update function for '{self.entity_id} called.'") # Cancel the currently scheduled event if there is any if self._unsub_update: @@ -189,33 +210,46 @@ async def async_update(self) -> None: utcnow().replace(minute=0, second=0) + timedelta(hours=1), ) - if self.coordinator.data is not None and self.coordinator.today_data_available(): + if ( + self.coordinator.data is not None + and self.coordinator.today_data_available() + ): value: Any = None try: - #_LOGGER.debug(f"current coordinator.data value: {self.coordinator.data}") + # _LOGGER.debug(f"current coordinator.data value: {self.coordinator.data}") value = self.entity_description.value_fn(self.coordinator) self._attr_native_value = value self.last_update_success = True _LOGGER.debug(f"updated '{self.entity_id}' to value: {value}") - + except Exception as exc: # No data available self.last_update_success = False - _LOGGER.warning(f"Unable to update entity '{self.entity_id}', value: {value} and error: {exc}, data: {self.coordinator.data}") + _LOGGER.warning( + f"Unable to update entity '{self.entity_id}', value: {value} and error: {exc}, data: {self.coordinator.data}" + ) else: - _LOGGER.warning(f"Unable to update entity '{self.entity_id}': No valid data for today available.") + _LOGGER.warning( + f"Unable to update entity '{self.entity_id}': No valid data for today available." + ) self.last_update_success = False try: - if self.description.key == "avg_price" and self._attr_native_value is not None and self.coordinator.data is not None: + if ( + self.description.key == "avg_price" + and self._attr_native_value is not None + and self.coordinator.data is not None + ): self._attr_extra_state_attributes = { - "prices_today": self.coordinator.get_prices_today(), - "prices_tomorrow": self.coordinator.get_prices_tomorrow(), - "prices": self.coordinator.get_prices() - } + "prices_today": self.coordinator.get_prices_today(), + "prices_tomorrow": self.coordinator.get_prices_tomorrow(), + "prices": self.coordinator.get_prices(), + } except Exception as exc: - _LOGGER.warning(f"Unable to update attributes of the average entity, error: {exc}, data: {self.coordinator.data}") + _LOGGER.warning( + f"Unable to update attributes of the average entity, error: {exc}, data: {self.coordinator.data}" + ) @property def available(self) -> bool: diff --git a/custom_components/entsoe/services.py b/custom_components/entsoe/services.py index 6c1714d..369204e 100644 --- a/custom_components/entsoe/services.py +++ b/custom_components/entsoe/services.py @@ -2,13 +2,12 @@ from __future__ import annotations +import logging from datetime import date, datetime from functools import partial from typing import Final import voluptuous as vol -import logging - from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import ( HomeAssistant, @@ -63,20 +62,15 @@ def __get_date(date_input: str | None) -> date | datetime: def __serialize_prices(prices) -> ServiceResponse: """Serialize prices.""" - return { + return { "prices": [ - { - "timestamp": dt.isoformat(), - "price": price - } + {"timestamp": dt.isoformat(), "price": price} for dt, price in prices.items() ] } -def __get_coordinator( - hass: HomeAssistant, call: ServiceCall -) -> EntsoeCoordinator: +def __get_coordinator(hass: HomeAssistant, call: ServiceCall) -> EntsoeCoordinator: """Get the coordinator from the entry.""" entry_id: str = call.data[ATTR_CONFIG_ENTRY] entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) @@ -130,4 +124,4 @@ def async_setup_services(hass: HomeAssistant) -> None: partial(__get_prices, hass=hass), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, - ) \ No newline at end of file + ) From 3ded09d912564ced38730dbf21e24e273b5aa897 Mon Sep 17 00:00:00 2001 From: Roeland Date: Tue, 10 Sep 2024 23:07:52 +0200 Subject: [PATCH 09/10] update version number for release --- custom_components/entsoe/config_flow.py | 1 - custom_components/entsoe/manifest.json | 2 +- hacs.json | 4 +++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/entsoe/config_flow.py b/custom_components/entsoe/config_flow.py index d2a1014..254b709 100644 --- a/custom_components/entsoe/config_flow.py +++ b/custom_components/entsoe/config_flow.py @@ -22,7 +22,6 @@ from .const import ( AREA_INFO, CALCULATION_MODE, - COMPONENT_TITLE, CONF_ADVANCED_OPTIONS, CONF_API_KEY, CONF_AREA, diff --git a/custom_components/entsoe/manifest.json b/custom_components/entsoe/manifest.json index 49c8627..c269f8b 100644 --- a/custom_components/entsoe/manifest.json +++ b/custom_components/entsoe/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/JaccoR/hass-entso-e/issues", "requirements": ["requests"], - "version": "0.5.0" + "version": "0.5.0-beta1" } diff --git a/hacs.json b/hacs.json index ee10d95..ea2ab12 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,7 @@ { "name": "ENTSO-e Transparency Platform", "homeassistant": "2024.1.0", - "render_readme": true + "render_readme": true, + "zip_release": true, + "filename": "entsoe.zip" } From ae5a4d2070a381642ce7d4bd621d92dd51a6e0f4 Mon Sep 17 00:00:00 2001 From: Roeland Date: Wed, 11 Sep 2024 14:50:34 +0200 Subject: [PATCH 10/10] add translations for service --- custom_components/entsoe/coordinator.py | 4 +- custom_components/entsoe/services.py | 2 +- custom_components/entsoe/translations/en.json | 46 +++++++++++++------ custom_components/entsoe/translations/nl.json | 20 ++++++++ 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/custom_components/entsoe/coordinator.py b/custom_components/entsoe/coordinator.py index 6b13da0..c48ce9b 100644 --- a/custom_components/entsoe/coordinator.py +++ b/custom_components/entsoe/coordinator.py @@ -121,7 +121,7 @@ def check_update_needed(self, now): return True if len(self.get_data_today()) != 24: return True - if len(self.get_data_tomorrow()) != 24 and now.hour > 12: + if len(self.get_data_tomorrow()) != 24 and now.hour > 11: return True return False @@ -167,7 +167,7 @@ async def get_energy_prices(self, start_date, end_date): for k, v in self.data.items() if k.date() >= start_date.date() and k.date() <= end_date.date() } - return await self.fetch_prices(start_date, end_date) + return self.parse_hourprices(await self.fetch_prices(start_date, end_date)) def today_data_available(self): return len(self.get_data_today()) == 24 diff --git a/custom_components/entsoe/services.py b/custom_components/entsoe/services.py index 369204e..907f464 100644 --- a/custom_components/entsoe/services.py +++ b/custom_components/entsoe/services.py @@ -46,7 +46,7 @@ def __get_date(date_input: str | None) -> date | datetime: """Get date.""" if not date_input: - return dt_util.now().date() + return dt_util.now() if value := dt_util.parse_datetime(date_input): return value diff --git a/custom_components/entsoe/translations/en.json b/custom_components/entsoe/translations/en.json index 849a6a6..985b9a3 100644 --- a/custom_components/entsoe/translations/en.json +++ b/custom_components/entsoe/translations/en.json @@ -2,18 +2,18 @@ "config": { "step": { "user": { - "description": "Please add the ENTSO-e Transparency Platform API key and area", - "data": { - "api_key": "Your API Key", - "area": "Area*", - "advanced_options": "I want to set VAT, template and calculation method (next step)", - "modifyer": "Price Modifyer Template (Optional)", - "currency": "Currency of the modified price (Optional)", - "name": "Name (Optional)" - } + "description": "Please add the ENTSO-e Transparency Platform API key and area", + "data": { + "api_key": "Your API Key", + "area": "Area*", + "advanced_options": "I want to set VAT, template and calculation method (next step)", + "modifyer": "Price Modifyer Template (Optional)", + "currency": "Currency of the modified price (Optional)", + "name": "Name (Optional)" + } }, "extra": { - "data":{ + "data": { "VAT_value": "VAT tariff", "modifyer": "Price Modifyer Template (Optional)", "currency": "Currency of the modified price (Optional)" @@ -24,7 +24,7 @@ "invalid_template": "Invalid template, check https://github.com/JaccoR/hass-entso-e", "missing_current_price": "'current_price' is missing from the template, check https://github.com/JaccoR/hass-entso-e", "already_configured": "Integration instance with the same name already exists" - } + } }, "options": { "step": { @@ -44,6 +44,26 @@ "invalid_template": "Invalid Template, Check https://github.com/JaccoR/hass-entso-e", "missing_current_price": "'current_price' is missing from the template, check https://github.com/JaccoR/hass-entso-e", "already_configured": "Integration instance with the same name already exists" + } + }, + "services": { + "get_energy_prices": { + "name": "Get energy prices", + "description": "Request prices for a specified range from entso-e", + "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + }, + "start": { + "name": "Start", + "description": "Specifies the date and time from which to retrieve prices. Defaults to today if omitted." + }, + "end": { + "name": "End", + "description": "Specifies the date and time until which to retrieve prices. Defaults to today if omitted." + } + } + } } - } -} +} \ No newline at end of file diff --git a/custom_components/entsoe/translations/nl.json b/custom_components/entsoe/translations/nl.json index dfe1cbb..4d48511 100644 --- a/custom_components/entsoe/translations/nl.json +++ b/custom_components/entsoe/translations/nl.json @@ -34,5 +34,25 @@ "missing_current_price": "'current_price' is missing from the template", "already_configured": "Integration instance with the same name already exists" } + }, + "services": { + "get_energy_prices": { + "name": "Get energy prices", + "description": "Request prices for a specified range from entso-e", + "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + }, + "start": { + "name": "Start", + "description": "Specifies the date and time from which to retrieve prices. Defaults to today if omitted." + }, + "end": { + "name": "End", + "description": "Specifies the date and time until which to retrieve prices. Defaults to today if omitted." + } + } + } } } \ No newline at end of file