diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index ec42294..06bcd87 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -162,6 +162,8 @@ solar_optimizer: activation_service: "input_boolean/turn_on" deactivation_service: "input_boolean/turn_off" max_on_time_per_day_min: 10 + min_on_time_per_day_min: 5 + offpeak_time: "14:00" - name: "Equipement B" entity_id: "input_boolean.fake_device_b" power_max: 500 @@ -170,6 +172,8 @@ solar_optimizer: action_mode: "service_call" activation_service: "input_boolean/turn_on" deactivation_service: "input_boolean/turn_off" + min_on_time_per_day_min: 15 + offpeak_time: "13:50" - name: "Equipement C" entity_id: "input_boolean.fake_device_c" power_max: 800 diff --git a/README-fr.md b/README-fr.md index d9959a5..210385f 100644 --- a/README-fr.md +++ b/README-fr.md @@ -24,6 +24,8 @@ > ![Nouveau](https://github.com/jmcollin78/solar_optimizer/blob/main/images/new-icon.png?raw=true) _*Nouveautés*_ +> * **release 2.1.0** : +> - ajout d'une durée minimale d'allumage en heure creuses. Permet de gérer les équipements qui doivent avoir un minimum d'allumage par jour comme les chauffes-eau ou les chargeurs (voitures, batteries, ……). Si l'ensoleillement n'a pas durée d'atteindre la durée requise, alors l'équipement s'allumera pendant les heures creuses. Vous pouvez en plus définir à quelle heure les compteurs d'allumage sont remis à zéro ce qui permet de profiter des toutes les heures creuses > * **release 2.0.0** : > - ajout d'un appareil (device) par équipement piloté pour regrouper les entités, > - ajout d'un compteur de temps d'allumage pour chaque appareil. Lorsque le switch commandé passe à 'Off', le compteur de temps est incrémenté du temps passé à 'On', en secondes. Ce compteur est remis à zéro tous les jours à minuit. @@ -70,7 +72,9 @@ Si une batterie est spécifiée lors du paramétrage de l'intégration et si le Un temps d'utilisation maximal journalier est paramétrable en facultatif. Si il est valorisé et si la durée d'utilisation de l'équipement est dépasée, alors l'équipement ne sera pas utilisable par l'algorithme et laisse donc de la puissance pour les autres équipements. -Ces 4 règles permettent à l'algorithme de ne commander que ce qui est réellement utile à un instant t. Ces règles sont ré-évaluées à chaque cycle. +Un temps d'utilisation minimal journalier est aussi paramétrable en facultatif. Ce paramètre permet d'assurer que l'équipement sera allumé pendant une certaine durée minimale. Vous spécifiez à quelle heure commence les heures creuses, (`offpeak_time`) et la durée minimale en minutes (`min_on_time_per_day_min`). Si à l'heure indiquée par `offpeak_time`, la durée minimale d'activation n'a pas été atteinte, alors l'équipement est activé jusqu'au changement de jour (paramètrable dans l'intégration et 05:00 par défaut) ou jusqu'à ce que le maximum d'utilisation soit atteint (`max_on_time_per_day_min`) ou pendant toute la durée des heures creuses si `max_on_time_per_day_min` n'est pas précisé. Vous assurez ainsi que le chauffe-eau ou la voiture sera chargée le lendemain matin même si la production solaire n'a pas permise de recharger l'appareil. A vous d'inventer les usages de cette fonction. + +Ces 5 règles permettent à l'algorithme de ne commander que ce qui est réellement utile à un instant t. Ces règles sont ré-évaluées à chaque cycle. # Comment on l'installe ? ## HACS installation (recommendé) @@ -92,8 +96,10 @@ Vous devez spécifier : 1. le sensor qui donne la consommation nette instantanée du logement (elle doit être négative si la production dépasse la consommation). Ce chiffre est indiqué en Watt, 2. le sensor qui donne la production photovoltaïque instantanée en Watt aussi, 3. un sensor ou input_number qui donne le cout du kwh importée, -3. un sensor ou input_number qui donne le prix du kwh exortée (dépend de votre contrat), -3. un sensor ou input_number qui donne la taxe applicable sur les kwh exortée (dépend de votre contrat) +4. un sensor ou input_number qui donne le prix du kwh exortée (dépend de votre contrat), +5. un sensor ou input_number qui donne la taxe applicable sur les kwh exortée (dépend de votre contrat) +6. l'heure de début de journée. A cette heure les compteurs d'uitlisation des équipements sont remis à zéro. La valeur par défaut est 05:00. Elle doit être avant la première production et le plus tard possible pour les activations en heures creuses. Cf. ci-dessus. + Ces 5 informations sont nécessaires à l'algorithme pour fonctionner, elles sont donc toutes obligatoires. Le fait que ce soit des sensor ou input_number permet d'avoir des valeurs qui sont réévaluées à chaque cycle. En conséquence le passage en heure creuse peut modifier le calcul et donc les états des équipements puisque l'import devient moins cher. Donc tout est dynamique et recalculé à chaque cycle. @@ -121,6 +127,8 @@ devices: deactivation_service: "switch/turn_off" battery_soc_threshold: max_on_time_per_day_min: + offpeak_time: + min_on_day_per_day_min: ``` Note: les paramètres sous `algorithm` ne doivent pas être touchés sauf si vous savez exactement ce que vous faites. @@ -140,6 +148,8 @@ Sous `devices` il faut déclarer tous les équipements qui seront commandés par | `deactivation_service` | uniquement si action_mode="service_call" | le service a appeler pour désactiver l'équipement sous la forme "domain/service" | "switch/turn_off" | la désactivation déclenchera le service "switch/turn_off" sur l'entité "entity_id" | | `battery_soc_threshold` | tous | le pourcentage minimal de charge de la batterie pour que l'équipement soit utilisable | 30 | | | `max_on_time_per_day_min` | tous | le nombre de minutes maximal en position allumé pour cet équipement. Au delà, l'équipement n'est plus utilisable par l'algorithme | 10 | L'équipement est sera allumé au maximum 10 minutes par jour | +| `offpeak_time` | tous | L'heure de début des heures creuses au format hh:mm | 22:00 | L'équipement pourra être allumé à 22h00 si la production de la journeé n'a pas été suffisante | +| `min_on_time_per_day_min` | tous | le nombre de minutes minimale en position allumé pour cet équipement. Si lors du démarrage des heures creuses, ce minimum n'est pas atteint alors l'équipement sera allumé à concurrence du début de journée ou du `max_on_time_per_day_min` | 5 | L'équipement est sera allumé au minimum 5 minutes par jour | Pour les équipements à puissance variable, les attributs suivants doivent être valorisés : @@ -175,6 +185,10 @@ devices: battery_soc_threshold: 10 # Une heure par jour maximum max_on_time_per_day_min: 60 + # 1/2h par jour minimum ... + min_on_time_per_day_min: 30 + # ... à partir de 22:30 + offpeak_time: "22:30" - name: "Recharge Tesla" entity_id: "switch.testla_charger" @@ -204,6 +218,10 @@ devices: convert_power_divide_factor: 660 # On ne démarre pas une charge si la batterie de l'installation solaire n'est pas chargée à au moins 50% battery_soc_threshold: 50 + # 4h par jour minimum ... + min_on_time_per_day_min: 240 + # ... à partir de 23:00 + offpeak_time: "22:00" ... ``` Tout changement dans la configuration nécessite un arrêt / relance de l'intégration (ou de Home Assistant) pour être pris en compte. diff --git a/README.md b/README.md index 87a6cd1..d7d0de4 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ >![New](https://github.com/jmcollin78/solar_optimizer/blob/main/images/new-icon.png?raw=true) _*News*_ -> * **release 2.0.0** : +> * **release 2.1.0** : +> - added a minimum duration of ignition during off-peak hours. Allows you to manage equipment that must have a minimum of ignition per day such as water heaters or chargers (cars, battery, ...). If the sunshine has not reached the required duration, then the equipment will turn on during off-peak hours. You can also define at what time the ignition counters are reset to zero, which allows you to take advantage of all off-peak hours> * **release 2.0.0** : > - added a device per controlled equipment to group the entities, > - added an ignition time counter for each device. When the controlled switch goes to 'Off', the time counter is incremented by the time spent 'On', in seconds. This counter is reset to zero every day at midnight. > - added a maximum time to 'On' in the configuration (in minutes). When this duration is exceeded, the equipment is no longer usable by the algorithm (is_usable = off) until the next reset. This offers the possibility of not exceeding a maximum ignition time per day, even when solar power is available. @@ -70,7 +71,9 @@ If a battery is specified when configuring the integration and if the threshold A maximum daily usage time is optionally configurable. If it is valued and if the duration of use of the equipment is exceeded, then the equipment will not be usable by the algorithm and therefore leaves power for other equipment. -These 4 rules allow the algorithm to only order what is really useful at a time t. These rules are re-evaluated at each cycle. +A minimum daily usage time is also optionally configurable. This parameter ensures that the equipment will be on for a certain minimum duration. You specify at what time the off-peak hours start (`offpeak_time`) and the minimum duration in minutes (`min_on_time_per_day_min`). If at the time indicated by `offpeak_time`, the minimum activation duration has not been reached, then the equipment is activated until the change of day (configurable in the integration and 05:00 by default) or until the maximum usage is reached (`max_on_time_per_day_min`) or during all the off-peak hours if `max_on_time_per_day_min` is not set. This ensures that the water heater or the car will be charged the next morning even if the solar production has not allowed the device to be recharged. It is up to you to invent the uses of this function. + +These 5 rules allow the algorithm to only order what is really useful at a time t. These rules are re-evaluated at each cycle. # How do we install it? ## HACS installation (recommended) @@ -92,8 +95,9 @@ You must specify: 1. the sensor which gives the instantaneous net consumption of the dwelling (it must be negative if production exceeds consumption). This figure is indicated in Watt, 2. the sensor which gives the instantaneous photovoltaic production in Watt too, 3. a sensor or input_number which gives the cost of the imported kwh, -3. a sensor or input_number which gives the price of the exported kwh (depends on your contract), -3. a sensor or input_number which gives the applicable tax on the exported kwh (depends on your contract) +4. a sensor or input_number which gives the price of the exported kwh (depends on your contract), +5. a sensor or input_number which gives the applicable tax on the exported kwh (depends on your contract) +6. the start time of the day. At this time the equipment usage counters are reset to zero. The default value is 05:00. It must be before the first production and as late as possible for activations during off-peak hours. See above. These 5 pieces of information are necessary for the algorithm to work, so they are all mandatory. The fact that they are sensors or input_number allows to have values that are re-evaluated at each cycle. Consequently, switching to off-peak hours can modify the calculation and therefore the states of the equipment since the import becomes less expensive. So everything is dynamic and recalculated at each cycle. @@ -121,6 +125,8 @@ devices: deactivation_service: "" battery_soc_threshold: max_on_time_per_day_min: + offpeak_time: + min_on_day_per_day_min: ``` Note: parameters under `algorithm` should not be touched unless you know exactly what you are doing. @@ -140,6 +146,8 @@ Under `devices` you must declare all the equipment that will be controlled by So | `deactivation_service` | only if action_mode="service_call" | the service to call to deactivate the equipment in the form "domain/service" | "switch/turn_off" | deactivation will trigger the "switch/turn_off" service on the entity "entity_id" | | `battery_soc_threshold` | tous | minimal percentage of charge of the solar battery to enable this device | 30 | | | `max_on_time_per_day_min` | all | the maximum number of minutes in the on position for this equipment. Beyond that, the equipment is no longer usable by the algorithm | 10 | The equipment will be on for a maximum of 10 minutes per day | +| `offpeak_time` | all | The start time of off-peak hours in hh:mm format | 22:00 | The equipment can be switched on at 22:00 if the production of the day has not been sufficient | +| `min_on_time_per_day_min` | all | the minimum number of minutes in the on position for this equipment. If at the start of off-peak hours, this minimum is not reached then the equipment will be switched on up to the start of the day or the `max_on_time_per_day_min` | 5 | The equipment will be switched on for a minimum of 5 minutes per day | For variable power equipment, the following attributes must be valued: @@ -174,7 +182,11 @@ devices: # We authorize the pump to start if there is 10% battery in the solar installation battery_soc_threshold: 10 # One hour per day maximum - max_on_time_per_day_min: 60 + max_on_time_per_day_min: 60 + # 1/2h per day minimum ... + min_on_time_per_day_min: 30 + # ... starting at 22:30 + offpeak_time: "22:30" - name: "Tesla Recharge" entity_id: "switch.cloucloute_charger" @@ -204,6 +216,10 @@ devices: convert_power_divide_factor: 660 # We do not start a charge if the battery of the solar installation is not at least 50% charged battery_soc_threshold: 50 + # 4h par day minimum ... + min_on_time_per_day_min: 240 + # ... starting at 23:00 + offpeak_time: "22:00" ... ``` Any change in the configuration requires a stop / restart of the integration (or of Home Assistant) to be taken into account. diff --git a/custom_components/solar_optimizer/__init__.py b/custom_components/solar_optimizer/__init__.py index 0dff275..7f35d94 100644 --- a/custom_components/solar_optimizer/__init__.py +++ b/custom_components/solar_optimizer/__init__.py @@ -7,6 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component + from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import ConfigType import homeassistant.helpers.config_validation as cv @@ -29,7 +30,7 @@ # from homeassistant.helpers.entity_component import EntityComponent -from .const import DOMAIN, PLATFORMS +from .const import DOMAIN, PLATFORMS, SERVICE_RESET_ON_TIME, validate_time_format from .coordinator import SolarOptimizerCoordinator # from .input_boolean import async_setup_entry as async_setup_entry_input_boolean @@ -90,6 +91,8 @@ "battery_soc_threshold", default=0 ): vol.Coerce(float), vol.Optional("max_on_time_per_day_min"): vol.Coerce(int), + vol.Optional("min_on_time_per_day_min"): vol.Coerce(int), + vol.Optional("offpeak_time"): validate_time_format, } ] ), diff --git a/custom_components/solar_optimizer/config_flow.py b/custom_components/solar_optimizer/config_flow.py index c3a1eb0..b6d34f1 100644 --- a/custom_components/solar_optimizer/config_flow.py +++ b/custom_components/solar_optimizer/config_flow.py @@ -3,7 +3,6 @@ import logging import voluptuous as vol - from homeassistant.core import callback from homeassistant.config_entries import ( ConfigFlow, @@ -15,11 +14,13 @@ from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.helpers import selector import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import HomeAssistantError -from .const import DOMAIN +from .const import DOMAIN, validate_time_format, DEFAULT_RAZ_TIME _LOGGER = logging.getLogger(__name__) + solar_optimizer_schema = { vol.Required("refresh_period_sec", default=300): int, vol.Required("power_consumption_entity_id"): selector.EntitySelector( @@ -41,6 +42,7 @@ vol.Optional("battery_soc_entity_id"): selector.EntitySelector( selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]) ), + vol.Optional("raz_time", default=DEFAULT_RAZ_TIME): str, } @@ -67,6 +69,19 @@ async def async_step_user(self, user_input: dict | None = None) -> FlowResult: ) return self.async_show_form(step_id="user", data_schema=user_form) + try: + validate_time_format(user_input.get("raz_time")) + except vol.Invalid: + errors = {"raz_time": "format_time_invalid"} + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=user_form, + suggested_values=user_input, + ), + errors=errors, + ) + # 2ème appel : il y a des user_input -> on stocke le résultat self._user_inputs.update(user_input) _LOGGER.debug( @@ -82,6 +97,10 @@ def async_get_options_flow(config_entry: ConfigEntry): """Get options flow for this handler""" return SolarOptimizerOptionsFlow(config_entry) + async def validate_input(self, data: dict) -> None: + """Validate the user input allows us to connect.""" + validate_time_format(data.get("raz_time")) + class SolarOptimizerOptionsFlow(OptionsFlow): """The class which enable to modified the configuration""" @@ -94,6 +113,8 @@ def __init__(self, config_entry: ConfigEntry) -> None: self.config_entry = config_entry # On initialise les user_inputs avec les données du configEntry self._user_inputs = config_entry.data.copy() + if self._user_inputs.get("raz_time") is None: + self._user_inputs["raz_time"] = DEFAULT_RAZ_TIME async def async_step_init(self, user_input: dict | None = None) -> FlowResult: """Gestion de l'étape 'user'. Point d'entrée de notre @@ -115,6 +136,20 @@ async def async_step_init(self, user_input: dict | None = None) -> FlowResult: ), ) + try: + validate_time_format(user_input.get("raz_time")) + except vol.Invalid: + errors = {"raz_time": "format_time_invalid"} + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + data_schema=user_form, + suggested_values=user_input, + ), + errors=errors, + ) + # 2ème appel : il y a des user_input -> on stocke le résultat self._user_inputs.update(user_input) _LOGGER.debug( @@ -140,3 +175,10 @@ async def async_end(self): ) # Suppression de l'objet options dans la configEntry return self.async_create_entry(title=None, data=None) + + def validate_input(self, data: dict) -> None: + """Validate the user input allows us to connect.""" + try: + validate_time_format(data.get("raz_time")) + except vol.Invalid as err: + raise HomeAssistantError("raz_time") from err diff --git a/custom_components/solar_optimizer/const.py b/custom_components/solar_optimizer/const.py index 038c6bf..be1dd90 100644 --- a/custom_components/solar_optimizer/const.py +++ b/custom_components/solar_optimizer/const.py @@ -1,5 +1,8 @@ """ Les constantes pour l'intégration Solar Optimizer """ +import re + from slugify import slugify +from voluptuous.error import Invalid from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -9,6 +12,7 @@ PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] DEFAULT_REFRESH_PERIOD_SEC = 300 +DEFAULT_RAZ_TIME = "05:00" CONF_ACTION_MODE_SERVICE = "service_call" CONF_ACTION_MODE_EVENT = "event" @@ -26,6 +30,10 @@ INTEGRATION_MODEL = "Solar Optimizer" DEVICE_MANUFACTURER = "JM. COLLIN" +SERVICE_RESET_ON_TIME = "reset_on_time" + +TIME_REGEX = r"^(?:[01]\d|2[0-3]):[0-5]\d$" + def get_tz(hass: HomeAssistant): """Get the current timezone""" @@ -49,6 +57,13 @@ def seconds_to_hms(seconds): return f"{minutes}:{secs:02d}" +def validate_time_format(value: str) -> str: + """check is a string have format "hh:mm" with hh between 00 and 23 and mm between 00 et 59""" + if not re.match(TIME_REGEX, value): + raise Invalid("The time value should be formatted like 'hh:mm'") + return value + + class ConfigurationError(Exception): """An error in configuration""" diff --git a/custom_components/solar_optimizer/coordinator.py b/custom_components/solar_optimizer/coordinator.py index aee5e71..dc2de75 100644 --- a/custom_components/solar_optimizer/coordinator.py +++ b/custom_components/solar_optimizer/coordinator.py @@ -1,7 +1,7 @@ """ The data coordinator class """ import logging import math -from datetime import timedelta +from datetime import datetime, timedelta, time from homeassistant.core import HomeAssistant # callback @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry -from .const import DEFAULT_REFRESH_PERIOD_SEC, name_to_unique_id +from .const import DEFAULT_REFRESH_PERIOD_SEC, name_to_unique_id, DEFAULT_RAZ_TIME from .managed_device import ManagedDevice from .simulated_annealing_algo import SimulatedAnnealingAlgorithm @@ -40,6 +40,7 @@ class SolarOptimizerCoordinator(DataUpdateCoordinator): _battery_soc_entity_id: str _smooth_production: bool _last_production: float + _raz_time: time _algo: SimulatedAnnealingAlgorithm @@ -55,7 +56,7 @@ def __init__(self, hass: HomeAssistant, config): try: for _, device in enumerate(config.get("devices")): _LOGGER.debug("Configuration of manageable device: %s", device) - self._devices.append(ManagedDevice(hass, device)) + self._devices.append(ManagedDevice(hass, device, self)) except Exception as err: _LOGGER.error(err) _LOGGER.error( @@ -91,6 +92,10 @@ async def configure(self, config: ConfigEntry) -> None: self._smooth_production = config.data.get("smooth_production") is True self._last_production = 0.0 + self._raz_time = datetime.strptime( + config.data.get("raz_time") or DEFAULT_RAZ_TIME, "%H:%M" + ).time() + # Do not calculate immediatly because switch state are not restored yet. Wait for homeassistant_started event # which is captured in onHAStarted method # await self.async_config_entry_first_refresh() @@ -175,11 +180,14 @@ async def _async_update_data(self): if not device: continue is_active = device.is_active - if is_active and not state: + should_force_offpeak = device.should_be_forced_offpeak + if should_force_offpeak: + _LOGGER.debug("%s - we should force %s name", self, name) + if is_active and not state and not should_force_offpeak: _LOGGER.debug("Extinction de %s", name) should_log = True await device.deactivate() - elif not is_active and state: + elif not is_active and (state or should_force_offpeak): _LOGGER.debug("Allumage de %s", name) should_log = True await device.activate(requested_power) @@ -226,3 +234,8 @@ def get_device_by_unique_id(self, uid: str) -> ManagedDevice | None: if device.unique_id == uid: return device return None + + @property + def raz_time(self) -> time: + """Get the raz time with default to DEFAULT_RAZ_TIME""" + return self._raz_time diff --git a/custom_components/solar_optimizer/managed_device.py b/custom_components/solar_optimizer/managed_device.py index 84699ee..4c49988 100644 --- a/custom_components/solar_optimizer/managed_device.py +++ b/custom_components/solar_optimizer/managed_device.py @@ -1,6 +1,6 @@ """ A ManagedDevice represent a device than can be managed by the optimisatiion algorithm""" import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, time from homeassistant.core import HomeAssistant from homeassistant.helpers.template import Template @@ -126,13 +126,16 @@ class ManagedDevice: _battery_soc_threshold: float _max_on_time_per_day_sec: int _on_time_sec: int + _min_on_time_per_day_sec: int + _offpeak_time: time - def __init__(self, hass: HomeAssistant, device_config): + def __init__(self, hass: HomeAssistant, device_config, coordinator): """Initialize a manageable device""" self._now = None # For testing purpose only self._current_tz = get_tz(hass) self._hass = hass + self._coordinator = coordinator self._name = device_config.get("name") self._unique_id = name_to_unique_id(self._name) self._entity_id = device_config.get("entity_id") @@ -186,6 +189,14 @@ def __init__(self, hass: HomeAssistant, device_config): ) self._on_time_sec = 0 + self._min_on_time_per_day_sec = ( + int(device_config.get("min_on_time_per_day_min") or 0) * 60 + ) + + self._offpeak_time = datetime.strptime( + device_config.get("offpeak_time") or "23:59", "%H:%M" + ).time() + if self.is_active: self._requested_power = self._current_power = ( self._power_max if self._can_change_power else self._power_min @@ -193,6 +204,18 @@ def __init__(self, hass: HomeAssistant, device_config): self._enable = True + # Some checks + # min_on_time_per_day_sec requires an offpeak_time + if self._min_on_time_per_day_sec > 0 and self._offpeak_time == time(23, 59): + msg = f"configuration of device ${self.name} is incorrect. min_on_time_per_day_sec 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) + raise ConfigurationError(msg) + async def _apply_action(self, action_type: str, requested_power=None): """Apply an action to a managed device. This method is a generical method for activate, deactivate, change_requested_power @@ -390,6 +413,24 @@ def is_usable(self) -> bool: return result + @property + def should_be_forced_offpeak(self) -> bool: + """True is we are offpeak and the max_on_time is not exceeded""" + if not self.is_usable: + return False + + if self._offpeak_time >= self._coordinator.raz_time: + 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 + 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 + ) + @property def is_waiting(self): """A device is waiting if the device is waiting for the end of its cycle""" @@ -485,6 +526,16 @@ def max_on_time_per_day_sec(self) -> int: """The max_on_time_per_day_sec configured""" return self._max_on_time_per_day_sec + @property + def min_on_time_per_day_sec(self) -> int: + """The min_on_time_per_day_sec configured""" + return self._min_on_time_per_day_sec + + @property + def offpeak_time(self) -> int: + """The offpeak_time configured""" + return self._offpeak_time + @property def battery_soc(self) -> int: """The battery soc""" diff --git a/custom_components/solar_optimizer/manifest.json b/custom_components/solar_optimizer/manifest.json index b8cf85e..da41c54 100644 --- a/custom_components/solar_optimizer/manifest.json +++ b/custom_components/solar_optimizer/manifest.json @@ -10,5 +10,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/jmcollin78/solar_optimizer/issues", "quality_scale": "silver", - "version": "2.0.0" + "version": "2.1.0" } \ No newline at end of file diff --git a/custom_components/solar_optimizer/sensor.py b/custom_components/solar_optimizer/sensor.py index cc26d8e..0930dc8 100644 --- a/custom_components/solar_optimizer/sensor.py +++ b/custom_components/solar_optimizer/sensor.py @@ -1,6 +1,6 @@ """ A sensor entity that holds the result of the recuit simule algorithm """ import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, time from homeassistant.const import ( UnitOfPower, UnitOfTime, @@ -10,6 +10,7 @@ ) from homeassistant.core import callback, HomeAssistant, Event, State from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers import entity_platform from homeassistant.components.sensor import ( SensorEntity, SensorDeviceClass, @@ -36,8 +37,10 @@ name_to_unique_id, DEVICE_MODEL, seconds_to_hms, + SERVICE_RESET_ON_TIME, ) from .coordinator import SolarOptimizerCoordinator +from .managed_device import ManagedDevice _LOGGER = logging.getLogger(__name__) @@ -50,10 +53,13 @@ async def async_setup_entry( # Sets the config entries values to SolarOptimizer coordinator coordinator: SolarOptimizerCoordinator = hass.data[DOMAIN]["coordinator"] + await coordinator.configure(entry) + entities = [] for _, device in enumerate(coordinator.devices): entity = TodayOnTimeSensor( hass, + coordinator, device, ) if entity is not None: @@ -69,7 +75,13 @@ async def async_setup_entry( async_add_entities(entities, False) - await coordinator.configure(entry) + # Add services + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_RESET_ON_TIME, + {}, + "service_reset_on_time", + ) class SolarOptimizerSensorEntity(CoordinatorEntity, SensorEntity): @@ -157,12 +169,19 @@ class TodayOnTimeSensor(SensorEntity, RestoreEntity): "max_on_time_per_day_min", "max_on_time_hms", "on_time_hms", + "raz_time", + "should_be_forced_offpeak", } ) ) ) - def __init__(self, hass: HomeAssistant, device) -> None: + def __init__( + self, + hass: HomeAssistant, + coordinator: SolarOptimizerCoordinator, + device: ManagedDevice, + ) -> None: """Initialize the sensor""" self.hass = hass idx = name_to_unique_id(device.name) @@ -173,6 +192,7 @@ def __init__(self, hass: HomeAssistant, device) -> None: self._attr_native_value = None self._entity_id = device.entity_id self._device = device + self._coordinator = coordinator self._last_datetime_on = None async def async_added_to_hass(self) -> None: @@ -189,9 +209,14 @@ async def async_added_to_hass(self) -> None: 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=0, minute=0, second=0 + hass=self.hass, + action=self._on_midnight, + hour=raz_time.hour, + minute=raz_time.minute, + second=0, ) ) @@ -208,7 +233,7 @@ async def async_added_to_hass(self) -> None: 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: + if old_state.state is not None and old_state.state != "unknown": self._attr_native_value = round(float(old_state.state)) old_value = old_state.attributes.get("last_datetime_on") @@ -277,7 +302,7 @@ async def _on_midnight(self, _=None) -> None: async def _on_update_on_time(self, _=None) -> None: """Called priodically to update the on_time sensor""" now = self._device.now - _LOGGER.info("Call of _on_update_on_time at %s", now) + _LOGGER.debug("Call of _on_update_on_time at %s", now) if self._last_datetime_on is not None: self._attr_native_value += round( @@ -297,6 +322,9 @@ def update_custom_attributes(self): "max_on_time_per_day_sec": self._device.max_on_time_per_day_sec, "on_time_hms": seconds_to_hms(self._attr_native_value), "max_on_time_hms": seconds_to_hms(self._device.max_on_time_per_day_sec), + "raz_time": self._coordinator.raz_time, + "should_be_forced_offpeak": self._device.should_be_forced_offpeak, + "offpeak_time": self._device.offpeak_time, } @property @@ -339,3 +367,13 @@ def last_datetime_on(self) -> datetime | None: 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_on_time(self): + """Called by a service call: + service: sensor.reset_on_time + data: + target: + entity_id: solar_optimizer.on_time_today_solar_optimizer_ + """ + _LOGGER.info("%s - Calling service_reset_on_time", self) + await self._on_midnight() diff --git a/custom_components/solar_optimizer/services.yaml b/custom_components/solar_optimizer/services.yaml index b782340..0187a5e 100644 --- a/custom_components/solar_optimizer/services.yaml +++ b/custom_components/solar_optimizer/services.yaml @@ -1,3 +1,10 @@ reload: name: Reload - description: Reload Solar Optimizer configuration \ No newline at end of file + description: Reload Solar Optimizer configuration + +reset_on_time: + name: Reset on time + description: Reset all on time for all devices + target: + entity: + integration: solar_optimizer diff --git a/custom_components/solar_optimizer/strings.json b/custom_components/solar_optimizer/strings.json index 563c8fa..0f76e76 100644 --- a/custom_components/solar_optimizer/strings.json +++ b/custom_components/solar_optimizer/strings.json @@ -1,5 +1,5 @@ { - "title": "solar_optimizer", + "title": "Solar Optimizer configuration", "config": { "flow_title": "Solar Optimizer configuration", "step": { @@ -14,7 +14,8 @@ "buy_cost_entity_id": "Energy buy price", "sell_tax_percent_entity_id": "Sell taxe percent", "smooth_production": "Smooth the solar production", - "battery_soc_entity_id": "Battery soc" + "battery_soc_entity_id": "Battery soc", + "raz_time": "Reset counter time" }, "data_description": { "refresh_period_sec": "Refresh period in seconds. Warning heavy calculs are done at each period. Don't refresh to often", @@ -24,9 +25,13 @@ "buy_cost_entity_id": "The entity_id which holds the current energy buy price.", "sell_tax_percent_entity_id": "The energy resell tax percent (0 to 100)", "smooth_production": "If checked, the solar production will be smoothed to avoid hard variation", - "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty" + "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty", + "raz_time": "Time to reset active time counters. Should be before first exposure to sunlight but not too early to allow enough time for night activation" } } + }, + "error": { + "format_time_invalid": "Format of time should be HH:MM" } }, "options": { @@ -43,7 +48,8 @@ "buy_cost_entity_id": "Energy buy price", "sell_tax_percent_entity_id": "Sell taxe percent", "smooth_production": "Smooth the solar production", - "battery_soc_entity_id": "Battery soc" + "battery_soc_entity_id": "Battery soc", + "raz_time": "Heure de remise à zéro" }, "data_description": { "refresh_period_sec": "Refresh period in seconds. Warning heavy calculs are done at each period. Don't refresh to often", @@ -53,9 +59,13 @@ "buy_cost_entity_id": "The entity_id which holds the current energy buy price.", "sell_tax_percent_entity_id": "The energy resell tax percent (0 to 100)", "smooth_production": "If checked, the solar production will be smoothed to avoid hard variation", - "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty" + "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty", + "raz_time": "Heure de remise à zéro des compteurs de temps passés actifs. Devrait être avant la première exposition au soleil mais pas trop tôt pour laisser du temps à l'activation de nuit" } } + }, + "error": { + "format_time_invalid": "Format of time should be HH:MM" } } } \ No newline at end of file diff --git a/custom_components/solar_optimizer/translations/en.json b/custom_components/solar_optimizer/translations/en.json index 563c8fa..0f76e76 100644 --- a/custom_components/solar_optimizer/translations/en.json +++ b/custom_components/solar_optimizer/translations/en.json @@ -1,5 +1,5 @@ { - "title": "solar_optimizer", + "title": "Solar Optimizer configuration", "config": { "flow_title": "Solar Optimizer configuration", "step": { @@ -14,7 +14,8 @@ "buy_cost_entity_id": "Energy buy price", "sell_tax_percent_entity_id": "Sell taxe percent", "smooth_production": "Smooth the solar production", - "battery_soc_entity_id": "Battery soc" + "battery_soc_entity_id": "Battery soc", + "raz_time": "Reset counter time" }, "data_description": { "refresh_period_sec": "Refresh period in seconds. Warning heavy calculs are done at each period. Don't refresh to often", @@ -24,9 +25,13 @@ "buy_cost_entity_id": "The entity_id which holds the current energy buy price.", "sell_tax_percent_entity_id": "The energy resell tax percent (0 to 100)", "smooth_production": "If checked, the solar production will be smoothed to avoid hard variation", - "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty" + "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty", + "raz_time": "Time to reset active time counters. Should be before first exposure to sunlight but not too early to allow enough time for night activation" } } + }, + "error": { + "format_time_invalid": "Format of time should be HH:MM" } }, "options": { @@ -43,7 +48,8 @@ "buy_cost_entity_id": "Energy buy price", "sell_tax_percent_entity_id": "Sell taxe percent", "smooth_production": "Smooth the solar production", - "battery_soc_entity_id": "Battery soc" + "battery_soc_entity_id": "Battery soc", + "raz_time": "Heure de remise à zéro" }, "data_description": { "refresh_period_sec": "Refresh period in seconds. Warning heavy calculs are done at each period. Don't refresh to often", @@ -53,9 +59,13 @@ "buy_cost_entity_id": "The entity_id which holds the current energy buy price.", "sell_tax_percent_entity_id": "The energy resell tax percent (0 to 100)", "smooth_production": "If checked, the solar production will be smoothed to avoid hard variation", - "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty" + "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty", + "raz_time": "Heure de remise à zéro des compteurs de temps passés actifs. Devrait être avant la première exposition au soleil mais pas trop tôt pour laisser du temps à l'activation de nuit" } } + }, + "error": { + "format_time_invalid": "Format of time should be HH:MM" } } } \ No newline at end of file diff --git a/custom_components/solar_optimizer/translations/fr.json b/custom_components/solar_optimizer/translations/fr.json index 563c8fa..0f76e76 100644 --- a/custom_components/solar_optimizer/translations/fr.json +++ b/custom_components/solar_optimizer/translations/fr.json @@ -1,5 +1,5 @@ { - "title": "solar_optimizer", + "title": "Solar Optimizer configuration", "config": { "flow_title": "Solar Optimizer configuration", "step": { @@ -14,7 +14,8 @@ "buy_cost_entity_id": "Energy buy price", "sell_tax_percent_entity_id": "Sell taxe percent", "smooth_production": "Smooth the solar production", - "battery_soc_entity_id": "Battery soc" + "battery_soc_entity_id": "Battery soc", + "raz_time": "Reset counter time" }, "data_description": { "refresh_period_sec": "Refresh period in seconds. Warning heavy calculs are done at each period. Don't refresh to often", @@ -24,9 +25,13 @@ "buy_cost_entity_id": "The entity_id which holds the current energy buy price.", "sell_tax_percent_entity_id": "The energy resell tax percent (0 to 100)", "smooth_production": "If checked, the solar production will be smoothed to avoid hard variation", - "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty" + "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty", + "raz_time": "Time to reset active time counters. Should be before first exposure to sunlight but not too early to allow enough time for night activation" } } + }, + "error": { + "format_time_invalid": "Format of time should be HH:MM" } }, "options": { @@ -43,7 +48,8 @@ "buy_cost_entity_id": "Energy buy price", "sell_tax_percent_entity_id": "Sell taxe percent", "smooth_production": "Smooth the solar production", - "battery_soc_entity_id": "Battery soc" + "battery_soc_entity_id": "Battery soc", + "raz_time": "Heure de remise à zéro" }, "data_description": { "refresh_period_sec": "Refresh period in seconds. Warning heavy calculs are done at each period. Don't refresh to often", @@ -53,9 +59,13 @@ "buy_cost_entity_id": "The entity_id which holds the current energy buy price.", "sell_tax_percent_entity_id": "The energy resell tax percent (0 to 100)", "smooth_production": "If checked, the solar production will be smoothed to avoid hard variation", - "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty" + "battery_soc_entity_id": "Battery state of charge in %. If you don't have battery, keep it empty", + "raz_time": "Heure de remise à zéro des compteurs de temps passés actifs. Devrait être avant la première exposition au soleil mais pas trop tôt pour laisser du temps à l'activation de nuit" } } + }, + "error": { + "format_time_invalid": "Format of time should be HH:MM" } } } \ No newline at end of file diff --git a/hacs.json b/hacs.json index 1966d24..e6ed078 100644 --- a/hacs.json +++ b/hacs.json @@ -3,5 +3,5 @@ "content_in_root": false, "render_readme": true, "hide_default_branch": false, - "homeassistant": "2024.9.3" + "homeassistant": "2024.11.1" } \ No newline at end of file diff --git a/images/lovelace-eqts.png b/images/lovelace-eqts.png index 199739b..ae025f4 100644 Binary files a/images/lovelace-eqts.png and b/images/lovelace-eqts.png differ diff --git a/tests/conftest.py b/tests/conftest.py index 7910970..c09d18a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -184,3 +184,101 @@ async def init_solar_optimizer_entry(hass): await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED + + +@pytest.fixture(name="config_2_devices_min_on_time_ok") +def define_config_2_devices_min_on_time_ok(): + """Define a configuration with 2 devices. One with min_on_time and offpeak, the other without""" + + return { + "solar_optimizer": { + "algorithm": { + "initial_temp": 1000, + "min_temp": 0.1, + "cooling_factor": 0.95, + "max_iteration_number": 1000, + }, + "devices": [ + { + "name": "Equipement A", + "entity_id": "input_boolean.fake_device_a", + "power_max": 1000, + "check_usable_template": "{{ True }}", + "duration_min": 2, + "duration_stop_min": 1, + "action_mode": "service_call", + "activation_service": "input_boolean/turn_on", + "deactivation_service": "input_boolean/turn_off", + "battery_soc_threshold": 30, + "max_on_time_per_day_min": 10, + "min_on_time_per_day_min": 5, + "offpeak_time": "23:00", + }, + { + "name": "Equipement B", + "entity_id": "input_boolean.fake_device_b", + "power_max": 2000, + "check_usable_template": "{{ False }}", + "duration_min": 1, + "duration_stop_min": 2, + "duration_power_min": 3, + "action_mode": "service_call", + "activation_service": "input_boolean/turn_on", + "deactivation_service": "input_boolean/turn_off", + }, + ], + } + } + + +@pytest.fixture(name="init_solar_optimizer_with_2_devices_min_on_time_ok") +async def init_solar_optimizer_with_2_devices_min_on_time_ok( + hass, config_2_devices_min_on_time_ok +) -> SolarOptimizerCoordinator: + """Initialization of Solar Optimizer with 2 managed device""" + await async_setup_component( + hass, "solar_optimizer", config_2_devices_min_on_time_ok + ) + return hass.data[DOMAIN]["coordinator"] + + +@pytest.fixture(name="config_devices_offpeak_morning") +def define_config_devices_offpeak_morning(): + """Define a configuration with 1 devices which have its offpeak_time on morning""" + + return { + "solar_optimizer": { + "algorithm": { + "initial_temp": 1000, + "min_temp": 0.1, + "cooling_factor": 0.95, + "max_iteration_number": 1000, + }, + "devices": [ + { + "name": "Equipement A", + "entity_id": "input_boolean.fake_device_a", + "power_max": 1000, + "check_usable_template": "{{ True }}", + "duration_min": 2, + "duration_stop_min": 1, + "action_mode": "service_call", + "activation_service": "input_boolean/turn_on", + "deactivation_service": "input_boolean/turn_off", + "battery_soc_threshold": 30, + "max_on_time_per_day_min": 10, + "min_on_time_per_day_min": 5, + "offpeak_time": "01:00", + } + ], + } + } + + +@pytest.fixture(name="init_solar_optimizer_with_devices_offpeak_morning") +async def init_solar_optimizer_with_devices_offpeak_morning( + hass, config_devices_offpeak_morning +) -> SolarOptimizerCoordinator: + """Initialization of Solar Optimizer with 2 managed device""" + await async_setup_component(hass, "solar_optimizer", config_devices_offpeak_morning) + return hass.data[DOMAIN]["coordinator"] diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 6d0b199..cfb8c90 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,14 +1,20 @@ +""" Testing the ConfigFlow """ + +# pylint: disable=unused-argument, wildcard-import, unused-wildcard-import + import itertools import pytest -import voluptuous from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType, InvalidData from custom_components.solar_optimizer import config_flow +from custom_components.solar_optimizer.const import * + async def test_empty_config(hass: HomeAssistant): + """Test an empty config. This should not work""" _result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": "user"} ) @@ -17,20 +23,33 @@ async def test_empty_config(hass: HomeAssistant): assert _result["type"] == FlowResultType.FORM assert _result["errors"] is None - with pytest.raises(InvalidData): + with pytest.raises(InvalidData) as err: await hass.config_entries.flow.async_configure( _result["flow_id"], user_input={} ) + assert err.typename == "InvalidData" + assert err.value.error_message == "required key not provided" + @pytest.mark.parametrize( - "power_consumption,power_production,sell_cost,buy_cost", - itertools.product( - ["sensor.power_consumption", "input_number.power_consumption"], - ["sensor.power_production", "input_number.power_production"], - ["sensor.sell_cost", "input_number.sell_cost"], - ["sensor.buy_cost", "input_number.buy_cost"], - ), + "power_consumption,power_production,sell_cost,buy_cost, raz_time", + [ + ( + "sensor.power_consumption", + "sensor.power_production", + "sensor.sell_cost", + "sensor.buy_cost", + "00:00", + ), + ( + "input_number.power_consumption", + "input_number.power_production", + "input_number.sell_cost", + "input_number.buy_cost", + "04:00", + ), + ], ) async def test_config_inputs_wo_battery( hass: HomeAssistant, @@ -40,7 +59,10 @@ async def test_config_inputs_wo_battery( power_production, sell_cost, buy_cost, + raz_time, ): + """Test a combinaison of config_flow without battery configuration""" + _result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": "user"} ) @@ -56,6 +78,7 @@ async def test_config_inputs_wo_battery( "sell_cost_entity_id": sell_cost, "buy_cost_entity_id": buy_cost, "sell_tax_percent_entity_id": "input_number.tax_percent", + "raz_time": raz_time, } result = await hass.config_entries.flow.async_configure( @@ -98,6 +121,7 @@ async def test_config_inputs_with_battery( buy_cost, battery_soc, ): + """Test a combinaison of config_flow with battery configuration""" _result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": "user"} ) @@ -132,3 +156,78 @@ async def test_config_inputs_with_battery( assert data["smooth_production"] assert result["title"] == "SolarOptimizer" + + +async def test_default_values( + hass: HomeAssistant, + init_solar_optimizer_with_2_devices_power_not_power_battery, + init_solar_optimizer_entry, +): + """Test a combinaison of config_flow with battery configuration""" + _result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert _result["step_id"] == "user" + assert _result["type"] == FlowResultType.FORM + assert _result["errors"] is None + + user_input = { + # "refresh_period_sec": 300, + # "raz_time": "04:00", + # "smooth_production": True, + "power_consumption_entity_id": "input_number.power_consumption", + "power_production_entity_id": "input_number.power_production", + "sell_cost_entity_id": "input_number.sell_cost", + "buy_cost_entity_id": "input_number.buy_cost", + "sell_tax_percent_entity_id": "input_number.tax_percent", + } + + result = await hass.config_entries.flow.async_configure( + _result["flow_id"], user_input + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + data = result.get("data") + assert data is not None + + for key, value in user_input.items(): + assert data.get(key) == value + + assert data.get("refresh_period_sec") == 300 + assert data.get("raz_time") == DEFAULT_RAZ_TIME + + assert data["smooth_production"] + + assert result["title"] == "SolarOptimizer" + + +async def test_wrong_raz_time(hass: HomeAssistant): + """Test an empty config. This should not work""" + _result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert _result["step_id"] == "user" + assert _result["type"] == FlowResultType.FORM + assert _result["errors"] is None + + user_input = { + # "refresh_period_sec": 300, + "raz_time": "04h00", # Not valid ! + # "smooth_production": True, + "power_consumption_entity_id": "input_number.power_consumption", + "power_production_entity_id": "input_number.power_production", + "sell_cost_entity_id": "input_number.sell_cost", + "buy_cost_entity_id": "input_number.buy_cost", + "sell_tax_percent_entity_id": "input_number.tax_percent", + } + + _result = await hass.config_entries.flow.async_configure( + _result["flow_id"], user_input=user_input + ) + + assert _result["step_id"] == "user" + assert _result["type"] == FlowResultType.FORM + assert _result["errors"] == {"raz_time": "format_time_invalid"} diff --git a/tests/test_min_in_time.py b/tests/test_min_in_time.py new file mode 100644 index 0000000..612df36 --- /dev/null +++ b/tests/test_min_in_time.py @@ -0,0 +1,316 @@ +""" Nominal Unit test module""" + +# pylint: disable=protected-access + +# from unittest.mock import patch +from datetime import datetime, timedelta, time + + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN + +from custom_components.solar_optimizer.const import get_tz +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import + + +async def test_min_on_time_config_ok( + hass: HomeAssistant, +): + """Testing min_on_time configuration ok""" + await async_setup_component( + hass, + "solar_optimizer", + { + "solar_optimizer": { + "algorithm": { + "initial_temp": 1000, + "min_temp": 0.1, + "cooling_factor": 0.95, + "max_iteration_number": 1000, + }, + "devices": [ + { + "name": "Equipement A", + "entity_id": "input_boolean.fake_device_a", + "power_max": 1000, + "check_usable_template": "{{ True }}", + "duration_min": 2, + "duration_stop_min": 1, + "action_mode": "service_call", + "activation_service": "input_boolean/turn_on", + "deactivation_service": "input_boolean/turn_off", + "battery_soc_threshold": 30, + "max_on_time_per_day_min": 10, + "min_on_time_per_day_min": 5, + "offpeak_time": "23:00", + }, + { + "name": "Equipement B", + "entity_id": "input_boolean.fake_device_b", + "power_max": 2000, + "check_usable_template": "{{ False }}", + "duration_min": 1, + "duration_stop_min": 2, + "duration_power_min": 3, + "action_mode": "service_call", + "activation_service": "input_boolean/turn_on", + "deactivation_service": "input_boolean/turn_off", + }, + ], + } + }, + ) + await hass.async_block_till_done() + coordinator: SolarOptimizerCoordinator = hass.data[DOMAIN].get("coordinator", None) + + assert coordinator is not None + assert coordinator.devices is not None + assert len(coordinator.devices) == 2 + + device: ManagedDevice = coordinator.devices[0] + assert device.name == "Equipement A" + assert device.is_enabled is True + assert device.max_on_time_per_day_sec == 10 * 60 + assert device.min_on_time_per_day_sec == 5 * 60 + assert device.offpeak_time == time(23, 0) + + +async def test_min_on_time_config_ko_1( + hass: HomeAssistant, +): + """Testing min_in_time with min >= max""" + await async_setup_component( + hass, + "solar_optimizer", + { + "solar_optimizer": { + "algorithm": { + "initial_temp": 1000, + "min_temp": 0.1, + "cooling_factor": 0.95, + "max_iteration_number": 1000, + }, + "devices": [ + { + "name": "Equipement A", + "entity_id": "input_boolean.fake_device_a", + "power_max": 1000, + "check_usable_template": "{{ True }}", + "duration_min": 0.3, + "duration_stop_min": 0.1, + "action_mode": "service_call", + "activation_service": "input_boolean/turn_on", + "deactivation_service": "input_boolean/turn_off", + "battery_soc_threshold": 30, + "max_on_time_per_day_min": 10, + "min_on_time_per_day_min": 15, + "offpeak_time": "23:00", + }, + ], + } + }, + ) + coordinator: SolarOptimizerCoordinator = hass.data[DOMAIN].get("coordinator", None) + + assert coordinator is None + + +async def test_min_on_time_config_ko_2( + hass: HomeAssistant, +): + """Testing min_in_time with min requires offpeak_time""" + await async_setup_component( + hass, + "solar_optimizer", + { + "solar_optimizer": { + "algorithm": { + "initial_temp": 1000, + "min_temp": 0.1, + "cooling_factor": 0.95, + "max_iteration_number": 1000, + }, + "devices": [ + { + "name": "Equipement A", + "entity_id": "input_boolean.fake_device_a", + "power_max": 1000, + "check_usable_template": "{{ True }}", + "duration_min": 0.3, + "duration_stop_min": 0.1, + "action_mode": "service_call", + "activation_service": "input_boolean/turn_on", + "deactivation_service": "input_boolean/turn_off", + "battery_soc_threshold": 30, + "max_on_time_per_day_min": 10, + "min_on_time_per_day_min": 5, + }, + ], + } + }, + ) + coordinator: SolarOptimizerCoordinator = hass.data[DOMAIN].get("coordinator", None) + + assert coordinator is None + + +@pytest.mark.parametrize( + "current_datetime, should_be_forced_offpeak", + [ + (datetime(2024, 11, 10, 10, 00, 00), False), + (datetime(2024, 11, 10, 0, 59, 00), False), + (datetime(2024, 11, 10, 1, 0, 00), True), + ], +) +async def test_min_on_time_config_ko_3( + hass: HomeAssistant, + init_solar_optimizer_with_devices_offpeak_morning, + init_solar_optimizer_entry, + current_datetime, + should_be_forced_offpeak, +): + """Testing min_in_time with min requires offpeak_time < raz_time""" + + coordinator: SolarOptimizerCoordinator = ( + init_solar_optimizer_with_devices_offpeak_morning + ) + + assert coordinator is not None + + assert len(coordinator.devices) == 1 + device = coordinator.devices[0] + assert device.offpeak_time == time(1, 0, 0) + + device._set_now(current_datetime.replace(tzinfo=get_tz(hass))) + + with patch( + "custom_components.solar_optimizer.managed_device.ManagedDevice.is_usable", + return_value=True, + ): + assert device.should_be_forced_offpeak is should_be_forced_offpeak + + +async def test_nominal_use_min_on_time( + hass: HomeAssistant, + init_solar_optimizer_with_2_devices_min_on_time_ok, + init_solar_optimizer_entry, # pylint: disable=unused-argument +): + """Testing the nominal case with min_on_time and offpeak_time""" + + coordinator: SolarOptimizerCoordinator = ( + init_solar_optimizer_with_2_devices_min_on_time_ok + ) + + assert coordinator is not None + assert coordinator.devices is not None + assert len(coordinator.devices) == 2 + # default value + assert coordinator.raz_time == time(5, 0, 0) + + device_a: ManagedDevice = coordinator.devices[0] + assert device_a.min_on_time_per_day_sec == 5 * 60 + assert device_a.offpeak_time == time(23, 0) + + now = datetime(2024, 11, 10, 10, 00, 00).replace(tzinfo=get_tz(hass)) + device_a._set_now(now) + + # Creates the fake input_boolean (the device) + await create_test_input_boolean(hass, device_a.entity_id, "fake underlying A") + fake_input_bool = search_entity( + hass, "input_boolean.fake_device_a", INPUT_BOOLEAN_DOMAIN + ) + assert fake_input_bool is not None + + # The on_time should be 0 + device_a_on_time_sensor = search_entity( + hass, "sensor.on_time_today_solar_optimizer_equipement_a", SENSOR_DOMAIN + ) + assert device_a_on_time_sensor.state == 0 + assert device_a_on_time_sensor.last_datetime_on is None + device_a.reset_next_date_available( + "Activate" + ) # for test only to reset the next_available date + + # + # 1. Activate the underlying switch + # + assert device_a.is_usable is False # because duration_min is 3 minutes + assert device_a.is_enabled is True + + await fake_input_bool.async_turn_on() + await hass.async_block_till_done() + + # + # 2. stop the unerlying switch, after 3 minutes. + # + now = now + timedelta(minutes=4) + device_a._set_now(now) + + assert device_a.is_usable is True + # before 23:00 device should not be forcable + assert device_a.should_be_forced_offpeak is False + + await fake_input_bool.async_turn_off() + await hass.async_block_till_done() + + assert device_a_on_time_sensor.state == 4 * 60 + + # + # 3. before 23:00, the device should not be Usable + # + now = datetime(2024, 11, 10, 22, 59, 59).replace(tzinfo=get_tz(hass)) + device_a._set_now(now) + assert device_a.should_be_forced_offpeak is False + + # + # 4. just after 23:00 it should be possible to force offpeak + # + now = datetime(2024, 11, 10, 23, 00, 00).replace(tzinfo=get_tz(hass)) + device_a._set_now(now) + assert device_a.should_be_forced_offpeak is True + + # + # 5. at 01:00 it should be possible to force offpeak + # + with patch( + "custom_components.solar_optimizer.managed_device.ManagedDevice.is_usable", + return_value=True, + ): + now = datetime(2024, 11, 10, 1, 00, 00).replace(tzinfo=get_tz(hass)) + device_a._set_now(now) + assert device_a.should_be_forced_offpeak is True + + # + # 6. at 04:59 it should be possible to force offpeak + # + now = datetime(2024, 11, 10, 4, 59, 00).replace(tzinfo=get_tz(hass)) + device_a._set_now(now) + assert device_a.should_be_forced_offpeak is True + + # + # 6. at 05:01 it should be not possible to force offpeak + # + now = datetime(2024, 11, 10, 5, 1, 00).replace(tzinfo=get_tz(hass)) + device_a._set_now(now) + assert device_a.should_be_forced_offpeak is False + + # + # 7. when on_time is > max_on_time it should be not possible to force off_peak + # + # Come back in offpeak + now = datetime(2024, 11, 10, 0, 0, 00).replace(tzinfo=get_tz(hass)) + device_a._set_now(now) + assert device_a.should_be_forced_offpeak is True + + await fake_input_bool.async_turn_on() + await hass.async_block_till_done() + + now = now + timedelta(minutes=6) # 6 minutes + 4 minutes already done + device_a._set_now(now) + await fake_input_bool.async_turn_off() + await hass.async_block_till_done() + + assert device_a_on_time_sensor.state == 10 * 60 + assert device_a._on_time_sec == 10 * 60 + + assert device_a.should_be_forced_offpeak is False