From dd5c5e0e25ba1207defa58aee2fd88c75ca5c879 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Mon, 11 Nov 2024 09:25:16 +0000 Subject: [PATCH 1/9] V1 config + testus + test int ok --- .devcontainer/configuration.yaml | 4 + custom_components/solar_optimizer/__init__.py | 15 +- custom_components/solar_optimizer/const.py | 2 + .../solar_optimizer/coordinator.py | 7 +- .../solar_optimizer/managed_device.py | 42 ++- custom_components/solar_optimizer/sensor.py | 20 ++ .../solar_optimizer/services.yaml | 10 +- tests/conftest.py | 56 ++++ tests/test_min_in_time.py | 248 ++++++++++++++++++ 9 files changed, 399 insertions(+), 5 deletions(-) create mode 100644 tests/test_min_in_time.py diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index ec42294..b61e8ae 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: "19:48" - 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: "19:50" - name: "Equipement C" entity_id: "input_boolean.fake_device_c" power_max: 800 diff --git a/custom_components/solar_optimizer/__init__.py b/custom_components/solar_optimizer/__init__.py index 0dff275..f79003e 100644 --- a/custom_components/solar_optimizer/__init__.py +++ b/custom_components/solar_optimizer/__init__.py @@ -1,12 +1,15 @@ """Initialisation du package de l'intégration HACS Tuto""" import logging import asyncio +import re import voluptuous as vol +from voluptuous.error import Invalid from homeassistant.const import EVENT_HOMEASSISTANT_START, SERVICE_RELOAD 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,13 +32,21 @@ # from homeassistant.helpers.entity_component import EntityComponent -from .const import DOMAIN, PLATFORMS +from .const import DOMAIN, PLATFORMS, SERVICE_RESET_ON_TIME from .coordinator import SolarOptimizerCoordinator # from .input_boolean import async_setup_entry as async_setup_entry_input_boolean _LOGGER = logging.getLogger(__name__) + +def validate_time_format(value): + """check is a string have format "hh:mm" with hh between 00 and 23 and mm between 00 et 59""" + if not re.match(r"^(?:[01]\d|2[0-3]):[0-5]\d$", value): + raise Invalid("The time value should be formatted like 'hh:mm'") + return value + + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -90,6 +101,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/const.py b/custom_components/solar_optimizer/const.py index 038c6bf..c28497f 100644 --- a/custom_components/solar_optimizer/const.py +++ b/custom_components/solar_optimizer/const.py @@ -26,6 +26,8 @@ INTEGRATION_MODEL = "Solar Optimizer" DEVICE_MANUFACTURER = "JM. COLLIN" +SERVICE_RESET_ON_TIME = "reset_on_time" + def get_tz(hass: HomeAssistant): """Get the current timezone""" diff --git a/custom_components/solar_optimizer/coordinator.py b/custom_components/solar_optimizer/coordinator.py index aee5e71..6ee46a7 100644 --- a/custom_components/solar_optimizer/coordinator.py +++ b/custom_components/solar_optimizer/coordinator.py @@ -175,11 +175,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) diff --git a/custom_components/solar_optimizer/managed_device.py b/custom_components/solar_optimizer/managed_device.py index 84699ee..b6e35b2 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,6 +126,8 @@ 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): """Initialize a manageable device""" @@ -186,6 +188,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 +203,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 +412,14 @@ 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""" + return ( + self.now.time() >= self._offpeak_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 +515,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/sensor.py b/custom_components/solar_optimizer/sensor.py index cc26d8e..90856b4 100644 --- a/custom_components/solar_optimizer/sensor.py +++ b/custom_components/solar_optimizer/sensor.py @@ -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,6 +37,7 @@ name_to_unique_id, DEVICE_MODEL, seconds_to_hms, + SERVICE_RESET_ON_TIME, ) from .coordinator import SolarOptimizerCoordinator @@ -71,6 +73,14 @@ async def async_setup_entry( 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): """The entity holding the algorithm calculation""" @@ -339,3 +349,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..2560fa0 100644 --- a/custom_components/solar_optimizer/services.yaml +++ b/custom_components/solar_optimizer/services.yaml @@ -1,3 +1,11 @@ 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 + fields: diff --git a/tests/conftest.py b/tests/conftest.py index 7910970..fa64825 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -184,3 +184,59 @@ 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"] diff --git a/tests/test_min_in_time.py b/tests/test_min_in_time.py new file mode 100644 index 0000000..267ac2e --- /dev/null +++ b/tests/test_min_in_time.py @@ -0,0 +1,248 @@ +""" 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 + + +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 + + 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. when on_time is > max_on_time it should be not possible to force off_peak + # + 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 From d75e5bf85344a6cd68233232ffe1d35782c57d1a Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Mon, 11 Nov 2024 12:39:04 +0000 Subject: [PATCH 2/9] With correct test flow and error management --- custom_components/solar_optimizer/__init__.py | 12 +-- .../solar_optimizer/config_flow.py | 46 ++++++++- custom_components/solar_optimizer/const.py | 13 +++ .../solar_optimizer/coordinator.py | 14 ++- custom_components/solar_optimizer/sensor.py | 25 ++++- .../solar_optimizer/strings.json | 20 +++- .../solar_optimizer/translations/en.json | 20 +++- .../solar_optimizer/translations/fr.json | 20 +++- tests/test_config_flow.py | 96 ++++++++++++++++++- 9 files changed, 228 insertions(+), 38 deletions(-) diff --git a/custom_components/solar_optimizer/__init__.py b/custom_components/solar_optimizer/__init__.py index f79003e..7f35d94 100644 --- a/custom_components/solar_optimizer/__init__.py +++ b/custom_components/solar_optimizer/__init__.py @@ -1,9 +1,7 @@ """Initialisation du package de l'intégration HACS Tuto""" import logging import asyncio -import re import voluptuous as vol -from voluptuous.error import Invalid from homeassistant.const import EVENT_HOMEASSISTANT_START, SERVICE_RELOAD from homeassistant.core import HomeAssistant @@ -32,21 +30,13 @@ # from homeassistant.helpers.entity_component import EntityComponent -from .const import DOMAIN, PLATFORMS, SERVICE_RESET_ON_TIME +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 _LOGGER = logging.getLogger(__name__) - -def validate_time_format(value): - """check is a string have format "hh:mm" with hh between 00 and 23 and mm between 00 et 59""" - if not re.match(r"^(?:[01]\d|2[0-3]):[0-5]\d$", value): - raise Invalid("The time value should be formatted like 'hh:mm'") - return value - - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( 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 c28497f..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" @@ -28,6 +32,8 @@ 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""" @@ -51,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 6ee46a7..c74fd31 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 @@ -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() @@ -229,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/sensor.py b/custom_components/solar_optimizer/sensor.py index 90856b4..dc4398c 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, @@ -40,6 +40,7 @@ SERVICE_RESET_ON_TIME, ) from .coordinator import SolarOptimizerCoordinator +from .managed_device import ManagedDevice _LOGGER = logging.getLogger(__name__) @@ -52,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: @@ -71,8 +75,6 @@ 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( @@ -167,12 +169,18 @@ class TodayOnTimeSensor(SensorEntity, RestoreEntity): "max_on_time_per_day_min", "max_on_time_hms", "on_time_hms", + "raz_time", } ) ) ) - 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) @@ -183,6 +191,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: @@ -199,9 +208,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, ) ) @@ -307,6 +321,7 @@ 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, } @property 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/tests/test_config_flow.py b/tests/test_config_flow.py index 6d0b199..c6f3c5c 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,19 +23,23 @@ 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", + "power_consumption,power_production,sell_cost,buy_cost, raz_time", 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"], + ["00:00", "04:00"], ), ) async def test_config_inputs_wo_battery( @@ -40,7 +50,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 +69,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 +112,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 +147,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"} From e599c81e82bf074e2ecde12411d475bb0626ad38 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Mon, 11 Nov 2024 17:44:16 +0000 Subject: [PATCH 3/9] Update configuration.yaml --- .devcontainer/configuration.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index b61e8ae..06bcd87 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -163,7 +163,7 @@ solar_optimizer: deactivation_service: "input_boolean/turn_off" max_on_time_per_day_min: 10 min_on_time_per_day_min: 5 - offpeak_time: "19:48" + offpeak_time: "14:00" - name: "Equipement B" entity_id: "input_boolean.fake_device_b" power_max: 500 @@ -173,7 +173,7 @@ solar_optimizer: activation_service: "input_boolean/turn_on" deactivation_service: "input_boolean/turn_off" min_on_time_per_day_min: 15 - offpeak_time: "19:50" + offpeak_time: "13:50" - name: "Equipement C" entity_id: "input_boolean.fake_device_c" power_max: 800 From 17c25ebd9940b5099f996b43531e3b87d8f72180 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Mon, 11 Nov 2024 18:14:04 +0000 Subject: [PATCH 4/9] Release --- README-fr.md | 24 ++++++++++++++-- README.md | 26 ++++++++++++++---- .../solar_optimizer/manifest.json | 2 +- hacs.json | 2 +- images/lovelace-eqts.png | Bin 37743 -> 38481 bytes 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/README-fr.md b/README-fr.md index d9959a5..406d148 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`). 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..4b961a3 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`). 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/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/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 199739be1926492046345cb9943baff8987cb509..ae025f45a75c40952eb843326bf9bd75cdb9bd0a 100644 GIT binary patch literal 38481 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

