diff --git a/custom_components/solar_optimizer/config_schema.py b/custom_components/solar_optimizer/config_schema.py index 824eabd..7969717 100644 --- a/custom_components/solar_optimizer/config_schema.py +++ b/custom_components/solar_optimizer/config_schema.py @@ -86,6 +86,7 @@ vol.Optional(CONF_BATTERY_SOC_THRESHOLD, default=0): str, vol.Optional(CONF_MAX_ON_TIME_PER_DAY_MIN): str, vol.Optional(CONF_MIN_ON_TIME_PER_DAY_MIN): str, + vol.Optional(CONF_MIN_ENERGY_PER_DAY_KWH, default="0"): selector.NumberSelector(selector.NumberSelectorConfig(min=0.0, max=200.0, step=0.1, mode=selector.NumberSelectorMode.BOX)), vol.Optional(CONF_OFFPEAK_TIME): str, } ) @@ -128,6 +129,7 @@ vol.Optional(CONF_BATTERY_SOC_THRESHOLD, default=0): str, vol.Optional(CONF_MAX_ON_TIME_PER_DAY_MIN): str, vol.Optional(CONF_MIN_ON_TIME_PER_DAY_MIN): str, + vol.Optional(CONF_MIN_ENERGY_PER_DAY_KWH, default="0"): selector.NumberSelector(selector.NumberSelectorConfig(min=0.0, max=200.0, step=0.1, mode=selector.NumberSelectorMode.BOX)), vol.Optional(CONF_OFFPEAK_TIME): str, } ) diff --git a/custom_components/solar_optimizer/const.py b/custom_components/solar_optimizer/const.py index 74ac6b7..4bfaf1b 100644 --- a/custom_components/solar_optimizer/const.py +++ b/custom_components/solar_optimizer/const.py @@ -37,6 +37,7 @@ DEVICE_MANUFACTURER = "JM. COLLIN" SERVICE_RESET_ON_TIME = "reset_on_time" +SERVICE_RESET_ENERGY = "reset_energy" TIME_REGEX = r"^(?:[01]\d|2[0-3]):[0-5]\d$" CONFIG_VERSION = 2 @@ -79,6 +80,7 @@ CONF_MAX_ON_TIME_PER_DAY_MIN = "max_on_time_per_day_min" CONF_MIN_ON_TIME_PER_DAY_MIN = "min_on_time_per_day_min" CONF_OFFPEAK_TIME = "offpeak_time" +CONF_MIN_ENERGY_PER_DAY_KWH = "min_energy_per_day_kwh" PRIORITY_WEIGHT_NULL = "None" PRIORITY_WEIGHT_LOW = "Low" diff --git a/custom_components/solar_optimizer/managed_device.py b/custom_components/solar_optimizer/managed_device.py index eaaccf2..c81c7d5 100644 --- a/custom_components/solar_optimizer/managed_device.py +++ b/custom_components/solar_optimizer/managed_device.py @@ -190,6 +190,9 @@ def __init__(self, hass: HomeAssistant, device_config, coordinator): self._min_on_time_per_day_min = convert_to_template_or_value(hass, device_config.get("min_on_time_per_day_min") or 0) + self._min_energy_per_day_kwh = convert_to_template_or_value(hass, device_config.get("min_energy_per_day_kwh") or 0) + self._on_time_energy = 0 + offpeak_time = device_config.get("offpeak_time", None) self._offpeak_time = None @@ -210,6 +213,12 @@ def __init__(self, hass: HomeAssistant, device_config, coordinator): _LOGGER.error("%s - %s", self, msg) raise ConfigurationError(msg) + # min_energy_per_day_kwh requires an offpeak_time + if self._min_energy_per_day_kwh > 0 and self._offpeak_time is None: + msg = f"configuration of device ${self.name} is incorrect. min_energy_per_day_kwh requires offpeak_time value" + _LOGGER.error("%s - %s", self, msg) + raise ConfigurationError(msg) + if self.min_on_time_per_day_sec > self.max_on_time_per_day_sec: msg = f"configuration of device ${self.name} is incorrect. min_on_time_per_day_sec should < max_on_time_per_day_sec" _LOGGER.error("%s - %s", self, msg) @@ -361,6 +370,27 @@ def set_on_time(self, on_time_sec: int): _LOGGER.info("%s - Set on_time=%s", self.name, on_time_sec) self._on_time_sec = on_time_sec + def set_on_time_energy(self, on_time_energy: float): + """Set the time the underlying device was on per day""" + _LOGGER.info("%s - Set on_time_energy=%s", self.name, on_time_energy) + self._on_time_energy = on_time_energy + + def update_on_time_energy(self, duration_sec: int = 60): + """Update the on_time_energy based on current power and duration""" + if self.is_active: + # Power is in Watts, duration in seconds. Convert to kWh. + energy_kwh = (self._current_power * duration_sec) / 3_600_000 + self._on_time_energy += energy_kwh + _LOGGER.debug( + "%s - Updated on_time_energy: +%.6f kWh (current_power=%dW, duration=%ds, total=%.6f kWh)", + self._name, energy_kwh, self._current_power, duration_sec, self._on_time_energy + ) + + def reset_on_time_energy(self): + """Reset the on_time_energy counter (e.g., at the start of a new day).""" + self._on_time_energy = 0 + _LOGGER.info("%s - on_time_energy reset to 0", self._name) + def set_requested_power(self, requested_power: int): """Set the requested power of the ManagedDevice""" self._requested_power = requested_power @@ -428,14 +458,20 @@ def should_be_forced_offpeak(self) -> bool: return ( (self.now.time() >= self._offpeak_time or self.now.time() < self._coordinator.raz_time) and self._on_time_sec < self.max_on_time_per_day_sec - and self._on_time_sec < self.min_on_time_per_day_sec + and ( + self._on_time_energy < self._min_energy_per_day_kwh + or self._on_time_sec < self.min_on_time_per_day_sec + ) ) else: return ( self.now.time() >= self._offpeak_time and self.now.time() < self._coordinator.raz_time and self._on_time_sec < self.max_on_time_per_day_sec - and self._on_time_sec < self.min_on_time_per_day_sec + and ( + self._on_time_energy < self._min_energy_per_day_kwh + or self._on_time_sec < self.min_on_time_per_day_sec + ) ) @property @@ -538,6 +574,16 @@ def min_on_time_per_day_sec(self) -> int: """The min_on_time_per_day_sec configured""" return get_template_or_value(self._hass, self._min_on_time_per_day_min) * 60 + @property + def min_energy_per_day_kwh(self) -> float: + """The min_energy_per_day_kwh configured""" + return get_template_or_value(self._hass, self._min_energy_per_day_kwh) + + @property + def on_time_energy(self) -> float: + """The current energy put in device this day""" + return self._on_time_energy + @property def offpeak_time(self) -> int: """The offpeak_time configured""" diff --git a/custom_components/solar_optimizer/sensor.py b/custom_components/solar_optimizer/sensor.py index 0b90660..a1b92a0 100644 --- a/custom_components/solar_optimizer/sensor.py +++ b/custom_components/solar_optimizer/sensor.py @@ -5,6 +5,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfTime, + UnitOfEnergy, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_ON, @@ -85,6 +86,14 @@ async def async_setup_entry( async_add_entities([entity1], False) + entity2 = TodayEnergySensor( + hass, + coordinator, + device, + ) + + async_add_entities([entity2], False) + # Add services platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -93,6 +102,13 @@ async def async_setup_entry( "service_reset_on_time", ) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_RESET_ENERGY, + {}, + "service_reset_energy", + ) + class SolarOptimizerSensorEntity(CoordinatorEntity, SensorEntity): """The entity holding the algorithm calculation""" @@ -404,3 +420,238 @@ async def service_reset_on_time(self): """ _LOGGER.info("%s - Calling service_reset_on_time", self) await self._on_midnight() + +class TodayEnergySensor(SensorEntity, RestoreEntity): + """Gives the estimated energy put in device for a day""" + + _entity_component_unrecorded_attributes = ( + SensorEntity._entity_component_unrecorded_attributes.union( + frozenset( + { + "last_calculation", + "offpeak_time", + "min_energy_per_day_kwh", + "on_time_energy", + "raz_time", + "should_be_forced_offpeak", + } + ) + ) + ) + + def __init__( + self, + hass: HomeAssistant, + coordinator: SolarOptimizerCoordinator, + device: ManagedDevice, + ) -> None: + """Initialize the sensor""" + self.hass = hass + idx = name_to_unique_id(device.name) + self._attr_name = "Energy today" + self._attr_has_entity_name = True + self.entity_id = f"{SENSOR_DOMAIN}.energy_today_solar_optimizer_{idx}" + self._attr_unique_id = "solar_optimizer_energy_today_" + idx + self._attr_native_value = None + self._entity_id = device.entity_id + self._device = device + self._coordinator = coordinator + self._last_calculation = self._device.now + self._old_state = None + + async def async_added_to_hass(self) -> None: + """The entity have been added to hass, listen to state change of the underlying entity""" + await super().async_added_to_hass() + + # Arme l'écoute de la première entité + listener_cancel = async_track_state_change_event( + self.hass, + [self._entity_id], + self._on_state_change, + ) + # desarme le timer lors de la destruction de l'entité + self.async_on_remove(listener_cancel) + + # Add listener to midnight to reset the counter + raz_time: time = self._coordinator.raz_time + self.async_on_remove( + async_track_time_change( + hass=self.hass, + action=self._on_midnight, + hour=raz_time.hour, + minute=raz_time.minute, + second=0, + ) + ) + + # Add a listener to calculate energy at each minute + self.async_on_remove( + async_track_time_interval( + self.hass, + self._on_update_energy_today, + interval=timedelta(minutes=1), + ) + ) + + # restore the last value or set to 0 + self._attr_native_value = 0 + old_state = await self.async_get_last_state() + if old_state is not None: + if old_state.state is not None and old_state.state != "unknown": + self._attr_native_value = round(float(old_state.state)) + _LOGGER.info( + "%s - read energy from storage is %s", + self, + self._attr_native_value, + ) + + old_value = old_state.attributes.get("last_calculation") + if old_value is not None: + self._last_calculation = datetime.fromisoformat(old_value) + + self.update_custom_attributes() + self.async_write_ha_state() + + async def async_will_remove_from_hass(self): + """Try to force backup of entity""" + _LOGGER.info( + "%s - force write before remove. energy_today is %s", + self, + self._attr_native_value, + ) + # Force dump in background + await restore_async_get(self.hass).async_dump_states() + + @callback + async def _on_state_change(self, event: Event) -> None: + """The entity have change its state""" + now = self._device.now + _LOGGER.info("Call of on_state_change at %s with event %s", now, event) + + if not event.data: + return + + new_state: State = event.data.get("new_state") + # old_state: State = event.data.get("old_state") + + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + _LOGGER.debug("No available state. Event is ignored") + return + + need_save = False + # We search for the date of the event + new_state = self._device.is_active # new_state.state == STATE_ON + # old_state = old_state is not None and old_state.state == STATE_ON + if new_state and not self._old_state: + _LOGGER.debug("The managed device becomes on - store the last_calculation and compute energy") + interval = (now - self._last_calculation).total_seconds() + self._device.update_on_time_energy(interval) + self._last_calculation = now + need_save = True + + if not new_state: + if self._old_state and self._last_calculation is not None: + _LOGGER.debug("The managed device becomes off - compute energy") + self._attr_native_value += self._device.on_time_energy + self._last_calculation = now + need_save = True + + # On sauvegarde le nouvel état + if need_save: + self._old_state = new_state + self.update_custom_attributes() + self.async_write_ha_state() + + @callback + async def _on_midnight(self, _=None) -> None: + """Called each day at midnight to reset the counter""" + self._attr_native_value = 0 + + _LOGGER.info("Call of _on_midnight to reset onTime") + + # reset _last_datetime_on to now if it was active. Here we lose the time on of yesterday but it is too late I can't do better. + # Else you will have two point with the same date and not the same value (one with value + duration and one with 0) + if self._last_calculation is not None: + self._last_calculation = self._device.now + + self._device.reset_on_time_energy() + + self.update_custom_attributes() + self.async_write_ha_state() + + @callback + async def _on_update_energy_today(self, _=None) -> None: + """Called priodically to update the energy_today sensor""" + now = self._device.now + _LOGGER.debug("Call of _on_update_energy_today at %s", now) + + if self._last_calculation is not None and self._device.is_active: + interval = (now - self._last_calculation).total_seconds() + self._attr_native_value += round(interval) + self._device.update_on_time_energy(interval) + self._last_calculation = now + self.update_custom_attributes() + self.async_write_ha_state() + + def update_custom_attributes(self): + """Add some custom attributes to the entity""" + self._attr_extra_state_attributes: dict(str, str) = { + "last_calculation": self._last_calculation, + "raz_time": self._coordinator.raz_time, + "should_be_forced_offpeak": self._device.should_be_forced_offpeak, + "offpeak_time": self._device.offpeak_time, + "min_energy_per_day_kwh": self._device.min_energy_per_day_kwh, + "on_time_energy": self._device.on_time_energy, + } + + @property + def icon(self) -> str | None: + return "mdi:timer-play" + + @property + def device_info(self) -> DeviceInfo | None: + # Retournez des informations sur le périphérique associé à votre entité + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._device.name)}, + name="Solar Optimizer-" + self._device.name, + manufacturer=DEVICE_MANUFACTURER, + model=DEVICE_MODEL, + ) + + @property + def device_class(self) -> SensorDeviceClass | None: + return SensorDeviceClass.ENERGY + + @property + def state_class(self) -> SensorStateClass | None: + return SensorStateClass.TOTAL + + @property + def native_unit_of_measurement(self) -> str | None: + return UnitOfEnergy.KILO_WATT_HOUR + + @property + def suggested_display_precision(self) -> int | None: + """Return the suggested number of decimal digits for display.""" + return 0 + + @property + def last_calculation(self) -> datetime | None: + """Returns the last_calculation""" + return self._last_calculation + + @property + def get_attr_extra_state_attributes(self): + """Get the extra state attributes for the entity""" + return self._attr_extra_state_attributes + + async def service_reset_energy(self): + """Called by a service call: + service: sensor.reset_energy + data: + target: + entity_id: solar_optimizer.energy_today_solar_optimizer_ + """ + _LOGGER.info("%s - Calling service_reset_energy", self) + await self._on_midnight() diff --git a/custom_components/solar_optimizer/strings.json b/custom_components/solar_optimizer/strings.json index 50bffc1..edac794 100644 --- a/custom_components/solar_optimizer/strings.json +++ b/custom_components/solar_optimizer/strings.json @@ -102,7 +102,8 @@ "battery_soc_threshold": "Battery soc threshold", "max_on_time_per_day_min": "Max on time per day", "min_on_time_per_day_min": "Min on time per day", - "offpeak_time": "Offpeak time" + "offpeak_time": "Offpeak time", + "min_energy_per_day_kwh": "Mininal Energy per day" }, "data_description": { "name": "The name of the device", @@ -124,7 +125,8 @@ "battery_soc_threshold": "The battery state of charge threshold to activate the device. Can be a number or a template", "max_on_time_per_day_min": "The maximum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time until max time is reached", "min_on_time_per_day_min": "The minimum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time", - "offpeak_time": "The offpeak time with format HH:MM" + "offpeak_time": "The offpeak time with format HH:MM", + "min_energy_per_day_kwh": "The minimum energy to put in device in kWh. If minium energy is not reached during daylight, it will be activated at max power during offpeak time until max time is reached" } } }, diff --git a/custom_components/solar_optimizer/translations/en.json b/custom_components/solar_optimizer/translations/en.json index 656b840..4e2b880 100644 --- a/custom_components/solar_optimizer/translations/en.json +++ b/custom_components/solar_optimizer/translations/en.json @@ -102,7 +102,8 @@ "battery_soc_threshold": "Battery soc threshold", "max_on_time_per_day_min": "Max on time per day", "min_on_time_per_day_min": "Min on time per day", - "offpeak_time": "Offpeak time" + "offpeak_time": "Offpeak time", + "min_energy_per_day_kwh": "Mininal Energy per day" }, "data_description": { "name": "The name of the device", @@ -124,7 +125,8 @@ "battery_soc_threshold": "The battery state of charge threshold to activate the device. Can be a number or a template", "max_on_time_per_day_min": "The maximum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time until max time is reached", "min_on_time_per_day_min": "The minimum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time", - "offpeak_time": "The offpeak time with format HH:MM" + "offpeak_time": "The offpeak time with format HH:MM", + "min_energy_per_day_kwh": "The minimum energy to put in device in kWh. If minium energy is not reached during daylight, it will be activated at max power during offpeak time until max time is reached" } } }, diff --git a/custom_components/solar_optimizer/translations/fr.json b/custom_components/solar_optimizer/translations/fr.json index 94f1006..0bfd38d 100644 --- a/custom_components/solar_optimizer/translations/fr.json +++ b/custom_components/solar_optimizer/translations/fr.json @@ -102,7 +102,8 @@ "battery_soc_threshold": "Seuil de charge de la batterie", "max_on_time_per_day_min": "Temps max par jour", "min_on_time_per_day_min": "Temps min par jour", - "offpeak_time": "Heure creuse" + "offpeak_time": "Heure creuse", + "min_energy_per_day_kwh": "Energie minimale par jour" }, "data_description": { "name": "Nom de l'équipement", @@ -124,7 +125,8 @@ "battery_soc_threshold": "Le seuil de charge de la batterie pour activer l'équipement", "max_on_time_per_day_min": "Le temps maximum par jour en état allumé en minutes. Si le temps minimum n'est pas atteint pendant la journée, il sera activé pendant l'heure creuse à concurrence du temps maximum", "min_on_time_per_day_min": "Le temps minimum par jour en état allumé en minutes. Si il n'est pas atteint, l'équipement sera activé pendant l'heure creuse", - "offpeak_time": "L'heure de début des heures creuses au format HH:MM" + "offpeak_time": "L'heure de début des heures creuses au format HH:MM", + "min_energy_per_day_kwh": "La quantité d'énergie par jour en kWh. Si il n'est pas atteint, l'équipement sera activé à la puissance maximale pendant l'heure creuse." } } },