diff --git a/custom_components/solar_optimizer/config_schema.py b/custom_components/solar_optimizer/config_schema.py index 2ea89c5..a7ded8c 100644 --- a/custom_components/solar_optimizer/config_schema.py +++ b/custom_components/solar_optimizer/config_schema.py @@ -15,6 +15,8 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.calendar import DOMAIN as CALENDAR_DOMAIN from .const import * # pylint: disable=wildcard-import, unused-wildcard-import @@ -87,6 +89,11 @@ vol.Optional(CONF_MAX_ON_TIME_PER_DAY_MIN): str, vol.Optional(CONF_MIN_ON_TIME_PER_DAY_MIN): str, vol.Optional(CONF_OFFPEAK_TIME): str, + vol.Optional(CONF_OFFPEAK_ENTITY_ID): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=[BINARY_SENSOR_DOMAIN, INPUT_BOOLEAN_DOMAIN, SENSOR_DOMAIN, CALENDAR_DOMAIN] + ) + ), } ) @@ -138,5 +145,10 @@ vol.Optional(CONF_MAX_ON_TIME_PER_DAY_MIN): str, vol.Optional(CONF_MIN_ON_TIME_PER_DAY_MIN): str, vol.Optional(CONF_OFFPEAK_TIME): str, + vol.Optional(CONF_OFFPEAK_ENTITY_ID): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=[BINARY_SENSOR_DOMAIN, INPUT_BOOLEAN_DOMAIN, SENSOR_DOMAIN, CALENDAR_DOMAIN] + ) + ), } ) diff --git a/custom_components/solar_optimizer/const.py b/custom_components/solar_optimizer/const.py index 74ac6b7..57b247a 100644 --- a/custom_components/solar_optimizer/const.py +++ b/custom_components/solar_optimizer/const.py @@ -79,6 +79,11 @@ CONF_MAX_ON_TIME_PER_DAY_MIN = "max_on_time_per_day_min" CONF_MIN_ON_TIME_PER_DAY_MIN = "min_on_time_per_day_min" CONF_OFFPEAK_TIME = "offpeak_time" +CONF_OFFPEAK_ENTITY_ID = "offpeak_entity_id" + +# Regex for time range format like "13:00-14:00" or "01:00-07:00,13:00-14:00" +TIME_RANGE_REGEX = r"^(?:[01]\d|2[0-3]):[0-5]\d-(?:[01]\d|2[0-3]):[0-5]\d$" +TIME_RANGES_REGEX = r"^((?:[01]\d|2[0-3]):[0-5]\d-(?:[01]\d|2[0-3]):[0-5]\d)(,((?:[01]\d|2[0-3]):[0-5]\d-(?:[01]\d|2[0-3]):[0-5]\d))*$" PRIORITY_WEIGHT_NULL = "None" PRIORITY_WEIGHT_LOW = "Low" @@ -157,6 +162,38 @@ def validate_time_format(value: str) -> str: return value +def validate_time_ranges_format(value: str) -> str: + """Check if a string has format "HH:MM-HH:MM" or "HH:MM-HH:MM,HH:MM-HH:MM,..." + Example: "13:00-14:00" or "01:00-07:00,13:00-14:00" + """ + if value is not None and not re.match(TIME_RANGES_REGEX, value): + raise Invalid( + "The time ranges value should be formatted like 'HH:MM-HH:MM' or 'HH:MM-HH:MM,HH:MM-HH:MM'. " + "Example: '13:00-14:00' or '01:00-07:00,13:00-14:00'" + ) + return value + + +def parse_time_ranges(value: str) -> list[tuple]: + """Parse a time ranges string like "13:00-14:00,01:00-07:00" into a list of (start_time, end_time) tuples. + Returns a list of tuples where each tuple contains (start_time, end_time) as datetime.time objects. + """ + from datetime import datetime + + if not value: + return [] + + ranges = [] + for time_range in value.split(","): + time_range = time_range.strip() + if "-" in time_range: + start_str, end_str = time_range.split("-") + start_time = datetime.strptime(start_str.strip(), "%H:%M").time() + end_time = datetime.strptime(end_str.strip(), "%H:%M").time() + ranges.append((start_time, end_time)) + return ranges + + def get_safe_float(hass, str_as_float) -> float | None: """Get a safe float state value for an str_as_float string. Return None if str_as_float is None or not a valid float. diff --git a/custom_components/solar_optimizer/managed_device.py b/custom_components/solar_optimizer/managed_device.py index 5a19f63..66ebcea 100644 --- a/custom_components/solar_optimizer/managed_device.py +++ b/custom_components/solar_optimizer/managed_device.py @@ -16,6 +16,7 @@ get_template_or_value, get_safe_float, convert_to_template_or_value, + parse_time_ranges, CONF_ACTION_MODE_ACTION, CONF_ACTION_MODE_EVENT, CONF_ACTION_MODES, @@ -203,13 +204,21 @@ def __init__(self, hass: HomeAssistant, device_config, coordinator): self._min_on_time_per_day_min = convert_to_template_or_value(hass, device_config.get("min_on_time_per_day_min") or 0) + # Off-peak configuration - supports two modes: + # 1. Time ranges string like "13:00-14:00,01:00-07:00" + # 2. Entity ID (binary_sensor, input_boolean, sensor, calendar) offpeak_time = device_config.get("offpeak_time", None) - self._offpeak_time = None + self._offpeak_time = None # Legacy single time (kept for backward compatibility) + self._offpeak_time_ranges = [] # List of (start_time, end_time) tuples + self._offpeak_entity_id = device_config.get("offpeak_entity_id", None) if offpeak_time: - self._offpeak_time = datetime.strptime( - device_config.get("offpeak_time"), "%H:%M" - ).time() + # Check if it's the new format with ranges (contains "-") + if "-" in offpeak_time: + self._offpeak_time_ranges = parse_time_ranges(offpeak_time) + else: + # Legacy format: single time "HH:MM" + self._offpeak_time = datetime.strptime(offpeak_time, "%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 @@ -217,9 +226,14 @@ def __init__(self, hass: HomeAssistant, device_config, coordinator): 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 is None: - msg = f"configuration of device ${self.name} is incorrect. min_on_time_per_day_sec requires offpeak_time value" + # min_on_time_per_day_sec requires an offpeak configuration (time, time_ranges, or entity) + has_offpeak_config = ( + self._offpeak_time is not None + or len(self._offpeak_time_ranges) > 0 + or self._offpeak_entity_id is not None + ) + if self.min_on_time_per_day_sec > 0 and not has_offpeak_config: + msg = f"configuration of device ${self.name} is incorrect. min_on_time_per_day_sec requires offpeak_time or offpeak_entity_id value" _LOGGER.error("%s - %s", self, msg) raise ConfigurationError(msg) @@ -456,25 +470,69 @@ def is_usable(self) -> bool: and the _max_on_time_per_day_sec is not exceeded""" return self.check_usable(True) + def _is_currently_offpeak(self) -> bool: + """Check if current time is within offpeak hours. + Supports three modes: + 1. Entity-based: checks the state of offpeak_entity_id + 2. Time ranges: checks if current time is within any defined range + 3. Legacy single time: checks if current time is between offpeak_time and raz_time + """ + # Mode 1: Entity-based off-peak detection + if self._offpeak_entity_id is not None: + entity_state = self._hass.states.get(self._offpeak_entity_id) + if entity_state is not None: + state_value = entity_state.state.lower() + # Handle various "on" states for different entity types + return state_value in (STATE_ON, "true", "1", "on", "yes") + return False + + # Mode 2: Multiple time ranges like "13:00-14:00,01:00-07:00" + if len(self._offpeak_time_ranges) > 0: + current_time = self.now.time() + for start_time, end_time in self._offpeak_time_ranges: + if start_time <= end_time: + # Normal range (e.g., 13:00-14:00) + if start_time <= current_time < end_time: + return True + else: + # Range crosses midnight (e.g., 22:00-06:00) + if current_time >= start_time or current_time < end_time: + return True + return False + + # Mode 3: Legacy single offpeak_time (from offpeak_time to raz_time) + if self._offpeak_time is not None: + current_time = self.now.time() + raz_time = self._coordinator.raz_time + if self._offpeak_time >= raz_time: + # Offpeak crosses midnight (e.g., 23:00 to 05:00) + return current_time >= self._offpeak_time or current_time < raz_time + else: + # Offpeak within same day (e.g., 01:00 to 05:00) + return self._offpeak_time <= current_time < raz_time + + return False + @property def should_be_forced_offpeak(self) -> bool: - """True is we are offpeak and the max_on_time is not exceeded""" - if not self.check_usable(False) or self._offpeak_time is None: + """True if we are offpeak and the max_on_time is not exceeded""" + if not self.check_usable(False): 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 - and self._on_time_sec < self.min_on_time_per_day_sec - ) - else: - return ( - self.now.time() >= self._offpeak_time - and self.now.time() < self._coordinator.raz_time - and self._on_time_sec < self.max_on_time_per_day_sec - and self._on_time_sec < self.min_on_time_per_day_sec - ) + # Check if any offpeak configuration exists + has_offpeak_config = ( + self._offpeak_time is not None + or len(self._offpeak_time_ranges) > 0 + or self._offpeak_entity_id is not None + ) + if not has_offpeak_config: + return False + + return ( + self._is_currently_offpeak() + and self._on_time_sec < self.max_on_time_per_day_sec + and self._on_time_sec < self.min_on_time_per_day_sec + ) @property def is_waiting(self): @@ -577,10 +635,25 @@ def min_on_time_per_day_sec(self) -> int: return get_template_or_value(self._hass, self._min_on_time_per_day_min) * 60 @property - def offpeak_time(self) -> int: - """The offpeak_time configured""" + def offpeak_time(self) -> time: + """The offpeak_time configured (legacy single time)""" return self._offpeak_time + @property + def offpeak_time_ranges(self) -> list: + """The offpeak time ranges configured as list of (start, end) tuples""" + return self._offpeak_time_ranges + + @property + def offpeak_entity_id(self) -> str: + """The entity ID used for off-peak detection""" + return self._offpeak_entity_id + + @property + def is_offpeak(self) -> bool: + """Returns True if currently in off-peak period""" + return self._is_currently_offpeak() + @property def battery_soc(self) -> int: """The battery soc""" diff --git a/custom_components/solar_optimizer/strings.json b/custom_components/solar_optimizer/strings.json index 50bffc1..b9448fb 100644 --- a/custom_components/solar_optimizer/strings.json +++ b/custom_components/solar_optimizer/strings.json @@ -60,7 +60,8 @@ "battery_soc_threshold": "Battery soc threshold", "max_on_time_per_day_min": "Max on time per day", "min_on_time_per_day_min": "Min on time per day", - "offpeak_time": "Offpeak time" + "offpeak_time": "Offpeak time ranges", + "offpeak_entity_id": "Offpeak sensor entity" }, "data_description": { "name": "The name of the device", @@ -76,7 +77,8 @@ "battery_soc_threshold": "The battery state of charge threshold to activate the device. Can be a number or a template", "max_on_time_per_day_min": "The maximum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time until max time is reached", "min_on_time_per_day_min": "The minimum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time", - "offpeak_time": "The offpeak time with format HH:MM" + "offpeak_time": "Off-peak time ranges. Format: HH:MM-HH:MM or multiple ranges separated by commas. Examples: '22:00-06:00' or '13:00-14:00,22:00-06:00'", + "offpeak_entity_id": "Entity (binary_sensor, input_boolean, sensor, or calendar) that indicates off-peak periods. When 'on', it's off-peak time. Alternative to offpeak_time" } }, "powered_device": { @@ -102,7 +104,8 @@ "battery_soc_threshold": "Battery soc threshold", "max_on_time_per_day_min": "Max on time per day", "min_on_time_per_day_min": "Min on time per day", - "offpeak_time": "Offpeak time" + "offpeak_time": "Offpeak time ranges", + "offpeak_entity_id": "Offpeak sensor entity" }, "data_description": { "name": "The name of the device", @@ -124,7 +127,8 @@ "battery_soc_threshold": "The battery state of charge threshold to activate the device. Can be a number or a template", "max_on_time_per_day_min": "The maximum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time until max time is reached", "min_on_time_per_day_min": "The minimum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time", - "offpeak_time": "The offpeak time with format HH:MM" + "offpeak_time": "Off-peak time ranges. Format: HH:MM-HH:MM or multiple ranges separated by commas. Examples: '22:00-06:00' or '13:00-14:00,22:00-06:00'", + "offpeak_entity_id": "Entity (binary_sensor, input_boolean, sensor, or calendar) that indicates off-peak periods. When 'on', it's off-peak time. Alternative to offpeak_time" } } }, @@ -192,7 +196,8 @@ "battery_soc_threshold": "Battery soc threshold", "max_on_time_per_day_min": "Max on time per day", "min_on_time_per_day_min": "Min on time per day", - "offpeak_time": "Offpeak time" + "offpeak_time": "Offpeak time ranges", + "offpeak_entity_id": "Offpeak sensor entity" }, "data_description": { "name": "The name of the device", @@ -208,7 +213,8 @@ "battery_soc_threshold": "The battery state of charge threshold to activate the device. Can be a number or a template", "max_on_time_per_day_min": "The maximum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time until max time is reached", "min_on_time_per_day_min": "The minimum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time", - "offpeak_time": "The offpeak time with format HH:MM" + "offpeak_time": "Off-peak time ranges. Format: HH:MM-HH:MM or multiple ranges separated by commas. Examples: '22:00-06:00' or '13:00-14:00,22:00-06:00'", + "offpeak_entity_id": "Entity (binary_sensor, input_boolean, sensor, or calendar) that indicates off-peak periods. When 'on', it's off-peak time. Alternative to offpeak_time" } }, "powered_device": { @@ -234,7 +240,8 @@ "battery_soc_threshold": "Battery soc threshold", "max_on_time_per_day_min": "Max on time per day", "min_on_time_per_day_min": "Min on time per day", - "offpeak_time": "Offpeak time" + "offpeak_time": "Offpeak time ranges", + "offpeak_entity_id": "Offpeak sensor entity" }, "data_description": { "name": "The name of the device", @@ -256,7 +263,8 @@ "battery_soc_threshold": "The battery state of charge threshold to activate the device. Can be a number or a template", "max_on_time_per_day_min": "The maximum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time until max time is reached", "min_on_time_per_day_min": "The minimum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time", - "offpeak_time": "The offpeak time with format HH:MM" + "offpeak_time": "Off-peak time ranges. Format: HH:MM-HH:MM or multiple ranges separated by commas. Examples: '22:00-06:00' or '13:00-14:00,22:00-06:00'", + "offpeak_entity_id": "Entity (binary_sensor, input_boolean, sensor, or calendar) that indicates off-peak periods. When 'on', it's off-peak time. Alternative to offpeak_time" } } }, diff --git a/tests/test_offpeak_time_ranges.py b/tests/test_offpeak_time_ranges.py new file mode 100644 index 0000000..22d931a --- /dev/null +++ b/tests/test_offpeak_time_ranges.py @@ -0,0 +1,400 @@ +"""Unit tests for off-peak time ranges and entity-based off-peak detection""" + +# pylint: disable=protected-access + +from datetime import datetime, timedelta, time +from unittest.mock import patch, MagicMock + +import pytest + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN +from homeassistant.const import STATE_ON, STATE_OFF + +from custom_components.solar_optimizer.const import ( + get_tz, + parse_time_ranges, + CONF_NAME, + CONF_DEVICE_TYPE, + CONF_DEVICE, + CONF_ENTITY_ID, + CONF_POWER_MAX, + CONF_CHECK_USABLE_TEMPLATE, + CONF_DURATION_MIN, + CONF_DURATION_STOP_MIN, + CONF_ACTION_MODE, + CONF_ACTION_MODE_ACTION, + CONF_ACTIVATION_SERVICE, + CONF_DEACTIVATION_SERVICE, + CONF_BATTERY_SOC_THRESHOLD, + CONF_MAX_ON_TIME_PER_DAY_MIN, + CONF_MIN_ON_TIME_PER_DAY_MIN, + CONF_OFFPEAK_TIME, + CONF_OFFPEAK_ENTITY_ID, + DOMAIN, +) +from .commons import ( + create_managed_device, + search_entity, + create_test_input_boolean, + MockConfigEntry, + HomeAssistant, + SolarOptimizerCoordinator, + ManagedDevice, +) + + +class TestParseTimeRanges: + """Test the parse_time_ranges utility function""" + + def test_parse_single_range(self): + """Test parsing a single time range""" + ranges = parse_time_ranges("13:00-14:00") + assert len(ranges) == 1 + assert ranges[0] == (time(13, 0), time(14, 0)) + + def test_parse_multiple_ranges(self): + """Test parsing multiple time ranges""" + ranges = parse_time_ranges("13:00-14:00,22:00-06:00") + assert len(ranges) == 2 + assert ranges[0] == (time(13, 0), time(14, 0)) + assert ranges[1] == (time(22, 0), time(6, 0)) + + def test_parse_three_ranges(self): + """Test parsing three time ranges""" + ranges = parse_time_ranges("06:00-07:00,12:00-13:00,22:00-23:00") + assert len(ranges) == 3 + assert ranges[0] == (time(6, 0), time(7, 0)) + assert ranges[1] == (time(12, 0), time(13, 0)) + assert ranges[2] == (time(22, 0), time(23, 0)) + + def test_parse_empty_string(self): + """Test parsing an empty string""" + ranges = parse_time_ranges("") + assert len(ranges) == 0 + + def test_parse_none(self): + """Test parsing None""" + ranges = parse_time_ranges(None) + assert len(ranges) == 0 + + +async def test_offpeak_time_ranges_config( + hass: HomeAssistant, init_solar_optimizer_central_config +): + """Test configuration with time ranges format""" + + entry_a = MockConfigEntry( + domain=DOMAIN, + title="Equipement A", + unique_id="eqtAUniqueId", + data={ + CONF_NAME: "Equipement A", + CONF_DEVICE_TYPE: CONF_DEVICE, + CONF_ENTITY_ID: "input_boolean.fake_device_a", + CONF_POWER_MAX: 1000, + CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", + CONF_DURATION_MIN: 2, + CONF_DURATION_STOP_MIN: 1, + CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, + CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", + CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", + CONF_BATTERY_SOC_THRESHOLD: 30, + CONF_MAX_ON_TIME_PER_DAY_MIN: 10, + CONF_MIN_ON_TIME_PER_DAY_MIN: 5, + CONF_OFFPEAK_TIME: "13:00-14:00,22:00-06:00", + }, + ) + + device = await create_managed_device( + hass, + entry_a, + "equipement_a", + ) + assert device is not None + + assert device.name == "Equipement A" + assert device.offpeak_time is None # Legacy single time should be None + assert len(device.offpeak_time_ranges) == 2 + assert device.offpeak_time_ranges[0] == (time(13, 0), time(14, 0)) + assert device.offpeak_time_ranges[1] == (time(22, 0), time(6, 0)) + + +@pytest.mark.parametrize( + "current_datetime, should_be_offpeak", + [ + # Not in any range + (datetime(2024, 11, 10, 10, 0, 0), False), + (datetime(2024, 11, 10, 15, 0, 0), False), + (datetime(2024, 11, 10, 20, 0, 0), False), + # In first range (13:00-14:00) + (datetime(2024, 11, 10, 13, 0, 0), True), + (datetime(2024, 11, 10, 13, 30, 0), True), + (datetime(2024, 11, 10, 13, 59, 0), True), + # Just before/after first range + (datetime(2024, 11, 10, 12, 59, 0), False), + (datetime(2024, 11, 10, 14, 0, 0), False), + # In second range (22:00-06:00) - crosses midnight + (datetime(2024, 11, 10, 22, 0, 0), True), + (datetime(2024, 11, 10, 23, 30, 0), True), + (datetime(2024, 11, 11, 0, 0, 0), True), + (datetime(2024, 11, 11, 3, 0, 0), True), + (datetime(2024, 11, 11, 5, 59, 0), True), + # Just before/after second range + (datetime(2024, 11, 10, 21, 59, 0), False), + (datetime(2024, 11, 11, 6, 0, 0), False), + ], +) +async def test_offpeak_time_ranges_detection( + hass: HomeAssistant, + init_solar_optimizer_central_config, + current_datetime, + should_be_offpeak, +): + """Test off-peak detection with multiple time ranges""" + + entry_a = MockConfigEntry( + domain=DOMAIN, + title="Equipement A", + unique_id="eqtAUniqueId", + data={ + CONF_NAME: "Equipement A", + CONF_DEVICE_TYPE: CONF_DEVICE, + CONF_ENTITY_ID: "input_boolean.fake_device_a", + CONF_POWER_MAX: 1000, + CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", + CONF_DURATION_MIN: 2, + CONF_DURATION_STOP_MIN: 1, + CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, + CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", + CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", + CONF_BATTERY_SOC_THRESHOLD: 30, + CONF_MAX_ON_TIME_PER_DAY_MIN: 10, + CONF_MIN_ON_TIME_PER_DAY_MIN: 5, + CONF_OFFPEAK_TIME: "13:00-14:00,22:00-06:00", + }, + ) + + device = await create_managed_device( + hass, + entry_a, + "equipement_a", + ) + assert device is not None + + device._set_now(current_datetime.replace(tzinfo=get_tz(hass))) + # Make device available + device._next_date_available = device.now - timedelta(minutes=5) + + with patch( + "custom_components.solar_optimizer.managed_device.ManagedDevice.is_usable", + return_value=True, + ): + assert device.is_offpeak is should_be_offpeak + if should_be_offpeak: + assert device.should_be_forced_offpeak is True + + +async def test_offpeak_entity_config( + hass: HomeAssistant, init_solar_optimizer_central_config +): + """Test configuration with entity-based off-peak detection""" + + entry_a = MockConfigEntry( + domain=DOMAIN, + title="Equipement A", + unique_id="eqtAUniqueId", + data={ + CONF_NAME: "Equipement A", + CONF_DEVICE_TYPE: CONF_DEVICE, + CONF_ENTITY_ID: "input_boolean.fake_device_a", + CONF_POWER_MAX: 1000, + CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", + CONF_DURATION_MIN: 2, + CONF_DURATION_STOP_MIN: 1, + CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, + CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", + CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", + CONF_BATTERY_SOC_THRESHOLD: 30, + CONF_MAX_ON_TIME_PER_DAY_MIN: 10, + CONF_MIN_ON_TIME_PER_DAY_MIN: 5, + CONF_OFFPEAK_ENTITY_ID: "binary_sensor.offpeak_hours", + }, + ) + + device = await create_managed_device( + hass, + entry_a, + "equipement_a", + ) + assert device is not None + + assert device.name == "Equipement A" + assert device.offpeak_time is None + assert len(device.offpeak_time_ranges) == 0 + assert device.offpeak_entity_id == "binary_sensor.offpeak_hours" + + +@pytest.mark.parametrize( + "entity_state, should_be_offpeak", + [ + (STATE_ON, True), + ("on", True), + ("true", True), + ("1", True), + ("yes", True), + (STATE_OFF, False), + ("off", False), + ("false", False), + ("0", False), + ("no", False), + ("unavailable", False), + ("unknown", False), + ], +) +async def test_offpeak_entity_detection( + hass: HomeAssistant, + init_solar_optimizer_central_config, + entity_state, + should_be_offpeak, +): + """Test off-peak detection using entity state""" + + entry_a = MockConfigEntry( + domain=DOMAIN, + title="Equipement A", + unique_id="eqtAUniqueId", + data={ + CONF_NAME: "Equipement A", + CONF_DEVICE_TYPE: CONF_DEVICE, + CONF_ENTITY_ID: "input_boolean.fake_device_a", + CONF_POWER_MAX: 1000, + CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", + CONF_DURATION_MIN: 2, + CONF_DURATION_STOP_MIN: 1, + CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, + CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", + CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", + CONF_BATTERY_SOC_THRESHOLD: 30, + CONF_MAX_ON_TIME_PER_DAY_MIN: 10, + CONF_MIN_ON_TIME_PER_DAY_MIN: 5, + CONF_OFFPEAK_ENTITY_ID: "binary_sensor.offpeak_hours", + }, + ) + + device = await create_managed_device( + hass, + entry_a, + "equipement_a", + ) + assert device is not None + + now = datetime(2024, 11, 10, 10, 0, 0).replace(tzinfo=get_tz(hass)) + device._set_now(now) + device._next_date_available = device.now - timedelta(minutes=5) + + # Mock the entity state + mock_state = MagicMock() + mock_state.state = entity_state + hass.states._states["binary_sensor.offpeak_hours"] = mock_state + + with patch( + "custom_components.solar_optimizer.managed_device.ManagedDevice.is_usable", + return_value=True, + ): + assert device.is_offpeak is should_be_offpeak + if should_be_offpeak: + assert device.should_be_forced_offpeak is True + + +async def test_offpeak_entity_not_found( + hass: HomeAssistant, init_solar_optimizer_central_config +): + """Test off-peak detection when entity is not found""" + + entry_a = MockConfigEntry( + domain=DOMAIN, + title="Equipement A", + unique_id="eqtAUniqueId", + data={ + CONF_NAME: "Equipement A", + CONF_DEVICE_TYPE: CONF_DEVICE, + CONF_ENTITY_ID: "input_boolean.fake_device_a", + CONF_POWER_MAX: 1000, + CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", + CONF_DURATION_MIN: 2, + CONF_DURATION_STOP_MIN: 1, + CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, + CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", + CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", + CONF_BATTERY_SOC_THRESHOLD: 30, + CONF_MAX_ON_TIME_PER_DAY_MIN: 10, + CONF_MIN_ON_TIME_PER_DAY_MIN: 5, + CONF_OFFPEAK_ENTITY_ID: "binary_sensor.nonexistent", + }, + ) + + device = await create_managed_device( + hass, + entry_a, + "equipement_a", + ) + assert device is not None + + now = datetime(2024, 11, 10, 10, 0, 0).replace(tzinfo=get_tz(hass)) + device._set_now(now) + device._next_date_available = device.now - timedelta(minutes=5) + + # Entity doesn't exist, should return False + assert device.is_offpeak is False + + +async def test_legacy_offpeak_time_still_works( + hass: HomeAssistant, init_solar_optimizer_central_config +): + """Test that legacy single offpeak_time format still works""" + + entry_a = MockConfigEntry( + domain=DOMAIN, + title="Equipement A", + unique_id="eqtAUniqueId", + data={ + CONF_NAME: "Equipement A", + CONF_DEVICE_TYPE: CONF_DEVICE, + CONF_ENTITY_ID: "input_boolean.fake_device_a", + CONF_POWER_MAX: 1000, + CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", + CONF_DURATION_MIN: 2, + CONF_DURATION_STOP_MIN: 1, + CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, + CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", + CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", + CONF_BATTERY_SOC_THRESHOLD: 30, + CONF_MAX_ON_TIME_PER_DAY_MIN: 10, + CONF_MIN_ON_TIME_PER_DAY_MIN: 5, + CONF_OFFPEAK_TIME: "23:00", # Legacy format + }, + ) + + device = await create_managed_device( + hass, + entry_a, + "equipement_a", + ) + assert device is not None + + assert device.offpeak_time == time(23, 0) + assert len(device.offpeak_time_ranges) == 0 + assert device.offpeak_entity_id is None + + # Test that legacy logic still works + now = datetime(2024, 11, 10, 23, 30, 0).replace(tzinfo=get_tz(hass)) + device._set_now(now) + device._next_date_available = device.now - timedelta(minutes=5) + + with patch( + "custom_components.solar_optimizer.managed_device.ManagedDevice.is_usable", + return_value=True, + ): + assert device.is_offpeak is True + assert device.should_be_forced_offpeak is True