From f31c21bd588cea8be5652d51621716961451486f Mon Sep 17 00:00:00 2001 From: Gamso Date: Sun, 20 Sep 2026 15:41:15 +0200 Subject: [PATCH 1/4] feat(covers): close the covers on the sun's elevation, and name those left up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the evening close, kept in one commit because they land in the same files and the same screens. **The closing time now comes from the sun's elevation.** "Sunset + N minutes" is a delay you have to guess at; degrees above the horizon are a light level, the value sun.sun already publishes. Covers close when the descending sun reaches the configured elevation. Degrees say nothing about clock time, so the form turns the setting into tonight's time from the instance's own location — "At -2°, that is 21:41 tonight." The minute offset is gone rather than kept alongside: two settings for one decision, with only one of them live, is a trap. The v3 -> v4 migration converts each entry's stored offset into the elevation it was landing on, averaged over the equinoxes and solstices and rounded to the half degree the form offers, so covers keep moving at the time they moved before. Measured with astral at 43.5N: sunset + 10 min is -2.5, sunset - 10 min is +1.5. The range offered (+-25) holds every value the old +-120 minutes could express, and an entry that never touched the offset is converted from the default it was silently running on. Default is -2°: that is where the old default of sunset + 10 min landed, at every season, so a fresh install behaves like the old one. The form refuses an elevation the sun never reaches at that location, checked against both solstices, instead of leaving the runtime to discover it at nightfall months later. What that rejects depends entirely on latitude: nothing at 43N, where the sun sweeps the whole range every day; +10 at 60N, where the midwinter sun stops at 6.6; everything above the polar circle in June. A rejected submission redisplays what was typed. The runtime keeps a fallback to plain sunset for what the form cannot check — a home that moved, an entry restored elsewhere — and the Cover Close Time sensor reports which of the two produced tonight's time. **binary_sensor.covers_left_open** raises the skipped-cover warning where it can be acted on. A cover whose window sensor reads open is left up and, until now, only mentioned in the log, which nobody reads. The entity is a device_class problem, on as soon as one cover was skipped, listing the covers and the sensor that blocked each of them so a notification can name them. It clears when the next day's schedule is computed, not when the window closes: closing the window does not bring the cover down, so the warning has to outlive its cause. The list is persisted, so a reboot the same evening does not silence it. Supporting changes: _covers_behind_an_open_window returns {cover: sensor} rather than a set, since the entity has to name the sensor and the callers only needed membership; _async_run_cover_checks pushes state to the entities, because the open/close timers fire between polls and the warning would otherwise show the previous value for up to five minutes; the astral lookups live at module level in cover_manager so the config flow and the migration can use them without a CoverManager, and go through get_astral_observer, since get_astral_location is deprecated for removal in HA 2027.7. 59 new tests (338 -> 397). Co-Authored-By: Claude Opus 5 --- README.md | 73 +++- custom_components/homeshift/__init__.py | 79 ++++ custom_components/homeshift/binary_sensor.py | 60 ++- custom_components/homeshift/config_flow.py | 128 +++++- custom_components/homeshift/const.py | 28 +- custom_components/homeshift/coordinator.py | 19 + custom_components/homeshift/cover_manager.py | 191 +++++++-- custom_components/homeshift/sensor.py | 25 +- .../homeshift/translations/en.json | 21 +- .../homeshift/translations/fr.json | 21 +- tests/test_config_flow.py | 232 ++++++++++- tests/test_coordinator_features.py | 383 ++++++++++++++++-- tests/test_init.py | 169 +++++++- tests/test_sensors.py | 146 +++++++ 14 files changed, 1459 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index bc834c9..e15fb53 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Automatic day-mode and thermostat-mode management for Home Assistant, driven by - [`sensor.next_mode_at`](#sensornext_mode_at) - [`sensor.cover_open_time`](#sensorcover_open_time) - [`sensor.cover_close_time`](#sensorcover_close_time) + - [`binary_sensor.covers_left_open`](#binary_sensorcovers_left_open) - [🛠️ Services](#️-services) - [`homeshift.refresh_schedulers`](#homeshiftrefresh_schedulers) - [`homeshift.sync_calendar`](#homeshiftsync_calendar) @@ -35,6 +36,7 @@ Automatic day-mode and thermostat-mode management for Home Assistant, driven by - [🗓️ Scheduler Integration](#️-scheduler-integration) - [Thermostat Tags](#thermostat-tags) - [🗓️ Daily Cover Schedule](#️-daily-cover-schedule) + - [When the Covers Close](#when-the-covers-close) - [Individual Covers](#individual-covers) - [☀️ Cover Heat Protection](#️-cover-heat-protection) - [Reactive Close](#reactive-close) @@ -155,12 +157,45 @@ Shows the cover opening time computed for today by the Daily Cover Schedule feat - **Only registered** when at least one cover is listed under **Individual Covers**. ### `sensor.cover_close_time` -Shows the daily cover closing time computed for today (today's sunset + offset) by the Daily Cover Schedule feature. +Shows the estimated daily cover closing time for today, computed by the Daily Cover Schedule feature from the [configured sun elevation](#when-the-covers-close). - **Type:** Sensor (text) - **Value:** `HH:MM` string (e.g. `21:40`), or `unknown` if not configured or not yet computed. +- **Attributes:** + - `sun_elevation` — the configured elevation the time was computed from + - `trigger` — `elevation` normally, `sunset` on a day the sun never reached it and the fallback stepped in - **Only registered** when at least one cover is listed under **Individual Covers**. +### `binary_sensor.covers_left_open` +Warns that tonight's close had to leave covers up, and names them. + +- **Type:** Binary sensor (`device_class: problem`) +- **Value:** `on` when at least one cover was skipped by the evening close, `off` otherwise. +- **Attributes:** + - `covers` — the entity ids left open, e.g. `["cover.volet_chambre"]` + - `window_sensors` — the sensor that blocked each one, e.g. `{"cover.volet_chambre": "binary_sensor.fenetre_chambre"}` + - `count`, `checked_on` — how many, and the day the close ran +- **Clears** when the next calendar day's schedule is computed, **not** when the window is closed — the cover stays up either way until someone acts on it. +- **Only registered** when at least one cover is listed under **Individual Covers**. +- **Survives a restart:** the list is persisted with the rest of the day's cover state. + +A notification automation can read the list straight out of the attributes: + +```yaml +automation: + - alias: Volets restés ouverts + triggers: + - trigger: state + entity_id: binary_sensor.covers_left_open + to: "on" + actions: + - action: notify.persistent_notification + data: + message: > + Volets non fermés ce soir : + {{ state_attr('binary_sensor.covers_left_open', 'covers') | join(', ') }} +``` + --- ## 🛠️ Services @@ -201,7 +236,7 @@ All parameters can be changed at any time via **Settings → Devices & Services | **Individual Covers** | — | The covers opened and closed daily, added one at a time, each with an optional window sensor and an optional My position button (separate from Cover Entities above); Cover Heat Protection's active window is derived from this schedule | | **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 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) | +| **Sun Elevation At Closing** | `-2°` | Covers close when the descending sun reaches this many degrees above the horizon, every day, for every mode — the same scale as `{{ state_attr('sun.sun', 'elevation') }}`. `0°` is sunset, negative is below the horizon | --- @@ -287,12 +322,42 @@ 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/minus **Close Offset From Sunset**, always, for every day mode (closing is not mode-dependent) +- **Close time** — when the setting sun reaches the configured elevation, always, for every day mode (closing is not mode-dependent): see below. 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. +### When the Covers Close + +The covers close when the descending sun reaches **Sun Elevation At Closing** degrees above the horizon — the value you would read from `{{ state_attr('sun.sun', 'elevation') }}`, on the same scale. You are setting a **light level**, not a delay: + +| Elevation | Roughly | Light | +| --------- | ------- | ----- | +| `+2°` | Sunset − ~15 min | The sun is still up | +| `0°` | Sunset | The sun touches the horizon | +| `-2°` *(default)* | Sunset + ~10 min | Dusk, still easy to see outside | +| `-4°` | Sunset + ~20-25 min | Room is dark | +| `-6°` | Sunset + ~35-40 min | End of civil twilight, artificial light needed | + +The "roughly" column is a mid-latitude approximation: the delay an elevation works out to shifts a little with latitude and season, which is the point — the light level does not. + +Degrees say nothing about when your covers will actually move, so the *Daily Cover Schedule* form turns the value into tonight's time, computed from your own location: + +``` +At -2°, that is 21:41 tonight. +``` + +`-2°` is the default because it is where the retired "sunset + 10 minutes" setting used to land, at every season. Pick `-4°` to wait until the room is genuinely dark (about a quarter of an hour later), or a positive value to close before the sun is down. + +The form refuses an elevation the sun never reaches at your location — a positive value that the midwinter sun stays below, or any value above the polar circle in June. At mid-latitude nothing in the range can be refused: the sun sweeps all of it every day of the year. Should it become unreachable anyway (a home that moved, an entry restored elsewhere), the covers close at plain sunset rather than staying up all night: + +``` +WARNING ... Daily cover schedule: the sun never reaches 8.0° on 2026-06-21 — closing at sunset instead +``` + +> **Upgrading from 1.7.x:** the evening close used to be "sunset ± N minutes". That setting is gone; the entry migration converts your offset into the elevation it was landing on, so the covers keep moving at the time they moved before. The conversion is logged. + ### Individual Covers The covers driven by the daily schedule are added **one at a time** from the *Individual Covers* menu: pick the cover, and optionally a **window opening sensor** (does that window stand open?) and a **My position button** (how should this cover close?). Adding a cover that is already in the list updates its settings instead of duplicating it. A cover group is a cover entity like any other, so driving the whole house through one group is simply a single entry. @@ -364,7 +429,9 @@ Whether the cover has already been closed by this automation today, and when the | 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 | +| Evening close driven by the sun's elevation | ✅ | See [When the Covers Close](#when-the-covers-close) | | Skip the evening close when a window is open | ✅ | See [Individual Covers](#individual-covers) | +| Warning entity listing the covers left open | ✅ | `binary_sensor.covers_left_open` | | Per-cover My position instead of a full close | ✅ | See [Individual Covers](#individual-covers) | | 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) | diff --git a/custom_components/homeshift/__init__.py b/custom_components/homeshift/__init__.py index 19e9ca0..87026f2 100644 --- a/custom_components/homeshift/__init__.py +++ b/custom_components/homeshift/__init__.py @@ -7,24 +7,37 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, Platform from homeassistant.core import CoreState, Event, HomeAssistant, callback from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from .const import ( + CLOSE_ELEVATION_MAX, + CLOSE_ELEVATION_MIN, + CONF_DAILY_COVER_CLOSE_ELEVATION, + CONF_DAILY_COVER_CLOSE_MODE, + CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, CONF_DAILY_COVER_ENTITIES, CONF_DAILY_COVER_ITEMS, CONF_ITEM_COVER, CONF_ITEM_MY_BUTTON, CONF_ITEM_WINDOW_SENSOR, + DEFAULT_DAILY_COVER_CLOSE_ELEVATION, + DEFAULT_DAILY_COVER_CLOSE_OFFSET_MINUTES, DOMAIN, + LEGACY_CLOSE_MODE_ELEVATION, SENSOR_NEXT_SCAN, SERVICE_REFRESH_SCHEDULERS, SERVICE_SYNC_CALENDAR, ) from .coordinator import HomeShiftCoordinator +from .cover_manager import elevation_for_sunset_offset _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.SELECT, Platform.NUMBER, Platform.SENSOR, Platform.BINARY_SENSOR] +# Settings the v3 -> v4 migration folds into CONF_DAILY_COVER_CLOSE_ELEVATION. +_RETIRED_CLOSE_KEYS = frozenset({CONF_DAILY_COVER_CLOSE_MODE, CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES}) + async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate config entry to the current version.""" @@ -78,9 +91,75 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: len(items), ) + if entry.version < 4: + # v3 -> v4: the evening close no longer takes a delay in minutes + # around sunset, only the sun elevation to close at. Convert the + # stored offset into the elevation it was landing on, so the covers + # keep moving at the time they moved yesterday. + merged = {**entry.data, **entry.options} + elevation = _migrated_close_elevation(hass, merged) + + data = {k: v for k, v in entry.data.items() if k not in _RETIRED_CLOSE_KEYS} + options = {k: v for k, v in entry.options.items() if k not in _RETIRED_CLOSE_KEYS} + if elevation is not None: + options[CONF_DAILY_COVER_CLOSE_ELEVATION] = elevation + + hass.config_entries.async_update_entry(entry, data=data, options=options, version=4) + _LOGGER.info( + "HomeShift config entry migrated to version 4 (covers now close at %s° of sun elevation)", + elevation, + ) + return True +def _migrated_close_elevation(hass: HomeAssistant, merged: dict) -> float | None: + """Return the elevation a v3 entry should close at, or None to leave it unset. + + An entry that already picked an elevation keeps it. Otherwise the stored + sunset offset — or the default the entry was silently running on — is + converted to the elevation it lands on, clamped to what the config flow + accepts. Returns None when the daily schedule drives no cover, so an + unused feature is not given a setting it never had. + """ + if not merged.get(CONF_DAILY_COVER_ITEMS): + return None + + if merged.get(CONF_DAILY_COVER_CLOSE_MODE) == LEGACY_CLOSE_MODE_ELEVATION: + try: + return float(merged[CONF_DAILY_COVER_CLOSE_ELEVATION]) + except (KeyError, TypeError, ValueError): + pass + + try: + offset = int(merged.get(CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, DEFAULT_DAILY_COVER_CLOSE_OFFSET_MINUTES)) + except (TypeError, ValueError): + offset = DEFAULT_DAILY_COVER_CLOSE_OFFSET_MINUTES + + elevation = elevation_for_sunset_offset(hass, offset, dt_util.now().year) + if elevation is None: + _LOGGER.warning( + "HomeShift: could not convert the %s min sunset offset to a sun elevation " + "(no usable location) — falling back to %s°", + offset, + DEFAULT_DAILY_COVER_CLOSE_ELEVATION, + ) + return DEFAULT_DAILY_COVER_CLOSE_ELEVATION + + clamped = min(max(elevation, CLOSE_ELEVATION_MIN), CLOSE_ELEVATION_MAX) + if clamped != elevation: + _LOGGER.warning( + "HomeShift: a %s min sunset offset works out to %s° of sun elevation, outside the " + "%s°..%s° the config flow accepts — clamped to %s°", + offset, + elevation, + CLOSE_ELEVATION_MIN, + CLOSE_ELEVATION_MAX, + clamped, + ) + return clamped + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up HomeShift from a config entry.""" hass.data.setdefault(DOMAIN, {}) diff --git a/custom_components/homeshift/binary_sensor.py b/custom_components/homeshift/binary_sensor.py index b263cce..36227a4 100644 --- a/custom_components/homeshift/binary_sensor.py +++ b/custom_components/homeshift/binary_sensor.py @@ -11,7 +11,14 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import BINARY_SENSOR_COVER_HEAT_ACTIVE, CONF_COVER_ENTITIES, CONF_COVER_TEMP_SENSOR, DOMAIN +from .const import ( + BINARY_SENSOR_COVER_HEAT_ACTIVE, + BINARY_SENSOR_COVERS_LEFT_OPEN, + CONF_COVER_ENTITIES, + CONF_COVER_TEMP_SENSOR, + CONF_DAILY_COVER_ITEMS, + DOMAIN, +) from .coordinator import HomeShiftCoordinator _LOGGER = logging.getLogger(__name__) @@ -28,6 +35,8 @@ async def async_setup_entry( entities = [] if config.get(CONF_COVER_ENTITIES) and config.get(CONF_COVER_TEMP_SENSOR): entities.append(HomeShiftCoverHeatActiveSensor(coordinator, entry)) + if config.get(CONF_DAILY_COVER_ITEMS): + entities.append(HomeShiftCoversLeftOpenSensor(coordinator, entry)) async_add_entities(entities) @@ -90,3 +99,52 @@ def is_on(self) -> bool | None: def device_info(self) -> dict: """Return device information.""" return _device_info(self._entry) + + +class HomeShiftCoversLeftOpenSensor(CoordinatorEntity[HomeShiftCoordinator], BinarySensorEntity): + """Problem sensor: on when tonight's close had to leave covers up. + + A cover whose window sensor reports the window open is skipped by the + evening close and only warned about in the log — which nobody reads. This + entity raises the same warning where it can be seen and acted on, and + lists the covers concerned in its attributes so a notification can name + them. + + Only registered when the daily schedule drives at least one cover + (CONF_DAILY_COVER_ITEMS). Stays on until the next calendar day's schedule + is computed: closing the window does not bring the cover down, so the + warning outlives the open window that caused it. + """ + + _attr_device_class = BinarySensorDeviceClass.PROBLEM + _attr_has_entity_name = True + _attr_name = "Covers Left Open" + _attr_icon = "mdi:window-open-variant" + + def __init__(self, coordinator: HomeShiftCoordinator, entry: ConfigEntry) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{entry.entry_id}_{BINARY_SENSOR_COVERS_LEFT_OPEN}" + self._entry = entry + + @property + def is_on(self) -> bool: + """Return True when at least one cover was left open tonight.""" + return bool(self.coordinator.covers_left_open) + + @property + def extra_state_attributes(self) -> dict: + """List the covers left open and the window sensor that blocked each.""" + left_open = self.coordinator.covers_left_open + checked_on = self.coordinator.covers_left_open_date + return { + "count": len(left_open), + "covers": list(left_open), + "window_sensors": dict(left_open), + "checked_on": checked_on.isoformat() if checked_on else None, + } + + @property + def device_info(self) -> dict: + """Return device information.""" + return _device_info(self._entry) diff --git a/custom_components/homeshift/config_flow.py b/custom_components/homeshift/config_flow.py index ac7b096..af02574 100644 --- a/custom_components/homeshift/config_flow.py +++ b/custom_components/homeshift/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from datetime import date from typing import Any, Self import voluptuous as vol @@ -10,6 +11,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import section from homeassistant.helpers import selector +from homeassistant.util import dt as dt_util from .const import ( DOMAIN, @@ -45,15 +47,18 @@ CONF_ITEM_WINDOW_SENSOR, CONF_ITEM_MY_BUTTON, CONF_DAILY_COVER_OPEN_TIME_MAP, - CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, + CONF_DAILY_COVER_CLOSE_ELEVATION, + CLOSE_ELEVATION_MIN, + CLOSE_ELEVATION_MAX, DEFAULT_DAILY_COVER_OPEN_TIME, - DEFAULT_DAILY_COVER_CLOSE_OFFSET_MINUTES, + DEFAULT_DAILY_COVER_CLOSE_ELEVATION, CONF_SUNRISE_EARLIEST, DEFAULT_SUNRISE_EARLIEST, LOCALIZED_DEFAULTS, get_localized_defaults, parse_key_value_map, ) +from .cover_manager import sun_time_at_elevation _LOGGER = logging.getLogger(__name__) @@ -306,10 +311,19 @@ def _apply_covers_input(user_input: dict[str, Any]) -> dict[str, Any]: return {key: user_input.get(key, "") for key in _CLEARABLE_COVER_ENTITIES} | user_input +def _ui_lang(hass) -> str: + """Return the HA instance language as a bare ISO 639-1 code. + + Selector option labels are built in Python (the integration has no + 'selector' translation section), so they need the language here. + """ + raw_lang = getattr(hass.config, "language", "en") if hasattr(hass, "config") else "en" + return (raw_lang or "en").split("-")[0].lower() + + def _covers_schema(hass, data: dict[str, Any]) -> vol.Schema: """Build the cover heat-control form schema.""" - raw_lang = getattr(hass.config, "language", "en") if hasattr(hass, "config") else "en" - lang = raw_lang.split("-")[0].lower() + lang = _ui_lang(hass) close_label = "Fermer les volets (close_cover)" if lang == "fr" else "Close Cover (close_cover)" stop_label = "Arrêter le mouvement (stop_cover)" if lang == "fr" else "Stop movement (stop_cover)" return vol.Schema( @@ -408,6 +422,53 @@ def _rebuild_daily_open_time_map(user_input: dict[str, Any], data: dict[str, Any return ", ".join(pairs) +def _close_elevation(data: dict[str, Any]) -> float: + """Return the configured close elevation, falling back to the default.""" + try: + return float(data.get(CONF_DAILY_COVER_CLOSE_ELEVATION, DEFAULT_DAILY_COVER_CLOSE_ELEVATION)) + except (TypeError, ValueError): + return DEFAULT_DAILY_COVER_CLOSE_ELEVATION + + +def _close_time_preview(hass, data: dict[str, Any]) -> dict[str, str]: + """Return tonight's closing time, ready for the form text. + + Degrees above the horizon say nothing about when the covers will move, + so the step description turns the configured elevation into tonight's + time, computed from the instance's own location. A time that cannot be + computed (no location set, or an elevation the sun never reaches today) + is rendered as '--:--'. + """ + elevation = _close_elevation(data) + at_elevation = sun_time_at_elevation(hass, elevation, dt_util.now().date()) + return { + "close_elevation": f"{elevation:g}", + "close_at_elevation": at_elevation.strftime("%H:%M") if at_elevation else "--:--", + } + + +def _validate_close_elevation(hass, user_input: dict[str, Any]) -> dict[str, str]: + """Reject an elevation the sun never reaches at this location. + + A threshold outside the sun's yearly range is a configuration mistake, + not a fact of the night — above the polar circle, or a positive value at + a high latitude in midwinter. Catching it here says so at once, instead + of leaving the user to discover months later that the covers had been + closing at sunset because the runtime kept falling back. + """ + if CONF_DAILY_COVER_CLOSE_ELEVATION not in user_input: + return {} + + elevation = _close_elevation(user_input) + year = dt_util.now().year + # The solstices bracket the year: the shortest night is the hardest day + # for a threshold below the horizon, the lowest midday sun for one above. + for solstice in (date(year, 6, 21), date(year, 12, 21)): + if sun_time_at_elevation(hass, elevation, solstice) is None: + return {CONF_DAILY_COVER_CLOSE_ELEVATION: "elevation_unreachable"} + return {} + + def _daily_cover_schema(hass, data: dict[str, Any]) -> vol.Schema: """Build the native daily cover open/close schedule form schema.""" schema_dict: dict = { @@ -416,13 +477,14 @@ def _daily_cover_schema(hass, data: dict[str, Any]) -> vol.Schema: 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), + CONF_DAILY_COVER_CLOSE_ELEVATION, + default=_close_elevation(data), ): selector.NumberSelector( selector.NumberSelectorConfig( - min=-120, - max=120, - step=1, + min=CLOSE_ELEVATION_MIN, + max=CLOSE_ELEVATION_MAX, + step=0.5, + unit_of_measurement="°", mode=selector.NumberSelectorMode.BOX, ) ), @@ -541,7 +603,7 @@ def _apply_cover_item_remove(user_input: dict[str, Any], data: dict[str, Any]) - class HomeShiftConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for HomeShift.""" - VERSION = 3 + VERSION = 4 def __init__(self) -> None: """Initialise the config flow.""" @@ -672,16 +734,27 @@ async def async_step_daily_cover_schedule( user_input: dict[str, Any] | None = None, ) -> config_entries.ConfigFlowResult: """Configure the native daily cover open/close schedule.""" + errors: dict[str, str] = {} + data = self._effective_data() + if user_input is not None: - 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() + errors = _validate_close_elevation(self.hass, user_input) + if not errors: + user_input[CONF_DAILY_COVER_OPEN_TIME_MAP] = _rebuild_daily_open_time_map( + user_input, data + ) + self._data.update(user_input) + return await self.async_step_menu() + # Redisplay what was typed, not what is stored, so the rejected + # value is there to correct and the preview matches it. + data = {**data, **user_input} + data[CONF_DAILY_COVER_OPEN_TIME_MAP] = _rebuild_daily_open_time_map(dict(user_input), data) return self.async_show_form( step_id="daily_cover_schedule", - data_schema=_daily_cover_schema(self.hass, self._effective_data()), + data_schema=_daily_cover_schema(self.hass, data), + description_placeholders=_close_time_preview(self.hass, data), + errors=errors, ) # -- individual covers --------------------------------------------------- @@ -873,16 +946,27 @@ async def async_step_daily_cover_schedule( user_input: dict[str, Any] | None = None, ) -> config_entries.ConfigFlowResult: """Configure the native daily cover open/close schedule.""" + errors: dict[str, str] = {} + data = self._effective_data() + if user_input is not None: - 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() + errors = _validate_close_elevation(self.hass, user_input) + if not errors: + user_input[CONF_DAILY_COVER_OPEN_TIME_MAP] = _rebuild_daily_open_time_map( + user_input, data + ) + self._data.update(user_input) + return await self.async_step_menu() + # Redisplay what was typed, not what is stored, so the rejected + # value is there to correct and the preview matches it. + data = {**data, **user_input} + data[CONF_DAILY_COVER_OPEN_TIME_MAP] = _rebuild_daily_open_time_map(dict(user_input), data) return self.async_show_form( step_id="daily_cover_schedule", - data_schema=_daily_cover_schema(self.hass, self._effective_data()), + data_schema=_daily_cover_schema(self.hass, data), + description_placeholders=_close_time_preview(self.hass, data), + errors=errors, ) # -- individual covers --------------------------------------------------- diff --git a/custom_components/homeshift/const.py b/custom_components/homeshift/const.py index d387760..a55317e 100644 --- a/custom_components/homeshift/const.py +++ b/custom_components/homeshift/const.py @@ -71,7 +71,25 @@ # 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. +# The evening close fires when the descending sun reaches +# CONF_DAILY_COVER_CLOSE_ELEVATION degrees above the horizon, as reported by +# sun.sun's 'elevation' attribute: 0 is sunset, -6 is the end of civil +# twilight (artificial light needed). A light level rather than a delay. +CONF_DAILY_COVER_CLOSE_ELEVATION = "daily_cover_close_elevation" +# Bounds offered by the config flow. Wide enough to hold every value the +# retired sunset offset could express: +-120 minutes around sunset spans +# roughly +21 to -22 degrees at 43N (measured with astral). +CLOSE_ELEVATION_MIN = -25.0 +CLOSE_ELEVATION_MAX = 25.0 +# Reported by the Cover Close Time sensor as 'trigger'. Not a setting: the +# elevation is the only trigger, and CLOSE_TRIGGER_SUNSET means the fallback +# had to step in because the sun never reached the configured elevation. +CLOSE_TRIGGER_ELEVATION = "elevation" +CLOSE_TRIGGER_SUNSET = "sunset" +# Retired settings, kept for the v3 -> v4 migration that converts the fixed +# sunset offset into the elevation it was landing on. Nothing else reads them. +CONF_DAILY_COVER_CLOSE_MODE = "daily_cover_close_mode" +LEGACY_CLOSE_MODE_ELEVATION = "elevation" CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES = "daily_cover_close_offset_minutes" # The covers driven by the daily schedule, added one at a time from the config # flow. Stored as a list of dicts: each pairs one cover with an optional @@ -90,7 +108,14 @@ WINDOW_OPEN_STATES = frozenset({"on", "open"}) DEFAULT_DAILY_COVER_OPEN_TIME = "08:30" +# The offset the retired setting defaulted to, used only to convert an entry +# that never touched it. DEFAULT_DAILY_COVER_CLOSE_OFFSET_MINUTES = 10 +# -2 degrees is what that default offset landed on at every season (measured +# at 48.8N: sunset + 10 min sits between -1.9 and -2.3 all year), so a fresh +# install behaves like the old one. -4 waits until the room is genuinely +# dark, -6 is the end of civil twilight. +DEFAULT_DAILY_COVER_CLOSE_ELEVATION = -2.0 # Entity IDs SELECT_DAY_MODE = "day_mode" @@ -103,6 +128,7 @@ SENSOR_COVER_OPEN_TIME = "cover_open_time" SENSOR_COVER_CLOSE_TIME = "cover_close_time" BINARY_SENSOR_COVER_HEAT_ACTIVE = "cover_heat_active" +BINARY_SENSOR_COVERS_LEFT_OPEN = "covers_left_open" # Sentinel value used as today_type when no calendar event is active EVENT_NONE = "None" diff --git a/custom_components/homeshift/coordinator.py b/custom_components/homeshift/coordinator.py index 0d08858..f469c78 100644 --- a/custom_components/homeshift/coordinator.py +++ b/custom_components/homeshift/coordinator.py @@ -332,6 +332,10 @@ async def _async_run_cover_checks(self) -> None: now = dt_util.now() await self._cover_manager.async_check_daily_schedule(now) await self._cover_manager.async_check_heat_protection(now) + # The timers fire between polls, so push the new cover state (today's + # times, the covers left open) to the entities right away instead of + # letting them show yesterday's until the next poll. + self.async_update_listeners() @callback def async_cancel_cover_timers(self) -> None: @@ -509,6 +513,21 @@ 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 + @property + def covers_left_open(self) -> dict[str, str]: + """Covers tonight's close had to skip, as {cover: window sensor}.""" + return self._cover_manager.covers_left_open + + @property + def covers_left_open_date(self) -> date | None: + """The day the skipped-cover list was last established.""" + return self._cover_manager.covers_left_open_date + + @property + def cover_close_trigger(self) -> str | None: + """What produced today's closing time: 'sunset' or 'elevation'.""" + return self._cover_manager.daily_close_trigger + def is_heat_protection_active(self, now: datetime) -> bool | None: """Return whether cover heat protection conditions are currently met. diff --git a/custom_components/homeshift/cover_manager.py b/custom_components/homeshift/cover_manager.py index 1ac8f6e..9c92fe5 100644 --- a/custom_components/homeshift/cover_manager.py +++ b/custom_components/homeshift/cover_manager.py @@ -13,10 +13,18 @@ from datetime import date, datetime, time as dt_time, timedelta from typing import TYPE_CHECKING, Callable +from astral.sun import ( + SunDirection, + elevation as astral_elevation, + sunset as astral_sunset, + time_at_elevation as astral_time_at_elevation, +) + 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.helpers.sun import get_astral_observer from homeassistant.util import dt as dt_util from .const import ( @@ -36,9 +44,11 @@ CONF_ITEM_MY_BUTTON, WINDOW_OPEN_STATES, CONF_DAILY_COVER_OPEN_TIME_MAP, - CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, + CONF_DAILY_COVER_CLOSE_ELEVATION, + CLOSE_TRIGGER_SUNSET, + CLOSE_TRIGGER_ELEVATION, DEFAULT_DAILY_COVER_OPEN_TIME, - DEFAULT_DAILY_COVER_CLOSE_OFFSET_MINUTES, + DEFAULT_DAILY_COVER_CLOSE_ELEVATION, CONF_SUNRISE_EARLIEST, DEFAULT_SUNRISE_EARLIEST, parse_key_value_map, @@ -70,6 +80,80 @@ def _parse_time_str(time_str: str) -> dt_time | None: return None +def next_sun_datetime(hass: HomeAssistant, attr_name: str) -> datetime | None: + """Return a sun.sun 'next_*' attribute as a local datetime, or None.""" + sun_state = hass.states.get("sun.sun") + if sun_state is None: + return None + raw = sun_state.attributes.get(attr_name) + if not raw: + return None + try: + return dt_util.as_local(datetime.fromisoformat(str(raw))) + except (ValueError, TypeError): + return None + + +def sunset_time(hass: HomeAssistant) -> dt_time | None: + """Return the next sunset's local time-of-day, or None. + + The fallback for the evening close when the configured elevation cannot + be reached. Reads sun.sun rather than astral so it still answers when the + location is not usable. + """ + sunset = next_sun_datetime(hass, "next_setting") + return sunset.time() if sunset is not None else None + + +def elevation_for_sunset_offset(hass: HomeAssistant, offset_minutes: int, year: int) -> float | None: + """Return the elevation a fixed sunset offset lands on, averaged over the year. + + Used once, by the v3 -> v4 migration, to carry an entry's retired "close + N minutes around sunset" setting over to the elevation that reproduces + it. Averaged over the equinoxes and solstices because the two are not + exactly equivalent: the gap is under half a degree for the offsets people + actually use (+-30 min) and widens for the extremes. Rounded to the half + degree the config flow offers. Returns None when the location cannot be + resolved. + """ + observer = get_astral_observer(hass) + elevations: list[float] = [] + for month, day in ((3, 21), (6, 21), (9, 21), (12, 21)): + try: + moment = astral_sunset(observer, date(year, month, day)) + timedelta(minutes=offset_minutes) + elevations.append(astral_elevation(observer, moment)) + except (ValueError, TypeError, AttributeError) as err: + _LOGGER.debug("No sunset on %s-%s-%s: %s", year, month, day, err) + if not elevations: + return None + return round(sum(elevations) / len(elevations) * 2) / 2 + + +def sun_time_at_elevation(hass: HomeAssistant, elevation: float, on_date: date) -> dt_time | None: + """Return the local time the setting sun reaches `elevation` on `on_date`. + + `elevation` is in degrees above the horizon, the same scale as the + 'elevation' attribute of sun.sun: 0 is sunset, negative values are below + it. Returns None when the sun never reaches that elevation on its way + down that day (polar latitudes, or a positive threshold in mid-winter), + so callers can fall back to sunset + offset. + + Lives at module level so the config flow can preview the resulting time + without building a CoverManager. + """ + try: + event = astral_time_at_elevation( + get_astral_observer(hass), + elevation, + date=on_date, + direction=SunDirection.SETTING, + ) + except (ValueError, TypeError, AttributeError) as err: + _LOGGER.debug("No sun elevation of %s on %s: %s", elevation, on_date, err) + return None + return dt_util.as_local(event).time() + + 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: @@ -88,6 +172,17 @@ 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 + # Which trigger produced daily_close_time — CLOSE_TRIGGER_ELEVATION, + # or CLOSE_TRIGGER_SUNSET when the elevation turned out to be + # unreachable today. The Cover Close Time sensor reports it, so a + # fallback night is visible rather than silent. + self.daily_close_trigger: str | None = None + # The covers tonight's close had to leave up, as {cover: window sensor}. + # Persisted so the warning entity survives a restart, and reset when a + # new calendar day is computed — not when the window is closed, since + # the cover stays up either way until someone acts on it. + self.covers_left_open: dict[str, str] = {} + self.covers_left_open_date: date | None = None # 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 @@ -141,6 +236,9 @@ async def async_restore_state(self) -> None: self._daily_opened_date = _parse_stored_date(stored.get("daily_opened_date")) self._daily_closed_date = _parse_stored_date(stored.get("daily_closed_date")) self._schedule_computed_date = _parse_stored_date(stored.get("schedule_computed_date")) + stored_left_open = stored.get("covers_left_open") + self.covers_left_open = dict(stored_left_open) if isinstance(stored_left_open, dict) else {} + self.covers_left_open_date = _parse_stored_date(stored.get("covers_left_open_date")) async def _async_save_state(self) -> None: """Persist cover-automation state to storage.""" @@ -152,6 +250,8 @@ async def _async_save_state(self) -> 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, "schedule_computed_date": self._schedule_computed_date.isoformat() if self._schedule_computed_date else None, + "covers_left_open": self.covers_left_open, + "covers_left_open_date": self.covers_left_open_date.isoformat() if self.covers_left_open_date else None, } ) except Exception as err: # noqa: BLE001 - defensive around storage I/O @@ -382,19 +482,9 @@ def is_heat_protection_active(self, now: datetime) -> bool | None: 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: - return None - - raw = sun_state.attributes.get(attr_name) - if not raw: - return None - - try: - local_dt = dt_util.as_local(datetime.fromisoformat(str(raw))) - except (ValueError, TypeError): + local_dt = next_sun_datetime(self._hass, attr_name) + if local_dt is None: return None - return _parse_time_str(local_dt.strftime("%H:%M")) # -- daily-schedule targets ------------------------------------------- @@ -417,16 +507,18 @@ def _daily_cover_targets(self) -> list[str]: targets.append(cover) return targets - def _covers_behind_an_open_window(self) -> set[str]: - """Return the individually-configured covers whose window is reported open. + def _covers_behind_an_open_window(self) -> dict[str, str]: + """Return {cover: window sensor} for the covers whose window reads open. Closing a cover over an open window traps the window (and can damage the cover), so the evening close skips these and warns instead. A sensor that is missing or unavailable can't establish that the window is open, so the cover closes as usual — with a warning, since a silently dead sensor would otherwise look like normal operation. + Keyed by cover, carrying the sensor that blocked it, so the warning + entity can name both. """ - blocked: set[str] = set() + blocked: dict[str, str] = {} for item in self._daily_cover_items(): cover = item.get(CONF_ITEM_COVER, "") sensor = item.get(CONF_ITEM_WINDOW_SENSOR, "") @@ -452,7 +544,7 @@ def _covers_behind_an_open_window(self) -> set[str]: cover, sensor, ) - blocked.add(cover) + blocked[cover] = sensor return blocked def _covers_with_my_button(self) -> set[str]: @@ -467,7 +559,7 @@ def _covers_with_my_button(self) -> set[str]: if item.get(CONF_ITEM_MY_BUTTON) } - def _my_button_presses(self, blocked: set[str]) -> list[tuple[str, str]]: + def _my_button_presses(self, blocked: dict[str, str]) -> list[tuple[str, str]]: """Return the (cover, button) presses to send for the My position covers. A cover configured with a My position button must not receive @@ -484,6 +576,43 @@ def _my_button_presses(self, blocked: set[str]) -> list[tuple[str, str]]: presses.append((cover, button)) return presses + # -- evening close time ------------------------------------------------ + + def _close_elevation(self) -> float: + """Return the configured close elevation in degrees, or its default.""" + try: + return float(self._config.get(CONF_DAILY_COVER_CLOSE_ELEVATION, DEFAULT_DAILY_COVER_CLOSE_ELEVATION)) + except (TypeError, ValueError): + return DEFAULT_DAILY_COVER_CLOSE_ELEVATION + + def _resolve_close_time(self, now: datetime) -> dt_time | None: + """Return the time the descending sun reaches the configured elevation. + + Falls back to plain sunset when the sun never gets there today, so a + threshold that has become unreachable cannot leave every cover open + all night. The config flow refuses an elevation outside the sun's + yearly range, so this is a safety net for what it cannot check — a + home whose latitude changed after setup, or an entry restored from + another location. Records which of the two won in + daily_close_trigger, so the sensor can report it. + """ + elevation = self._close_elevation() + close_time = sun_time_at_elevation(self._hass, elevation, now.date()) + if close_time is not None: + self.daily_close_trigger = CLOSE_TRIGGER_ELEVATION + return close_time + + _LOGGER.warning( + "Daily cover schedule: the sun never reaches %s° on %s — closing at sunset instead", + elevation, + now.date(), + ) + self.daily_close_trigger = CLOSE_TRIGGER_SUNSET + fallback = sunset_time(self._hass) + if fallback is None: + _LOGGER.warning("Daily cover schedule: could not determine sunset time either") + return fallback + 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. @@ -492,8 +621,9 @@ async def async_compute_daily_schedule(self, now: datetime, day_mode_key: str | '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. + Close time comes from _resolve_close_time(): the moment the setting + sun reaches CONF_DAILY_COVER_CLOSE_ELEVATION degrees above the + horizon. 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. Also resets heat protection's closed state, but only when @@ -506,6 +636,8 @@ async def async_compute_daily_schedule(self, now: datetime, day_mode_key: str | if self._schedule_computed_date != today: self._heat_closed = False self._schedule_computed_date = today + self.covers_left_open = {} + self.covers_left_open_date = None await self._async_save_state() if not self._daily_cover_targets(): @@ -535,19 +667,14 @@ async def async_compute_daily_schedule(self, now: datetime, day_mode_key: str | ) self.cover_open_time = None - 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 + close_time = self._resolve_close_time(now) + self.daily_close_time = close_time.strftime("%H:%M") if close_time is not None else None _LOGGER.info( - "Daily cover schedule: open_time=%s, close_time=%s (day_mode=%s)", + "Daily cover schedule: open_time=%s, close_time=%s via %s (day_mode=%s)", self.cover_open_time, self.daily_close_time, + self.daily_close_trigger, day_mode_key, ) @@ -634,7 +761,7 @@ async def _async_run_daily_actions(self, now: datetime, today: date, targets: li presses = self._my_button_presses(blocked) # Covers with an open window, or with their own My button, are # handled separately — everything else gets one close_cover. - handled = blocked | self._covers_with_my_button() + handled = set(blocked) | self._covers_with_my_button() close_targets = [entity_id for entity_id in targets if entity_id not in handled] if close_targets: @@ -667,4 +794,6 @@ async def _async_run_daily_actions(self, now: datetime, today: date, targets: li ) self._daily_closed_date = today + self.covers_left_open = blocked + self.covers_left_open_date = today await self._async_save_state() diff --git a/custom_components/homeshift/sensor.py b/custom_components/homeshift/sensor.py index 105aec5..948307f 100644 --- a/custom_components/homeshift/sensor.py +++ b/custom_components/homeshift/sensor.py @@ -11,7 +11,10 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( + CLOSE_TRIGGER_ELEVATION, + CONF_DAILY_COVER_CLOSE_ELEVATION, CONF_DAILY_COVER_ITEMS, + DEFAULT_DAILY_COVER_CLOSE_ELEVATION, DOMAIN, SENSOR_COVER_CLOSE_TIME, SENSOR_COVER_OPEN_TIME, @@ -135,12 +138,15 @@ def device_info(self) -> dict: class HomeShiftCoverCloseTimeSensor(CoordinatorEntity[HomeShiftCoordinator], SensorEntity): - """String sensor: the scheduled daily cover closing time for today. + """String sensor: the estimated daily cover closing time for today. Only registered when the daily schedule drives at least one cover (CONF_DAILY_COVER_ITEMS). - Updated each morning when async_compute_daily_schedule() runs - (today's sunset + CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES). + Updated each morning when async_compute_daily_schedule() runs: the time + the setting sun reaches CONF_DAILY_COVER_CLOSE_ELEVATION. The attributes + report the configured elevation, and whether it is what produced tonight's + time — a day the sun never reaches it closes at plain sunset instead, and + that has to be visible rather than silent. """ _attr_has_entity_name = True @@ -155,9 +161,20 @@ def __init__(self, coordinator: HomeShiftCoordinator, entry: ConfigEntry) -> Non @property def native_value(self) -> str | None: - """Return today's computed cover closing time (HH:MM).""" + """Return today's estimated cover closing time (HH:MM).""" return self.coordinator.cover_close_time + @property + def extra_state_attributes(self) -> dict: + """Return the configured elevation and what produced tonight's time.""" + config = {**self._entry.data, **self._entry.options} + return { + "sun_elevation": config.get( + CONF_DAILY_COVER_CLOSE_ELEVATION, DEFAULT_DAILY_COVER_CLOSE_ELEVATION + ), + "trigger": self.coordinator.cover_close_trigger or CLOSE_TRIGGER_ELEVATION, + } + @property def device_info(self) -> dict: """Return device information.""" diff --git a/custom_components/homeshift/translations/en.json b/custom_components/homeshift/translations/en.json index 8c3c327..542d77b 100644 --- a/custom_components/homeshift/translations/en.json +++ b/custom_components/homeshift/translations/en.json @@ -99,10 +99,10 @@ }, "daily_cover_schedule": { "title": "Daily Cover Schedule", - "description": "Natively open and close your covers every day, without a separate Scheduler-integration entity. Which covers are driven is set under Individual Covers; this section sets the times. Kept independent 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.", + "description": "Natively open and close your covers every day, without a separate Scheduler-integration entity. Which covers are driven is set under Individual Covers; this section sets the times. Kept independent 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, when the setting sun drops past the elevation below.\n\nAt {close_elevation}°, that is {close_at_elevation} tonight.", "data": { "sunrise_earliest": "Earliest Allowed Opening Time", - "daily_cover_close_offset_minutes": "Close Offset From Sunset (minutes)", + "daily_cover_close_elevation": "Sun elevation at closing", "daily_open_time_home": "Open Time — Home", "daily_open_time_work": "Open Time — Work", "daily_open_time_remote": "Open Time — Remote", @@ -110,7 +110,7 @@ }, "data_description": { "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 relative to sunset, every day, regardless of day mode. Positive closes after sunset, negative closes before (e.g. -10 = 10 min before sunset).", + "daily_cover_close_elevation": "Degrees above the horizon — the 'elevation' attribute of sun.sun, so a light level rather than a delay. 0° is sunset, -2° (recommended) is about ten minutes later, -4° is twenty to twenty-five minutes later with the room already dark, -6° is the end of civil twilight. Positive values close while the sun is still up.", "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.", @@ -149,7 +149,8 @@ } }, "error": { - "invalid_calendar": "The specified calendar entity does not exist" + "invalid_calendar": "The specified calendar entity does not exist", + "elevation_unreachable": "The sun does not reach this elevation every day of the year at your location. Pick a value inside the sun's yearly range — 0° is sunset, negative values are below the horizon." }, "abort": { "already_configured": "HomeShift is already configured", @@ -262,10 +263,10 @@ }, "daily_cover_schedule": { "title": "Daily Cover Schedule", - "description": "Natively open and close your covers every day, without a separate Scheduler-integration entity. Which covers are driven is set under Individual Covers; this section sets the times. Kept independent 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.", + "description": "Natively open and close your covers every day, without a separate Scheduler-integration entity. Which covers are driven is set under Individual Covers; this section sets the times. Kept independent 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, when the setting sun drops past the elevation below.\n\nAt {close_elevation}°, that is {close_at_elevation} tonight.", "data": { "sunrise_earliest": "Earliest Allowed Opening Time", - "daily_cover_close_offset_minutes": "Close Offset From Sunset (minutes)", + "daily_cover_close_elevation": "Sun elevation at closing", "daily_open_time_home": "Open Time — Home", "daily_open_time_work": "Open Time — Work", "daily_open_time_remote": "Open Time — Remote", @@ -273,7 +274,7 @@ }, "data_description": { "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 relative to sunset, every day, regardless of day mode. Positive closes after sunset, negative closes before (e.g. -10 = 10 min before sunset).", + "daily_cover_close_elevation": "Degrees above the horizon — the 'elevation' attribute of sun.sun, so a light level rather than a delay. 0° is sunset, -2° (recommended) is about ten minutes later, -4° is twenty to twenty-five minutes later with the room already dark, -6° is the end of civil twilight. Positive values close while the sun is still up.", "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.", @@ -310,6 +311,9 @@ "remove_covers": "Covers to remove" } } + }, + "error": { + "elevation_unreachable": "The sun does not reach this elevation every day of the year at your location. Pick a value inside the sun's yearly range — 0° is sunset, negative values are below the horizon." } }, "entity": { @@ -343,6 +347,9 @@ "binary_sensor": { "cover_heat_active": { "name": "Cover Heat Active" + }, + "covers_left_open": { + "name": "Covers Left Open" } } }, diff --git a/custom_components/homeshift/translations/fr.json b/custom_components/homeshift/translations/fr.json index db47a27..d3faca9 100644 --- a/custom_components/homeshift/translations/fr.json +++ b/custom_components/homeshift/translations/fr.json @@ -99,10 +99,10 @@ }, "daily_cover_schedule": { "title": "Planning quotidien des volets", - "description": "Ouvre et ferme vos volets tous les jours nativement, sans passer par une entité Scheduler séparée. Les volets concernés se définissent dans Volets individuels ; cette section règle les horaires. 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.", + "description": "Ouvre et ferme vos volets tous les jours nativement, sans passer par une entité Scheduler séparée. Les volets concernés se définissent dans Volets individuels ; cette section règle les horaires. 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, quand le soleil descend sous l'élévation ci-dessous.\n\nÀ {close_elevation}°, ce sera {close_at_elevation} ce soir.", "data": { "sunrise_earliest": "Heure d'ouverture minimale", - "daily_cover_close_offset_minutes": "Décalage de fermeture par rapport au coucher du soleil (minutes)", + "daily_cover_close_elevation": "Élévation du soleil à la fermeture", "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", @@ -110,7 +110,7 @@ }, "data_description": { "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 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_cover_close_elevation": "Degrés au-dessus de l'horizon — l'attribut « elevation » de sun.sun, donc un niveau de lumière plutôt qu'un délai. 0° = coucher du soleil, -2° (recommandé) ≈ dix minutes plus tard, -4° ≈ vingt à vingt-cinq minutes plus tard, pièce déjà sombre, -6° = fin du crépuscule civil. Une valeur positive ferme alors que le soleil est encore levé.", "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'.", @@ -149,7 +149,8 @@ } }, "error": { - "invalid_calendar": "L'entité calendrier spécifiée n'existe pas" + "invalid_calendar": "L'entité calendrier spécifiée n'existe pas", + "elevation_unreachable": "À votre position, le soleil n'atteint pas cette élévation tous les jours de l'année. Choisissez une valeur dans l'amplitude annuelle du soleil — 0° = coucher du soleil, les valeurs négatives sont sous l'horizon." }, "abort": { "already_configured": "HomeShift est déjà configuré", @@ -262,10 +263,10 @@ }, "daily_cover_schedule": { "title": "Planning quotidien des volets", - "description": "Ouvre et ferme vos volets tous les jours nativement, sans passer par une entité Scheduler séparée. Les volets concernés se définissent dans Volets individuels ; cette section règle les horaires. 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.", + "description": "Ouvre et ferme vos volets tous les jours nativement, sans passer par une entité Scheduler séparée. Les volets concernés se définissent dans Volets individuels ; cette section règle les horaires. 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, quand le soleil descend sous l'élévation ci-dessous.\n\nÀ {close_elevation}°, ce sera {close_at_elevation} ce soir.", "data": { "sunrise_earliest": "Heure d'ouverture minimale", - "daily_cover_close_offset_minutes": "Décalage de fermeture par rapport au coucher du soleil (minutes)", + "daily_cover_close_elevation": "Élévation du soleil à la fermeture", "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", @@ -273,7 +274,7 @@ }, "data_description": { "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 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_cover_close_elevation": "Degrés au-dessus de l'horizon — l'attribut « elevation » de sun.sun, donc un niveau de lumière plutôt qu'un délai. 0° = coucher du soleil, -2° (recommandé) ≈ dix minutes plus tard, -4° ≈ vingt à vingt-cinq minutes plus tard, pièce déjà sombre, -6° = fin du crépuscule civil. Une valeur positive ferme alors que le soleil est encore levé.", "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'.", @@ -310,6 +311,9 @@ "remove_covers": "Volets à supprimer" } } + }, + "error": { + "elevation_unreachable": "À votre position, le soleil n'atteint pas cette élévation tous les jours de l'année. Choisissez une valeur dans l'amplitude annuelle du soleil — 0° = coucher du soleil, les valeurs négatives sont sous l'horizon." } }, "entity": { @@ -343,6 +347,9 @@ "binary_sensor": { "cover_heat_active": { "name": "Chaleur volets active" + }, + "covers_left_open": { + "name": "Volets non fermés" } } }, diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 8a0e00a..006c119 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,19 +1,23 @@ """Tests for HomeShift's config_flow: schema builders, map (de)serialization, and step wiring.""" from __future__ import annotations -from unittest.mock import MagicMock +import re +from datetime import datetime +from unittest.mock import MagicMock, patch import pytest import voluptuous as vol import custom_components.homeshift.config_flow as cf from custom_components.homeshift.const import ( + CLOSE_ELEVATION_MAX, + CLOSE_ELEVATION_MIN, CONF_CALENDAR_ENTITY, CONF_COVER_ACTION, CONF_COVER_ENTITIES, CONF_COVER_TEMP_SENSOR, CONF_COVER_TEMP_THRESHOLD, - CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, + CONF_DAILY_COVER_CLOSE_ELEVATION, CONF_DAILY_COVER_ITEMS, CONF_ITEM_COVER, CONF_DAILY_COVER_OPEN_TIME_MAP, @@ -21,6 +25,7 @@ CONF_HOLIDAY_CALENDAR, CONF_SCHEDULERS_PER_MODE, CONF_SUNRISE_EARLIEST, + DEFAULT_DAILY_COVER_CLOSE_ELEVATION, DEFAULT_DAILY_COVER_OPEN_TIME, DEFAULT_DAY_MODE_MAP, ) @@ -219,7 +224,7 @@ def test_includes_base_and_per_mode_fields(self): field_names = {marker.schema for marker in schema.schema} assert { CONF_SUNRISE_EARLIEST, - CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, + CONF_DAILY_COVER_CLOSE_ELEVATION, "daily_open_time_home", "daily_open_time_work", "daily_open_time_remote", @@ -835,3 +840,224 @@ async def test_add_cover_without_optional_fields_stores_empty_strings(self): assert flow._data[CONF_DAILY_COVER_ITEMS] == [ {"cover": "cover.chambre", "window_sensor": "", "my_button": ""} ] + + +class TestCloseTimePreview: + """_close_time_preview: the two candidate closing times shown in the form.""" + + def _hass(self, latitude=48.85, longitude=2.35): + hass = _make_hass() + hass.data = {} + hass.config.latitude = latitude + hass.config.longitude = longitude + hass.config.time_zone = "Europe/Paris" + hass.config.elevation = 0 + sun = MagicMock() + sun.attributes = {"next_setting": "2026-07-01T19:30:00+00:00"} + hass.states.get.side_effect = lambda eid: sun if eid == "sun.sun" else MagicMock() + return hass + + def test_the_time_is_rendered_as_hh_mm(self): + preview = cf._close_time_preview(self._hass(), {}) + assert re.fullmatch(r"\d{2}:\d{2}", preview["close_at_elevation"]) + + def test_the_configured_elevation_is_echoed_back(self): + """The form text names the value the time was computed from.""" + preview = cf._close_time_preview( + self._hass(), {CONF_DAILY_COVER_CLOSE_ELEVATION: -2.5} + ) + assert preview["close_elevation"] == "-2.5" + + def test_an_unreachable_elevation_renders_a_placeholder(self): + """Polar summer: no time to show, but the form must still render.""" + hass = self._hass(latitude=78.2, longitude=15.6) + with patch("custom_components.homeshift.config_flow.dt_util") as mock_dt: + mock_dt.now.return_value = datetime(2026, 6, 21, 12, 0, 0) + preview = cf._close_time_preview(hass, {}) + assert preview["close_at_elevation"] == "--:--" + + def test_a_corrupted_setting_falls_back_to_the_default(self): + preview = cf._close_time_preview( + self._hass(), {CONF_DAILY_COVER_CLOSE_ELEVATION: "y"} + ) + assert preview["close_elevation"] == f"{DEFAULT_DAILY_COVER_CLOSE_ELEVATION:g}" + + +class TestCloseElevationField: + """The evening close is configured by one field and one field only.""" + + def _marker(self, data): + schema = cf._daily_cover_schema(_make_hass(), data) + return next(m for m in schema.schema if m.schema == CONF_DAILY_COVER_CLOSE_ELEVATION) + + def test_the_retired_settings_are_gone_from_the_form(self): + """The minute offset and the trigger choice no longer exist.""" + schema = cf._daily_cover_schema(_make_hass(), {CONF_DAY_MODE_MAP: DEFAULT_DAY_MODE_MAP}) + field_names = {marker.schema for marker in schema.schema} + + assert "daily_cover_close_offset_minutes" not in field_names + assert "daily_cover_close_mode" not in field_names + + def test_it_defaults_to_the_recommended_value(self): + assert self._marker({}).default() == DEFAULT_DAILY_COVER_CLOSE_ELEVATION + + def test_the_stored_value_is_preselected(self): + assert self._marker({CONF_DAILY_COVER_CLOSE_ELEVATION: -4.5}).default() == -4.5 + + def test_the_range_covers_every_retired_offset(self): + """+-120 min around sunset spans roughly +21..-22° at mid latitude.""" + schema = cf._daily_cover_schema(_make_hass(), {}) + config = next( + validator + for marker, validator in schema.schema.items() + if marker.schema == CONF_DAILY_COVER_CLOSE_ELEVATION + ).config + + assert config["min"] == CLOSE_ELEVATION_MIN <= -22 + assert config["max"] == CLOSE_ELEVATION_MAX >= 21 + + +class TestDailyCoverScheduleStepShowsThePreview: + """Both flows hand the estimated closing times to the form description.""" + + async def test_config_flow_passes_the_placeholders(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + result = await flow.async_step_daily_cover_schedule() + assert set(result["description_placeholders"]) == { + "close_elevation", + "close_at_elevation", + } + + async def test_options_flow_passes_the_placeholders(self): + flow = _make_options_flow({CONF_DAY_MODE_MAP: "home:Home"}) + await flow.async_step_init() + result = await flow.async_step_daily_cover_schedule() + assert "close_at_elevation" in result["description_placeholders"] + + async def test_the_chosen_elevation_is_stored(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _make_hass() + flow._data[CONF_DAY_MODE_MAP] = "home:Home" + await flow.async_step_daily_cover_schedule( + { + CONF_DAILY_COVER_CLOSE_ELEVATION: -3.5, + "daily_open_time_home": "08:30", + } + ) + assert flow._data[CONF_DAILY_COVER_CLOSE_ELEVATION] == -3.5 + + +def _located_hass(latitude: float, longitude: float, time_zone: str = "Europe/Paris") -> MagicMock: + """A hass carrying a real location, for the astral-backed helpers.""" + hass = _make_hass() + hass.data = {} + hass.config.latitude = latitude + hass.config.longitude = longitude + hass.config.time_zone = time_zone + hass.config.elevation = 0 + return hass + + +class TestValidateCloseElevation: + """An elevation outside the sun's yearly range is caught in the form.""" + + def _errors(self, hass, elevation): + return cf._validate_close_elevation( + hass, {CONF_DAILY_COVER_CLOSE_ELEVATION: elevation} + ) + + def test_every_offered_value_is_valid_at_mid_latitude(self): + """At 43°N the sun sweeps the whole selector range every day of the year.""" + hass = _located_hass(43.45, 5.47) + for elevation in (-18, -6, -2, 0, 2, 10): + assert self._errors(hass, elevation) == {}, elevation + + def test_a_high_positive_value_is_rejected_further_north(self): + """At 60°N the midwinter sun never climbs to 10°.""" + errors = self._errors(_located_hass(59.9, 10.75, "Europe/Oslo"), 10) + + assert errors == {CONF_DAILY_COVER_CLOSE_ELEVATION: "elevation_unreachable"} + + def test_the_polar_day_rejects_a_below_horizon_value(self): + """Above the polar circle the June sun never sets at all.""" + errors = self._errors(_located_hass(78.2, 15.6, "Arctic/Longyearbyen"), -2) + + assert errors == {CONF_DAILY_COVER_CLOSE_ELEVATION: "elevation_unreachable"} + + def test_a_submission_without_the_field_is_not_validated(self): + """Another step's input must not be judged on a value it never sent.""" + hass = _located_hass(78.2, 15.6, "Arctic/Longyearbyen") + + assert cf._validate_close_elevation(hass, {"daily_open_time_home": "sunrise"}) == {} + + +class TestDailyCoverScheduleStepRejectsAnUnreachableElevation: + """Both flows surface the error on the field instead of storing the value.""" + + def _polar_hass(self): + return _located_hass(78.2, 15.6, "Arctic/Longyearbyen") + + def _input(self): + return { + CONF_DAILY_COVER_CLOSE_ELEVATION: -2.0, + "daily_open_time_home": "sunrise", + } + + async def test_the_form_comes_back_with_the_error(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = self._polar_hass() + flow._data[CONF_DAY_MODE_MAP] = "home:Home" + + result = await flow.async_step_daily_cover_schedule(self._input()) + + assert result["step_id"] == "daily_cover_schedule" + assert result["errors"] == {CONF_DAILY_COVER_CLOSE_ELEVATION: "elevation_unreachable"} + + async def test_nothing_is_stored(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = self._polar_hass() + flow._data[CONF_DAY_MODE_MAP] = "home:Home" + + await flow.async_step_daily_cover_schedule(self._input()) + + assert CONF_DAILY_COVER_CLOSE_ELEVATION not in flow._data + assert CONF_DAILY_COVER_OPEN_TIME_MAP not in flow._data + + async def test_the_rejected_form_still_shows_what_was_typed(self): + """Re-rendering from the stored data would wipe the whole screen.""" + flow = cf.HomeShiftConfigFlow() + flow.hass = self._polar_hass() + flow._data[CONF_DAY_MODE_MAP] = "home:Home" + + result = await flow.async_step_daily_cover_schedule(self._input()) + + defaults = { + marker.schema: marker.default() + for marker in result["data_schema"].schema + if marker.default is not vol.UNDEFINED + } + assert defaults[CONF_DAILY_COVER_CLOSE_ELEVATION] == -2.0 + assert defaults["daily_open_time_home"] == "sunrise" + + async def test_a_valid_elevation_goes_through(self): + flow = cf.HomeShiftConfigFlow() + flow.hass = _located_hass(43.45, 5.47) + flow._data[CONF_DAY_MODE_MAP] = "home:Home" + + result = await flow.async_step_daily_cover_schedule(self._input()) + + assert result["step_id"] == "menu" + assert flow._data[CONF_DAILY_COVER_CLOSE_ELEVATION] == -2.0 + + async def test_the_options_flow_validates_too(self): + flow = _make_options_flow({CONF_DAY_MODE_MAP: "home:Home"}) + flow.hass = self._polar_hass() + flow.hass.config_entries.async_get_known_entry.return_value = MagicMock( + data={CONF_DAY_MODE_MAP: "home:Home"}, options={} + ) + await flow.async_step_init() + + result = await flow.async_step_daily_cover_schedule(self._input()) + + assert result["errors"] == {CONF_DAILY_COVER_CLOSE_ELEVATION: "elevation_unreachable"} diff --git a/tests/test_coordinator_features.py b/tests/test_coordinator_features.py index f49a6d7..0cb13c9 100644 --- a/tests/test_coordinator_features.py +++ b/tests/test_coordinator_features.py @@ -1494,19 +1494,19 @@ def test_exactly_at_threshold_returns_false(self): class TestDailyCoverScheduleCompute: """Verify async_compute_daily_schedule() sets cover_open_time / daily_close_time.""" - def _make_entry(self, entities=None, open_time_map="", earliest="07:10:00", close_offset=10): + def _make_entry(self, entities=None, open_time_map="", earliest="07:10:00", elevation=-2.0): from custom_components.homeshift.const import ( CONF_DAILY_COVER_ITEMS, CONF_DAILY_COVER_OPEN_TIME_MAP, CONF_SUNRISE_EARLIEST, - CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, + CONF_DAILY_COVER_CLOSE_ELEVATION, ) entry = make_mock_entry() entry.options = { CONF_DAILY_COVER_ITEMS: _cover_items(*(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, + CONF_DAILY_COVER_CLOSE_ELEVATION: elevation, } return entry @@ -1582,28 +1582,6 @@ def test_skip_value_sets_open_time_to_none(self): 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_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 no cover is configured.""" hass = make_mock_hass() @@ -2317,7 +2295,8 @@ def test_schedule_is_computed_with_individual_covers_only(self): ) ) - assert coordinator._cover_manager.daily_close_time == "21:40" + # the exact minute depends on the location; that it was computed is the point + assert coordinator._cover_manager.daily_close_time is not None class TestDailyCoverMyPositionButton: """A cover configured with a My position button closes to that position (a button press) instead of receiving close_cover. @@ -2507,7 +2486,7 @@ def test_same_day_recompute_keeps_the_closed_flag(self): assert manager._heat_closed is True # the times are still recomputed — they are not persisted - assert manager.daily_close_time == "21:40" + assert manager.daily_close_time is not None def test_new_day_recompute_clears_the_closed_flag(self): """Next morning: heat protection starts over.""" @@ -2803,3 +2782,353 @@ def test_active_event_is_none_when_off_or_unparseable(self): assert coordinator._prepare_active_event( make_calendar_state(state="on", message="x", start_time="nope", end_time="nope") ) is None + + +# --------------------------------------------------------------------------- +# Evening close on a sun elevation instead of sunset + offset +# --------------------------------------------------------------------------- + +class TestDailyCoverCloseOnSunElevation: + """The evening close fires when the setting sun reaches the configured elevation.""" + + def _make_entry(self, elevation=None): + from custom_components.homeshift.const import ( + CONF_DAILY_COVER_CLOSE_ELEVATION, + CONF_DAILY_COVER_ITEMS, + CONF_DAILY_COVER_OPEN_TIME_MAP, + ) + + entry = make_mock_entry() + options = { + CONF_DAILY_COVER_ITEMS: _cover_items("cover.volets"), + CONF_DAILY_COVER_OPEN_TIME_MAP: "away:skip", + } + if elevation is not None: + options[CONF_DAILY_COVER_CLOSE_ELEVATION] = elevation + entry.options = options + return entry + + def _hass(self): + """A hass whose sun.sun sets at 21:30 local (19:30 UTC + 2h).""" + sun = MagicMock() + sun.attributes = {"next_setting": "2026-07-01T19:30:00+00:00"} + hass = make_mock_hass() + hass.states.get.side_effect = lambda eid: sun if eid == "sun.sun" else None + return hass + + def _run(self, hass, entry, at_elevation): + """Compute the schedule with the astral elevation lookup stubbed out.""" + from datetime import timedelta as tdelta, timezone + + coordinator = HomeShiftCoordinator(hass, entry) + with patch("custom_components.homeshift.cover_manager.dt_util") as mock_dt: + tz_paris = timezone(tdelta(hours=2)) + mock_dt.as_local.side_effect = lambda dt: dt.astimezone(tz_paris) + with patch( + "custom_components.homeshift.cover_manager.sun_time_at_elevation", + return_value=at_elevation, + ) as mock_at_elevation: + asyncio.get_event_loop().run_until_complete( + coordinator._cover_manager.async_compute_daily_schedule( + datetime(2026, 7, 1, 0, 5, 0), "away" + ) + ) + return coordinator, mock_at_elevation + + def test_the_close_time_is_when_the_sun_reaches_the_elevation(self): + from datetime import time as dt_time + + from custom_components.homeshift.const import CLOSE_TRIGGER_ELEVATION + + coordinator, mock_at_elevation = self._run( + self._hass(), self._make_entry(elevation=-4.0), at_elevation=dt_time(21, 52) + ) + + assert coordinator._cover_manager.daily_close_time == "21:52" + assert coordinator._cover_manager.daily_close_trigger == CLOSE_TRIGGER_ELEVATION + _hass_arg, elevation, on_date = mock_at_elevation.call_args.args + assert elevation == -4.0 + assert on_date == date(2026, 7, 1) + + def test_an_entry_without_the_setting_uses_the_recommended_default(self): + from datetime import time as dt_time + + from custom_components.homeshift.const import DEFAULT_DAILY_COVER_CLOSE_ELEVATION + + _coordinator, mock_at_elevation = self._run( + self._hass(), self._make_entry(), at_elevation=dt_time(21, 41) + ) + + assert mock_at_elevation.call_args.args[1] == DEFAULT_DAILY_COVER_CLOSE_ELEVATION + + def test_an_unreachable_elevation_falls_back_to_sunset_and_warns(self, caplog): + """A threshold the sun never reaches must not leave the covers open all night.""" + from custom_components.homeshift.const import CLOSE_TRIGGER_SUNSET + + with caplog.at_level("WARNING"): + coordinator, _ = self._run( + self._hass(), self._make_entry(elevation=5.0), at_elevation=None + ) + + assert coordinator._cover_manager.daily_close_time == "21:30" + assert coordinator._cover_manager.daily_close_trigger == CLOSE_TRIGGER_SUNSET + assert "never reaches" in caplog.text + + def test_an_unparseable_elevation_uses_the_recommended_default(self): + """A corrupted value must not crash the nightly close.""" + from datetime import time as dt_time + + from custom_components.homeshift.const import DEFAULT_DAILY_COVER_CLOSE_ELEVATION + + _coordinator, mock_at_elevation = self._run( + self._hass(), self._make_entry(elevation="nonsense"), at_elevation=dt_time(21, 52) + ) + + assert mock_at_elevation.call_args.args[1] == DEFAULT_DAILY_COVER_CLOSE_ELEVATION + + def test_the_close_timer_follows_the_elevation_time(self): + """close_datetime — and so the one-shot timer — uses the elevation time.""" + from datetime import time as dt_time + + coordinator, _ = self._run( + self._hass(), self._make_entry(elevation=-4.0), at_elevation=dt_time(21, 52) + ) + + close_at = coordinator._cover_manager.close_datetime(datetime(2026, 7, 1, 12, 0, 0)) + assert close_at == datetime(2026, 7, 1, 21, 52, 0) + + def test_no_sun_entity_leaves_the_close_time_unset(self): + """Without sun.sun neither the elevation nor the fallback can answer.""" + hass = make_mock_hass() + hass.states.get.side_effect = lambda _eid: None + + coordinator, _ = self._run(hass, self._make_entry(), at_elevation=None) + + assert coordinator._cover_manager.daily_close_time is None + + +class TestSunTimeAtElevation: + """sun_time_at_elevation(): the astral lookup behind the elevation trigger.""" + + def _hass(self, latitude=48.85, longitude=2.35, time_zone="Europe/Paris"): + """A hass carrying a real location — get_astral_location needs a dict data store.""" + hass = make_mock_hass() + hass.data = {} + hass.config.latitude = latitude + hass.config.longitude = longitude + hass.config.time_zone = time_zone + hass.config.elevation = 0 + return hass + + def test_a_lower_elevation_gives_a_later_time(self): + """The sun keeps sinking, so -6° is always reached after -2°.""" + from custom_components.homeshift.cover_manager import sun_time_at_elevation + + hass = self._hass() + at_two = sun_time_at_elevation(hass, -2, date(2026, 6, 21)) + at_six = sun_time_at_elevation(hass, -6, date(2026, 6, 21)) + + assert at_two is not None and at_six is not None + assert at_two < at_six + + def test_the_time_is_recomputed_for_each_date(self): + """Twilight moves with the season: the lookup is not a cached constant.""" + from custom_components.homeshift.cover_manager import sun_time_at_elevation + + hass = self._hass() + june = sun_time_at_elevation(hass, -4, date(2026, 6, 21)) + december = sun_time_at_elevation(hass, -4, date(2026, 12, 21)) + + assert june is not None and december is not None + assert june != december + + def test_returns_none_when_the_sun_never_reaches_the_elevation(self): + """Polar summer: the sun stays up, so the caller must fall back.""" + from custom_components.homeshift.cover_manager import sun_time_at_elevation + + hass = self._hass(latitude=78.2, longitude=15.6, time_zone="Arctic/Longyearbyen") + + assert sun_time_at_elevation(hass, -4, date(2026, 6, 21)) is None + + +# --------------------------------------------------------------------------- +# The covers the evening close had to leave up +# --------------------------------------------------------------------------- + +class TestCoversLeftOpenState: + """The close records which covers it skipped, for the warning entity.""" + + def _make_entry(self, items): + from custom_components.homeshift.const import CONF_DAILY_COVER_ITEMS + + entry = make_mock_entry() + entry.options = {CONF_DAILY_COVER_ITEMS: items} + return entry + + def _hass(self, states: dict): + hass = make_mock_hass() + hass.services.async_call = AsyncMock() + hass.states.get.side_effect = lambda eid: states.get(eid) + return hass + + def _state(self, value: str): + state = MagicMock() + state.state = value + state.attributes = {} + return state + + def _manager(self, hass, entry, stored=None): + """A cover manager whose Store is mocked, so nothing touches the disk.""" + manager = HomeShiftCoordinator(hass, entry)._cover_manager + store = MagicMock() + store.async_load = AsyncMock(return_value=stored) + store.async_save = AsyncMock() + manager._store = store + return manager + + def _close(self, hass, entry, on_day=date(2026, 7, 1)): + manager = self._manager(hass, entry) + manager.cover_open_time = "08:30" + manager.daily_close_time = "21:40" + manager._daily_opened_date = on_day + asyncio.get_event_loop().run_until_complete( + manager.async_check_daily_schedule( + datetime(on_day.year, on_day.month, on_day.day, 21, 40, 0) + ) + ) + return manager + + def test_a_skipped_cover_is_recorded_with_its_sensor(self): + """The entity must be able to name both the cover and why.""" + hass = self._hass({"binary_sensor.fenetre_chambre": self._state("on")}) + entry = self._make_entry( + [{"cover": "cover.chambre", "window_sensor": "binary_sensor.fenetre_chambre"}] + ) + + manager = self._close(hass, entry) + + assert manager.covers_left_open == {"cover.chambre": "binary_sensor.fenetre_chambre"} + assert manager.covers_left_open_date == date(2026, 7, 1) + + def test_only_the_blocked_covers_are_listed(self): + hass = self._hass( + { + "binary_sensor.f1": self._state("on"), + "binary_sensor.f2": self._state("off"), + } + ) + entry = self._make_entry( + [ + {"cover": "cover.chambre", "window_sensor": "binary_sensor.f1"}, + {"cover": "cover.salon", "window_sensor": "binary_sensor.f2"}, + ] + ) + + manager = self._close(hass, entry) + + assert list(manager.covers_left_open) == ["cover.chambre"] + + def test_a_clean_close_records_nothing(self): + hass = self._hass({"binary_sensor.f1": self._state("off")}) + entry = self._make_entry([{"cover": "cover.chambre", "window_sensor": "binary_sensor.f1"}]) + + manager = self._close(hass, entry) + + assert manager.covers_left_open == {} + assert manager.covers_left_open_date == date(2026, 7, 1) + + def test_an_unavailable_sensor_is_not_a_left_open_cover(self): + """That cover was closed anyway, so it is not left up.""" + hass = self._hass({"binary_sensor.f1": self._state("unavailable")}) + entry = self._make_entry([{"cover": "cover.chambre", "window_sensor": "binary_sensor.f1"}]) + + manager = self._close(hass, entry) + + assert manager.covers_left_open == {} + + def test_a_my_position_cover_is_not_left_open(self): + """It was sent to its favourite position, which counts as handled.""" + hass = self._hass({}) + entry = self._make_entry( + [{"cover": "cover.salon", "window_sensor": "", "my_button": "button.my_salon"}] + ) + + manager = self._close(hass, entry) + + assert manager.covers_left_open == {} + + def test_the_list_is_persisted(self): + hass = self._hass({"binary_sensor.f1": self._state("on")}) + entry = self._make_entry([{"cover": "cover.chambre", "window_sensor": "binary_sensor.f1"}]) + + manager = self._close(hass, entry) + saved = manager._store.async_save.call_args.args[0] + + assert saved["covers_left_open"] == {"cover.chambre": "binary_sensor.f1"} + assert saved["covers_left_open_date"] == "2026-07-01" + + def test_the_list_is_restored_after_a_restart(self): + """A reboot the same evening must not silence the warning.""" + manager = self._manager( + make_mock_hass(), + self._make_entry([]), + stored={ + "covers_left_open": {"cover.chambre": "binary_sensor.f1"}, + "covers_left_open_date": "2026-07-01", + }, + ) + + asyncio.get_event_loop().run_until_complete(manager.async_restore_state()) + + assert manager.covers_left_open == {"cover.chambre": "binary_sensor.f1"} + assert manager.covers_left_open_date == date(2026, 7, 1) + + def test_a_corrupted_stored_list_is_ignored(self): + manager = self._manager( + make_mock_hass(), self._make_entry([]), stored={"covers_left_open": "junk"} + ) + + asyncio.get_event_loop().run_until_complete(manager.async_restore_state()) + + assert manager.covers_left_open == {} + + def _compute(self, manager, on_day): + sun = MagicMock() + sun.attributes = {"next_setting": "2026-07-01T19:30:00+00:00"} + manager._hass.states.get.side_effect = lambda eid: sun if eid == "sun.sun" else None + asyncio.get_event_loop().run_until_complete( + manager.async_compute_daily_schedule( + datetime(on_day.year, on_day.month, on_day.day, 0, 5, 0), "home" + ) + ) + + def test_a_new_day_clears_the_warning(self): + hass = self._hass({"binary_sensor.f1": self._state("on")}) + entry = self._make_entry([{"cover": "cover.chambre", "window_sensor": "binary_sensor.f1"}]) + manager = self._close(hass, entry) + + self._compute(manager, date(2026, 7, 2)) + + assert manager.covers_left_open == {} + assert manager.covers_left_open_date is None + + def test_a_same_day_recompute_keeps_the_warning(self): + """A restart re-runs the compute for today; the warning must survive it.""" + hass = self._hass({"binary_sensor.f1": self._state("on")}) + entry = self._make_entry([{"cover": "cover.chambre", "window_sensor": "binary_sensor.f1"}]) + manager = self._close(hass, entry) + manager._schedule_computed_date = date(2026, 7, 1) + + self._compute(manager, date(2026, 7, 1)) + + assert manager.covers_left_open == {"cover.chambre": "binary_sensor.f1"} + + def test_the_entities_are_refreshed_right_after_a_timer_close(self): + """The timers fire between polls — the warning must not wait for one.""" + hass = self._hass({}) + coordinator = HomeShiftCoordinator(hass, self._make_entry([])) + coordinator.async_update_listeners = MagicMock() + + asyncio.get_event_loop().run_until_complete(coordinator._async_run_cover_checks()) + + coordinator.async_update_listeners.assert_called_once() diff --git a/tests/test_init.py b/tests/test_init.py index b726b9e..fb216e4 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -204,12 +204,29 @@ def capture_listen_once(event_name, callback): # async_sync_calendar() was called to obtain the coroutine for async_create_task. coord.async_sync_calendar.assert_called_once() hass.async_create_task.assert_called() -class TestMigrationToVersion3: - """v2 → v3 folds the flat "Daily Cover Entities" list into the per-cover list.""" +class _MigrationHarness: + """Shared harness: migrations chain, so the fake entry must record writes. + + async_update_entry applies data/options/version to the entry the way HA + does, otherwise a later step in the same run reads what the earlier one + was supposed to have replaced. + """ - def _hass(self) -> MagicMock: + def _hass(self, entry=None) -> MagicMock: hass = MagicMock() - hass.config_entries.async_update_entry = MagicMock() + hass.config.latitude = 43.45 + hass.config.longitude = 5.47 + hass.config.time_zone = "Europe/Paris" + hass.config.elevation = 0 + hass.data = {} + + def _update(target, **kwargs): + for key in ("data", "options", "version"): + if key in kwargs: + setattr(target, key, kwargs[key]) + return True + + hass.config_entries.async_update_entry = MagicMock(side_effect=_update) return hass def _entry(self, data: dict, options: dict, version: int = 2) -> MagicMock: @@ -219,10 +236,24 @@ def _entry(self, data: dict, options: dict, version: int = 2) -> MagicMock: entry.options = options return entry - def _migrate(self, hass, entry) -> dict: + def _run(self, hass, entry) -> None: with patch("custom_components.homeshift.er"): asyncio.get_event_loop().run_until_complete(async_migrate_entry(hass, entry)) - return hass.config_entries.async_update_entry.call_args.kwargs + + def _writes(self, hass, version: int) -> dict: + """Return the kwargs of the call that bumped the entry to `version`.""" + for call in hass.config_entries.async_update_entry.call_args_list: + if call.kwargs.get("version") == version: + return call.kwargs + raise AssertionError(f"no migration wrote version {version}") + + +class TestMigrationToVersion3(_MigrationHarness): + """v2 → v3 folds the flat "Daily Cover Entities" list into the per-cover list.""" + + def _migrate(self, hass, entry) -> dict: + self._run(hass, entry) + return self._writes(hass, 3) def test_entities_become_items(self): hass = self._hass() @@ -290,14 +321,17 @@ def test_nothing_configured_leaves_an_empty_list(self): assert updated["version"] == 3 assert "daily_cover_items" not in updated["options"] - def test_an_already_migrated_entry_is_not_touched(self): + def test_an_already_migrated_entry_skips_this_step(self): hass = self._hass() entry = self._entry({}, {"daily_cover_items": [{"cover": "cover.a"}]}, version=3) - with patch("custom_components.homeshift.er"): - asyncio.get_event_loop().run_until_complete(async_migrate_entry(hass, entry)) + self._run(hass, entry) - hass.config_entries.async_update_entry.assert_not_called() + versions = [ + call.kwargs.get("version") + for call in hass.config_entries.async_update_entry.call_args_list + ] + assert 3 not in versions class TestUnloadRemovesServices: """async_unload_entry drops the services once the last entry is gone. @@ -386,3 +420,118 @@ def test_refresh_schedulers_is_a_noop_once_unloaded(self): ) stale.async_refresh_schedulers.assert_not_called() + + +class TestMigrationToVersion4(_MigrationHarness): + """v3 → v4 converts the retired sunset offset into the elevation it landed on. + + The reference location is Gardanne (43.45°N): sunset + 10 min sits at + -2.5° there, sunset - 10 min at +1.5°, measured with astral. + """ + + def _migrate(self, options: dict, data: dict | None = None) -> dict: + hass = self._hass() + entry = self._entry(data or {}, options, version=3) + self._run(hass, entry) + return self._writes(hass, 4) + + def _covers(self) -> list[dict]: + return [{"cover": "cover.volets", "window_sensor": "", "my_button": ""}] + + def test_a_stored_offset_becomes_the_elevation_it_landed_on(self): + """Closing 10 min before sunset is +1.5° at this latitude.""" + updated = self._migrate( + {"daily_cover_items": self._covers(), "daily_cover_close_offset_minutes": -10} + ) + + assert updated["version"] == 4 + assert updated["options"]["daily_cover_close_elevation"] == 1.5 + + def test_an_entry_that_never_touched_the_offset_gets_the_default_converted(self): + """It was silently running on +10 min, which is -2.5° here.""" + updated = self._migrate({"daily_cover_items": self._covers()}) + + assert updated["options"]["daily_cover_close_elevation"] == -2.5 + + def test_the_retired_keys_are_stripped_from_data_and_options(self): + updated = self._migrate( + {"daily_cover_items": self._covers(), "daily_cover_close_offset_minutes": 10}, + data={"daily_cover_close_offset_minutes": 30, "calendar_entity": "calendar.a"}, + ) + + assert "daily_cover_close_offset_minutes" not in updated["options"] + assert "daily_cover_close_offset_minutes" not in updated["data"] + assert "daily_cover_close_mode" not in updated["options"] + assert updated["data"]["calendar_entity"] == "calendar.a" + + def test_an_entry_that_already_picked_an_elevation_keeps_it(self): + """The intermediate build let the elevation be chosen explicitly.""" + updated = self._migrate( + { + "daily_cover_items": self._covers(), + "daily_cover_close_mode": "elevation", + "daily_cover_close_elevation": -6.0, + "daily_cover_close_offset_minutes": 10, + } + ) + + assert updated["options"]["daily_cover_close_elevation"] == -6.0 + + def test_an_unused_daily_schedule_gets_no_setting(self): + """No cover is driven, so there is nothing to convert.""" + updated = self._migrate({"daily_cover_close_offset_minutes": 10}) + + assert updated["version"] == 4 + assert "daily_cover_close_elevation" not in updated["options"] + + def test_a_corrupted_offset_converts_the_default_instead(self): + updated = self._migrate( + {"daily_cover_items": self._covers(), "daily_cover_close_offset_minutes": "nonsense"} + ) + + assert updated["options"]["daily_cover_close_elevation"] == -2.5 + + def test_an_unusable_location_falls_back_to_the_recommended_value(self): + from custom_components.homeshift.const import DEFAULT_DAILY_COVER_CLOSE_ELEVATION + + hass = self._hass() + entry = self._entry({}, {"daily_cover_items": self._covers()}, version=3) + with patch("custom_components.homeshift.elevation_for_sunset_offset", return_value=None): + self._run(hass, entry) + + assert ( + self._writes(hass, 4)["options"]["daily_cover_close_elevation"] + == DEFAULT_DAILY_COVER_CLOSE_ELEVATION + ) + + def test_an_elevation_outside_the_offered_range_is_clamped(self, caplog): + from custom_components.homeshift.const import CLOSE_ELEVATION_MAX + + hass = self._hass() + entry = self._entry({}, {"daily_cover_items": self._covers()}, version=3) + with patch("custom_components.homeshift.elevation_for_sunset_offset", return_value=40.0): + with caplog.at_level("WARNING"): + self._run(hass, entry) + + assert self._writes(hass, 4)["options"]["daily_cover_close_elevation"] == CLOSE_ELEVATION_MAX + assert "clamped" in caplog.text + + def test_a_v2_entry_runs_both_migrations_in_one_go(self): + """The old flat list and the old offset are both retired at once.""" + hass = self._hass() + entry = self._entry({}, {"daily_cover_entities": ["cover.volets"]}, version=2) + + self._run(hass, entry) + + assert self._writes(hass, 3)["options"]["daily_cover_items"] == [ + {"cover": "cover.volets", "window_sensor": "", "my_button": ""} + ] + assert self._writes(hass, 4)["options"]["daily_cover_close_elevation"] == -2.5 + + def test_an_already_migrated_entry_is_left_alone(self): + hass = self._hass() + entry = self._entry({}, {"daily_cover_items": self._covers()}, version=4) + + self._run(hass, entry) + + hass.config_entries.async_update_entry.assert_not_called() diff --git a/tests/test_sensors.py b/tests/test_sensors.py index 9fb36de..c95c837 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -753,3 +753,149 @@ def test_allday_event_today_followed_by_allday_event_tomorrow(self): # Vacances keyword maps to 'home' → 'Maison' assert coordinator.next_mode_predicted == _fr_day_map["home"] assert coordinator.next_mode_at == datetime(2026, 3, 12, 0, 0, 0) + + +# --------------------------------------------------------------------------- +# Cover Close Time — the estimated closing time and the trigger behind it +# --------------------------------------------------------------------------- + +class TestCoverCloseTimeSensor: + """The sensor reports tonight's estimate and how it was obtained.""" + + def _sensor(self, options, trigger=None, close_time="21:40"): + from custom_components.homeshift.sensor import HomeShiftCoverCloseTimeSensor + + entry = make_mock_entry() + entry.options = options + coordinator = HomeShiftCoordinator(make_mock_hass(), entry) + coordinator._cover_manager.daily_close_time = close_time + coordinator._cover_manager.daily_close_trigger = trigger + return HomeShiftCoverCloseTimeSensor(coordinator, entry) + + def test_native_value_is_the_estimated_time(self): + assert self._sensor({}).native_value == "21:40" + + def test_the_attributes_report_the_configured_elevation(self): + from custom_components.homeshift.const import ( + CLOSE_TRIGGER_ELEVATION, + CONF_DAILY_COVER_CLOSE_ELEVATION, + ) + + sensor = self._sensor( + {CONF_DAILY_COVER_CLOSE_ELEVATION: -3.5}, trigger=CLOSE_TRIGGER_ELEVATION + ) + + assert sensor.extra_state_attributes == { + "sun_elevation": -3.5, + "trigger": CLOSE_TRIGGER_ELEVATION, + } + + def test_a_fallback_night_is_visible(self): + """The sun never reached the elevation, so tonight came from plain sunset.""" + from custom_components.homeshift.const import ( + CLOSE_TRIGGER_SUNSET, + CONF_DAILY_COVER_CLOSE_ELEVATION, + ) + + sensor = self._sensor( + {CONF_DAILY_COVER_CLOSE_ELEVATION: 8.0}, trigger=CLOSE_TRIGGER_SUNSET + ) + + assert sensor.extra_state_attributes["trigger"] == CLOSE_TRIGGER_SUNSET + assert sensor.extra_state_attributes["sun_elevation"] == 8.0 + + def test_an_untouched_entry_reports_the_default_elevation(self): + """Nothing has run yet and nothing was configured.""" + from custom_components.homeshift.const import ( + CLOSE_TRIGGER_ELEVATION, + DEFAULT_DAILY_COVER_CLOSE_ELEVATION, + ) + + sensor = self._sensor({}, trigger=None) + + assert sensor.extra_state_attributes == { + "sun_elevation": DEFAULT_DAILY_COVER_CLOSE_ELEVATION, + "trigger": CLOSE_TRIGGER_ELEVATION, + } + + +class TestCoversLeftOpenSensor: + """The problem sensor that names the covers the evening close skipped.""" + + def _sensor(self, left_open, on_day=None): + from custom_components.homeshift.binary_sensor import HomeShiftCoversLeftOpenSensor + + entry = make_mock_entry() + entry.options = {} + coordinator = HomeShiftCoordinator(make_mock_hass(), entry) + coordinator._cover_manager.covers_left_open = left_open + coordinator._cover_manager.covers_left_open_date = on_day + return HomeShiftCoversLeftOpenSensor(coordinator, entry) + + def test_off_when_every_cover_closed(self): + assert self._sensor({}).is_on is False + + def test_on_when_a_cover_was_left_open(self): + assert self._sensor({"cover.chambre": "binary_sensor.f1"}).is_on is True + + def test_it_is_a_problem_sensor(self): + """device_class problem is what makes it show up as a warning.""" + from homeassistant.components.binary_sensor import BinarySensorDeviceClass + + assert self._sensor({}).device_class == BinarySensorDeviceClass.PROBLEM + + def test_the_attributes_list_the_covers_and_their_sensors(self): + from datetime import date as dt_date + + sensor = self._sensor( + {"cover.chambre": "binary_sensor.f1", "cover.salon": "binary_sensor.f2"}, + on_day=dt_date(2026, 7, 1), + ) + + assert sensor.extra_state_attributes == { + "count": 2, + "covers": ["cover.chambre", "cover.salon"], + "window_sensors": { + "cover.chambre": "binary_sensor.f1", + "cover.salon": "binary_sensor.f2", + }, + "checked_on": "2026-07-01", + } + + def test_the_attributes_are_empty_before_the_first_close(self): + sensor = self._sensor({}) + assert sensor.extra_state_attributes == { + "count": 0, + "covers": [], + "window_sensors": {}, + "checked_on": None, + } + + +class TestCoversLeftOpenSensorRegistration: + """The sensor only exists where the daily schedule drives covers.""" + + def _added(self, options): + from custom_components.homeshift.binary_sensor import async_setup_entry + from custom_components.homeshift.const import DOMAIN + + entry = make_mock_entry() + entry.options = options + hass = make_mock_hass() + coordinator = HomeShiftCoordinator(hass, entry) + hass.data = {DOMAIN: {entry.entry_id: coordinator}} + added = [] + asyncio.get_event_loop().run_until_complete( + async_setup_entry(hass, entry, lambda entities: added.extend(entities)) + ) + return [type(entity).__name__ for entity in added] + + def test_registered_when_daily_covers_are_configured(self): + from custom_components.homeshift.const import CONF_DAILY_COVER_ITEMS + + names = self._added({CONF_DAILY_COVER_ITEMS: [{"cover": "cover.volets"}]}) + + assert "HomeShiftCoversLeftOpenSensor" in names + + def test_not_registered_without_any_daily_cover(self): + assert self._added({}) == [] From 109aaee88213f5eab7b36569e4e7e69061986c0f Mon Sep 17 00:00:00 2001 From: Gamso Date: Sun, 20 Sep 2026 15:51:20 +0200 Subject: [PATCH 2/4] fix(covers): target HA 2026.9, and stop depending on a helper HA keeps moving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI was testing Home Assistant 2026.2.3 while the local image ran 2026.9.2, and get_astral_observer was added between the two — so the import failed only on CI. The version was never chosen: the workflow pins Python 3.13, which caps the resolvable pytest-homeassistant-custom-component, and that caps Home Assistant. This integration targets 2026.9 and above, so the workflows now run on Python 3.14 (which resolves 2026.9.3), hacs.json refuses installation below 2026.9.0 instead of 2026.2.2, and the README says so. The astral observer is built from hass.config.latitude / longitude / elevation rather than imported. With the floor raised, get_astral_observer would be available — but homeassistant.helpers.sun has already moved this ground once, get_astral_location is deprecated for removal in 2027.7, and building the observer is the single line that helper contains, against config attributes that have been stable for years. Verified on containers matching both jobs (python:3.14 + requirements_test.txt, Home Assistant 2026.9.3): 397 passed, py_compile and validate.sh clean. Co-Authored-By: Claude Opus 5 --- .github/workflows/tests.yml | 2 +- .github/workflows/validate.yml | 2 +- README.md | 1 + custom_components/homeshift/cover_manager.py | 19 ++++++++++++++++--- hacs.json | 2 +- tests/test_coordinator_features.py | 2 +- 6 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b1627a7..f17cedc 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.13" + python-version: "3.14" - name: Install dependencies run: | diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index dd3cb57..3463baf 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.11" + python-version: "3.14" - name: Validate Python syntax run: | diff --git a/README.md b/README.md index e15fb53..3a82755 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ At regular intervals (every 5 minutes by default), it reads your calendar, picks ## ✅ Requirements +- **Home Assistant 2026.9** or newer - A **calendar** entity containing your work or schedule events - A **calendar** entity for public holidays - The [Scheduler integration](https://github.com/nielsfaber/scheduler-component) to automate scheduler switches diff --git a/custom_components/homeshift/cover_manager.py b/custom_components/homeshift/cover_manager.py index 9c92fe5..a2e0ff6 100644 --- a/custom_components/homeshift/cover_manager.py +++ b/custom_components/homeshift/cover_manager.py @@ -13,6 +13,7 @@ from datetime import date, datetime, time as dt_time, timedelta from typing import TYPE_CHECKING, Callable +from astral import Observer from astral.sun import ( SunDirection, elevation as astral_elevation, @@ -24,7 +25,6 @@ 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.helpers.sun import get_astral_observer from homeassistant.util import dt as dt_util from .const import ( @@ -80,6 +80,19 @@ def _parse_time_str(time_str: str) -> dt_time | None: return None +def _astral_observer(hass: HomeAssistant) -> Observer: + """Return an astral observer for the Home Assistant location. + + Built here rather than taken from homeassistant.helpers.sun, which has + already moved this ground once: get_astral_location, still present, is + deprecated for removal in 2027.7 and logs a warning naming this + integration, and its replacement get_astral_observer is too recent to + exist everywhere. This is exactly what that replacement does, against + config attributes that have been stable for years. + """ + return Observer(hass.config.latitude, hass.config.longitude, hass.config.elevation) + + def next_sun_datetime(hass: HomeAssistant, attr_name: str) -> datetime | None: """Return a sun.sun 'next_*' attribute as a local datetime, or None.""" sun_state = hass.states.get("sun.sun") @@ -116,7 +129,7 @@ def elevation_for_sunset_offset(hass: HomeAssistant, offset_minutes: int, year: degree the config flow offers. Returns None when the location cannot be resolved. """ - observer = get_astral_observer(hass) + observer = _astral_observer(hass) elevations: list[float] = [] for month, day in ((3, 21), (6, 21), (9, 21), (12, 21)): try: @@ -143,7 +156,7 @@ def sun_time_at_elevation(hass: HomeAssistant, elevation: float, on_date: date) """ try: event = astral_time_at_elevation( - get_astral_observer(hass), + _astral_observer(hass), elevation, date=on_date, direction=SunDirection.SETTING, diff --git a/hacs.json b/hacs.json index df6e750..64f66ae 100644 --- a/hacs.json +++ b/hacs.json @@ -2,5 +2,5 @@ "name": "HomeShift", "domain": "home_shift", "render_readme": true, - "homeassistant": "2026.2.2" + "homeassistant": "2026.9.0" } \ No newline at end of file diff --git a/tests/test_coordinator_features.py b/tests/test_coordinator_features.py index 0cb13c9..3586d6f 100644 --- a/tests/test_coordinator_features.py +++ b/tests/test_coordinator_features.py @@ -2911,7 +2911,7 @@ class TestSunTimeAtElevation: """sun_time_at_elevation(): the astral lookup behind the elevation trigger.""" def _hass(self, latitude=48.85, longitude=2.35, time_zone="Europe/Paris"): - """A hass carrying a real location — get_astral_location needs a dict data store.""" + """A hass carrying a real location, which is all the astral lookup reads.""" hass = make_mock_hass() hass.data = {} hass.config.latitude = latitude From d5b4ed6567f0606b6c10c565a889db9a16d78cb2 Mon Sep 17 00:00:00 2001 From: Gamso Date: Sun, 20 Sep 2026 19:14:20 +0200 Subject: [PATCH 3/4] refactor(config_flow): put the evening close above the opening settings The closing elevation sat between "Earliest Open Time" and the per-mode opening times, splitting the opening settings in two. It now comes first, on its own, so the step reads as one closing field followed by everything about opening. The translation files are reordered to match, which keeps them diffable against the form. Co-Authored-By: Claude Opus 5 --- custom_components/homeshift/config_flow.py | 11 +++++++---- custom_components/homeshift/translations/en.json | 8 ++++---- custom_components/homeshift/translations/fr.json | 8 ++++---- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/custom_components/homeshift/config_flow.py b/custom_components/homeshift/config_flow.py index af02574..46b0a2f 100644 --- a/custom_components/homeshift/config_flow.py +++ b/custom_components/homeshift/config_flow.py @@ -471,11 +471,10 @@ def _validate_close_elevation(hass, user_input: dict[str, Any]) -> dict[str, str def _daily_cover_schema(hass, data: dict[str, Any]) -> vol.Schema: """Build the native daily cover open/close schedule form schema.""" + # The evening close comes first, on its own; everything after it — + # the sunrise floor and the per-mode times — is about opening, so the + # two concerns read as two blocks instead of interleaving. schema_dict: dict = { - vol.Optional( - CONF_SUNRISE_EARLIEST, - default=data.get(CONF_SUNRISE_EARLIEST, DEFAULT_SUNRISE_EARLIEST), - ): selector.TimeSelector(), vol.Optional( CONF_DAILY_COVER_CLOSE_ELEVATION, default=_close_elevation(data), @@ -488,6 +487,10 @@ def _daily_cover_schema(hass, data: dict[str, Any]) -> vol.Schema: mode=selector.NumberSelectorMode.BOX, ) ), + vol.Optional( + CONF_SUNRISE_EARLIEST, + default=data.get(CONF_SUNRISE_EARLIEST, DEFAULT_SUNRISE_EARLIEST), + ): selector.TimeSelector(), } schema_dict.update(_daily_open_time_fields(data)) return vol.Schema(schema_dict) diff --git a/custom_components/homeshift/translations/en.json b/custom_components/homeshift/translations/en.json index 542d77b..c69d52a 100644 --- a/custom_components/homeshift/translations/en.json +++ b/custom_components/homeshift/translations/en.json @@ -101,16 +101,16 @@ "title": "Daily Cover Schedule", "description": "Natively open and close your covers every day, without a separate Scheduler-integration entity. Which covers are driven is set under Individual Covers; this section sets the times. Kept independent 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, when the setting sun drops past the elevation below.\n\nAt {close_elevation}°, that is {close_at_elevation} tonight.", "data": { - "sunrise_earliest": "Earliest Allowed Opening Time", "daily_cover_close_elevation": "Sun elevation at closing", + "sunrise_earliest": "Earliest Allowed Opening Time", "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_earliest": "The sunrise-based opening time will be the later of actual sunrise and this value.", "daily_cover_close_elevation": "Degrees above the horizon — the 'elevation' attribute of sun.sun, so a light level rather than a delay. 0° is sunset, -2° (recommended) is about ten minutes later, -4° is twenty to twenty-five minutes later with the room already dark, -6° is the end of civil twilight. Positive values close while the sun is still up.", + "sunrise_earliest": "The sunrise-based opening time will be the later of actual sunrise and this value.", "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.", @@ -265,16 +265,16 @@ "title": "Daily Cover Schedule", "description": "Natively open and close your covers every day, without a separate Scheduler-integration entity. Which covers are driven is set under Individual Covers; this section sets the times. Kept independent 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, when the setting sun drops past the elevation below.\n\nAt {close_elevation}°, that is {close_at_elevation} tonight.", "data": { - "sunrise_earliest": "Earliest Allowed Opening Time", "daily_cover_close_elevation": "Sun elevation at closing", + "sunrise_earliest": "Earliest Allowed Opening Time", "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_earliest": "The sunrise-based opening time will be the later of actual sunrise and this value.", "daily_cover_close_elevation": "Degrees above the horizon — the 'elevation' attribute of sun.sun, so a light level rather than a delay. 0° is sunset, -2° (recommended) is about ten minutes later, -4° is twenty to twenty-five minutes later with the room already dark, -6° is the end of civil twilight. Positive values close while the sun is still up.", + "sunrise_earliest": "The sunrise-based opening time will be the later of actual sunrise and this value.", "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 d3faca9..8de5cd8 100644 --- a/custom_components/homeshift/translations/fr.json +++ b/custom_components/homeshift/translations/fr.json @@ -101,16 +101,16 @@ "title": "Planning quotidien des volets", "description": "Ouvre et ferme vos volets tous les jours nativement, sans passer par une entité Scheduler séparée. Les volets concernés se définissent dans Volets individuels ; cette section règle les horaires. 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, quand le soleil descend sous l'élévation ci-dessous.\n\nÀ {close_elevation}°, ce sera {close_at_elevation} ce soir.", "data": { - "sunrise_earliest": "Heure d'ouverture minimale", "daily_cover_close_elevation": "Élévation du soleil à la fermeture", + "sunrise_earliest": "Heure d'ouverture minimale", "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_earliest": "L'heure d'ouverture au lever du soleil sera la plus tardive entre le lever réel et cette valeur.", "daily_cover_close_elevation": "Degrés au-dessus de l'horizon — l'attribut « elevation » de sun.sun, donc un niveau de lumière plutôt qu'un délai. 0° = coucher du soleil, -2° (recommandé) ≈ dix minutes plus tard, -4° ≈ vingt à vingt-cinq minutes plus tard, pièce déjà sombre, -6° = fin du crépuscule civil. Une valeur positive ferme alors que le soleil est encore levé.", + "sunrise_earliest": "L'heure d'ouverture au lever du soleil sera la plus tardive entre le lever réel et cette valeur.", "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'.", @@ -265,16 +265,16 @@ "title": "Planning quotidien des volets", "description": "Ouvre et ferme vos volets tous les jours nativement, sans passer par une entité Scheduler séparée. Les volets concernés se définissent dans Volets individuels ; cette section règle les horaires. 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, quand le soleil descend sous l'élévation ci-dessous.\n\nÀ {close_elevation}°, ce sera {close_at_elevation} ce soir.", "data": { - "sunrise_earliest": "Heure d'ouverture minimale", "daily_cover_close_elevation": "Élévation du soleil à la fermeture", + "sunrise_earliest": "Heure d'ouverture minimale", "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_earliest": "L'heure d'ouverture au lever du soleil sera la plus tardive entre le lever réel et cette valeur.", "daily_cover_close_elevation": "Degrés au-dessus de l'horizon — l'attribut « elevation » de sun.sun, donc un niveau de lumière plutôt qu'un délai. 0° = coucher du soleil, -2° (recommandé) ≈ dix minutes plus tard, -4° ≈ vingt à vingt-cinq minutes plus tard, pièce déjà sombre, -6° = fin du crépuscule civil. Une valeur positive ferme alors que le soleil est encore levé.", + "sunrise_earliest": "L'heure d'ouverture au lever du soleil sera la plus tardive entre le lever réel et cette valeur.", "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'.", From e89eabdfa82fe74cb979102f90d85f9f4232c84e Mon Sep 17 00:00:00 2001 From: Gamso Date: Tue, 22 Sep 2026 20:06:57 +0200 Subject: [PATCH 4/4] bump version to 1.9.0 --- custom_components/homeshift/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/homeshift/manifest.json b/custom_components/homeshift/manifest.json index 111fddb..56165b4 100644 --- a/custom_components/homeshift/manifest.json +++ b/custom_components/homeshift/manifest.json @@ -1,7 +1,7 @@ { "domain": "homeshift", "name": "HomeShift", - "version": "1.7.1", + "version": "1.9.0", "documentation": "https://github.com/Gamso/day_mode", "requirements": [], "dependencies": [],