From 104cde40600598b721053729cbc0afdeb4d67040 Mon Sep 17 00:00:00 2001 From: Francis Labarre Date: Sat, 31 Jan 2026 14:51:39 -0800 Subject: [PATCH 1/4] [AI] Initial implementation for input_time support --- custom_components/climate_scheduler/common.py | 14 ++ custom_components/climate_scheduler/const.py | 1 + .../climate_scheduler/profile.py | 83 ++++++--- .../climate_scheduler/schedule.py | 69 ++++++- custom_components/climate_scheduler/switch.py | 31 +++- .../climate_scheduler/validation.py | 21 ++- tests/test_integration_input_time.py | 170 ++++++++++++++++++ tests/test_profile.py | 135 ++++++++++++-- tests/test_schedule.py | 98 ++++++++++ 9 files changed, 573 insertions(+), 49 deletions(-) create mode 100644 tests/test_integration_input_time.py create mode 100644 tests/test_schedule.py diff --git a/custom_components/climate_scheduler/common.py b/custom_components/climate_scheduler/common.py index 0e82e70..444dc52 100644 --- a/custom_components/climate_scheduler/common.py +++ b/custom_components/climate_scheduler/common.py @@ -1,8 +1,22 @@ """Common data structures for Climate Scheduler.""" from collections import namedtuple +from dataclasses import dataclass +from datetime import timedelta +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .schedule import ClimateSchedulerSchedule ComputedClimateData = namedtuple( "ComputedClimateData", ["hvac_mode", "fan_mode", "swing_mode", "min_temp", "max_temp"], ) + + +@dataclass +class ResolvedScheduleEntry: + """Entry with resolved time.""" + + time: timedelta + schedule: "ClimateSchedulerSchedule" diff --git a/custom_components/climate_scheduler/const.py b/custom_components/climate_scheduler/const.py index a47aa06..2a73258 100644 --- a/custom_components/climate_scheduler/const.py +++ b/custom_components/climate_scheduler/const.py @@ -22,6 +22,7 @@ CONF_SCHEDULE_MAX_TEMP = "max_temp" CONF_SCHEDULE_FAN_MODE = "fan_mode" CONF_SCHEDULE_SWING_MODE = "swing_mode" +CONF_SCHEDULE_OFFSET = "offset" ATTR_IS_ON = "is_on" ATTR_PROFILE = "current_profile" diff --git a/custom_components/climate_scheduler/profile.py b/custom_components/climate_scheduler/profile.py index 5c2366f..24840f3 100644 --- a/custom_components/climate_scheduler/profile.py +++ b/custom_components/climate_scheduler/profile.py @@ -1,12 +1,14 @@ """Profile class for Climate Scheduler.""" +import logging from datetime import timedelta import homeassistant.helpers.config_validation as cv import voluptuous as vol from homeassistant.components.climate import HVAC_MODES +from homeassistant.core import HomeAssistant -from .common import ComputedClimateData +from .common import ComputedClimateData, ResolvedScheduleEntry from .const import ( CONF_PROFILE_DEFAULT_FAN_MODE, CONF_PROFILE_DEFAULT_HVAC_MODE, @@ -19,6 +21,8 @@ from .schedule import SCHEDULE_SCHEMA, ClimateSchedulerSchedule from .validation import unique_schedule_times +_LOGGER = logging.getLogger(__name__) + PROFILES_SCHEMA = vol.Schema( [ { @@ -48,16 +52,25 @@ def __init__(self, config: dict) -> None: self._default_max_temp = config.get(CONF_PROFILE_DEFAULT_MAX_TEMP) self._schedules = [ClimateSchedulerSchedule(c) for c in config.get(CONF_PROFILE_SCHEDULE)] - self._schedules.sort(key=lambda x: x.time.total_seconds()) @property def profile_id(self) -> str: """Return the profile ID.""" return self._id - def compute_climate(self, time_of_day: timedelta) -> ComputedClimateData: + def get_time_entities(self) -> set[str]: + """Return a set of entity IDs used in this profile's schedules.""" + entities = set() + for schedule in self._schedules: + if schedule.entity_id: + entities.add(schedule.entity_id) + return entities + + def compute_climate(self, time_of_day: timedelta, hass: HomeAssistant) -> ComputedClimateData: """Compute the climate settings for a specific time of day.""" - schedule = self._find_schedule(time_of_day) + resolved_schedules = self._resolve_schedules(hass) + schedule = self._find_schedule(time_of_day, resolved_schedules) + if schedule is None: return ComputedClimateData( self._default_hvac_mode, @@ -75,30 +88,58 @@ def compute_climate(self, time_of_day: timedelta) -> ComputedClimateData: schedule.max_temp if schedule.max_temp else self._default_max_temp, ) - def get_trigger_times(self) -> list[timedelta]: + def get_trigger_times(self, hass: HomeAssistant) -> list[timedelta]: """Return a list of times when the schedule changes.""" - return [s.time for s in self._schedules] - - def _find_schedule(self, time_of_day: timedelta) -> ClimateSchedulerSchedule | None: - if len(self._schedules) == 0: + # Use a dict to deduplicate times, then return sorted list + # or just set + resolved = self._resolve_schedules(hass) + return sorted(list({entry.time for entry in resolved})) + + def _resolve_schedules(self, hass: HomeAssistant) -> list[ResolvedScheduleEntry]: + """Resolve all schedules to static times and sort them.""" + resolved_entries = [] + for schedule in self._schedules: + time = schedule.resolve_time(hass) + if time is not None: + resolved_entries.append(ResolvedScheduleEntry(time, schedule)) + + resolved_entries.sort(key=lambda x: x.time.total_seconds()) + + # Check for collisions + if len(resolved_entries) > 1: + for i in range(len(resolved_entries) - 1): + if resolved_entries[i].time == resolved_entries[i + 1].time: + _LOGGER.warning( + "Collision detected in profile %s for time %s. Using the last defined schedule.", + self._id, + resolved_entries[i].time, + ) + + return resolved_entries + + def _find_schedule( + self, time_of_day: timedelta, resolved_schedules: list[ResolvedScheduleEntry] + ) -> ClimateSchedulerSchedule | None: + if len(resolved_schedules) == 0: return None - if len(self._schedules) == 1: - return self._schedules[0] + if len(resolved_schedules) == 1: + return resolved_schedules[0].schedule # If the current time is earlier than the first schedule, wrap around and # return the last schedule of the day - if time_of_day < self._schedules[0].time: - return self._schedules[-1] + if time_of_day < resolved_schedules[0].time: + return resolved_schedules[-1].schedule - for index, schedule in enumerate(self._schedules): - # Search for a schedule starting earlier than the current time of day - # which appears right before a schedule which starts later than the - # current time of the day or which is the last schedule of the day. + for index, entry in enumerate(resolved_schedules): + schedule = entry.schedule + schedule_time = entry.time - next_schedule = None - if index < len(self._schedules) - 1: - next_schedule = self._schedules[index + 1] + next_entry = None + if index < len(resolved_schedules) - 1: + next_entry = resolved_schedules[index + 1] - if time_of_day >= schedule.time and (next_schedule is None or time_of_day < next_schedule.time): + if time_of_day >= schedule_time and (next_entry is None or time_of_day < next_entry.time): return schedule + + return None diff --git a/custom_components/climate_scheduler/schedule.py b/custom_components/climate_scheduler/schedule.py index 24dc3d0..d086865 100644 --- a/custom_components/climate_scheduler/schedule.py +++ b/custom_components/climate_scheduler/schedule.py @@ -1,28 +1,35 @@ """Schedule class for Climate Scheduler.""" +import logging from datetime import timedelta import homeassistant.helpers.config_validation as cv import voluptuous as vol from homeassistant.components.climate import HVAC_MODES +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import parse_time from .const import ( CONF_SCHEDULE_FAN_MODE, CONF_SCHEDULE_HVAC, CONF_SCHEDULE_MAX_TEMP, CONF_SCHEDULE_MIN_TEMP, + CONF_SCHEDULE_OFFSET, CONF_SCHEDULE_SWING_MODE, CONF_SCHEDULE_TIME, ) -from .validation import less_than_24h +from .validation import valid_offset, valid_time_or_entity + +_LOGGER = logging.getLogger(__name__) + SCHEDULE_SCHEMA = vol.Schema( [ { vol.Required(CONF_SCHEDULE_TIME): vol.All( - cv.positive_time_period, - less_than_24h, + valid_time_or_entity, ), + vol.Optional(CONF_SCHEDULE_OFFSET): valid_offset, vol.Optional(CONF_SCHEDULE_HVAC): vol.All(cv.string, vol.In(HVAC_MODES)), vol.Optional(CONF_SCHEDULE_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_SCHEDULE_MAX_TEMP): vol.Coerce(float), @@ -38,7 +45,21 @@ class ClimateSchedulerSchedule: def __init__(self, config: dict) -> None: """Initialize the schedule.""" - self._time: timedelta = config.get(CONF_SCHEDULE_TIME) + self._static_time: timedelta | None = None + self._time_entity_id: str | None = None + self._offset: timedelta = timedelta(0) + self._parse_time_config(config) + + def _parse_time_config(self, config: dict) -> None: + """Parse time configuration.""" + time_val = config.get(CONF_SCHEDULE_TIME) + if isinstance(time_val, timedelta): + self._static_time = time_val + else: + self._time_entity_id = time_val + + self._offset = config.get(CONF_SCHEDULE_OFFSET, timedelta(0)) + self._hvac_mode: str | None = config.get(CONF_SCHEDULE_HVAC) self._fan_mode: str | None = config.get(CONF_SCHEDULE_FAN_MODE) self._swing_mode: str | None = config.get(CONF_SCHEDULE_SWING_MODE) @@ -46,9 +67,14 @@ def __init__(self, config: dict) -> None: self._max_temp: int | None = config.get(CONF_SCHEDULE_MAX_TEMP) @property - def time(self) -> timedelta: - """Return the time of the schedule.""" - return self._time + def is_dynamic(self) -> bool: + """Return True if the schedule time depends on an entity.""" + return self._time_entity_id is not None + + @property + def entity_id(self) -> str | None: + """Return the entity ID if dynamic.""" + return self._time_entity_id @property def hvac_mode(self) -> str | None: @@ -74,3 +100,32 @@ def min_temp(self) -> float | None: def max_temp(self) -> float | None: """Return the max temp.""" return self._max_temp + + def resolve_time(self, hass: HomeAssistant) -> timedelta | None: + """Resolve the schedule time, accounting for entities and offsets.""" + base_time = self._static_time + + if self._time_entity_id: + state = hass.states.get(self._time_entity_id) + print(f"DEBUG: resolving {self._time_entity_id}, state found: {state}") + if state is None: + _LOGGER.warning("Entity %s not found for schedule", self._time_entity_id) + return None + + # parse_time handles strings like "10:00:00" + parsed = parse_time(state.state) + if parsed is None: + _LOGGER.warning("Invalid time state %s for entity %s", state.state, self._time_entity_id) + return None + + base_time = timedelta(hours=parsed.hour, minutes=parsed.minute, seconds=parsed.second) + + if base_time is None: + return None + + # Apply offset + final_time = base_time + self._offset + + # Handle wrap around (modulo 24h) + total_seconds = final_time.total_seconds() % 86400 + return timedelta(seconds=total_seconds) diff --git a/custom_components/climate_scheduler/switch.py b/custom_components/climate_scheduler/switch.py index b35e8ab..fbcf9a0 100644 --- a/custom_components/climate_scheduler/switch.py +++ b/custom_components/climate_scheduler/switch.py @@ -128,6 +128,7 @@ def __init__(self, hass: HomeAssistant, cs: ClimateScheduler, config: dict) -> N hass, self.async_update_climate, self._update_interval ) self._schedule_tracker_remove_callbacks: list[Callable[[], None]] = [] + self._entity_tracker_remove_callbacks: list[Callable[[], None]] = [] self._update_schedule_trackers() logging.info(f"Initialized Climate Scheduler switch {self.entity_id}") @@ -264,10 +265,15 @@ def _update_schedule_trackers(self): # Clear any previous schedule trackers for remove_callback in self._schedule_tracker_remove_callbacks: remove_callback() - - # Register new trackers self._schedule_tracker_remove_callbacks = [] - for schedule in self._current_profile.get_trigger_times(): + + # Clear any previous entity trackers + for remove_callback in self._entity_tracker_remove_callbacks: + remove_callback() + self._entity_tracker_remove_callbacks = [] + + # Register new time trackers + for schedule in self._current_profile.get_trigger_times(self._hass): self._schedule_tracker_remove_callbacks.append( async_track_time_change( self._hass, @@ -278,6 +284,23 @@ def _update_schedule_trackers(self): ) ) + # Register new entity trackers + for entity_id in self._current_profile.get_time_entities(): + self._entity_tracker_remove_callbacks.append( + async_track_state_change_event( + self._hass, + [entity_id], + self._async_on_time_entity_change, + ) + ) + + async def _async_on_time_entity_change(self, event): + """Called when a time entity changes.""" + # When a time entity changes, we need to re-register the time trackers + # because the schedule times have changed. + self._update_schedule_trackers() + await self.async_update_climate() + async def async_turn_on(self, **kwargs) -> None: _LOGGER.info(self.entity_id + ": Turn on") @@ -309,7 +332,7 @@ async def async_update_climate(self, *args, **kwargs) -> None: dt = now() time_of_day = timedelta(hours=dt.hour, minutes=dt.minute, seconds=dt.second) - climate_data = self._current_profile.compute_climate(time_of_day) + climate_data = self._current_profile.compute_climate(time_of_day, self._hass) update_tasks = [ asyncio.create_task(self._async_update_climate_entity(entity, climate_data)) diff --git a/custom_components/climate_scheduler/validation.py b/custom_components/climate_scheduler/validation.py index c343152..7407a07 100644 --- a/custom_components/climate_scheduler/validation.py +++ b/custom_components/climate_scheduler/validation.py @@ -2,11 +2,30 @@ from datetime import timedelta +import homeassistant.helpers.config_validation as cv import voluptuous as vol from .const import CONF_PROFILE_ID, CONF_SCHEDULE_TIME +def valid_time_or_entity(value): + """Validate that value is a time period or an entity ID.""" + try: + return cv.positive_time_period(value) + except vol.Invalid: + pass + + return cv.entity_id(value) + + +def valid_offset(value): + """Validate that value is a time offset (can be negative, max +/- 24h).""" + offset = cv.time_period(value) + if abs(offset.total_seconds()) > 86400: + raise vol.Invalid("Offset cannot exceed 24 hours") + return offset + + def less_than_24h(delta: timedelta) -> timedelta: """Validate that a duration is less than 24 hours.""" if delta.total_seconds() >= 24 * 60 * 60: @@ -24,7 +43,7 @@ def unique_profiles(profiles: dict) -> dict: def unique_schedule_times(schedules: dict) -> dict: """Validate that schedule times are unique within a profile.""" - times = [s.get(CONF_SCHEDULE_TIME).total_seconds() for s in schedules] + times = [s.get(CONF_SCHEDULE_TIME) for s in schedules] if (len(times)) != len(set(times)): raise vol.Invalid("Schedule times must be unique within a profile") return schedules diff --git a/tests/test_integration_input_time.py b/tests/test_integration_input_time.py new file mode 100644 index 0000000..f1371e9 --- /dev/null +++ b/tests/test_integration_input_time.py @@ -0,0 +1,170 @@ +"""Integration tests for input_time support.""" + +from datetime import timedelta +from unittest.mock import patch + +import pytest +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + SERVICE_SET_HVAC_MODE, + HVACMode, +) +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_NAME, + CONF_PLATFORM, + SERVICE_TURN_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util +from pytest_homeassistant_custom_component.common import ( + async_fire_time_changed, + async_mock_service, + mock_component, +) + +from custom_components.climate_scheduler import DOMAIN +from custom_components.climate_scheduler.const import ( + CONF_CLIMATE_ENTITIES, + CONF_PROFILE_ID, + CONF_PROFILE_SCHEDULE, + CONF_PROFILES, + CONF_SCHEDULE_HVAC, + CONF_SCHEDULE_OFFSET, + CONF_SCHEDULE_TIME, +) + + +@pytest.fixture +def input_time_config(): + return { + CONF_PLATFORM: "climate_scheduler", + CONF_NAME: "Input Time Scheduler", + CONF_CLIMATE_ENTITIES: ["climate.test_ac"], + CONF_PROFILES: [ + { + CONF_PROFILE_ID: "Dynamic", + CONF_PROFILE_SCHEDULE: [ + { + CONF_SCHEDULE_TIME: "input_datetime.wake_up", + CONF_SCHEDULE_HVAC: HVACMode.HEAT, + }, + { + CONF_SCHEDULE_TIME: "input_datetime.sleep", + CONF_SCHEDULE_OFFSET: timedelta(minutes=-30), # 30 min before sleep + CONF_SCHEDULE_HVAC: HVACMode.COOL, + }, + ], + }, + ], + } + + +async def async_setup_scheduler(hass, config): + # Mock climate entity + mock_component(hass, "climate") + hass.states.async_set("climate.test_ac", HVACMode.OFF) + + # Mock input_datetime entities + hass.states.async_set("input_datetime.wake_up", "07:00:00") + hass.states.async_set("input_datetime.sleep", "22:00:00") + + full_config = {DOMAIN: {}, SWITCH_DOMAIN: [config]} + + assert await async_setup_component(hass, DOMAIN, full_config) + assert await async_setup_component(hass, SWITCH_DOMAIN, full_config) + await hass.async_block_till_done() + + +async def test_input_time_initial_state(hass: HomeAssistant, input_time_config): + """Test initial state based on input times.""" + mock_set_hvac = async_mock_service(hass, "climate", SERVICE_SET_HVAC_MODE) + await async_setup_scheduler(hass, input_time_config) + entity_id = "switch.climate_scheduler_input_time_scheduler" + + # Turn On + await hass.services.async_call(SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True) + await hass.async_block_till_done() + + # Time: 08:00 (After wake up 07:00) -> HEAT + target_time = dt_util.now().replace(hour=8, minute=0, second=0, microsecond=0) + with patch("custom_components.climate_scheduler.switch.now", return_value=target_time): + async_fire_time_changed(hass, target_time) + await hass.async_block_till_done() + + assert mock_set_hvac[-1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_input_time_entity_update(hass: HomeAssistant, input_time_config): + """Test response to input_datetime entity changes.""" + mock_set_hvac = async_mock_service(hass, "climate", SERVICE_SET_HVAC_MODE) + await async_setup_scheduler(hass, input_time_config) + entity_id = "switch.climate_scheduler_input_time_scheduler" + await hass.services.async_call(SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True) + await hass.async_block_till_done() + + # Initial state check logic omitted for brevity, assuming initial setup is correct + target_time = dt_util.now().replace(hour=8, minute=0, second=0, microsecond=0) + + # Change wake_up time to 09:00 (so 08:00 is now BEFORE wake up, likely wrapping to previous day's sleep schedule) + # Sleep is 22:00 - 30m = 21:30. + # So 08:00 is between 21:30 (yesterday) and 09:00. Should be COOL. + mock_set_hvac.clear() + + # We must ensure 'now' is still 08:00 when the update triggers + with patch("custom_components.climate_scheduler.switch.now", return_value=target_time): + hass.states.async_set("input_datetime.wake_up", "09:00:00") + await hass.async_block_till_done() + + assert len(mock_set_hvac) > 0, "Update not triggered on entity change" + assert mock_set_hvac[-1].data[ATTR_HVAC_MODE] == HVACMode.COOL + + +async def test_input_time_schedule_trigger(hass: HomeAssistant, input_time_config): + """Test that schedules trigger at dynamic times.""" + mock_set_hvac = async_mock_service(hass, "climate", SERVICE_SET_HVAC_MODE) + await async_setup_scheduler(hass, input_time_config) + entity_id = "switch.climate_scheduler_input_time_scheduler" + await hass.services.async_call(SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True) + await hass.async_block_till_done() + + # Move time to 09:00:00. This assumes wake_up is default 07:00 unless we change it. + # Wait, the config fixture sets it to 07:00 initially. + # Let's verify trigger at 07:00:00 + target_time = dt_util.now().replace(hour=7, minute=0, second=0, microsecond=0) + + mock_set_hvac.clear() + with patch("custom_components.climate_scheduler.switch.now", return_value=target_time): + async_fire_time_changed(hass, target_time, fire_all=True) + await hass.async_block_till_done() + + assert len(mock_set_hvac) > 0, "Update not triggered on schedule time" + assert mock_set_hvac[-1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_offset_handling(hass: HomeAssistant, input_time_config): + mock_set_hvac = async_mock_service(hass, "climate", SERVICE_SET_HVAC_MODE) + + await async_setup_scheduler(hass, input_time_config) + entity_id = "switch.climate_scheduler_input_time_scheduler" + await hass.services.async_call(SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True) + await hass.async_block_till_done() + + # Sleep is 22:00. with -30m offset = 21:30. + # At 21:29 -> HEAT (from wake up, presumably) + target_time = dt_util.now().replace(hour=21, minute=29, second=0, microsecond=0) + with patch("custom_components.climate_scheduler.switch.now", return_value=target_time): + async_fire_time_changed(hass, target_time) + await hass.async_block_till_done() + + assert mock_set_hvac[-1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + # At 21:31 -> COOL + target_time_2 = dt_util.now().replace(hour=21, minute=31, second=0, microsecond=0) + with patch("custom_components.climate_scheduler.switch.now", return_value=target_time_2): + async_fire_time_changed(hass, target_time_2) + await hass.async_block_till_done() + + assert mock_set_hvac[-1].data[ATTR_HVAC_MODE] == HVACMode.COOL diff --git a/tests/test_profile.py b/tests/test_profile.py index f7a68d2..52fac28 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -1,4 +1,7 @@ from datetime import timedelta +from unittest.mock import MagicMock + +import pytest from custom_components.climate_scheduler.const import ( CONF_PROFILE_DEFAULT_FAN_MODE, @@ -14,7 +17,14 @@ from custom_components.climate_scheduler.profile import ClimateSchedulerProfile -def test_profile_no_schedule_uses_defaults(): +@pytest.fixture +def mock_hass(): + hass = MagicMock() + hass.states = MagicMock() + return hass + + +def test_profile_no_schedule_uses_defaults(mock_hass): config = { CONF_PROFILE_ID: "test", CONF_PROFILE_DEFAULT_HVAC_MODE: "heat", @@ -25,7 +35,7 @@ def test_profile_no_schedule_uses_defaults(): CONF_PROFILE_SCHEDULE: [], } profile = ClimateSchedulerProfile(config) - data = profile.compute_climate(timedelta(hours=12)) + data = profile.compute_climate(timedelta(hours=12), mock_hass) assert data.hvac_mode == "heat" assert data.fan_mode == "auto" @@ -34,7 +44,7 @@ def test_profile_no_schedule_uses_defaults(): assert data.max_temp == 25.0 -def test_profile_schedules_sorted(): +def test_profile_schedules_sorted(mock_hass): config = { CONF_PROFILE_ID: "test", CONF_PROFILE_SCHEDULE: [ @@ -43,12 +53,11 @@ def test_profile_schedules_sorted(): ], } profile = ClimateSchedulerProfile(config) - # Access private _schedules to verify sort order directly or check behavior - triggers = profile.get_trigger_times() + triggers = profile.get_trigger_times(mock_hass) assert triggers == [timedelta(hours=8), timedelta(hours=14)] -def test_profile_wraps_around_to_last_schedule(): +def test_profile_wraps_around_to_last_schedule(mock_hass): # If the current time is earlier than the very first schedule, # wrap around to last schedule of the day. config = { @@ -63,12 +72,12 @@ def test_profile_wraps_around_to_last_schedule(): profile = ClimateSchedulerProfile(config) # Time before first schedule (e.g., 6 AM) - data = profile.compute_climate(timedelta(hours=6)) + data = profile.compute_climate(timedelta(hours=6), mock_hass) # Should find the last schedule of the day (cool) assert data.hvac_mode == "cool" -def test_profile_finds_correct_schedule_mid_day(): +def test_profile_finds_correct_schedule_mid_day(mock_hass): config = { CONF_PROFILE_ID: "test", CONF_PROFILE_SCHEDULE: [ @@ -79,11 +88,11 @@ def test_profile_finds_correct_schedule_mid_day(): profile = ClimateSchedulerProfile(config) # Time between schedules (e.g., 12 PM) - data = profile.compute_climate(timedelta(hours=12)) + data = profile.compute_climate(timedelta(hours=12), mock_hass) assert data.hvac_mode == "heat" -def test_profile_matches_exact_start_time(): +def test_profile_matches_exact_start_time(mock_hass): config = { CONF_PROFILE_ID: "test", CONF_PROFILE_SCHEDULE: [ @@ -93,11 +102,11 @@ def test_profile_matches_exact_start_time(): profile = ClimateSchedulerProfile(config) # Exactly 8 AM - data = profile.compute_climate(timedelta(hours=8)) + data = profile.compute_climate(timedelta(hours=8), mock_hass) assert data.hvac_mode == "heat" -def test_profile_only_one_schedule(): +def test_profile_only_one_schedule(mock_hass): config = { CONF_PROFILE_ID: "test", CONF_PROFILE_SCHEDULE: [ @@ -107,12 +116,12 @@ def test_profile_only_one_schedule(): profile = ClimateSchedulerProfile(config) # Before - assert profile.compute_climate(timedelta(hours=6)).hvac_mode == "heat" + assert profile.compute_climate(timedelta(hours=6), mock_hass).hvac_mode == "heat" # After - assert profile.compute_climate(timedelta(hours=12)).hvac_mode == "heat" + assert profile.compute_climate(timedelta(hours=12), mock_hass).hvac_mode == "heat" -def test_profile_schedule_fallback_to_defaults(): +def test_profile_schedule_fallback_to_defaults(mock_hass): # Falling back to a schedule's default values if specific schedule entry omits them config = { CONF_PROFILE_ID: "test", @@ -128,6 +137,100 @@ def test_profile_schedule_fallback_to_defaults(): } profile = ClimateSchedulerProfile(config) - data = profile.compute_climate(timedelta(hours=10)) + data = profile.compute_climate(timedelta(hours=10), mock_hass) assert data.hvac_mode == "heat" assert data.fan_mode == "low" # fallback + + +def test_profile_dynamic_time_resolution(mock_hass): + """Test that dynamic schedules are resolved correctly.""" + mock_hass.states.get.return_value.state = "10:00:00" + + config = { + CONF_PROFILE_ID: "test", + CONF_PROFILE_SCHEDULE: [ + {CONF_SCHEDULE_TIME: "input_datetime.test", CONF_SCHEDULE_HVAC: "dynamic"}, + ], + } + profile = ClimateSchedulerProfile(config) + + triggers = profile.get_trigger_times(mock_hass) + assert triggers == [timedelta(hours=10)] + + data = profile.compute_climate(timedelta(hours=10, minutes=1), mock_hass) + assert data.hvac_mode == "dynamic" + + +def test_profile_dynamic_time_changing(mock_hass): + """Test that schedule order updates when entity time changes.""" + mock_hass.states.get.return_value.state = "10:00:00" + + config = { + CONF_PROFILE_ID: "test", + CONF_PROFILE_SCHEDULE: [ + {CONF_SCHEDULE_TIME: timedelta(hours=12), CONF_SCHEDULE_HVAC: "static"}, + {CONF_SCHEDULE_TIME: "input_datetime.test", CONF_SCHEDULE_HVAC: "dynamic"}, + ], + } + profile = ClimateSchedulerProfile(config) + + # Initial state: dynamic (10:00) < static (12:00) + assert profile.get_trigger_times(mock_hass) == [timedelta(hours=10), timedelta(hours=12)] + + # Change entity time to 13:00 -> static (12:00) < dynamic (13:00) + mock_hass.states.get.return_value.state = "13:00:00" + assert profile.get_trigger_times(mock_hass) == [timedelta(hours=12), timedelta(hours=13)] + + +def test_profile_mixed_static_dynamic_input(mock_hass): + """Test mixed input types interaction and wrapping.""" + mock_hass.states.get.return_value.state = "13:00:00" + + config = { + CONF_PROFILE_ID: "test", + CONF_PROFILE_SCHEDULE: [ + {CONF_SCHEDULE_TIME: timedelta(hours=12), CONF_SCHEDULE_HVAC: "static"}, + {CONF_SCHEDULE_TIME: "input_datetime.test", CONF_SCHEDULE_HVAC: "dynamic"}, + ], + } + profile = ClimateSchedulerProfile(config) + + # List is [12:00 (static), 13:00 (dynamic)] + + # At 11:00 (before first), should wrap to last (dynamic) + data_wrap = profile.compute_climate(timedelta(hours=11), mock_hass) + assert data_wrap.hvac_mode == "dynamic" + + # At 12:30 (between), should be static + data_mid = profile.compute_climate(timedelta(hours=12, minutes=30), mock_hass) + assert data_mid.hvac_mode == "static" + + +def test_profile_collision_handling(mock_hass, caplog): + """Test that collisions are detected and the last schedule is used.""" + import logging + + config = { + CONF_PROFILE_ID: "test_collision", + CONF_PROFILE_SCHEDULE: [ + {CONF_SCHEDULE_TIME: timedelta(hours=10), CONF_SCHEDULE_HVAC: "first"}, + # Same time collision + {CONF_SCHEDULE_TIME: timedelta(hours=10), CONF_SCHEDULE_HVAC: "second"}, + ], + } + + profile = ClimateSchedulerProfile(config) + + with caplog.at_level(logging.WARNING): + profile.get_trigger_times(mock_hass) + + # Check if collision warning was logged + assert "Collision detected" in caplog.text + + # Check that "second" won (it's the last one in the resolved list for that time) + # The sort is stable, so original order should be preserved for equal times if Python's sort is used. + # However, get_trigger_times returns distinct times. + # We check compute_climate to see which one is picked. + + data = profile.compute_climate(timedelta(hours=10), mock_hass) + assert data.hvac_mode == "second" diff --git a/tests/test_schedule.py b/tests/test_schedule.py new file mode 100644 index 0000000..628006d --- /dev/null +++ b/tests/test_schedule.py @@ -0,0 +1,98 @@ +"""Test schedule time resolution.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +import pytest + +from custom_components.climate_scheduler.const import ( + CONF_SCHEDULE_OFFSET, + CONF_SCHEDULE_TIME, +) +from custom_components.climate_scheduler.schedule import ClimateSchedulerSchedule + + +@pytest.fixture +def mock_hass(): + hass = MagicMock() + hass.states = MagicMock() + return hass + + +def test_static_time_resolution(mock_hass): + config = {CONF_SCHEDULE_TIME: timedelta(hours=10)} + schedule = ClimateSchedulerSchedule(config) + assert not schedule.is_dynamic + + resolved = schedule.resolve_time(mock_hass) + assert resolved == timedelta(hours=10) + + +def test_entity_time_resolution(mock_hass): + mock_hass.states.get.return_value.state = "10:30:00" + config = {CONF_SCHEDULE_TIME: "input_datetime.test"} + schedule = ClimateSchedulerSchedule(config) + assert schedule.is_dynamic + assert schedule.entity_id == "input_datetime.test" + + resolved = schedule.resolve_time(mock_hass) + assert resolved == timedelta(hours=10, minutes=30) + mock_hass.states.get.assert_called_with("input_datetime.test") + + +def test_entity_resolution_with_offset(mock_hass): + mock_hass.states.get.return_value.state = "10:00:00" + config = { + CONF_SCHEDULE_TIME: "input_datetime.test", + # -30 minutes + CONF_SCHEDULE_OFFSET: timedelta(minutes=-30), + } + schedule = ClimateSchedulerSchedule(config) + + resolved = schedule.resolve_time(mock_hass) + assert resolved == timedelta(hours=9, minutes=30) + + +def test_offset_wrapping(mock_hass): + mock_hass.states.get.return_value.state = "00:10:00" + config = {CONF_SCHEDULE_TIME: "input_datetime.test", CONF_SCHEDULE_OFFSET: timedelta(minutes=-20)} + schedule = ClimateSchedulerSchedule(config) + + resolved = schedule.resolve_time(mock_hass) + # Should wrap to previous day: 23:50 + assert resolved == timedelta(hours=23, minutes=50) + + +def test_missing_entity_returns_none(mock_hass): + mock_hass.states.get.return_value = None + config = {CONF_SCHEDULE_TIME: "input_datetime.missing"} + schedule = ClimateSchedulerSchedule(config) + + resolved = schedule.resolve_time(mock_hass) + assert resolved is None + + +def test_invalid_entity_state_returns_none(mock_hass): + mock_hass.states.get.return_value.state = "invalid" + config = {CONF_SCHEDULE_TIME: "input_datetime.invalid"} + schedule = ClimateSchedulerSchedule(config) + + resolved = schedule.resolve_time(mock_hass) + assert resolved is None + + +def test_invalid_offset_validation(): + """Test that offsets greater than 24h are invalid.""" + import voluptuous as vol + + from custom_components.climate_scheduler.validation import valid_offset + + # Valid + assert valid_offset(timedelta(hours=23)) == timedelta(hours=23) + assert valid_offset(timedelta(hours=-23)) == timedelta(hours=-23) + + # Invalid + with pytest.raises(vol.Invalid): + valid_offset(timedelta(hours=25)) + with pytest.raises(vol.Invalid): + valid_offset(timedelta(hours=-25)) From 44ef0a19546889c5b56fd885ec7dcc9c825e8e1b Mon Sep 17 00:00:00 2001 From: Francis Labarre Date: Sat, 31 Jan 2026 15:05:47 -0800 Subject: [PATCH 2/4] Add input_datetime dependency to manifest --- custom_components/climate_scheduler/manifest.json | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/custom_components/climate_scheduler/manifest.json b/custom_components/climate_scheduler/manifest.json index 082dc9c..027eb3a 100644 --- a/custom_components/climate_scheduler/manifest.json +++ b/custom_components/climate_scheduler/manifest.json @@ -4,8 +4,15 @@ "version": "0.1.0", "documentation": "https://github.com/FrancisLab/hass-climate-scheduler", "issue_tracker": "https://github.com/FrancisLab/hass-climate-scheduler/issues", - "dependencies": ["input_select", "switch", "climate"], - "codeowners": ["@FrancisLab"], + "dependencies": [ + "input_select", + "switch", + "climate", + "input_datetime" + ], + "codeowners": [ + "@FrancisLab" + ], "config_flow": false, - "iot_class" : "calculated" + "iot_class": "calculated" } From 661ae78b9e3b63ed003c2b621fc9c5c25d0ec3f4 Mon Sep 17 00:00:00 2001 From: Francis Labarre Date: Sat, 31 Jan 2026 15:22:50 -0800 Subject: [PATCH 3/4] [AI] Add some debug logs --- custom_components/climate_scheduler/profile.py | 7 +++++++ custom_components/climate_scheduler/schedule.py | 5 +++++ custom_components/climate_scheduler/switch.py | 9 ++++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/custom_components/climate_scheduler/profile.py b/custom_components/climate_scheduler/profile.py index 24840f3..da0885e 100644 --- a/custom_components/climate_scheduler/profile.py +++ b/custom_components/climate_scheduler/profile.py @@ -71,6 +71,13 @@ def compute_climate(self, time_of_day: timedelta, hass: HomeAssistant) -> Comput resolved_schedules = self._resolve_schedules(hass) schedule = self._find_schedule(time_of_day, resolved_schedules) + _LOGGER.debug( + "Computed climate for profile %s at %s. Using schedule: %s", + self._id, + time_of_day, + schedule, + ) + if schedule is None: return ComputedClimateData( self._default_hvac_mode, diff --git a/custom_components/climate_scheduler/schedule.py b/custom_components/climate_scheduler/schedule.py index d086865..2266e8f 100644 --- a/custom_components/climate_scheduler/schedule.py +++ b/custom_components/climate_scheduler/schedule.py @@ -129,3 +129,8 @@ def resolve_time(self, hass: HomeAssistant) -> timedelta | None: # Handle wrap around (modulo 24h) total_seconds = final_time.total_seconds() % 86400 return timedelta(seconds=total_seconds) + + def __str__(self) -> str: + if self._time_entity_id: + return f"Dynamic: {self._time_entity_id} + {self._offset}" + return f"Static: {self._static_time} + {self._offset}" diff --git a/custom_components/climate_scheduler/switch.py b/custom_components/climate_scheduler/switch.py index fbcf9a0..4db4f11 100644 --- a/custom_components/climate_scheduler/switch.py +++ b/custom_components/climate_scheduler/switch.py @@ -251,6 +251,7 @@ async def _async_update_profile(self, new_profile_id: str) -> None: return self._current_profile = self._profiles.get(new_profile_id) + _LOGGER.debug(f"Profile updated to {new_profile_id}") self._update_schedule_trackers() await self.async_update_climate() @@ -277,7 +278,7 @@ def _update_schedule_trackers(self): self._schedule_tracker_remove_callbacks.append( async_track_time_change( self._hass, - self.async_update_climate, + self._async_on_schedule_time_trigger, hour=schedule.seconds // 3600, minute=schedule.seconds // 60 % 60, second=schedule.seconds % 60, @@ -296,11 +297,17 @@ def _update_schedule_trackers(self): async def _async_on_time_entity_change(self, event): """Called when a time entity changes.""" + _LOGGER.debug(f"Time entity changed: {event.data.get('entity_id')}") # When a time entity changes, we need to re-register the time trackers # because the schedule times have changed. self._update_schedule_trackers() await self.async_update_climate() + async def _async_on_schedule_time_trigger(self, *args): + """Called when a schedule time is triggered.""" + _LOGGER.debug(f"Schedule time trigger fired at {now()}") + await self.async_update_climate() + async def async_turn_on(self, **kwargs) -> None: _LOGGER.info(self.entity_id + ": Turn on") From 662a9bf9fe8db8a1ea8f7799cf27f1e9d63d14e7 Mon Sep 17 00:00:00 2001 From: Francis Labarre Date: Sun, 1 Feb 2026 08:50:39 -0800 Subject: [PATCH 4/4] [AI] Add prefix to all logs --- custom_components/climate_scheduler/common.py | 9 ++++++ .../climate_scheduler/profile.py | 9 +++--- .../climate_scheduler/schedule.py | 9 +++--- custom_components/climate_scheduler/switch.py | 29 ++++++++++--------- 4 files changed, 35 insertions(+), 21 deletions(-) diff --git a/custom_components/climate_scheduler/common.py b/custom_components/climate_scheduler/common.py index 444dc52..99a013b 100644 --- a/custom_components/climate_scheduler/common.py +++ b/custom_components/climate_scheduler/common.py @@ -1,5 +1,6 @@ """Common data structures for Climate Scheduler.""" +import logging from collections import namedtuple from dataclasses import dataclass from datetime import timedelta @@ -20,3 +21,11 @@ class ResolvedScheduleEntry: time: timedelta schedule: "ClimateSchedulerSchedule" + + +class PrefixAdapter(logging.LoggerAdapter): + """Logger adapter to add a prefix to messages.""" + + def process(self, msg, kwargs): + """Process the message.""" + return f"{self.extra['prefix']} {msg}", kwargs diff --git a/custom_components/climate_scheduler/profile.py b/custom_components/climate_scheduler/profile.py index da0885e..6d53d29 100644 --- a/custom_components/climate_scheduler/profile.py +++ b/custom_components/climate_scheduler/profile.py @@ -41,8 +41,9 @@ class ClimateSchedulerProfile: """Representation of a profile.""" - def __init__(self, config: dict) -> None: + def __init__(self, config: dict, logger: logging.Logger = _LOGGER) -> None: """Initialize the profile.""" + self._logger = logger self._id: str = config.get(CONF_PROFILE_ID) self._default_hvac_mode = config.get(CONF_PROFILE_DEFAULT_HVAC_MODE) @@ -51,7 +52,7 @@ def __init__(self, config: dict) -> None: self._default_min_temp = config.get(CONF_PROFILE_DEFAULT_MIN_TEMP) self._default_max_temp = config.get(CONF_PROFILE_DEFAULT_MAX_TEMP) - self._schedules = [ClimateSchedulerSchedule(c) for c in config.get(CONF_PROFILE_SCHEDULE)] + self._schedules = [ClimateSchedulerSchedule(c, logger) for c in config.get(CONF_PROFILE_SCHEDULE)] @property def profile_id(self) -> str: @@ -71,7 +72,7 @@ def compute_climate(self, time_of_day: timedelta, hass: HomeAssistant) -> Comput resolved_schedules = self._resolve_schedules(hass) schedule = self._find_schedule(time_of_day, resolved_schedules) - _LOGGER.debug( + self._logger.debug( "Computed climate for profile %s at %s. Using schedule: %s", self._id, time_of_day, @@ -116,7 +117,7 @@ def _resolve_schedules(self, hass: HomeAssistant) -> list[ResolvedScheduleEntry] if len(resolved_entries) > 1: for i in range(len(resolved_entries) - 1): if resolved_entries[i].time == resolved_entries[i + 1].time: - _LOGGER.warning( + self._logger.warning( "Collision detected in profile %s for time %s. Using the last defined schedule.", self._id, resolved_entries[i].time, diff --git a/custom_components/climate_scheduler/schedule.py b/custom_components/climate_scheduler/schedule.py index 2266e8f..bb422b3 100644 --- a/custom_components/climate_scheduler/schedule.py +++ b/custom_components/climate_scheduler/schedule.py @@ -43,8 +43,9 @@ class ClimateSchedulerSchedule: """Representation of a single schedule entry.""" - def __init__(self, config: dict) -> None: + def __init__(self, config: dict, logger: logging.Logger = _LOGGER) -> None: """Initialize the schedule.""" + self._logger = logger self._static_time: timedelta | None = None self._time_entity_id: str | None = None self._offset: timedelta = timedelta(0) @@ -107,15 +108,15 @@ def resolve_time(self, hass: HomeAssistant) -> timedelta | None: if self._time_entity_id: state = hass.states.get(self._time_entity_id) - print(f"DEBUG: resolving {self._time_entity_id}, state found: {state}") + self._logger.debug(f"resolving {self._time_entity_id}, state found: {state}") if state is None: - _LOGGER.warning("Entity %s not found for schedule", self._time_entity_id) + self._logger.warning("Entity %s not found for schedule", self._time_entity_id) return None # parse_time handles strings like "10:00:00" parsed = parse_time(state.state) if parsed is None: - _LOGGER.warning("Invalid time state %s for entity %s", state.state, self._time_entity_id) + self._logger.warning("Invalid time state %s for entity %s", state.state, self._time_entity_id) return None base_time = timedelta(hours=parsed.hour, minutes=parsed.minute, seconds=parsed.second) diff --git a/custom_components/climate_scheduler/switch.py b/custom_components/climate_scheduler/switch.py index 4db4f11..8d565f7 100644 --- a/custom_components/climate_scheduler/switch.py +++ b/custom_components/climate_scheduler/switch.py @@ -52,7 +52,7 @@ from homeassistant.util import slugify from homeassistant.util.dt import now -from .common import ComputedClimateData +from .common import ComputedClimateData, PrefixAdapter from .const import ( ATTR_PROFILE, ATTR_PROFILE_OPTIONS, @@ -104,9 +104,12 @@ def __init__(self, hass: HomeAssistant, cs: ClimateScheduler, config: dict) -> N self._state: str | None = None self._default_state: str | None = STATE_ON if config.get(CONF_DEFAULT_STATE) else STATE_OFF + # Setup logger + self._logger = PrefixAdapter(_LOGGER, {"prefix": f"[{self.entity_id_suffix}]"}) + # Setup profiles self._profiles: dict[str, ClimateSchedulerProfile] = { - profile_conf[CONF_PROFILE_ID]: ClimateSchedulerProfile(profile_conf) + profile_conf[CONF_PROFILE_ID]: ClimateSchedulerProfile(profile_conf, self._logger) for profile_conf in config.get(CONF_PROFILES) } @@ -242,16 +245,16 @@ async def _async_on_profile_selector_change(self, event) -> None: if new_state is None: return - _LOGGER.info(f"Profile selector changed to {new_state.state}") + self._logger.info(f"Profile selector changed to {new_state.state}") await self._async_update_profile(new_state.state) async def _async_update_profile(self, new_profile_id: str) -> None: if new_profile_id not in self._profiles: - logging.warning(f"Ignoring invalid profile with id={new_profile_id}") + self._logger.warning(f"Ignoring invalid profile with id={new_profile_id}") return self._current_profile = self._profiles.get(new_profile_id) - _LOGGER.debug(f"Profile updated to {new_profile_id}") + self._logger.debug(f"Profile updated to {new_profile_id}") self._update_schedule_trackers() await self.async_update_climate() @@ -297,7 +300,7 @@ def _update_schedule_trackers(self): async def _async_on_time_entity_change(self, event): """Called when a time entity changes.""" - _LOGGER.debug(f"Time entity changed: {event.data.get('entity_id')}") + self._logger.debug(f"Time entity changed: {event.data.get('entity_id')}") # When a time entity changes, we need to re-register the time trackers # because the schedule times have changed. self._update_schedule_trackers() @@ -305,32 +308,32 @@ async def _async_on_time_entity_change(self, event): async def _async_on_schedule_time_trigger(self, *args): """Called when a schedule time is triggered.""" - _LOGGER.debug(f"Schedule time trigger fired at {now()}") + self._logger.debug(f"Schedule time trigger fired at {now()}") await self.async_update_climate() async def async_turn_on(self, **kwargs) -> None: - _LOGGER.info(self.entity_id + ": Turn on") + self._logger.info("Turn on") self._state = STATE_ON await self.async_update_climate() self.async_schedule_update_ha_state() async def async_turn_off(self, **kwargs) -> None: - _LOGGER.info(self.entity_id + ": Turn off") + self._logger.info("Turn off") self._state = STATE_OFF self.async_schedule_update_ha_state() async def async_update_climate(self, *args, **kwargs) -> None: """Update all climate entities controlled by the swtich""" - _LOGGER.info(self.entity_id + ": Updating climate") + self._logger.info("Updating climate") if not self.is_on: - _LOGGER.info(self.entity_id + ": Disabled") + self._logger.info("Disabled") return if self._current_profile is None: - _LOGGER.info(self.entity_id + ": No profile") + self._logger.info("No profile") return # TODO: Track temperature of entities. Only heat/cool if under/above threshold @@ -362,7 +365,7 @@ async def _async_set_climate_hvac_mode( hvac_mode: str, ): if hvac_mode is None: - _LOGGER.info(self.entity_id + ": No HVAC mode") + self._logger.info("No HVAC mode") return data = {ATTR_ENTITY_ID: entity, ATTR_HVAC_MODE: hvac_mode}