k6v<3al 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 From 5e3ad90b9f94ea8402469fb56e2c160dc1510232 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Tue, 12 Nov 2024 07:38:45 +0000 Subject: [PATCH 5/9] Fix beta1 bugs --- .../solar_optimizer/coordinator.py | 2 +- .../solar_optimizer/managed_device.py | 7 +++-- custom_components/solar_optimizer/sensor.py | 6 ++-- .../solar_optimizer/services.yaml | 2 +- tests/test_min_in_time.py | 30 ++++++++++++++++++- 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/custom_components/solar_optimizer/coordinator.py b/custom_components/solar_optimizer/coordinator.py index c74fd31..dc2de75 100644 --- a/custom_components/solar_optimizer/coordinator.py +++ b/custom_components/solar_optimizer/coordinator.py @@ -56,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( diff --git a/custom_components/solar_optimizer/managed_device.py b/custom_components/solar_optimizer/managed_device.py index b6e35b2..ed007b8 100644 --- a/custom_components/solar_optimizer/managed_device.py +++ b/custom_components/solar_optimizer/managed_device.py @@ -129,12 +129,13 @@ class ManagedDevice: _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") @@ -417,8 +418,8 @@ def should_be_forced_offpeak(self) -> bool: """True is we are offpeak and the max_on_time is not exceeded""" return ( self.now.time() >= self._offpeak_time - and self._on_time_sec < self._max_on_time_per_day_sec - ) + or self.now.time() < self._coordinator.raz_time + ) and self._on_time_sec < self._max_on_time_per_day_sec @property def is_waiting(self): diff --git a/custom_components/solar_optimizer/sensor.py b/custom_components/solar_optimizer/sensor.py index dc4398c..11d8079 100644 --- a/custom_components/solar_optimizer/sensor.py +++ b/custom_components/solar_optimizer/sensor.py @@ -170,6 +170,7 @@ class TodayOnTimeSensor(SensorEntity, RestoreEntity): "max_on_time_hms", "on_time_hms", "raz_time", + "should_be_forced_offpeak", } ) ) @@ -232,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") @@ -301,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( @@ -322,6 +323,7 @@ def update_custom_attributes(self): "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, } @property diff --git a/custom_components/solar_optimizer/services.yaml b/custom_components/solar_optimizer/services.yaml index 2560fa0..26b3f27 100644 --- a/custom_components/solar_optimizer/services.yaml +++ b/custom_components/solar_optimizer/services.yaml @@ -8,4 +8,4 @@ reset_on_time: target: entity: integration: solar_optimizer - fields: + fields: [] diff --git a/tests/test_min_in_time.py b/tests/test_min_in_time.py index 267ac2e..8bc7f17 100644 --- a/tests/test_min_in_time.py +++ b/tests/test_min_in_time.py @@ -168,6 +168,8 @@ async def test_nominal_use_min_on_time( 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 @@ -232,8 +234,34 @@ async def test_nominal_use_min_on_time( assert device_a.should_be_forced_offpeak is True # - # 5. when on_time is > max_on_time it should be not possible to force off_peak + # 5. at 01:00 it should be possible to force offpeak # + 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() From 33d64cf986d797c16b9d3cf05afb94024e5d82af Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Tue, 12 Nov 2024 10:56:42 +0000 Subject: [PATCH 6/9] Fix when offpeak_time is > raz_time --- .../solar_optimizer/managed_device.py | 15 +++++-- custom_components/solar_optimizer/sensor.py | 1 + tests/conftest.py | 42 +++++++++++++++++++ tests/test_config_flow.py | 23 ++++++---- tests/test_min_in_time.py | 32 ++++++++++++++ 5 files changed, 102 insertions(+), 11 deletions(-) diff --git a/custom_components/solar_optimizer/managed_device.py b/custom_components/solar_optimizer/managed_device.py index ed007b8..5711e25 100644 --- a/custom_components/solar_optimizer/managed_device.py +++ b/custom_components/solar_optimizer/managed_device.py @@ -416,10 +416,17 @@ def is_usable(self) -> bool: @property def should_be_forced_offpeak(self) -> bool: """True is we are offpeak and the max_on_time is not exceeded""" - 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 + 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): diff --git a/custom_components/solar_optimizer/sensor.py b/custom_components/solar_optimizer/sensor.py index 11d8079..0930dc8 100644 --- a/custom_components/solar_optimizer/sensor.py +++ b/custom_components/solar_optimizer/sensor.py @@ -324,6 +324,7 @@ def update_custom_attributes(self): "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 diff --git a/tests/conftest.py b/tests/conftest.py index fa64825..c09d18a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -240,3 +240,45 @@ async def init_solar_optimizer_with_2_devices_min_on_time_ok( 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 c6f3c5c..cfb8c90 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -34,13 +34,22 @@ async def test_empty_config(hass: HomeAssistant): @pytest.mark.parametrize( "power_consumption,power_production,sell_cost,buy_cost, raz_time", - 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"], - ["00:00", "04:00"], - ), + [ + ( + "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, diff --git a/tests/test_min_in_time.py b/tests/test_min_in_time.py index 8bc7f17..f61b0b9 100644 --- a/tests/test_min_in_time.py +++ b/tests/test_min_in_time.py @@ -154,6 +154,38 @@ async def test_min_on_time_config_ko_2( 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))) + + 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, From f8030761d7f1e2c641829e86414fd6caae061d00 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Tue, 12 Nov 2024 21:58:50 +0000 Subject: [PATCH 7/9] Not usable cannot be forced in offpeak --- .../solar_optimizer/managed_device.py | 3 + tests/test_min_in_time.py | 86 ++++++++++--------- 2 files changed, 50 insertions(+), 39 deletions(-) diff --git a/custom_components/solar_optimizer/managed_device.py b/custom_components/solar_optimizer/managed_device.py index 5711e25..4c49988 100644 --- a/custom_components/solar_optimizer/managed_device.py +++ b/custom_components/solar_optimizer/managed_device.py @@ -416,6 +416,9 @@ def is_usable(self) -> bool: @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 diff --git a/tests/test_min_in_time.py b/tests/test_min_in_time.py index f61b0b9..612df36 100644 --- a/tests/test_min_in_time.py +++ b/tests/test_min_in_time.py @@ -183,7 +183,11 @@ async def test_min_on_time_config_ko_3( device._set_now(current_datetime.replace(tzinfo=get_tz(hass))) - assert device.should_be_forced_offpeak is should_be_forced_offpeak + 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( @@ -268,41 +272,45 @@ async def test_nominal_use_min_on_time( # # 5. at 01:00 it should be possible to force offpeak # - 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 + 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 From 77c32612f9affe26115e47b62a1a190f84ce4aeb Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Wed, 13 Nov 2024 07:06:32 +0000 Subject: [PATCH 8/9] Precise documentation when `max_on_time_per_day_min` is not set --- README-fr.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README-fr.md b/README-fr.md index 406d148..210385f 100644 --- a/README-fr.md +++ b/README-fr.md @@ -72,7 +72,7 @@ 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. -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`). 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. +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. diff --git a/README.md b/README.md index 4b961a3..d7d0de4 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ 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. -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`). 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. +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. From 254cf265512f6bbd083c7ef607fdab9806351432 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Wed, 13 Nov 2024 07:11:07 +0000 Subject: [PATCH 9/9] Fix syntax --- custom_components/solar_optimizer/services.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/solar_optimizer/services.yaml b/custom_components/solar_optimizer/services.yaml index 26b3f27..0187a5e 100644 --- a/custom_components/solar_optimizer/services.yaml +++ b/custom_components/solar_optimizer/services.yaml @@ -8,4 +8,3 @@ reset_on_time: target: entity: integration: solar_optimizer - fields: []