From f858867138b45c4d9361e247c4b923c17afbef0c Mon Sep 17 00:00:00 2001 From: Gamso Date: Sat, 18 Jul 2026 11:56:05 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20cover=20heat=20protection=20?= =?UTF-8?q?=E2=80=94=20proactive=20forecast-based=20close,=20evening=20reo?= =?UTF-8?q?pen,=20persistence=20-=20Add=20CoverManager.=5Fasync=5Fcheck=5F?= =?UTF-8?q?proactive=5Fclose():=20once=20per=20day=20at=20Heat=20Window=20?= =?UTF-8?q?Start,=20close=20covers=20ahead=20of=20time=20if=20today's=20fo?= =?UTF-8?q?recast=20high=20(via=20weather.get=5Fforecasts=20on=20a=20confi?= =?UTF-8?q?gurable=20cover=5Fweather=5Fentity)=20exceeds=20cover=5Fforecas?= =?UTF-8?q?t=5Fthreshold=20=E2=80=94=20the=20reactive=20threshold=20alone?= =?UTF-8?q?=20only=20fires=20after=20the=20room=20has=20already=20absorbed?= =?UTF-8?q?=20hours=20of=20heat=20-=20Reactive=20threshold=20stays=20activ?= =?UTF-8?q?e=20as=20a=20fallback=20for=20forecast=20misses=20-=20Add=20Cov?= =?UTF-8?q?erManager.=5Fasync=5Fcheck=5Fevening=5Freopen():=20reopen=20cov?= =?UTF-8?q?ers=20this=20automation=20closed=20today,=20once=20after=20Heat?= =?UTF-8?q?=20Window=20End=20the=20outdoor=20sensor=20drops=20to/below=20c?= =?UTF-8?q?over=5Fevening=5Freopen=5Ftemp;=20never=20touches=20a=20cover?= =?UTF-8?q?=20closed=20manually=20or=20by=20another=20scheduler=20-=20Add?= =?UTF-8?q?=20cover=5Fweather=5Fentity,=20cover=5Fforecast=5Fthreshold,=20?= =?UTF-8?q?cover=5Fevening=5Freopen=5Ftemp=20config=20options=20(config=5F?= =?UTF-8?q?flow=20selectors=20+=20EN/FR=20translations)=20-=20Refactor=20c?= =?UTF-8?q?over=20action=20application=20(My=20button=20/=20close=5Fcover?= =?UTF-8?q?=20/=20stop=5Fcover)=20into=20a=20shared=20=5Fasync=5Fapply=5Fc?= =?UTF-8?q?over=5Faction()=20helper=20-=20Persist=20the=20once-per-day=20t?= =?UTF-8?q?racking=20dates=20via=20Store=20(async=5Frestore=5Fstate=20/=20?= =?UTF-8?q?=5Fasync=5Fsave=5Fstate),=20restored=20at=20startup=20=E2=80=94?= =?UTF-8?q?=20survives=20a=20HA=20restart=20mid-day=20-=20Update=20README:?= =?UTF-8?q?=20document=20the=20two=20close=20paths,=20evening=20reopen,=20?= =?UTF-8?q?state=20persistence,=20and=20add=20a=20feature-support=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 69 +++- custom_components/homeshift/__init__.py | 1 + custom_components/homeshift/config_flow.py | 31 ++ custom_components/homeshift/const.py | 13 + custom_components/homeshift/cover_manager.py | 235 ++++++++++-- .../homeshift/translations/en.json | 28 +- .../homeshift/translations/fr.json | 28 +- tests/test_coordinator_features.py | 362 +++++++++++++++++- tests/test_init.py | 5 + 9 files changed, 715 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 7173b85..eadc905 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,11 @@ Automatic day-mode and thermostat-mode management for Home Assistant, driven by - [Thermostat Tags](#thermostat-tags) - [🌅 Sunrise Scheduler Adjustment](#-sunrise-scheduler-adjustment) - [☀️ Cover Heat Protection](#️-cover-heat-protection) + - [Reactive Close](#reactive-close) + - [Proactive Forecast-Based Close](#proactive-forecast-based-close) + - [Automatic Evening Reopen](#automatic-evening-reopen) + - [State Persistence](#state-persistence) + - [🧩 Feature Support](#-feature-support) - [📄 License](#-license) --- @@ -180,9 +185,14 @@ All parameters can be changed at any time via **Settings → Devices & Services | **Away Mode** | `Away` | When this mode is active, automatic updates are paused | | **Cover Entities** | — | Cover entities to close when it is too hot (optional) | | **Temperature Sensor** | — | Sensor providing the outdoor temperature | -| **Temperature Threshold** | `30 °C` | Temperature above which covers are closed | -| **Heat Window Start** | `08:00` | Earliest time of day the heat protection is active | -| **Heat Window End** | `20:00` | Latest time of day the heat protection is active | +| **Temperature Threshold** | `30 °C` | Temperature above which covers close reactively (fallback for days the forecast misses) | +| **Cover Action** | `close_cover` | Service called when heat protection triggers: `close_cover` or `stop_cover` | +| **My Position Button** | — | Button entity to press instead of a cover service (e.g. Somfy RTS "My" position) | +| **Heat Window Start** | `08:35` | Earliest time of day the heat protection is active; also when the forecast is checked for the proactive close | +| **Heat Window End** | `18:00` | Latest time of day the heat protection is active; also the earliest time the evening reopen can fire | +| **Weather Entity** | — | Weather entity with daily forecasts, used for the proactive close (optional) | +| **Forecast Threshold** | `28 °C` | Forecast daily high above which covers close proactively | +| **Evening Reopen Temperature** | `26 °C` | Once closed by this automation, covers reopen after Heat Window End when the sensor drops to/below this value | | **Sunrise Schedulers** | — | Scheduler switch entities whose opening time tracks sunrise | | **Earliest Open Time** | `07:10` | Minimum opening time even when sunrise is earlier | @@ -277,19 +287,54 @@ and calls `scheduler.edit` on each configured scheduler entity to update its fir ## ☀️ Cover Heat Protection -HomeShift can automatically close covers when the outdoor temperature exceeds a threshold during a configurable time window. This prevents heat build-up without requiring any automation. +HomeShift can automatically close covers to prevent heat build-up, and reopen them once it has cooled down — without requiring any separate automation. There are two ways a close can be triggered, and one way covers reopen automatically. + +### Reactive Close Each time the coordinator runs **and** whenever the temperature sensor value changes, HomeShift checks: -- Is the current time within the configured active window? -- Is the temperature above the configured threshold? +- Is the current time within the configured active window (**Heat Window Start/End**)? +- Is the outdoor temperature above **Temperature Threshold**? -If both conditions are met, `cover.stop_cover` is called on all configured cover entities. For Somfy covers this closes the cover to their pre-recorded favourite position. +If both conditions are met, the configured **Cover Action** is applied to all configured cover entities: `close_cover` (default), `stop_cover` (interrupts movement mid-travel — useful for Somfy RTS covers), or pressing the **My Position Button** if one is configured (sends the cover to its pre-recorded favourite position). -**Configuration:** -- **Cover Entities** — covers to control -- **Temperature Sensor** — sensor providing the current outdoor temperature -- **Temperature Threshold** — temperature above which covers are closed (default: `30 °C`) -- **Heat Window Start / End** — time range during which the feature is active (default: `08:00–20:00`) +This check reacts to the outdoor sensor as it happens, so on a well-insulated room it can end up closing the cover only after the room has already absorbed a few hours of heat. + +### Proactive Forecast-Based Close + +If a **Weather Entity** (with daily forecasts) is configured, HomeShift also checks — once per day, at **Heat Window Start** — whether today's forecast high exceeds **Forecast Threshold**. If it does, covers close immediately, ahead of the outdoor sensor actually crossing the reactive threshold. + +The reactive close above still runs during the rest of the window as a fallback, in case the forecast lookup fails or under-predicts the day. + +Leave **Weather Entity** unset to disable the proactive close entirely; behavior then falls back to the reactive close only. + +### Automatic Evening Reopen + +Once a cover has been closed by this automation (proactively or reactively) on a given day, HomeShift reopens it automatically — at most once per day — after **Heat Window End**, as soon as the outdoor sensor drops to or below **Evening Reopen Temperature**. + +The reopen only ever applies to a cover this automation itself closed that day; it never touches a cover closed manually or by an unrelated automation/scheduler. + +### State Persistence + +Which covers were closed today, when the forecast was last checked, and whether the evening reopen already ran are all persisted to storage, so a Home Assistant restart mid-day doesn't lose track of the day's state (e.g. it won't skip the evening reopen just because HA restarted in the afternoon). + +--- + +## 🧩 Feature Support + +| Feature | Status | Notes | +| ------------------------------------------- | :----: | -------------------------------------------------------------------| +| Calendar-driven day mode | ✅ | See [Detection Logic](#-detection-logic) | +| Thermostat mode + scheduler tags | ✅ | See [Thermostat Tags](#thermostat-tags) | +| Half-day event support | ✅ | See [Half-Day Events](#half-day-events) | +| Early switch (pre-activation) | ✅ | See [Early Switch](#early-switch) | +| Manual override with timeout | ✅ | `number.override_duration` | +| Sunrise-based scheduler opening time | ✅ | See [Sunrise Scheduler Adjustment](#-sunrise-scheduler-adjustment) | +| Cover reactive heat close | ✅ | See [Reactive Close](#reactive-close) | +| Cover proactive forecast-based close | ✅ | See [Proactive Forecast-Based Close](#proactive-forecast-based-close) | +| Cover automatic evening reopen | ✅ | See [Automatic Evening Reopen](#automatic-evening-reopen) | +| Cover automation state survives HA restart | ✅ | See [State Persistence](#state-persistence) | +| Day-mode/thermostat-mode state survives HA restart | ✅ | Restored from storage at startup | +| Cover position feedback (open/closed state) | ❌ | Not tracked — commands are sent blind; HomeShift only tracks whether *it* closed a cover today, not the cover's actual position | --- diff --git a/custom_components/homeshift/__init__.py b/custom_components/homeshift/__init__.py index 783f2bb..7d7474d 100644 --- a/custom_components/homeshift/__init__.py +++ b/custom_components/homeshift/__init__.py @@ -42,6 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Create coordinator coordinator = HomeShiftCoordinator(hass, entry) await coordinator.async_restore_state() + await coordinator._cover_manager.async_restore_state() await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id] = coordinator diff --git a/custom_components/homeshift/config_flow.py b/custom_components/homeshift/config_flow.py index fb5dd5b..2b2c703 100644 --- a/custom_components/homeshift/config_flow.py +++ b/custom_components/homeshift/config_flow.py @@ -41,6 +41,11 @@ DEFAULT_COVER_TIME_START, DEFAULT_COVER_TIME_END, DEFAULT_COVER_ACTION, + CONF_COVER_WEATHER_ENTITY, + CONF_COVER_FORECAST_THRESHOLD, + DEFAULT_COVER_FORECAST_THRESHOLD, + CONF_COVER_EVENING_REOPEN_TEMP, + DEFAULT_COVER_EVENING_REOPEN_TEMP, CONF_SUNRISE_SCHEDULERS, CONF_SUNRISE_EARLIEST, DEFAULT_SUNRISE_EARLIEST, @@ -334,6 +339,32 @@ def _covers_schema(hass, data: dict[str, Any]) -> vol.Schema: CONF_COVER_TIME_END, default=data.get(CONF_COVER_TIME_END, DEFAULT_COVER_TIME_END), ): selector.TimeSelector(), + vol.Optional( + CONF_COVER_WEATHER_ENTITY, + default=data.get(CONF_COVER_WEATHER_ENTITY, ""), + ): selector.EntitySelector(selector.EntitySelectorConfig(domain="weather")), + vol.Optional( + CONF_COVER_FORECAST_THRESHOLD, + default=data.get(CONF_COVER_FORECAST_THRESHOLD, DEFAULT_COVER_FORECAST_THRESHOLD), + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + max=60, + step=0.5, + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Optional( + CONF_COVER_EVENING_REOPEN_TEMP, + default=data.get(CONF_COVER_EVENING_REOPEN_TEMP, DEFAULT_COVER_EVENING_REOPEN_TEMP), + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + max=60, + step=0.5, + mode=selector.NumberSelectorMode.BOX, + ) + ), } ) diff --git a/custom_components/homeshift/const.py b/custom_components/homeshift/const.py index 32ff315..6745f06 100644 --- a/custom_components/homeshift/const.py +++ b/custom_components/homeshift/const.py @@ -47,6 +47,19 @@ DEFAULT_COVER_TIME_END = "18:00:00" DEFAULT_COVER_ACTION = "close_cover" +# Proactive (forecast-based) closing — closes ahead of the reactive threshold +# so covers shade the room before, not after, the heat has already built up. +CONF_COVER_WEATHER_ENTITY = "cover_weather_entity" +CONF_COVER_FORECAST_THRESHOLD = "cover_forecast_threshold" + +DEFAULT_COVER_FORECAST_THRESHOLD = 28.0 + +# Automatic evening reopening — reopens covers the heat-protection automation +# closed, once it has cooled back down after the active window. +CONF_COVER_EVENING_REOPEN_TEMP = "cover_evening_reopen_temp" + +DEFAULT_COVER_EVENING_REOPEN_TEMP = 26.0 + # Sunrise-based scheduler adjustment CONF_SUNRISE_SCHEDULERS = "sunrise_schedulers" CONF_SUNRISE_EARLIEST = "sunrise_earliest" diff --git a/custom_components/homeshift/cover_manager.py b/custom_components/homeshift/cover_manager.py index 183bbe2..d7ff41e 100644 --- a/custom_components/homeshift/cover_manager.py +++ b/custom_components/homeshift/cover_manager.py @@ -6,12 +6,13 @@ from __future__ import annotations import logging -from datetime import datetime, time as dt_time +from datetime import date, datetime, time as dt_time from typing import TYPE_CHECKING, Callable from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.event import EventStateChangedData, async_track_state_change_event +from homeassistant.helpers.storage import Store from homeassistant.util import dt as dt_util from .const import ( @@ -26,6 +27,11 @@ DEFAULT_COVER_TIME_START, DEFAULT_COVER_TIME_END, DEFAULT_COVER_ACTION, + CONF_COVER_WEATHER_ENTITY, + CONF_COVER_FORECAST_THRESHOLD, + DEFAULT_COVER_FORECAST_THRESHOLD, + CONF_COVER_EVENING_REOPEN_TEMP, + DEFAULT_COVER_EVENING_REOPEN_TEMP, CONF_SUNRISE_SCHEDULERS, CONF_SUNRISE_EARLIEST, DEFAULT_SUNRISE_EARLIEST, @@ -36,6 +42,11 @@ _LOGGER = logging.getLogger(__name__) +# Persistent storage — survives HA restarts so a mid-day reboot doesn't lose +# track of which covers this automation already closed today. +STORAGE_VERSION = 1 +STORAGE_KEY = f"{__name__}.state" + def _parse_time_str(time_str: str) -> dt_time | None: """Parse 'HH:MM' or 'HH:MM:SS' string into a time object. @@ -52,6 +63,16 @@ def _parse_time_str(time_str: str) -> dt_time | None: return None +def _parse_stored_date(value: str | None) -> date | None: + """Parse an ISO 'YYYY-MM-DD' string into a date. Returns None if unparseable.""" + if not value: + return None + try: + return date.fromisoformat(value) + except ValueError: + return None + + class CoverManager: """Manages cover heat-protection and sunrise-based scheduler adjustment.""" @@ -59,12 +80,51 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._hass = hass self._entry = entry self.cover_open_time: str | None = None + # Tracks which day each once-per-day action last ran on, so proactive + # closing and evening reopening each fire at most once per calendar day. + self._proactive_checked_date: date | None = None + self._closed_date: date | None = None + self._reopened_date: date | None = None + self._store: Store = Store(hass, STORAGE_VERSION, f"{STORAGE_KEY}.{entry.entry_id}") @property def _config(self) -> dict: """Return merged config: entry.data overridden by entry.options.""" return {**self._entry.data, **self._entry.options} + async def async_restore_state(self) -> None: + """Restore the once-per-day action dates from storage. + + Called once during setup, before the first check, so a HA restart + between the proactive close and the evening reopen doesn't silently + disable the reopen for the rest of that day. + """ + try: + stored = await self._store.async_load() + except Exception as err: # noqa: BLE001 - defensive around storage I/O + _LOGGER.warning("Cover manager: could not load persisted state: %s", err) + return + + if not stored: + return + + self._proactive_checked_date = _parse_stored_date(stored.get("proactive_checked_date")) + self._closed_date = _parse_stored_date(stored.get("closed_date")) + self._reopened_date = _parse_stored_date(stored.get("reopened_date")) + + async def _async_save_state(self) -> None: + """Persist the once-per-day action dates to storage.""" + try: + await self._store.async_save( + { + "proactive_checked_date": self._proactive_checked_date.isoformat() if self._proactive_checked_date else None, + "closed_date": self._closed_date.isoformat() if self._closed_date else None, + "reopened_date": self._reopened_date.isoformat() if self._reopened_date else None, + } + ) + except Exception as err: # noqa: BLE001 - defensive around storage I/O + _LOGGER.warning("Cover manager: could not persist state: %s", err) + def async_setup_listeners(self) -> Callable[[], None]: """Register a state-change listener on the temperature sensor. @@ -92,6 +152,10 @@ async def async_check_heat_protection(self, now: datetime) -> None: When the current time falls inside the window and the temperature is above the threshold, it calls the configured cover service (default: close_cover) on all configured cover entities. + + Also runs the proactive forecast-based close and the automatic evening + reopen, since both are cheap no-ops once already handled for the day and + this method is already called on every sensor change and coordinator poll. """ cover_entities = self._config.get(CONF_COVER_ENTITIES, []) @@ -100,6 +164,9 @@ async def async_check_heat_protection(self, now: datetime) -> None: if not cover_entities or not temp_sensor: return + await self._async_check_proactive_close(now, cover_entities) + await self._async_check_evening_reopen(now, cover_entities) + active = self.is_heat_protection_active(now) if not active: return @@ -107,44 +174,156 @@ async def async_check_heat_protection(self, now: datetime) -> None: temp_state = self._hass.states.get(temp_sensor) temperature = float(temp_state.state) if temp_state else 0.0 threshold = float(self._config.get(CONF_COVER_TEMP_THRESHOLD, DEFAULT_COVER_TEMP_THRESHOLD)) + + _LOGGER.info( + "Cover heat protection: temperature=%.1f > threshold=%.1f, reacting on covers %s", + temperature, + threshold, + cover_entities, + ) + await self._async_apply_cover_action(cover_entities) + self._closed_date = now.date() + await self._async_save_state() + + async def _async_apply_cover_action(self, cover_entities: list[str]) -> None: + """Press the configured My button, or call the configured cover service.""" my_button = self._config.get(CONF_COVER_MY_BUTTON, "") if my_button: - _LOGGER.info( - "Cover heat protection: temperature=%.1f > threshold=%.1f, pressing My button %s", - temperature, - threshold, - my_button, - ) await self._hass.services.async_call( "button", "press", {"entity_id": my_button}, blocking=False, ) - else: - configured_cover_action = self._config.get(CONF_COVER_ACTION, DEFAULT_COVER_ACTION) - allowed_cover_actions = {"close_cover", "stop_cover"} - cover_action = configured_cover_action if configured_cover_action in allowed_cover_actions else DEFAULT_COVER_ACTION - if configured_cover_action not in allowed_cover_actions: - _LOGGER.warning( - "Cover heat protection: invalid cover action '%s', falling back to '%s'", - configured_cover_action, - DEFAULT_COVER_ACTION, - ) - _LOGGER.info( - "Cover heat protection: temperature=%.1f > threshold=%.1f, action=%s on covers %s", - temperature, - threshold, - cover_action, - cover_entities, + return + + configured_cover_action = self._config.get(CONF_COVER_ACTION, DEFAULT_COVER_ACTION) + allowed_cover_actions = {"close_cover", "stop_cover"} + cover_action = configured_cover_action if configured_cover_action in allowed_cover_actions else DEFAULT_COVER_ACTION + if configured_cover_action not in allowed_cover_actions: + _LOGGER.warning( + "Cover heat protection: invalid cover action '%s', falling back to '%s'", + configured_cover_action, + DEFAULT_COVER_ACTION, ) - await self._hass.services.async_call( - "cover", - cover_action, - {"entity_id": cover_entities}, - blocking=False, + await self._hass.services.async_call( + "cover", + cover_action, + {"entity_id": cover_entities}, + blocking=False, + ) + + async def _async_check_proactive_close(self, now: datetime, cover_entities: list[str]) -> None: + """Close covers ahead of time when today's forecast high is hot enough. + + Reactive closing (temperature > threshold) only fires once the outdoor + air is already hot — by then a south-facing room has often already + absorbed hours of solar/conductive gain that a later closure can't + undo. Checking the day's forecast once, at CONF_COVER_TIME_START, + lets the cover close before that gain happens. + Runs at most once per calendar day; a failed forecast lookup is + retried on the next call instead of being marked done. + """ + weather_entity = self._config.get(CONF_COVER_WEATHER_ENTITY, "") + if not weather_entity: + return + + today = now.date() + if self._proactive_checked_date == today: + return + + time_start = _parse_time_str(self._config.get(CONF_COVER_TIME_START, DEFAULT_COVER_TIME_START)) + if time_start is None or now.time() < time_start: + return + + forecast_high = await self._async_get_forecast_high(weather_entity) + if forecast_high is None: + return + + self._proactive_checked_date = today + + threshold = float(self._config.get(CONF_COVER_FORECAST_THRESHOLD, DEFAULT_COVER_FORECAST_THRESHOLD)) + if forecast_high <= threshold: + await self._async_save_state() + return + + _LOGGER.info( + "Cover proactive close: forecast high=%.1f > threshold=%.1f, closing covers %s", + forecast_high, + threshold, + cover_entities, + ) + await self._async_apply_cover_action(cover_entities) + self._closed_date = today + await self._async_save_state() + + async def _async_get_forecast_high(self, weather_entity: str) -> float | None: + """Return today's forecast high temperature for weather_entity, or None on failure.""" + try: + response = await self._hass.services.async_call( + "weather", + "get_forecasts", + {"entity_id": weather_entity, "type": "daily"}, + blocking=True, + return_response=True, ) + except Exception as err: # noqa: BLE001 - defensive boundary around an external service call + _LOGGER.warning("Cover proactive close: get_forecasts failed for '%s': %s", weather_entity, err) + return None + + forecasts = (response or {}).get(weather_entity, {}).get("forecast") or [] + if not forecasts: + return None + + try: + return float(forecasts[0].get("temperature")) + except (TypeError, ValueError): + return None + + async def _async_check_evening_reopen(self, now: datetime, cover_entities: list[str]) -> None: + """Reopen covers the heat-protection automation closed, once it has cooled down. + + Only reopens covers that this automation itself closed today (proactively + or reactively), only after CONF_COVER_TIME_END, and only once per day — + it never touches a cover the user closed manually for other reasons. + """ + today = now.date() + if self._closed_date != today or self._reopened_date == today: + return + + time_end = _parse_time_str(self._config.get(CONF_COVER_TIME_END, DEFAULT_COVER_TIME_END)) + if time_end is None or now.time() < time_end: + return + + temp_sensor = self._config.get(CONF_COVER_TEMP_SENSOR, "") + temp_state = self._hass.states.get(temp_sensor) + if temp_state is None: + return + + try: + temperature = float(temp_state.state) + except (ValueError, TypeError): + return + + reopen_temp = float(self._config.get(CONF_COVER_EVENING_REOPEN_TEMP, DEFAULT_COVER_EVENING_REOPEN_TEMP)) + if temperature > reopen_temp: + return + + _LOGGER.info( + "Cover evening reopen: temperature=%.1f <= %.1f, opening covers %s", + temperature, + reopen_temp, + cover_entities, + ) + await self._hass.services.async_call( + "cover", + "open_cover", + {"entity_id": cover_entities}, + blocking=False, + ) + self._reopened_date = today + await self._async_save_state() def is_heat_protection_active(self, now: datetime) -> bool | None: """Return whether heat protection conditions are currently met. diff --git a/custom_components/homeshift/translations/en.json b/custom_components/homeshift/translations/en.json index 68bb465..acee24c 100644 --- a/custom_components/homeshift/translations/en.json +++ b/custom_components/homeshift/translations/en.json @@ -81,16 +81,22 @@ "cover_time_start": "Active Window Start", "cover_time_end": "Active Window End", "cover_action": "Cover Action", - "cover_my_button": "My position button" + "cover_my_button": "My position button", + "cover_weather_entity": "Weather Entity (proactive close)", + "cover_forecast_threshold": "Forecast Threshold (°C)", + "cover_evening_reopen_temp": "Evening Reopen Temperature (°C)" }, "data_description": { "cover_entities": "Select the cover entities to control when it is too hot.", "cover_temp_sensor": "Sensor providing the outdoor temperature.", "cover_temp_threshold": "Temperature above which the selected action is triggered.", - "cover_time_start": "Earliest time of day the feature is active.", - "cover_time_end": "Latest time of day the feature is active.", + "cover_time_start": "Earliest time of day the feature is active. Also the time the forecast is checked for the proactive close.", + "cover_time_end": "Latest time of day the feature is active. Also the earliest time the evening reopen can fire.", "cover_action": "'Close' sends the standard close_cover command. 'Stop' sends stop_cover, halting movement mid-travel — useful for Somfy RTS covers (the My/favorite position is a separate button entity).", - "cover_my_button": "Button to press to send covers to their favorite (My) position. If set, this button is used instead of the action below." + "cover_my_button": "Button to press to send covers to their favorite (My) position. If set, this button is used instead of the action below.", + "cover_weather_entity": "Optional weather entity with daily forecasts. When set, covers close proactively at Active Window Start if today's forecast high exceeds the forecast threshold, instead of waiting for the outdoor sensor to actually cross it.", + "cover_forecast_threshold": "Forecast daily high above which the proactive close triggers.", + "cover_evening_reopen_temp": "Once covers have been closed by this automation, they reopen automatically after Active Window End as soon as the outdoor sensor drops to or below this temperature." } }, "sunrise_schedulers": { @@ -201,16 +207,22 @@ "cover_time_start": "Active Window Start", "cover_time_end": "Active Window End", "cover_action": "Cover Action", - "cover_my_button": "My position button" + "cover_my_button": "My position button", + "cover_weather_entity": "Weather Entity (proactive close)", + "cover_forecast_threshold": "Forecast Threshold (°C)", + "cover_evening_reopen_temp": "Evening Reopen Temperature (°C)" }, "data_description": { "cover_entities": "Select the cover entities to control when it is too hot.", "cover_temp_sensor": "Sensor providing the outdoor temperature.", "cover_temp_threshold": "Temperature above which the selected action is triggered.", - "cover_time_start": "Earliest time of day the feature is active.", - "cover_time_end": "Latest time of day the feature is active.", + "cover_time_start": "Earliest time of day the feature is active. Also the time the forecast is checked for the proactive close.", + "cover_time_end": "Latest time of day the feature is active. Also the earliest time the evening reopen can fire.", "cover_action": "'Close' sends the standard close_cover command. 'Stop' sends stop_cover, halting movement mid-travel — useful for Somfy RTS covers (the My/favorite position is a separate button entity).", - "cover_my_button": "Button to press to send covers to their favorite (My) position. If set, this button is used instead of the action below." + "cover_my_button": "Button to press to send covers to their favorite (My) position. If set, this button is used instead of the action below.", + "cover_weather_entity": "Optional weather entity with daily forecasts. When set, covers close proactively at Active Window Start if today's forecast high exceeds the forecast threshold, instead of waiting for the outdoor sensor to actually cross it.", + "cover_forecast_threshold": "Forecast daily high above which the proactive close triggers.", + "cover_evening_reopen_temp": "Once covers have been closed by this automation, they reopen automatically after Active Window End as soon as the outdoor sensor drops to or below this temperature." } }, "sunrise_schedulers": { diff --git a/custom_components/homeshift/translations/fr.json b/custom_components/homeshift/translations/fr.json index 1df36bf..c303258 100644 --- a/custom_components/homeshift/translations/fr.json +++ b/custom_components/homeshift/translations/fr.json @@ -81,16 +81,22 @@ "cover_time_start": "Début de la plage horaire", "cover_time_end": "Fin de la plage horaire", "cover_action": "Action sur les volets", - "cover_my_button": "Bouton position favorite (My)" + "cover_my_button": "Bouton position favorite (My)", + "cover_weather_entity": "Entité météo (fermeture proactive)", + "cover_forecast_threshold": "Seuil de prévision (°C)", + "cover_evening_reopen_temp": "Température de réouverture du soir (°C)" }, "data_description": { "cover_entities": "Sélectionnez les entités volets à contrôler en cas de forte chaleur.", "cover_temp_sensor": "Capteur fournissant la température extérieure.", "cover_temp_threshold": "Température au-dessus de laquelle l'action sélectionnée est déclenchée.", - "cover_time_start": "Heure de début de la plage d'activation.", - "cover_time_end": "Heure de fin de la plage d'activation.", + "cover_time_start": "Heure de début de la plage d'activation. C'est aussi l'heure à laquelle la prévision est vérifiée pour la fermeture proactive.", + "cover_time_end": "Heure de fin de la plage d'activation. C'est aussi l'heure la plus tôt à laquelle la réouverture du soir peut se déclencher.", "cover_action": "'Fermer' envoie close_cover (volets standard). 'Arrêter' envoie stop_cover, qui interrompt le mouvement en cours — utile pour les volets Somfy RTS (la position favorite My est un bouton séparé).", - "cover_my_button": "Bouton à appuyer pour envoyer les volets en position favorite (My). Si renseigné, ce bouton est utilisé à la place de l'action ci-dessous." + "cover_my_button": "Bouton à appuyer pour envoyer les volets en position favorite (My). Si renseigné, ce bouton est utilisé à la place de l'action ci-dessous.", + "cover_weather_entity": "Entité météo optionnelle avec prévisions journalières. Si renseignée, les volets se ferment de manière proactive à l'heure de début de plage si la prévision du jour dépasse le seuil de prévision, sans attendre que le capteur extérieur le dépasse réellement.", + "cover_forecast_threshold": "Température maximale prévue au-delà de laquelle la fermeture proactive se déclenche.", + "cover_evening_reopen_temp": "Une fois les volets fermés par cette automatisation, ils se rouvrent automatiquement après la fin de la plage horaire dès que le capteur extérieur redescend à cette température ou en dessous." } }, "sunrise_schedulers": { @@ -201,16 +207,22 @@ "cover_time_start": "Début de la plage horaire", "cover_time_end": "Fin de la plage horaire", "cover_action": "Action sur les volets", - "cover_my_button": "Bouton position favorite (My)" + "cover_my_button": "Bouton position favorite (My)", + "cover_weather_entity": "Entité météo (fermeture proactive)", + "cover_forecast_threshold": "Seuil de prévision (°C)", + "cover_evening_reopen_temp": "Température de réouverture du soir (°C)" }, "data_description": { "cover_entities": "Sélectionnez les entités volets à contrôler en cas de forte chaleur.", "cover_temp_sensor": "Capteur fournissant la température extérieure.", "cover_temp_threshold": "Température au-dessus de laquelle l'action sélectionnée est déclenchée.", - "cover_time_start": "Heure de début de la plage d'activation.", - "cover_time_end": "Heure de fin de la plage d'activation.", + "cover_time_start": "Heure de début de la plage d'activation. C'est aussi l'heure à laquelle la prévision est vérifiée pour la fermeture proactive.", + "cover_time_end": "Heure de fin de la plage d'activation. C'est aussi l'heure la plus tôt à laquelle la réouverture du soir peut se déclencher.", "cover_action": "'Fermer' envoie close_cover (volets standard). 'Arrêter' envoie stop_cover, qui interrompt le mouvement en cours — utile pour les volets Somfy RTS (la position favorite My est un bouton séparé).", - "cover_my_button": "Bouton à appuyer pour envoyer les volets en position favorite (My). Si renseigné, ce bouton est utilisé à la place de l'action ci-dessous." + "cover_my_button": "Bouton à appuyer pour envoyer les volets en position favorite (My). Si renseigné, ce bouton est utilisé à la place de l'action ci-dessous.", + "cover_weather_entity": "Entité météo optionnelle avec prévisions journalières. Si renseignée, les volets se ferment de manière proactive à l'heure de début de plage si la prévision du jour dépasse le seuil de prévision, sans attendre que le capteur extérieur le dépasse réellement.", + "cover_forecast_threshold": "Température maximale prévue au-delà de laquelle la fermeture proactive se déclenche.", + "cover_evening_reopen_temp": "Une fois les volets fermés par cette automatisation, ils se rouvrent automatiquement après la fin de la plage horaire dès que le capteur extérieur redescend à cette température ou en dessous." } }, "sunrise_schedulers": { diff --git a/tests/test_coordinator_features.py b/tests/test_coordinator_features.py index d8dd5c0..31f0754 100644 --- a/tests/test_coordinator_features.py +++ b/tests/test_coordinator_features.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from datetime import datetime +from datetime import date, datetime from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -1065,6 +1065,366 @@ def test_no_call_when_temp_sensor_unavailable(self): hass.services.async_call.assert_not_called() +# --------------------------------------------------------------------------- +# Proactive forecast-based close +# --------------------------------------------------------------------------- + +class TestCoverProactiveClose: + """Verify CoverManager closes covers ahead of time based on the weather forecast.""" + + def _make_entry(self, forecast_threshold=28.0, weather_entity="weather.home", time_start="08:35:00", time_end="18:00:00"): + from custom_components.homeshift.const import ( + CONF_COVER_ENTITIES, + CONF_COVER_TEMP_SENSOR, + CONF_COVER_TEMP_THRESHOLD, + CONF_COVER_TIME_START, + CONF_COVER_TIME_END, + CONF_COVER_WEATHER_ENTITY, + CONF_COVER_FORECAST_THRESHOLD, + ) + + entry = make_mock_entry() + entry.options = { + CONF_COVER_ENTITIES: ["cover.volet_salon"], + CONF_COVER_TEMP_SENSOR: "sensor.temp", + CONF_COVER_TEMP_THRESHOLD: 99.0, # keep the reactive path from firing in these tests + CONF_COVER_TIME_START: time_start, + CONF_COVER_TIME_END: time_end, + } + if weather_entity is not None: + entry.options[CONF_COVER_WEATHER_ENTITY] = weather_entity + if forecast_threshold is not None: + entry.options[CONF_COVER_FORECAST_THRESHOLD] = forecast_threshold + return entry + + def _forecast_side_effect(self, forecast_temp): + def _side_effect(domain, service, data=None, **kwargs): + if domain == "weather" and service == "get_forecasts": + return {data["entity_id"]: {"forecast": [{"temperature": forecast_temp}]}} + return None + return _side_effect + + def test_closes_when_forecast_exceeds_threshold_at_window_start(self): + """Covers close proactively when the forecast high exceeds the threshold at time_start.""" + hass = make_mock_hass() + hass.services.async_call = AsyncMock(side_effect=self._forecast_side_effect(32.0)) + entry = self._make_entry(forecast_threshold=28.0, time_start="08:35:00") + hass.states.get.side_effect = lambda entity_id: self._temp_state(20.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") + + coordinator = HomeShiftCoordinator(hass, entry) + now = datetime(2026, 7, 1, 8, 35, 0) + + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_heat_protection(now) + ) + + calls = hass.services.async_call.call_args_list + assert any(c.args[0] == "weather" and c.args[1] == "get_forecasts" for c in calls) + close_calls = [c for c in calls if c.args[0] == "cover" and c.args[1] == "close_cover"] + assert len(close_calls) == 1 + assert "cover.volet_salon" in close_calls[0].args[2]["entity_id"] + assert coordinator._cover_manager._closed_date == now.date() + + def test_no_close_when_forecast_below_threshold(self): + """No cover action when the forecast high stays under the threshold.""" + hass = make_mock_hass() + hass.services.async_call = AsyncMock(side_effect=self._forecast_side_effect(24.0)) + entry = self._make_entry(forecast_threshold=28.0, time_start="08:35:00") + hass.states.get.side_effect = lambda entity_id: self._temp_state(20.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") + + coordinator = HomeShiftCoordinator(hass, entry) + now = datetime(2026, 7, 1, 8, 35, 0) + + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_heat_protection(now) + ) + + calls = hass.services.async_call.call_args_list + assert not any(c.args[0] == "cover" for c in calls) + + def test_no_forecast_lookup_when_no_weather_entity_configured(self): + """No weather.get_forecasts call when CONF_COVER_WEATHER_ENTITY is unset.""" + hass = make_mock_hass() + hass.services.async_call = AsyncMock(side_effect=self._forecast_side_effect(32.0)) + entry = self._make_entry(weather_entity=None, time_start="08:35:00") + hass.states.get.side_effect = lambda entity_id: self._temp_state(20.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") + + coordinator = HomeShiftCoordinator(hass, entry) + now = datetime(2026, 7, 1, 8, 35, 0) + + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_heat_protection(now) + ) + + hass.services.async_call.assert_not_called() + + def test_no_close_before_window_start(self): + """No forecast lookup yet if now is before CONF_COVER_TIME_START.""" + hass = make_mock_hass() + hass.services.async_call = AsyncMock(side_effect=self._forecast_side_effect(32.0)) + entry = self._make_entry(forecast_threshold=28.0, time_start="08:35:00") + hass.states.get.side_effect = lambda entity_id: self._temp_state(20.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") + + coordinator = HomeShiftCoordinator(hass, entry) + now = datetime(2026, 7, 1, 7, 0, 0) + + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_heat_protection(now) + ) + + hass.services.async_call.assert_not_called() + + def test_runs_once_per_day(self): + """A second check on the same day does not repeat the forecast lookup or the close.""" + hass = make_mock_hass() + hass.services.async_call = AsyncMock(side_effect=self._forecast_side_effect(32.0)) + entry = self._make_entry(forecast_threshold=28.0, time_start="08:35:00") + hass.states.get.side_effect = lambda entity_id: self._temp_state(20.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") + + coordinator = HomeShiftCoordinator(hass, entry) + + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_heat_protection(datetime(2026, 7, 1, 8, 35, 0)) + ) + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_heat_protection(datetime(2026, 7, 1, 9, 0, 0)) + ) + + calls = hass.services.async_call.call_args_list + forecast_calls = [c for c in calls if c.args[0] == "weather"] + close_calls = [c for c in calls if c.args[0] == "cover" and c.args[1] == "close_cover"] + assert len(forecast_calls) == 1 + assert len(close_calls) == 1 + + def _temp_state(self, temperature: float): + state = MagicMock() + state.state = str(temperature) + return state + + +# --------------------------------------------------------------------------- +# Automatic evening reopen +# --------------------------------------------------------------------------- + +class TestCoverEveningReopen: + """Verify CoverManager reopens covers it closed, once it has cooled down after the window.""" + + def _make_entry(self, evening_reopen_temp=26.0, time_end="18:00:00"): + from custom_components.homeshift.const import ( + CONF_COVER_ENTITIES, + CONF_COVER_TEMP_SENSOR, + CONF_COVER_TEMP_THRESHOLD, + CONF_COVER_TIME_START, + CONF_COVER_TIME_END, + CONF_COVER_EVENING_REOPEN_TEMP, + ) + + entry = make_mock_entry() + entry.options = { + CONF_COVER_ENTITIES: ["cover.volet_salon"], + CONF_COVER_TEMP_SENSOR: "sensor.temp", + CONF_COVER_TEMP_THRESHOLD: 99.0, # keep the reactive path from firing in these tests + CONF_COVER_TIME_START: "08:35:00", + CONF_COVER_TIME_END: time_end, + CONF_COVER_EVENING_REOPEN_TEMP: evening_reopen_temp, + } + return entry + + def _temp_state(self, temperature: float): + state = MagicMock() + state.state = str(temperature) + return state + + def _make_coordinator(self, hass, entry, closed_date): + coordinator = HomeShiftCoordinator(hass, entry) + coordinator._cover_manager._closed_date = closed_date + return coordinator + + def test_reopens_after_window_end_once_cooled(self): + """cover.open_cover is called once the sensor drops to/below the reopen temperature.""" + hass = make_mock_hass() + hass.services.async_call = AsyncMock() + entry = self._make_entry(evening_reopen_temp=26.0, time_end="18:00:00") + hass.states.get.side_effect = lambda entity_id: self._temp_state(25.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") + + now = datetime(2026, 7, 1, 20, 0, 0) + coordinator = self._make_coordinator(hass, entry, closed_date=now.date()) + + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_heat_protection(now) + ) + + hass.services.async_call.assert_called_once() + call = hass.services.async_call.call_args + assert call.args[0] == "cover" + assert call.args[1] == "open_cover" + assert "cover.volet_salon" in call.args[2]["entity_id"] + assert coordinator._cover_manager._reopened_date == now.date() + + def test_no_reopen_while_still_hot(self): + """No reopen while the sensor is still above the reopen temperature.""" + hass = make_mock_hass() + hass.services.async_call = AsyncMock() + entry = self._make_entry(evening_reopen_temp=26.0, time_end="18:00:00") + hass.states.get.side_effect = lambda entity_id: self._temp_state(27.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") + + now = datetime(2026, 7, 1, 20, 0, 0) + coordinator = self._make_coordinator(hass, entry, closed_date=now.date()) + + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_heat_protection(now) + ) + + hass.services.async_call.assert_not_called() + + def test_no_reopen_before_window_end(self): + """No reopen before CONF_COVER_TIME_END, even if already cool enough.""" + hass = make_mock_hass() + hass.services.async_call = AsyncMock() + entry = self._make_entry(evening_reopen_temp=26.0, time_end="18:00:00") + hass.states.get.side_effect = lambda entity_id: self._temp_state(20.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") + + now = datetime(2026, 7, 1, 15, 0, 0) + coordinator = self._make_coordinator(hass, entry, closed_date=now.date()) + + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_heat_protection(now) + ) + + hass.services.async_call.assert_not_called() + + def test_no_reopen_when_nothing_closed_today(self): + """No reopen when the automation never closed a cover today.""" + hass = make_mock_hass() + hass.services.async_call = AsyncMock() + entry = self._make_entry(evening_reopen_temp=26.0, time_end="18:00:00") + hass.states.get.side_effect = lambda entity_id: self._temp_state(20.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") + + now = datetime(2026, 7, 1, 20, 0, 0) + coordinator = self._make_coordinator(hass, entry, closed_date=None) + + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_heat_protection(now) + ) + + hass.services.async_call.assert_not_called() + + def test_reopens_once_per_day(self): + """A second check the same evening does not reopen a second time.""" + hass = make_mock_hass() + hass.services.async_call = AsyncMock() + entry = self._make_entry(evening_reopen_temp=26.0, time_end="18:00:00") + hass.states.get.side_effect = lambda entity_id: self._temp_state(20.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") + + now = datetime(2026, 7, 1, 20, 0, 0) + coordinator = self._make_coordinator(hass, entry, closed_date=now.date()) + + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_heat_protection(now) + ) + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_heat_protection(datetime(2026, 7, 1, 21, 0, 0)) + ) + + hass.services.async_call.assert_called_once() + + +# --------------------------------------------------------------------------- +# Cover manager state persistence (async_restore_state / _async_save_state) +# --------------------------------------------------------------------------- + +class TestCoverPersistence: + """Verify CoverManager persists and restores its once-per-day action dates.""" + + def _make_store(self, stored_data: dict | None) -> MagicMock: + """Return a mock Store that returns stored_data on async_load.""" + store = MagicMock() + store.async_load = AsyncMock(return_value=stored_data) + store.async_save = AsyncMock() + return store + + def test_restore_state_sets_all_three_dates(self): + """async_restore_state() parses all three persisted ISO dates.""" + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + mock_store = self._make_store( + { + "proactive_checked_date": "2026-07-01", + "closed_date": "2026-07-01", + "reopened_date": None, + } + ) + coordinator._cover_manager._store = mock_store + + asyncio.get_event_loop().run_until_complete(coordinator._cover_manager.async_restore_state()) + + assert coordinator._cover_manager._proactive_checked_date == date(2026, 7, 1) + assert coordinator._cover_manager._closed_date == date(2026, 7, 1) + assert coordinator._cover_manager._reopened_date is None + + def test_restore_state_no_stored_data_leaves_defaults(self): + """async_restore_state() leaves all dates None when storage is empty.""" + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + mock_store = self._make_store(None) + coordinator._cover_manager._store = mock_store + + asyncio.get_event_loop().run_until_complete(coordinator._cover_manager.async_restore_state()) + + assert coordinator._cover_manager._closed_date is None + assert coordinator._cover_manager._reopened_date is None + assert coordinator._cover_manager._proactive_checked_date is None + + def test_restore_state_handles_load_error_gracefully(self): + """async_restore_state() does not raise when storage load fails.""" + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + mock_store = MagicMock() + mock_store.async_load = AsyncMock(side_effect=OSError("disk error")) + coordinator._cover_manager._store = mock_store + + # Should not raise + asyncio.get_event_loop().run_until_complete(coordinator._cover_manager.async_restore_state()) + assert coordinator._cover_manager._closed_date is None + + def test_save_state_called_after_reactive_close(self): + """_async_save_state() persists closed_date after a reactive close.""" + from custom_components.homeshift.const import ( + CONF_COVER_ENTITIES, + CONF_COVER_TEMP_SENSOR, + CONF_COVER_TEMP_THRESHOLD, + CONF_COVER_TIME_START, + CONF_COVER_TIME_END, + ) + + hass = make_mock_hass() + hass.services.async_call = AsyncMock() + entry = make_mock_entry() + entry.options = { + CONF_COVER_ENTITIES: ["cover.volet_salon"], + CONF_COVER_TEMP_SENSOR: "sensor.temp", + CONF_COVER_TEMP_THRESHOLD: 30.0, + CONF_COVER_TIME_START: "08:00:00", + CONF_COVER_TIME_END: "20:00:00", + } + temp_state = MagicMock() + temp_state.state = "35.0" + hass.states.get.side_effect = lambda entity_id: temp_state if entity_id == "sensor.temp" else make_calendar_state(state="off") + + coordinator = HomeShiftCoordinator(hass, entry) + mock_store = MagicMock() + mock_store.async_save = AsyncMock() + coordinator._cover_manager._store = mock_store + + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_heat_protection(datetime(2026, 7, 1, 14, 0, 0)) + ) + + mock_store.async_save.assert_called_once() + saved = mock_store.async_save.call_args[0][0] + assert saved["closed_date"] == "2026-07-01" + + # --------------------------------------------------------------------------- # is_heat_protection_active (binary sensor logic) # --------------------------------------------------------------------------- diff --git a/tests/test_init.py b/tests/test_init.py index c8ef605..e8b0238 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -61,6 +61,7 @@ def test_sync_scheduled_immediately_when_ha_already_running(self): ): coord = MagicMock() coord.async_restore_state = AsyncMock() + coord._cover_manager.async_restore_state = AsyncMock() coord.async_config_entry_first_refresh = AsyncMock() coord.async_sync_calendar = AsyncMock() MockCoord.return_value = coord @@ -82,6 +83,7 @@ def test_event_listener_registered_when_ha_not_yet_running(self): ): coord = MagicMock() coord.async_restore_state = AsyncMock() + coord._cover_manager.async_restore_state = AsyncMock() coord.async_config_entry_first_refresh = AsyncMock() coord.async_sync_calendar = AsyncMock() MockCoord.return_value = coord @@ -108,6 +110,7 @@ def test_listener_unsubscribe_registered_as_unload_hook(self): ): coord = MagicMock() coord.async_restore_state = AsyncMock() + coord._cover_manager.async_restore_state = AsyncMock() coord.async_config_entry_first_refresh = AsyncMock() coord.async_sync_calendar = AsyncMock() MockCoord.return_value = coord @@ -140,6 +143,7 @@ def capture_listen_once(event_name, cb): ): coord = MagicMock() coord.async_restore_state = AsyncMock() + coord._cover_manager.async_restore_state = AsyncMock() coord.async_config_entry_first_refresh = AsyncMock() coord.async_sync_calendar = AsyncMock() MockCoord.return_value = coord @@ -176,6 +180,7 @@ def capture_listen_once(event_name, callback): ): coord = MagicMock() coord.async_restore_state = AsyncMock() + coord._cover_manager.async_restore_state = AsyncMock() coord.async_config_entry_first_refresh = AsyncMock() coord.async_sync_calendar = AsyncMock() MockCoord.return_value = coord From ce5dfeee6ddda6c027f06a36f49f80b5d67879e2 Mon Sep 17 00:00:00 2001 From: Gamso Date: Sat, 18 Jul 2026 13:31:27 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20native=20daily=20cover=20open/close?= =?UTF-8?q?=20schedule,=20drop=20Sunrise=20Scheduler=20Adjustment=20-=20Ad?= =?UTF-8?q?d=20CoverManager.async=5Fcompute=5Fdaily=5Fschedule()/async=5Fc?= =?UTF-8?q?heck=5Fdaily=5Fschedule():=20opens/closes=20CONF=5FDAILY=5FCOVE?= =?UTF-8?q?R=5FENTITIES=20(typically=20a=20cover=20group)=20natively,=20wi?= =?UTF-8?q?thout=20any=20Scheduler-integration=20entity=20=E2=80=94=20sepa?= =?UTF-8?q?rate=20from=20CONF=5FCOVER=5FENTITIES=20used=20by=20heat=20prot?= =?UTF-8?q?ection,=20so=20a=20single=20unitary=20cover=20stays=20under=20h?= =?UTF-8?q?eat=20protection's=20control=20while=20a=20whole-house=20group?= =?UTF-8?q?=20follows=20this=20schedule=20-=20Open=20time=20is=20resolved?= =?UTF-8?q?=20per=20day=20mode=20via=20daily=5Fcover=5Fopen=5Ftime=5Fmap?= =?UTF-8?q?=20("ModeKey:Value,=20..."):=20'sunrise'=20(floored=20at=20sunr?= =?UTF-8?q?ise=5Fearliest),=20'skip'=20(no=20automatic=20opening),=20or=20?= =?UTF-8?q?a=20fixed=20'HH:MM'=20=E2=80=94=20one=20config=20field=20per=20?= =?UTF-8?q?day=20mode=20in=20a=20new=20"Daily=20Cover=20Schedule"=20option?= =?UTF-8?q?s-flow=20step,=20so=20behavior=20can=20be=20batched=20per=20mod?= =?UTF-8?q?e=20(e.g.=20Work=20+=20Remote=20both=20on=20sunrise)=20instead?= =?UTF-8?q?=20of=20two=20separate=20sunrise-modes/skip-modes=20lists;=20a?= =?UTF-8?q?=20mode=20absent=20from=20the=20map=20falls=20back=20to=2008:30?= =?UTF-8?q?,=20so=20Away=20opens=20like=20Home=20by=20default=20-=20Close?= =?UTF-8?q?=20time=20is=20always=20today's=20sunset=20+=20daily=5Fcover=5F?= =?UTF-8?q?close=5Foffset=5Fminutes,=20unconditionally=20for=20every=20day?= =?UTF-8?q?=20mode=20(closing=20was=20never=20mode-dependent)=20-=20Both?= =?UTF-8?q?=20actions=20fire=20at=20most=20once=20per=20calendar=20day;=20?= =?UTF-8?q?state=20persists=20via=20the=20same=20Store=20as=20heat=20prote?= =?UTF-8?q?ction,=20surviving=20a=20HA=20restart=20mid-day=20-=20Remove=20?= =?UTF-8?q?CoverManager.async=5Fadjust=5Fsunrise=5Fschedulers()=20and=20th?= =?UTF-8?q?e=20whole=20"Sunrise=20Scheduler=20Adjustment"=20config=5Fflow?= =?UTF-8?q?=20step/menu=20entry/translations=20=E2=80=94=20superseded=20no?= =?UTF-8?q?w=20that=20Daily=20Cover=20Schedule=20covers=20sunrise-based=20?= =?UTF-8?q?opening=20natively;=20CONF=5FSUNRISE=5FEARLIEST=20is=20kept=20(?= =?UTF-8?q?now=20solely=20used=20as=20the=20daily=20schedule's=20sunrise?= =?UTF-8?q?=20floor)=20-=20sensor.cover=5Fopen=5Ftime=20now=20only=20regis?= =?UTF-8?q?ters=20on=20daily=5Fcover=5Fentities;=20new=20sensor.cover=5Fcl?= =?UTF-8?q?ose=5Ftime=20mirrors=20it=20for=20the=20computed=20close=20time?= =?UTF-8?q?=20-=20Update=20README=20(Daily=20Cover=20Schedule=20section,?= =?UTF-8?q?=20config=20table,=20feature-support=20table)=20and=20translati?= =?UTF-8?q?ons=20(EN/FR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .devcontainer/configuration.yaml | 13 +- README.md | 52 +-- custom_components/homeshift/config_flow.py | 129 +++++--- custom_components/homeshift/const.py | 20 +- custom_components/homeshift/coordinator.py | 16 +- custom_components/homeshift/cover_manager.py | 220 ++++++++----- custom_components/homeshift/sensor.py | 46 ++- .../homeshift/translations/en.json | 55 +++- .../homeshift/translations/fr.json | 55 +++- tests/test_coordinator_features.py | 295 ++++++++++++------ 10 files changed, 606 insertions(+), 295 deletions(-) diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index f640c7d..87922cb 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -7,11 +7,14 @@ homeassistant: country: FR time_zone: Europe/Paris -# Charger la config par défaut SAUF conversation -default_config: - -# Désactiver explicitement conversation -conversation: null +# default_config: pulls in bluetooth/usb/dhcp/ssdp/cloud/assist_pipeline/ +# conversation/stream/mobile_app/go2rtc — none of which work (or matter) in a +# devcontainer, and their on-the-fly pip installs are what caused frontend to +# fail to load (concurrent installs during startup). Declare only what's +# actually useful for testing HomeShift instead. frontend/config/automation/ +# script/scene are already pulled in via lovelace: below and core bootstrap. +history: +logbook: # Enable development mode logger: diff --git a/README.md b/README.md index eadc905..0a3b3c8 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Automatic day-mode and thermostat-mode management for Home Assistant, driven by - [`sensor.next_mode`](#sensornext_mode) - [`sensor.next_mode_at`](#sensornext_mode_at) - [`sensor.cover_open_time`](#sensorcover_open_time) + - [`sensor.cover_close_time`](#sensorcover_close_time) - [🛠️ Services](#️-services) - [`homeshift.refresh_schedulers`](#homeshiftrefresh_schedulers) - [`homeshift.sync_calendar`](#homeshiftsync_calendar) @@ -33,7 +34,7 @@ Automatic day-mode and thermostat-mode management for Home Assistant, driven by - [Early Switch](#early-switch) - [🗓️ Scheduler Integration](#️-scheduler-integration) - [Thermostat Tags](#thermostat-tags) - - [🌅 Sunrise Scheduler Adjustment](#-sunrise-scheduler-adjustment) + - [🗓️ Daily Cover Schedule](#️-daily-cover-schedule) - [☀️ Cover Heat Protection](#️-cover-heat-protection) - [Reactive Close](#reactive-close) - [Proactive Forecast-Based Close](#proactive-forecast-based-close) @@ -147,11 +148,18 @@ Shows when the next automatic mode change is expected to occur (taking early_swi - **Unit:** ISO 8601 datetime ### `sensor.cover_open_time` -Shows the cover opening time computed for today by the Sunrise Scheduler Adjustment feature. +Shows the cover opening time computed for today by the Daily Cover Schedule feature. - **Type:** Sensor (text) -- **Value:** `HH:MM` string (e.g. `07:45`), or `unknown` if the feature is not configured or has not run yet. -- **Only registered** when at least one scheduler entity is listed in **Sunrise Schedulers**. +- **Value:** `HH:MM` string (e.g. `07:45`), or `unknown` if not configured or has not run yet. +- **Only registered** when **Daily Cover Entities** is configured. + +### `sensor.cover_close_time` +Shows the daily cover closing time computed for today (today's sunset + offset) by the Daily Cover Schedule feature. + +- **Type:** Sensor (text) +- **Value:** `HH:MM` string (e.g. `21:40`), or `unknown` if not configured or not yet computed. +- **Only registered** when **Daily Cover Entities** is configured. --- @@ -193,8 +201,10 @@ All parameters can be changed at any time via **Settings → Devices & Services | **Weather Entity** | — | Weather entity with daily forecasts, used for the proactive close (optional) | | **Forecast Threshold** | `28 °C` | Forecast daily high above which covers close proactively | | **Evening Reopen Temperature** | `26 °C` | Once closed by this automation, covers reopen after Heat Window End when the sensor drops to/below this value | -| **Sunrise Schedulers** | — | Scheduler switch entities whose opening time tracks sunrise | -| **Earliest Open Time** | `07:10` | Minimum opening time even when sunrise is earlier | +| **Daily Cover Entities** | — | Cover entity/group opened and closed daily (optional, separate from Cover Entities above) | +| **Open Time — *(per day mode)*** | `08:30` | One field per day mode: `sunrise`, `skip`, or a custom `HH:MM` value | +| **Earliest Open Time** | `07:00` | Floor time used when a day mode's Open Time is `sunrise` | +| **Close Offset After Sunset** | `10 min` | Covers close this many minutes after sunset, every day, for every mode | --- @@ -267,21 +277,24 @@ When `thermostat_mode` is set to **Off**, HomeShift will force-disable all switc --- -## 🌅 Sunrise Scheduler Adjustment +## 🗓️ Daily Cover Schedule -HomeShift can adjust the opening time of scheduler switches every morning based on today's actual sunrise time. +HomeShift can natively open and close a cover (typically a cover group) every day, without depending on any Scheduler-integration entity. This is independent from Cover Heat Protection below (which targets a single, unitary cover) — **Daily Cover Entities** is its own entity list, so a whole-house cover group can follow the daily open/close schedule while a single south-facing cover stays under heat-protection's control. -Each day shortly after midnight, HomeShift computes: -``` -target_time = max(sunrise_local, earliest_open_time) -``` -and calls `scheduler.edit` on each configured scheduler entity to update its first timeslot start time. +Once per day, shortly after midnight, HomeShift computes: +- **Open time** — resolved per day mode. Each configured day mode has its own **Open Time** field, set to one of: + - `sunrise` — sunrise, floored at **Earliest Open Time** (e.g. never before `07:00`) + - `skip` — no automatic opening for that mode (covers stay as they are) + - a custom `HH:MM` value — a fixed clock time + + Day modes sharing the same value effectively form a batch (e.g. both `Work` and `Remote` set to `sunrise`). A day mode with no value configured falls back to `08:30`. There's no separate "skip modes" list — set a mode's Open Time to `skip` directly. +- **Close time** — today's sunset plus **Close Offset After Sunset**, always, for every day mode (closing is not mode-dependent) + +Then, on every coordinator poll, it opens the covers once now reaches the open time, and closes them once now reaches the close time — each action firing at most once per calendar day. Timing resolution matches the poll interval (5 minutes by default), not the exact minute. -**Configuration:** -- **Sunrise Schedulers** — list of `switch.schedule_*` entities to update -- **Earliest Open Time** — floor time so covers never open before a fixed hour (e.g. `07:10`) +**`sensor.cover_open_time`** and **`sensor.cover_close_time`** reflect today's computed times, so you can display them on your dashboard. -**`sensor.cover_open_time`** reflects the time that was applied this morning, so you can display it on your dashboard. +> **Migrating from Scheduler-integration volet entities:** if you previously used two Scheduler entities (a fixed/sunrise-based "open" and a sunset-offset "close") purely to drive covers, you can disable/delete them once Daily Cover Schedule is configured with the same times — HomeShift no longer needs the Scheduler integration for covers at all. --- @@ -315,7 +328,7 @@ The reopen only ever applies to a cover this automation itself closed that day; ### State Persistence -Which covers were closed today, when the forecast was last checked, and whether the evening reopen already ran are all persisted to storage, so a Home Assistant restart mid-day doesn't lose track of the day's state (e.g. it won't skip the evening reopen just because HA restarted in the afternoon). +Which covers were closed today, when the forecast was last checked, and whether the evening reopen already ran are all persisted to storage, so a Home Assistant restart mid-day doesn't lose track of the day's state (e.g. it won't skip the evening reopen just because HA restarted in the afternoon). The same storage also tracks whether today's Daily Cover Schedule open/close actions have already run, for the same reason. --- @@ -328,7 +341,8 @@ Which covers were closed today, when the forecast was last checked, and whether | Half-day event support | ✅ | See [Half-Day Events](#half-day-events) | | Early switch (pre-activation) | ✅ | See [Early Switch](#early-switch) | | Manual override with timeout | ✅ | `number.override_duration` | -| Sunrise-based scheduler opening time | ✅ | See [Sunrise Scheduler Adjustment](#-sunrise-scheduler-adjustment) | +| Native daily cover open/close (no Scheduler entity needed) | ✅ | See [Daily Cover Schedule](#️-daily-cover-schedule) | +| Daily cover schedule state survives HA restart | ✅ | Persisted alongside the heat-protection cover state | | Cover reactive heat close | ✅ | See [Reactive Close](#reactive-close) | | Cover proactive forecast-based close | ✅ | See [Proactive Forecast-Based Close](#proactive-forecast-based-close) | | Cover automatic evening reopen | ✅ | See [Automatic Evening Reopen](#automatic-evening-reopen) | diff --git a/custom_components/homeshift/config_flow.py b/custom_components/homeshift/config_flow.py index 2b2c703..29488b6 100644 --- a/custom_components/homeshift/config_flow.py +++ b/custom_components/homeshift/config_flow.py @@ -46,7 +46,11 @@ DEFAULT_COVER_FORECAST_THRESHOLD, CONF_COVER_EVENING_REOPEN_TEMP, DEFAULT_COVER_EVENING_REOPEN_TEMP, - CONF_SUNRISE_SCHEDULERS, + CONF_DAILY_COVER_ENTITIES, + CONF_DAILY_COVER_OPEN_TIME_MAP, + CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, + DEFAULT_DAILY_COVER_OPEN_TIME, + DEFAULT_DAILY_COVER_CLOSE_OFFSET_MINUTES, CONF_SUNRISE_EARLIEST, DEFAULT_SUNRISE_EARLIEST, LOCALIZED_DEFAULTS, @@ -369,21 +373,74 @@ def _covers_schema(hass, data: dict[str, Any]) -> vol.Schema: ) -def _sunrise_schedulers_schema(hass, data: dict[str, Any]) -> vol.Schema: - """Build the sunrise-scheduler adjustment form schema.""" - sel = _scheduler_selector(hass) - return vol.Schema( - { - vol.Optional( - CONF_SUNRISE_SCHEDULERS, - default=data.get(CONF_SUNRISE_SCHEDULERS, []), - ): sel, - vol.Optional( - CONF_SUNRISE_EARLIEST, - default=data.get(CONF_SUNRISE_EARLIEST, DEFAULT_SUNRISE_EARLIEST), - ): selector.TimeSelector(), - } +def _daily_open_time_fields(data: dict[str, Any]) -> dict: + """Return one open-time field per day mode key (key = label). + + Each field accepts 'sunrise', 'skip', or a custom 'HH:MM' value — letting + every day mode be assigned its own opening behavior independently, so + modes that should share a time are simply set to the same value. + """ + day_mode_map = _parse_day_mode_map(data.get(CONF_DAY_MODE_MAP, DEFAULT_DAY_MODE_MAP)) + current_map = _parse_day_mode_map(data.get(CONF_DAILY_COVER_OPEN_TIME_MAP, "")) + time_or_special_selector = selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + {"value": "sunrise", "label": "Sunrise (floored at Earliest Open Time)"}, + {"value": "skip", "label": "Skip (do not open automatically)"}, + ], + custom_value=True, + mode=selector.SelectSelectorMode.DROPDOWN, + ) ) + fields: dict = {} + for key in day_mode_map: + field_name = f"daily_open_time_{key}" + fields[ + vol.Optional( + field_name, + default=current_map.get(key, DEFAULT_DAILY_COVER_OPEN_TIME), + ) + ] = time_or_special_selector + return fields + + +def _rebuild_daily_open_time_map(user_input: dict[str, Any], data: dict[str, Any]) -> str: + """Reconstruct CONF_DAILY_COVER_OPEN_TIME_MAP from individual per-mode fields.""" + day_mode_map = _parse_day_mode_map(data.get(CONF_DAY_MODE_MAP, DEFAULT_DAY_MODE_MAP)) + current_map = _parse_day_mode_map(data.get(CONF_DAILY_COVER_OPEN_TIME_MAP, "")) + pairs: list[str] = [] + for key in day_mode_map: + field_name = f"daily_open_time_{key}" + value = user_input.pop(field_name, current_map.get(key, DEFAULT_DAILY_COVER_OPEN_TIME)) + pairs.append(f"{key}:{value}") + return ", ".join(pairs) + + +def _daily_cover_schema(hass, data: dict[str, Any]) -> vol.Schema: + """Build the native daily cover open/close schedule form schema.""" + schema_dict: dict = { + vol.Optional( + CONF_DAILY_COVER_ENTITIES, + default=data.get(CONF_DAILY_COVER_ENTITIES, []), + ): selector.EntitySelector(selector.EntitySelectorConfig(domain="cover", multiple=True)), + vol.Optional( + CONF_SUNRISE_EARLIEST, + default=data.get(CONF_SUNRISE_EARLIEST, DEFAULT_SUNRISE_EARLIEST), + ): selector.TimeSelector(), + vol.Optional( + CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, + default=data.get(CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, DEFAULT_DAILY_COVER_CLOSE_OFFSET_MINUTES), + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + max=120, + step=1, + mode=selector.NumberSelectorMode.BOX, + ) + ), + } + schema_dict.update(_daily_open_time_fields(data)) + return vol.Schema(schema_dict) # --------------------------------------------------------------------------- @@ -430,7 +487,7 @@ async def async_step_menu( _user_input: dict[str, Any] | None = None, ) -> config_entries.ConfigFlowResult: """Show the configuration menu.""" - menu_options = ["calendars", "mapping", "schedulers", "covers", "sunrise_schedulers"] + menu_options = ["calendars", "mapping", "schedulers", "covers", "daily_cover_schedule"] if self._is_config_complete(): menu_options.append("finalize") return self.async_show_menu(step_id="menu", menu_options=menu_options) @@ -512,26 +569,23 @@ async def async_step_covers( data_schema=_covers_schema(self.hass, self._effective_data()), ) - # -- sunrise schedulers ------------------------------------------------ + # -- daily cover schedule ------------------------------------------------ - async def async_step_sunrise_schedulers( + async def async_step_daily_cover_schedule( self, user_input: dict[str, Any] | None = None, ) -> config_entries.ConfigFlowResult: - """Configure sunrise-based scheduler adjustment.""" + """Configure the native daily cover open/close schedule.""" if user_input is not None: - value = user_input.get(CONF_SUNRISE_SCHEDULERS, []) - if isinstance(value, str): - value = [value] if value else [] - self._data[CONF_SUNRISE_SCHEDULERS] = value - self._data[CONF_SUNRISE_EARLIEST] = user_input.get( - CONF_SUNRISE_EARLIEST, DEFAULT_SUNRISE_EARLIEST + user_input[CONF_DAILY_COVER_OPEN_TIME_MAP] = _rebuild_daily_open_time_map( + user_input, self._effective_data() ) + self._data.update(user_input) return await self.async_step_menu() return self.async_show_form( - step_id="sunrise_schedulers", - data_schema=_sunrise_schedulers_schema(self.hass, self._effective_data()), + step_id="daily_cover_schedule", + data_schema=_daily_cover_schema(self.hass, self._effective_data()), ) # -- finalize ---------------------------------------------------------- @@ -593,7 +647,7 @@ async def async_step_menu( _user_input: dict[str, Any] | None = None, ) -> config_entries.ConfigFlowResult: """Show the options menu.""" - menu_options = ["calendars", "mapping", "schedulers", "covers", "sunrise_schedulers"] + menu_options = ["calendars", "mapping", "schedulers", "covers", "daily_cover_schedule"] if self._is_config_complete(): menu_options.append("finalize") return self.async_show_menu(step_id="menu", menu_options=menu_options) @@ -675,26 +729,23 @@ async def async_step_covers( data_schema=_covers_schema(self.hass, self._effective_data()), ) - # -- sunrise schedulers ------------------------------------------------ + # -- daily cover schedule ------------------------------------------------ - async def async_step_sunrise_schedulers( + async def async_step_daily_cover_schedule( self, user_input: dict[str, Any] | None = None, ) -> config_entries.ConfigFlowResult: - """Configure sunrise-based scheduler adjustment.""" + """Configure the native daily cover open/close schedule.""" if user_input is not None: - value = user_input.get(CONF_SUNRISE_SCHEDULERS, []) - if isinstance(value, str): - value = [value] if value else [] - self._data[CONF_SUNRISE_SCHEDULERS] = value - self._data[CONF_SUNRISE_EARLIEST] = user_input.get( - CONF_SUNRISE_EARLIEST, DEFAULT_SUNRISE_EARLIEST + user_input[CONF_DAILY_COVER_OPEN_TIME_MAP] = _rebuild_daily_open_time_map( + user_input, self._effective_data() ) + self._data.update(user_input) return await self.async_step_menu() return self.async_show_form( - step_id="sunrise_schedulers", - data_schema=_sunrise_schedulers_schema(self.hass, self._effective_data()), + step_id="daily_cover_schedule", + data_schema=_daily_cover_schema(self.hass, self._effective_data()), ) # -- finalize ---------------------------------------------------------- diff --git a/custom_components/homeshift/const.py b/custom_components/homeshift/const.py index 6745f06..dc36fe2 100644 --- a/custom_components/homeshift/const.py +++ b/custom_components/homeshift/const.py @@ -60,12 +60,27 @@ DEFAULT_COVER_EVENING_REOPEN_TEMP = 26.0 -# Sunrise-based scheduler adjustment -CONF_SUNRISE_SCHEDULERS = "sunrise_schedulers" +# Earliest sunrise-based opening time — used by the Daily Cover Schedule's +# 'sunrise' open-time value (floors sunrise so covers never open too early). CONF_SUNRISE_EARLIEST = "sunrise_earliest" DEFAULT_SUNRISE_EARLIEST = "07:00:00" +# Daily cover schedule — native open/close of a cover group (e.g. a whole-house +# volet group), replacing a pair of external Scheduler-integration entities. +# Kept separate from CONF_COVER_ENTITIES (heat protection), which targets a +# single cover — the daily schedule is meant for a group entity instead. +CONF_DAILY_COVER_ENTITIES = "daily_cover_entities" +# CONF_DAILY_COVER_OPEN_TIME_MAP format: "ModeKey:Value, ..." — Value is either +# 'sunrise' (floored at CONF_SUNRISE_EARLIEST), 'skip' (never opens +# automatically that day), or a fixed 'HH:MM' time. A mode key missing from +# the map falls back to DEFAULT_DAILY_COVER_OPEN_TIME. +CONF_DAILY_COVER_OPEN_TIME_MAP = "daily_cover_open_time_map" +CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES = "daily_cover_close_offset_minutes" + +DEFAULT_DAILY_COVER_OPEN_TIME = "08:30" +DEFAULT_DAILY_COVER_CLOSE_OFFSET_MINUTES = 10 + # Entity IDs SELECT_DAY_MODE = "day_mode" SELECT_THERMOSTAT_MODE = "thermostat_mode" @@ -75,6 +90,7 @@ SENSOR_NEXT_MODE = "next_mode" SENSOR_NEXT_MODE_AT = "next_mode_at" SENSOR_COVER_OPEN_TIME = "cover_open_time" +SENSOR_COVER_CLOSE_TIME = "cover_close_time" BINARY_SENSOR_COVER_HEAT_ACTIVE = "cover_heat_active" # Sentinel value used as today_type when no calendar event is active diff --git a/custom_components/homeshift/coordinator.py b/custom_components/homeshift/coordinator.py index 3002336..1ce1343 100644 --- a/custom_components/homeshift/coordinator.py +++ b/custom_components/homeshift/coordinator.py @@ -162,7 +162,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: # Persistent storage — used to restore modes after HA restart self._store: Store = Store(hass, STORAGE_VERSION, f"{STORAGE_KEY}.{entry.entry_id}") - # Cover heat-protection and sunrise scheduler adjustment + # Cover heat-protection and native daily cover open/close schedule self._cover_manager = CoverManager(hass, entry) # One-shot timer scheduled to fire at _next_mode_at; None when no timer is pending @@ -435,9 +435,14 @@ def next_mode_at(self) -> datetime | None: @property def cover_open_time(self) -> str | None: - """Today's computed cover opening time (HH:MM), or None if sunrise adjustment is not configured.""" + """Today's computed cover opening time (HH:MM), or None if not configured.""" return self._cover_manager.cover_open_time + @property + def cover_close_time(self) -> str | None: + """Today's computed daily cover closing time (HH:MM), or None if not configured.""" + return self._cover_manager.daily_close_time + def is_heat_protection_active(self, now: datetime) -> bool | None: """Return whether cover heat protection conditions are currently met. @@ -637,8 +642,8 @@ async def _async_update_data(self) -> dict: _LOGGER.info("New calendar day (%s), resetting today_type", today) self._today_type = EVENT_NONE self._today_date = today - # Adjust sunrise-based schedulers for the new day - await self._cover_manager.async_adjust_sunrise_schedulers() + # Compute today's native daily cover open/close times + await self._cover_manager.async_compute_daily_schedule(now, self.day_mode_key) if calendar_state.state == "on": event_message = calendar_state.attributes.get("message", "") @@ -734,6 +739,9 @@ async def _async_update_data(self) -> dict: # Cover heat protection — close covers if temperature exceeds threshold in window await self._cover_manager.async_check_heat_protection(now) + # Native daily cover open/close schedule + await self._cover_manager.async_check_daily_schedule(now) + return self._build_result() def _build_result(self) -> dict: diff --git a/custom_components/homeshift/cover_manager.py b/custom_components/homeshift/cover_manager.py index d7ff41e..7425892 100644 --- a/custom_components/homeshift/cover_manager.py +++ b/custom_components/homeshift/cover_manager.py @@ -1,12 +1,12 @@ """Cover manager for HomeShift integration. Handles cover heat-protection (closing covers when it gets too hot) and -sunrise-based scheduler adjustment. +the native daily open/close schedule for a cover group. """ from __future__ import annotations import logging -from datetime import date, datetime, time as dt_time +from datetime import date, datetime, time as dt_time, timedelta from typing import TYPE_CHECKING, Callable from homeassistant.config_entries import ConfigEntry @@ -32,7 +32,11 @@ DEFAULT_COVER_FORECAST_THRESHOLD, CONF_COVER_EVENING_REOPEN_TEMP, DEFAULT_COVER_EVENING_REOPEN_TEMP, - CONF_SUNRISE_SCHEDULERS, + CONF_DAILY_COVER_ENTITIES, + CONF_DAILY_COVER_OPEN_TIME_MAP, + CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, + DEFAULT_DAILY_COVER_OPEN_TIME, + DEFAULT_DAILY_COVER_CLOSE_OFFSET_MINUTES, CONF_SUNRISE_EARLIEST, DEFAULT_SUNRISE_EARLIEST, ) @@ -73,18 +77,33 @@ def _parse_stored_date(value: str | None) -> date | None: return None +def _parse_mode_map(raw: str) -> dict[str, str]: + """Parse a 'Key:Value, Key:Value, ...' string into a dict.""" + result: dict[str, str] = {} + for pair in raw.split(","): + pair = pair.strip() + if ":" in pair: + key, _, value = pair.partition(":") + result[key.strip()] = value.strip() + return result + + class CoverManager: - """Manages cover heat-protection and sunrise-based scheduler adjustment.""" + """Manages cover heat-protection and the native daily open/close schedule.""" def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._hass = hass self._entry = entry self.cover_open_time: str | None = None + self.daily_close_time: str | None = None # Tracks which day each once-per-day action last ran on, so proactive # closing and evening reopening each fire at most once per calendar day. self._proactive_checked_date: date | None = None self._closed_date: date | None = None self._reopened_date: date | None = None + # Same, for the native daily open/close schedule (separate cover group). + self._daily_opened_date: date | None = None + self._daily_closed_date: date | None = None self._store: Store = Store(hass, STORAGE_VERSION, f"{STORAGE_KEY}.{entry.entry_id}") @property @@ -111,6 +130,8 @@ async def async_restore_state(self) -> None: self._proactive_checked_date = _parse_stored_date(stored.get("proactive_checked_date")) self._closed_date = _parse_stored_date(stored.get("closed_date")) self._reopened_date = _parse_stored_date(stored.get("reopened_date")) + self._daily_opened_date = _parse_stored_date(stored.get("daily_opened_date")) + self._daily_closed_date = _parse_stored_date(stored.get("daily_closed_date")) async def _async_save_state(self) -> None: """Persist the once-per-day action dates to storage.""" @@ -120,6 +141,8 @@ async def _async_save_state(self) -> None: "proactive_checked_date": self._proactive_checked_date.isoformat() if self._proactive_checked_date else None, "closed_date": self._closed_date.isoformat() if self._closed_date else None, "reopened_date": self._reopened_date.isoformat() if self._reopened_date else None, + "daily_opened_date": self._daily_opened_date.isoformat() if self._daily_opened_date else None, + "daily_closed_date": self._daily_closed_date.isoformat() if self._daily_closed_date else None, } ) except Exception as err: # noqa: BLE001 - defensive around storage I/O @@ -362,98 +385,123 @@ def is_heat_protection_active(self, now: datetime) -> bool | None: threshold = float(self._config.get(CONF_COVER_TEMP_THRESHOLD, DEFAULT_COVER_TEMP_THRESHOLD)) return temperature > threshold - async def async_adjust_sunrise_schedulers(self) -> None: - """Adjust scheduler timeslots based on today's sunrise time. - - For each scheduler entity listed in CONF_SUNRISE_SCHEDULERS, compute: - target_time = max(sunrise, CONF_SUNRISE_EARLIEST) - and update its first timeslot start via the scheduler.edit service. - - Called automatically when a new calendar day is detected in the coordinator - so that schedulers are updated shortly after midnight. - """ - sunrise_schedulers = self._config.get(CONF_SUNRISE_SCHEDULERS, []) - if not sunrise_schedulers: - return - - earliest_str = self._config.get(CONF_SUNRISE_EARLIEST, DEFAULT_SUNRISE_EARLIEST) - earliest_time = _parse_time_str(earliest_str) - if earliest_time is None: - _LOGGER.warning( - "Sunrise adjustment: cannot parse earliest time '%s'", earliest_str - ) - return - + def _get_next_sun_time(self, attr_name: str) -> dt_time | None: + """Return the local time-of-day for a sun.sun 'next_*' attribute, or None.""" sun_state = self._hass.states.get("sun.sun") if sun_state is None: - _LOGGER.warning( - "Sunrise adjustment: sun.sun entity not found, skipping" - ) - return + return None - next_rising = sun_state.attributes.get("next_rising") - if not next_rising: - _LOGGER.warning( - "Sunrise adjustment: sun.sun has no next_rising attribute" - ) - return + raw = sun_state.attributes.get(attr_name) + if not raw: + return None try: - next_rising_dt = datetime.fromisoformat(str(next_rising)) - sunrise_local = dt_util.as_local(next_rising_dt) - sunrise_str = sunrise_local.strftime("%H:%M") - sunrise_time = _parse_time_str(sunrise_str) - except (ValueError, TypeError) as err: - _LOGGER.warning("Sunrise adjustment: cannot parse next_rising: %s", err) - return + local_dt = dt_util.as_local(datetime.fromisoformat(str(raw))) + except (ValueError, TypeError): + return None - if sunrise_time is None: + return _parse_time_str(local_dt.strftime("%H:%M")) + + async def async_compute_daily_schedule(self, now: datetime, day_mode_key: str | None) -> None: + """Compute today's open/close times for the native daily cover schedule. + + The open time is resolved from CONF_DAILY_COVER_OPEN_TIME_MAP for + today's day_mode_key: 'sunrise' (floored at CONF_SUNRISE_EARLIEST), + 'skip' (no automatic opening today — sets cover_open_time to None), + or a fixed 'HH:MM' time. A mode missing from the map falls back to + DEFAULT_DAILY_COVER_OPEN_TIME. + Close time is always today's sunset plus CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES + — unlike opening, closing is not mode-dependent. + Called once when a new calendar day is detected. Computed once per + day — a day-mode change later that same day does not recompute the + open time. + """ + if not self._config.get(CONF_DAILY_COVER_ENTITIES, []): return - # Use whichever is later: sunrise or the configured earliest - earliest_hhmm = earliest_time.strftime("%H:%M") - target_time = sunrise_str if sunrise_time > earliest_time else earliest_hhmm - self.cover_open_time = target_time - - for entity_id in sunrise_schedulers: - state = self._hass.states.get(entity_id) - if state is None: + open_time_map = _parse_mode_map(self._config.get(CONF_DAILY_COVER_OPEN_TIME_MAP, "")) + raw_value = open_time_map.get(day_mode_key or "", DEFAULT_DAILY_COVER_OPEN_TIME).strip().lower() + + if raw_value == "skip": + self.cover_open_time = None + elif raw_value == "sunrise": + sunrise_time = self._get_next_sun_time("next_rising") + earliest_time = _parse_time_str(self._config.get(CONF_SUNRISE_EARLIEST, DEFAULT_SUNRISE_EARLIEST)) + if sunrise_time is not None and earliest_time is not None: + target = sunrise_time if sunrise_time > earliest_time else earliest_time + self.cover_open_time = target.strftime("%H:%M") + else: + _LOGGER.warning("Daily cover schedule: could not determine sunrise/earliest open time") + self.cover_open_time = None + else: + open_time = _parse_time_str(raw_value) + if open_time is not None: + self.cover_open_time = open_time.strftime("%H:%M") + else: _LOGGER.warning( - "Sunrise adjustment: scheduler entity '%s' not found", entity_id + "Daily cover schedule: could not parse open time '%s' for mode '%s'", raw_value, day_mode_key ) - continue + self.cover_open_time = None - actions: list = list(state.attributes.get("actions") or []) - entities: list = list(state.attributes.get("entities") or []) + sunset_time = self._get_next_sun_time("next_setting") + if sunset_time is not None: + offset = int(self._config.get(CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, DEFAULT_DAILY_COVER_CLOSE_OFFSET_MINUTES)) + close_dt = datetime.combine(now.date(), sunset_time) + timedelta(minutes=offset) + self.daily_close_time = close_dt.strftime("%H:%M") + else: + _LOGGER.warning("Daily cover schedule: could not determine sunset time") + self.daily_close_time = None - if not actions: - _LOGGER.warning( - "Sunrise adjustment: scheduler '%s' has no actions, skipping", - entity_id, - ) - continue - - # Merge the cover entity_id into the action dict (mirrors the original - # automation: dict(current_action, **{'entity_id': current_entity})) - current_action: dict = dict(actions[0]) - if entities: - current_action["entity_id"] = entities[0] - - _LOGGER.info( - "Sunrise adjustment: '%s' timeslot start=%s " - "(sunrise=%s, earliest=%s)", - entity_id, - target_time, - sunrise_str, - earliest_hhmm, - ) + _LOGGER.info( + "Daily cover schedule: open_time=%s, close_time=%s (day_mode=%s)", + self.cover_open_time, + self.daily_close_time, + day_mode_key, + ) - await self._hass.services.async_call( - "scheduler", - "edit", - { - "entity_id": entity_id, - "timeslots": [{"start": target_time, "actions": [current_action]}], - }, - blocking=False, - ) + async def async_check_daily_schedule(self, now: datetime) -> None: + """Open covers at today's computed open time, close them at the computed close time. + + Targets CONF_DAILY_COVER_ENTITIES (typically a cover group) — a + separate entity list from CONF_COVER_ENTITIES used by heat protection, + so a single unitary cover can stay under heat-protection's control + while the group follows the daily open/close schedule. + Each action fires at most once per calendar day. cover_open_time is + None when today's mode resolved to 'skip' (see + async_compute_daily_schedule), which naturally skips the open below. + Closing is unconditional — it always happens once per day regardless + of mode. + """ + daily_entities = self._config.get(CONF_DAILY_COVER_ENTITIES, []) + if not daily_entities: + return + + today = now.date() + + if self._daily_opened_date != today and self.cover_open_time: + open_time = _parse_time_str(self.cover_open_time) + if open_time is not None and now.time() >= open_time: + _LOGGER.info( + "Daily cover schedule: opening covers %s (open_time=%s)", + daily_entities, + self.cover_open_time, + ) + await self._hass.services.async_call( + "cover", "open_cover", {"entity_id": daily_entities}, blocking=False + ) + self._daily_opened_date = today + await self._async_save_state() + + if self._daily_closed_date != today and self.daily_close_time: + close_time = _parse_time_str(self.daily_close_time) + if close_time is not None and now.time() >= close_time: + _LOGGER.info( + "Daily cover schedule: closing covers %s (close_time=%s)", + daily_entities, + self.daily_close_time, + ) + await self._hass.services.async_call( + "cover", "close_cover", {"entity_id": daily_entities}, blocking=False + ) + self._daily_closed_date = today + await self._async_save_state() diff --git a/custom_components/homeshift/sensor.py b/custom_components/homeshift/sensor.py index 8b04fbc..be37ea5 100644 --- a/custom_components/homeshift/sensor.py +++ b/custom_components/homeshift/sensor.py @@ -10,7 +10,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_SUNRISE_SCHEDULERS, DOMAIN, SENSOR_COVER_OPEN_TIME, SENSOR_NEXT_MODE, SENSOR_NEXT_MODE_AT +from .const import ( + CONF_DAILY_COVER_ENTITIES, + DOMAIN, + SENSOR_COVER_CLOSE_TIME, + SENSOR_COVER_OPEN_TIME, + SENSOR_NEXT_MODE, + SENSOR_NEXT_MODE_AT, +) from .coordinator import HomeShiftCoordinator _LOGGER = logging.getLogger(__name__) @@ -28,8 +35,10 @@ async def async_setup_entry( HomeShiftNextModeAtSensor(coordinator, entry), ] config = {**entry.data, **entry.options} - if config.get(CONF_SUNRISE_SCHEDULERS): + if config.get(CONF_DAILY_COVER_ENTITIES): entities.append(HomeShiftCoverOpenTimeSensor(coordinator, entry)) + if config.get(CONF_DAILY_COVER_ENTITIES): + entities.append(HomeShiftCoverCloseTimeSensor(coordinator, entry)) async_add_entities(entities) @@ -95,8 +104,8 @@ def device_info(self) -> dict: class HomeShiftCoverOpenTimeSensor(CoordinatorEntity[HomeShiftCoordinator], SensorEntity): """String sensor: the scheduled cover opening time for today. - Only registered when CONF_SUNRISE_SCHEDULERS is configured. - Updated each morning when async_adjust_sunrise_schedulers() runs. + Only registered when CONF_DAILY_COVER_ENTITIES is configured. + Updated each morning when async_compute_daily_schedule() runs. """ _attr_has_entity_name = True @@ -118,3 +127,32 @@ def native_value(self) -> str | None: def device_info(self) -> dict: """Return device information.""" return _device_info(self._entry) + + +class HomeShiftCoverCloseTimeSensor(CoordinatorEntity[HomeShiftCoordinator], SensorEntity): + """String sensor: the scheduled daily cover closing time for today. + + Only registered when CONF_DAILY_COVER_ENTITIES is configured. + Updated each morning when async_compute_daily_schedule() runs + (today's sunset + CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES). + """ + + _attr_has_entity_name = True + _attr_name = "Cover Close Time" + _attr_icon = "mdi:roller-shade-closed" + + def __init__(self, coordinator: HomeShiftCoordinator, entry: ConfigEntry) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{entry.entry_id}_{SENSOR_COVER_CLOSE_TIME}" + self._entry = entry + + @property + def native_value(self) -> str | None: + """Return today's computed cover closing time (HH:MM).""" + return self.coordinator.cover_close_time + + @property + def device_info(self) -> dict: + """Return device information.""" + return _device_info(self._entry) diff --git a/custom_components/homeshift/translations/en.json b/custom_components/homeshift/translations/en.json index acee24c..1a47dc0 100644 --- a/custom_components/homeshift/translations/en.json +++ b/custom_components/homeshift/translations/en.json @@ -9,7 +9,7 @@ "mapping": "Mode Mapping", "schedulers": "Schedulers", "covers": "Cover Heat Control", - "sunrise_schedulers": "Sunrise Scheduler Adjustment", + "daily_cover_schedule": "Daily Cover Schedule", "finalize": "Save Configuration" } }, @@ -99,16 +99,26 @@ "cover_evening_reopen_temp": "Once covers have been closed by this automation, they reopen automatically after Active Window End as soon as the outdoor sensor drops to or below this temperature." } }, - "sunrise_schedulers": { - "title": "Sunrise Scheduler Adjustment", - "description": "Adjust the opening time of scheduler switches daily based on sunrise, respecting a configurable earliest time.", + "daily_cover_schedule": { + "title": "Daily Cover Schedule", + "description": "Natively open and close a cover group every day, without a separate Scheduler-integration entity. Kept separate from Cover Heat Control, which targets a single cover. For each day mode below, set 'sunrise', 'skip', or a custom 'HH:MM' opening time — modes sharing the same value form a batch. Closing always happens once per day, for every mode, at sunset + the offset below.", "data": { - "sunrise_schedulers": "Scheduler Entities to Adjust", - "sunrise_earliest": "Earliest Allowed Opening Time" + "daily_cover_entities": "Daily Cover Entities", + "sunrise_earliest": "Earliest Allowed Opening Time", + "daily_cover_close_offset_minutes": "Close Offset After Sunset (minutes)", + "daily_open_time_home": "Open Time — Home", + "daily_open_time_work": "Open Time — Work", + "daily_open_time_remote": "Open Time — Remote", + "daily_open_time_away": "Open Time — Away" }, "data_description": { - "sunrise_schedulers": "Select the scheduler switch entities whose first timeslot will be adjusted at each new day.", - "sunrise_earliest": "The opening time will be the later of sunrise and this value." + "daily_cover_entities": "Cover entities (e.g. a cover group) to open and close on the daily schedule below.", + "sunrise_earliest": "The sunrise-based opening time will be the later of actual sunrise and this value.", + "daily_cover_close_offset_minutes": "Covers close this many minutes after sunset, every day, regardless of day mode.", + "daily_open_time_home": "Opening time for the Home day mode: 'sunrise', 'skip', or a custom 'HH:MM' value.", + "daily_open_time_work": "Opening time for the Work day mode: 'sunrise', 'skip', or a custom 'HH:MM' value.", + "daily_open_time_remote": "Opening time for the Remote day mode: 'sunrise', 'skip', or a custom 'HH:MM' value.", + "daily_open_time_away": "Opening time for the Away day mode: 'sunrise', 'skip', or a custom 'HH:MM' value." } } }, @@ -129,7 +139,7 @@ "mapping": "Mode Mapping", "schedulers": "Schedulers", "covers": "Cover Heat Control", - "sunrise_schedulers": "Sunrise Scheduler Adjustment", + "daily_cover_schedule": "Daily Cover Schedule", "finalize": "Save Configuration" } }, @@ -225,16 +235,26 @@ "cover_evening_reopen_temp": "Once covers have been closed by this automation, they reopen automatically after Active Window End as soon as the outdoor sensor drops to or below this temperature." } }, - "sunrise_schedulers": { - "title": "Sunrise Scheduler Adjustment", - "description": "Adjust the opening time of scheduler switches daily based on sunrise, respecting a configurable earliest time.", + "daily_cover_schedule": { + "title": "Daily Cover Schedule", + "description": "Natively open and close a cover group every day, without a separate Scheduler-integration entity. Kept separate from Cover Heat Control, which targets a single cover. For each day mode below, set 'sunrise', 'skip', or a custom 'HH:MM' opening time — modes sharing the same value form a batch. Closing always happens once per day, for every mode, at sunset + the offset below.", "data": { - "sunrise_schedulers": "Scheduler Entities to Adjust", - "sunrise_earliest": "Earliest Allowed Opening Time" + "daily_cover_entities": "Daily Cover Entities", + "sunrise_earliest": "Earliest Allowed Opening Time", + "daily_cover_close_offset_minutes": "Close Offset After Sunset (minutes)", + "daily_open_time_home": "Open Time — Home", + "daily_open_time_work": "Open Time — Work", + "daily_open_time_remote": "Open Time — Remote", + "daily_open_time_away": "Open Time — Away" }, "data_description": { - "sunrise_schedulers": "Select the scheduler switch entities whose first timeslot will be adjusted at each new day.", - "sunrise_earliest": "The opening time will be the later of sunrise and this value." + "daily_cover_entities": "Cover entities (e.g. a cover group) to open and close on the daily schedule below.", + "sunrise_earliest": "The sunrise-based opening time will be the later of actual sunrise and this value.", + "daily_cover_close_offset_minutes": "Covers close this many minutes after sunset, every day, regardless of day mode.", + "daily_open_time_home": "Opening time for the Home day mode: 'sunrise', 'skip', or a custom 'HH:MM' value.", + "daily_open_time_work": "Opening time for the Work day mode: 'sunrise', 'skip', or a custom 'HH:MM' value.", + "daily_open_time_remote": "Opening time for the Remote day mode: 'sunrise', 'skip', or a custom 'HH:MM' value.", + "daily_open_time_away": "Opening time for the Away day mode: 'sunrise', 'skip', or a custom 'HH:MM' value." } } } @@ -262,6 +282,9 @@ }, "next_mode_at": { "name": "Next Mode At" + }, + "cover_close_time": { + "name": "Cover Close Time" } }, "binary_sensor": { diff --git a/custom_components/homeshift/translations/fr.json b/custom_components/homeshift/translations/fr.json index c303258..686cbd2 100644 --- a/custom_components/homeshift/translations/fr.json +++ b/custom_components/homeshift/translations/fr.json @@ -9,7 +9,7 @@ "mapping": "Mapping des modes", "schedulers": "Schedulers", "covers": "Contrôle des volets (chaleur)", - "sunrise_schedulers": "Ajustement des schedulers au lever du soleil", + "daily_cover_schedule": "Planning quotidien des volets", "finalize": "Enregistrer la configuration" } }, @@ -99,16 +99,26 @@ "cover_evening_reopen_temp": "Une fois les volets fermés par cette automatisation, ils se rouvrent automatiquement après la fin de la plage horaire dès que le capteur extérieur redescend à cette température ou en dessous." } }, - "sunrise_schedulers": { - "title": "Ajustement des schedulers au lever du soleil", - "description": "Ajuster quotidiennement l'heure d'ouverture des schedulers en fonction du lever du soleil, en respectant une heure minimum configurable.", + "daily_cover_schedule": { + "title": "Planning quotidien des volets", + "description": "Ouvre et ferme un groupe de volets tous les jours nativement, sans passer par une entité Scheduler séparée. Reste indépendant du Contrôle des volets (chaleur), qui cible un volet unitaire. Pour chaque mode de journée ci-dessous, définissez 'sunrise', 'skip', ou une heure personnalisée 'HH:MM' — les modes partageant la même valeur forment un lot. La fermeture a toujours lieu une fois par jour, pour tous les modes, au coucher du soleil + le décalage ci-dessous.", "data": { - "sunrise_schedulers": "Schedulers à ajuster", - "sunrise_earliest": "Heure d'ouverture minimale" + "daily_cover_entities": "Entités volets (planning quotidien)", + "sunrise_earliest": "Heure d'ouverture minimale", + "daily_cover_close_offset_minutes": "Décalage de fermeture après le coucher du soleil (minutes)", + "daily_open_time_home": "Heure d'ouverture — Maison", + "daily_open_time_work": "Heure d'ouverture — Travail", + "daily_open_time_remote": "Heure d'ouverture — Télétravail", + "daily_open_time_away": "Heure d'ouverture — Absence" }, "data_description": { - "sunrise_schedulers": "Sélectionnez les schedulers dont le premier créneau sera ajusté chaque jour.", - "sunrise_earliest": "L'heure d'ouverture sera la plus tardive entre le lever du soleil et cette valeur." + "daily_cover_entities": "Entités volets (par exemple un groupe) à ouvrir et fermer selon le planning ci-dessous.", + "sunrise_earliest": "L'heure d'ouverture au lever du soleil sera la plus tardive entre le lever réel et cette valeur.", + "daily_cover_close_offset_minutes": "Les volets se ferment ce nombre de minutes après le coucher du soleil, tous les jours, quel que soit le mode.", + "daily_open_time_home": "Heure d'ouverture pour le mode Maison : 'sunrise', 'skip', ou une valeur personnalisée 'HH:MM'.", + "daily_open_time_work": "Heure d'ouverture pour le mode Travail : 'sunrise', 'skip', ou une valeur personnalisée 'HH:MM'.", + "daily_open_time_remote": "Heure d'ouverture pour le mode Télétravail : 'sunrise', 'skip', ou une valeur personnalisée 'HH:MM'.", + "daily_open_time_away": "Heure d'ouverture pour le mode Absence : 'sunrise', 'skip', ou une valeur personnalisée 'HH:MM'." } } }, @@ -129,7 +139,7 @@ "mapping": "Mapping des modes", "schedulers": "Schedulers", "covers": "Contrôle des volets (chaleur)", - "sunrise_schedulers": "Ajustement des schedulers au lever du soleil", + "daily_cover_schedule": "Planning quotidien des volets", "finalize": "Enregistrer la configuration" } }, @@ -225,16 +235,26 @@ "cover_evening_reopen_temp": "Une fois les volets fermés par cette automatisation, ils se rouvrent automatiquement après la fin de la plage horaire dès que le capteur extérieur redescend à cette température ou en dessous." } }, - "sunrise_schedulers": { - "title": "Ajustement des schedulers au lever du soleil", - "description": "Ajuster quotidiennement l'heure d'ouverture des schedulers en fonction du lever du soleil, en respectant une heure minimum configurable.", + "daily_cover_schedule": { + "title": "Planning quotidien des volets", + "description": "Ouvre et ferme un groupe de volets tous les jours nativement, sans passer par une entité Scheduler séparée. Reste indépendant du Contrôle des volets (chaleur), qui cible un volet unitaire. Pour chaque mode de journée ci-dessous, définissez 'sunrise', 'skip', ou une heure personnalisée 'HH:MM' — les modes partageant la même valeur forment un lot. La fermeture a toujours lieu une fois par jour, pour tous les modes, au coucher du soleil + le décalage ci-dessous.", "data": { - "sunrise_schedulers": "Schedulers à ajuster", - "sunrise_earliest": "Heure d'ouverture minimale" + "daily_cover_entities": "Entités volets (planning quotidien)", + "sunrise_earliest": "Heure d'ouverture minimale", + "daily_cover_close_offset_minutes": "Décalage de fermeture après le coucher du soleil (minutes)", + "daily_open_time_home": "Heure d'ouverture — Maison", + "daily_open_time_work": "Heure d'ouverture — Travail", + "daily_open_time_remote": "Heure d'ouverture — Télétravail", + "daily_open_time_away": "Heure d'ouverture — Absence" }, "data_description": { - "sunrise_schedulers": "Sélectionnez les schedulers dont le premier créneau sera ajusté chaque jour.", - "sunrise_earliest": "L'heure d'ouverture sera la plus tardive entre le lever du soleil et cette valeur." + "daily_cover_entities": "Entités volets (par exemple un groupe) à ouvrir et fermer selon le planning ci-dessous.", + "sunrise_earliest": "L'heure d'ouverture au lever du soleil sera la plus tardive entre le lever réel et cette valeur.", + "daily_cover_close_offset_minutes": "Les volets se ferment ce nombre de minutes après le coucher du soleil, tous les jours, quel que soit le mode.", + "daily_open_time_home": "Heure d'ouverture pour le mode Maison : 'sunrise', 'skip', ou une valeur personnalisée 'HH:MM'.", + "daily_open_time_work": "Heure d'ouverture pour le mode Travail : 'sunrise', 'skip', ou une valeur personnalisée 'HH:MM'.", + "daily_open_time_remote": "Heure d'ouverture pour le mode Télétravail : 'sunrise', 'skip', ou une valeur personnalisée 'HH:MM'.", + "daily_open_time_away": "Heure d'ouverture pour le mode Absence : 'sunrise', 'skip', ou une valeur personnalisée 'HH:MM'." } } } @@ -262,6 +282,9 @@ }, "next_mode_at": { "name": "Prochain changement à" + }, + "cover_close_time": { + "name": "Heure de fermeture des volets" } }, "binary_sensor": { diff --git a/tests/test_coordinator_features.py b/tests/test_coordinator_features.py index 31f0754..c00f31f 100644 --- a/tests/test_coordinator_features.py +++ b/tests/test_coordinator_features.py @@ -1362,6 +1362,20 @@ def test_restore_state_sets_all_three_dates(self): assert coordinator._cover_manager._closed_date == date(2026, 7, 1) assert coordinator._cover_manager._reopened_date is None + def test_restore_state_sets_daily_schedule_dates(self): + """async_restore_state() also restores the daily-schedule action dates.""" + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + mock_store = self._make_store( + {"daily_opened_date": "2026-07-01", "daily_closed_date": "2026-06-30"} + ) + coordinator._cover_manager._store = mock_store + + asyncio.get_event_loop().run_until_complete(coordinator._cover_manager.async_restore_state()) + + assert coordinator._cover_manager._daily_opened_date == date(2026, 7, 1) + assert coordinator._cover_manager._daily_closed_date == date(2026, 6, 30) + def test_restore_state_no_stored_data_leaves_defaults(self): """async_restore_state() leaves all dates None when storage is empty.""" hass = make_mock_hass() @@ -1526,169 +1540,242 @@ def test_exactly_at_threshold_returns_false(self): # --------------------------------------------------------------------------- -# Sunrise scheduler adjustment +# Daily cover schedule — compute (open/close time) # --------------------------------------------------------------------------- -class TestSunriseSchedulerAdjustment: - """Verify async_adjust_sunrise_schedulers edits scheduler timeslots.""" +class TestDailyCoverScheduleCompute: + """Verify async_compute_daily_schedule() sets cover_open_time / daily_close_time.""" - def _make_entry(self, schedulers=None, earliest="07:10:00"): + def _make_entry(self, entities=None, open_time_map="", earliest="07:10:00", close_offset=10): from custom_components.homeshift.const import ( - CONF_SUNRISE_SCHEDULERS, CONF_SUNRISE_EARLIEST, + CONF_DAILY_COVER_ENTITIES, + CONF_DAILY_COVER_OPEN_TIME_MAP, + CONF_SUNRISE_EARLIEST, + CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, ) entry = make_mock_entry() entry.options = { - CONF_SUNRISE_SCHEDULERS: schedulers or ["switch.schedule_volets"], + CONF_DAILY_COVER_ENTITIES: entities if entities is not None else ["cover.volets"], + CONF_DAILY_COVER_OPEN_TIME_MAP: open_time_map, CONF_SUNRISE_EARLIEST: earliest, + CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES: close_offset, } return entry - def _sun_state(self, next_rising_iso: str): - state = MagicMock() - state.attributes = {"next_rising": next_rising_iso} - return state - - def _scheduler_state(self, actions=None, entities=None): + def _sun_state(self, next_rising_iso: str | None = None, next_setting_iso: str | None = None): state = MagicMock() - state.attributes = { - "actions": actions or [{"service": "cover.open_cover"}], - "entities": entities or ["cover.volet_salon"], - } + state.attributes = {} + if next_rising_iso is not None: + state.attributes["next_rising"] = next_rising_iso + if next_setting_iso is not None: + state.attributes["next_setting"] = next_setting_iso return state - def test_scheduler_edit_called_with_sunrise_when_after_earliest(self): - """scheduler.edit is called with sunrise time when sunrise > earliest.""" - hass = make_mock_hass() - hass.services.async_call = AsyncMock() - - entry = self._make_entry(earliest="07:10:00") - - def _get_state(entity_id): - if entity_id == "sun.sun": - # sunrise at 07:45 local - return self._sun_state("2026-07-01T05:45:00+00:00") - if entity_id == "switch.schedule_volets": - return self._scheduler_state() - return None - - hass.states.get.side_effect = _get_state - + def _run_compute(self, hass, entry, now, day_mode_key): coordinator = HomeShiftCoordinator(hass, entry) - with patch("custom_components.homeshift.cover_manager.dt_util") as mock_dt: from datetime import timezone, timedelta as tdelta tz_paris = timezone(tdelta(hours=2)) mock_dt.as_local.side_effect = lambda dt: dt.astimezone(tz_paris) asyncio.get_event_loop().run_until_complete( - coordinator._cover_manager.async_adjust_sunrise_schedulers() + coordinator._cover_manager.async_compute_daily_schedule(now, day_mode_key) ) + return coordinator - hass.services.async_call.assert_called_once() - call = hass.services.async_call.call_args - assert call.args[0] == "scheduler" - assert call.args[1] == "edit" - timeslots = call.args[2]["timeslots"] - assert len(timeslots) == 1 - # sunrise 07:45 > earliest 07:10 → use sunrise - assert timeslots[0]["start"] == "07:45" + def test_fixed_open_time_for_mode_with_custom_time(self): + """Uses the mode's custom HH:MM value from the open-time map.""" + hass = make_mock_hass() + hass.states.get.side_effect = lambda eid: self._sun_state(next_setting_iso="2026-07-01T19:30:00+00:00") if eid == "sun.sun" else None + entry = self._make_entry(open_time_map="work:sunrise, home:08:30") + + coordinator = self._run_compute(hass, entry, datetime(2026, 7, 1, 0, 5, 0), day_mode_key="home") - def test_scheduler_edit_called_with_earliest_when_sunrise_before(self): - """scheduler.edit is called with earliest time when sunrise < earliest.""" + assert coordinator._cover_manager.cover_open_time == "08:30" + + def test_mode_missing_from_map_falls_back_to_default(self): + """A day mode absent from the map falls back to DEFAULT_DAILY_COVER_OPEN_TIME (matches 'home' behavior).""" hass = make_mock_hass() - hass.services.async_call = AsyncMock() + hass.states.get.side_effect = lambda eid: self._sun_state(next_setting_iso="2026-07-01T19:30:00+00:00") if eid == "sun.sun" else None + entry = self._make_entry(open_time_map="work:sunrise") - entry = self._make_entry(earliest="07:10:00") + coordinator = self._run_compute(hass, entry, datetime(2026, 7, 1, 0, 5, 0), day_mode_key="away") - def _get_state(entity_id): - if entity_id == "sun.sun": - # sunrise at 06:30 local (before earliest 07:10) - return self._sun_state("2026-03-01T05:30:00+00:00") - if entity_id == "switch.schedule_volets": - return self._scheduler_state() - return None + assert coordinator._cover_manager.cover_open_time == "08:30" - hass.states.get.side_effect = _get_state + def test_sunrise_based_open_time_when_mode_set_to_sunrise(self): + """Uses sunrise (floored at earliest) when the mode's map value is 'sunrise'.""" + hass = make_mock_hass() + # sunrise at 07:45 local (05:45 UTC + 2h) + hass.states.get.side_effect = lambda eid: self._sun_state(next_rising_iso="2026-07-01T05:45:00+00:00") if eid == "sun.sun" else None + entry = self._make_entry(open_time_map="work:sunrise", earliest="07:10:00") + + coordinator = self._run_compute(hass, entry, datetime(2026, 7, 1, 0, 5, 0), day_mode_key="work") + + assert coordinator._cover_manager.cover_open_time == "07:45" + + def test_sunrise_floored_at_earliest_when_sunrise_too_early(self): + """Uses earliest when sunrise is before it (winter case).""" + hass = make_mock_hass() + # sunrise at 05:30 local (03:30 UTC + 2h) — earlier than the 07:10 floor + hass.states.get.side_effect = lambda eid: self._sun_state(next_rising_iso="2026-07-01T03:30:00+00:00") if eid == "sun.sun" else None + entry = self._make_entry(open_time_map="work:sunrise", earliest="07:10:00") + + coordinator = self._run_compute(hass, entry, datetime(2026, 7, 1, 0, 5, 0), day_mode_key="work") + + assert coordinator._cover_manager.cover_open_time == "07:10" + + def test_skip_value_sets_open_time_to_none(self): + """A mode set to 'skip' results in cover_open_time = None (no automatic opening).""" + hass = make_mock_hass() + hass.states.get.side_effect = lambda eid: self._sun_state(next_setting_iso="2026-07-01T19:30:00+00:00") if eid == "sun.sun" else None + entry = self._make_entry(open_time_map="away:skip, home:08:30") + + coordinator = self._run_compute(hass, entry, datetime(2026, 7, 1, 0, 5, 0), day_mode_key="away") + + assert coordinator._cover_manager.cover_open_time is None + + def test_close_time_is_sunset_plus_offset(self): + """daily_close_time is today's sunset plus the configured offset, regardless of day mode.""" + hass = make_mock_hass() + # sunset at 21:30 local (19:30 UTC + 2h) + hass.states.get.side_effect = lambda eid: self._sun_state(next_setting_iso="2026-07-01T19:30:00+00:00") if eid == "sun.sun" else None + entry = self._make_entry(open_time_map="away:skip", close_offset=10) + + coordinator = self._run_compute(hass, entry, datetime(2026, 7, 1, 0, 5, 0), day_mode_key="away") + + assert coordinator._cover_manager.daily_close_time == "21:40" + + def test_noop_when_no_daily_cover_entities_configured(self): + """Does nothing when CONF_DAILY_COVER_ENTITIES is empty.""" + hass = make_mock_hass() + hass.states.get.side_effect = lambda eid: self._sun_state(next_setting_iso="2026-07-01T19:30:00+00:00") if eid == "sun.sun" else None + entry = self._make_entry(entities=[]) + + coordinator = self._run_compute(hass, entry, datetime(2026, 7, 1, 0, 5, 0), day_mode_key="home") + + assert coordinator._cover_manager.cover_open_time is None + assert coordinator._cover_manager.daily_close_time is None + + +# --------------------------------------------------------------------------- +# Daily cover schedule — check (open/close actions) +# --------------------------------------------------------------------------- + +class TestDailyCoverScheduleCheck: + """Verify async_check_daily_schedule() opens/closes covers at the computed times.""" + + def _make_entry(self, entities=None): + from custom_components.homeshift.const import CONF_DAILY_COVER_ENTITIES + entry = make_mock_entry() + entry.options = { + CONF_DAILY_COVER_ENTITIES: entities if entities is not None else ["cover.volets"], + } + return entry + def _make_coordinator(self, hass, entry, open_time=None, close_time=None): coordinator = HomeShiftCoordinator(hass, entry) + coordinator._cover_manager.cover_open_time = open_time + coordinator._cover_manager.daily_close_time = close_time + return coordinator - with patch("custom_components.homeshift.cover_manager.dt_util") as mock_dt: - from datetime import timezone, timedelta as tdelta - tz_paris = timezone(tdelta(hours=1)) - mock_dt.as_local.side_effect = lambda dt: dt.astimezone(tz_paris) - asyncio.get_event_loop().run_until_complete( - coordinator._cover_manager.async_adjust_sunrise_schedulers() - ) + def test_opens_covers_at_open_time(self): + """cover.open_cover is called once now.time() reaches the computed open time.""" + hass = make_mock_hass() + hass.services.async_call = AsyncMock() + hass.states.get.return_value = make_calendar_state(state="off") + entry = self._make_entry() + coordinator = self._make_coordinator(hass, entry, open_time="08:30", close_time="21:40") + + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_daily_schedule(datetime(2026, 7, 1, 8, 30, 0)) + ) hass.services.async_call.assert_called_once() call = hass.services.async_call.call_args - timeslots = call.args[2]["timeslots"] - # sunrise 06:30 < earliest 07:10 → use earliest - assert timeslots[0]["start"] == "07:10" + assert call.args[0] == "cover" + assert call.args[1] == "open_cover" + assert "cover.volets" in call.args[2]["entity_id"] - def test_no_call_when_no_sunrise_schedulers_configured(self): - """scheduler.edit is NOT called when no sunrise schedulers are configured.""" + def test_closes_covers_at_close_time(self): + """cover.close_cover is called once now.time() reaches the computed close time.""" hass = make_mock_hass() hass.services.async_call = AsyncMock() - entry = make_mock_entry() # no sunrise_schedulers in config - hass.states.get.return_value = make_calendar_state(state="off") - coordinator = HomeShiftCoordinator(hass, entry) + entry = self._make_entry() + coordinator = self._make_coordinator(hass, entry, open_time="08:30", close_time="21:40") + # Already opened today, so only the close action is under test here. + coordinator._cover_manager._daily_opened_date = date(2026, 7, 1) asyncio.get_event_loop().run_until_complete( - coordinator._cover_manager.async_adjust_sunrise_schedulers() + coordinator._cover_manager.async_check_daily_schedule(datetime(2026, 7, 1, 21, 40, 0)) ) - hass.services.async_call.assert_not_called() + hass.services.async_call.assert_called_once() + call = hass.services.async_call.call_args + assert call.args[0] == "cover" + assert call.args[1] == "close_cover" + assert "cover.volets" in call.args[2]["entity_id"] - def test_no_call_when_sun_entity_missing(self): - """scheduler.edit is NOT called when sun.sun entity is unavailable.""" + def test_no_action_before_open_time(self): + """No call yet when now is before the computed open time.""" hass = make_mock_hass() hass.services.async_call = AsyncMock() + hass.states.get.return_value = make_calendar_state(state="off") entry = self._make_entry() - - hass.states.get.return_value = None - - coordinator = HomeShiftCoordinator(hass, entry) + coordinator = self._make_coordinator(hass, entry, open_time="08:30", close_time="21:40") asyncio.get_event_loop().run_until_complete( - coordinator._cover_manager.async_adjust_sunrise_schedulers() + coordinator._cover_manager.async_check_daily_schedule(datetime(2026, 7, 1, 7, 0, 0)) ) hass.services.async_call.assert_not_called() - def test_action_gets_entity_id_merged_in(self): - """The entity_id from scheduler.entities[0] is merged into the action dict.""" + def test_no_open_call_but_close_still_fires_when_open_time_is_none(self): + """cover_open_time=None (mode resolved to 'skip') skips the open, but close is unconditional.""" hass = make_mock_hass() hass.services.async_call = AsyncMock() + hass.states.get.return_value = make_calendar_state(state="off") + entry = self._make_entry() + coordinator = self._make_coordinator(hass, entry, open_time=None, close_time="21:40") + + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_daily_schedule(datetime(2026, 7, 1, 21, 40, 0)) + ) - entry = self._make_entry(earliest="06:00:00") + hass.services.async_call.assert_called_once() + call = hass.services.async_call.call_args + assert call.args[1] == "close_cover" - def _get_state(entity_id): - if entity_id == "sun.sun": - return self._sun_state("2026-07-01T04:30:00+00:00") - if entity_id == "switch.schedule_volets": - return self._scheduler_state( - actions=[{"service": "cover.open_cover", "data": {"position": 100}}], - entities=["cover.volet_salon"], - ) - return None + def test_noop_when_no_daily_cover_entities_configured(self): + """No call when CONF_DAILY_COVER_ENTITIES is empty.""" + hass = make_mock_hass() + hass.services.async_call = AsyncMock() + hass.states.get.return_value = make_calendar_state(state="off") + entry = self._make_entry(entities=[]) + coordinator = self._make_coordinator(hass, entry, open_time="08:30", close_time="21:40") - hass.states.get.side_effect = _get_state + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_daily_schedule(datetime(2026, 7, 1, 21, 40, 0)) + ) - coordinator = HomeShiftCoordinator(hass, entry) + hass.services.async_call.assert_not_called() - with patch("custom_components.homeshift.cover_manager.dt_util") as mock_dt: - from datetime import timezone, timedelta as tdelta - tz_paris = timezone(tdelta(hours=2)) - mock_dt.as_local.side_effect = lambda dt: dt.astimezone(tz_paris) - asyncio.get_event_loop().run_until_complete( - coordinator._cover_manager.async_adjust_sunrise_schedulers() - ) + def test_open_and_close_each_fire_once_per_day(self): + """A second check later the same day does not repeat either action.""" + hass = make_mock_hass() + hass.services.async_call = AsyncMock() + hass.states.get.return_value = make_calendar_state(state="off") + entry = self._make_entry() + coordinator = self._make_coordinator(hass, entry, open_time="08:30", close_time="21:40") - call = hass.services.async_call.call_args - action = call.args[2]["timeslots"][0]["actions"][0] - assert action.get("entity_id") == "cover.volet_salon" - assert action.get("service") == "cover.open_cover" + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_daily_schedule(datetime(2026, 7, 1, 21, 40, 0)) + ) + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_daily_schedule(datetime(2026, 7, 1, 22, 0, 0)) + ) + + assert hass.services.async_call.call_count == 2 # one open + one close, not repeated # --------------------------------------------------------------------------- From 629164ff9b4c961eea7816cf72f4fe2f37775a70 Mon Sep 17 00:00:00 2001 From: Gamso Date: Sat, 18 Jul 2026 17:48:27 +0200 Subject: [PATCH 3/5] feat: make cover heat protection close-only, share its window with Daily Cover Schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Heat protection no longer owns an independent active window (cover_time_start/end, cover_reopen_temp all removed): its window is now derived entirely from Daily Cover Schedule's computed cover_open_time/daily_close_time, so it stays inert until that feature is configured and has computed today's times - Once the cover closes for heat (proactively via forecast, or reactively via the reactive threshold), it stays closed for the rest of the day — no automatic reopening; only the next day's normal open, or the daily schedule's own unconditional sunset close, touches it again - _async_check_reactive_close/_async_check_proactive_close key off the shared window/open-time instead of their own config, and heat_closed (bool) replaces the old closed_date/reopened_date pair in both memory and the persisted Store state - async_compute_daily_schedule() resets heat_closed at the top of each new day - Update config_flow, README, and EN/FR translations to drop the now-removed fields and describe the close-only behavior - Rewrite tests/test_coordinator_features.py's cover suites for the new close-only, schedule-derived behavior (drop TestCoverReactiveReopen and the reopen-tracking assertions) - Add tests/test_config_flow.py (58 tests): schema field sets, day-mode/daily-open-time map (de)serialization, and every config/options flow step — closes the coverage gap left by config_flow.py having zero tests despite being the most heavily reshaped file across this branch's work (0% -> 99% line coverage) Co-Authored-By: Claude Sonnet 5 --- .github/workflows/tests.yml | 2 +- README.md | 35 +- custom_components/homeshift/binary_sensor.py | 5 +- custom_components/homeshift/config_flow.py | 25 - custom_components/homeshift/const.py | 15 +- custom_components/homeshift/cover_manager.py | 166 +++-- .../homeshift/translations/en.json | 28 +- .../homeshift/translations/fr.json | 28 +- tests/test_config_flow.py | 579 ++++++++++++++++++ tests/test_coordinator_features.py | 333 +++++----- 10 files changed, 827 insertions(+), 389 deletions(-) create mode 100644 tests/test_config_flow.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 13940bf..b1627a7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.11" + python-version: "3.13" - name: Install dependencies run: | diff --git a/README.md b/README.md index 0a3b3c8..2b6f1e3 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,6 @@ Automatic day-mode and thermostat-mode management for Home Assistant, driven by - [☀️ Cover Heat Protection](#️-cover-heat-protection) - [Reactive Close](#reactive-close) - [Proactive Forecast-Based Close](#proactive-forecast-based-close) - - [Automatic Evening Reopen](#automatic-evening-reopen) - [State Persistence](#state-persistence) - [🧩 Feature Support](#-feature-support) - [📄 License](#-license) @@ -191,17 +190,14 @@ All parameters can be changed at any time via **Settings → Devices & Services | **Holiday Mode** | `Home` | Mode used on public holidays | | **Event Mode Map** | `Vacation:home, Remote:remote` | Maps calendar event names to day modes | | **Away Mode** | `Away` | When this mode is active, automatic updates are paused | -| **Cover Entities** | — | Cover entities to close when it is too hot (optional) | +| **Cover Entities** | — | Cover entities to close when it is too hot (optional; requires Daily Cover Entities below to be configured too) | | **Temperature Sensor** | — | Sensor providing the outdoor temperature | | **Temperature Threshold** | `30 °C` | Temperature above which covers close reactively (fallback for days the forecast misses) | | **Cover Action** | `close_cover` | Service called when heat protection triggers: `close_cover` or `stop_cover` | | **My Position Button** | — | Button entity to press instead of a cover service (e.g. Somfy RTS "My" position) | -| **Heat Window Start** | `08:35` | Earliest time of day the heat protection is active; also when the forecast is checked for the proactive close | -| **Heat Window End** | `18:00` | Latest time of day the heat protection is active; also the earliest time the evening reopen can fire | | **Weather Entity** | — | Weather entity with daily forecasts, used for the proactive close (optional) | | **Forecast Threshold** | `28 °C` | Forecast daily high above which covers close proactively | -| **Evening Reopen Temperature** | `26 °C` | Once closed by this automation, covers reopen after Heat Window End when the sensor drops to/below this value | -| **Daily Cover Entities** | — | Cover entity/group opened and closed daily (optional, separate from Cover Entities above) | +| **Daily Cover Entities** | — | Cover entity/group opened and closed daily (optional, separate from Cover Entities above); Cover Heat Protection's active window is derived from this | | **Open Time — *(per day mode)*** | `08:30` | One field per day mode: `sunrise`, `skip`, or a custom `HH:MM` value | | **Earliest Open Time** | `07:00` | Floor time used when a day mode's Open Time is `sunrise` | | **Close Offset After Sunset** | `10 min` | Covers close this many minutes after sunset, every day, for every mode | @@ -279,7 +275,7 @@ When `thermostat_mode` is set to **Off**, HomeShift will force-disable all switc ## 🗓️ Daily Cover Schedule -HomeShift can natively open and close a cover (typically a cover group) every day, without depending on any Scheduler-integration entity. This is independent from Cover Heat Protection below (which targets a single, unitary cover) — **Daily Cover Entities** is its own entity list, so a whole-house cover group can follow the daily open/close schedule while a single south-facing cover stays under heat-protection's control. +HomeShift can natively open and close a cover (typically a cover group) every day, without depending on any Scheduler-integration entity. **Daily Cover Entities** is its own entity list, separate from Cover Heat Protection's **Cover Entities** — so a whole-house cover group can follow the daily open/close schedule below while a single south-facing cover stays under heat-protection's control. [Cover Heat Protection](#️-cover-heat-protection) derives its active window from the times computed here, so configure this feature first. Once per day, shortly after midnight, HomeShift computes: - **Open time** — resolved per day mode. Each configured day mode has its own **Open Time** field, set to one of: @@ -300,35 +296,27 @@ Then, on every coordinator poll, it opens the covers once now reaches the open t ## ☀️ Cover Heat Protection -HomeShift can automatically close covers to prevent heat build-up, and reopen them once it has cooled down — without requiring any separate automation. There are two ways a close can be triggered, and one way covers reopen automatically. +HomeShift can automatically close a cover to prevent heat build-up — without requiring any separate automation, and without a separately configured time window. Once closed by this automation, the cover stays closed for the rest of the day; it never reopens itself. It's touched again either by the next day's normal open, or by Daily Cover Schedule's own unconditional evening close. -### Reactive Close +**Cover Heat Protection now requires [Daily Cover Schedule](#️-daily-cover-schedule) to be configured.** Its active window isn't set independently — it's exactly `[cover_open_time, daily_close_time]`, the same times Daily Cover Schedule already computes every day. This also means: if it's already hot right when the cover would normally open for the day, heat protection can apply the closed/protected position immediately instead of opening it and closing it again moments later. -Each time the coordinator runs **and** whenever the temperature sensor value changes, HomeShift checks: -- Is the current time within the configured active window (**Heat Window Start/End**)? -- Is the outdoor temperature above **Temperature Threshold**? +### Reactive Close -If both conditions are met, the configured **Cover Action** is applied to all configured cover entities: `close_cover` (default), `stop_cover` (interrupts movement mid-travel — useful for Somfy RTS covers), or pressing the **My Position Button** if one is configured (sends the cover to its pre-recorded favourite position). +Each time the coordinator runs **and** whenever the temperature sensor value changes, HomeShift checks: if the cover hasn't already been closed by this automation today, and the current time is within today's `[cover_open_time, daily_close_time]` window, and the outdoor temperature exceeds **Temperature Threshold**, it applies the configured **Cover Action**: `close_cover` (default), `stop_cover` (interrupts movement mid-travel — useful for Somfy RTS covers), or presses the **My Position Button** if one is configured (sends the cover to its pre-recorded favourite position). -This check reacts to the outdoor sensor as it happens, so on a well-insulated room it can end up closing the cover only after the room has already absorbed a few hours of heat. +This fires at most once per day — once closed, HomeShift leaves the cover alone regardless of what the temperature does afterward. ### Proactive Forecast-Based Close -If a **Weather Entity** (with daily forecasts) is configured, HomeShift also checks — once per day, at **Heat Window Start** — whether today's forecast high exceeds **Forecast Threshold**. If it does, covers close immediately, ahead of the outdoor sensor actually crossing the reactive threshold. +If a **Weather Entity** (with daily forecasts) is configured, HomeShift also checks — once per day, at today's `cover_open_time` — whether today's forecast high exceeds **Forecast Threshold**. If it does, the cover closes immediately (or skips opening in the first place, if this runs before/at the same moment Daily Cover Schedule opens it), ahead of the outdoor sensor actually crossing the reactive threshold. The reactive close above still runs during the rest of the window as a fallback, in case the forecast lookup fails or under-predicts the day. Leave **Weather Entity** unset to disable the proactive close entirely; behavior then falls back to the reactive close only. -### Automatic Evening Reopen - -Once a cover has been closed by this automation (proactively or reactively) on a given day, HomeShift reopens it automatically — at most once per day — after **Heat Window End**, as soon as the outdoor sensor drops to or below **Evening Reopen Temperature**. - -The reopen only ever applies to a cover this automation itself closed that day; it never touches a cover closed manually or by an unrelated automation/scheduler. - ### State Persistence -Which covers were closed today, when the forecast was last checked, and whether the evening reopen already ran are all persisted to storage, so a Home Assistant restart mid-day doesn't lose track of the day's state (e.g. it won't skip the evening reopen just because HA restarted in the afternoon). The same storage also tracks whether today's Daily Cover Schedule open/close actions have already run, for the same reason. +Whether the cover has already been closed by this automation today, and when the forecast was last checked, are persisted to storage, so a Home Assistant restart mid-day doesn't lose track of the day's state. The same storage also tracks whether today's Daily Cover Schedule open/close actions have already run, for the same reason. --- @@ -343,9 +331,8 @@ Which covers were closed today, when the forecast was last checked, and whether | Manual override with timeout | ✅ | `number.override_duration` | | Native daily cover open/close (no Scheduler entity needed) | ✅ | See [Daily Cover Schedule](#️-daily-cover-schedule) | | Daily cover schedule state survives HA restart | ✅ | Persisted alongside the heat-protection cover state | -| Cover reactive heat close | ✅ | See [Reactive Close](#reactive-close) | +| Cover reactive heat close | ✅ | See [Reactive Close](#reactive-close); active window derived from Daily Cover Schedule; never reopens itself | | Cover proactive forecast-based close | ✅ | See [Proactive Forecast-Based Close](#proactive-forecast-based-close) | -| Cover automatic evening reopen | ✅ | See [Automatic Evening Reopen](#automatic-evening-reopen) | | Cover automation state survives HA restart | ✅ | See [State Persistence](#state-persistence) | | Day-mode/thermostat-mode state survives HA restart | ✅ | Restored from storage at startup | | Cover position feedback (open/closed state) | ❌ | Not tracked — commands are sent blind; HomeShift only tracks whether *it* closed a cover today, not the cover's actual position | diff --git a/custom_components/homeshift/binary_sensor.py b/custom_components/homeshift/binary_sensor.py index 6371129..b263cce 100644 --- a/custom_components/homeshift/binary_sensor.py +++ b/custom_components/homeshift/binary_sensor.py @@ -44,8 +44,9 @@ def _device_info(entry: ConfigEntry) -> dict: class HomeShiftCoverHeatActiveSensor(CoordinatorEntity[HomeShiftCoordinator], BinarySensorEntity): """Binary sensor: True when cover heat protection conditions are currently met. - Active (on) when the current time is within the configured window AND the - outdoor temperature exceeds the configured threshold. + Active (on) when the current time is within the daily cover open/close + window (from Daily Cover Schedule) AND the outdoor temperature exceeds + the configured threshold. Updates every coordinator poll cycle and also immediately whenever the temperature sensor value changes. """ diff --git a/custom_components/homeshift/config_flow.py b/custom_components/homeshift/config_flow.py index 29488b6..a5b3897 100644 --- a/custom_components/homeshift/config_flow.py +++ b/custom_components/homeshift/config_flow.py @@ -33,19 +33,13 @@ CONF_COVER_ENTITIES, CONF_COVER_TEMP_SENSOR, CONF_COVER_TEMP_THRESHOLD, - CONF_COVER_TIME_START, - CONF_COVER_TIME_END, CONF_COVER_ACTION, CONF_COVER_MY_BUTTON, DEFAULT_COVER_TEMP_THRESHOLD, - DEFAULT_COVER_TIME_START, - DEFAULT_COVER_TIME_END, DEFAULT_COVER_ACTION, CONF_COVER_WEATHER_ENTITY, CONF_COVER_FORECAST_THRESHOLD, DEFAULT_COVER_FORECAST_THRESHOLD, - CONF_COVER_EVENING_REOPEN_TEMP, - DEFAULT_COVER_EVENING_REOPEN_TEMP, CONF_DAILY_COVER_ENTITIES, CONF_DAILY_COVER_OPEN_TIME_MAP, CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, @@ -335,14 +329,6 @@ def _covers_schema(hass, data: dict[str, Any]) -> vol.Schema: mode=selector.SelectSelectorMode.DROPDOWN, ) ), - vol.Optional( - CONF_COVER_TIME_START, - default=data.get(CONF_COVER_TIME_START, DEFAULT_COVER_TIME_START), - ): selector.TimeSelector(), - vol.Optional( - CONF_COVER_TIME_END, - default=data.get(CONF_COVER_TIME_END, DEFAULT_COVER_TIME_END), - ): selector.TimeSelector(), vol.Optional( CONF_COVER_WEATHER_ENTITY, default=data.get(CONF_COVER_WEATHER_ENTITY, ""), @@ -358,17 +344,6 @@ def _covers_schema(hass, data: dict[str, Any]) -> vol.Schema: mode=selector.NumberSelectorMode.BOX, ) ), - vol.Optional( - CONF_COVER_EVENING_REOPEN_TEMP, - default=data.get(CONF_COVER_EVENING_REOPEN_TEMP, DEFAULT_COVER_EVENING_REOPEN_TEMP), - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=0, - max=60, - step=0.5, - mode=selector.NumberSelectorMode.BOX, - ) - ), } ) diff --git a/custom_components/homeshift/const.py b/custom_components/homeshift/const.py index dc36fe2..b5c8577 100644 --- a/custom_components/homeshift/const.py +++ b/custom_components/homeshift/const.py @@ -33,18 +33,17 @@ DEFAULT_EVENT_MODE_MAP = "Vacation:home, Remote:remote" DEFAULT_MODE_ABSENCE = "away" -# Cover heat control +# Cover heat control — the active window is no longer independently +# configured: it's fully derived from the Daily Cover Schedule feature +# (below), i.e. between cover_open_time and daily_close_time. Heat protection +# is therefore inert until Daily Cover Schedule is configured. CONF_COVER_ENTITIES = "cover_entities" CONF_COVER_TEMP_SENSOR = "cover_temp_sensor" CONF_COVER_TEMP_THRESHOLD = "cover_temp_threshold" -CONF_COVER_TIME_START = "cover_time_start" -CONF_COVER_TIME_END = "cover_time_end" CONF_COVER_ACTION = "cover_action" CONF_COVER_MY_BUTTON = "cover_my_button" DEFAULT_COVER_TEMP_THRESHOLD = 30.0 -DEFAULT_COVER_TIME_START = "08:35:00" -DEFAULT_COVER_TIME_END = "18:00:00" DEFAULT_COVER_ACTION = "close_cover" # Proactive (forecast-based) closing — closes ahead of the reactive threshold @@ -54,12 +53,6 @@ DEFAULT_COVER_FORECAST_THRESHOLD = 28.0 -# Automatic evening reopening — reopens covers the heat-protection automation -# closed, once it has cooled back down after the active window. -CONF_COVER_EVENING_REOPEN_TEMP = "cover_evening_reopen_temp" - -DEFAULT_COVER_EVENING_REOPEN_TEMP = 26.0 - # Earliest sunrise-based opening time — used by the Daily Cover Schedule's # 'sunrise' open-time value (floors sunrise so covers never open too early). CONF_SUNRISE_EARLIEST = "sunrise_earliest" diff --git a/custom_components/homeshift/cover_manager.py b/custom_components/homeshift/cover_manager.py index 7425892..3ac3941 100644 --- a/custom_components/homeshift/cover_manager.py +++ b/custom_components/homeshift/cover_manager.py @@ -1,7 +1,9 @@ """Cover manager for HomeShift integration. Handles cover heat-protection (closing covers when it gets too hot) and -the native daily open/close schedule for a cover group. +the native daily open/close schedule for a cover group. Heat protection's +active window is fully derived from the daily schedule's computed open/close +times, so it is inert until the daily schedule is configured. """ from __future__ import annotations @@ -19,19 +21,13 @@ CONF_COVER_ENTITIES, CONF_COVER_TEMP_SENSOR, CONF_COVER_TEMP_THRESHOLD, - CONF_COVER_TIME_START, - CONF_COVER_TIME_END, CONF_COVER_ACTION, CONF_COVER_MY_BUTTON, DEFAULT_COVER_TEMP_THRESHOLD, - DEFAULT_COVER_TIME_START, - DEFAULT_COVER_TIME_END, DEFAULT_COVER_ACTION, CONF_COVER_WEATHER_ENTITY, CONF_COVER_FORECAST_THRESHOLD, DEFAULT_COVER_FORECAST_THRESHOLD, - CONF_COVER_EVENING_REOPEN_TEMP, - DEFAULT_COVER_EVENING_REOPEN_TEMP, CONF_DAILY_COVER_ENTITIES, CONF_DAILY_COVER_OPEN_TIME_MAP, CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, @@ -96,12 +92,16 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._entry = entry self.cover_open_time: str | None = None self.daily_close_time: str | None = None - # Tracks which day each once-per-day action last ran on, so proactive - # closing and evening reopening each fire at most once per calendar day. + # Heat protection state — whether the heat-protected cover has been + # closed by this automation today (proactively or reactively). Once + # closed, it stays closed for the rest of the day: heat protection + # never reopens it — only the next day's normal open (which resets + # this flag) or the daily schedule's own unconditional evening close + # touch the cover again. + self._heat_closed: bool = False self._proactive_checked_date: date | None = None - self._closed_date: date | None = None - self._reopened_date: date | None = None - # Same, for the native daily open/close schedule (separate cover group). + # Once-per-day tracking for the native daily open/close schedule + # (separate cover group from heat protection). self._daily_opened_date: date | None = None self._daily_closed_date: date | None = None self._store: Store = Store(hass, STORAGE_VERSION, f"{STORAGE_KEY}.{entry.entry_id}") @@ -112,11 +112,12 @@ def _config(self) -> dict: return {**self._entry.data, **self._entry.options} async def async_restore_state(self) -> None: - """Restore the once-per-day action dates from storage. + """Restore persisted cover-automation state from storage. Called once during setup, before the first check, so a HA restart - between the proactive close and the evening reopen doesn't silently - disable the reopen for the rest of that day. + mid-day doesn't lose track of the day's state (e.g. whether the + heat-protected cover is currently closed, or whether today's daily + open/close already ran). """ try: stored = await self._store.async_load() @@ -127,20 +128,18 @@ async def async_restore_state(self) -> None: if not stored: return + self._heat_closed = bool(stored.get("heat_closed", False)) self._proactive_checked_date = _parse_stored_date(stored.get("proactive_checked_date")) - self._closed_date = _parse_stored_date(stored.get("closed_date")) - self._reopened_date = _parse_stored_date(stored.get("reopened_date")) self._daily_opened_date = _parse_stored_date(stored.get("daily_opened_date")) self._daily_closed_date = _parse_stored_date(stored.get("daily_closed_date")) async def _async_save_state(self) -> None: - """Persist the once-per-day action dates to storage.""" + """Persist cover-automation state to storage.""" try: await self._store.async_save( { + "heat_closed": self._heat_closed, "proactive_checked_date": self._proactive_checked_date.isoformat() if self._proactive_checked_date else None, - "closed_date": self._closed_date.isoformat() if self._closed_date else None, - "reopened_date": self._reopened_date.isoformat() if self._reopened_date else None, "daily_opened_date": self._daily_opened_date.isoformat() if self._daily_opened_date else None, "daily_closed_date": self._daily_closed_date.isoformat() if self._daily_closed_date else None, } @@ -166,47 +165,44 @@ def _on_temp_change(_event: Event[EventStateChangedData]) -> None: _LOGGER.debug("Cover heat protection: listening for changes on '%s'", temp_sensor) return async_track_state_change_event(self._hass, [temp_sensor], _on_temp_change) + def _is_within_daily_window(self, now: datetime) -> bool: + """Return True when now falls between cover_open_time and daily_close_time.""" + if self.cover_open_time is None or self.daily_close_time is None: + return False + open_time = _parse_time_str(self.cover_open_time) + close_time = _parse_time_str(self.daily_close_time) + if open_time is None or close_time is None: + return False + return open_time <= now.time() <= close_time + async def async_check_heat_protection(self, now: datetime) -> None: - """Run the configured cover action when temperature exceeds the threshold in the window. - - Reads CONF_COVER_ENTITIES, CONF_COVER_TEMP_SENSOR, CONF_COVER_TEMP_THRESHOLD, - CONF_COVER_TIME_START, CONF_COVER_TIME_END, and CONF_COVER_ACTION from the - config entry. - When the current time falls inside the window and the temperature is above - the threshold, it calls the configured cover service (default: close_cover) - on all configured cover entities. - - Also runs the proactive forecast-based close and the automatic evening - reopen, since both are cheap no-ops once already handled for the day and - this method is already called on every sensor change and coordinator poll. + """Run proactive and reactive heat protection for the configured cover. + + The active window is derived entirely from the Daily Cover Schedule's + computed cover_open_time/daily_close_time — heat protection does + nothing until that feature is configured and has computed today's + times. Within the window, the cover closes when the temperature + exceeds the threshold (or, once per day at cover_open_time, when + today's forecast high does). It never reopens itself once closed — + it stays closed for the rest of the day; only the next day's normal + open (or the daily schedule's own unconditional evening close) + touches it again. """ - cover_entities = self._config.get(CONF_COVER_ENTITIES, []) temp_sensor = self._config.get(CONF_COVER_TEMP_SENSOR, "") if not cover_entities or not temp_sensor: return - await self._async_check_proactive_close(now, cover_entities) - await self._async_check_evening_reopen(now, cover_entities) - - active = self.is_heat_protection_active(now) - if not active: + if self.cover_open_time is None or self.daily_close_time is None: + _LOGGER.debug( + "Cover heat protection: waiting on Daily Cover Schedule's computed " + "open/close times (not configured yet, or not computed today)" + ) return - temp_state = self._hass.states.get(temp_sensor) - temperature = float(temp_state.state) if temp_state else 0.0 - threshold = float(self._config.get(CONF_COVER_TEMP_THRESHOLD, DEFAULT_COVER_TEMP_THRESHOLD)) - - _LOGGER.info( - "Cover heat protection: temperature=%.1f > threshold=%.1f, reacting on covers %s", - temperature, - threshold, - cover_entities, - ) - await self._async_apply_cover_action(cover_entities) - self._closed_date = now.date() - await self._async_save_state() + await self._async_check_proactive_close(now, cover_entities) + await self._async_check_reactive_close(now, cover_entities, temp_sensor) async def _async_apply_cover_action(self, cover_entities: list[str]) -> None: """Press the configured My button, or call the configured cover service.""" @@ -238,13 +234,14 @@ async def _async_apply_cover_action(self, cover_entities: list[str]) -> None: ) async def _async_check_proactive_close(self, now: datetime, cover_entities: list[str]) -> None: - """Close covers ahead of time when today's forecast high is hot enough. + """Close the cover ahead of time when today's forecast high is hot enough. Reactive closing (temperature > threshold) only fires once the outdoor air is already hot — by then a south-facing room has often already absorbed hours of solar/conductive gain that a later closure can't - undo. Checking the day's forecast once, at CONF_COVER_TIME_START, - lets the cover close before that gain happens. + undo. Checking the day's forecast once, at cover_open_time, lets the + cover close before that gain happens (or skip the open->close flicker + entirely if it's already known to be a hot day). Runs at most once per calendar day; a failed forecast lookup is retried on the next call instead of being marked done. """ @@ -256,8 +253,8 @@ async def _async_check_proactive_close(self, now: datetime, cover_entities: list if self._proactive_checked_date == today: return - time_start = _parse_time_str(self._config.get(CONF_COVER_TIME_START, DEFAULT_COVER_TIME_START)) - if time_start is None or now.time() < time_start: + open_time = _parse_time_str(self.cover_open_time) + if open_time is None or now.time() < open_time: return forecast_high = await self._async_get_forecast_high(weather_entity) @@ -278,7 +275,7 @@ async def _async_check_proactive_close(self, now: datetime, cover_entities: list cover_entities, ) await self._async_apply_cover_action(cover_entities) - self._closed_date = today + self._heat_closed = True await self._async_save_state() async def _async_get_forecast_high(self, weather_entity: str) -> float | None: @@ -304,22 +301,19 @@ async def _async_get_forecast_high(self, weather_entity: str) -> float | None: except (TypeError, ValueError): return None - async def _async_check_evening_reopen(self, now: datetime, cover_entities: list[str]) -> None: - """Reopen covers the heat-protection automation closed, once it has cooled down. + async def _async_check_reactive_close(self, now: datetime, cover_entities: list[str], temp_sensor: str) -> None: + """Close the cover when the outdoor temperature exceeds the threshold. - Only reopens covers that this automation itself closed today (proactively - or reactively), only after CONF_COVER_TIME_END, and only once per day — - it never touches a cover the user closed manually for other reasons. + Only acts within the active window, and only once per day — once + closed by this automation, it's left alone for the rest of the day + (see async_check_heat_protection). """ - today = now.date() - if self._closed_date != today or self._reopened_date == today: + if self._heat_closed: return - time_end = _parse_time_str(self._config.get(CONF_COVER_TIME_END, DEFAULT_COVER_TIME_END)) - if time_end is None or now.time() < time_end: + if not self._is_within_daily_window(now): return - temp_sensor = self._config.get(CONF_COVER_TEMP_SENSOR, "") temp_state = self._hass.states.get(temp_sensor) if temp_state is None: return @@ -329,31 +323,27 @@ async def _async_check_evening_reopen(self, now: datetime, cover_entities: list[ except (ValueError, TypeError): return - reopen_temp = float(self._config.get(CONF_COVER_EVENING_REOPEN_TEMP, DEFAULT_COVER_EVENING_REOPEN_TEMP)) - if temperature > reopen_temp: + threshold = float(self._config.get(CONF_COVER_TEMP_THRESHOLD, DEFAULT_COVER_TEMP_THRESHOLD)) + if temperature <= threshold: return _LOGGER.info( - "Cover evening reopen: temperature=%.1f <= %.1f, opening covers %s", + "Cover heat protection: temperature=%.1f > threshold=%.1f, closing covers %s", temperature, - reopen_temp, + threshold, cover_entities, ) - await self._hass.services.async_call( - "cover", - "open_cover", - {"entity_id": cover_entities}, - blocking=False, - ) - self._reopened_date = today + await self._async_apply_cover_action(cover_entities) + self._heat_closed = True await self._async_save_state() def is_heat_protection_active(self, now: datetime) -> bool | None: """Return whether heat protection conditions are currently met. - Returns True when: within time window AND temperature > threshold. - Returns False when: outside time window, or within window but temp <= threshold. - Returns None when: not configured, sensor unavailable, or unparseable values. + Returns True when: within the daily open/close window AND temperature > threshold. + Returns False when: outside the window, or within it but temp <= threshold. + Returns None when: not configured, Daily Cover Schedule hasn't computed + today's times yet, sensor unavailable, or unparseable values. """ cover_entities = self._config.get(CONF_COVER_ENTITIES, []) temp_sensor = self._config.get(CONF_COVER_TEMP_SENSOR, "") @@ -361,16 +351,10 @@ def is_heat_protection_active(self, now: datetime) -> bool | None: if not cover_entities or not temp_sensor: return None - time_start_str = self._config.get(CONF_COVER_TIME_START, DEFAULT_COVER_TIME_START) - time_end_str = self._config.get(CONF_COVER_TIME_END, DEFAULT_COVER_TIME_END) - time_start = _parse_time_str(time_start_str) - time_end = _parse_time_str(time_end_str) - - if time_start is None or time_end is None: + if self.cover_open_time is None or self.daily_close_time is None: return None - now_time = now.time() - if not time_start <= now_time <= time_end: + if not self._is_within_daily_window(now): return False temp_state = self._hass.states.get(temp_sensor) @@ -414,8 +398,10 @@ async def async_compute_daily_schedule(self, now: datetime, day_mode_key: str | — unlike opening, closing is not mode-dependent. Called once when a new calendar day is detected. Computed once per day — a day-mode change later that same day does not recompute the - open time. + open time. Also resets heat protection's closed state for the new day. """ + self._heat_closed = False + if not self._config.get(CONF_DAILY_COVER_ENTITIES, []): return diff --git a/custom_components/homeshift/translations/en.json b/custom_components/homeshift/translations/en.json index 1a47dc0..7a93c48 100644 --- a/custom_components/homeshift/translations/en.json +++ b/custom_components/homeshift/translations/en.json @@ -73,30 +73,24 @@ }, "covers": { "title": "Cover Heat Control", - "description": "Close covers automatically when the temperature exceeds the threshold during the configured time window.", + "description": "Close a cover automatically when it's too hot. Once closed, it stays closed for the rest of the day — only the next day's normal open, or the daily schedule's own evening close, touch it again. The active window is entirely derived from Daily Cover Schedule's computed open/close times — configure that first.", "data": { "cover_entities": "Cover Entities", "cover_temp_sensor": "Temperature Sensor", "cover_temp_threshold": "Temperature Threshold (°C)", - "cover_time_start": "Active Window Start", - "cover_time_end": "Active Window End", "cover_action": "Cover Action", "cover_my_button": "My position button", "cover_weather_entity": "Weather Entity (proactive close)", - "cover_forecast_threshold": "Forecast Threshold (°C)", - "cover_evening_reopen_temp": "Evening Reopen Temperature (°C)" + "cover_forecast_threshold": "Forecast Threshold (°C)" }, "data_description": { "cover_entities": "Select the cover entities to control when it is too hot.", "cover_temp_sensor": "Sensor providing the outdoor temperature.", "cover_temp_threshold": "Temperature above which the selected action is triggered.", - "cover_time_start": "Earliest time of day the feature is active. Also the time the forecast is checked for the proactive close.", - "cover_time_end": "Latest time of day the feature is active. Also the earliest time the evening reopen can fire.", "cover_action": "'Close' sends the standard close_cover command. 'Stop' sends stop_cover, halting movement mid-travel — useful for Somfy RTS covers (the My/favorite position is a separate button entity).", "cover_my_button": "Button to press to send covers to their favorite (My) position. If set, this button is used instead of the action below.", - "cover_weather_entity": "Optional weather entity with daily forecasts. When set, covers close proactively at Active Window Start if today's forecast high exceeds the forecast threshold, instead of waiting for the outdoor sensor to actually cross it.", - "cover_forecast_threshold": "Forecast daily high above which the proactive close triggers.", - "cover_evening_reopen_temp": "Once covers have been closed by this automation, they reopen automatically after Active Window End as soon as the outdoor sensor drops to or below this temperature." + "cover_weather_entity": "Optional weather entity with daily forecasts. When set, the cover closes proactively at the day's cover open time if today's forecast high exceeds the forecast threshold, instead of waiting for the outdoor sensor to actually cross it.", + "cover_forecast_threshold": "Forecast daily high above which the proactive close triggers." } }, "daily_cover_schedule": { @@ -209,30 +203,24 @@ }, "covers": { "title": "Cover Heat Control", - "description": "Close covers automatically when the temperature exceeds the threshold during the configured time window.", + "description": "Close a cover automatically when it's too hot. Once closed, it stays closed for the rest of the day — only the next day's normal open, or the daily schedule's own evening close, touch it again. The active window is entirely derived from Daily Cover Schedule's computed open/close times — configure that first.", "data": { "cover_entities": "Cover Entities", "cover_temp_sensor": "Temperature Sensor", "cover_temp_threshold": "Temperature Threshold (°C)", - "cover_time_start": "Active Window Start", - "cover_time_end": "Active Window End", "cover_action": "Cover Action", "cover_my_button": "My position button", "cover_weather_entity": "Weather Entity (proactive close)", - "cover_forecast_threshold": "Forecast Threshold (°C)", - "cover_evening_reopen_temp": "Evening Reopen Temperature (°C)" + "cover_forecast_threshold": "Forecast Threshold (°C)" }, "data_description": { "cover_entities": "Select the cover entities to control when it is too hot.", "cover_temp_sensor": "Sensor providing the outdoor temperature.", "cover_temp_threshold": "Temperature above which the selected action is triggered.", - "cover_time_start": "Earliest time of day the feature is active. Also the time the forecast is checked for the proactive close.", - "cover_time_end": "Latest time of day the feature is active. Also the earliest time the evening reopen can fire.", "cover_action": "'Close' sends the standard close_cover command. 'Stop' sends stop_cover, halting movement mid-travel — useful for Somfy RTS covers (the My/favorite position is a separate button entity).", "cover_my_button": "Button to press to send covers to their favorite (My) position. If set, this button is used instead of the action below.", - "cover_weather_entity": "Optional weather entity with daily forecasts. When set, covers close proactively at Active Window Start if today's forecast high exceeds the forecast threshold, instead of waiting for the outdoor sensor to actually cross it.", - "cover_forecast_threshold": "Forecast daily high above which the proactive close triggers.", - "cover_evening_reopen_temp": "Once covers have been closed by this automation, they reopen automatically after Active Window End as soon as the outdoor sensor drops to or below this temperature." + "cover_weather_entity": "Optional weather entity with daily forecasts. When set, the cover closes proactively at the day's cover open time if today's forecast high exceeds the forecast threshold, instead of waiting for the outdoor sensor to actually cross it.", + "cover_forecast_threshold": "Forecast daily high above which the proactive close triggers." } }, "daily_cover_schedule": { diff --git a/custom_components/homeshift/translations/fr.json b/custom_components/homeshift/translations/fr.json index 686cbd2..5c79356 100644 --- a/custom_components/homeshift/translations/fr.json +++ b/custom_components/homeshift/translations/fr.json @@ -73,30 +73,24 @@ }, "covers": { "title": "Contrôle des volets (chaleur)", - "description": "Fermer les volets automatiquement quand la température dépasse le seuil pendant la plage horaire configurée.", + "description": "Fermer un volet automatiquement en cas de forte chaleur. Une fois fermé, il reste fermé pour le reste de la journée — seule l'ouverture normale du lendemain, ou la fermeture du soir du planning quotidien, le touchent à nouveau. La plage active est entièrement dérivée des heures d'ouverture/fermeture calculées par le Planning quotidien des volets — configurez-le d'abord.", "data": { "cover_entities": "Entités volets", "cover_temp_sensor": "Capteur de température", "cover_temp_threshold": "Seuil de température (°C)", - "cover_time_start": "Début de la plage horaire", - "cover_time_end": "Fin de la plage horaire", "cover_action": "Action sur les volets", "cover_my_button": "Bouton position favorite (My)", "cover_weather_entity": "Entité météo (fermeture proactive)", - "cover_forecast_threshold": "Seuil de prévision (°C)", - "cover_evening_reopen_temp": "Température de réouverture du soir (°C)" + "cover_forecast_threshold": "Seuil de prévision (°C)" }, "data_description": { "cover_entities": "Sélectionnez les entités volets à contrôler en cas de forte chaleur.", "cover_temp_sensor": "Capteur fournissant la température extérieure.", "cover_temp_threshold": "Température au-dessus de laquelle l'action sélectionnée est déclenchée.", - "cover_time_start": "Heure de début de la plage d'activation. C'est aussi l'heure à laquelle la prévision est vérifiée pour la fermeture proactive.", - "cover_time_end": "Heure de fin de la plage d'activation. C'est aussi l'heure la plus tôt à laquelle la réouverture du soir peut se déclencher.", "cover_action": "'Fermer' envoie close_cover (volets standard). 'Arrêter' envoie stop_cover, qui interrompt le mouvement en cours — utile pour les volets Somfy RTS (la position favorite My est un bouton séparé).", "cover_my_button": "Bouton à appuyer pour envoyer les volets en position favorite (My). Si renseigné, ce bouton est utilisé à la place de l'action ci-dessous.", - "cover_weather_entity": "Entité météo optionnelle avec prévisions journalières. Si renseignée, les volets se ferment de manière proactive à l'heure de début de plage si la prévision du jour dépasse le seuil de prévision, sans attendre que le capteur extérieur le dépasse réellement.", - "cover_forecast_threshold": "Température maximale prévue au-delà de laquelle la fermeture proactive se déclenche.", - "cover_evening_reopen_temp": "Une fois les volets fermés par cette automatisation, ils se rouvrent automatiquement après la fin de la plage horaire dès que le capteur extérieur redescend à cette température ou en dessous." + "cover_weather_entity": "Entité météo optionnelle avec prévisions journalières. Si renseignée, le volet se ferme de manière proactive à l'heure d'ouverture du jour si la prévision du jour dépasse le seuil de prévision, sans attendre que le capteur extérieur le dépasse réellement.", + "cover_forecast_threshold": "Température maximale prévue au-delà de laquelle la fermeture proactive se déclenche." } }, "daily_cover_schedule": { @@ -209,30 +203,24 @@ }, "covers": { "title": "Contrôle des volets (chaleur)", - "description": "Fermer les volets automatiquement quand la température dépasse le seuil pendant la plage horaire configurée.", + "description": "Fermer un volet automatiquement en cas de forte chaleur. Une fois fermé, il reste fermé pour le reste de la journée — seule l'ouverture normale du lendemain, ou la fermeture du soir du planning quotidien, le touchent à nouveau. La plage active est entièrement dérivée des heures d'ouverture/fermeture calculées par le Planning quotidien des volets — configurez-le d'abord.", "data": { "cover_entities": "Entités volets", "cover_temp_sensor": "Capteur de température", "cover_temp_threshold": "Seuil de température (°C)", - "cover_time_start": "Début de la plage horaire", - "cover_time_end": "Fin de la plage horaire", "cover_action": "Action sur les volets", "cover_my_button": "Bouton position favorite (My)", "cover_weather_entity": "Entité météo (fermeture proactive)", - "cover_forecast_threshold": "Seuil de prévision (°C)", - "cover_evening_reopen_temp": "Température de réouverture du soir (°C)" + "cover_forecast_threshold": "Seuil de prévision (°C)" }, "data_description": { "cover_entities": "Sélectionnez les entités volets à contrôler en cas de forte chaleur.", "cover_temp_sensor": "Capteur fournissant la température extérieure.", "cover_temp_threshold": "Température au-dessus de laquelle l'action sélectionnée est déclenchée.", - "cover_time_start": "Heure de début de la plage d'activation. C'est aussi l'heure à laquelle la prévision est vérifiée pour la fermeture proactive.", - "cover_time_end": "Heure de fin de la plage d'activation. C'est aussi l'heure la plus tôt à laquelle la réouverture du soir peut se déclencher.", "cover_action": "'Fermer' envoie close_cover (volets standard). 'Arrêter' envoie stop_cover, qui interrompt le mouvement en cours — utile pour les volets Somfy RTS (la position favorite My est un bouton séparé).", "cover_my_button": "Bouton à appuyer pour envoyer les volets en position favorite (My). Si renseigné, ce bouton est utilisé à la place de l'action ci-dessous.", - "cover_weather_entity": "Entité météo optionnelle avec prévisions journalières. Si renseignée, les volets se ferment de manière proactive à l'heure de début de plage si la prévision du jour dépasse le seuil de prévision, sans attendre que le capteur extérieur le dépasse réellement.", - "cover_forecast_threshold": "Température maximale prévue au-delà de laquelle la fermeture proactive se déclenche.", - "cover_evening_reopen_temp": "Une fois les volets fermés par cette automatisation, ils se rouvrent automatiquement après la fin de la plage horaire dès que le capteur extérieur redescend à cette température ou en dessous." + "cover_weather_entity": "Entité météo optionnelle avec prévisions journalières. Si renseignée, le volet se ferme de manière proactive à l'heure d'ouverture du jour si la prévision du jour dépasse le seuil de prévision, sans attendre que le capteur extérieur le dépasse réellement.", + "cover_forecast_threshold": "Température maximale prévue au-delà de laquelle la fermeture proactive se déclenche." } }, "daily_cover_schedule": { diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..fcaef96 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,579 @@ +"""Tests for HomeShift's config_flow: schema builders, map (de)serialization, and step wiring.""" +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +import custom_components.homeshift.config_flow as cf +from custom_components.homeshift.const import ( + CONF_CALENDAR_ENTITY, + CONF_COVER_ACTION, + CONF_COVER_ENTITIES, + CONF_COVER_TEMP_THRESHOLD, + CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, + CONF_DAILY_COVER_ENTITIES, + CONF_DAILY_COVER_OPEN_TIME_MAP, + CONF_DAY_MODE_MAP, + CONF_HOLIDAY_CALENDAR, + CONF_SCHEDULERS_PER_MODE, + CONF_SUNRISE_EARLIEST, + DEFAULT_DAILY_COVER_OPEN_TIME, + DEFAULT_DAY_MODE_MAP, +) + + +def _make_hass(language: str = "en", switch_states: list | None = None) -> MagicMock: + """Return a MagicMock hass suitable for config_flow schema builders/steps.""" + hass = MagicMock() + hass.config.language = language + hass.states.async_all.return_value = switch_states or [] + hass.states.get.return_value = MagicMock() # any entity_id "exists" + return hass + + +# --------------------------------------------------------------------------- +# Day-mode map helpers (shared with covers' daily open-time map) +# --------------------------------------------------------------------------- + +class TestParseDayModeMap: + """_parse_day_mode_map: 'Key:Display, ...' -> ordered dict.""" + + def test_parses_default_map(self): + result = cf._parse_day_mode_map(DEFAULT_DAY_MODE_MAP) + assert result == {"home": "Home", "work": "Work", "remote": "Remote", "away": "Away"} + + def test_ignores_entries_without_colon(self): + result = cf._parse_day_mode_map("home:Home, garbage, work:Work") + assert result == {"home": "Home", "work": "Work"} + + def test_empty_string_yields_empty_map(self): + assert cf._parse_day_mode_map("") == {} + + +class TestRebuildDayModeMap: + """_rebuild_day_mode_map: per-mode display fields -> 'Key:Display, ...' string, consuming fields.""" + + def test_rebuilds_from_display_fields(self): + data = {CONF_DAY_MODE_MAP: "home:Home, work:Work"} + user_input = {"day_display_home": "Maison", "day_display_work": "Boulot"} + result = cf._rebuild_day_mode_map(user_input, data) + assert result == "home:Maison, work:Boulot" + + def test_falls_back_to_current_display_when_field_missing(self): + data = {CONF_DAY_MODE_MAP: "home:Home, work:Work"} + result = cf._rebuild_day_mode_map({}, data) + assert result == "home:Home, work:Work" + + def test_pops_consumed_fields_from_user_input(self): + data = {CONF_DAY_MODE_MAP: "home:Home"} + user_input = {"day_display_home": "Maison"} + cf._rebuild_day_mode_map(user_input, data) + assert "day_display_home" not in user_input + + +# --------------------------------------------------------------------------- +# Daily cover open-time per-mode map (mirrors day-mode map pattern) +# --------------------------------------------------------------------------- + +class TestDailyOpenTimeFields: + """_daily_open_time_fields: one selector field per configured day-mode key.""" + + def test_one_field_per_day_mode(self): + data = {CONF_DAY_MODE_MAP: DEFAULT_DAY_MODE_MAP} + fields = cf._daily_open_time_fields(data) + field_names = {marker.schema for marker in fields} + assert field_names == { + "daily_open_time_home", + "daily_open_time_work", + "daily_open_time_remote", + "daily_open_time_away", + } + + def test_default_is_sunrise_when_no_current_map(self): + data = {CONF_DAY_MODE_MAP: "home:Home"} + fields = cf._daily_open_time_fields(data) + (marker,) = fields.keys() + assert marker.default() == DEFAULT_DAILY_COVER_OPEN_TIME + + def test_reflects_existing_map_value(self): + data = { + CONF_DAY_MODE_MAP: "home:Home, away:Away", + CONF_DAILY_COVER_OPEN_TIME_MAP: "home:sunrise, away:skip", + } + fields = cf._daily_open_time_fields(data) + defaults = {marker.schema: marker.default() for marker in fields} + assert defaults["daily_open_time_home"] == "sunrise" + assert defaults["daily_open_time_away"] == "skip" + + +class TestRebuildDailyOpenTimeMap: + """_rebuild_daily_open_time_map: per-mode fields -> 'Key:Value, ...' string, consuming fields.""" + + def test_rebuilds_from_user_input(self): + data = {CONF_DAY_MODE_MAP: "home:Home, work:Work, away:Away"} + user_input = { + "daily_open_time_home": "08:30", + "daily_open_time_work": "sunrise", + "daily_open_time_away": "skip", + } + result = cf._rebuild_daily_open_time_map(user_input, data) + assert result == "home:08:30, work:sunrise, away:skip" + + def test_falls_back_to_default_when_field_missing(self): + data = {CONF_DAY_MODE_MAP: "home:Home"} + result = cf._rebuild_daily_open_time_map({}, data) + assert result == f"home:{DEFAULT_DAILY_COVER_OPEN_TIME}" + + def test_falls_back_to_existing_map_value_when_field_missing(self): + data = { + CONF_DAY_MODE_MAP: "home:Home, away:Away", + CONF_DAILY_COVER_OPEN_TIME_MAP: "home:sunrise, away:skip", + } + result = cf._rebuild_daily_open_time_map({}, data) + assert result == "home:sunrise, away:skip" + + def test_allows_custom_time_value(self): + data = {CONF_DAY_MODE_MAP: "home:Home"} + user_input = {"daily_open_time_home": "09:15"} + result = cf._rebuild_daily_open_time_map(user_input, data) + assert result == "home:09:15" + + def test_pops_consumed_fields_from_user_input(self): + data = {CONF_DAY_MODE_MAP: "home:Home"} + user_input = {"daily_open_time_home": "sunrise"} + cf._rebuild_daily_open_time_map(user_input, data) + assert "daily_open_time_home" not in user_input + + def test_away_mode_not_special_cased(self): + """Away has no hardcoded default: it follows the map like any other mode (opens like Home unless configured otherwise).""" + data = {CONF_DAY_MODE_MAP: "home:Home, away:Away"} + result = cf._rebuild_daily_open_time_map({}, data) + assert result == f"home:{DEFAULT_DAILY_COVER_OPEN_TIME}, away:{DEFAULT_DAILY_COVER_OPEN_TIME}" + + +# --------------------------------------------------------------------------- +# Schema builders +# --------------------------------------------------------------------------- + +class TestCoversSchema: + """_covers_schema: field set matches the close-only, native-schedule design.""" + + def test_field_names(self): + hass = _make_hass() + schema = cf._covers_schema(hass, {}) + field_names = {marker.schema for marker in schema.schema} + assert field_names == { + "cover_entities", + "cover_temp_sensor", + "cover_temp_threshold", + "cover_my_button", + "cover_action", + "cover_weather_entity", + "cover_forecast_threshold", + } + + def test_no_reopen_or_time_window_fields(self): + """Old fields removed when heat protection became close-only & schedule-driven.""" + hass = _make_hass() + schema = cf._covers_schema(hass, {}) + field_names = {marker.schema for marker in schema.schema} + assert "cover_reopen_temp" not in field_names + assert "cover_time_start" not in field_names + assert "cover_time_end" not in field_names + + def test_french_action_labels(self): + hass = _make_hass(language="fr") + schema = cf._covers_schema(hass, {}) + action_marker = next(m for m in schema.schema if m.schema == CONF_COVER_ACTION) + action_selector = schema.schema[action_marker] + labels = {opt["label"] for opt in action_selector.config["options"]} + assert "Fermer les volets (close_cover)" in labels + + def test_english_action_labels(self): + hass = _make_hass(language="en") + schema = cf._covers_schema(hass, {}) + action_marker = next(m for m in schema.schema if m.schema == CONF_COVER_ACTION) + action_selector = schema.schema[action_marker] + labels = {opt["label"] for opt in action_selector.config["options"]} + assert "Close Cover (close_cover)" in labels + + def test_defaults_pulled_from_existing_data(self): + hass = _make_hass() + schema = cf._covers_schema(hass, {CONF_COVER_TEMP_THRESHOLD: 32.5}) + marker = next(m for m in schema.schema if m.schema == CONF_COVER_TEMP_THRESHOLD) + assert marker.default() == 32.5 + + +class TestDailyCoverSchema: + """_daily_cover_schema: base fields + one open-time field per day mode.""" + + def test_includes_base_and_per_mode_fields(self): + hass = _make_hass() + data = {CONF_DAY_MODE_MAP: DEFAULT_DAY_MODE_MAP} + schema = cf._daily_cover_schema(hass, data) + field_names = {marker.schema for marker in schema.schema} + assert { + CONF_DAILY_COVER_ENTITIES, + CONF_SUNRISE_EARLIEST, + CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, + "daily_open_time_home", + "daily_open_time_work", + "daily_open_time_remote", + "daily_open_time_away", + }.issubset(field_names) + + def test_no_independent_heat_window_fields(self): + """Heat window start/end were merged into this schedule; no separate config remains.""" + hass = _make_hass() + schema = cf._daily_cover_schema(hass, {}) + field_names = {marker.schema for marker in schema.schema} + assert "cover_time_start" not in field_names + assert "cover_time_end" not in field_names + + +class TestValidateCalendars: + """_validate_calendars: error keys for missing/invalid calendar entities.""" + + def test_no_errors_when_both_exist(self): + hass = _make_hass() + errors = cf._validate_calendars(hass, {CONF_CALENDAR_ENTITY: "calendar.a", CONF_HOLIDAY_CALENDAR: "calendar.b"}) + assert errors == {} + + def test_error_when_calendar_missing(self): + hass = MagicMock() + hass.states.get.return_value = None + errors = cf._validate_calendars(hass, {CONF_CALENDAR_ENTITY: "calendar.a", CONF_HOLIDAY_CALENDAR: "calendar.b"}) + assert errors[CONF_CALENDAR_ENTITY] == "invalid_calendar" + assert errors[CONF_HOLIDAY_CALENDAR] == "invalid_calendar" + + def test_no_error_when_calendar_entity_blank(self): + """CONF_CALENDAR_ENTITY is only validated if provided (falsy short-circuits the check).""" + hass = MagicMock() + hass.states.get.return_value = None + errors = cf._validate_calendars(hass, {CONF_CALENDAR_ENTITY: "", CONF_HOLIDAY_CALENDAR: "calendar.b"}) + assert CONF_CALENDAR_ENTITY not in errors + assert CONF_HOLIDAY_CALENDAR in errors + + +class TestSchedulerHelpers: + """_get_scheduler_options / _scheduler_selector / _extract_schedulers.""" + + def test_get_scheduler_options_filters_by_name_or_attribute(self): + matching = MagicMock() + matching.entity_id = "switch.schedule_volets" + matching.attributes = {"friendly_name": "Volets"} + non_matching = MagicMock() + non_matching.entity_id = "switch.other" + non_matching.attributes = {} + hass = _make_hass(switch_states=[matching, non_matching]) + options = cf._get_scheduler_options(hass) + assert len(options) == 1 + assert options[0]["value"] == "switch.schedule_volets" + + def test_extract_schedulers_normalizes_single_string_to_list(self): + data = {CONF_DAY_MODE_MAP: "home:Home, work:Work"} + user_input = {"Home": "switch.a", "Work": ["switch.b", "switch.c"]} + result = cf._extract_schedulers(user_input, data) + assert result == {"Home": ["switch.a"], "Work": ["switch.b", "switch.c"]} + + def test_extract_schedulers_defaults_missing_mode_to_empty_list(self): + data = {CONF_DAY_MODE_MAP: "home:Home, work:Work"} + result = cf._extract_schedulers({}, data) + assert result == {"Home": [], "Work": []} + + def test_scheduler_selector_uses_select_when_matches_found(self): + matching = MagicMock() + matching.entity_id = "switch.schedule_volets" + matching.attributes = {"friendly_name": "Volets"} + hass = _make_hass(switch_states=[matching]) + sel = cf._scheduler_selector(hass) + assert isinstance(sel, type(cf.selector.SelectSelector(cf.selector.SelectSelectorConfig(options=[])))) + + def test_scheduler_selector_falls_back_to_entity_selector_when_no_matches(self): + hass = _make_hass(switch_states=[]) + sel = cf._scheduler_selector(hass) + assert isinstance(sel, cf.selector.EntitySelector) + + +class TestMappingSchema: + """_mapping_schema: three collapsible sections (day modes, defaults, thermostat).""" + + def test_has_three_sections(self): + data = {CONF_DAY_MODE_MAP: DEFAULT_DAY_MODE_MAP} + schema = cf._mapping_schema(data) + section_names = {marker.schema for marker in schema.schema} + assert section_names == {"day_modes_section", "defaults_section", "thermostat_section"} + + +# --------------------------------------------------------------------------- +# ConfigFlow step wiring +# --------------------------------------------------------------------------- + +class TestConfigFlowMenu: + """async_step_menu: options offered, 'finalize' gated on minimum config.""" + + async def test_user_step_redirects_to_menu(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + result = await flow.async_step_user() + assert result["step_id"] == "menu" + + async def test_finalize_absent_when_incomplete(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + result = await flow.async_step_menu() + assert result["menu_options"] == ["calendars", "mapping", "schedulers", "covers", "daily_cover_schedule"] + + async def test_finalize_present_once_calendar_configured(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + flow._data[CONF_CALENDAR_ENTITY] = "calendar.a" + result = await flow.async_step_menu() + assert result["menu_options"][-1] == "finalize" + + +class TestConfigFlowCalendarsStep: + """async_step_calendars: validates, stores, and returns to the menu.""" + + async def test_valid_input_updates_data_and_returns_to_menu(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + result = await flow.async_step_calendars({CONF_CALENDAR_ENTITY: "calendar.a", CONF_HOLIDAY_CALENDAR: "calendar.b"}) + assert result["step_id"] == "menu" + assert flow._data[CONF_CALENDAR_ENTITY] == "calendar.a" + + async def test_invalid_input_redisplays_form_with_errors(self): + flow = cf.HomeShiftConfigFlow() + hass = MagicMock() + hass.states.get.return_value = None + flow.hass = hass + result = await flow.async_step_calendars({CONF_CALENDAR_ENTITY: "calendar.a", CONF_HOLIDAY_CALENDAR: "calendar.b"}) + assert result["step_id"] == "calendars" + assert result["errors"] + assert CONF_CALENDAR_ENTITY not in flow._data + + +class TestConfigFlowCoversStep: + """async_step_covers: stores whatever fields the form submits verbatim.""" + + async def test_stores_submitted_fields(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + result = await flow.async_step_covers({CONF_COVER_ENTITIES: ["cover.salon"], CONF_COVER_ACTION: "stop_cover"}) + assert result["step_id"] == "menu" + assert flow._data[CONF_COVER_ENTITIES] == ["cover.salon"] + assert flow._data[CONF_COVER_ACTION] == "stop_cover" + + async def test_shows_form_when_no_input(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + result = await flow.async_step_covers() + assert result["step_id"] == "covers" + + +class TestConfigFlowMappingSchedulersShowForm: + """Steps that only build+return a form when called with no input.""" + + async def test_mapping_shows_form(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + result = await flow.async_step_mapping() + assert result["step_id"] == "mapping" + + async def test_schedulers_shows_form(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + result = await flow.async_step_schedulers() + assert result["step_id"] == "schedulers" + + async def test_daily_cover_schedule_shows_form(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + result = await flow.async_step_daily_cover_schedule() + assert result["step_id"] == "daily_cover_schedule" + + async def test_calendars_shows_form_with_no_input(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + result = await flow.async_step_calendars() + assert result["step_id"] == "calendars" + + +class TestConfigFlowDailyCoverScheduleStep: + """async_step_daily_cover_schedule: rebuilds the per-mode open-time map before storing.""" + + async def test_rebuilds_open_time_map_into_data(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + flow._data[CONF_DAY_MODE_MAP] = "home:Home, away:Away" + result = await flow.async_step_daily_cover_schedule( + { + CONF_DAILY_COVER_ENTITIES: ["cover.volets"], + "daily_open_time_home": "sunrise", + "daily_open_time_away": "skip", + } + ) + assert result["step_id"] == "menu" + assert flow._data[CONF_DAILY_COVER_OPEN_TIME_MAP] == "home:sunrise, away:skip" + assert flow._data[CONF_DAILY_COVER_ENTITIES] == ["cover.volets"] + + async def test_per_mode_fields_not_leaked_into_stored_data(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + flow._data[CONF_DAY_MODE_MAP] = "home:Home" + await flow.async_step_daily_cover_schedule({"daily_open_time_home": "08:30"}) + assert "daily_open_time_home" not in flow._data + + +class TestConfigFlowMappingStep: + """async_step_mapping: flattens the three sections and rebuilds both maps.""" + + async def test_flattens_sections_and_rebuilds_maps(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + user_input = { + "day_modes_section": {"day_display_home": "Maison"}, + "defaults_section": {}, + "thermostat_section": {}, + } + result = await flow.async_step_mapping(user_input) + assert result["step_id"] == "menu" + assert "home:Maison" in flow._data[CONF_DAY_MODE_MAP] + + +class TestConfigFlowSchedulersStep: + """async_step_schedulers: extracts per-mode scheduler assignments.""" + + async def test_stores_extracted_schedulers(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + flow._data[CONF_DAY_MODE_MAP] = "home:Home" + result = await flow.async_step_schedulers({"Home": "switch.a"}) + assert result["step_id"] == "menu" + assert flow._data[CONF_SCHEDULERS_PER_MODE] == {"Home": ["switch.a"]} + + +class TestConfigFlowFinalize: + """async_step_finalize: creates the config entry with the accumulated data.""" + + async def test_creates_entry_with_accumulated_data(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + flow._data[CONF_CALENDAR_ENTITY] = "calendar.a" + result = await flow.async_step_finalize() + assert result["title"] == "HomeShift" + assert result["data"][CONF_CALENDAR_ENTITY] == "calendar.a" + + def test_options_flow_accessor_returns_options_flow(self): + flow_handler = cf.HomeShiftConfigFlow.async_get_options_flow(MagicMock()) + assert isinstance(flow_handler, cf.HomeShiftOptionsFlow) + + +# --------------------------------------------------------------------------- +# OptionsFlow step wiring +# --------------------------------------------------------------------------- + +def _make_options_flow(entry_data: dict, entry_options: dict | None = None) -> cf.HomeShiftOptionsFlow: + """Build a HomeShiftOptionsFlow with hass/handler wired for the config_entry property.""" + flow = cf.HomeShiftOptionsFlow() + hass = _make_hass() + entry = MagicMock() + entry.data = entry_data + entry.options = entry_options or {} + hass.config_entries.async_get_known_entry.return_value = entry + flow.hass = hass + flow.handler = "test_entry" + return flow + + +class TestOptionsFlowInit: + """async_step_init: pre-populates _data from the existing entry before showing the menu.""" + + async def test_prepopulates_from_entry_and_shows_menu(self): + flow = _make_options_flow({CONF_CALENDAR_ENTITY: "calendar.a"}) + result = await flow.async_step_init() + assert result["step_id"] == "menu" + assert flow._data[CONF_CALENDAR_ENTITY] == "calendar.a" + assert result["menu_options"][-1] == "finalize" + + async def test_options_override_entry_data(self): + flow = _make_options_flow({CONF_COVER_TEMP_THRESHOLD: 30.0}, entry_options={CONF_COVER_TEMP_THRESHOLD: 32.0}) + await flow.async_step_init() + assert flow._data[CONF_COVER_TEMP_THRESHOLD] == 32.0 + + +class TestOptionsFlowDailyCoverScheduleStep: + """Same rebuild-map behavior as the config flow's step.""" + + async def test_rebuilds_open_time_map(self): + flow = _make_options_flow({CONF_DAY_MODE_MAP: "home:Home, away:Away"}) + await flow.async_step_init() + result = await flow.async_step_daily_cover_schedule( + {"daily_open_time_home": "08:30", "daily_open_time_away": "skip"} + ) + assert result["step_id"] == "menu" + assert flow._data[CONF_DAILY_COVER_OPEN_TIME_MAP] == "home:08:30, away:skip" + + +class TestOptionsFlowFinalize: + """async_step_finalize: saves accumulated _data as the entry's options.""" + + async def test_saves_data_as_options(self): + flow = _make_options_flow({CONF_CALENDAR_ENTITY: "calendar.a"}) + await flow.async_step_init() + result = await flow.async_step_finalize() + assert result["title"] == "" + assert result["data"][CONF_CALENDAR_ENTITY] == "calendar.a" + + +class TestOptionsFlowShowForms: + """Each step mirrors the config flow's: shows its form when called with no input.""" + + async def test_calendars_shows_form(self): + flow = _make_options_flow({}) + await flow.async_step_init() + result = await flow.async_step_calendars() + assert result["step_id"] == "calendars" + + async def test_calendars_invalid_input_shows_errors(self): + flow = _make_options_flow({}) + flow.hass.states.get.return_value = None + await flow.async_step_init() + result = await flow.async_step_calendars({CONF_CALENDAR_ENTITY: "calendar.a", CONF_HOLIDAY_CALENDAR: "calendar.b"}) + assert result["step_id"] == "calendars" + assert result["errors"] + + async def test_mapping_shows_form_and_accepts_input(self): + flow = _make_options_flow({CONF_DAY_MODE_MAP: DEFAULT_DAY_MODE_MAP}) + await flow.async_step_init() + form = await flow.async_step_mapping() + assert form["step_id"] == "mapping" + result = await flow.async_step_mapping( + {"day_modes_section": {}, "defaults_section": {}, "thermostat_section": {}} + ) + assert result["step_id"] == "menu" + + async def test_schedulers_shows_form_and_accepts_input(self): + flow = _make_options_flow({CONF_DAY_MODE_MAP: "home:Home"}) + await flow.async_step_init() + form = await flow.async_step_schedulers() + assert form["step_id"] == "schedulers" + result = await flow.async_step_schedulers({"Home": "switch.a"}) + assert result["step_id"] == "menu" + assert flow._data[CONF_SCHEDULERS_PER_MODE] == {"Home": ["switch.a"]} + + async def test_covers_shows_form_and_accepts_input(self): + flow = _make_options_flow({}) + await flow.async_step_init() + form = await flow.async_step_covers() + assert form["step_id"] == "covers" + result = await flow.async_step_covers({CONF_COVER_ENTITIES: ["cover.a"]}) + assert result["step_id"] == "menu" + assert flow._data[CONF_COVER_ENTITIES] == ["cover.a"] + + async def test_daily_cover_schedule_shows_form(self): + flow = _make_options_flow({CONF_DAY_MODE_MAP: "home:Home"}) + await flow.async_step_init() + form = await flow.async_step_daily_cover_schedule() + assert form["step_id"] == "daily_cover_schedule" diff --git a/tests/test_coordinator_features.py b/tests/test_coordinator_features.py index c00f31f..cfc7282 100644 --- a/tests/test_coordinator_features.py +++ b/tests/test_coordinator_features.py @@ -841,15 +841,18 @@ def test_next_mode_at_allday_not_shifted(self): # --------------------------------------------------------------------------- class TestCoverHeatControl: - """Verify CoverManager.async_check_heat_protection closes covers when conditions are met.""" + """Verify CoverManager.async_check_heat_protection closes covers when conditions are met. - def _make_entry(self, cover_entities=None, temp_sensor="sensor.temp", threshold=30.0, time_start="08:00:00", time_end="20:00:00", cover_action=None, my_button=None): + Heat protection's active window is derived from cover_open_time/daily_close_time + (normally computed by Daily Cover Schedule) — tests set these directly on the + CoverManager instance rather than going through async_compute_daily_schedule. + """ + + def _make_entry(self, cover_entities=None, temp_sensor="sensor.temp", threshold=30.0, cover_action=None, my_button=None): from custom_components.homeshift.const import ( CONF_COVER_ENTITIES, CONF_COVER_TEMP_SENSOR, CONF_COVER_TEMP_THRESHOLD, - CONF_COVER_TIME_START, - CONF_COVER_TIME_END, CONF_COVER_ACTION, CONF_COVER_MY_BUTTON, ) @@ -859,8 +862,6 @@ def _make_entry(self, cover_entities=None, temp_sensor="sensor.temp", threshold= CONF_COVER_ENTITIES: cover_entities or ["cover.volet_salon"], CONF_COVER_TEMP_SENSOR: temp_sensor, CONF_COVER_TEMP_THRESHOLD: threshold, - CONF_COVER_TIME_START: time_start, - CONF_COVER_TIME_END: time_end, } if cover_action is not None: entry.options[CONF_COVER_ACTION] = cover_action @@ -868,6 +869,12 @@ def _make_entry(self, cover_entities=None, temp_sensor="sensor.temp", threshold= entry.options[CONF_COVER_MY_BUTTON] = my_button return entry + def _make_coordinator(self, hass, entry, open_time="08:00", close_time="20:00"): + coordinator = HomeShiftCoordinator(hass, entry) + coordinator._cover_manager.cover_open_time = open_time + coordinator._cover_manager.daily_close_time = close_time + return coordinator + def _temp_state(self, temperature: float): state = MagicMock() state.state = str(temperature) @@ -877,7 +884,7 @@ def test_close_cover_called_when_hot_and_in_window(self): """cover.close_cover is called by default when temperature > threshold inside the window.""" hass = make_mock_hass() hass.services.async_call = AsyncMock() - entry = self._make_entry(threshold=30.0, time_start="08:00:00", time_end="20:00:00") + entry = self._make_entry(threshold=30.0) def _get_state(entity_id): if entity_id == "sensor.temp": @@ -886,7 +893,7 @@ def _get_state(entity_id): hass.states.get.side_effect = _get_state - coordinator = HomeShiftCoordinator(hass, entry) + coordinator = self._make_coordinator(hass, entry, open_time="08:00", close_time="20:00") now = datetime(2026, 7, 1, 14, 0, 0) asyncio.get_event_loop().run_until_complete( @@ -903,7 +910,7 @@ def test_stop_cover_called_when_configured_and_hot(self): """cover.stop_cover is called when configured explicitly and temperature > threshold.""" hass = make_mock_hass() hass.services.async_call = AsyncMock() - entry = self._make_entry(threshold=30.0, time_start="08:00:00", time_end="20:00:00", cover_action="stop_cover") + entry = self._make_entry(threshold=30.0, cover_action="stop_cover") def _get_state(entity_id): if entity_id == "sensor.temp": @@ -912,7 +919,7 @@ def _get_state(entity_id): hass.states.get.side_effect = _get_state - coordinator = HomeShiftCoordinator(hass, entry) + coordinator = self._make_coordinator(hass, entry, open_time="08:00", close_time="20:00") now = datetime(2026, 7, 1, 14, 0, 0) asyncio.get_event_loop().run_until_complete( @@ -931,8 +938,6 @@ def test_my_position_button_pressed_when_configured_and_hot(self): hass.services.async_call = AsyncMock() entry = self._make_entry( threshold=30.0, - time_start="08:00:00", - time_end="20:00:00", my_button="button.volet_salon_my_position", # cover_action deliberately left as default close_cover — button takes priority ) @@ -944,7 +949,7 @@ def _get_state(entity_id): hass.states.get.side_effect = _get_state - coordinator = HomeShiftCoordinator(hass, entry) + coordinator = self._make_coordinator(hass, entry, open_time="08:00", close_time="20:00") now = datetime(2026, 7, 1, 14, 0, 0) asyncio.get_event_loop().run_until_complete(coordinator._cover_manager.async_check_heat_protection(now)) @@ -961,8 +966,6 @@ def test_my_position_button_overrides_stop_cover_action(self): hass.services.async_call = AsyncMock() entry = self._make_entry( threshold=30.0, - time_start="08:00:00", - time_end="20:00:00", cover_action="stop_cover", my_button="button.volet_salon_my_position", ) @@ -974,7 +977,7 @@ def _get_state(entity_id): hass.states.get.side_effect = _get_state - coordinator = HomeShiftCoordinator(hass, entry) + coordinator = self._make_coordinator(hass, entry, open_time="08:00", close_time="20:00") now = datetime(2026, 7, 1, 14, 0, 0) asyncio.get_event_loop().run_until_complete(coordinator._cover_manager.async_check_heat_protection(now)) @@ -997,7 +1000,7 @@ def _get_state(entity_id): hass.states.get.side_effect = _get_state - coordinator = HomeShiftCoordinator(hass, entry) + coordinator = self._make_coordinator(hass, entry, open_time="08:00", close_time="20:00") now = datetime(2026, 7, 1, 14, 0, 0) asyncio.get_event_loop().run_until_complete( @@ -1010,7 +1013,7 @@ def test_no_call_outside_time_window(self): """cover.stop_cover is NOT called when current time is outside the window.""" hass = make_mock_hass() hass.services.async_call = AsyncMock() - entry = self._make_entry(threshold=30.0, time_start="08:00:00", time_end="18:00:00") + entry = self._make_entry(threshold=30.0) def _get_state(entity_id): if entity_id == "sensor.temp": @@ -1019,7 +1022,7 @@ def _get_state(entity_id): hass.states.get.side_effect = _get_state - coordinator = HomeShiftCoordinator(hass, entry) + coordinator = self._make_coordinator(hass, entry, open_time="08:00", close_time="18:00") # 19:00 — outside the 08:00–18:00 window now = datetime(2026, 7, 1, 19, 0, 0) @@ -1029,6 +1032,29 @@ def _get_state(entity_id): hass.services.async_call.assert_not_called() + def test_no_call_when_daily_schedule_not_configured(self): + """cover.close_cover is NOT called when cover_open_time/daily_close_time haven't been computed.""" + hass = make_mock_hass() + hass.services.async_call = AsyncMock() + entry = self._make_entry(threshold=30.0) + + def _get_state(entity_id): + if entity_id == "sensor.temp": + return self._temp_state(35.0) + return make_calendar_state(state="off") + + hass.states.get.side_effect = _get_state + + # Daily Cover Schedule not configured: cover_open_time/daily_close_time stay None + coordinator = HomeShiftCoordinator(hass, entry) + now = datetime(2026, 7, 1, 14, 0, 0) + + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_heat_protection(now) + ) + + hass.services.async_call.assert_not_called() + def test_no_call_when_no_cover_entities_configured(self): """cover.stop_cover is NOT called when no cover entities are configured.""" hass = make_mock_hass() @@ -1037,7 +1063,7 @@ def test_no_call_when_no_cover_entities_configured(self): entry = make_mock_entry() hass.states.get.return_value = make_calendar_state(state="off") - coordinator = HomeShiftCoordinator(hass, entry) + coordinator = self._make_coordinator(hass, entry, open_time="08:00", close_time="20:00") now = datetime(2026, 7, 1, 14, 0, 0) asyncio.get_event_loop().run_until_complete( @@ -1055,7 +1081,7 @@ def test_no_call_when_temp_sensor_unavailable(self): # Return None for the sensor hass.states.get.return_value = None - coordinator = HomeShiftCoordinator(hass, entry) + coordinator = self._make_coordinator(hass, entry, open_time="08:00", close_time="20:00") now = datetime(2026, 7, 1, 14, 0, 0) asyncio.get_event_loop().run_until_complete( @@ -1064,6 +1090,38 @@ def test_no_call_when_temp_sensor_unavailable(self): hass.services.async_call.assert_not_called() + def test_stays_closed_after_temperature_drops(self): + """Once closed for heat, the cover is left alone even if it cools back down the same day.""" + hass = make_mock_hass() + hass.services.async_call = AsyncMock() + entry = self._make_entry(threshold=30.0) + + temps = {"value": 35.0} + + def _get_state(entity_id): + if entity_id == "sensor.temp": + return self._temp_state(temps["value"]) + return make_calendar_state(state="off") + + hass.states.get.side_effect = _get_state + + coordinator = self._make_coordinator(hass, entry, open_time="08:00", close_time="20:00") + + # 11:00 — hot, closes + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_heat_protection(datetime(2026, 7, 1, 11, 0, 0)) + ) + assert coordinator._cover_manager._heat_closed is True + + # 14:00 — cooled down, but heat protection never reopens on its own + temps["value"] = 20.0 + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_check_heat_protection(datetime(2026, 7, 1, 14, 0, 0)) + ) + + hass.services.async_call.assert_called_once() # only the original close, nothing else + assert coordinator._cover_manager._heat_closed is True + # --------------------------------------------------------------------------- # Proactive forecast-based close @@ -1072,13 +1130,11 @@ def test_no_call_when_temp_sensor_unavailable(self): class TestCoverProactiveClose: """Verify CoverManager closes covers ahead of time based on the weather forecast.""" - def _make_entry(self, forecast_threshold=28.0, weather_entity="weather.home", time_start="08:35:00", time_end="18:00:00"): + def _make_entry(self, forecast_threshold=28.0, weather_entity="weather.home"): from custom_components.homeshift.const import ( CONF_COVER_ENTITIES, CONF_COVER_TEMP_SENSOR, CONF_COVER_TEMP_THRESHOLD, - CONF_COVER_TIME_START, - CONF_COVER_TIME_END, CONF_COVER_WEATHER_ENTITY, CONF_COVER_FORECAST_THRESHOLD, ) @@ -1088,8 +1144,6 @@ def _make_entry(self, forecast_threshold=28.0, weather_entity="weather.home", ti CONF_COVER_ENTITIES: ["cover.volet_salon"], CONF_COVER_TEMP_SENSOR: "sensor.temp", CONF_COVER_TEMP_THRESHOLD: 99.0, # keep the reactive path from firing in these tests - CONF_COVER_TIME_START: time_start, - CONF_COVER_TIME_END: time_end, } if weather_entity is not None: entry.options[CONF_COVER_WEATHER_ENTITY] = weather_entity @@ -1097,6 +1151,12 @@ def _make_entry(self, forecast_threshold=28.0, weather_entity="weather.home", ti entry.options[CONF_COVER_FORECAST_THRESHOLD] = forecast_threshold return entry + def _make_coordinator(self, hass, entry, open_time="08:35", close_time="18:00"): + coordinator = HomeShiftCoordinator(hass, entry) + coordinator._cover_manager.cover_open_time = open_time + coordinator._cover_manager.daily_close_time = close_time + return coordinator + def _forecast_side_effect(self, forecast_temp): def _side_effect(domain, service, data=None, **kwargs): if domain == "weather" and service == "get_forecasts": @@ -1104,14 +1164,14 @@ def _side_effect(domain, service, data=None, **kwargs): return None return _side_effect - def test_closes_when_forecast_exceeds_threshold_at_window_start(self): - """Covers close proactively when the forecast high exceeds the threshold at time_start.""" + def test_closes_when_forecast_exceeds_threshold_at_open_time(self): + """Covers close proactively when the forecast high exceeds the threshold at cover_open_time.""" hass = make_mock_hass() hass.services.async_call = AsyncMock(side_effect=self._forecast_side_effect(32.0)) - entry = self._make_entry(forecast_threshold=28.0, time_start="08:35:00") + entry = self._make_entry(forecast_threshold=28.0) hass.states.get.side_effect = lambda entity_id: self._temp_state(20.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") - coordinator = HomeShiftCoordinator(hass, entry) + coordinator = self._make_coordinator(hass, entry, open_time="08:35") now = datetime(2026, 7, 1, 8, 35, 0) asyncio.get_event_loop().run_until_complete( @@ -1123,16 +1183,16 @@ def test_closes_when_forecast_exceeds_threshold_at_window_start(self): close_calls = [c for c in calls if c.args[0] == "cover" and c.args[1] == "close_cover"] assert len(close_calls) == 1 assert "cover.volet_salon" in close_calls[0].args[2]["entity_id"] - assert coordinator._cover_manager._closed_date == now.date() + assert coordinator._cover_manager._heat_closed is True def test_no_close_when_forecast_below_threshold(self): """No cover action when the forecast high stays under the threshold.""" hass = make_mock_hass() hass.services.async_call = AsyncMock(side_effect=self._forecast_side_effect(24.0)) - entry = self._make_entry(forecast_threshold=28.0, time_start="08:35:00") + entry = self._make_entry(forecast_threshold=28.0) hass.states.get.side_effect = lambda entity_id: self._temp_state(20.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") - coordinator = HomeShiftCoordinator(hass, entry) + coordinator = self._make_coordinator(hass, entry, open_time="08:35") now = datetime(2026, 7, 1, 8, 35, 0) asyncio.get_event_loop().run_until_complete( @@ -1146,10 +1206,10 @@ def test_no_forecast_lookup_when_no_weather_entity_configured(self): """No weather.get_forecasts call when CONF_COVER_WEATHER_ENTITY is unset.""" hass = make_mock_hass() hass.services.async_call = AsyncMock(side_effect=self._forecast_side_effect(32.0)) - entry = self._make_entry(weather_entity=None, time_start="08:35:00") + entry = self._make_entry(weather_entity=None) hass.states.get.side_effect = lambda entity_id: self._temp_state(20.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") - coordinator = HomeShiftCoordinator(hass, entry) + coordinator = self._make_coordinator(hass, entry, open_time="08:35") now = datetime(2026, 7, 1, 8, 35, 0) asyncio.get_event_loop().run_until_complete( @@ -1158,14 +1218,14 @@ def test_no_forecast_lookup_when_no_weather_entity_configured(self): hass.services.async_call.assert_not_called() - def test_no_close_before_window_start(self): - """No forecast lookup yet if now is before CONF_COVER_TIME_START.""" + def test_no_close_before_open_time(self): + """No forecast lookup yet if now is before cover_open_time.""" hass = make_mock_hass() hass.services.async_call = AsyncMock(side_effect=self._forecast_side_effect(32.0)) - entry = self._make_entry(forecast_threshold=28.0, time_start="08:35:00") + entry = self._make_entry(forecast_threshold=28.0) hass.states.get.side_effect = lambda entity_id: self._temp_state(20.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") - coordinator = HomeShiftCoordinator(hass, entry) + coordinator = self._make_coordinator(hass, entry, open_time="08:35") now = datetime(2026, 7, 1, 7, 0, 0) asyncio.get_event_loop().run_until_complete( @@ -1178,10 +1238,10 @@ def test_runs_once_per_day(self): """A second check on the same day does not repeat the forecast lookup or the close.""" hass = make_mock_hass() hass.services.async_call = AsyncMock(side_effect=self._forecast_side_effect(32.0)) - entry = self._make_entry(forecast_threshold=28.0, time_start="08:35:00") + entry = self._make_entry(forecast_threshold=28.0) hass.states.get.side_effect = lambda entity_id: self._temp_state(20.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") - coordinator = HomeShiftCoordinator(hass, entry) + coordinator = self._make_coordinator(hass, entry, open_time="08:35") asyncio.get_event_loop().run_until_complete( coordinator._cover_manager.async_check_heat_protection(datetime(2026, 7, 1, 8, 35, 0)) @@ -1202,139 +1262,12 @@ def _temp_state(self, temperature: float): return state -# --------------------------------------------------------------------------- -# Automatic evening reopen -# --------------------------------------------------------------------------- - -class TestCoverEveningReopen: - """Verify CoverManager reopens covers it closed, once it has cooled down after the window.""" - - def _make_entry(self, evening_reopen_temp=26.0, time_end="18:00:00"): - from custom_components.homeshift.const import ( - CONF_COVER_ENTITIES, - CONF_COVER_TEMP_SENSOR, - CONF_COVER_TEMP_THRESHOLD, - CONF_COVER_TIME_START, - CONF_COVER_TIME_END, - CONF_COVER_EVENING_REOPEN_TEMP, - ) - - entry = make_mock_entry() - entry.options = { - CONF_COVER_ENTITIES: ["cover.volet_salon"], - CONF_COVER_TEMP_SENSOR: "sensor.temp", - CONF_COVER_TEMP_THRESHOLD: 99.0, # keep the reactive path from firing in these tests - CONF_COVER_TIME_START: "08:35:00", - CONF_COVER_TIME_END: time_end, - CONF_COVER_EVENING_REOPEN_TEMP: evening_reopen_temp, - } - return entry - - def _temp_state(self, temperature: float): - state = MagicMock() - state.state = str(temperature) - return state - - def _make_coordinator(self, hass, entry, closed_date): - coordinator = HomeShiftCoordinator(hass, entry) - coordinator._cover_manager._closed_date = closed_date - return coordinator - - def test_reopens_after_window_end_once_cooled(self): - """cover.open_cover is called once the sensor drops to/below the reopen temperature.""" - hass = make_mock_hass() - hass.services.async_call = AsyncMock() - entry = self._make_entry(evening_reopen_temp=26.0, time_end="18:00:00") - hass.states.get.side_effect = lambda entity_id: self._temp_state(25.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") - - now = datetime(2026, 7, 1, 20, 0, 0) - coordinator = self._make_coordinator(hass, entry, closed_date=now.date()) - - asyncio.get_event_loop().run_until_complete( - coordinator._cover_manager.async_check_heat_protection(now) - ) - - hass.services.async_call.assert_called_once() - call = hass.services.async_call.call_args - assert call.args[0] == "cover" - assert call.args[1] == "open_cover" - assert "cover.volet_salon" in call.args[2]["entity_id"] - assert coordinator._cover_manager._reopened_date == now.date() - - def test_no_reopen_while_still_hot(self): - """No reopen while the sensor is still above the reopen temperature.""" - hass = make_mock_hass() - hass.services.async_call = AsyncMock() - entry = self._make_entry(evening_reopen_temp=26.0, time_end="18:00:00") - hass.states.get.side_effect = lambda entity_id: self._temp_state(27.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") - - now = datetime(2026, 7, 1, 20, 0, 0) - coordinator = self._make_coordinator(hass, entry, closed_date=now.date()) - - asyncio.get_event_loop().run_until_complete( - coordinator._cover_manager.async_check_heat_protection(now) - ) - - hass.services.async_call.assert_not_called() - - def test_no_reopen_before_window_end(self): - """No reopen before CONF_COVER_TIME_END, even if already cool enough.""" - hass = make_mock_hass() - hass.services.async_call = AsyncMock() - entry = self._make_entry(evening_reopen_temp=26.0, time_end="18:00:00") - hass.states.get.side_effect = lambda entity_id: self._temp_state(20.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") - - now = datetime(2026, 7, 1, 15, 0, 0) - coordinator = self._make_coordinator(hass, entry, closed_date=now.date()) - - asyncio.get_event_loop().run_until_complete( - coordinator._cover_manager.async_check_heat_protection(now) - ) - - hass.services.async_call.assert_not_called() - - def test_no_reopen_when_nothing_closed_today(self): - """No reopen when the automation never closed a cover today.""" - hass = make_mock_hass() - hass.services.async_call = AsyncMock() - entry = self._make_entry(evening_reopen_temp=26.0, time_end="18:00:00") - hass.states.get.side_effect = lambda entity_id: self._temp_state(20.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") - - now = datetime(2026, 7, 1, 20, 0, 0) - coordinator = self._make_coordinator(hass, entry, closed_date=None) - - asyncio.get_event_loop().run_until_complete( - coordinator._cover_manager.async_check_heat_protection(now) - ) - - hass.services.async_call.assert_not_called() - - def test_reopens_once_per_day(self): - """A second check the same evening does not reopen a second time.""" - hass = make_mock_hass() - hass.services.async_call = AsyncMock() - entry = self._make_entry(evening_reopen_temp=26.0, time_end="18:00:00") - hass.states.get.side_effect = lambda entity_id: self._temp_state(20.0) if entity_id == "sensor.temp" else make_calendar_state(state="off") - - now = datetime(2026, 7, 1, 20, 0, 0) - coordinator = self._make_coordinator(hass, entry, closed_date=now.date()) - - asyncio.get_event_loop().run_until_complete( - coordinator._cover_manager.async_check_heat_protection(now) - ) - asyncio.get_event_loop().run_until_complete( - coordinator._cover_manager.async_check_heat_protection(datetime(2026, 7, 1, 21, 0, 0)) - ) - - hass.services.async_call.assert_called_once() - - # --------------------------------------------------------------------------- # Cover manager state persistence (async_restore_state / _async_save_state) # --------------------------------------------------------------------------- class TestCoverPersistence: - """Verify CoverManager persists and restores its once-per-day action dates.""" + """Verify CoverManager persists and restores its cover-automation state.""" def _make_store(self, stored_data: dict | None) -> MagicMock: """Return a mock Store that returns stored_data on async_load.""" @@ -1343,24 +1276,22 @@ def _make_store(self, stored_data: dict | None) -> MagicMock: store.async_save = AsyncMock() return store - def test_restore_state_sets_all_three_dates(self): - """async_restore_state() parses all three persisted ISO dates.""" + def test_restore_state_sets_heat_closed_and_proactive_date(self): + """async_restore_state() restores heat_closed and proactive_checked_date.""" hass = make_mock_hass() coordinator = HomeShiftCoordinator(hass, make_mock_entry()) mock_store = self._make_store( { + "heat_closed": True, "proactive_checked_date": "2026-07-01", - "closed_date": "2026-07-01", - "reopened_date": None, } ) coordinator._cover_manager._store = mock_store asyncio.get_event_loop().run_until_complete(coordinator._cover_manager.async_restore_state()) + assert coordinator._cover_manager._heat_closed is True assert coordinator._cover_manager._proactive_checked_date == date(2026, 7, 1) - assert coordinator._cover_manager._closed_date == date(2026, 7, 1) - assert coordinator._cover_manager._reopened_date is None def test_restore_state_sets_daily_schedule_dates(self): """async_restore_state() also restores the daily-schedule action dates.""" @@ -1377,7 +1308,7 @@ def test_restore_state_sets_daily_schedule_dates(self): assert coordinator._cover_manager._daily_closed_date == date(2026, 6, 30) def test_restore_state_no_stored_data_leaves_defaults(self): - """async_restore_state() leaves all dates None when storage is empty.""" + """async_restore_state() leaves defaults when storage is empty.""" hass = make_mock_hass() coordinator = HomeShiftCoordinator(hass, make_mock_entry()) mock_store = self._make_store(None) @@ -1385,8 +1316,7 @@ def test_restore_state_no_stored_data_leaves_defaults(self): asyncio.get_event_loop().run_until_complete(coordinator._cover_manager.async_restore_state()) - assert coordinator._cover_manager._closed_date is None - assert coordinator._cover_manager._reopened_date is None + assert coordinator._cover_manager._heat_closed is False assert coordinator._cover_manager._proactive_checked_date is None def test_restore_state_handles_load_error_gracefully(self): @@ -1399,16 +1329,14 @@ def test_restore_state_handles_load_error_gracefully(self): # Should not raise asyncio.get_event_loop().run_until_complete(coordinator._cover_manager.async_restore_state()) - assert coordinator._cover_manager._closed_date is None + assert coordinator._cover_manager._heat_closed is False def test_save_state_called_after_reactive_close(self): - """_async_save_state() persists closed_date after a reactive close.""" + """_async_save_state() persists heat_closed=True after a reactive close.""" from custom_components.homeshift.const import ( CONF_COVER_ENTITIES, CONF_COVER_TEMP_SENSOR, CONF_COVER_TEMP_THRESHOLD, - CONF_COVER_TIME_START, - CONF_COVER_TIME_END, ) hass = make_mock_hass() @@ -1418,14 +1346,14 @@ def test_save_state_called_after_reactive_close(self): CONF_COVER_ENTITIES: ["cover.volet_salon"], CONF_COVER_TEMP_SENSOR: "sensor.temp", CONF_COVER_TEMP_THRESHOLD: 30.0, - CONF_COVER_TIME_START: "08:00:00", - CONF_COVER_TIME_END: "20:00:00", } temp_state = MagicMock() temp_state.state = "35.0" hass.states.get.side_effect = lambda entity_id: temp_state if entity_id == "sensor.temp" else make_calendar_state(state="off") coordinator = HomeShiftCoordinator(hass, entry) + coordinator._cover_manager.cover_open_time = "08:00" + coordinator._cover_manager.daily_close_time = "20:00" mock_store = MagicMock() mock_store.async_save = AsyncMock() coordinator._cover_manager._store = mock_store @@ -1436,7 +1364,7 @@ def test_save_state_called_after_reactive_close(self): mock_store.async_save.assert_called_once() saved = mock_store.async_save.call_args[0][0] - assert saved["closed_date"] == "2026-07-01" + assert saved["heat_closed"] is True # --------------------------------------------------------------------------- @@ -1446,21 +1374,24 @@ def test_save_state_called_after_reactive_close(self): class TestIsHeatProtectionActive: """Verify CoverManager.is_heat_protection_active returns correct bool/None values.""" - def _make_entry(self, threshold=30.0, time_start="08:00:00", time_end="20:00:00"): + def _make_entry(self, threshold=30.0): from custom_components.homeshift.const import ( CONF_COVER_ENTITIES, CONF_COVER_TEMP_SENSOR, CONF_COVER_TEMP_THRESHOLD, - CONF_COVER_TIME_START, CONF_COVER_TIME_END, ) entry = make_mock_entry() entry.options = { CONF_COVER_ENTITIES: ["cover.volet_salon"], CONF_COVER_TEMP_SENSOR: "sensor.temp", CONF_COVER_TEMP_THRESHOLD: threshold, - CONF_COVER_TIME_START: time_start, - CONF_COVER_TIME_END: time_end, } return entry + def _make_coordinator(self, hass, entry, open_time="08:00", close_time="20:00"): + coordinator = HomeShiftCoordinator(hass, entry) + coordinator._cover_manager.cover_open_time = open_time + coordinator._cover_manager.daily_close_time = close_time + return coordinator + def _temp_state(self, temperature: float): state = MagicMock() state.state = str(temperature) @@ -1470,7 +1401,7 @@ def test_returns_true_when_hot_and_in_window(self): """Returns True when temperature > threshold inside the window.""" hass = make_mock_hass() hass.states.get.side_effect = lambda eid: self._temp_state(35.0) if eid == "sensor.temp" else make_calendar_state(state="off") - coordinator = HomeShiftCoordinator(hass, self._make_entry()) + coordinator = self._make_coordinator(hass, self._make_entry()) result = coordinator._cover_manager.is_heat_protection_active(datetime(2026, 7, 1, 14, 0, 0)) @@ -1480,7 +1411,7 @@ def test_returns_false_when_cool_and_in_window(self): """Returns False when temperature <= threshold (within window).""" hass = make_mock_hass() hass.states.get.side_effect = lambda eid: self._temp_state(25.0) if eid == "sensor.temp" else make_calendar_state(state="off") - coordinator = HomeShiftCoordinator(hass, self._make_entry()) + coordinator = self._make_coordinator(hass, self._make_entry()) result = coordinator._cover_manager.is_heat_protection_active(datetime(2026, 7, 1, 14, 0, 0)) @@ -1490,7 +1421,7 @@ def test_returns_false_when_outside_window(self): """Returns False when current time is outside the active window.""" hass = make_mock_hass() hass.states.get.side_effect = lambda eid: self._temp_state(40.0) if eid == "sensor.temp" else make_calendar_state(state="off") - coordinator = HomeShiftCoordinator(hass, self._make_entry(time_start="08:00:00", time_end="18:00:00")) + coordinator = self._make_coordinator(hass, self._make_entry(), open_time="08:00", close_time="18:00") result = coordinator._cover_manager.is_heat_protection_active(datetime(2026, 7, 1, 19, 0, 0)) @@ -1500,7 +1431,17 @@ def test_returns_none_when_not_configured(self): """Returns None when no cover entities or temp sensor configured.""" hass = make_mock_hass() hass.states.get.return_value = make_calendar_state(state="off") - coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + coordinator = self._make_coordinator(hass, make_mock_entry()) + + result = coordinator._cover_manager.is_heat_protection_active(datetime(2026, 7, 1, 14, 0, 0)) + + assert result is None + + def test_returns_none_when_daily_schedule_not_configured(self): + """Returns None when cover_open_time/daily_close_time haven't been computed.""" + hass = make_mock_hass() + hass.states.get.side_effect = lambda eid: self._temp_state(35.0) if eid == "sensor.temp" else make_calendar_state(state="off") + coordinator = HomeShiftCoordinator(hass, self._make_entry()) # open_time/close_time left None result = coordinator._cover_manager.is_heat_protection_active(datetime(2026, 7, 1, 14, 0, 0)) @@ -1510,7 +1451,7 @@ def test_returns_none_when_sensor_unavailable(self): """Returns None when the temperature sensor is not found in HA state.""" hass = make_mock_hass() hass.states.get.return_value = None - coordinator = HomeShiftCoordinator(hass, self._make_entry()) + coordinator = self._make_coordinator(hass, self._make_entry()) result = coordinator._cover_manager.is_heat_protection_active(datetime(2026, 7, 1, 14, 0, 0)) @@ -1522,7 +1463,7 @@ def test_returns_none_when_sensor_has_invalid_state(self): bad_state = MagicMock() bad_state.state = "unavailable" hass.states.get.side_effect = lambda eid: bad_state if eid == "sensor.temp" else make_calendar_state(state="off") - coordinator = HomeShiftCoordinator(hass, self._make_entry()) + coordinator = self._make_coordinator(hass, self._make_entry()) result = coordinator._cover_manager.is_heat_protection_active(datetime(2026, 7, 1, 14, 0, 0)) @@ -1532,7 +1473,7 @@ def test_exactly_at_threshold_returns_false(self): """Returns False when temperature == threshold (not strictly above).""" hass = make_mock_hass() hass.states.get.side_effect = lambda eid: self._temp_state(30.0) if eid == "sensor.temp" else make_calendar_state(state="off") - coordinator = HomeShiftCoordinator(hass, self._make_entry(threshold=30.0)) + coordinator = self._make_coordinator(hass, self._make_entry(threshold=30.0)) result = coordinator._cover_manager.is_heat_protection_active(datetime(2026, 7, 1, 14, 0, 0)) From c574462211a4cb7044e6bc39d30b0c63cb6de09d Mon Sep 17 00:00:00 2001 From: Gamso Date: Sat, 18 Jul 2026 22:06:05 +0200 Subject: [PATCH 4/5] fix: fire cover open/close exactly on time, use blocking service calls, allow closing before sunset, fix heat-protection/schedule ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Run Daily Cover Schedule's check before heat protection's in every poll cycle: heat protection's proactive close and the daily schedule's morning open both key off cover_open_time, so with the old order a hot-forecast proactive close was immediately undone by the group's own open action firing right after in the same cycle - Add CoverManager.open_datetime()/close_datetime() and coordinator._schedule_cover_timers(): one-shot timers (async_track_point_in_time, same mechanism already used for next-mode changes) scheduled whenever today's times are computed, so open/close fire at the scheduled minute instead of waiting up to SCAN_INTERVAL_MINUTES for the next poll. The periodic poll stays as an idempotent fallback/catch-up; timers are cancelled on unload - Switch every cover/button service call (daily open, daily close, heat-protection close, My button) from blocking=False to blocking=True — a fire-and-forget call swallows failures in HA's core log instead of surfacing them, and left _daily_closed_date/_daily_opened_date marked done even when the underlying call failed, silently skipping the retry for the rest of the day - Allow the Close Offset to be negative: covers can now close before sunset (e.g. -10) as well as after (e.g. +10) — the underlying timedelta math already supported it, only the config_flow NumberSelector's min=0 floor was blocking it - Add tests: open_datetime/close_datetime, cover timer scheduling/cancellation, check-order coverage for both the timer callback and the periodic poll path, and a negative close-offset case Co-Authored-By: Claude Sonnet 5 --- README.md | 6 +- custom_components/homeshift/__init__.py | 3 + custom_components/homeshift/config_flow.py | 2 +- custom_components/homeshift/const.py | 1 + custom_components/homeshift/coordinator.py | 80 +++++- custom_components/homeshift/cover_manager.py | 31 ++- .../homeshift/translations/en.json | 8 +- .../homeshift/translations/fr.json | 8 +- tests/test_coordinator_features.py | 246 ++++++++++++++++++ 9 files changed, 365 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 2b6f1e3..52c06eb 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ All parameters can be changed at any time via **Settings → Devices & Services | **Daily Cover Entities** | — | Cover entity/group opened and closed daily (optional, separate from Cover Entities above); Cover Heat Protection's active window is derived from this | | **Open Time — *(per day mode)*** | `08:30` | One field per day mode: `sunrise`, `skip`, or a custom `HH:MM` value | | **Earliest Open Time** | `07:00` | Floor time used when a day mode's Open Time is `sunrise` | -| **Close Offset After Sunset** | `10 min` | Covers close this many minutes after sunset, every day, for every mode | +| **Close Offset From Sunset** | `10 min` | Covers close this many minutes relative to sunset, every day, for every mode. Positive = after sunset, negative = before (e.g. `-10` = 10 min before sunset) | --- @@ -284,9 +284,9 @@ Once per day, shortly after midnight, HomeShift computes: - a custom `HH:MM` value — a fixed clock time Day modes sharing the same value effectively form a batch (e.g. both `Work` and `Remote` set to `sunrise`). A day mode with no value configured falls back to `08:30`. There's no separate "skip modes" list — set a mode's Open Time to `skip` directly. -- **Close time** — today's sunset plus **Close Offset After Sunset**, always, for every day mode (closing is not mode-dependent) +- **Close time** — today's sunset plus/minus **Close Offset From Sunset**, always, for every day mode (closing is not mode-dependent) -Then, on every coordinator poll, it opens the covers once now reaches the open time, and closes them once now reaches the close time — each action firing at most once per calendar day. Timing resolution matches the poll interval (5 minutes by default), not the exact minute. +A one-shot timer fires the open/close action at the exact scheduled minute; the periodic coordinator poll (5 minutes by default) acts as a fallback in case the timer is missed (e.g. a HA restart). Each action fires at most once per calendar day. **`sensor.cover_open_time`** and **`sensor.cover_close_time`** reflect today's computed times, so you can display them on your dashboard. diff --git a/custom_components/homeshift/__init__.py b/custom_components/homeshift/__init__.py index 7d7474d..29c38a0 100644 --- a/custom_components/homeshift/__init__.py +++ b/custom_components/homeshift/__init__.py @@ -56,6 +56,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Cancel the next-mode timer when the entry is unloaded entry.async_on_unload(coordinator.async_cancel_next_mode_timer) + # Cancel the cover open/close timers when the entry is unloaded + entry.async_on_unload(coordinator.async_cancel_cover_timers) + # React immediately when the temperature sensor changes (no need to wait for the poll) entry.async_on_unload(coordinator._cover_manager.async_setup_listeners()) diff --git a/custom_components/homeshift/config_flow.py b/custom_components/homeshift/config_flow.py index a5b3897..0b2368e 100644 --- a/custom_components/homeshift/config_flow.py +++ b/custom_components/homeshift/config_flow.py @@ -407,7 +407,7 @@ def _daily_cover_schema(hass, data: dict[str, Any]) -> vol.Schema: default=data.get(CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, DEFAULT_DAILY_COVER_CLOSE_OFFSET_MINUTES), ): selector.NumberSelector( selector.NumberSelectorConfig( - min=0, + min=-120, max=120, step=1, mode=selector.NumberSelectorMode.BOX, diff --git a/custom_components/homeshift/const.py b/custom_components/homeshift/const.py index b5c8577..b2d9b2c 100644 --- a/custom_components/homeshift/const.py +++ b/custom_components/homeshift/const.py @@ -69,6 +69,7 @@ # automatically that day), or a fixed 'HH:MM' time. A mode key missing from # the map falls back to DEFAULT_DAILY_COVER_OPEN_TIME. CONF_DAILY_COVER_OPEN_TIME_MAP = "daily_cover_open_time_map" +# Minutes relative to sunset — negative closes before sunset, positive after. CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES = "daily_cover_close_offset_minutes" DEFAULT_DAILY_COVER_OPEN_TIME = "08:30" diff --git a/custom_components/homeshift/coordinator.py b/custom_components/homeshift/coordinator.py index 1ce1343..2bab12f 100644 --- a/custom_components/homeshift/coordinator.py +++ b/custom_components/homeshift/coordinator.py @@ -168,6 +168,12 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: # One-shot timer scheduled to fire at _next_mode_at; None when no timer is pending self._cancel_next_mode_timer: Callable[[], None] | None = None + # One-shot timers so the daily cover open/close (and heat protection's + # forecast check tied to open time) fire exactly on time instead of + # waiting for the next periodic poll; None when no timer is pending. + self._cancel_cover_open_timer: Callable[[], None] | None = None + self._cancel_cover_close_timer: Callable[[], None] | None = None + @property def _config(self) -> dict: """Return merged config: entry.data overridden by entry.options.""" @@ -260,6 +266,67 @@ def async_cancel_next_mode_timer(self) -> None: self._cancel_next_mode_timer() self._cancel_next_mode_timer = None + @callback + def _schedule_cover_timers(self) -> None: + """Schedule one-shot timers so cover open/close fire exactly on time. + + Without this, the daily open/close (and heat protection's proactive + forecast check, which is tied to open time) would wait for the next + ~SCAN_INTERVAL_MINUTES poll, firing up to that long after the + scheduled time. Called whenever today's cover_open_time/ + daily_close_time are (re)computed (see async_compute_daily_schedule). + If a target time has already passed today (e.g. HA restarted after + it), the timer fires almost immediately — harmless, since the checks + it triggers are idempotent no-ops once already handled for the day. + """ + if self._cancel_cover_open_timer is not None: + self._cancel_cover_open_timer() + self._cancel_cover_open_timer = None + if self._cancel_cover_close_timer is not None: + self._cancel_cover_close_timer() + self._cancel_cover_close_timer = None + + now = dt_util.now() + + @callback + def _on_cover_timer(_now: datetime) -> None: + self.hass.async_create_task(self._async_run_cover_checks()) + + open_at = self._cover_manager.open_datetime(now) + if open_at is not None: + self._cancel_cover_open_timer = async_track_point_in_time(self.hass, _on_cover_timer, open_at) + _LOGGER.debug("Scheduled cover open timer at %s", open_at) + + close_at = self._cover_manager.close_datetime(now) + if close_at is not None: + self._cancel_cover_close_timer = async_track_point_in_time(self.hass, _on_cover_timer, close_at) + _LOGGER.debug("Scheduled cover close timer at %s", close_at) + + async def _async_run_cover_checks(self) -> None: + """Run the daily cover schedule and heat protection checks immediately. + + Called by the precise open/close timers scheduled in + _schedule_cover_timers, in the same order as the periodic poll + (daily schedule first, so a heat-protection close isn't undone by + the group's own open action firing in the same cycle). + """ + now = dt_util.now() + await self._cover_manager.async_check_daily_schedule(now) + await self._cover_manager.async_check_heat_protection(now) + + @callback + def async_cancel_cover_timers(self) -> None: + """Cancel the pending cover open/close timers, if any. + + Called on integration unload to prevent stale timer callbacks. + """ + if self._cancel_cover_open_timer is not None: + self._cancel_cover_open_timer() + self._cancel_cover_open_timer = None + if self._cancel_cover_close_timer is not None: + self._cancel_cover_close_timer() + self._cancel_cover_close_timer = None + def _log_next_mode_prediction(self, context: str) -> None: """Log the currently computed next-mode prediction at INFO level.""" next_mode_at = self._next_mode_at.isoformat() if self._next_mode_at else None @@ -642,8 +709,10 @@ async def _async_update_data(self) -> dict: _LOGGER.info("New calendar day (%s), resetting today_type", today) self._today_type = EVENT_NONE self._today_date = today - # Compute today's native daily cover open/close times + # Compute today's native daily cover open/close times, then + # schedule precise timers so they fire exactly on time. await self._cover_manager.async_compute_daily_schedule(now, self.day_mode_key) + self._schedule_cover_timers() if calendar_state.state == "on": event_message = calendar_state.attributes.get("message", "") @@ -736,12 +805,15 @@ async def _async_update_data(self) -> dict: context="Next-mode prediction updated", ) + # Native daily cover open/close schedule — runs first so the morning + # group open is never undone by a heat-protection action that fires + # at the same cover_open_time (the heat-protected cover is typically + # also a member of the daily-schedule cover group). + await self._cover_manager.async_check_daily_schedule(now) + # Cover heat protection — close covers if temperature exceeds threshold in window await self._cover_manager.async_check_heat_protection(now) - # Native daily cover open/close schedule - await self._cover_manager.async_check_daily_schedule(now) - return self._build_result() def _build_result(self) -> dict: diff --git a/custom_components/homeshift/cover_manager.py b/custom_components/homeshift/cover_manager.py index 3ac3941..e1b6b41 100644 --- a/custom_components/homeshift/cover_manager.py +++ b/custom_components/homeshift/cover_manager.py @@ -213,7 +213,7 @@ async def _async_apply_cover_action(self, cover_entities: list[str]) -> None: "button", "press", {"entity_id": my_button}, - blocking=False, + blocking=True, ) return @@ -230,7 +230,7 @@ async def _async_apply_cover_action(self, cover_entities: list[str]) -> None: "cover", cover_action, {"entity_id": cover_entities}, - blocking=False, + blocking=True, ) async def _async_check_proactive_close(self, now: datetime, cover_entities: list[str]) -> None: @@ -445,6 +445,29 @@ async def async_compute_daily_schedule(self, now: datetime, day_mode_key: str | day_mode_key, ) + def open_datetime(self, now: datetime) -> datetime | None: + """Return today's computed cover_open_time combined with now's date/tz. + + Lets the coordinator schedule a precise one-shot timer instead of + relying solely on the periodic poll. Returns None when not + configured (or today's mode resolved to 'skip'). + """ + open_time = _parse_time_str(self.cover_open_time) if self.cover_open_time else None + if open_time is None: + return None + return now.replace(hour=open_time.hour, minute=open_time.minute, second=0, microsecond=0) + + def close_datetime(self, now: datetime) -> datetime | None: + """Return today's computed daily_close_time combined with now's date/tz. + + Lets the coordinator schedule a precise one-shot timer instead of + relying solely on the periodic poll. Returns None when not configured. + """ + close_time = _parse_time_str(self.daily_close_time) if self.daily_close_time else None + if close_time is None: + return None + return now.replace(hour=close_time.hour, minute=close_time.minute, second=0, microsecond=0) + async def async_check_daily_schedule(self, now: datetime) -> None: """Open covers at today's computed open time, close them at the computed close time. @@ -473,7 +496,7 @@ async def async_check_daily_schedule(self, now: datetime) -> None: self.cover_open_time, ) await self._hass.services.async_call( - "cover", "open_cover", {"entity_id": daily_entities}, blocking=False + "cover", "open_cover", {"entity_id": daily_entities}, blocking=True ) self._daily_opened_date = today await self._async_save_state() @@ -487,7 +510,7 @@ async def async_check_daily_schedule(self, now: datetime) -> None: self.daily_close_time, ) await self._hass.services.async_call( - "cover", "close_cover", {"entity_id": daily_entities}, blocking=False + "cover", "close_cover", {"entity_id": daily_entities}, blocking=True ) self._daily_closed_date = today await self._async_save_state() diff --git a/custom_components/homeshift/translations/en.json b/custom_components/homeshift/translations/en.json index 7a93c48..2f7cbf2 100644 --- a/custom_components/homeshift/translations/en.json +++ b/custom_components/homeshift/translations/en.json @@ -99,7 +99,7 @@ "data": { "daily_cover_entities": "Daily Cover Entities", "sunrise_earliest": "Earliest Allowed Opening Time", - "daily_cover_close_offset_minutes": "Close Offset After Sunset (minutes)", + "daily_cover_close_offset_minutes": "Close Offset From Sunset (minutes)", "daily_open_time_home": "Open Time — Home", "daily_open_time_work": "Open Time — Work", "daily_open_time_remote": "Open Time — Remote", @@ -108,7 +108,7 @@ "data_description": { "daily_cover_entities": "Cover entities (e.g. a cover group) to open and close on the daily schedule below.", "sunrise_earliest": "The sunrise-based opening time will be the later of actual sunrise and this value.", - "daily_cover_close_offset_minutes": "Covers close this many minutes after sunset, every day, regardless of day mode.", + "daily_cover_close_offset_minutes": "Covers close this many minutes relative to sunset, every day, regardless of day mode. Positive closes after sunset, negative closes before (e.g. -10 = 10 min before sunset).", "daily_open_time_home": "Opening time for the Home day mode: 'sunrise', 'skip', or a custom 'HH:MM' value.", "daily_open_time_work": "Opening time for the Work day mode: 'sunrise', 'skip', or a custom 'HH:MM' value.", "daily_open_time_remote": "Opening time for the Remote day mode: 'sunrise', 'skip', or a custom 'HH:MM' value.", @@ -229,7 +229,7 @@ "data": { "daily_cover_entities": "Daily Cover Entities", "sunrise_earliest": "Earliest Allowed Opening Time", - "daily_cover_close_offset_minutes": "Close Offset After Sunset (minutes)", + "daily_cover_close_offset_minutes": "Close Offset From Sunset (minutes)", "daily_open_time_home": "Open Time — Home", "daily_open_time_work": "Open Time — Work", "daily_open_time_remote": "Open Time — Remote", @@ -238,7 +238,7 @@ "data_description": { "daily_cover_entities": "Cover entities (e.g. a cover group) to open and close on the daily schedule below.", "sunrise_earliest": "The sunrise-based opening time will be the later of actual sunrise and this value.", - "daily_cover_close_offset_minutes": "Covers close this many minutes after sunset, every day, regardless of day mode.", + "daily_cover_close_offset_minutes": "Covers close this many minutes relative to sunset, every day, regardless of day mode. Positive closes after sunset, negative closes before (e.g. -10 = 10 min before sunset).", "daily_open_time_home": "Opening time for the Home day mode: 'sunrise', 'skip', or a custom 'HH:MM' value.", "daily_open_time_work": "Opening time for the Work day mode: 'sunrise', 'skip', or a custom 'HH:MM' value.", "daily_open_time_remote": "Opening time for the Remote day mode: 'sunrise', 'skip', or a custom 'HH:MM' value.", diff --git a/custom_components/homeshift/translations/fr.json b/custom_components/homeshift/translations/fr.json index 5c79356..73bd203 100644 --- a/custom_components/homeshift/translations/fr.json +++ b/custom_components/homeshift/translations/fr.json @@ -99,7 +99,7 @@ "data": { "daily_cover_entities": "Entités volets (planning quotidien)", "sunrise_earliest": "Heure d'ouverture minimale", - "daily_cover_close_offset_minutes": "Décalage de fermeture après le coucher du soleil (minutes)", + "daily_cover_close_offset_minutes": "Décalage de fermeture par rapport au coucher du soleil (minutes)", "daily_open_time_home": "Heure d'ouverture — Maison", "daily_open_time_work": "Heure d'ouverture — Travail", "daily_open_time_remote": "Heure d'ouverture — Télétravail", @@ -108,7 +108,7 @@ "data_description": { "daily_cover_entities": "Entités volets (par exemple un groupe) à ouvrir et fermer selon le planning ci-dessous.", "sunrise_earliest": "L'heure d'ouverture au lever du soleil sera la plus tardive entre le lever réel et cette valeur.", - "daily_cover_close_offset_minutes": "Les volets se ferment ce nombre de minutes après le coucher du soleil, tous les jours, quel que soit le mode.", + "daily_cover_close_offset_minutes": "Les volets se ferment ce nombre de minutes par rapport au coucher du soleil, tous les jours, quel que soit le mode. Positif = après le coucher, négatif = avant (ex: -10 = 10 min avant le coucher du soleil).", "daily_open_time_home": "Heure d'ouverture pour le mode Maison : 'sunrise', 'skip', ou une valeur personnalisée 'HH:MM'.", "daily_open_time_work": "Heure d'ouverture pour le mode Travail : 'sunrise', 'skip', ou une valeur personnalisée 'HH:MM'.", "daily_open_time_remote": "Heure d'ouverture pour le mode Télétravail : 'sunrise', 'skip', ou une valeur personnalisée 'HH:MM'.", @@ -229,7 +229,7 @@ "data": { "daily_cover_entities": "Entités volets (planning quotidien)", "sunrise_earliest": "Heure d'ouverture minimale", - "daily_cover_close_offset_minutes": "Décalage de fermeture après le coucher du soleil (minutes)", + "daily_cover_close_offset_minutes": "Décalage de fermeture par rapport au coucher du soleil (minutes)", "daily_open_time_home": "Heure d'ouverture — Maison", "daily_open_time_work": "Heure d'ouverture — Travail", "daily_open_time_remote": "Heure d'ouverture — Télétravail", @@ -238,7 +238,7 @@ "data_description": { "daily_cover_entities": "Entités volets (par exemple un groupe) à ouvrir et fermer selon le planning ci-dessous.", "sunrise_earliest": "L'heure d'ouverture au lever du soleil sera la plus tardive entre le lever réel et cette valeur.", - "daily_cover_close_offset_minutes": "Les volets se ferment ce nombre de minutes après le coucher du soleil, tous les jours, quel que soit le mode.", + "daily_cover_close_offset_minutes": "Les volets se ferment ce nombre de minutes par rapport au coucher du soleil, tous les jours, quel que soit le mode. Positif = après le coucher, négatif = avant (ex: -10 = 10 min avant le coucher du soleil).", "daily_open_time_home": "Heure d'ouverture pour le mode Maison : 'sunrise', 'skip', ou une valeur personnalisée 'HH:MM'.", "daily_open_time_work": "Heure d'ouverture pour le mode Travail : 'sunrise', 'skip', ou une valeur personnalisée 'HH:MM'.", "daily_open_time_remote": "Heure d'ouverture pour le mode Télétravail : 'sunrise', 'skip', ou une valeur personnalisée 'HH:MM'.", diff --git a/tests/test_coordinator_features.py b/tests/test_coordinator_features.py index cfc7282..a8d0b99 100644 --- a/tests/test_coordinator_features.py +++ b/tests/test_coordinator_features.py @@ -1586,6 +1586,17 @@ def test_close_time_is_sunset_plus_offset(self): assert coordinator._cover_manager.daily_close_time == "21:40" + def test_close_time_supports_negative_offset_before_sunset(self): + """A negative offset closes covers before sunset instead of after.""" + hass = make_mock_hass() + # sunset at 21:30 local (19:30 UTC + 2h) + hass.states.get.side_effect = lambda eid: self._sun_state(next_setting_iso="2026-07-01T19:30:00+00:00") if eid == "sun.sun" else None + entry = self._make_entry(open_time_map="away:skip", close_offset=-10) + + coordinator = self._run_compute(hass, entry, datetime(2026, 7, 1, 0, 5, 0), day_mode_key="away") + + assert coordinator._cover_manager.daily_close_time == "21:20" + def test_noop_when_no_daily_cover_entities_configured(self): """Does nothing when CONF_DAILY_COVER_ENTITIES is empty.""" hass = make_mock_hass() @@ -1836,3 +1847,238 @@ def _fake_track(h, cb, fire_at): hass.async_create_task.assert_called_once() assert coordinator._cancel_next_mode_timer is None + + +# --------------------------------------------------------------------------- +# Cover open/close datetime helpers +# --------------------------------------------------------------------------- + +class TestCoverOpenCloseDatetime: + """Verify CoverManager.open_datetime()/close_datetime() combine today's HH:MM with now's date/tz.""" + + def test_open_datetime_combines_time_with_now(self): + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + coordinator._cover_manager.cover_open_time = "08:30" + + result = coordinator._cover_manager.open_datetime(datetime(2026, 7, 1, 0, 5, 0)) + + assert result == datetime(2026, 7, 1, 8, 30, 0) + + def test_close_datetime_combines_time_with_now(self): + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + coordinator._cover_manager.daily_close_time = "21:26" + + result = coordinator._cover_manager.close_datetime(datetime(2026, 7, 1, 0, 5, 0)) + + assert result == datetime(2026, 7, 1, 21, 26, 0) + + def test_open_datetime_none_when_not_configured(self): + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + coordinator._cover_manager.cover_open_time = None + + assert coordinator._cover_manager.open_datetime(datetime(2026, 7, 1, 0, 5, 0)) is None + + def test_close_datetime_none_when_not_configured(self): + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + coordinator._cover_manager.daily_close_time = None + + assert coordinator._cover_manager.close_datetime(datetime(2026, 7, 1, 0, 5, 0)) is None + + def test_open_datetime_preserves_tzinfo(self): + from datetime import timezone, timedelta as tdelta + + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + coordinator._cover_manager.cover_open_time = "08:30" + now = datetime(2026, 7, 1, 0, 5, 0, tzinfo=timezone(tdelta(hours=2))) + + result = coordinator._cover_manager.open_datetime(now) + + assert result.tzinfo == now.tzinfo + + +# --------------------------------------------------------------------------- +# Cover open/close timer scheduling +# --------------------------------------------------------------------------- + +class TestCoverTimerScheduling: + """Verify precise one-shot timers fire cover open/close exactly on time (see _schedule_cover_timers). + + Without these, actions would wait for the next ~5-minute poll cycle. + """ + + def test_timers_scheduled_when_open_and_close_times_are_set(self): + """Both open and close timers are scheduled when both times are configured.""" + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + coordinator._cover_manager.cover_open_time = "08:30" + coordinator._cover_manager.daily_close_time = "21:26" + + open_cancel = MagicMock() + close_cancel = MagicMock() + + with ( + patch("custom_components.homeshift.coordinator.dt_util") as mock_dt, + patch( + "custom_components.homeshift.coordinator.async_track_point_in_time", + side_effect=[open_cancel, close_cancel], + ) as mock_track, + ): + mock_dt.now.return_value = datetime(2026, 7, 1, 0, 5, 0) + coordinator._schedule_cover_timers() + + assert mock_track.call_count == 2 + assert coordinator._cancel_cover_open_timer is open_cancel + assert coordinator._cancel_cover_close_timer is close_cancel + + def test_no_timer_scheduled_when_time_is_none(self): + """A None open/close time (e.g. mode resolved to 'skip') schedules no timer for it.""" + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + coordinator._cover_manager.cover_open_time = None + coordinator._cover_manager.daily_close_time = "21:26" + + with ( + patch("custom_components.homeshift.coordinator.dt_util") as mock_dt, + patch( + "custom_components.homeshift.coordinator.async_track_point_in_time", + return_value=MagicMock(), + ) as mock_track, + ): + mock_dt.now.return_value = datetime(2026, 7, 1, 0, 5, 0) + coordinator._schedule_cover_timers() + + assert mock_track.call_count == 1 + assert coordinator._cancel_cover_open_timer is None + assert coordinator._cancel_cover_close_timer is not None + + def test_previous_timers_cancelled_before_rescheduling(self): + """A second call cancels the previously scheduled timers first.""" + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + coordinator._cover_manager.cover_open_time = "08:30" + coordinator._cover_manager.daily_close_time = "21:26" + + first_open, first_close = MagicMock(), MagicMock() + second_open, second_close = MagicMock(), MagicMock() + + with ( + patch("custom_components.homeshift.coordinator.dt_util") as mock_dt, + patch( + "custom_components.homeshift.coordinator.async_track_point_in_time", + side_effect=[first_open, first_close, second_open, second_close], + ), + ): + mock_dt.now.return_value = datetime(2026, 7, 1, 0, 5, 0) + coordinator._schedule_cover_timers() + coordinator._schedule_cover_timers() + + first_open.assert_called_once() + first_close.assert_called_once() + assert coordinator._cancel_cover_open_timer is second_open + assert coordinator._cancel_cover_close_timer is second_close + + def test_cancel_clears_both_pending_timers(self): + """async_cancel_cover_timers calls both cancel functions and clears them.""" + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + open_cancel, close_cancel = MagicMock(), MagicMock() + coordinator._cancel_cover_open_timer = open_cancel + coordinator._cancel_cover_close_timer = close_cancel + + coordinator.async_cancel_cover_timers() + + open_cancel.assert_called_once() + close_cancel.assert_called_once() + assert coordinator._cancel_cover_open_timer is None + assert coordinator._cancel_cover_close_timer is None + + def test_cancel_is_no_op_when_no_timers(self): + """async_cancel_cover_timers is safe to call when no timer is pending.""" + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + coordinator.async_cancel_cover_timers() # must not raise + assert coordinator._cancel_cover_open_timer is None + assert coordinator._cancel_cover_close_timer is None + + def test_timer_fires_and_schedules_cover_checks(self): + """When a cover timer fires, _async_run_cover_checks is scheduled as a task.""" + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + coordinator._cover_manager.cover_open_time = "08:30" + + captured_callbacks = [] + + def _fake_track(h, cb, fire_at): + captured_callbacks.append(cb) + return MagicMock() + + with ( + patch("custom_components.homeshift.coordinator.dt_util") as mock_dt, + patch( + "custom_components.homeshift.coordinator.async_track_point_in_time", + side_effect=_fake_track, + ), + ): + mock_dt.now.return_value = datetime(2026, 7, 1, 0, 5, 0) + coordinator._schedule_cover_timers() + + assert len(captured_callbacks) == 1 + + hass.async_create_task = MagicMock() + captured_callbacks[0](datetime(2026, 7, 1, 8, 30, 0)) + + hass.async_create_task.assert_called_once() + + +class TestAsyncRunCoverChecks: + """Verify _async_run_cover_checks() runs the daily schedule before heat protection. + + Order matters: it prevents a proactive heat-protection close (which fires + at the same open_time) from being immediately undone by the daily + schedule's own group-open action firing right after in the same cycle. + """ + + def test_daily_schedule_runs_before_heat_protection(self): + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + + call_order = [] + coordinator._cover_manager.async_check_daily_schedule = AsyncMock( + side_effect=lambda now: call_order.append("daily_schedule") + ) + coordinator._cover_manager.async_check_heat_protection = AsyncMock( + side_effect=lambda now: call_order.append("heat_protection") + ) + + asyncio.get_event_loop().run_until_complete(coordinator._async_run_cover_checks()) + + assert call_order == ["daily_schedule", "heat_protection"] + + +class TestCoverCheckOrderInPeriodicPoll: + """Verify the periodic poll (_async_update_data) also checks the daily schedule before heat protection.""" + + def test_daily_schedule_runs_before_heat_protection_in_periodic_poll(self): + hass = make_mock_hass() + hass.states.get.return_value = make_calendar_state(state="off") + hass.services.async_call = AsyncMock(return_value={}) + coordinator = HomeShiftCoordinator(hass, make_mock_entry()) + + call_order = [] + coordinator._cover_manager.async_check_daily_schedule = AsyncMock( + side_effect=lambda now: call_order.append("daily_schedule") + ) + coordinator._cover_manager.async_check_heat_protection = AsyncMock( + side_effect=lambda now: call_order.append("heat_protection") + ) + + with patch("custom_components.homeshift.coordinator.dt_util") as mock_dt: + mock_dt.now.return_value = datetime(2026, 3, 11, 12, 0, 0) + asyncio.get_event_loop().run_until_complete(coordinator.async_update_data()) + + assert call_order == ["daily_schedule", "heat_protection"] From cf66ee8f8a30716bdf6f891b2b27b758900eaedc Mon Sep 17 00:00:00 2001 From: Gamso Date: Sun, 19 Jul 2026 21:52:42 +0200 Subject: [PATCH 5/5] Bump to 1.7.0 --- TODO.md | 107 ---------------------- custom_components/homeshift/manifest.json | 2 +- 2 files changed, 1 insertion(+), 108 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 3b72359..0000000 --- a/TODO.md +++ /dev/null @@ -1,107 +0,0 @@ -# TODO - Future Enhancements - -## High Priority - -### 1. Automatic Scheduler Management -- [ ] Implement auto-discovery of scheduler switches via entity registry -- [ ] Add support for tags/labels on schedulers -- [ ] Auto turn on/off schedulers based on current modes -- [ ] Add configuration UI for scheduler associations - -### 2. Climate Entity Control -- [ ] Implement automatic HVAC mode setting based on thermostat mode -- [ ] Add support for temperature presets -- [ ] Add climate entity selection in config flow -- [ ] Implement climate control logic in coordinator - -### 3. Enhanced Calendar Support -- [ ] Support multiple work calendars -- [ ] Add more event types (half-day, flexible hours, etc.) -- [ ] Add event priority/override system -- [ ] Better calendar event parsing - -## Medium Priority - -### 4. User Interface Improvements -- [ ] Add a dedicated panel/dashboard -- [ ] Add visual indicators for mode status -- [ ] Add quick mode change buttons -- [ ] Add calendar event preview - -### 5. Statistics & History -- [ ] Track mode changes over time -- [ ] Generate usage statistics -- [ ] Add history visualization -- [ ] Export reports - -### 6. Notifications -- [ ] Add configurable notifications for mode changes -- [ ] Add next day type notifications -- [ ] Support multiple notification channels -- [ ] Add notification templates - -### 7. Advanced Features -- [ ] Add manual override with timeout -- [ ] Add presence detection integration -- [ ] Add weather-based mode suggestions -- [ ] Add learning/AI suggestions - -## Low Priority - -### 8. Integration Improvements -- [ ] Add support for more calendar platforms -- [ ] Add integration with Google Calendar API directly -- [ ] Add iCal/CalDAV support improvements -- [ ] Add Outlook calendar support - -### 9. Testing -- [ ] Add unit tests -- [ ] Add integration tests -- [ ] Add test coverage reports -- [ ] Add automated testing in CI - -### 10. Documentation -- [ ] Add video tutorials -- [ ] Add animated GIFs for setup -- [ ] Translate documentation to more languages -- [ ] Add FAQ section - -## Nice to Have - -### 11. Multi-Home Support -- [ ] Support multiple homes/locations -- [ ] Support per-room/zone modes -- [ ] Add location-based automation - -### 12. Mobile App -- [ ] Add dedicated mobile app features -- [ ] Add widgets for quick mode changes -- [ ] Add geofencing support - -### 13. Voice Control -- [ ] Add Alexa skill -- [ ] Add Google Assistant action -- [ ] Add Siri shortcuts - -## Technical Debt - -- [ ] Add proper error handling for all edge cases -- [ ] Add retry logic for calendar API failures -- [ ] Optimize coordinator update frequency -- [ ] Add caching for calendar data -- [ ] Improve async/await usage -- [ ] Add type hints everywhere -- [ ] Improve code documentation - -## Community Requests - -Track user-requested features here: -- [Add specific community requests as they come in] - -## Notes - -- Prioritize based on user feedback -- Keep breaking changes to minimum -- Maintain backwards compatibility -- Follow Home Assistant best practices -- Update documentation with each feature diff --git a/custom_components/homeshift/manifest.json b/custom_components/homeshift/manifest.json index 1c80a76..c1bd43a 100644 --- a/custom_components/homeshift/manifest.json +++ b/custom_components/homeshift/manifest.json @@ -1,7 +1,7 @@ { "domain": "homeshift", "name": "HomeShift", - "version": "1.6.0", + "version": "1.7.0", "documentation": "https://github.com/Gamso/day_mode", "requirements": [], "dependencies": [],