From 10d54a5fcb9f9d6b7e3d25557c3e0ef77ccbe26a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 12:34:16 +0000 Subject: [PATCH] Weather protection switched on per window, off with the automatic (0.8.0) The weather protection now acts only on windows with the new setting "Weather protection for this window" (default off). Without it, wind, rain and frost never move or limit the cover, even with positions entered (they are kept and hidden in the panel until it is switched on). With the automatic off (globally or for the window) there is no weather protection either: a running protection ends for these windows without a movement, and starts again when the automatic is turned back on. The running protection of every window is now stored, so saving settings (the reload after changing the global or a window's own wind threshold) no longer moves all protected covers again, also not covers moved by hand during the protection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8JsQKc7cECnoGEkCNSRtu --- README.en.md | 10 +- README.md | 10 +- .../shutter_assistant/config_flow.py | 4 +- custom_components/shutter_assistant/const.py | 5 +- custom_components/shutter_assistant/engine.py | 42 +++++-- .../shutter_assistant/frontend/labels.js | 12 +- .../shutter_assistant/frontend/panel.js | 20 +++- .../shutter_assistant/logic/window.py | 16 ++- .../shutter_assistant/manifest.json | 2 +- .../shutter_assistant/messages.py | 4 + .../shutter_assistant/strings.json | 4 +- custom_components/shutter_assistant/switch.py | 2 +- .../shutter_assistant/translations/de.json | 4 +- .../shutter_assistant/translations/en.json | 4 +- tests/ha/test_protection.py | 112 +++++++++++++++++- tests/logic/test_window.py | 27 +++-- 16 files changed, 228 insertions(+), 50 deletions(-) diff --git a/README.en.md b/README.en.md index 05c8ef2..db9ab89 100644 --- a/README.en.md +++ b/README.en.md @@ -6,7 +6,7 @@ Custom integration for Home Assistant that controls roller shutters and blinds a It typically replaces a bundle of YAML automations with many helpers (input_boolean, input_number, timer) for sun protection, auto up, evening run, closing after the window was closed, ventilation, override and vacation. -> Status: version 0.7.1. +> Status: version 0.8.0. ## How it works @@ -42,7 +42,9 @@ The integration is event driven: it only reacts to state changes of the configur ### Weather protection -* **Wind and rain:** globally a wind sensor with a threshold (in the unit of the sensor) and a rain sensor (on/off or an amount above a threshold). Per window a **position with wind** and **with rain**, empty = not affected (e.g. external venetian blinds to 100 % with wind, a roof window to 0 % with rain). An **own wind threshold** on a window replaces the global threshold for that window (e.g. a balcony shutter already from 50 km/h, the others only from 75 km/h); it has its own release time, and the wind protection binary sensor is on while any window is protected (attribute `windows`). The protection starts at once and moves the affected covers to their position, **also during an override and with the automatic off** (not during the restart protection; caught up afterwards). Wind comes before rain. An open window only prevents closing; it is caught up as soon as the window is closed. +* **Switched on per window:** the weather protection only acts on windows with **weather protection for this window** on (default off). Without it, wind, rain and frost never move or limit the cover, even with positions entered (they are kept). +* **Automatic off = no weather protection:** with the automatic off (globally or for the window) the weather protection does not move either; a running protection ends for these windows without a movement. If the automatic is turned on again while the protection still runs, the covers move to their protection position. +* **Wind and rain:** globally a wind sensor with a threshold (in the unit of the sensor) and a rain sensor (on/off or an amount above a threshold). Per window a **position with wind** and **with rain**, empty = not affected (e.g. external venetian blinds to 100 % with wind, a roof window to 0 % with rain). An **own wind threshold** on a window replaces the global threshold for that window (e.g. a balcony shutter already from 50 km/h, the others only from 75 km/h); it has its own release time, and the wind protection binary sensor is on while any window is protected (attribute `windows`). The protection starts at once and moves the affected covers to their position once, **also during an override** (not during the restart protection; caught up afterwards). A cover moved by hand during the protection keeps that position: saving settings (reload) does not move covers that are already protected again. Wind comes before rain. An open window only prevents closing; it is caught up as soon as the window is closed. * **While the protection runs,** all other rules of the window rest (status "Weather protection"). It ends only after the value stayed on the safe side for the **release time** (default 15 min). An unavailable sensor never ends a protection. Afterwards the automatic decides again (sun protection, evening run); the old position is not restored. * **Frost:** windows with a **lowest position with frost** (e.g. 5 %) close only down to it while the outdoor temperature is at or below the frost threshold (default 1 °C), so the cover cannot freeze on. This applies to all automatic movements and services, not to the wind and rain protection. * Start and end are written to the logbook, plus the event `shutter_assistant_protection` with `kind` (wind, rain, frost) and `active`. @@ -153,7 +155,7 @@ Values are checked exactly like in the dialogs (same ranges, units and rules). T 1. **Window:** name, cover, window contacts (also window handles), tilt contacts, group (no group = no sun protection). 2. **Sun protection:** thresholds for the sensor types of the group, outdoor temperature at least (19 °C), hold times temperature (10 min) and sun (5 min), room temperature (thermostat: too warm from setpoint + 2 K, sensor: from 24 °C), air conditioner, sun protection position (10 %), tolerance for auto up (5 %), auto up, notification about an open window, manual sun protection up to (25 %), detection period (5 min), follow-up (5 min), moving onto the sun protection position counts. -3. **Behaviour:** override (60 min, sunrise, evening and vacation runs despite override), additional conditions, evening (evening run, evening position 0 %, close after window closed, own evening times), ventilation (30 %, counts as closed up to 5 %, 3 s in the evening, 60 s during the day), vacation simulation, weather protection & door (position with wind, own wind threshold, position with rain, lowest position with frost, lockout protection). +3. **Behaviour:** override (60 min, sunrise, evening and vacation runs despite override), additional conditions, evening (evening run, evening position 0 %, close after window closed, own evening times), ventilation (30 %, counts as closed up to 5 %, 3 s in the evening, 60 s during the day), vacation simulation, weather protection & door (weather protection for this window, position with wind, own wind threshold, position with rain, lowest position with frost, lockout protection). 4. **Morning opening:** on or off, mode and period, then the settings of the mode. Positions: 100 % = open, 0 % = closed. @@ -174,7 +176,7 @@ Per window (own device): * Sensor **Status** (weather protection, automatic off, restart protection, manual override, closed for the evening, sun protection, vacation simulation, ready) * Sensor **Last reason** (text, code, position, time) * Sensor **Next action** (point in time, attribute `action`: evening run, morning opening, end of the override, sun enters or leaves the sector, weather protection may end) -* Switches **Automatic** (only this window; the weather protection keeps working) and **Sun protection** (off = not lowered for the sun; covers already lowered open again as usual), both survive restarts +* Switches **Automatic** (only this window; off = no weather protection either) and **Sun protection** (off = not lowered for the sun; covers already lowered open again as usual), both survive restarts * Binary sensors **Manual override** (with end), **Sun condition** (with partial conditions), **Sun protection today**, **Closed this evening** For control cards on a dashboard, **Sun condition** (thresholds, sensors, azimuth sector, hold times and when they end) and **Status** (cover, window contacts, evening start, override end) provide all values as attributes. **Evening mode** has the start and end of the current or next evening, **Automatic** the end of a running restart protection. diff --git a/README.md b/README.md index 001ff92..3044c47 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Custom Integration für Home Assistant, die Rollos und Raffstores automatisch st Die Integration ersetzt typischerweise ein Bündel von YAML-Automationen mit vielen Helfern (input_boolean, input_number, timer) für Sonnenschutz, Auto-Hoch, Abendlauf, Nachziehen, Lüften, Übersteuerung und Urlaub. -> Status: Version 0.7.1. +> Status: Version 0.8.0. ## Funktionsweise @@ -42,7 +42,9 @@ Die Integration arbeitet ereignisgesteuert: Sie reagiert nur auf Zustandswechsel ### Wetterschutz -* **Wind und Regen:** global ein Windsensor mit Schwelle (in der Einheit des Sensors) und ein Regensensor (an/aus oder eine Menge über einer Schwelle). Pro Fenster eine **Position bei Wind** und **bei Regen**, leer = nicht betroffen (z. B. Raffstores bei Wind auf 100 %, ein Dachfenster bei Regen auf 0 %). Eine **eigene Windschwelle** am Fenster ersetzt für dieses Fenster die globale Schwelle (z. B. ein Balkonrollo schon ab 50 km/h, die übrigen erst ab 75 km/h); sie hat eine eigene Freigabezeit, und der Binärsensor Windschutz ist an, solange irgendein Fenster geschützt wird (Attribut `windows`). Der Schutz beginnt sofort und fährt die betroffenen Rollos auf ihre Position, **auch bei Übersteuerung und ausgeschalteter Automatik** (nicht im Neustart-Schutz, danach nachgeholt). Wind geht vor Regen. Ein offenes Fenster verhindert nur Schließfahrten; sie werden nachgeholt, sobald es zu ist. +* **Pro Fenster einschalten:** Der Wetterschutz wirkt nur auf Fenster, bei denen **Wetterschutz für dieses Fenster** eingeschaltet ist (Standard aus). Ohne den Schalter fahren oder begrenzen Wind, Regen und Frost das Rollo nie, auch wenn Positionen eingetragen sind (sie bleiben erhalten). +* **Automatik aus = kein Wetterschutz:** Ist die Automatik aus (global oder für das Fenster), fährt auch der Wetterschutz nicht; ein laufender Schutz endet für diese Fenster ohne Fahrt. Wird die Automatik wieder eingeschaltet, während der Schutz noch läuft, fahren die Rollos auf ihre Schutzposition. +* **Wind und Regen:** global ein Windsensor mit Schwelle (in der Einheit des Sensors) und ein Regensensor (an/aus oder eine Menge über einer Schwelle). Pro Fenster eine **Position bei Wind** und **bei Regen**, leer = nicht betroffen (z. B. Raffstores bei Wind auf 100 %, ein Dachfenster bei Regen auf 0 %). Eine **eigene Windschwelle** am Fenster ersetzt für dieses Fenster die globale Schwelle (z. B. ein Balkonrollo schon ab 50 km/h, die übrigen erst ab 75 km/h); sie hat eine eigene Freigabezeit, und der Binärsensor Windschutz ist an, solange irgendein Fenster geschützt wird (Attribut `windows`). Der Schutz beginnt sofort und fährt die betroffenen Rollos einmal auf ihre Position, **auch bei Übersteuerung** (nicht im Neustart-Schutz, danach nachgeholt). Wer ein Rollo während des Schutzes von Hand verfährt, behält diese Position: Speichern von Einstellungen (Neuladen) fährt bereits geschützte Rollos nicht erneut. Wind geht vor Regen. Ein offenes Fenster verhindert nur Schließfahrten; sie werden nachgeholt, sobald es zu ist. * **Solange der Schutz läuft,** ruhen alle anderen Regeln für das Fenster (Status „Wetterschutz“). Er endet erst, wenn der Wert die **Freigabezeit** (Standard 15 min) lang wieder im sicheren Bereich war. Ein nicht verfügbarer Sensor beendet nie einen Schutz. Danach entscheidet die Automatik neu (Sonnenschutz, Abendlauf); die alte Position wird nicht wiederhergestellt. * **Frost:** Fenster mit einer **tiefsten Position bei Frost** (z. B. 5 %) schließen bei einer Außentemperatur bis zur Frostschwelle (Standard 1 °C) nur bis dorthin, damit das Rollo nicht anfriert. Das gilt für alle automatischen Fahrten und Services, nicht für den Wind- und Regenschutz. * Ein- und Ende stehen im Logbuch, dazu das Ereignis `shutter_assistant_protection` mit `kind` (wind, rain, frost) und `active`. @@ -153,7 +155,7 @@ Die Werte werden genauso geprüft wie in den Dialogen (gleiche Bereiche, Einheit 1. **Fenster:** Name, Rollo, Fensterkontakte (auch Fenstergriffe), Kipp-Kontakte, Gruppe (ohne Gruppe kein Sonnenschutz). 2. **Sonnenschutz:** Schwellen für die Sensortypen der Gruppe, Außentemperatur mindestens (19 °C), Haltezeiten Temperatur (10 min) und Sonne (5 min), Raumtemperatur (Thermostat: zu warm ab Sollwert + 2 K, Sensor: ab 24 °C), Klimaanlage, Sonnenschutz-Position (10 %), Toleranz für Auto-Hoch (5 %), Auto-Hoch, Benachrichtigung bei offenem Fenster, manueller Sonnenschutz bis (25 %), Erkennungszeitraum (5 min), Nachlauf (5 min), Fahrt auf Sonnenschutz-Position zählt. -3. **Verhalten:** Übersteuerung (60 min, Sonnenaufgang, Abend- und Urlaubsfahrten trotz Übersteuerung), Zusatzbedingungen, Abend (Abendlauf, Abendposition 0 %, Nachziehen, eigene Abendzeiten), Lüften (30 %, gilt als geschlossen bis 5 %, 3 s abends, 60 s tagsüber), Urlaubssimulation, Wetterschutz & Tür (Position bei Wind, eigene Windschwelle, Position bei Regen, tiefste Position bei Frost, Aussperrschutz). +3. **Verhalten:** Übersteuerung (60 min, Sonnenaufgang, Abend- und Urlaubsfahrten trotz Übersteuerung), Zusatzbedingungen, Abend (Abendlauf, Abendposition 0 %, Nachziehen, eigene Abendzeiten), Lüften (30 %, gilt als geschlossen bis 5 %, 3 s abends, 60 s tagsüber), Urlaubssimulation, Wetterschutz & Tür (Wetterschutz für dieses Fenster, Position bei Wind, eigene Windschwelle, Position bei Regen, tiefste Position bei Frost, Aussperrschutz). 4. **Morgenöffnung:** an oder aus, Modus und Zeitfenster, danach die Einstellungen des Modus. Positionen: 100 % = offen, 0 % = zu. @@ -174,7 +176,7 @@ Je Fenster (eigenes Gerät): * Sensor **Status** (Wetterschutz, Automatik aus, Neustart-Schutz, Manuelle Übersteuerung, Abends geschlossen, Sonnenschutz, Urlaubssimulation, Bereit) * Sensor **Letzter Grund** (Text, Code, Position, Zeit) * Sensor **Nächste Aktion** (Zeitpunkt, Attribut `action`: Abendlauf, Morgenöffnung, Ende der Übersteuerung, Sonne kommt in oder verlässt den Bereich, Wetterschutz kann enden) -* Schalter **Automatik** (nur dieses Fenster; der Wetterschutz läuft weiter) und **Sonnenschutz** (aus = bei Sonne nicht absenken; schon abgesenkte Rollos öffnen wieder wie gewohnt), beide bleiben über Neustarts erhalten +* Schalter **Automatik** (nur dieses Fenster; aus = auch kein Wetterschutz) und **Sonnenschutz** (aus = bei Sonne nicht absenken; schon abgesenkte Rollos öffnen wieder wie gewohnt), beide bleiben über Neustarts erhalten * Binärsensoren **Manuelle Übersteuerung** (mit Ende), **Sonnenbedingung** (mit Teilbedingungen), **Sonnenschutz heute**, **Abends geschlossen** Für Kontroll-Karten im Dashboard liefern **Sonnenbedingung** (Schwellen, Sensoren, Azimut-Bereich, Haltezeiten und wann sie ablaufen) und **Status** (Rollo, Fensterkontakte, Abendbeginn, Übersteuerung bis) alle Werte als Attribute. **Abendmodus** hat Beginn und Ende des aktuellen oder nächsten Abends, **Automatik** das Ende eines laufenden Neustart-Schutzes. diff --git a/custom_components/shutter_assistant/config_flow.py b/custom_components/shutter_assistant/config_flow.py index d81e8af..475644e 100644 --- a/custom_components/shutter_assistant/config_flow.py +++ b/custom_components/shutter_assistant/config_flow.py @@ -126,6 +126,7 @@ CONF_VENTILATION, CONF_VERIFY_AFTER, CONF_WEATHER, + CONF_WEATHER_PROTECTION, CONF_WEEKEND_DAYS, CONF_WIND_ENTITY, CONF_WIND_POSITION, @@ -800,8 +801,9 @@ def window_ventilation_fields(values: dict[str, Any]) -> dict[Any, Any]: def window_protection_fields(values: dict[str, Any]) -> dict[Any, Any]: - """Weather protection (empty = not affected) and the lockout protection.""" + """Weather protection (switch, positions: empty = not affected) and the lockout protection.""" return { + _wreq(CONF_WEATHER_PROTECTION, values): BooleanSelector(), _opt(CONF_WIND_POSITION, values): _number(0, 100, 1, "%"), _opt(CONF_WINDOW_WIND_THRESHOLD, values): _number(0, 300, 0.5), _opt(CONF_RAIN_POSITION, values): _number(0, 100, 1, "%"), diff --git a/custom_components/shutter_assistant/const.py b/custom_components/shutter_assistant/const.py index fb97254..2d5462e 100644 --- a/custom_components/shutter_assistant/const.py +++ b/custom_components/shutter_assistant/const.py @@ -169,7 +169,9 @@ # as window contacts) and the lockout protection of doors. CONF_TILT_SENSORS = "tilt_sensors" CONF_LOCKOUT = "lockout_protection" -# Weather protection per window (empty = not affected / off). +# Weather protection per window: only with this switch on, then the positions +# (empty = not affected / off). +CONF_WEATHER_PROTECTION = "weather_protection" CONF_WIND_POSITION = "wind_position" # own wind threshold of the window (empty = the global threshold) CONF_WINDOW_WIND_THRESHOLD = "window_wind_threshold" @@ -253,6 +255,7 @@ CONF_WINDOW_SENSORS: [], CONF_TILT_SENSORS: [], CONF_LOCKOUT: False, + CONF_WEATHER_PROTECTION: False, CONF_PV_THRESHOLD: 130.0, CONF_LUX_THRESHOLD: 14500.0, CONF_RADIATION_THRESHOLD: 150.0, diff --git a/custom_components/shutter_assistant/engine.py b/custom_components/shutter_assistant/engine.py index 14f5430..4b14403 100644 --- a/custom_components/shutter_assistant/engine.py +++ b/custom_components/shutter_assistant/engine.py @@ -137,6 +137,7 @@ CONF_VENTILATION, CONF_VERIFY_AFTER, CONF_WEATHER, + CONF_WEATHER_PROTECTION, CONF_WEEKEND_DAYS, CONF_WIND_ENTITY, CONF_WIND_POSITION, @@ -272,7 +273,8 @@ class Window: evening_missed: date | None = None tilt_sensors: list[str] = field(default_factory=list) window_tilted: bool = False - # wind or rain protection running for this window + # wind or rain protection running for this window (persisted, so a reload + # does not move covers that are already protected or were moved by hand since) protection: str | None = None # own wind threshold (None = the global one) and its protection state (persisted) wind_threshold: float | None = None @@ -430,6 +432,7 @@ def _build_window(self, window_id: str, title: str, data: dict[str, Any]) -> Win morning=bool(conf[CONF_MORNING]), morning_position=int(conf[CONF_MORNING_POSITION]), morning_sun_position=bool(conf[CONF_MORNING_SUN_POSITION]), + weather_protection=bool(conf[CONF_WEATHER_PROTECTION]), wind_position=_int(conf.get(CONF_WIND_POSITION)), rain_position=_int(conf.get(CONF_RAIN_POSITION)), frost_position=_int(conf.get(CONF_FROST_POSITION)), @@ -437,7 +440,11 @@ def _build_window(self, window_id: str, title: str, data: dict[str, Any]) -> Win ), evening_settings=evening_settings, morning_settings=morning_settings, - wind_threshold=as_float(conf.get(CONF_WINDOW_WIND_THRESHOLD)), + wind_threshold=( + as_float(conf.get(CONF_WINDOW_WIND_THRESHOLD)) + if conf[CONF_WEATHER_PROTECTION] + else None + ), temp_hold=Hold(float(conf[CONF_TEMP_HOLD]) * 60), live_hold=Hold(float(conf[CONF_SUN_HOLD]) * 60), no_temp=FollowUp(float(conf[CONF_MANUAL_FOLLOW_UP]) * 60), @@ -520,6 +527,8 @@ def _restore(self, stored: dict[str, Any]) -> None: win.last_reason = data.get("last_reason") or {} win.automatic = bool(data.get("automatic", True)) win.sun_enabled = bool(data.get("sun_enabled", True)) + protection = data.get("protection") + win.protection = protection if protection in (WIND, RAIN) else None guard_data = data.get("wind_guard") or {} if win.wind_guard is not None and guard_data.get("active"): win.wind_guard.active = True @@ -557,6 +566,7 @@ def _data_to_save(self) -> dict[str, Any]: "automatic": win.automatic, "sun_enabled": win.sun_enabled, "wind_guard": _guard_data(win.wind_guard), + "protection": win.protection, } for wid, win in self.windows.items() }, @@ -1326,7 +1336,10 @@ def _on_outdoor(self, _event: Event[EventStateChangedData]) -> None: def protection_enabled(self, kind: str) -> bool: """Wind and rain need their sensor, frost a window with a frost position.""" if kind == FROST: - return any(win.rules.frost_position is not None for win in self.windows.values()) + return any( + win.rules.weather_protection and win.rules.frost_position is not None + for win in self.windows.values() + ) return bool(self.opt(CONF_WIND_ENTITY if kind == WIND else CONF_RAIN_ENTITY)) def _protection_values(self) -> dict[str, bool | None]: @@ -1418,14 +1431,15 @@ def _update_protection(self, now: datetime) -> bool: key = f"{WIND}:{win.window_id}" if self._update_guard(win.wind_guard, over, ts, WIND, key, win): changed = True - if changed: - self._save() active = self.active_protections for win in self.windows.values(): own = active if win.wind_guard is not None: own = (active - {WIND}) | ({WIND} if win.wind_guard.active else set()) - kind = rules.protection_kind(win.rules, own) + # with the automatic off there is no weather protection either + kind = ( + rules.protection_kind(win.rules, own) if self.automatic and win.automatic else None + ) if kind == win.protection: continue changed = True @@ -1434,6 +1448,8 @@ def _update_protection(self, now: datetime) -> bool: self._protect(win, now) else: self._after_protection(win, now) + if changed: + self._save() return changed def _protect(self, win: Window, now: datetime) -> None: @@ -1441,7 +1457,10 @@ def _protect(self, win: Window, now: datetime) -> None: def _after_protection(self, win: Window, now: datetime) -> None: """Protection ended: no old position is restored, the rules decide again.""" - self._set_reason(win, "protection_end", None, now) + if self.automatic and win.automatic: + self._set_reason(win, "protection_end", None, now) + else: + self._set_reason(win, "protection_automatic_off", None, now) self._refresh(win, now) if self._evening(win, now): self._run_evening(win, now) @@ -1717,8 +1736,11 @@ def set_window_switch(self, window_id: str, key: str, on: bool) -> None: win = self.windows[window_id] setattr(win, key, on) self._save() + now = dt_util.now() + if key == "automatic": + # the weather protection follows the automatic + self._update_protection(now) if on: - now = dt_util.now() self._refresh(win, now) self._evaluate(win, now) self.async_update_listeners() @@ -2245,8 +2267,10 @@ def _on_morning_input(self, event: Event[EventStateChangedData]) -> None: async def async_set_automatic(self, enabled: bool) -> None: self.automatic = enabled self._save() + now = dt_util.now() + # the weather protection follows the automatic + self._update_protection(now) if enabled: - now = dt_util.now() day = self._evening_day(now) for win in self.windows.values(): if win.evening_missed == day and self._evening(win, now): diff --git a/custom_components/shutter_assistant/frontend/labels.js b/custom_components/shutter_assistant/frontend/labels.js index a9b5e33..44399e0 100644 --- a/custom_components/shutter_assistant/frontend/labels.js +++ b/custom_components/shutter_assistant/frontend/labels.js @@ -389,6 +389,10 @@ export const LABELS = { "Wetter-Entität", "Ihre Tagesvorhersage entscheidet beim Tageswechsel, ob heute ein Sommertag ist." ], + "weather_protection": [ + "Wetterschutz für dieses Fenster", + "Aus = Wind, Regen und Frost fahren oder begrenzen dieses Rollo nie, egal was unten eingetragen ist. Für jedes Fenster einschalten, das mitmachen soll. Mit ausgeschalteter Automatik (global oder für dieses Fenster) gibt es auch keinen Wetterschutz." + ], "weekend_days": [ "Wochenendtage", "Nur ohne Werktag-Sensor: an diesen Tagen gilt die Wochenend-Uhrzeit der Morgenöffnung." @@ -399,7 +403,7 @@ export const LABELS = { ], "wind_position": [ "Position bei Wind", - "Leer = nicht betroffen. Z. B. 100 % fährt Raffstores hoch." + "Leer = nicht betroffen. Z. B. 100 % fährt Raffstores hoch. Nur mit eingeschaltetem Wetterschutz des Fensters." ], "wind_threshold": [ "Windschutz ab", @@ -815,6 +819,10 @@ export const LABELS = { "Weather entity", "Its daily forecast decides at the day change whether today is a summer day." ], + "weather_protection": [ + "Weather protection for this window", + "Off = wind, rain and frost never move or limit this cover, whatever the positions below say. Turn it on for every window that should take part. With the automatic off (globally or for this window) there is no weather protection either." + ], "weekend_days": [ "Weekend days", "Only without a workday sensor: on these days the weekend time of the morning opening applies." @@ -825,7 +833,7 @@ export const LABELS = { ], "wind_position": [ "Position with wind", - "Empty = not affected. E.g. 100 % retracts external venetian blinds." + "Empty = not affected. E.g. 100 % retracts external venetian blinds. Only with the weather protection of the window on." ], "wind_threshold": [ "Wind protection from", diff --git a/custom_components/shutter_assistant/frontend/panel.js b/custom_components/shutter_assistant/frontend/panel.js index 92038fc..303a78d 100644 --- a/custom_components/shutter_assistant/frontend/panel.js +++ b/custom_components/shutter_assistant/frontend/panel.js @@ -25,8 +25,8 @@ const UI = { w_vent: ["Lüften & Urlaub", "Lüftungsposition beim Öffnen des Fensters und Teilnahme an der Urlaubssimulation."], w_morning: ["Morgenöffnung", "Nie bei Übersteuerung, Öffnungssperre oder einem seit dem Abend von Hand verstellten Rollo. Im Urlaub öffnet die Simulation."], w_conditions: ["Zusatzbedingungen", "Öffnungssperre (z. B. Fernseher an), Schließ-Auslöser und Fenster-Ausnahme."], - w_protection: ["Wetterschutz & Tür", "Positionen bei Wind und Regen, Spalt bei Frost und der Aussperrschutz einer Tür. Die Sensoren stehen unter Allgemein > Wetterschutz."], - protection: ["Wetterschutz", "Wind und Regen fahren die beteiligten Fenster auf ihre Schutzposition, auch bei Übersteuerung. Frost lässt beim Schließen einen Spalt. Die Positionen stehen am Fenster."], + w_protection: ["Wetterschutz & Tür", "Wetterschutz für dieses Fenster ein/aus, Positionen bei Wind und Regen, Spalt bei Frost und der Aussperrschutz einer Tür. Die Sensoren stehen unter Allgemein > Wetterschutz."], + protection: ["Wetterschutz", "Wind und Regen fahren nur die Fenster mit eingeschaltetem Wetterschutz auf ihre Schutzposition, auch bei Übersteuerung, nicht bei ausgeschalteter Automatik. Frost lässt beim Schließen einen Spalt. Die Positionen stehen am Fenster."], g_basics: ["Gruppe", "Eine Himmelsrichtung. Die Schwellen stehen am Fenster."], g_sensors: ["Sonnensensoren", "Jeder Sensor der Gruppe muss die Schwelle des Fensters erreichen."], compass: ["Sonnenkompass", "Aktueller Sonnenstand und die Bereiche der Gruppen."], @@ -182,8 +182,8 @@ const UI = { w_vent: ["Ventilation & vacation", "Ventilation position when the window opens and taking part in the vacation simulation."], w_morning: ["Morning opening", "Never with an active override, an opening block or a cover moved by hand since the evening. During vacation the simulation opens instead."], w_conditions: ["Additional conditions", "Opening block (e.g. TV on), close trigger and window exception."], - w_protection: ["Weather protection & door", "Positions with wind and rain, a gap with frost and the lockout protection of a door. The sensors are under General > Weather protection."], - protection: ["Weather protection", "Wind and rain move the windows that take part to their protection position, also during an override. Frost keeps a gap when closing. The positions are set per window."], + w_protection: ["Weather protection & door", "Weather protection for this window on/off, positions with wind and rain, a gap with frost and the lockout protection of a door. The sensors are under General > Weather protection."], + protection: ["Weather protection", "Wind and rain move only the windows with the weather protection on to their protection position, also during an override, not with the automatic off. Frost keeps a gap when closing. The positions are set per window."], g_basics: ["Group", "A direction of the sun. Thresholds are set per window."], g_sensors: ["Sun sensors", "Every sensor of the group must reach the threshold of the window."], compass: ["Sun compass", "Current position of the sun and the sectors of the groups."], @@ -425,13 +425,21 @@ const WINDOW_CARDS = [ ], }, ]; -WINDOW_CARDS.push({ id: "w_protection", icon: "mdi:weather-windy", fields: () => ["wind_position", "window_wind_threshold", "rain_position", "frost_position", "lockout_protection"] }); +WINDOW_CARDS.push({ + id: "w_protection", + icon: "mdi:weather-windy", + // the positions keep their values while the weather protection is off + fields: (d) => + d.weather_protection + ? ["weather_protection", "wind_position", "window_wind_threshold", "rain_position", "frost_position", "lockout_protection"] + : ["weather_protection", "lockout_protection"], +}); const GROUP_CARDS = [ { id: "g_basics", icon: "mdi:compass-outline", fields: () => ["name", "wall", "azimuth_from", "azimuth_to"] }, { id: "g_sensors", icon: "mdi:weather-sunny", fields: () => ["radiation_sensors", "pv_sensors", "lux_sensors"] }, ]; // changing one of these shows or hides other fields -const REDRAW_KEYS = new Set(["group", "wall", "evening_custom", "morning_enabled", "morning_mode"]); +const REDRAW_KEYS = new Set(["group", "wall", "evening_custom", "morning_enabled", "morning_mode", "weather_protection"]); // walls of the house by their angle to the front (right and left seen from inside) const WALLS = [ ["front", 0], diff --git a/custom_components/shutter_assistant/logic/window.py b/custom_components/shutter_assistant/logic/window.py index 343ba9c..90814dd 100644 --- a/custom_components/shutter_assistant/logic/window.py +++ b/custom_components/shutter_assistant/logic/window.py @@ -81,8 +81,10 @@ class WindowRules: morning: bool = False morning_position: int = OPEN morning_sun_position: bool = True - # weather protection: target position (None = not affected), lowest - # position of closing movements during frost (None = off) + # weather protection: only with the switch of the window on; target + # position (None = not affected), lowest position of closing movements + # during frost (None = off) + weather_protection: bool = False wind_position: int | None = None rain_position: int | None = None frost_position: int | None = None @@ -327,6 +329,8 @@ def morning(rules: WindowRules, s: Situation, manual_since_evening: bool) -> lis def protection_kind(rules: WindowRules, active: set[str] | frozenset[str]) -> str | None: """The running protection that applies to the window (wind before rain).""" + if not rules.weather_protection: + return None if WIND in active and rules.wind_position is not None: return WIND if RAIN in active and rules.rain_position is not None: @@ -335,10 +339,10 @@ def protection_kind(rules: WindowRules, active: set[str] | frozenset[str]) -> st def protect(rules: WindowRules, s: Situation) -> list[Action]: - """Wind or rain: move to the protection position. Also during an override - and with the automatic off, not during the restart protection. An open + """Wind or rain: move to the protection position. Also during an override, + not with the automatic off and not during the restart protection. An open window only prevents closing (as everywhere).""" - if s.protection is None or s.startup or s.position is None: + if s.protection is None or not s.automatic or s.startup or s.position is None: return [] wind = s.protection == WIND target = rules.wind_position if wind else rules.rain_position @@ -361,7 +365,7 @@ def limit_target( if s.position is None or target >= s.position: return target floor = CLOSED - if not protective and s.frost and rules.frost_position is not None: + if not protective and s.frost and rules.weather_protection and rules.frost_position is not None: floor = max(floor, rules.frost_position) if not protective and s.window_tilted: floor = max(floor, rules.ventilation_position) diff --git a/custom_components/shutter_assistant/manifest.json b/custom_components/shutter_assistant/manifest.json index 298371a..7995bec 100644 --- a/custom_components/shutter_assistant/manifest.json +++ b/custom_components/shutter_assistant/manifest.json @@ -10,5 +10,5 @@ "issue_tracker": "https://github.com/Jumpmaster2/Shutter-Assistant/issues", "requirements": [], "single_config_entry": true, - "version": "0.7.1" + "version": "0.8.0" } diff --git a/custom_components/shutter_assistant/messages.py b/custom_components/shutter_assistant/messages.py index e4d357e..8dff5fb 100644 --- a/custom_components/shutter_assistant/messages.py +++ b/custom_components/shutter_assistant/messages.py @@ -97,6 +97,10 @@ "de": "Wetterschutz beendet: Automatik wieder aktiv", "en": "Weather protection ended: automatic active again", }, + "protection_automatic_off": { + "de": "Wetterschutz beendet: Automatik aus", + "en": "Weather protection ended: automatic off", + }, "kind_wind": {"de": "Windschutz", "en": "Wind protection"}, "kind_rain": {"de": "Regenschutz", "en": "Rain protection"}, "kind_frost": {"de": "Frostschutz", "en": "Frost protection"}, diff --git a/custom_components/shutter_assistant/strings.json b/custom_components/shutter_assistant/strings.json index 79d736b..53cddaa 100644 --- a/custom_components/shutter_assistant/strings.json +++ b/custom_components/shutter_assistant/strings.json @@ -451,6 +451,7 @@ "protection": { "name": "Weather protection & door", "data": { + "weather_protection": "Weather protection for this window", "wind_position": "Position with wind", "window_wind_threshold": "Own wind threshold", "rain_position": "Position with rain", @@ -458,7 +459,8 @@ "lockout_protection": "Lockout protection (door)" }, "data_description": { - "wind_position": "Empty = not affected. E.g. 100 % retracts external venetian blinds.", + "weather_protection": "Off = wind, rain and frost never move or limit this cover, whatever the positions below say. Turn it on for every window that should take part. With the automatic off (globally or for this window) there is no weather protection either.", + "wind_position": "Empty = not affected. E.g. 100 % retracts external venetian blinds. Only with the weather protection of the window on.", "window_wind_threshold": "Empty = wind protection from the threshold of the basic settings. Otherwise this window is protected already or only from this value (unit of the wind sensor).", "rain_position": "Empty = not affected. E.g. 0 % for a roof window shutter.", "frost_position": "Empty = off. With frost, closing stops at this position so the cover cannot freeze on, e.g. 5 %.", diff --git a/custom_components/shutter_assistant/switch.py b/custom_components/shutter_assistant/switch.py index 85b9c75..8e667b8 100644 --- a/custom_components/shutter_assistant/switch.py +++ b/custom_components/shutter_assistant/switch.py @@ -61,7 +61,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: class WindowSwitch(WindowEntity, SwitchEntity): """Automatic (off = like the main switch, but for this window) or sun protection (off = not lowered for the sun; opens again if it was lowered) of one window. - The weather protection keeps working.""" + The automatic off also ends the weather protection of the window.""" def __init__(self, engine: ShutterEngine, window_id: str, key: str, attribute: str) -> None: super().__init__(engine, window_id, key) diff --git a/custom_components/shutter_assistant/translations/de.json b/custom_components/shutter_assistant/translations/de.json index da9e4fd..528d1b3 100644 --- a/custom_components/shutter_assistant/translations/de.json +++ b/custom_components/shutter_assistant/translations/de.json @@ -451,6 +451,7 @@ "protection": { "name": "Wetterschutz & Tür", "data": { + "weather_protection": "Wetterschutz für dieses Fenster", "wind_position": "Position bei Wind", "window_wind_threshold": "Eigene Windschwelle", "rain_position": "Position bei Regen", @@ -458,7 +459,8 @@ "lockout_protection": "Aussperrschutz (Tür)" }, "data_description": { - "wind_position": "Leer = nicht betroffen. Z. B. 100 % fährt Raffstores hoch.", + "weather_protection": "Aus = Wind, Regen und Frost fahren oder begrenzen dieses Rollo nie, egal was unten eingetragen ist. Für jedes Fenster einschalten, das mitmachen soll. Mit ausgeschalteter Automatik (global oder für dieses Fenster) gibt es auch keinen Wetterschutz.", + "wind_position": "Leer = nicht betroffen. Z. B. 100 % fährt Raffstores hoch. Nur mit eingeschaltetem Wetterschutz des Fensters.", "window_wind_threshold": "Leer = Windschutz ab der Schwelle der Grundeinstellungen. Sonst schützt dieses Fenster schon oder erst ab diesem Wert (Einheit des Windsensors).", "rain_position": "Leer = nicht betroffen. Z. B. 0 % für ein Dachfenster.", "frost_position": "Leer = aus. Bei Frost stoppt das Schließen hier, damit das Rollo nicht anfriert, z. B. 5 %.", diff --git a/custom_components/shutter_assistant/translations/en.json b/custom_components/shutter_assistant/translations/en.json index 79d736b..53cddaa 100644 --- a/custom_components/shutter_assistant/translations/en.json +++ b/custom_components/shutter_assistant/translations/en.json @@ -451,6 +451,7 @@ "protection": { "name": "Weather protection & door", "data": { + "weather_protection": "Weather protection for this window", "wind_position": "Position with wind", "window_wind_threshold": "Own wind threshold", "rain_position": "Position with rain", @@ -458,7 +459,8 @@ "lockout_protection": "Lockout protection (door)" }, "data_description": { - "wind_position": "Empty = not affected. E.g. 100 % retracts external venetian blinds.", + "weather_protection": "Off = wind, rain and frost never move or limit this cover, whatever the positions below say. Turn it on for every window that should take part. With the automatic off (globally or for this window) there is no weather protection either.", + "wind_position": "Empty = not affected. E.g. 100 % retracts external venetian blinds. Only with the weather protection of the window on.", "window_wind_threshold": "Empty = wind protection from the threshold of the basic settings. Otherwise this window is protected already or only from this value (unit of the wind sensor).", "rain_position": "Empty = not affected. E.g. 0 % for a roof window shutter.", "frost_position": "Empty = off. With frost, closing stops at this position so the cover cannot freeze on, e.g. 5 %.", diff --git a/tests/ha/test_protection.py b/tests/ha/test_protection.py index 0d8f339..3e9c25a 100644 --- a/tests/ha/test_protection.py +++ b/tests/ha/test_protection.py @@ -14,6 +14,8 @@ STATUS = "sensor.living_room_status" OVERRIDE = "binary_sensor.living_room_manual_override" WIND_ON = "binary_sensor.shutter_assistant_wind_protection" +AUTOMATIC = "switch.shutter_assistant_automatic" +WINDOW_AUTOMATIC = "switch.living_room_automatic" def positions(calls) -> list[int]: @@ -28,7 +30,11 @@ async def test_wind_moves_also_during_an_override_and_releases_later( set_cover(hass, 40) await setup_entry( hass, - make_entry(window={"wind_position": 100}, wind_entity=WIND, protection_release=15), + make_entry( + window={"weather_protection": True, "wind_position": 100}, + wind_entity=WIND, + protection_release=15, + ), ) events = [] hass.bus.async_listen("shutter_assistant_protection", events.append) @@ -72,12 +78,13 @@ async def test_own_wind_threshold_per_window( "name": "Balcony", "cover": second, "window_sensors": [], + "weather_protection": True, "wind_position": 20, "window_wind_threshold": 50, } } entry = make_entry( - window={"wind_position": 20}, + window={"weather_protection": True, "wind_position": 20}, extra_windows=balcony, wind_entity=WIND, wind_threshold=75, @@ -141,13 +148,110 @@ async def test_windows_without_a_position_are_not_affected( assert hass.states.get(STATUS).state != "protection" +async def test_positions_without_the_switch_of_the_window_do_nothing( + hass: HomeAssistant, base_states, calls, freezer: FrozenDateTimeFactory +) -> None: + freezer.move_to("2026-10-15 12:00:00+02:00") + hass.states.async_set(WIND, "10") + set_cover(hass, 40) + await setup_entry( + hass, + make_entry(window={"wind_position": 100, "window_wind_threshold": 20}, wind_entity=WIND), + ) + hass.states.async_set(WIND, "90") + await hass.async_block_till_done() + await advance(hass, freezer, 30, step=1) + assert calls["open_cover"] == [] and positions(calls) == [] + assert hass.states.get(STATUS).state != "protection" + + +async def test_no_weather_protection_with_the_automatic_off( + hass: HomeAssistant, base_states, calls, freezer: FrozenDateTimeFactory +) -> None: + freezer.move_to("2026-10-15 12:00:00+02:00") + hass.states.async_set(WIND, "10") + set_cover(hass, 40) + await setup_entry( + hass, + make_entry(window={"weather_protection": True, "wind_position": 100}, wind_entity=WIND), + ) + await hass.services.async_call("switch", "turn_off", {"entity_id": AUTOMATIC}, blocking=True) + hass.states.async_set(WIND, "90") + await hass.async_block_till_done() + assert calls["open_cover"] == [] + assert hass.states.get(STATUS).state == "off" + # automatic on again: the running protection applies + await hass.services.async_call("switch", "turn_on", {"entity_id": AUTOMATIC}, blocking=True) + assert len(calls["open_cover"]) == 1 + assert hass.states.get(STATUS).state == "protection" + # the automatic of the window off ends it for this window, nothing moves + await hass.services.async_call( + "switch", "turn_off", {"entity_id": WINDOW_AUTOMATIC}, blocking=True + ) + assert hass.states.get(STATUS).state == "off" + assert len(calls["open_cover"]) == 1 and positions(calls) == [] + reason = hass.states.get("sensor.living_room_last_reason") + assert reason.attributes["code"] == "protection_automatic_off" + + +async def test_saving_settings_does_not_move_protected_covers_again( + hass: HomeAssistant, base_states, calls, freezer: FrozenDateTimeFactory +) -> None: + second = "cover.balcony" + freezer.move_to("2026-10-15 12:00:00+02:00") + hass.states.async_set(WIND, "10") + hass.states.async_set(second, "open", {"current_position": 100, "supported_features": 15}) + set_cover(hass, 100) + balcony = { + "window_balcony": { + "name": "Balcony", + "cover": second, + "window_sensors": [], + "weather_protection": True, + "wind_position": 20, + "window_wind_threshold": 50, + } + } + entry = make_entry( + window={"weather_protection": True, "wind_position": 20}, + extra_windows=balcony, + wind_entity=WIND, + wind_threshold=75, + ) + await setup_entry(hass, entry) + hass.states.async_set(WIND, "80") + await hass.async_block_till_done() + await advance(hass, freezer, 30, step=1) + assert len(calls["set_cover_position"]) == 2 + # moved up by hand during the protection + set_cover(hass, 100) + await hass.async_block_till_done() + await advance(hass, freezer, 30, step=1) + + # own wind threshold of the balcony changed: reload, nothing moves again + sub = entry.subentries["window_balcony"] + hass.config_entries.async_update_subentry( + entry, sub, data={**sub.data, "window_wind_threshold": 60} + ) + await hass.async_block_till_done() + await advance(hass, freezer, 30, step=1) + # global threshold changed: reload, nothing moves again either + hass.config_entries.async_update_entry(entry, options={**entry.options, "wind_threshold": 70}) + await hass.async_block_till_done() + await advance(hass, freezer, 30, step=1) + assert len(calls["set_cover_position"]) == 2 + assert hass.states.get(STATUS).state == "protection" + + async def test_rain_closes_after_the_window_was_closed( hass: HomeAssistant, base_states, calls, freezer: FrozenDateTimeFactory ) -> None: freezer.move_to("2026-10-15 12:00:00+02:00") hass.states.async_set(RAIN, "off") hass.states.async_set(CONTACT, "on") - await setup_entry(hass, make_entry(window={"rain_position": 0}, rain_entity=RAIN)) + await setup_entry( + hass, make_entry(window={"weather_protection": True, "rain_position": 0}, rain_entity=RAIN) + ) hass.states.async_set(RAIN, "on") await hass.async_block_till_done() assert calls["close_cover"] == [] @@ -162,7 +266,7 @@ async def test_frost_keeps_a_gap_in_the_evening( ) -> None: freezer.move_to("2026-12-15 16:50:00+01:00") hass.states.async_set(OUTDOOR, "-3.0") - await setup_entry(hass, make_entry(window={"frost_position": 5})) + await setup_entry(hass, make_entry(window={"weather_protection": True, "frost_position": 5})) assert hass.states.get("binary_sensor.shutter_assistant_frost_protection").state == "on" await advance(hass, freezer, 15 * 60) assert calls["close_cover"] == [] diff --git a/tests/logic/test_window.py b/tests/logic/test_window.py index 60e31e6..743d4d5 100644 --- a/tests/logic/test_window.py +++ b/tests/logic/test_window.py @@ -311,26 +311,35 @@ def test_no_notification_for_an_unavailable_contact() -> None: # ---------------------------------------------------------------- weather protection +WEATHER = replace(RULES, weather_protection=True) + + def test_protection_kind_wind_before_rain() -> None: - rules = replace(RULES, wind_position=100, rain_position=0) + rules = replace(WEATHER, wind_position=100, rain_position=0) assert w.protection_kind(rules, {"wind", "rain"}) == "wind" assert w.protection_kind(rules, {"rain"}) == "rain" # a window without a position is not affected - assert w.protection_kind(RULES, {"wind", "rain"}) is None - assert w.protection_kind(replace(RULES, rain_position=0), {"wind", "rain"}) == "rain" + assert w.protection_kind(WEATHER, {"wind", "rain"}) is None + assert w.protection_kind(replace(WEATHER, rain_position=0), {"wind", "rain"}) == "rain" + + +def test_protection_kind_needs_the_switch_of_the_window() -> None: + rules = replace(RULES, wind_position=100, rain_position=0) + assert w.protection_kind(rules, {"wind", "rain"}) is None -def test_protect_also_with_override_and_automatic_off() -> None: - rules = replace(RULES, wind_position=100) - s = Situation(position=10, protection="wind", override=True, automatic=False) +def test_protect_also_with_override_not_with_automatic_off() -> None: + rules = replace(WEATHER, wind_position=100) + s = Situation(position=10, protection="wind", override=True) assert kinds(w.protect(rules, s)) == [(w.POSITION, w.R_PROTECT_WIND, 100)] + assert w.protect(rules, replace(s, automatic=False)) == [] assert w.protect(rules, replace(s, position=100)) == [] assert w.protect(rules, replace(s, startup=True)) == [] assert w.protect(rules, replace(s, protection=None)) == [] def test_protect_rain_does_not_close_an_open_window() -> None: - rules = replace(RULES, rain_position=0) + rules = replace(WEATHER, rain_position=0) s = Situation(position=100, protection="rain") assert kinds(w.protect(rules, s)) == [(w.POSITION, w.R_PROTECT_RAIN, 0)] assert w.protect(rules, replace(s, window_open=True)) == [] @@ -355,9 +364,11 @@ def test_sun_switch_off_does_not_lower_but_opens_again() -> None: def test_frost_keeps_a_gap_except_for_protection() -> None: - rules = replace(RULES, frost_position=5) + rules = replace(WEATHER, frost_position=5) s = Situation(position=100, frost=True) assert w.limit_target(rules, s, 0) == 5 + # only with the weather protection of the window on + assert w.limit_target(replace(rules, weather_protection=False), s, 0) == 0 assert w.limit_target(rules, s, 0, protective=True) == 0 assert w.limit_target(rules, replace(s, frost=False), 0) == 0 assert w.limit_target(RULES, s, 0) == 0