From 676eb525bf8c20287b37649ce2a6afd58944fcc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:52:24 +0000 Subject: [PATCH 1/2] Further closed counts as protected (0.8.1) Wind and rain no longer open a cover that is already further closed than its protection position, e.g. a balcony shutter at 0 % with a wind position of 20 % stayed closed but went up to 20 % when the door was closed. Not for a protection position of 100 %: retracting external venetian blinds always runs. New window option "Further closed counts as protected" (default on). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8JsQKc7cECnoGEkCNSRtu --- README.en.md | 6 ++--- README.md | 6 ++--- .../shutter_assistant/config_flow.py | 2 ++ custom_components/shutter_assistant/const.py | 3 +++ custom_components/shutter_assistant/engine.py | 2 ++ .../shutter_assistant/frontend/labels.js | 8 +++++++ .../shutter_assistant/frontend/panel.js | 2 +- .../shutter_assistant/logic/window.py | 8 ++++++- .../shutter_assistant/manifest.json | 2 +- .../shutter_assistant/strings.json | 2 ++ .../shutter_assistant/translations/de.json | 2 ++ .../shutter_assistant/translations/en.json | 2 ++ tests/ha/test_protection.py | 23 +++++++++++++++++++ tests/logic/test_window.py | 13 +++++++++++ 14 files changed, 72 insertions(+), 9 deletions(-) diff --git a/README.en.md b/README.en.md index db9ab89..5159830 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.8.0. +> Status: version 0.8.1. ## How it works @@ -44,7 +44,7 @@ The integration is event driven: it only reacts to state changes of the configur * **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. +* **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. A cover already **further closed** than its protection position counts as protected and is not opened (option **Further closed counts as protected**, default on; not for 100 %, retracting external venetian blinds always runs). 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`. @@ -155,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 (weather protection for this window, 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, further closed counts as protected, lockout protection). 4. **Morning opening:** on or off, mode and period, then the settings of the mode. Positions: 100 % = open, 0 % = closed. diff --git a/README.md b/README.md index 3044c47..c1c13a4 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.8.0. +> Status: Version 0.8.1. ## Funktionsweise @@ -44,7 +44,7 @@ Die Integration arbeitet ereignisgesteuert: Sie reagiert nur auf Zustandswechsel * **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. +* **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. Ein Rollo, das schon **weiter zu** ist als seine Schutzposition, gilt als geschützt und wird nicht hochgefahren (Option **Weiter geschlossen gilt als geschützt**, Standard an; nicht bei 100 %, Raffstores einfahren läuft immer). 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`. @@ -155,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 (Wetterschutz für dieses Fenster, 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, weiter geschlossen gilt als geschützt, Aussperrschutz). 4. **Morgenöffnung:** an oder aus, Modus und Zeitfenster, danach die Einstellungen des Modus. Positionen: 100 % = offen, 0 % = zu. diff --git a/custom_components/shutter_assistant/config_flow.py b/custom_components/shutter_assistant/config_flow.py index 475644e..e946727 100644 --- a/custom_components/shutter_assistant/config_flow.py +++ b/custom_components/shutter_assistant/config_flow.py @@ -88,6 +88,7 @@ CONF_OVERRIDE_IGNORE_EVENING, CONF_OVERRIDE_SUNRISE, CONF_OVERRIDE_TRIGGER, + CONF_PROTECTION_KEEP_CLOSED, CONF_PROTECTION_RELEASE, CONF_PV_SENSORS, CONF_RADIATION_SENSORS, @@ -808,6 +809,7 @@ def window_protection_fields(values: dict[str, Any]) -> dict[Any, Any]: _opt(CONF_WINDOW_WIND_THRESHOLD, values): _number(0, 300, 0.5), _opt(CONF_RAIN_POSITION, values): _number(0, 100, 1, "%"), _opt(CONF_FROST_POSITION, values): _number(0, 100, 1, "%"), + _wreq(CONF_PROTECTION_KEEP_CLOSED, values): BooleanSelector(), _wreq(CONF_LOCKOUT, values): BooleanSelector(), } diff --git a/custom_components/shutter_assistant/const.py b/custom_components/shutter_assistant/const.py index 2d5462e..5dc5fe4 100644 --- a/custom_components/shutter_assistant/const.py +++ b/custom_components/shutter_assistant/const.py @@ -172,6 +172,8 @@ # Weather protection per window: only with this switch on, then the positions # (empty = not affected / off). CONF_WEATHER_PROTECTION = "weather_protection" +# a cover already further closed than its protection position counts as protected +CONF_PROTECTION_KEEP_CLOSED = "protection_keep_closed" CONF_WIND_POSITION = "wind_position" # own wind threshold of the window (empty = the global threshold) CONF_WINDOW_WIND_THRESHOLD = "window_wind_threshold" @@ -256,6 +258,7 @@ CONF_TILT_SENSORS: [], CONF_LOCKOUT: False, CONF_WEATHER_PROTECTION: False, + CONF_PROTECTION_KEEP_CLOSED: True, 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 4b14403..3b304f1 100644 --- a/custom_components/shutter_assistant/engine.py +++ b/custom_components/shutter_assistant/engine.py @@ -102,6 +102,7 @@ CONF_OVERRIDE_IGNORE_EVENING, CONF_OVERRIDE_SUNRISE, CONF_OVERRIDE_TRIGGER, + CONF_PROTECTION_KEEP_CLOSED, CONF_PROTECTION_RELEASE, CONF_RAIN_ENTITY, CONF_RAIN_POSITION, @@ -433,6 +434,7 @@ def _build_window(self, window_id: str, title: str, data: dict[str, Any]) -> Win morning_position=int(conf[CONF_MORNING_POSITION]), morning_sun_position=bool(conf[CONF_MORNING_SUN_POSITION]), weather_protection=bool(conf[CONF_WEATHER_PROTECTION]), + protection_keep_closed=bool(conf[CONF_PROTECTION_KEEP_CLOSED]), wind_position=_int(conf.get(CONF_WIND_POSITION)), rain_position=_int(conf.get(CONF_RAIN_POSITION)), frost_position=_int(conf.get(CONF_FROST_POSITION)), diff --git a/custom_components/shutter_assistant/frontend/labels.js b/custom_components/shutter_assistant/frontend/labels.js index 44399e0..df1c0b5 100644 --- a/custom_components/shutter_assistant/frontend/labels.js +++ b/custom_components/shutter_assistant/frontend/labels.js @@ -257,6 +257,10 @@ export const LABELS = { "Toleranz für Auto-Hoch", "Auto-Hoch öffnet nur Rollos, die auf der Sonnenschutz-Position bis so viel darüber stehen. Von Hand anders gestellte Rollos bleiben." ], + "protection_keep_closed": [ + "Weiter geschlossen gilt als geschützt", + "Ein Rollo, das schon weiter zu ist als seine Position bei Wind oder Regen, wird nicht dorthin hochgefahren (z. B. zu auf 0 % bei Wind-Position 20 %). Nicht bei 100 %: Raffstores einfahren läuft immer." + ], "protection_release": [ "Freigabe nach", "Ein Schutz endet erst, wenn der Wert so lange wieder im sicheren Bereich war. Ein nicht verfügbarer Sensor beendet nie einen Schutz." @@ -687,6 +691,10 @@ export const LABELS = { "Tolerance for auto up", "Auto up only opens covers standing at the sun protection position up to this much above it. Covers moved elsewhere by hand stay." ], + "protection_keep_closed": [ + "Further closed counts as protected", + "A cover already further closed than its position with wind or rain is not opened to it (e.g. closed at 0 % with a wind position of 20 %). Not for a position of 100 %: retracting external venetian blinds always runs." + ], "protection_release": [ "Release after", "A protection ends only after the value stayed on the safe side this long. An unavailable sensor never ends a protection." diff --git a/custom_components/shutter_assistant/frontend/panel.js b/custom_components/shutter_assistant/frontend/panel.js index 303a78d..553eb04 100644 --- a/custom_components/shutter_assistant/frontend/panel.js +++ b/custom_components/shutter_assistant/frontend/panel.js @@ -431,7 +431,7 @@ WINDOW_CARDS.push({ // 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", "wind_position", "window_wind_threshold", "rain_position", "frost_position", "protection_keep_closed", "lockout_protection"] : ["weather_protection", "lockout_protection"], }); const GROUP_CARDS = [ diff --git a/custom_components/shutter_assistant/logic/window.py b/custom_components/shutter_assistant/logic/window.py index 90814dd..0bea629 100644 --- a/custom_components/shutter_assistant/logic/window.py +++ b/custom_components/shutter_assistant/logic/window.py @@ -85,6 +85,9 @@ class WindowRules: # position (None = not affected), lowest position of closing movements # during frost (None = off) weather_protection: bool = False + # a cover already further closed than the protection position is not opened + # to it (not for 100 %: retracting external venetian blinds) + protection_keep_closed: bool = True wind_position: int | None = None rain_position: int | None = None frost_position: int | None = None @@ -341,13 +344,16 @@ 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, not with the automatic off and not during the restart protection. An open - window only prevents closing (as everywhere).""" + window only prevents closing (as everywhere). A cover already further + closed counts as protected (option, not for a protection position of 100 %).""" 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 if target is None or target == s.position: return [] + if rules.protection_keep_closed and s.position < target < OPEN: + return [] if target < s.position and s.window_blocks: return [] return [_move(target, R_PROTECT_WIND if wind else R_PROTECT_RAIN)] diff --git a/custom_components/shutter_assistant/manifest.json b/custom_components/shutter_assistant/manifest.json index 7995bec..879216d 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.8.0" + "version": "0.8.1" } diff --git a/custom_components/shutter_assistant/strings.json b/custom_components/shutter_assistant/strings.json index 53cddaa..a8abe6e 100644 --- a/custom_components/shutter_assistant/strings.json +++ b/custom_components/shutter_assistant/strings.json @@ -456,6 +456,7 @@ "window_wind_threshold": "Own wind threshold", "rain_position": "Position with rain", "frost_position": "Lowest position with frost", + "protection_keep_closed": "Further closed counts as protected", "lockout_protection": "Lockout protection (door)" }, "data_description": { @@ -464,6 +465,7 @@ "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 %.", + "protection_keep_closed": "A cover already further closed than its position with wind or rain is not opened to it (e.g. closed at 0 % with a wind position of 20 %). Not for a position of 100 %: retracting external venetian blinds always runs.", "lockout_protection": "While the contact reports open, nothing closes below the ventilation position, also not with the window exception or the weather protection." } } diff --git a/custom_components/shutter_assistant/translations/de.json b/custom_components/shutter_assistant/translations/de.json index 528d1b3..2ce764b 100644 --- a/custom_components/shutter_assistant/translations/de.json +++ b/custom_components/shutter_assistant/translations/de.json @@ -456,6 +456,7 @@ "window_wind_threshold": "Eigene Windschwelle", "rain_position": "Position bei Regen", "frost_position": "Tiefste Position bei Frost", + "protection_keep_closed": "Weiter geschlossen gilt als geschützt", "lockout_protection": "Aussperrschutz (Tür)" }, "data_description": { @@ -464,6 +465,7 @@ "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 %.", + "protection_keep_closed": "Ein Rollo, das schon weiter zu ist als seine Position bei Wind oder Regen, wird nicht dorthin hochgefahren (z. B. zu auf 0 % bei Wind-Position 20 %). Nicht bei 100 %: Raffstores einfahren läuft immer.", "lockout_protection": "Solange der Kontakt offen meldet, fährt nichts unter die Lüftungsposition, auch nicht mit Fenster-Ausnahme oder Wetterschutz." } } diff --git a/custom_components/shutter_assistant/translations/en.json b/custom_components/shutter_assistant/translations/en.json index 53cddaa..a8abe6e 100644 --- a/custom_components/shutter_assistant/translations/en.json +++ b/custom_components/shutter_assistant/translations/en.json @@ -456,6 +456,7 @@ "window_wind_threshold": "Own wind threshold", "rain_position": "Position with rain", "frost_position": "Lowest position with frost", + "protection_keep_closed": "Further closed counts as protected", "lockout_protection": "Lockout protection (door)" }, "data_description": { @@ -464,6 +465,7 @@ "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 %.", + "protection_keep_closed": "A cover already further closed than its position with wind or rain is not opened to it (e.g. closed at 0 % with a wind position of 20 %). Not for a position of 100 %: retracting external venetian blinds always runs.", "lockout_protection": "While the contact reports open, nothing closes below the ventilation position, also not with the window exception or the weather protection." } } diff --git a/tests/ha/test_protection.py b/tests/ha/test_protection.py index 3e9c25a..71a47cf 100644 --- a/tests/ha/test_protection.py +++ b/tests/ha/test_protection.py @@ -243,6 +243,29 @@ async def test_saving_settings_does_not_move_protected_covers_again( assert hass.states.get(STATUS).state == "protection" +async def test_closing_the_door_does_not_open_a_cover_further_closed( + 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") + hass.states.async_set(CONTACT, "on") + set_cover(hass, 0) + await setup_entry( + hass, + make_entry( + window={"weather_protection": True, "wind_position": 20, "ventilation": False}, + wind_entity=WIND, + ), + ) + hass.states.async_set(WIND, "90") + await hass.async_block_till_done() + hass.states.async_set(CONTACT, "off") + await hass.async_block_till_done() + await advance(hass, freezer, 30, step=1) + assert positions(calls) == [] and calls["open_cover"] == [] + 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: diff --git a/tests/logic/test_window.py b/tests/logic/test_window.py index 743d4d5..686b11f 100644 --- a/tests/logic/test_window.py +++ b/tests/logic/test_window.py @@ -338,6 +338,19 @@ def test_protect_also_with_override_not_with_automatic_off() -> None: assert w.protect(rules, replace(s, protection=None)) == [] +def test_protect_does_not_open_a_cover_already_further_closed() -> None: + rules = replace(WEATHER, wind_position=20) + s = Situation(position=0, protection="wind") + assert w.protect(rules, s) == [] + assert kinds(w.protect(rules, replace(s, position=60))) == [(w.POSITION, w.R_PROTECT_WIND, 20)] + # option off: opens to the protection position + off = replace(rules, protection_keep_closed=False) + assert kinds(w.protect(off, s)) == [(w.POSITION, w.R_PROTECT_WIND, 20)] + # 100 %: retracting external venetian blinds always runs + blinds = replace(WEATHER, wind_position=100) + assert kinds(w.protect(blinds, s)) == [(w.POSITION, w.R_PROTECT_WIND, 100)] + + def test_protect_rain_does_not_close_an_open_window() -> None: rules = replace(WEATHER, rain_position=0) s = Situation(position=100, protection="rain") From 4952307a1e5a556e2bc224e0f69c5b4d71768c5e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:55:15 +0000 Subject: [PATCH 2/2] Show the current value of the triggering sensor (0.9.0) While the wind or rain protection runs, the overview of the panel and the card show the current value of the sensor that triggered it, e.g. "Wind protection 62 km/h". The wind and rain protection binary sensors name that sensor in the new attribute `sensor`; panel and card read its value live. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8JsQKc7cECnoGEkCNSRtu --- README.en.md | 3 ++- README.md | 3 ++- .../shutter_assistant/binary_sensor.py | 8 +++++++- .../shutter_assistant/frontend/card.js | 17 +++++++++++++++-- .../shutter_assistant/frontend/panel.js | 13 +++++++++++-- .../shutter_assistant/manifest.json | 2 +- tests/ha/test_protection.py | 1 + 7 files changed, 39 insertions(+), 8 deletions(-) diff --git a/README.en.md b/README.en.md index 5159830..37f69e4 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.8.1. +> Status: version 0.9.0. ## How it works @@ -47,6 +47,7 @@ The integration is event driven: it only reacts to state changes of the configur * **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. A cover already **further closed** than its protection position counts as protected and is not opened (option **Further closed counts as protected**, default on; not for 100 %, retracting external venetian blinds always runs). 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. +* **Display:** while the wind or rain protection runs, the overview in the panel and the card show the current value of the sensor that triggered it (e.g. "Wind protection 62 km/h"). The wind and rain protection binary sensors name the sensor in the attribute `sensor`. * Start and end are written to the logbook, plus the event `shutter_assistant_protection` with `kind` (wind, rain, frost) and `active`. ### Reliability diff --git a/README.md b/README.md index c1c13a4..02b9134 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.8.1. +> Status: Version 0.9.0. ## Funktionsweise @@ -47,6 +47,7 @@ Die Integration arbeitet ereignisgesteuert: Sie reagiert nur auf Zustandswechsel * **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. Ein Rollo, das schon **weiter zu** ist als seine Schutzposition, gilt als geschützt und wird nicht hochgefahren (Option **Weiter geschlossen gilt als geschützt**, Standard an; nicht bei 100 %, Raffstores einfahren läuft immer). 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. +* **Anzeige:** Solange Wind- oder Regenschutz läuft, zeigen die Übersicht im Panel und die Karte den aktuellen Wert des auslösenden Sensors (z. B. „Windschutz 62 km/h“). Die Binärsensoren Wind- und Regenschutz nennen den Sensor im Attribut `sensor`. * Ein- und Ende stehen im Logbuch, dazu das Ereignis `shutter_assistant_protection` mit `kind` (wind, rain, frost) und `active`. ### Zuverlässigkeit diff --git a/custom_components/shutter_assistant/binary_sensor.py b/custom_components/shutter_assistant/binary_sensor.py index 8416852..baecf74 100644 --- a/custom_components/shutter_assistant/binary_sensor.py +++ b/custom_components/shutter_assistant/binary_sensor.py @@ -10,9 +10,10 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util +from .const import CONF_RAIN_ENTITY, CONF_WIND_ENTITY from .engine import ShutterConfigEntry, ShutterEngine from .entity import GlobalEntity, WindowEntity -from .logic.protection import KINDS, WIND +from .logic.protection import KINDS, RAIN, WIND PARALLEL_UPDATES = 0 @@ -100,6 +101,11 @@ def extra_state_attributes(self) -> dict[str, Any]: if due is not None else None } + # the sensor whose current value the overview shows next to the protection + if self.kind in (WIND, RAIN): + attrs["sensor"] = engine.opt( + CONF_WIND_ENTITY if self.kind == WIND else CONF_RAIN_ENTITY + ) if self.kind == WIND: attrs["windows"] = sorted( win.name for win in engine.windows.values() if win.protection == WIND diff --git a/custom_components/shutter_assistant/frontend/card.js b/custom_components/shutter_assistant/frontend/card.js index 783be89..f39958b 100644 --- a/custom_components/shutter_assistant/frontend/card.js +++ b/custom_components/shutter_assistant/frontend/card.js @@ -147,6 +147,14 @@ class ShutterAssistantCard extends HTMLElement { return id ? this._hass?.states?.[id] : undefined; } + _value(id) { + const st = this._state(id); + const number = Number(st?.state); + if (!st || st.state === "" || Number.isNaN(number)) return ""; + const text = number.toLocaleString(this._hass?.locale?.language || this.lang, { maximumFractionDigits: 1 }); + return `${text} ${st.attributes.unit_of_measurement || ""}`.trim(); + } + _windows() { const all = this._data?.windows || []; const wanted = this._config?.windows; @@ -156,6 +164,8 @@ class ShutterAssistantCard extends HTMLElement { _ids() { const ids = ["sun.sun", ...Object.values(this._data?.entities || {})]; + // sensors of the weather protection (their value is shown while it runs) + for (const [kind] of PROTECTIONS) ids.push(this._state(this._data?.entities?.[`${kind}_protection`])?.attributes?.sensor); for (const win of this._windows()) ids.push(win.cover, ...Object.values(win.entities || {})); return ids.filter(Boolean); } @@ -242,8 +252,11 @@ class ShutterAssistantCard extends HTMLElement { const auto = this._state(ent.automatic); if (auto && auto.state === "off") chips.push(`${esc(ui.automaticOff)}`); for (const [kind, icon] of PROTECTIONS) { - if (this._state(ent[`${kind}_protection`])?.state === "on") { - chips.push(`${esc(ui.protections[kind])}`); + const guard = this._state(ent[`${kind}_protection`]); + if (guard?.state === "on") { + const value = this._value(guard.attributes?.sensor); + const text = value ? `${ui.protections[kind]} · ${value}` : ui.protections[kind]; + chips.push(`${esc(text)}`); } } const compass = this._config.compass === false ? "" : this._compass(); diff --git a/custom_components/shutter_assistant/frontend/panel.js b/custom_components/shutter_assistant/frontend/panel.js index 553eb04..eae7d53 100644 --- a/custom_components/shutter_assistant/frontend/panel.js +++ b/custom_components/shutter_assistant/frontend/panel.js @@ -778,7 +778,7 @@ class ShutterAssistantPanel extends HTMLElement { _liveIds() { const cfg = this._cfg; - const ids = [...Object.values(cfg.entities || {}), "sun.sun", cfg.options.outdoor_temperature, ...(cfg.options.vacation_entities || [])]; + const ids = [...Object.values(cfg.entities || {}), "sun.sun", cfg.options.outdoor_temperature, cfg.options.wind_entity, cfg.options.rain_entity, ...(cfg.options.vacation_entities || [])]; for (const win of this._windows) { ids.push(...Object.values(win.entities || {}), win.data.cover, ...(win.data.window_sensors || []), ...(win.data.tilt_sensors || [])); } @@ -930,7 +930,10 @@ class ShutterAssistantPanel extends HTMLElement { if (!guard) continue; const active = guard.state === "on"; const release = guard.attributes?.release_at; - facts.push([icon, ui.protections[kind], active ? (release ? ui.until(this._time(release)) : ui.on) : ui.off, active ? "danger" : ""]); + // while it runs: the current value of the sensor that triggered it + const value = active ? this._numericValue(guard.attributes?.sensor) : ""; + const text = active ? [value, release ? ui.until(this._time(release)) : value ? "" : ui.on].filter(Boolean).join(" · ") : ui.off; + facts.push([icon, ui.protections[kind], text, active ? "danger" : ""]); } const restart = auto?.attributes?.restart_protection_until; if (restart) facts.push(["mdi:restart", ui.restart, ui.until(this._time(restart)), "muted"]); @@ -1291,6 +1294,12 @@ class ShutterAssistantPanel extends HTMLElement { // ------------------------------------------------------------------ groups tab + _numericValue(id) { + const st = this._state(id); + if (!st || INVALID.includes(st.state) || this._num(st.state) === "") return ""; + return `${this._num(st.state, 1)} ${st.attributes.unit_of_measurement || ""}`.trim(); + } + _sensorValue(id) { const st = this._state(id); if (!st || INVALID.includes(st.state)) return this.ui.unavailable; diff --git a/custom_components/shutter_assistant/manifest.json b/custom_components/shutter_assistant/manifest.json index 879216d..b8360b3 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.8.1" + "version": "0.9.0" } diff --git a/tests/ha/test_protection.py b/tests/ha/test_protection.py index 71a47cf..8080532 100644 --- a/tests/ha/test_protection.py +++ b/tests/ha/test_protection.py @@ -104,6 +104,7 @@ def moved() -> list[tuple[str, int]]: assert moved() == [(second, 20)] assert hass.states.get(WIND_ON).state == "on" assert hass.states.get(WIND_ON).attributes["windows"] == ["Balcony"] + assert hass.states.get(WIND_ON).attributes["sensor"] == WIND assert events[-1].data == { "kind": "wind", "active": True,