diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7cff87..66b9582 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@ All notable changes to this project are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+<<<<<<< Updated upstream
## [1.62.1] - 2026-10-01
### Fixed
@@ -23,6 +24,62 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
### Fixed
- Reloading the integration failed with "RuntimeError: Added route will never be executed" followed by "has already been setup!" errors for the switch, sensor and select platforms, leaving the entities unavailable until a Home Assistant restart. The panel's static path is now registered once per Home Assistant runtime instead of on every entry setup ([#3](https://github.com/crandler/CoverAutomatic/issues/3)).
+=======
+## [2.0.0] - 2026-10-01
+
+Major release consolidating all changes since 1.61.1.
+
+### Added
+
+**French language**
+- French added alongside English and German throughout the integration: configuration panel, dashboard card, services and error messages, activity log and HA logbook, entities, facade devices, weather states and built-in scenarios (Everyday → Quotidien…). Follows the Home Assistant language, with French typography (narrow no-break space before ":"). The three languages share the same set of texts.
+
+**Rules**
+- **Home Assistant conditions**: any automation condition (state, numeric state, template, zone, sun…), edited with a form or in YAML and validated live.
+- **Condition groups and NOT**: e.g. (A OR B) AND (C OR D). A negated condition is never met while its input is unavailable.
+- **Scenarios per rule**: each rule selects the scenarios it applies in.
+- **Dawn and dusk** conditions with an offset in minutes; sunrise/sunset and dawn/dusk pairs are complementary.
+- **Outdoor air compared to the room** and **Room occupied** conditions, with an occupancy sensor per cover.
+- **Safety rule**: acts even when the cover is paused, in manual mode, wind protected or with automation off. Never overrides the window lock; between rules, priority decides.
+- **Duplicate a rule**; ▲▼ buttons to order conditions, groups and priorities.
+
+**Covers**
+- **Keep current position when the window opens**.
+- **Travel time** entered or learned automatically, with a global default; removes false "manual" pauses on slow covers.
+- **Resume when the position matches the rule** (only pauses caused by a manual move).
+- **Lost commands resent automatically** (radio frame lost), never after a manual counter-order.
+
+**Global settings**
+- Configurable **wind protection position**.
+- **Thresholds driven by an entity** (temperature setpoints, sunshine threshold), with the typed value as fallback.
+- Configurable **"sun on facade" behaviour**, globally and per cover.
+- **Room temperature colours**: by action needed or like a thermometer.
+
+**Entities and dashboard**
+- **Dashboard card** `custom:cover-automatic-card`: one line per cover; header with scenario, master switch, wind, counters and a "Resume all" button.
+- **Sensors per cover**: active rule, target, position, comfort mode, pause end.
+- **Global entities**: wind protection and number of covers paused / manual / locked.
+
+**Log**
+- **Per-cover filter**, with a button on the cover sheet.
+
+### Changed
+
+- **Cover sheet** reorganised into collapsible sections: General, Window, Room, Sun exposure, Automation.
+- **Settings** reorganised (Sensors, Sun exposure…), with "How does it work?" blocks and options greyed when they have no effect.
+- **Rule editor**: collapsible conditions, draft kept, warning before losing unsaved changes.
+- **Scenarios tab**: rules listed by priority, disabled rules greyed, **Safety** badge.
+- **Mobile**: cover sheet header reachable under the notch, shorter labels, focus and scroll position kept.
+- Panel live updates through a dedicated WebSocket subscription instead of a bus event written to the recorder.
+
+### Fixed
+
+- **Window and wind**: the lock (window open) keeps priority over the wind during the whole storm; lock / wind state survives a restart; an unknown window sensor never lowers the cover; a removed sensor no longer blocks the cover forever.
+- **False manual pauses removed**: slow covers, rule change during a move, window closed while the cover moves to the lock position.
+- **Backups**: complete export; validated import (out-of-range values, unknown references, migrations); runtime state no longer restored from the file.
+- Reloading the integration no longer fails; HA deprecation warning fixed; calculation cycles can no longer interleave; an unreadable rule is disabled instead of applying everywhere.
+- Three full code audits; 1325 automated tests.
+>>>>>>> Stashed changes
## [1.61.1] - 2026-08-18
diff --git a/README.md b/README.md
index cd87880..31544a1 100644
--- a/README.md
+++ b/README.md
@@ -166,8 +166,13 @@ After installation, all configuration is done via the **CoverAutomatic** sidebar
1. **Covers** - Add cover entities to manage
2. **Facades** - Define building facades by cardinal direction (with compass visualization)
+<<<<<<< Updated upstream
3. **Rules** - Create automation rules with conditions (sun, temperature, time, weather, etc.); duplicate an existing rule to use it as a starting point
4. **Scenarios** - Define modes like "Summer", "Winter", "Vacation" to disable specific rules
+=======
+3. **Rules** - Create automation rules with conditions (sun, temperature, time, weather, etc.). Conditions can be negated (NOT) and organised in groups, e.g. (A OR B) AND (C OR D)
+4. **Scenarios** - Define modes like "Summer", "Winter", "Vacation". Each rule chooses the scenarios it belongs to (all by default); inside a scenario a member rule can still be switched off temporarily
+>>>>>>> Stashed changes
5. **Settings** - Configure sensors, comfort temperatures, wind protection, and more
### Example: heat protection by outdoor temperature
@@ -188,6 +193,34 @@ The rule closes the cover only while the sun actually hits that facade **and**
the outdoor temperature is above the threshold, and releases it again once
either condition clears.
+### Conditions on any entity (Home Assistant conditions)
+
+The **Entities** group of the *Add condition* menu adds a condition evaluated
+by Home Assistant itself, in the same format as the `condition:` section of an
+automation:
+
+- **Entity state** / **Entity numeric value** open a form: pick the entity,
+ optionally an attribute, then the states (proposed as buttons, e.g. the zones
+ of a person), *is / is not*, a minimum duration, or above / below / between.
+- **Home Assistant condition (YAML)** accepts any automation condition —
+ templates, zones, devices, nested `and` / `or` / `not`:
+
+```yaml
+condition: or
+conditions:
+ - condition: state
+ entity_id: media_player.living_room
+ state: [playing, paused]
+ - condition: template
+ value_template: "{{ states('sensor.lux') | float(0) > 20000 }}"
+```
+
+Each card has a **Form / YAML** switch, is checked by Home Assistant while you
+type and shows the entities it watches (rules react to them immediately). An
+invalid or incomplete condition is kept but never matches and is marked in red.
+Existing "State is" / "Numeric value" conditions keep working and can be
+converted with one click.
+
### Created Entities
For each managed cover, the integration creates:
@@ -201,8 +234,15 @@ For each facade:
| Entity | Description |
|--------|-------------|
| `sensor.*_sun` | Sun on facade indicator (on/off) |
+<<<<<<< Updated upstream
| `sensor.*_sun_entry` | Time when sun enters facade today (real sun path at your location) |
| `sensor.*_sun_exit` | Time when sun leaves facade today |
+=======
+| `sensor.*_sun_entry` | Time when sun enters facade (today, computed from the real solar path of your location) |
+| `sensor.*_sun_exit` | Time when sun leaves facade |
+>>>>>>> Stashed changes
+
+Entities are created and removed automatically when covers or facades are added or deleted in the panel -- no reload needed.
Global:
- `select.cover_automatic_scenario` - Active scenario selector
@@ -235,13 +275,15 @@ By design. After startup, CoverAutomatic waits **120 seconds** before applying a
CoverAutomatic detected a **manual override**: the cover was moved by something other than CoverAutomatic itself — a wall switch, a remote, another automation, or the HA UI. Automation for that cover pauses for the configured pause duration (global or per-cover) so your manual choice is respected, then resumes automatically. Resume earlier via the **X** button in the panel or the `cover_automatic.resume` service.
+For slow covers that report neither intermediate positions nor *opening* / *closing*, the integration waits for the cover's **travel time** (measured automatically, or set per cover under *Advanced → Travel time*) before judging a position as manual. If a slow cover is still paused wrongly right after an automatic move, set its travel time a little above the real one.
+
### A rule matches but the cover doesn't move
Check in this order:
1. **Status priority** — WIND_PROTECTED, LOCKED (window open), VENTING (window tilted) and PAUSED all override rule evaluation. The covers table in the panel shows the current status and the winning rule per cover.
2. **Master switch / per-cover automation toggle** — both must be on.
-3. **Active scenario** — scenarios can disable specific rules.
+3. **Active scenario** — only rules that belong to the active scenario (and are not switched off in it) are considered.
4. **Minimum time between changes** — position changes are rate-limited by the configured interval; the move happens on a later scan.
### A sun rule doesn't shade
diff --git a/custom_components/cover_automatic/__init__.py b/custom_components/cover_automatic/__init__.py
index 49d8748..6de9c25 100644
--- a/custom_components/cover_automatic/__init__.py
+++ b/custom_components/cover_automatic/__init__.py
@@ -5,7 +5,11 @@
import pathlib
from dataclasses import dataclass
-from homeassistant.components.frontend import async_register_built_in_panel, async_remove_panel
+from homeassistant.components.frontend import (
+ add_extra_js_url,
+ async_register_built_in_panel,
+ async_remove_panel,
+)
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
@@ -18,6 +22,7 @@
from .api import async_setup_api
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
from .coordinator import CoverAutomaticCoordinator
+from .entities import async_cleanup_orphan_entities
from .services import async_setup_services, async_unload_services
from .storage import ActivityLogStorage, CoverAutomaticStorage
@@ -35,6 +40,7 @@ class CoverAutomaticRuntimeData:
type CoverAutomaticConfigEntry = ConfigEntry[CoverAutomaticRuntimeData]
PLATFORMS_LIST: list[Platform] = [
+ Platform.BINARY_SENSOR,
Platform.SWITCH,
Platform.SENSOR,
Platform.SELECT,
@@ -42,6 +48,48 @@ class CoverAutomaticRuntimeData:
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+PANEL_JS_URL = "/cover_automatic/panel.js"
+# Dashboard card, loaded on every frontend page (custom:cover-automatic-card)
+CARD_JS_URL = "/cover_automatic/cover-automatic-card.js"
+# hass.data[DOMAIN] flag: static panel route already registered this HA run
+_STATIC_PATH_REGISTERED = "static_path_registered"
+
+
+async def _async_register_card_resource(hass: HomeAssistant, url: str) -> bool:
+ """Declare the dashboard card as a Lovelace resource (storage mode).
+
+ Lovelace resources are loaded with the dashboards, so the card is
+ defined when they are built (the extra module URL could arrive too late
+ and the card then showed "Configuration error" until a refresh). The
+ entry is updated when the version changes. Returns False when the
+ resources cannot be managed (YAML mode, Lovelace not loaded).
+ """
+ try:
+ from homeassistant.components.lovelace.const import LOVELACE_DATA
+ from homeassistant.components.lovelace.resources import ResourceStorageCollection
+ except ImportError:
+ return False
+ data = hass.data.get(LOVELACE_DATA)
+ resources = getattr(data, "resources", None)
+ if not isinstance(resources, ResourceStorageCollection):
+ return False
+ try:
+ if not resources.loaded:
+ await resources.async_load()
+ resources.loaded = True
+ base = url.split("?", 1)[0]
+ for item in resources.async_items():
+ if str(item.get("url", "")).split("?", 1)[0] != base:
+ continue
+ if item.get("url") != url or item.get("type") != "module":
+ await resources.async_update_item(item["id"], {"res_type": "module", "url": url})
+ return True
+ await resources.async_create_item({"res_type": "module", "url": url})
+ except Exception as err: # noqa: BLE001 -- never block the integration setup
+ _LOGGER.warning("Could not register the dashboard card resource: %s", err)
+ return False
+ return True
+
def _cleanup_removed_entities(hass: HomeAssistant) -> None:
"""Remove orphan entities from prior versions (pre-1.52.0: per-cover pause_duration)."""
@@ -85,6 +133,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoverAutomaticConfigEntr
await log_storage.async_load()
_cleanup_removed_entities(hass)
+ # Entities of covers/facades deleted while HA was running an older
+ # version (or deleted before a restart) are dropped here.
+ async_cleanup_orphan_entities(hass, entry, storage)
async def async_options_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Handle options update by reloading entry to recreate entities."""
@@ -137,8 +188,31 @@ async def async_options_updated(hass: HomeAssistant, config_entry: ConfigEntry)
# Setup WebSocket API for config panel
async_setup_api(hass, storage, coordinator, version=panel_version)
+<<<<<<< Updated upstream
# Register custom panel (version query for cache busting); the static path
# behind js_url is registered once in async_setup
+=======
+ # Register custom panel (version query for cache busting). aiohttp routes
+ # cannot be removed, and registering the same GET route twice raises
+ # RuntimeError -- so the static path is registered once per HA run and
+ # survives config entry reloads.
+ domain_data: dict = hass.data.setdefault(DOMAIN, {})
+ panel_dir = pathlib.Path(__file__).parent / "panel"
+ panel_stamp = await _async_asset_stamp(hass, panel_dir / "cover-automatic-panel.js")
+ if not domain_data.get(_STATIC_PATH_REGISTERED):
+ await hass.http.async_register_static_paths(
+ [
+ StaticPathConfig(PANEL_JS_URL, str(panel_dir / "cover-automatic-panel.js"), False),
+ StaticPathConfig(CARD_JS_URL, str(panel_dir / "cover-automatic-card.js"), False),
+ ]
+ )
+ domain_data[_STATIC_PATH_REGISTERED] = True
+ card_stamp = await _async_asset_stamp(hass, panel_dir / "cover-automatic-card.js")
+ card_url = f"{CARD_JS_URL}?v={panel_version}{card_stamp}"
+ if not await _async_register_card_resource(hass, card_url):
+ # YAML resources / no Lovelace: load the card with every page.
+ add_extra_js_url(hass, card_url)
+>>>>>>> Stashed changes
async_register_built_in_panel(
hass,
component_name="custom",
@@ -146,20 +220,43 @@ async def async_options_updated(hass: HomeAssistant, config_entry: ConfigEntry)
sidebar_icon="mdi:blinds",
frontend_url_path="cover-automatic",
require_admin=True,
+ # update=True: a failed unload can leave the panel registered; a
+ # later setup must replace it instead of raising "Overwriting panel".
+ update=True,
config={
"_panel_custom": {
"name": "cover-automatic-panel",
- "js_url": f"/cover_automatic/panel.js?v={panel_version}",
+ "js_url": f"{PANEL_JS_URL}?v={panel_version}{panel_stamp}",
"embed_iframe": False,
}
},
)
- entry.async_on_unload(coordinator.async_shutdown)
+ # coordinator.async_shutdown is registered on unload by
+ # DataUpdateCoordinator itself (config_entry is passed to it).
return True
+async def _async_asset_stamp(hass: HomeAssistant, path: pathlib.Path) -> str:
+ """Modification time of a bundled asset, for cache busting.
+
+ The version alone is not enough: files updated without a version bump
+ would keep being served from the browser cache. Read in the executor
+ (stat is file I/O); "" when unavailable, the version is then used alone.
+ """
+ try:
+ mtime = await hass.async_add_executor_job(_asset_mtime, path)
+ except (OSError, TypeError):
+ return ""
+ return f"-{mtime}" if isinstance(mtime, int) else ""
+
+
+def _asset_mtime(path: pathlib.Path) -> int:
+ """Integer mtime of a file (executor)."""
+ return int(path.stat().st_mtime)
+
+
async def async_unload_entry(hass: HomeAssistant, entry: CoverAutomaticConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS_LIST)
diff --git a/custom_components/cover_automatic/api.py b/custom_components/cover_automatic/api.py
index 4a67507..991eace 100644
--- a/custom_components/cover_automatic/api.py
+++ b/custom_components/cover_automatic/api.py
@@ -4,14 +4,31 @@
import copy
import logging
import re
+import unicodedata
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components import websocket_api
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
-
-from .const import DOMAIN, FACADE_PRESETS
-from .models import Condition, CoverConfig, Facade, Rule, Scenario
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .const import DOMAIN, FACADE_PRESETS, SIGNAL_DATA_UPDATED
+from . import ha_condition as hac
+from .models import (
+ MAX_CONDITION_GROUPS,
+ Condition,
+ ConditionType,
+ CoverConfig,
+ Facade,
+ Rule,
+ Scenario,
+ finite_float,
+ finite_range,
+ optional_entity_id,
+)
+from .storage import SETTING_VALIDATORS, settings_cross_error
# Integration version shown in the panel. Set once by async_setup_api() from
# the cached integration manifest -- reading manifest.json here would be a
@@ -32,8 +49,13 @@
"lock_sensor", "lock_position", "vent_sensor", "vent_position",
"inverted", "supports_tilt", "lock_tilt_position", "vent_tilt_position",
"inverted_tilt", "indoor_temp_sensor", "comfort_temp_min", "comfort_temp_max",
- "preemptive_shading",
+ "preemptive_shading", "lock_hold_position",
"min_position_change", "min_time_between_changes",
+ "travel_time", "measured_travel_time",
+ "comfort_temp_min_entity", "comfort_temp_max_entity",
+ "sun_heating_ignore", "sun_neutral_ignore",
+ "occupancy_sensor", "occupancy_states",
+ "pause_resume_on_match",
)
# Settings fields that can be updated via WS API
@@ -50,6 +72,11 @@
"command_stagger",
"solar_sensor", "solar_threshold", "solar_hysteresis",
"logbook_enabled", "update_check_enabled",
+ "default_travel_time",
+ "wind_position",
+ "comfort_temp_min_entity", "comfort_temp_max_entity", "solar_threshold_entity",
+ "sun_heating_ignore", "sun_neutral_ignore", "preemptive_shading",
+ "temp_color_thermometer", "pause_resume_on_match",
)
# Umlaut replacement map
@@ -73,6 +100,10 @@ def _sanitize_id(name: str) -> str:
result = name
for char, replacement in _UMLAUT_MAP.items():
result = result.replace(char, replacement)
+ # Other accents (é, è, à, ç…) become their base letter: "Météo" -> "meteo"
+ result = "".join(
+ c for c in unicodedata.normalize("NFKD", result) if not unicodedata.combining(c)
+ )
result = result.lower()
result = re.sub(r"[^a-z0-9]", "_", result)
result = re.sub(r"_+", "_", result)
@@ -80,6 +111,43 @@ def _sanitize_id(name: str) -> str:
return result or "unnamed"
+# Optional entity id: None/"" -> None, else "domain.object" (shared validator)
+_optional_entity_id = optional_entity_id
+
+
+def _normalize_azimuth(value: float) -> float:
+ """Compass bearing in [0, 360): any finite number is taken modulo 360.
+
+ As before v1.87, -90 means 270 and 450 means 90; 360 is stored as 0
+ (a facade with start == end covers the full circle).
+ """
+ bearing = value % 360
+ # A tiny negative value rounds up to 360.0; + 0.0 turns -0.0 into 0.0
+ return 0.0 if bearing >= 360 else bearing + 0.0
+
+
+# Facade geometry: any finite bearing, normalised to 0-360 before the range
+# check (360 is stored as 0), sun elevation -90-90
+_AZIMUTH = vol.All(finite_float, _normalize_azimuth, vol.Range(min=0, max=360))
+_ELEVATION = finite_range(-90, 90)
+# Per-cover comfort band (same bounds as the global settings)
+_COVER_COMFORT_TEMP = vol.Any(None, finite_range(-50, 60))
+
+
+def _rule_id_for_name(rule_id: str, name: str, rules: dict[str, Any]) -> str:
+ """Id a rule should have for its name.
+
+ The current id is kept when it already is the sanitized name, or that
+ name plus a numeric uniqueness suffix; otherwise a unique id is derived
+ from the name (ignoring the rule itself).
+ """
+ desired = _sanitize_id(name)
+ if rule_id == desired or re.fullmatch(re.escape(desired) + r"_\d+", rule_id):
+ return rule_id
+ others = {key: value for key, value in rules.items() if key != rule_id}
+ return _unique_id(desired, others)
+
+
def _unique_id(base_id: str, existing: dict[str, Any]) -> str:
"""Ensure ID is unique by appending a numeric suffix if needed."""
if base_id not in existing:
@@ -129,6 +197,16 @@ def _build_config_response(
"solar_hysteresis": storage.solar_hysteresis,
"logbook_enabled": storage.logbook_enabled,
"update_check_enabled": storage.update_check_enabled,
+ "default_travel_time": storage.default_travel_time,
+ "wind_position": storage.wind_position,
+ "comfort_temp_min_entity": storage.comfort_temp_min_entity,
+ "comfort_temp_max_entity": storage.comfort_temp_max_entity,
+ "solar_threshold_entity": storage.solar_threshold_entity,
+ "sun_heating_ignore": storage.sun_heating_ignore,
+ "sun_neutral_ignore": storage.sun_neutral_ignore,
+ "preemptive_shading": storage.preemptive_shading,
+ "temp_color_thermometer": storage.temp_color_thermometer,
+ "pause_resume_on_match": storage.pause_resume_on_match,
},
}
if hass:
@@ -139,6 +217,15 @@ def _build_config_response(
if s.entity_id not in managed
]
if coordinator:
+ # Validation status of native HA conditions: {rule_id: {idx: status}}
+ status: dict[str, dict[str, Any]] = {}
+ engine = getattr(coordinator, "engine", None)
+ if engine is not None:
+ for rule_id, rule in storage.rules.items():
+ for idx, cond in enumerate(rule.conditions):
+ if cond.type == ConditionType.HA_CONDITION:
+ status.setdefault(rule_id, {})[str(idx)] = engine.ha_condition_status(cond)
+ result["condition_status"] = status
result["active_rules"] = coordinator.get_active_rules()
result["live_covers"] = coordinator.get_live_cover_data()
result["live_facades"] = coordinator.get_live_facade_data()
@@ -194,10 +281,15 @@ def _parse_conditions(raw: list[dict[str, Any]]) -> tuple[list[Condition], list[
"""Parse condition dicts, collecting errors for invalid ones."""
conditions: list[Condition] = []
errors: list[str] = []
+ if not isinstance(raw, list):
+ return conditions, [f"conditions must be a list, got {type(raw).__name__}"]
for idx, item in enumerate(raw):
+ if not isinstance(item, dict):
+ errors.append(f"Condition {idx}: must be a dict, got {type(item).__name__}")
+ continue
try:
conditions.append(Condition.from_dict(item))
- except (ValueError, KeyError) as err:
+ except (ValueError, KeyError, TypeError) as err:
errors.append(f"Condition {idx}: {err}")
return conditions, errors
@@ -251,6 +343,18 @@ async def ws_cover_update(
connection.send_error(msg["id"], "not_found", f"Facade '{msg['facade_id']}' not found")
return
+ # Per-cover comfort band (falls back to the global values when unset)
+ def _eff_comfort(key: str) -> float:
+ value = msg[key] if key in msg else raw.get(key)
+ return value if value is not None else getattr(storage, key)
+
+ if _eff_comfort("comfort_temp_min") >= _eff_comfort("comfort_temp_max"):
+ connection.send_error(
+ msg["id"], "invalid_comfort_range",
+ "comfort_temp_min must be lower than comfort_temp_max",
+ )
+ return
+
# Track facade change for bidirectional sync
old_facade_id = raw.get("facade_id")
# Track auto_enabled change to mirror the switch entity's status transition
@@ -303,6 +407,7 @@ async def ws_cover_add(
if added:
await storage.async_save()
coordinator.refresh_state_tracking()
+ coordinator.async_sync_entities()
connection.send_result(msg["id"], _build_config_response(storage, hass, coordinator))
@@ -316,6 +421,7 @@ async def ws_cover_delete(
"""Handle cover_automatic/cover/delete."""
await storage.async_remove_cover(msg["entity_id"])
coordinator.refresh_state_tracking()
+ coordinator.async_sync_entities()
connection.send_result(msg["id"], _build_config_response(storage, hass, coordinator))
@@ -349,6 +455,7 @@ async def ws_facade_add(
await storage.async_add_facade(facade, save=False)
_sync_cover_facade_ids(storage, facade_id, new_cover_ids)
await storage.async_save()
+ coordinator.async_sync_entities()
connection.send_result(msg["id"], _build_config_response(storage, hass, coordinator))
@@ -386,6 +493,11 @@ async def ws_facade_update(
# Sync cover.facade_id with facade.cover_ids
_sync_cover_facade_ids(storage, facade_id, new_cover_ids)
await storage.async_save()
+ # Like the other updates: a new azimuth/elevation or name must take
+ # effect now, not at the next periodic refresh.
+ coordinator.refresh_state_tracking()
+ coordinator.async_sync_entities()
+ await coordinator.async_request_refresh()
connection.send_result(msg["id"], _build_config_response(storage, hass, coordinator))
@@ -403,6 +515,7 @@ async def ws_facade_delete(
return
await storage.async_remove_facade(facade_id)
+ coordinator.async_sync_entities()
connection.send_result(msg["id"], _build_config_response(storage, hass, coordinator))
@@ -426,19 +539,40 @@ async def ws_rule_add(
connection.send_error(msg["id"], "not_found", _unknown_refs_msg(unknown_f, unknown_c))
return
+ # New rules belong to every scenario unless told otherwise.
+ scenario_ids = list(msg.get("scenario_ids", list(storage.scenarios.keys())))
+ unknown_s = _missing_ids(scenario_ids, storage.scenarios)
+ if unknown_s:
+ connection.send_error(msg["id"], "not_found", f"Unknown scenarios: {', '.join(unknown_s)}")
+ return
+
rule = Rule(
id=_unique_id(_sanitize_id(name), storage.rules),
name=name,
enabled=msg.get("enabled", True),
priority=msg.get("priority", 10),
condition_operator=msg.get("condition_operator", "and"),
+ # Without explicit groups the operator applies inside the single group.
+ group_operators=list(msg.get("group_operators") or [msg.get("condition_operator", "and")]),
facade_ids=msg.get("facade_ids", []),
cover_ids=msg.get("cover_ids", []),
conditions=conditions,
target_position=msg.get("target_position", 0),
target_tilt_position=msg.get("target_tilt_position"),
+ scenario_ids=scenario_ids,
+ safety=bool(msg.get("safety", False)),
)
- await storage.async_add_rule(rule)
+ if "priority" in msg:
+ await storage.async_add_rule(rule)
+ else:
+ # A new rule goes to the bottom of the list with its own priority
+ # (a shared default priority made its place depend on the id).
+ order = storage.rule_order()
+ await storage.async_add_rule(rule, save=False)
+ storage.renumber_priorities([*order, rule.id])
+ await storage.async_save()
+ await _async_prepare_ha_conditions(coordinator)
+ coordinator.refresh_state_tracking()
await coordinator.async_request_refresh()
connection.send_result(msg["id"], _build_config_response(storage, hass, coordinator))
@@ -471,9 +605,35 @@ async def ws_rule_update(
connection.send_error(msg["id"], "not_found", _unknown_refs_msg(unknown_f, unknown_c))
return
+ group_operators = existing.effective_group_operators()
+ if "group_operators" in msg:
+ group_operators = list(msg["group_operators"]) or ["and"]
+ elif "condition_operator" in msg and len(existing.condition_groups()) <= 1:
+ # Legacy clients: the operator of a single-group rule is its group's.
+ group_operators = [msg["condition_operator"], *group_operators[1:]]
+
+ scenario_ids = existing.scenario_ids
+ if "scenario_ids" in msg:
+ scenario_ids = list(msg["scenario_ids"])
+ unknown_s = _missing_ids(scenario_ids, storage.scenarios)
+ if unknown_s:
+ connection.send_error(msg["id"], "not_found", f"Unknown scenarios: {', '.join(unknown_s)}")
+ return
+
+ # The id follows a new name (only when the name actually changed: an
+ # unchanged name keeps whatever id the rule has); references move with it
+ name = msg.get("name", existing.name)
+ new_id = rule_id
+ if name != existing.name:
+ new_id = _rule_id_for_name(rule_id, name, storage.rules)
+ if new_id != rule_id:
+ await storage.async_rename_rule(rule_id, new_id, save=False)
+ coordinator.rename_rule_references(rule_id, new_id)
+ _LOGGER.info("Rule '%s' renamed: id %s -> %s", name, rule_id, new_id)
+
updated = Rule(
- id=rule_id,
- name=msg.get("name", existing.name),
+ id=new_id,
+ name=name,
enabled=msg.get("enabled", existing.enabled),
priority=msg.get("priority", existing.priority),
condition_operator=msg.get("condition_operator", existing.condition_operator),
@@ -482,10 +642,18 @@ async def ws_rule_update(
conditions=conditions,
target_position=msg.get("target_position", existing.target_position),
target_tilt_position=msg.get("target_tilt_position", existing.target_tilt_position),
+ scenario_ids=scenario_ids,
+ group_operators=group_operators,
+ safety=bool(msg.get("safety", existing.safety)),
)
await storage.async_add_rule(updated)
+ await _async_prepare_ha_conditions(coordinator)
+ coordinator.refresh_state_tracking()
await coordinator.async_request_refresh()
- connection.send_result(msg["id"], _build_config_response(storage, hass, coordinator))
+ response = _build_config_response(storage, hass, coordinator)
+ if new_id != rule_id:
+ response["renamed_rule"] = {"from": rule_id, "to": new_id}
+ connection.send_result(msg["id"], response)
async def ws_rule_delete(
@@ -502,6 +670,7 @@ async def ws_rule_delete(
return
await storage.async_remove_rule(rule_id)
+ coordinator.refresh_state_tracking()
await coordinator.async_request_refresh()
connection.send_result(msg["id"], _build_config_response(storage, hass, coordinator))
@@ -563,7 +732,7 @@ async def ws_rule_reorder(
Array position determines priority: first = highest priority.
Top of the list wins over bottom.
"""
- rule_ids: list[str] = msg["rule_ids"]
+ rule_ids: list[str] = list(dict.fromkeys(msg["rule_ids"])) # drop duplicates
rules_data = storage._data.get("rules", {})
unknown = _missing_ids(rule_ids, rules_data)
@@ -573,9 +742,10 @@ async def ws_rule_reorder(
)
return
- total = len(rule_ids)
- for idx, rid in enumerate(rule_ids):
- rules_data[rid]["priority"] = (total - idx) * 10
+ # Rules missing from a partial list keep their relative order below the
+ # given ones, so no two rules end up sharing a priority.
+ rest = [rid for rid in storage.rule_order() if rid not in rule_ids]
+ storage.renumber_priorities([*rule_ids, *rest])
storage._invalidate_cache()
await storage.async_save()
@@ -583,6 +753,53 @@ async def ws_rule_reorder(
connection.send_result(msg["id"], _build_config_response(storage, hass, coordinator))
+# Rule names: shown in entity states (max 255 chars), keep them readable
+RULE_NAME_MAX = 100
+_RULE_NAME = vol.All(str, vol.Length(min=1, max=RULE_NAME_MAX))
+
+
+def _copy_name(base: str, rules: dict[str, Any]) -> str:
+ """base, or base plus " 2", " 3"... when a rule already has that name."""
+ taken = {getattr(rule, "name", None) for rule in rules.values()}
+ if base not in taken:
+ return base
+ counter = 2
+ while f"{base} {counter}" in taken:
+ counter += 1
+ return f"{base} {counter}"
+
+
+async def ws_rule_duplicate(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+ storage: CoverAutomaticStorage,
+ coordinator: CoverAutomaticCoordinator,
+) -> None:
+ """Handle cover_automatic/rule/duplicate.
+
+ The copy is placed right below the original and created active: with the
+ same conditions it never wins over the original until it is edited.
+ """
+ rule_id = msg["rule_id"]
+ original = storage.rules.get(rule_id)
+ if original is None:
+ connection.send_error(msg["id"], "not_found", f"Rule '{rule_id}' not found")
+ return
+ base = (msg.get("name") or "").strip() or f"{original.name} - copy"
+ # Leave room for a " 99" uniqueness suffix within the name limit
+ base = base[: RULE_NAME_MAX - 3].rstrip()
+ name = _copy_name(base, storage.rules)
+ new_id = _unique_id(_sanitize_id(name) or f"{rule_id}_copy", storage.rules)
+ await storage.async_duplicate_rule(rule_id, new_id, name)
+ await _async_prepare_ha_conditions(coordinator)
+ coordinator.refresh_state_tracking()
+ await coordinator.async_request_refresh()
+ response = _build_config_response(storage, hass, coordinator)
+ response["new_rule_id"] = new_id
+ connection.send_result(msg["id"], response)
+
+
async def ws_rule_preview_conditions(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
@@ -600,10 +817,80 @@ async def ws_rule_preview_conditions(
if errors:
connection.send_error(msg["id"], "invalid_conditions", "; ".join(errors))
return
+ await _async_prepare_ha_conditions(coordinator, conditions)
results = [coordinator.engine.preview_condition(c) for c in conditions]
connection.send_result(msg["id"], {"results": results})
+async def _async_prepare_ha_conditions(
+ coordinator: CoverAutomaticCoordinator, conditions: list[Condition] | None = None
+) -> None:
+ """Compile native HA conditions so their status/preview is current."""
+ engine = getattr(coordinator, "engine", None)
+ prepare = getattr(engine, "async_prepare_ha_conditions", None)
+ if prepare is None:
+ return
+ try:
+ await prepare(conditions)
+ except TypeError:
+ pass # mocked engine in tests
+
+
+async def ws_condition_validate(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+ storage: CoverAutomaticStorage,
+ coordinator: CoverAutomaticCoordinator,
+) -> None:
+ """Handle cover_automatic/condition/validate.
+
+ Validates a native Home Assistant condition given as YAML text or as a
+ config dict (read-only, nothing is stored). Returns the parsed config, a
+ YAML rendering of it, the referenced entities (and those that do not
+ exist), the validation error if any, and whether it is met right now.
+ """
+ yaml_error = None
+ if "yaml" in msg:
+ text = msg["yaml"]
+ config, yaml_error = hac.parse_yaml(text)
+ else:
+ config = msg.get("config")
+ text = hac.dump_yaml(config) if isinstance(config, dict) else ""
+
+ response: dict[str, Any] = {
+ "valid": False,
+ "config": config,
+ "yaml": text,
+ "error": None,
+ "error_code": None,
+ "error_line": None,
+ "error_column": None,
+ "entities": [],
+ "unknown_entities": [],
+ "matched": None,
+ }
+ if yaml_error:
+ response.update(
+ error=yaml_error["detail"], error_code=yaml_error["code"],
+ error_line=yaml_error["line"], error_column=yaml_error["column"],
+ )
+ connection.send_result(msg["id"], response)
+ return
+
+ compiled = await hac.async_compile(hass, config)
+ entities = sorted(compiled.entities)
+ response.update(
+ valid=compiled.valid,
+ error=compiled.error,
+ error_code=compiled.error_code,
+ entities=entities,
+ unknown_entities=[e for e in entities if hass.states.get(e) is None],
+ matched=hac.evaluate(hass, compiled) if compiled.valid else None,
+ )
+ connection.send_result(msg["id"], response)
+
+
async def ws_scenario_add(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
@@ -695,18 +982,46 @@ async def ws_settings_update(
"""Handle cover_automatic/settings/update."""
# Validate ALL fields up front so a single rejection does not leave the
# in-memory state half-updated while disk still has the old values.
- if "active_scenario" in msg and msg["active_scenario"]:
- if msg["active_scenario"] not in storage._data.get("scenarios", {}):
+ if "active_scenario" in msg:
+ # An empty or unknown id is rejected (it would otherwise be stored)
+ if not msg["active_scenario"] or msg["active_scenario"] not in storage._data.get("scenarios", {}):
connection.send_error(
msg["id"], "not_found", f"Scenario '{msg['active_scenario']}' not found"
)
return
+ # Cross-field checks against the resulting values (new or existing).
+ def _effective(key: str) -> Any:
+ return msg[key] if key in msg else getattr(storage, key)
+
+ # Comfort band (invalid_comfort_range), wind and solar deadbands
+ # (invalid_wind_hysteresis, invalid_solar_hysteresis): shared with import.
+ # Only the checks touching a field actually sent: an inconsistent pair
+ # stored by an older version must not block saving anything else.
+ cross_error = settings_cross_error(_effective, msg.keys())
+ if cross_error:
+ connection.send_error(msg["id"], *cross_error)
+ return
+
+ old_rotation = storage.house_rotation
+
# All validations passed -- now apply atomically.
for key in _SETTINGS_FIELDS:
if key in msg:
setattr(storage, key, msg[key])
+ # Facade azimuths are stored as real compass bearings with the house
+ # rotation already applied. Rotating the house must therefore rotate the
+ # existing facades too, or they keep pointing at the old orientation.
+ rotation_delta = storage.house_rotation - old_rotation
+ if rotation_delta:
+ for facade_raw in storage._data.get("facades", {}).values():
+ for az_key in ("azimuth_start", "azimuth_end"):
+ facade_raw[az_key] = round(
+ (float(facade_raw.get(az_key, 0.0)) + rotation_delta) % 360, 2
+ )
+ storage._invalidate_cache()
+
await storage.async_save()
coordinator.refresh_state_tracking()
await coordinator.async_request_refresh()
@@ -745,6 +1060,7 @@ async def ws_get_log(
event_type=msg.get("event_type"),
entity_id=msg.get("entity_id"),
limit=msg.get("limit", 500),
+ include_global=msg.get("include_global", False),
)
connection.send_result(msg["id"], {"entries": entries})
@@ -770,7 +1086,7 @@ async def ws_export_config(
coordinator: CoverAutomaticCoordinator,
) -> None:
"""Handle cover_automatic/export -- return raw config as JSON."""
- connection.send_result(msg["id"], {"data": storage.get_raw_data()})
+ connection.send_result(msg["id"], {"data": storage.get_export_data()})
_MAX_IMPORT_ENTRIES = 1000
@@ -798,11 +1114,39 @@ async def ws_import_config(
except (ValueError, TypeError) as err:
connection.send_error(msg["id"], "invalid_data", str(err))
return
+ # Let the coordinator drop runtime state that no longer matches the
+ # imported configuration (removed covers, wind protection...).
+ reconcile = getattr(coordinator, "reconcile_after_import", None)
+ if callable(reconcile):
+ reconcile()
coordinator.refresh_state_tracking()
+ coordinator.async_sync_entities()
await coordinator.async_request_refresh()
connection.send_result(msg["id"], _build_config_response(storage, hass, coordinator))
+@callback
+def ws_subscribe_updates(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Handle cover_automatic/subscribe -- push a message on every data update.
+
+ Uses a dispatcher signal (not a bus event) so updates are not written to
+ the recorder database, and the subscription survives entry reloads.
+ """
+
+ @callback
+ def _forward() -> None:
+ connection.send_message(websocket_api.event_message(msg["id"], {}))
+
+ connection.subscriptions[msg["id"]] = async_dispatcher_connect(
+ hass, SIGNAL_DATA_UPDATED, _forward
+ )
+ connection.send_result(msg["id"])
+
+
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
@@ -836,22 +1180,35 @@ def async_setup_api(
vol.Optional("name"): str,
vol.Optional("facade_id"): vol.Any(str, None),
vol.Optional("auto_enabled"): bool,
- vol.Optional("pause_duration"): vol.Any(vol.All(int, vol.Range(min=0, max=480)), None),
- vol.Optional("lock_sensor"): vol.Any(str, None),
+ # None follows the global pause duration
+ vol.Optional("pause_duration"): vol.Any(vol.All(int, vol.Range(min=1, max=480)), None),
+ vol.Optional("lock_sensor"): _optional_entity_id,
vol.Optional("lock_position"): vol.Any(vol.All(int, vol.Range(min=0, max=100)), None),
- vol.Optional("vent_sensor"): vol.Any(str, None),
+ vol.Optional("vent_sensor"): _optional_entity_id,
vol.Optional("vent_position"): vol.Any(vol.All(int, vol.Range(min=0, max=100)), None),
vol.Optional("inverted"): bool,
vol.Optional("supports_tilt"): bool,
- vol.Optional("lock_tilt_position"): vol.Any(int, None),
- vol.Optional("vent_tilt_position"): vol.Any(int, None),
+ vol.Optional("lock_tilt_position"): vol.Any(vol.All(int, vol.Range(min=0, max=100)), None),
+ vol.Optional("vent_tilt_position"): vol.Any(vol.All(int, vol.Range(min=0, max=100)), None),
vol.Optional("inverted_tilt"): bool,
- vol.Optional("indoor_temp_sensor"): vol.Any(str, None),
- vol.Optional("comfort_temp_min"): vol.Any(vol.Coerce(float), None),
- vol.Optional("comfort_temp_max"): vol.Any(vol.Coerce(float), None),
- vol.Optional("preemptive_shading"): bool,
+ vol.Optional("indoor_temp_sensor"): _optional_entity_id,
+ vol.Optional("comfort_temp_min"): _COVER_COMFORT_TEMP,
+ vol.Optional("comfort_temp_max"): _COVER_COMFORT_TEMP,
+ # Tri-state: None follows the global setting
+ vol.Optional("preemptive_shading"): vol.Any(bool, None),
+ vol.Optional("sun_heating_ignore"): vol.Any(bool, None),
+ vol.Optional("pause_resume_on_match"): vol.Any(bool, None),
+ vol.Optional("sun_neutral_ignore"): vol.Any(bool, None),
+ vol.Optional("lock_hold_position"): bool,
vol.Optional("min_position_change"): vol.Any(vol.All(int, vol.Range(min=1, max=50)), None),
vol.Optional("min_time_between_changes"): vol.Any(vol.All(int, vol.Range(min=60, max=3600)), None),
+ vol.Optional("travel_time"): vol.Any(vol.All(int, vol.Range(min=1, max=300)), None),
+ # Only reset is allowed: the measured value is learned by the coordinator
+ vol.Optional("measured_travel_time"): None,
+ vol.Optional("comfort_temp_min_entity"): _optional_entity_id,
+ vol.Optional("comfort_temp_max_entity"): _optional_entity_id,
+ vol.Optional("occupancy_sensor"): _optional_entity_id,
+ vol.Optional("occupancy_states"): vol.Any(None, vol.All(str, vol.Length(max=200))),
},
),
(
@@ -874,9 +1231,9 @@ def async_setup_api(
{
vol.Required("name"): str,
vol.Optional("direction", default="south"): str,
- vol.Optional("azimuth_start"): vol.Coerce(float),
- vol.Optional("azimuth_end"): vol.Coerce(float),
- vol.Optional("min_elevation"): vol.Coerce(float),
+ vol.Optional("azimuth_start"): _AZIMUTH,
+ vol.Optional("azimuth_end"): _AZIMUTH,
+ vol.Optional("min_elevation"): _ELEVATION,
vol.Optional("cover_ids"): [str],
},
),
@@ -887,9 +1244,9 @@ def async_setup_api(
vol.Required("facade_id"): str,
vol.Optional("name"): str,
vol.Optional("direction"): str,
- vol.Optional("azimuth_start"): vol.Coerce(float),
- vol.Optional("azimuth_end"): vol.Coerce(float),
- vol.Optional("min_elevation"): vol.Coerce(float),
+ vol.Optional("azimuth_start"): _AZIMUTH,
+ vol.Optional("azimuth_end"): _AZIMUTH,
+ vol.Optional("min_elevation"): _ELEVATION,
vol.Optional("cover_ids"): [str],
},
),
@@ -904,15 +1261,20 @@ def async_setup_api(
f"{DOMAIN}/rule/add",
ws_rule_add,
{
- vol.Required("name"): str,
+ vol.Required("name"): _RULE_NAME,
vol.Optional("enabled"): bool,
vol.Optional("priority"): int,
- vol.Optional("condition_operator"): str,
+ vol.Optional("condition_operator"): vol.In(["and", "or"]),
+ vol.Optional("group_operators"): vol.All(
+ [vol.In(["and", "or"])], vol.Length(max=MAX_CONDITION_GROUPS)
+ ),
vol.Optional("facade_ids"): [str],
vol.Optional("cover_ids"): [str],
vol.Optional("conditions"): list,
- vol.Optional("target_position"): int,
- vol.Optional("target_tilt_position"): vol.Any(int, None),
+ vol.Optional("target_position"): vol.All(int, vol.Range(min=0, max=100)),
+ vol.Optional("target_tilt_position"): vol.Any(vol.All(int, vol.Range(min=0, max=100)), None),
+ vol.Optional("scenario_ids"): [str],
+ vol.Optional("safety"): bool,
},
),
(
@@ -920,15 +1282,20 @@ def async_setup_api(
ws_rule_update,
{
vol.Required("rule_id"): str,
- vol.Optional("name"): str,
+ vol.Optional("name"): _RULE_NAME,
vol.Optional("enabled"): bool,
vol.Optional("priority"): int,
- vol.Optional("condition_operator"): str,
+ vol.Optional("condition_operator"): vol.In(["and", "or"]),
+ vol.Optional("group_operators"): vol.All(
+ [vol.In(["and", "or"])], vol.Length(max=MAX_CONDITION_GROUPS)
+ ),
vol.Optional("facade_ids"): [str],
vol.Optional("cover_ids"): [str],
vol.Optional("conditions"): list,
- vol.Optional("target_position"): int,
- vol.Optional("target_tilt_position"): vol.Any(int, None),
+ vol.Optional("target_position"): vol.All(int, vol.Range(min=0, max=100)),
+ vol.Optional("target_tilt_position"): vol.Any(vol.All(int, vol.Range(min=0, max=100)), None),
+ vol.Optional("scenario_ids"): [str],
+ vol.Optional("safety"): bool,
},
),
(
@@ -943,7 +1310,11 @@ def async_setup_api(
ws_rule_duplicate,
{
vol.Required("rule_id"): str,
+<<<<<<< Updated upstream
vol.Optional("name"): str,
+=======
+ vol.Optional("name"): vol.All(str, vol.Length(max=200)), # truncated below
+>>>>>>> Stashed changes
},
),
(
@@ -960,6 +1331,14 @@ def async_setup_api(
vol.Optional("conditions"): list,
},
),
+ (
+ f"{DOMAIN}/condition/validate",
+ ws_condition_validate,
+ {
+ vol.Exclusive("yaml", "source"): str,
+ vol.Exclusive("config", "source"): vol.Any(dict, None),
+ },
+ ),
(
f"{DOMAIN}/scenario/add",
ws_scenario_add,
@@ -991,33 +1370,9 @@ def async_setup_api(
f"{DOMAIN}/settings/update",
ws_settings_update,
{
- vol.Optional("enabled"): bool,
- vol.Optional("outdoor_temp_sensor"): vol.Any(str, None),
- vol.Optional("indoor_temp_sensor"): vol.Any(str, None),
- vol.Optional("weather_entity"): vol.Any(str, None),
- vol.Optional("comfort_temp_min"): vol.Coerce(float),
- vol.Optional("comfort_temp_max"): vol.Coerce(float),
- vol.Optional("comfort_hysteresis"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=5.0)),
- vol.Optional("threshold_hysteresis"): vol.All(vol.Coerce(float), vol.Range(min=0, max=5.0)),
- vol.Optional("pause_duration"): vol.All(vol.Coerce(int), vol.Range(min=1, max=480)),
- vol.Optional("lock_position"): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)),
- vol.Optional("vent_position"): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)),
- vol.Optional("lock_tilt_position"): vol.Any(vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), None),
- vol.Optional("vent_tilt_position"): vol.Any(vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), None),
- vol.Optional("min_position_change"): vol.All(vol.Coerce(int), vol.Range(min=1, max=50)),
- vol.Optional("min_time_between_changes"): vol.All(vol.Coerce(int), vol.Range(min=60, max=3600)),
- vol.Optional("house_rotation"): vol.All(vol.Coerce(float), vol.Range(min=-180, max=180)),
+ # Same bounds as the import (storage.SETTING_VALIDATORS)
+ **{vol.Optional(key): validator for key, validator in SETTING_VALIDATORS.items()},
vol.Optional("active_scenario"): str,
- vol.Optional("workday_sensor"): vol.Any(str, None),
- vol.Optional("wind_sensor"): vol.Any(str, None),
- vol.Optional("wind_speed_threshold"): vol.All(vol.Coerce(float), vol.Range(min=0)),
- vol.Optional("wind_speed_hysteresis"): vol.All(vol.Coerce(float), vol.Range(min=0)),
- vol.Optional("command_stagger"): vol.All(vol.Coerce(float), vol.Range(min=0, max=2.0)),
- vol.Optional("solar_sensor"): vol.Any(str, None),
- vol.Optional("solar_threshold"): vol.All(vol.Coerce(float), vol.Range(min=0)),
- vol.Optional("solar_hysteresis"): vol.All(vol.Coerce(float), vol.Range(min=0)),
- vol.Optional("logbook_enabled"): bool,
- vol.Optional("update_check_enabled"): bool,
},
),
(
@@ -1033,6 +1388,7 @@ def async_setup_api(
{
vol.Optional("event_type"): str,
vol.Optional("entity_id"): str,
+ vol.Optional("include_global"): bool,
vol.Optional("limit"): vol.All(int, vol.Range(min=1, max=2000)),
},
),
@@ -1070,6 +1426,16 @@ async def _handler(
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
+ # After the entry was unloaded (and not set up again, which
+ # re-registers these commands) the bound storage/coordinator
+ # are dead: refuse instead of mutating and saving them.
+ entry = getattr(coordinator, "config_entry", None)
+ state = getattr(entry, "state", None)
+ if isinstance(state, ConfigEntryState) and state is not ConfigEntryState.LOADED:
+ connection.send_error(
+ msg["id"], "not_loaded", "CoverAutomatic is not loaded"
+ )
+ return
await fn(hass, connection, msg, storage, coordinator)
if ct not in read_only_commands:
return websocket_api.require_admin(_handler)
@@ -1081,3 +1447,12 @@ async def _handler(
websocket_api.async_register_command(
hass, command_type, _make_handler(handler_fn, command_type), schema
)
+
+ websocket_api.async_register_command(
+ hass,
+ f"{DOMAIN}/subscribe",
+ ws_subscribe_updates,
+ websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
+ {vol.Required("type"): f"{DOMAIN}/subscribe"}
+ ),
+ )
diff --git a/custom_components/cover_automatic/binary_sensor.py b/custom_components/cover_automatic/binary_sensor.py
new file mode 100644
index 0000000..c79113b
--- /dev/null
+++ b/custom_components/cover_automatic/binary_sensor.py
@@ -0,0 +1,62 @@
+"""Binary sensor platform for CoverAutomatic."""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from homeassistant.components.binary_sensor import BinarySensorEntity
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from . import i18n
+from .const import DOMAIN
+from .coordinator import CoverAutomaticCoordinator
+
+if TYPE_CHECKING:
+ from homeassistant.core import HomeAssistant
+ from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+ from . import CoverAutomaticConfigEntry
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: CoverAutomaticConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up binary sensor entities."""
+ coordinator = entry.runtime_data.coordinator
+ async_add_entities([WindProtectionBinarySensor(coordinator, entry.entry_id)])
+
+
+class WindProtectionBinarySensor(CoordinatorEntity[CoverAutomaticCoordinator], BinarySensorEntity):
+ """On while wind protection drives the covers."""
+
+ _attr_has_entity_name = True
+ _attr_translation_key = "wind_protection"
+ _attr_icon = "mdi:weather-windy"
+
+ def __init__(self, coordinator: CoverAutomaticCoordinator, entry_id: str) -> None:
+ """Initialize the binary sensor."""
+ super().__init__(coordinator)
+ self._attr_unique_id = f"{DOMAIN}_{entry_id}_wind_protection"
+ self._attr_device_info = {
+ "identifiers": {(DOMAIN, entry_id)},
+ "name": "CoverAutomatic",
+ "manufacturer": "CoverAutomatic",
+ "model": i18n.text(coordinator.hass, "model_controller"),
+ }
+
+ @property
+ def is_on(self) -> bool:
+ """Whether wind protection is active."""
+ return self.coordinator.wind_protected
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any]:
+ """Wind speed, threshold and hysteresis."""
+ storage = self.coordinator.storage
+ return {
+ "wind_speed": self.coordinator.get_wind_speed(),
+ "threshold": storage.wind_speed_threshold,
+ "hysteresis": storage.wind_speed_hysteresis,
+ "wind_sensor": storage.wind_sensor,
+ }
diff --git a/custom_components/cover_automatic/const.py b/custom_components/cover_automatic/const.py
index 2c68589..5f1ac3b 100644
--- a/custom_components/cover_automatic/const.py
+++ b/custom_components/cover_automatic/const.py
@@ -19,6 +19,8 @@
# Tilt / slat control
TILT_COMMAND_DELAY: Final = 1.5 # seconds between position and tilt command
TILT_FEATURE_FLAG: Final = CoverEntityFeature.SET_TILT_POSITION
+# Covers lacking this feature only support open/close commands
+SET_POSITION_FEATURE_FLAG: Final = CoverEntityFeature.SET_POSITION
# Binary sensor on-states for contact sensors
BINARY_SENSOR_ON_STATES: Final = frozenset({"on", "open", "true", "1"})
@@ -35,5 +37,7 @@
LOG_EVENT_RULE: Final = "rule"
LOG_EVENT_WIND: Final = "wind"
-# Panel push event (fired when coordinator data changes)
-EVENT_DATA_UPDATED: Final = f"{DOMAIN}_updated"
+# Panel push signal (dispatched when coordinator data changes). A dispatcher
+# signal instead of a bus event: bus events are written to the recorder
+# database, which grew by one row per update cycle.
+SIGNAL_DATA_UPDATED: Final = f"{DOMAIN}_updated"
diff --git a/custom_components/cover_automatic/coordinator.py b/custom_components/cover_automatic/coordinator.py
index 4eeca62..ac93088 100644
--- a/custom_components/cover_automatic/coordinator.py
+++ b/custom_components/cover_automatic/coordinator.py
@@ -4,11 +4,12 @@
import asyncio
import logging
import time as time_mod
-from datetime import timedelta
+from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from homeassistant.components.logbook import async_log_entry
from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
@@ -17,14 +18,16 @@
BINARY_SENSOR_ON_STATES,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
- EVENT_DATA_UPDATED,
+ SIGNAL_DATA_UPDATED,
LOG_EVENT_POSITION,
LOG_EVENT_RULE,
LOG_EVENT_STATUS,
LOG_EVENT_WIND,
+ SET_POSITION_FEATURE_FLAG,
TILT_COMMAND_DELAY,
TILT_FEATURE_FLAG,
)
+from . import i18n
from .engine import RuleEngine
from .models import CoverConfig, CoverStatus
from .storage import ActivityLogStorage, CoverAutomaticStorage
@@ -42,13 +45,76 @@
# Seconds to ignore position changes after our own commands
SETTLE_TIME = 30
+# Margin added to a cover's travel time to get its settle time (seconds)
+TRAVEL_TIME_MARGIN = 5
+
+# Travel time measurement: minimum move (%) and maximum duration (seconds).
+# Short moves of polled covers are dominated by the polling interval (the
+# arrival is only seen at the next poll), which inflated the learned value:
+# only moves of at least half the full travel are used.
+MIN_MEASURED_DISTANCE = 50
+MAX_MEASURED_TRAVEL = 300
+# A new travel sample may exceed the previous measurement by this factor at
+# most (one late poll must not double the settle time).
+MAX_TRAVEL_GROWTH = 1.5
+
+# Resends of a position command the cover did not react to at all (lost
+# radio/cloud command) before it is treated as a manual override.
+MAX_LOST_COMMAND_RETRIES = 2
+
+# Keys written by the coordinator into storage._data (runtime state only,
+# never part of the configuration): wind protection and pre-lock statuses,
+# so protections survive a Home Assistant restart.
+WIND_STATE_KEY = "wind_protected_state"
+PRE_LOCK_STATES_KEY = "pre_lock_states"
+
+_UNUSABLE_STATES = ("unavailable", "unknown")
+
+# Resume on match (manual pause ended when the cover is back at its rule's
+# position): no check during the first seconds of a pause -- a cover moved by
+# hand still reports its start position, which may be the rule's target --
+# and the cover must be still (no report for SETTLE_TIME).
+RESUME_MATCH_GRACE = 60
+
# Seconds after startup before applying positions (sensor stabilization)
STARTUP_GRACE_PERIOD = 120
+def _is_safety(result: Any) -> bool:
+ """Whether an engine result comes from a safety rule."""
+ return result is not None and getattr(result, "safety", False) is True
+
+
class CoverAutomaticCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinate data updates and rule evaluation."""
+ # Runtime helpers created lazily (see their accessors): the class
+ # defaults keep coordinators built without __init__ (tests) working.
+ _wind_tasks: set[asyncio.Task[None]] | None = None
+ _lost_command_retries: dict[str, int] | None = None
+ _apply_lock: asyncio.Lock | None = None
+ _paused_at: dict[str, float] | None = None
+ # Origin of each pause: True = manual override detected, False = explicit
+ # pause (service). Unknown (e.g. restored after a restart) = manual.
+ _pause_manual: dict[str, bool] | None = None
+ # Covers that moved at all since our last position command (even during
+ # the settle time): a lost-command resend is only for covers that did not
+ # react at all -- a user counter-order also ends at the start position.
+ _moved_since_command: set[str] | None = None
+ # Monotonic time of the last position / motion change of each cover
+ # (attribute-only reports such as Zigbee linkquality do not count)
+ _position_changed_at: dict[str, float] | None = None
+ # Covers whose lock sensor entity no longer exists (logged once)
+ _lock_sensor_missing: set[str] | None = None
+ # Covers whose move is blocked by an unknown window state (logged once
+ # per blocking episode)
+ _move_blocked_logged: set[str] | None = None
+ # Wind protection restored from storage, still to be confirmed by the
+ # wind sensor before the end of the startup grace period
+ _wind_restore_pending: bool = False
+ # The wind sensor gave a valid numeric value since startup
+ _wind_value_seen: bool = False
+
def __init__(
self,
hass: HomeAssistant,
@@ -79,17 +145,65 @@ def __init__(
self._hysteresis_info: dict[str, str | None] = {}
self._last_matching_rules: dict[str, str | None] = {}
self._last_move_rule: dict[str, str | None] = {}
+ # Last position actually commanded by the apply cycle (raw, inverted)
+ self._last_sent_target: dict[str, int] = {}
+ # Moves commanded by the apply cycle, for travel time measurement:
+ # entity_id -> (monotonic start, start position, target), raw positions
+ self._move_start: dict[str, tuple[float, int, int]] = {}
# Covers that just exited a protective status (LOCKED / VENTING) and
# need to bypass the time hysteresis once -- otherwise a long
# min_time_between_changes leaves the cover at vent/lock position
# long after the sensor has cleared.
self._post_protective_exit: set[str] = set()
+ # Covers currently driven by a safety rule: entity_id -> rule id
+ self._safety_holds: dict[str, str] = {}
+ # Covers whose protective status is kept because a lock/vent sensor
+ # is unavailable (logged once per outage)
+ self._sensor_unknown_kept: set[str] = set()
+ # Wind sensor unavailable while protected (warned once per outage)
+ self._wind_sensor_warned: bool = False
+ # Pending wind command tasks (staggered sends), cancelled when the
+ # wind protection ends or is re-activated and on shutdown
+ self._wind_tasks = set()
+ # Resends of lost position commands per cover (bounded)
+ self._lost_command_retries = {}
+ # Serialises apply cycles: one may sleep during the command stagger
+ self._apply_lock = asyncio.Lock()
+ # Start of each manual pause (monotonic), for the resume-on-match grace
+ self._paused_at = {}
+ self._pause_manual = {}
+ self._moved_since_command = set()
+ self._position_changed_at = {}
+ self._lock_sensor_missing = set()
+ self._move_blocked_logged = set()
+ self._wind_restore_pending = False
+ self._wind_value_seen = False
self._startup_time: float = time_mod.monotonic()
self._startup_skip: bool = True
self._grace_synced: bool = False
self._unsub_update_listener: Any = None
self.log_storage: ActivityLogStorage | None = None
+ def _runtime_set(self, name: str) -> set[str]:
+ """Per-cover runtime set created lazily (coordinators built without __init__)."""
+ value = getattr(self, name, None)
+ if value is None:
+ value = set()
+ setattr(self, name, value)
+ return value
+
+ def _runtime_dict(self, name: str) -> dict[str, Any]:
+ """Per-cover runtime dict created lazily (see _runtime_set)."""
+ value = getattr(self, name, None)
+ if value is None:
+ value = {}
+ setattr(self, name, value)
+ return value
+
+ def _in_startup_grace(self) -> bool:
+ """Whether the startup grace period (sensor stabilisation) is running."""
+ return (time_mod.monotonic() - self._startup_time) < STARTUP_GRACE_PERIOD
+
def _cover_val(self, cover_raw: dict[str, Any], key: str) -> Any:
"""Get cover config value with global fallback from storage."""
val = cover_raw.get(key)
@@ -118,22 +232,54 @@ async def async_setup(self) -> None:
@callback
def _fire_update_event(self) -> None:
- """Fire HA event to notify panel of data changes."""
- self.hass.bus.async_fire(EVENT_DATA_UPDATED)
+ """Notify panel subscriptions (WebSocket) of data changes."""
+ async_dispatcher_send(self.hass, SIGNAL_DATA_UPDATED)
def _restore_cover_states(self) -> None:
"""Restore cover states from persisted storage data.
- Only restores PAUSED (with unexpired timer). All other statuses start
- as AUTO and get re-derived from sensor states by _sync_cover_statuses.
+ Restores PAUSED (with unexpired timer), the protective statuses
+ LOCKED / VENTING (with their pre-lock status) and, when the wind
+ protection was active and is still configured, WIND_PROTECTED.
+ Everything else starts as AUTO and is re-derived by
+ _sync_cover_statuses.
+
+ The protective statuses must survive a restart: battery sensors
+ (Zigbee) or alarm integrations often report "unknown" for minutes
+ after startup, and an AUTO cover whose window state is unknown would
+ otherwise be closed by the next night rule on an open window. While
+ the sensor stays unknown the sync keeps the restored status; once it
+ reports "closed" the cover is unlocked normally.
"""
- for entity_id, cover_data in self.storage._data.get("covers", {}).items():
+ data = self.storage._data
+ wind_configured = bool(self.storage.wind_sensor) and self.storage.wind_speed_threshold > 0
+ self._wind_protected = data.get(WIND_STATE_KEY) is True and wind_configured
+ # Confirmed by the wind sensor before the end of the grace period,
+ # otherwise dropped (see _drop_unconfirmed_wind_restore)
+ self._wind_restore_pending = self._wind_protected
+ if self._wind_protected:
+ _LOGGER.info("Startup: wind protection was active, kept until the wind sensor reports")
+ stored_pre_lock = data.get(PRE_LOCK_STATES_KEY)
+ if not isinstance(stored_pre_lock, dict):
+ stored_pre_lock = {}
+ kept = {CoverStatus.LOCKED.value, CoverStatus.VENTING.value}
+ if self._wind_protected:
+ kept.add(CoverStatus.WIND_PROTECTED.value)
+ for entity_id, cover_data in data.get("covers", {}).items():
stored_status = cover_data.get("status", "auto")
if stored_status == CoverStatus.PAUSED.value:
pause_until = cover_data.get("pause_until")
if pause_until and dt_util.now().timestamp() < pause_until:
self._cover_states[entity_id] = CoverStatus.PAUSED
continue
+ if stored_status in kept:
+ self._cover_states[entity_id] = CoverStatus(stored_status)
+ try:
+ self._pre_lock_states[entity_id] = CoverStatus(stored_pre_lock.get(entity_id))
+ except ValueError:
+ pass # no (or invalid) pre-lock status: unlock falls back to AUTO
+ _LOGGER.debug("[%s] Startup: restored %s", entity_id, stored_status)
+ continue
# Reset everything else to AUTO (lock/vent re-detected from sensors)
if stored_status != CoverStatus.AUTO.value:
_LOGGER.debug("[%s] Startup: reset %s -> AUTO", entity_id, stored_status)
@@ -142,46 +288,81 @@ def _restore_cover_states(self) -> None:
self.storage.update_cover_status(
entity_id, CoverStatus.AUTO.value, None
)
+ self._persist_runtime_state()
+
+ def _persist_runtime_state(self) -> None:
+ """Write the wind state and the pre-lock statuses into the store.
+
+ Only written (debounced save) when changed. Called after every sync
+ cycle as well, which also rewrites the keys after an import replaced
+ the whole store content.
+ """
+ data = self.storage._data
+ pre_lock = {
+ entity_id: status.value
+ for entity_id, status in self._pre_lock_states.items()
+ if isinstance(status, CoverStatus)
+ }
+ if data.get(WIND_STATE_KEY) is self._wind_protected and data.get(PRE_LOCK_STATES_KEY) == pre_lock:
+ return
+ data[WIND_STATE_KEY] = self._wind_protected
+ data[PRE_LOCK_STATES_KEY] = pre_lock
+ self.storage._schedule_save()
+
+ def _set_wind_protected(self, active: bool) -> None:
+ """Change the global wind protection state and persist it.
+
+ Any real change ends the pending confirmation of a wind state
+ restored at startup (the sensor has spoken, or settings changed).
+ """
+ self._wind_protected = active
+ self._wind_restore_pending = False
+ self._persist_runtime_state()
async def _async_setup_default_scenarios(self) -> None:
- """Create default scenarios if none exist."""
+ """Create default scenarios (named in the HA language) if none exist.
+
+ Existing default scenarios whose name was never changed (still one of
+ the built-in names in any language) are renamed to the current HA
+ language, so an English-named "Everyday" becomes "Quotidien" on a
+ French installation. User-chosen names are left untouched.
+ """
+ from .models import Scenario
+
+ icons = {
+ "everyday": "mdi:home",
+ "summer": "mdi:white-balance-sunny",
+ "winter": "mdi:snowflake",
+ "vacation": "mdi:airplane",
+ "cinema": "mdi:movie",
+ "manual": "mdi:hand-back-right",
+ }
if not self.storage.scenarios:
- from .models import Scenario
-
- defaults = [
- Scenario(
- id="everyday",
- name="Everyday",
- icon="mdi:home",
- ),
- Scenario(
- id="summer",
- name="Summer",
- icon="mdi:white-balance-sunny",
- ),
- Scenario(
- id="winter",
- name="Winter",
- icon="mdi:snowflake",
- ),
- Scenario(
- id="vacation",
- name="Vacation",
- icon="mdi:airplane",
- ),
- Scenario(
- id="cinema",
- name="Cinema",
- icon="mdi:movie",
- ),
- Scenario(
- id="manual",
- name="Manual",
- icon="mdi:hand-back-right",
- ),
- ]
- for scenario in defaults:
- await self.storage.async_add_scenario(scenario)
+ for scenario_id in i18n.DEFAULT_SCENARIO_IDS:
+ await self.storage.async_add_scenario(
+ Scenario(
+ id=scenario_id,
+ name=i18n.text(self.hass, f"scenario_{scenario_id}"),
+ icon=icons[scenario_id],
+ ),
+ save=False,
+ )
+ await self.storage.async_save()
+ return
+
+ renamed = False
+ for scenario_id in i18n.DEFAULT_SCENARIO_IDS:
+ raw = self.storage._data.get("scenarios", {}).get(scenario_id)
+ if not isinstance(raw, dict):
+ continue
+ localized = i18n.text(self.hass, f"scenario_{scenario_id}")
+ name = raw.get("name")
+ if name != localized and name in i18n.default_scenario_names(scenario_id):
+ raw["name"] = localized
+ renamed = True
+ if renamed:
+ self.storage._invalidate_cache()
+ await self.storage.async_save()
def _setup_state_tracking(self, full_refresh: bool = False) -> None:
"""Set up state change tracking for relevant entities.
@@ -209,15 +390,37 @@ def _setup_state_tracking(self, full_refresh: bool = False) -> None:
self._hysteresis_info,
self._last_matching_rules,
self._last_move_rule,
+ self._last_sent_target,
+ self._move_start,
+ self._safety_holds,
):
orphaned = set(state_dict.keys()) - current_covers
for entity_id in orphaned:
value = state_dict.pop(entity_id)
if isinstance(value, asyncio.Task) and not value.done():
value.cancel()
+ if self._lost_command_retries:
+ for entity_id in set(self._lost_command_retries) - current_covers:
+ self._lost_command_retries.pop(entity_id, None)
+ if self._paused_at:
+ for entity_id in set(self._paused_at) - current_covers:
+ self._paused_at.pop(entity_id, None)
+ for name in ("_pause_manual", "_position_changed_at"):
+ mapping = self._runtime_dict(name)
+ for entity_id in set(mapping) - current_covers:
+ mapping.pop(entity_id, None)
+ for name in ("_moved_since_command", "_lock_sensor_missing", "_move_blocked_logged"):
+ entries = self._runtime_set(name)
+ entries -= set(entries) - current_covers
# Per-entity sets: drop orphaned entity ids as well
- for state_set in (self._pending_settle, self._post_protective_exit):
+ for state_set in (
+ self._pending_settle, self._post_protective_exit, self._sensor_unknown_kept,
+ ):
state_set -= set(state_set) - current_covers
+ # Rule engine per-cover caches (comfort mode, warnings)
+ engine = getattr(self, "engine", None)
+ if engine is not None:
+ engine.forget_covers_except(current_covers)
entities_to_track: set[str] = {SUN_ENTITY_ID}
@@ -229,6 +432,12 @@ def _setup_state_tracking(self, full_refresh: bool = False) -> None:
entities_to_track.add(vent_sensor)
if indoor_sensor := cover_data.get("indoor_temp_sensor"):
entities_to_track.add(indoor_sensor)
+ if isinstance(occupancy := cover_data.get("occupancy_sensor"), str) and occupancy:
+ entities_to_track.add(occupancy)
+ # Entities holding a per-cover comfort band
+ for key in ("comfort_temp_min_entity", "comfort_temp_max_entity"):
+ if isinstance(ref := cover_data.get(key), str) and ref:
+ entities_to_track.add(ref)
if self.storage.outdoor_temp_sensor:
entities_to_track.add(self.storage.outdoor_temp_sensor)
@@ -245,12 +454,31 @@ def _setup_state_tracking(self, full_refresh: bool = False) -> None:
if self.storage.solar_sensor:
entities_to_track.add(self.storage.solar_sensor)
+ if self.storage.workday_sensor:
+ entities_to_track.add(self.storage.workday_sensor)
+
+ # Entities holding global thresholds (a change re-evaluates the rules)
+ for key in ("comfort_temp_min_entity", "comfort_temp_max_entity", "solar_threshold_entity"):
+ if isinstance(ref := getattr(self.storage, key, None), str) and ref:
+ entities_to_track.add(ref)
+
for rule_data in self.storage._data.get("rules", {}).values():
for condition in rule_data.get("conditions", []):
- if sensor := condition.get("params", {}).get("sensor"):
- entities_to_track.add(sensor)
- if entity := condition.get("params", {}).get("entity"):
- entities_to_track.add(entity)
+ params = condition.get("params") or {}
+ if not isinstance(params, dict):
+ continue
+ # The panel stores entities under "entity_id" (state_is,
+ # numeric_state, workday); "sensor"/"entity" are legacy keys.
+ for key in ("sensor", "entity", "entity_id"):
+ ref = params.get(key)
+ if isinstance(ref, str) and ref:
+ entities_to_track.add(ref)
+
+ # Entities referenced by native Home Assistant conditions (incl. templates)
+ try:
+ entities_to_track |= set(self.engine.ha_condition_entities())
+ except (AttributeError, TypeError):
+ pass
new_entities = entities_to_track - self._tracked_entities
@@ -294,9 +522,13 @@ def _get_wind_speed(self) -> float | None:
if state is None:
return None
try:
- return float(state.state)
+ speed = float(state.state)
except (ValueError, TypeError):
return None
+ if speed != speed: # NaN is no valid reading
+ return None
+ self._wind_value_seen = True
+ return speed
def _check_wind_protection(self) -> None:
"""Check wind sensor and update global wind protection state.
@@ -304,36 +536,179 @@ def _check_wind_protection(self) -> None:
Uses hysteresis: activates at threshold, deactivates at
threshold - hysteresis to prevent oscillation in gusty wind.
"""
+ threshold = self.storage.wind_speed_threshold
+ hysteresis = self.storage.wind_speed_hysteresis
+
+ # Feature switched off (sensor removed or threshold set to 0) while
+ # protection is active: release it, otherwise covers stay blocked.
+ if self._wind_protected and (not self.storage.wind_sensor or threshold <= 0):
+ _LOGGER.info("Wind protection DEACTIVATED (wind protection disabled in settings)")
+ self._set_wind_protected(False)
+ self._log(LOG_EVENT_WIND, None, "Deactivated (disabled in settings)",
+ {"key": "wind_disabled"})
+ self._logbook(i18n.text(self.hass, "wind_disabled"))
+ self._deactivate_wind_protection()
+ return
+
wind_speed = self._get_wind_speed()
+ if self._drop_unconfirmed_wind_restore():
+ return
if wind_speed is None:
- if self._wind_protected:
+ if self._wind_protected and not self._wind_sensor_warned:
+ self._wind_sensor_warned = True
_LOGGER.warning("Wind sensor unavailable while WIND_PROTECTED, keeping protection active")
return
+ self._wind_sensor_warned = False
- threshold = self.storage.wind_speed_threshold
- hysteresis = self.storage.wind_speed_hysteresis
+ if hysteresis >= threshold > 0:
+ # Invalid config (e.g. older storage/import): the release level
+ # threshold - hysteresis would be <= 0 and never reached.
+ _LOGGER.debug(
+ "wind_speed_hysteresis (%.1f) >= threshold (%.1f), ignoring hysteresis",
+ hysteresis, threshold,
+ )
+ hysteresis = 0.0
if not self._wind_protected and wind_speed >= threshold > 0:
- _LOGGER.info("Wind protection ACTIVATED (%.1f >= %.1f)", wind_speed, threshold)
- self._wind_protected = True
- self._log(LOG_EVENT_WIND, None, f"Activated ({wind_speed:.1f} >= {threshold:.1f})")
- self._logbook(f"wind protection activated ({wind_speed:.1f} >= {threshold:.1f})")
+ wind_pos = self._wind_position()
+ _LOGGER.info(
+ "Wind protection ACTIVATED (%.1f >= %.1f), covers -> %d%%",
+ wind_speed, threshold, wind_pos,
+ )
+ self._set_wind_protected(True)
+ self._log(LOG_EVENT_WIND, None, f"Activated ({wind_speed:.1f} >= {threshold:.1f})",
+ {"key": "wind_activated", "speed": round(wind_speed, 1),
+ "threshold": round(threshold, 1), "position": wind_pos})
+ self._logbook(i18n.text(
+ self.hass, "wind_activated",
+ speed=f"{wind_speed:.1f}", threshold=f"{threshold:.1f}",
+ ))
self._activate_wind_protection()
elif self._wind_protected and wind_speed <= threshold - hysteresis:
_LOGGER.info("Wind protection DEACTIVATED (%.1f <= %.1f)", wind_speed, threshold - hysteresis)
- self._wind_protected = False
- self._log(LOG_EVENT_WIND, None, f"Deactivated ({wind_speed:.1f} <= {threshold - hysteresis:.1f})")
- self._logbook(f"wind protection deactivated ({wind_speed:.1f} <= {threshold - hysteresis:.1f})")
+ self._set_wind_protected(False)
+ release = threshold - hysteresis
+ self._log(LOG_EVENT_WIND, None, f"Deactivated ({wind_speed:.1f} <= {release:.1f})",
+ {"key": "wind_deactivated", "speed": round(wind_speed, 1),
+ "threshold": round(release, 1)})
+ self._logbook(i18n.text(
+ self.hass, "wind_deactivated",
+ speed=f"{wind_speed:.1f}", threshold=f"{release:.1f}",
+ ))
self._deactivate_wind_protection()
+ def _drop_unconfirmed_wind_restore(self) -> bool:
+ """Drop a wind protection restored at startup the sensor never confirmed.
+
+ The restored state is kept during the startup grace period (wind
+ sensors often report late). At its end, a wind sensor entity that
+ does not exist (renamed / removed) or that never gave a valid value
+ since startup cannot release the protection: the covers would stay
+ blocked at the wind position forever. The protection is then ended
+ normally (covers back to their previous status) and logged once.
+ Returns True when it was dropped.
+ """
+ if not self._wind_restore_pending or self._in_startup_grace():
+ return False
+ self._wind_restore_pending = False
+ if not self._wind_protected:
+ return False
+ sensor_id = self.storage.wind_sensor
+ if self.hass.states.get(sensor_id) is not None and self._wind_value_seen:
+ return False
+ _LOGGER.warning(
+ "Wind protection restored at startup dropped: wind sensor %s %s",
+ sensor_id,
+ "does not exist" if self.hass.states.get(sensor_id) is None else "never reported a value",
+ )
+ self._set_wind_protected(False)
+ self._log(LOG_EVENT_WIND, None, f"Restored wind protection dropped (sensor {sensor_id})",
+ {"key": "wind_restore_dropped", "sensor": sensor_id})
+ self._deactivate_wind_protection()
+ return True
+
+ def _wind_position(self) -> int:
+ """Logical position covers move to while wind protection is active."""
+ value = getattr(self.storage, "wind_position", 100)
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ return 100
+ return max(0, min(100, int(value)))
+
+ def _lock_open_window(self, entity_id: str, cover_raw: dict[str, Any]) -> None:
+ """Lock a cover whose window is open (lock position, or held in place)."""
+ if self._cover_states.get(entity_id) == CoverStatus.LOCKED:
+ return
+ lock_pos = self._cover_val(cover_raw, "lock_position")
+ current = self._get_current_position(entity_id)
+ if self._lock_moves(cover_raw, current, lock_pos):
+ lock_tilt = self._cover_val(cover_raw, "lock_tilt_position")
+ self._lock_cover(entity_id, lock_pos, lock_tilt=lock_tilt)
+ else:
+ if entity_id not in self._pre_lock_states:
+ prev = self._cover_states.get(entity_id, CoverStatus.AUTO)
+ self._pre_lock_states[entity_id] = CoverStatus.AUTO if prev == CoverStatus.PAUSED else prev
+ self._lock_in_place(entity_id, cover_raw)
+
+ def _keeps_lock_sensor_unknown(self, entity_id: str, cover_raw: dict[str, Any]) -> bool:
+ """Whether a LOCKED cover must stay locked: its window state is unknown.
+
+ Never close a cover on a possibly open window: an unavailable lock
+ sensor is not "closed".
+ """
+ return (
+ self._cover_states.get(entity_id) == CoverStatus.LOCKED
+ and self._is_sensor_unknown(cover_raw, "lock_sensor")
+ )
+
+ def _protect_one_from_wind(self, entity_id: str, cover_raw: dict[str, Any]) -> None:
+ """Wind protection for one cover while it is active.
+
+ An open window (lock sensor) wins over the wind: the cover stays or
+ gets locked (emergency exit). Otherwise the cover takes the wind
+ status and position -- only this cover is commanded, not all.
+ """
+ if self._is_sensor_open(cover_raw, "lock_sensor"):
+ self._lock_open_window(entity_id, cover_raw)
+ return
+ status = self._cover_states.get(entity_id)
+ if status == CoverStatus.WIND_PROTECTED:
+ return
+ if self._keeps_lock_sensor_unknown(entity_id, cover_raw):
+ return
+ prev = self._cover_states.get(entity_id, CoverStatus.AUTO)
+ if entity_id not in self._pre_lock_states:
+ self._pre_lock_states[entity_id] = CoverStatus.AUTO if prev == CoverStatus.PAUSED else prev
+ self._cover_states[entity_id] = CoverStatus.WIND_PROTECTED
+ self.storage.update_cover_status(entity_id, CoverStatus.WIND_PROTECTED.value, None)
+ self._log(LOG_EVENT_STATUS, entity_id, f"{prev.value} -> wind_protected",
+ {"key": "status_change", "from": prev.value, "to": "wind_protected"})
+ if entity_id not in self._safety_holds:
+ self._send_wind_position(entity_id)
+
def _activate_wind_protection(self) -> None:
- """Set all covers to WIND_PROTECTED and move to fully open."""
+ """Set all covers to WIND_PROTECTED and move them to the wind position.
+
+ Covers whose window is open are locked instead: the lock (emergency
+ exit) has priority over the wind. A LOCKED cover whose window state
+ is unknown stays locked (same guard as _protect_one_from_wind).
+ """
+ # A previous activation may still be sending: never run two
+ # staggered wind sequences in parallel.
+ self._cancel_wind_tasks()
+ wind_pos = self._wind_position()
commands: list[tuple[str, int]] = []
for entity_id in self.storage._data.get("covers", {}):
cover_raw = self.storage.get_cover_raw(entity_id)
if cover_raw is None:
continue
+ if self._is_sensor_open(cover_raw, "lock_sensor"):
+ self._lock_open_window(entity_id, cover_raw)
+ continue
+ if self._keeps_lock_sensor_unknown(entity_id, cover_raw):
+ _LOGGER.debug("[%s] Wind protection: kept LOCKED (window state unknown)", entity_id)
+ continue
+
prev = self._cover_states.get(entity_id, CoverStatus.AUTO)
if prev not in (CoverStatus.WIND_PROTECTED,):
if entity_id not in self._pre_lock_states:
@@ -342,40 +717,94 @@ def _activate_wind_protection(self) -> None:
self._cover_states[entity_id] = CoverStatus.WIND_PROTECTED
self.storage.update_cover_status(entity_id, CoverStatus.WIND_PROTECTED.value, None)
- # Move to fully open (position 100)
+ if entity_id in self._safety_holds:
+ # A safety rule drives this cover: it keeps the rule's position
+ _LOGGER.debug("[%s] Wind protection: kept by safety rule", entity_id)
+ continue
+
+ # Move to the wind position (logical, inverted covers mirrored)
inverted = cover_raw.get("inverted", False)
- actual_position = 0 if inverted else 100
+ actual_position = (100 - wind_pos) if inverted else wind_pos
self._last_positions[entity_id] = actual_position
self._last_command_time[entity_id] = time_mod.monotonic()
commands.append((entity_id, actual_position))
if commands:
- self.hass.async_create_task(self._send_staggered_commands(commands))
+ self._start_wind_commands(commands)
+ self._persist_runtime_state()
if self.data is not None:
self.async_set_updated_data(self.data)
+ def _send_wind_position(self, entity_id: str) -> None:
+ """Move one wind protected cover to the wind position."""
+ cover_raw = self.storage.get_cover_raw(entity_id)
+ if cover_raw is None:
+ return
+ wind_pos = self._wind_position()
+ actual = (100 - wind_pos) if cover_raw.get("inverted", False) else wind_pos
+ self._last_positions[entity_id] = actual
+ self._last_command_time[entity_id] = time_mod.monotonic()
+ _LOGGER.debug("[%s] Wind protection re-applied -> %d%%", entity_id, wind_pos)
+ self._start_wind_commands([(entity_id, actual)])
+
+ def _wind_task_set(self) -> set[asyncio.Task[None]]:
+ """Pending wind command tasks (created lazily)."""
+ if self._wind_tasks is None:
+ self._wind_tasks = set()
+ return self._wind_tasks
+
+ def _start_wind_commands(self, commands: list[tuple[str, int]]) -> None:
+ """Send wind commands in a tracked task (cancellable on release)."""
+ task = self.hass.async_create_task(self._send_staggered_commands(commands))
+ if isinstance(task, asyncio.Task):
+ tasks = self._wind_task_set()
+ tasks.add(task)
+ task.add_done_callback(tasks.discard)
+
+ def _cancel_wind_tasks(self) -> None:
+ """Cancel pending (staggered) wind commands."""
+ tasks = self._wind_task_set()
+ for task in list(tasks):
+ if not task.done():
+ task.cancel()
+ tasks.clear()
+
async def _send_staggered_commands(
self, commands: list[tuple[str, int]]
) -> None:
- """Send position commands with optional stagger delay between them."""
+ """Send wind position commands with optional stagger delay between them.
+
+ The wind may drop, a window may open (LOCKED) or a safety rule may
+ take over during the stagger sleeps: each command is re-checked
+ right before it is sent, and the settle window starts at the actual
+ send time (a late command must not be read as a manual move).
+ """
stagger = self.storage.command_stagger
for i, (entity_id, position) in enumerate(commands):
if i > 0 and stagger > 0:
await asyncio.sleep(stagger)
- await self.hass.services.async_call(
- "cover", "set_cover_position",
- {"entity_id": entity_id, "position": position},
- blocking=False,
- )
+ if (
+ not self._wind_protected
+ or self._cover_states.get(entity_id) != CoverStatus.WIND_PROTECTED
+ or entity_id in self._safety_holds
+ ):
+ _LOGGER.debug("[%s] Wind command dropped: status changed meanwhile", entity_id)
+ continue
+ self._last_positions[entity_id] = self._effective_position(entity_id, position)
+ self._last_command_time[entity_id] = time_mod.monotonic()
+ await self._async_send_position(entity_id, position)
def _deactivate_wind_protection(self) -> None:
"""Remove WIND_PROTECTED status from all covers, re-derive from sensors.
Evaluates lock/vent sensors inline so covers with open windows
transition directly to LOCKED/VENTING instead of briefly landing in
- AUTO and waiting for the next scan tick.
+ AUTO and waiting for the next scan tick. A cover whose window state
+ is unknown is locked in place (possibly open window).
"""
+ # Wind commands still waiting for their stagger slot are obsolete
+ self._cancel_wind_tasks()
for entity_id in list(self.storage._data.get("covers", {})):
if self._cover_states.get(entity_id) != CoverStatus.WIND_PROTECTED:
continue
@@ -384,46 +813,49 @@ def _deactivate_wind_protection(self) -> None:
if cover_raw is None:
continue
- self._pre_lock_states.pop(entity_id, None)
+ pre_wind = self._pre_lock_states.pop(entity_id, None)
if self._is_sensor_open(cover_raw, "lock_sensor"):
lock_pos = self._cover_val(cover_raw, "lock_position")
current = self._get_current_position(entity_id)
- if current is None or current < lock_pos:
+ if self._lock_moves(cover_raw, current, lock_pos):
lock_tilt = self._cover_val(cover_raw, "lock_tilt_position")
self._lock_cover(entity_id, lock_pos, lock_tilt=lock_tilt)
else:
- self._cover_states[entity_id] = CoverStatus.LOCKED
- self.storage.update_cover_status(entity_id, CoverStatus.LOCKED.value, None)
- self._update_last_position_from_state(entity_id)
+ # Record a pre-lock state (the pre-wind one was just
+ # dropped) so the window-closed unlock restores AUTO.
+ self._pre_lock_states[entity_id] = CoverStatus.AUTO
+ self._lock_in_place(entity_id, cover_raw)
continue
- if self._is_sensor_open(cover_raw, "vent_sensor"):
- vent_pos = self._cover_val(cover_raw, "vent_position")
- current = self._get_current_position(entity_id)
- if current is not None and current < vent_pos:
- inverted = cover_raw.get("inverted", False)
- actual = (100 - vent_pos) if inverted else vent_pos
- self._last_positions[entity_id] = actual
- self._last_command_time[entity_id] = time_mod.monotonic()
- self._pending_settle.add(entity_id)
- self.hass.async_create_task(
- self.hass.services.async_call(
- "cover", "set_cover_position",
- {"entity_id": entity_id, "position": actual},
- blocking=False,
- )
- )
- else:
- self._update_last_position_from_state(entity_id)
- self._cover_states[entity_id] = CoverStatus.VENTING
- self.storage.update_cover_status(entity_id, CoverStatus.VENTING.value, None)
+ if self._is_sensor_unknown(cover_raw, "lock_sensor"):
+ # Window state unknown (sensor unavailable): treat it as
+ # possibly open. Handing the cover back to AUTO would let the
+ # next rule close it on an open window; it stays where it is
+ # (LOCKED) until the sensor reports again.
+ self._pre_lock_states[entity_id] = self._status_for_automation(
+ pre_wind if pre_wind in (CoverStatus.AUTO, CoverStatus.MANUAL) else CoverStatus.AUTO,
+ cover_raw,
+ )
+ self._lock_in_place(entity_id, cover_raw, sensor_unknown=True)
continue
- self._cover_states[entity_id] = CoverStatus.AUTO
- self.storage.update_cover_status(entity_id, CoverStatus.AUTO.value, None)
- self._update_last_position_from_state(entity_id)
+ if self._is_sensor_open(cover_raw, "vent_sensor"):
+ self._enter_venting(entity_id, cover_raw, "wind protection ended")
+ continue
+ # Automation switched off during the storm: MANUAL right away
+ # (not AUTO until the next sync), whatever the pre-wind status.
+ target = self._status_for_automation(CoverStatus.AUTO, cover_raw)
+ self._cover_states[entity_id] = target
+ self.storage.update_cover_status(entity_id, target.value, None)
+ self._sync_expected_after_protective_exit(entity_id)
+ # Like the lock/vent exits: return to the rule right away instead
+ # of waiting for min_time_between_changes.
+ if target == CoverStatus.AUTO:
+ self._post_protective_exit.add(entity_id)
+
+ self._persist_runtime_state()
if self.data is not None:
self.async_set_updated_data(self.data)
@@ -447,6 +879,9 @@ def _async_on_state_change(self, event: Event) -> None:
if entity_id in self.storage._data.get("covers", {}):
self._handle_cover_state_change(entity_id, old_state, new_state)
+ # A managed cover may also feed a rule condition
+ if self._is_rule_entity(entity_id):
+ self.hass.async_create_task(self.async_request_refresh())
elif entity_id == self.storage.wind_sensor:
self._handle_wind_sensor_change(new_state)
else:
@@ -455,9 +890,28 @@ def _async_on_state_change(self, event: Event) -> None:
self._handle_contact_sensor_change(
entity_id, lock_covers, vent_covers, old_state, new_state
)
+ # The same entity may also feed a rule condition
+ if self._is_rule_entity(entity_id):
+ self.hass.async_create_task(self.async_request_refresh())
else:
self.hass.async_create_task(self.async_request_refresh())
+ def _is_rule_entity(self, entity_id: str) -> bool:
+ """Return True if a rule condition references this entity."""
+ try:
+ if entity_id in self.engine.ha_condition_entities():
+ return True
+ except (AttributeError, TypeError):
+ pass
+ for rule_data in self.storage._data.get("rules", {}).values():
+ for condition in rule_data.get("conditions", []):
+ params = condition.get("params") or {}
+ if isinstance(params, dict) and entity_id in (
+ params.get("sensor"), params.get("entity"), params.get("entity_id"),
+ ):
+ return True
+ return False
+
def _is_sensor_open(self, cover_raw: dict[str, Any], key: str) -> bool:
"""Check if a binary sensor (lock/vent) for a cover is open."""
sensor = cover_raw.get(key)
@@ -468,6 +922,58 @@ def _is_sensor_open(self, cover_raw: dict[str, Any], key: str) -> bool:
return False
return sensor_state.state in BINARY_SENSOR_ON_STATES
+ def _is_sensor_unknown(self, cover_raw: dict[str, Any], key: str) -> bool:
+ """Check if a configured lock/vent sensor has no usable state.
+
+ "unavailable" / "unknown" always count as unknown (possibly open
+ window). A sensor entity that does not exist at all (state None) is
+ only unknown during the startup grace period, while integrations are
+ still loading: afterwards it was renamed or removed and would block
+ the cover forever (restored LOCKED never released, moves down never
+ sent), so it is treated like no sensor configured.
+ """
+ sensor = cover_raw.get(key)
+ if not sensor:
+ return False
+ sensor_state = self.hass.states.get(sensor)
+ if sensor_state is None:
+ return self._in_startup_grace()
+ return sensor_state.state in _UNUSABLE_STATES
+
+ def _track_missing_lock_sensor(self, entity_id: str, cover_raw: dict[str, Any]) -> None:
+ """Log once per cover that its lock sensor entity no longer exists.
+
+ Such a sensor is ignored after the grace period (_is_sensor_unknown):
+ the activity log tells the user why the window lock no longer acts.
+ Logged again only after the sensor came back and disappeared again.
+ """
+ missing = self._runtime_set("_lock_sensor_missing")
+ sensor = cover_raw.get("lock_sensor")
+ if (
+ not sensor
+ or self.hass.states.get(sensor) is not None
+ or self._in_startup_grace()
+ ):
+ missing.discard(entity_id)
+ return
+ if entity_id in missing:
+ return
+ missing.add(entity_id)
+ _LOGGER.warning(
+ "[%s] Lock sensor %s does not exist: window lock ignored", entity_id, sensor
+ )
+ self._log(LOG_EVENT_STATUS, entity_id, f"Lock sensor {sensor} missing, ignored",
+ {"key": "lock_sensor_missing", "sensor": sensor})
+
+ @staticmethod
+ def _is_real_transition(old_state: Any, new_state: Any) -> bool:
+ """Whether a sensor event is a real change between two usable states."""
+ return (
+ old_state is not None
+ and old_state.state not in _UNUSABLE_STATES
+ and old_state.state != new_state.state
+ )
+
def _handle_contact_sensor_change(
self,
sensor_id: str,
@@ -510,50 +1016,47 @@ def is_sensor_open_cached(cover_raw: dict[str, Any], key: str) -> bool:
if is_open:
lock_pos = self._cover_val(cover_raw, "lock_position")
current = self._get_current_position(cover_id)
- if current is None or current < lock_pos:
- _LOGGER.info("[%s] Lock sensor open -> LOCKED at %d%%", cover_id, lock_pos)
+ if self._lock_moves(cover_raw, current, lock_pos):
self._lock_cover(cover_id, lock_pos, lock_tilt=self._cover_val(cover_raw, "lock_tilt_position"))
else:
- _LOGGER.info("[%s] Lock sensor open -> LOCKED (already at %d%%)", cover_id, current)
if cover_id not in self._pre_lock_states:
prev = self._cover_states.get(cover_id, CoverStatus.AUTO)
self._pre_lock_states[cover_id] = CoverStatus.AUTO if prev == CoverStatus.PAUSED else prev
- self._cover_states[cover_id] = CoverStatus.LOCKED
- self.storage.update_cover_status(cover_id, CoverStatus.LOCKED.value, None)
- self._update_last_position_from_state(cover_id)
+ self._lock_in_place(cover_id, cover_raw)
if self.data is not None:
self.async_set_updated_data(self.data)
+ elif self._cover_states.get(cover_id) == CoverStatus.LOCKED and self._wind_protected:
+ # Window closed during a storm: back to the wind protection
+ # (this cover only).
+ self._protect_one_from_wind(cover_id, cover_raw)
+ if self.data is not None:
+ self.async_set_updated_data(self.data)
elif self._cover_states.get(cover_id) == CoverStatus.LOCKED:
# Only unlock if vent sensor is also not open
if not is_sensor_open_cached(cover_raw, "vent_sensor"):
- _LOGGER.info("[%s] Lock sensor closed -> unlocking", cover_id)
+ _LOGGER.debug("[%s] Lock sensor %s closed -> unlocking", cover_id, sensor_id)
self._unlock_cover(cover_id)
else:
- _LOGGER.info("[%s] Lock sensor closed, vent still open -> VENTING", cover_id)
# Move to vent position if currently below it
- vent_pos = self._cover_val(cover_raw, "vent_position")
- current = self._get_current_position(cover_id)
- if current is not None and current < vent_pos:
- inverted = cover_raw.get("inverted", False)
- actual = (100 - vent_pos) if inverted else vent_pos
- self._last_positions[cover_id] = actual
- self._last_command_time[cover_id] = time_mod.monotonic()
- self._pending_settle.add(cover_id)
- self.hass.async_create_task(
- self.hass.services.async_call(
- "cover", "set_cover_position",
- {"entity_id": cover_id, "position": actual},
- blocking=False,
- )
- )
- else:
- self._update_last_position_from_state(cover_id)
self._pre_lock_states.pop(cover_id, None)
- self._cover_states[cover_id] = CoverStatus.VENTING
- self.storage.update_cover_status(cover_id, CoverStatus.VENTING.value, None)
+ self._enter_venting(
+ cover_id, cover_raw, f"window {sensor_id} closed, vent still open"
+ )
if self.data is not None:
self.async_set_updated_data(self.data)
+ # A vent sensor coming back from unavailable/unknown (or an event
+ # without a state change, e.g. an attribute update) is no real
+ # transition: acting on it would e.g. cancel a manual pause. The
+ # periodic sync reconciles the status from the current sensor state.
+ if not self._is_real_transition(old_state, new_state):
+ if vent_covers:
+ _LOGGER.debug(
+ "[%s] Vent sensor event without transition (%s -> %s), left to sync",
+ sensor_id, getattr(old_state, "state", None), new_state.state,
+ )
+ vent_covers = []
+
# Handle vent sensor covers (vent open -> min position, automation continues)
for cover_id in vent_covers:
cover_raw = self.storage.get_cover_raw(cover_id)
@@ -563,38 +1066,29 @@ def is_sensor_open_cached(cover_raw: dict[str, Any], key: str) -> bool:
# Skip if lock sensor is open (lock has priority)
if is_sensor_open_cached(cover_raw, "lock_sensor"):
continue
+ # A tilted window does not override the wind protection
+ if self._wind_protected:
+ continue
current_status = self._cover_states.get(cover_id, CoverStatus.AUTO)
- if is_open and current_status not in (CoverStatus.LOCKED, CoverStatus.VENTING):
- vent_pos = self._cover_val(cover_raw, "vent_position")
- current = self._get_current_position(cover_id)
+ if is_open and current_status == CoverStatus.PAUSED:
+ # A (manual) pause is kept, like the periodic sync does: a
+ # window tilted for a few seconds must not cancel it. Only
+ # the airflow is ensured (raised to the vent position when
+ # below); the pause timer is unchanged.
+ _LOGGER.debug(
+ "[%s] Vent sensor %s open while paused: pause kept", cover_id, sensor_id,
+ )
+ self._raise_to_vent_floor(cover_id, cover_raw)
+ elif is_open and current_status not in (CoverStatus.LOCKED, CoverStatus.VENTING):
# Move up to vent_position if currently below it
- if current is not None and current < vent_pos:
- _LOGGER.info("[%s] Vent sensor open -> VENTING, moving %d%% -> %d%%", cover_id, current, vent_pos)
- inverted = cover_raw.get("inverted", False)
- actual = (100 - vent_pos) if inverted else vent_pos
- self._last_positions[cover_id] = actual
- self._last_command_time[cover_id] = time_mod.monotonic()
- self._pending_settle.add(cover_id)
- self.hass.async_create_task(
- self.hass.services.async_call(
- "cover", "set_cover_position",
- {"entity_id": cover_id, "position": actual},
- blocking=False,
- )
- )
- else:
- _LOGGER.info(
- "[%s] Vent sensor open -> VENTING (at %d%%, min %d%%)",
- cover_id, current if current is not None else 0, vent_pos,
- )
- self._update_last_position_from_state(cover_id)
- self._cover_states[cover_id] = CoverStatus.VENTING
- self.storage.update_cover_status(cover_id, CoverStatus.VENTING.value, None)
+ self._enter_venting(cover_id, cover_raw, f"vent sensor {sensor_id} open")
if self.data is not None:
self.async_set_updated_data(self.data)
- elif not is_open and current_status in (CoverStatus.VENTING, CoverStatus.PAUSED):
+ elif not is_open and current_status == CoverStatus.VENTING:
+ # A PAUSED cover stays PAUSED (pause timer unchanged): the
+ # vent sensor closing is no reason to end a manual pause.
# Mirror the _sync_cover_statuses branch: a cover with automation
# disabled returns to MANUAL, not AUTO, so the refresh triggered
# below does not run an AUTO apply cycle (which could move a
@@ -604,10 +1098,16 @@ def is_sensor_open_cached(cover_raw: dict[str, Any], key: str) -> bool:
if cover_raw.get("auto_enabled", True)
else CoverStatus.MANUAL
)
- _LOGGER.info("[%s] Vent sensor closed -> %s", cover_id, target.value)
+ _LOGGER.info(
+ "[%s] Vent sensor %s closed: %s -> %s",
+ cover_id, sensor_id, current_status.value, target.value,
+ )
+ self._log(LOG_EVENT_STATUS, cover_id, f"{current_status.value} -> {target.value}",
+ {"key": "status_change", "from": current_status.value,
+ "to": target.value, "sensor": sensor_id})
self._cover_states[cover_id] = target
self.storage.update_cover_status(cover_id, target.value, None)
- self._update_last_position_from_state(cover_id)
+ self._sync_expected_after_protective_exit(cover_id)
# Mark for one-shot time-hysteresis bypass so the next apply
# cycle re-applies the matching rule even when
# min_time_between_changes has not elapsed.
@@ -616,20 +1116,168 @@ def is_sensor_open_cached(cover_raw: dict[str, Any], key: str) -> bool:
self.async_set_updated_data(self.data)
self.hass.async_create_task(self.async_request_refresh())
+ # Pre-lock statuses may have changed (lock/unlock): persist them now
+ # rather than at the next sync cycle.
+ self._persist_runtime_state()
+
+ @staticmethod
+ def _raw_position(state: Any) -> int | None:
+ """HA position of a cover state (raw, not inverted).
+
+ Covers without a current_position attribute (open/close only) are
+ read from their state instead of being taken for closed (0).
+ """
+ value = state.attributes.get("current_position")
+ if value is None:
+ return {"open": 100, "closed": 0}.get(state.state)
+ try:
+ return int(value)
+ except (ValueError, TypeError):
+ return None
+
+ def _supports_set_position(self, entity_id: str) -> bool:
+ """Whether a cover accepts set_cover_position.
+
+ Covers that only support open/close (some RTS / relay covers) must
+ get open_cover / close_cover instead; set_cover_position is rejected
+ by them. Without a supported_features attribute the cover is assumed
+ to support positions (previous behaviour).
+ """
+ state = self.hass.states.get(entity_id)
+ if state is None:
+ return True
+ features = state.attributes.get("supported_features")
+ if isinstance(features, bool) or not isinstance(features, int):
+ return True
+ return bool(features & SET_POSITION_FEATURE_FLAG)
+
+ def _effective_position(self, entity_id: str, position: int) -> int:
+ """Raw position a command really reaches (open/close-only: 0 or 100)."""
+ if self._supports_set_position(entity_id):
+ return position
+ return 100 if position >= 50 else 0
+
+ def _position_call(self, entity_id: str, position: int) -> Any:
+ """Service call moving a cover to a raw position (awaitable).
+
+ Open/close-only covers are opened from 50 % up, closed below.
+ """
+ if self._supports_set_position(entity_id):
+ return self.hass.services.async_call(
+ "cover", "set_cover_position",
+ {"entity_id": entity_id, "position": position},
+ blocking=False,
+ )
+ service = "open_cover" if position >= 50 else "close_cover"
+ return self.hass.services.async_call(
+ "cover", service, {"entity_id": entity_id}, blocking=False,
+ )
+
+ async def _async_send_position(self, entity_id: str, position: int) -> None:
+ """Send a raw position to a cover (see _position_call)."""
+ await self._position_call(entity_id, position)
+
def _get_current_position(self, entity_id: str) -> int | None:
"""Get current cover position (handles inverted covers)."""
state = self.hass.states.get(entity_id)
if state is None or state.state in ("unavailable", "unknown"):
return None
- try:
- pos = int(state.attributes.get("current_position", 0))
- except (ValueError, TypeError):
+ pos = self._raw_position(state)
+ if pos is None:
return None
cover_raw = self.storage.get_cover_raw(entity_id)
if cover_raw and cover_raw.get("inverted", False):
pos = 100 - pos
return pos
+ @staticmethod
+ def _lock_moves(cover_raw: dict[str, Any], current: int | None, lock_pos: int) -> bool:
+ """Whether locking moves the cover to its lock position.
+
+ With lock_hold_position the cover stays where it is (no position or
+ tilt command); otherwise it only moves up to lock_pos, never down.
+ """
+ if cover_raw.get("lock_hold_position"):
+ return False
+ return current is None or current < lock_pos
+
+ def _lock_in_place(
+ self, entity_id: str, cover_raw: dict[str, Any], *, sensor_unknown: bool = False
+ ) -> None:
+ """Set a cover LOCKED without moving it (hold option or already above).
+
+ With sensor_unknown the window state is unknown (sensor unavailable)
+ rather than open: the cover is held as a precaution. The caller
+ records the pre-lock state.
+ """
+ prev = self._cover_states.get(entity_id, CoverStatus.AUTO)
+ self._cover_states[entity_id] = CoverStatus.LOCKED
+ self.storage.update_cover_status(entity_id, CoverStatus.LOCKED.value, None)
+ self._update_last_position_from_state(entity_id)
+ if prev == CoverStatus.LOCKED:
+ return
+ current = self._get_current_position(entity_id)
+ sensor = cover_raw.get("lock_sensor")
+ _LOGGER.info(
+ "[%s] Window %s %s: %s -> locked (kept at %s%%)",
+ entity_id, sensor, "state unknown" if sensor_unknown else "open", prev.value, current,
+ )
+ self._log(LOG_EVENT_STATUS, entity_id, f"{prev.value} -> locked (kept at {current}%)",
+ {"key": "status_change", "from": prev.value, "to": "locked",
+ "sensor": sensor, "position": current, "kept": True})
+
+ def _raise_to_vent_floor(self, entity_id: str, cover_raw: dict[str, Any]) -> bool:
+ """Raise a cover below its vent position to it (with the vent tilt).
+
+ Returns True when a command was sent; otherwise the expected
+ position is synced to where the cover is. Does not change the status:
+ shared by _enter_venting and by a paused cover whose vent opens (the
+ pause is kept, only the airflow is ensured).
+ """
+ vent_pos = self._cover_val(cover_raw, "vent_position")
+ current = self._get_current_position(entity_id)
+ if current is None or current >= vent_pos:
+ self._update_last_position_from_state(entity_id)
+ return False
+ inverted = cover_raw.get("inverted", False)
+ actual = (100 - vent_pos) if inverted else vent_pos
+ self._last_positions[entity_id] = self._effective_position(entity_id, actual)
+ self._last_command_time[entity_id] = time_mod.monotonic()
+ self._pending_settle.add(entity_id)
+ self.hass.async_create_task(self._position_call(entity_id, actual))
+ vent_tilt = self._cover_val(cover_raw, "vent_tilt_position")
+ if (
+ isinstance(vent_tilt, int)
+ and not isinstance(vent_tilt, bool)
+ and cover_raw.get("supports_tilt", False)
+ ):
+ actual_tilt = 100 - vent_tilt if cover_raw.get("inverted_tilt", False) else vent_tilt
+ self._last_tilt_positions[entity_id] = actual_tilt
+ self._schedule_tilt(entity_id, actual_tilt, TILT_COMMAND_DELAY)
+ return True
+
+ def _enter_venting(self, entity_id: str, cover_raw: dict[str, Any], reason: str) -> None:
+ """Set a cover VENTING, raising it to the vent position when below.
+
+ When the cover is raised, the vent tilt (slats) is sent after the
+ move like the lock tilt, so the configured airflow angle is applied.
+ """
+ prev = self._cover_states.get(entity_id, CoverStatus.AUTO)
+ vent_pos = self._cover_val(cover_raw, "vent_position")
+ current = self._get_current_position(entity_id)
+ if self._raise_to_vent_floor(entity_id, cover_raw):
+ detail = f"moving {current}% -> {vent_pos}%"
+ else:
+ detail = f"kept at {current}%, min {vent_pos}%"
+ self._cover_states[entity_id] = CoverStatus.VENTING
+ self.storage.update_cover_status(entity_id, CoverStatus.VENTING.value, None)
+ if prev == CoverStatus.VENTING:
+ return
+ _LOGGER.info("[%s] %s: %s -> venting (%s)", entity_id, reason, prev.value, detail)
+ self._log(LOG_EVENT_STATUS, entity_id, f"{prev.value} -> venting ({detail})",
+ {"key": "status_change", "from": prev.value, "to": "venting",
+ "sensor": cover_raw.get("vent_sensor"), "position": vent_pos})
+
def _lock_cover(
self, entity_id: str, lock_position: int, *, lock_tilt: int | None = None
) -> None:
@@ -639,14 +1287,22 @@ def _lock_cover(
if entity_id not in self._pre_lock_states:
prev = self._cover_states.get(entity_id, CoverStatus.AUTO)
self._pre_lock_states[entity_id] = CoverStatus.AUTO if prev == CoverStatus.PAUSED else prev
+ prev_status = self._cover_states.get(entity_id, CoverStatus.AUTO)
self._cover_states[entity_id] = CoverStatus.LOCKED
prev_val = self._pre_lock_states.get(entity_id, CoverStatus.AUTO).value
- self._log(LOG_EVENT_STATUS, entity_id, f"{prev_val} -> locked")
- self._logbook(f"locked at {lock_position}% (window open)", entity_id)
+ cover_raw = self.storage.get_cover_raw(entity_id)
+ sensor = cover_raw.get("lock_sensor") if cover_raw else None
+ _LOGGER.info(
+ "[%s] Window %s open: %s -> locked, moving to %s%%",
+ entity_id, sensor, prev_status.value, lock_position,
+ )
+ self._log(LOG_EVENT_STATUS, entity_id, f"{prev_val} -> locked",
+ {"key": "status_change", "from": prev_val, "to": "locked",
+ "sensor": sensor, "position": lock_position})
+ self._logbook(i18n.text(self.hass, "locked", position=lock_position), entity_id)
self.storage.update_cover_status(entity_id, CoverStatus.LOCKED.value, None)
# Handle inverted covers
- cover_raw = self.storage.get_cover_raw(entity_id)
actual_position = lock_position
if cover_raw and cover_raw.get("inverted", False):
actual_position = 100 - lock_position
@@ -654,17 +1310,10 @@ def _lock_cover(
_LOGGER.debug("Locking cover %s at position %s (actual: %s)", entity_id, lock_position, actual_position)
# Update expected position to prevent false manual override
- self._last_positions[entity_id] = actual_position
+ self._last_positions[entity_id] = self._effective_position(entity_id, actual_position)
self._last_command_time[entity_id] = time_mod.monotonic()
- self.hass.async_create_task(
- self.hass.services.async_call(
- "cover",
- "set_cover_position",
- {"entity_id": entity_id, "position": actual_position},
- blocking=False,
- )
- )
+ self.hass.async_create_task(self._position_call(entity_id, actual_position))
# Send tilt command if supported and configured
if lock_tilt is not None and cover_raw and cover_raw.get("supports_tilt", False):
@@ -678,72 +1327,118 @@ def _lock_cover(
self.async_set_updated_data(self.data)
def _unlock_cover(self, entity_id: str) -> None:
- """Unlock a cover when contact sensor closes."""
- if entity_id not in self._pre_lock_states:
+ """Unlock a cover when its contact sensors are closed (or removed).
+
+ Only acts on covers that are actually LOCKED or VENTING. A missing
+ pre-lock state (e.g. VENTING entered directly, or a sensor removed
+ from the cover config) falls back to AUTO instead of leaving the
+ cover stuck in its protective status forever.
+ """
+ current = self._cover_states.get(entity_id)
+ if current not in (CoverStatus.LOCKED, CoverStatus.VENTING):
+ self._pre_lock_states.pop(entity_id, None)
return
- _LOGGER.debug("Unlocking cover %s", entity_id)
- self._log(LOG_EVENT_STATUS, entity_id, "locked -> unlocked")
- self._logbook("unlocked (window closed)", entity_id)
+ self._log(LOG_EVENT_STATUS, entity_id, f"{current.value} -> unlocked",
+ {"key": "status_change", "from": current.value, "to": "unlocked"})
+ self._logbook(i18n.text(self.hass, "unlocked"), entity_id)
- # Restore previous state if was PAUSED and pause not expired
+ cover_raw = self.storage.get_cover_raw(entity_id)
previous = self._pre_lock_states.pop(entity_id, CoverStatus.AUTO)
+ # Restore PAUSED (pause not expired), MANUAL, or VENTING (vent
+ # sensor still open); everything else returns to AUTO.
+ target = CoverStatus.AUTO
+ pause_until = None
if previous == CoverStatus.PAUSED:
- cover_raw = self.storage.get_cover_raw(entity_id)
pause_until = cover_raw.get("pause_until") if cover_raw else None
if pause_until and dt_util.now().timestamp() < pause_until:
- self._cover_states[entity_id] = CoverStatus.PAUSED
- self.storage.update_cover_status(
- entity_id, CoverStatus.PAUSED.value, pause_until
- )
- # Update expected position to current to prevent false override
- self._update_last_position_from_state(entity_id)
- if self.data is not None:
- self.async_set_updated_data(self.data)
- return
+ target = CoverStatus.PAUSED
+ elif previous == CoverStatus.MANUAL:
+ target = CoverStatus.MANUAL
+ elif previous == CoverStatus.VENTING:
+ if cover_raw and self._is_sensor_open(cover_raw, "vent_sensor"):
+ target = CoverStatus.VENTING
+ # The automation may have been switched on/off during the lock
+ target = self._status_for_automation(target, cover_raw)
+
+ sensors = ", ".join(
+ s for s in ((cover_raw or {}).get("lock_sensor"), (cover_raw or {}).get("vent_sensor")) if s
+ ) or "-"
+ _LOGGER.info(
+ "[%s] Window %s closed: %s -> %s (kept at %s%%)",
+ entity_id, sensors, current.value, target.value,
+ self._get_current_position(entity_id),
+ )
- # Restore MANUAL if cover was manual before lock
- if previous == CoverStatus.MANUAL:
- self._cover_states[entity_id] = CoverStatus.MANUAL
- self.storage.update_cover_status(
- entity_id, CoverStatus.MANUAL.value, None
- )
- self._update_last_position_from_state(entity_id)
+ self._cover_states[entity_id] = target
+ self.storage.update_cover_status(
+ entity_id, target.value, pause_until if target == CoverStatus.PAUSED else None
+ )
+ # Expected position: where the cover is (or is still going), so
+ # neither a finished lock move nor the stop is taken as manual
+ self._sync_expected_after_protective_exit(entity_id)
+ if target != CoverStatus.AUTO:
if self.data is not None:
self.async_set_updated_data(self.data)
return
-
- # Restore VENTING if vent sensor is still open
- if previous == CoverStatus.VENTING:
- cover_raw = self.storage.get_cover_raw(entity_id)
- if cover_raw and self._is_sensor_open(cover_raw, "vent_sensor"):
- self._cover_states[entity_id] = CoverStatus.VENTING
- self.storage.update_cover_status(
- entity_id, CoverStatus.VENTING.value, None
- )
- self._update_last_position_from_state(entity_id)
- if self.data is not None:
- self.async_set_updated_data(self.data)
- return
-
- self._cover_states[entity_id] = CoverStatus.AUTO
- self.storage.update_cover_status(entity_id, CoverStatus.AUTO.value, None)
- # Update expected position to current to prevent false override
- self._update_last_position_from_state(entity_id)
# One-shot time-hysteresis bypass for the next apply cycle.
self._post_protective_exit.add(entity_id)
self.hass.async_create_task(self.async_request_refresh())
+ @staticmethod
+ def _status_for_automation(
+ status: CoverStatus, cover_raw: dict[str, Any] | None
+ ) -> CoverStatus:
+ """Align a status restored after a protection with auto_enabled.
+
+ The pre-lock / pre-wind status was recorded when the protection
+ started; the automation may have been switched on or off since then
+ (switch, import, resume refused while locked). A MANUAL status with
+ the automation now on becomes AUTO -- the cover would otherwise never
+ be driven again -- and AUTO / PAUSED with the automation off becomes
+ MANUAL. Other statuses (VENTING) are returned unchanged.
+ """
+ auto_enabled = bool((cover_raw or {}).get("auto_enabled", True))
+ if status == CoverStatus.MANUAL and auto_enabled:
+ return CoverStatus.AUTO
+ if status in (CoverStatus.AUTO, CoverStatus.PAUSED) and not auto_enabled:
+ return CoverStatus.MANUAL
+ return status
+
+ def _sync_expected_after_protective_exit(self, entity_id: str) -> None:
+ """Expected position when a cover leaves LOCKED / VENTING / wind protection.
+
+ Normally the current position (the cover stays where the protection
+ left it). But when the window closes while the cover is still
+ travelling to its lock / vent / wind position, the current reading is
+ a mid-travel value (e.g. 34 % on the way to 100 %): taking it as the
+ expected position made the end of our own move look like a manual
+ command a few seconds later, and the cover was paused. While our
+ command is still settling, the commanded position is kept instead;
+ the post-settle check then syncs to where the cover really stopped.
+ """
+ last_cmd = self._last_command_time.get(entity_id, 0)
+ if (
+ entity_id in self._pending_settle
+ and self._last_positions.get(entity_id) is not None
+ and (time_mod.monotonic() - last_cmd) < self._settle_time(entity_id)
+ ):
+ _LOGGER.debug(
+ "[%s] Protection ended during our move: expected position kept at %s%%",
+ entity_id, self._last_positions.get(entity_id),
+ )
+ return
+ self._update_last_position_from_state(entity_id)
+
def _update_last_position_from_state(self, entity_id: str) -> None:
"""Update _last_positions and _last_tilt_positions from current HA state."""
state = self.hass.states.get(entity_id)
if state and state.state not in ("unavailable", "unknown"):
- try:
- self._last_positions[entity_id] = int(
- state.attributes.get("current_position", 0)
- )
- except (ValueError, TypeError):
- _LOGGER.warning(
+ position = self._raw_position(state)
+ if position is not None:
+ self._last_positions[entity_id] = position
+ else:
+ _LOGGER.debug(
"Invalid position attribute for %s, resetting tracked position",
entity_id,
)
@@ -770,6 +1465,13 @@ def _handle_cover_state_change(
if new_state.state in ("unavailable", "unknown"):
return
+ # Motion bookkeeping first: it must also see the reports received
+ # during the grace, wind and settle periods ignored below.
+ self._record_motion(entity_id, old_state, new_state)
+
+ # Learn the cover's travel time from our own moves (feeds the settle time)
+ self._measure_travel(entity_id, new_state)
+
# Ignore during startup grace period (device reconnection can report
# positions that differ from HA's persisted state)
if (time_mod.monotonic() - self._startup_time) < STARTUP_GRACE_PERIOD:
@@ -785,12 +1487,21 @@ def _handle_cover_state_change(
# Ignore position changes during settle time after our own commands
last_cmd = self._last_command_time.get(entity_id, 0)
- if (time_mod.monotonic() - last_cmd) < SETTLE_TIME:
+ if (time_mod.monotonic() - last_cmd) < self._settle_time(entity_id):
return
- try:
- current_position = int(new_state.attributes.get("current_position", 0))
- except (ValueError, TypeError):
+ current_position = self._raw_position(new_state)
+ if current_position is None:
+ return
+
+ # Slow covers that do not report opening/closing: while the position
+ # keeps moving towards our own target, the cover is still travelling
+ # from our command -- extend the settle window instead of reading the
+ # intermediate position as a manual override.
+ if entity_id in self._pending_settle and self._is_progressing_to_target(
+ entity_id, old_state, current_position
+ ):
+ self._last_command_time[entity_id] = time_mod.monotonic()
return
# After settle time, sync actual position before override check
@@ -807,6 +1518,8 @@ def _handle_cover_state_change(
"[%s] Post-settle: large deviation %d%% vs expected %d%%, checking override",
entity_id, current_position, expected_target,
)
+ if self._resend_if_lost(entity_id, current_position):
+ return
# Fall through to override check below
else:
self._last_positions[entity_id] = current_position
@@ -838,6 +1551,11 @@ def _handle_cover_state_change(
pass
if position_mismatch or tilt_mismatch:
+ if entity_id in self._safety_holds:
+ # A safety rule drives the cover: no pause, the next cycle
+ # re-applies the rule's position.
+ _LOGGER.debug("[%s] Position change ignored: safety rule active", entity_id)
+ return
if cover.auto_enabled and self._cover_states.get(entity_id) in (CoverStatus.AUTO, CoverStatus.VENTING):
_LOGGER.info(
"[%s] Manual override -> PAUSED (expected pos %s, got %s, expected tilt %s)",
@@ -848,12 +1566,267 @@ def _handle_cover_state_change(
)
self.pause_cover(cover)
- def pause_cover(self, cover: CoverConfig) -> None:
- """Pause automation for a cover."""
+ def _record_motion(self, entity_id: str, old_state: Any, new_state: Any) -> None:
+ """Record position/motion changes of a cover (not attribute-only reports).
+
+ Two uses:
+ - the time of the last real change (position, state, opening /
+ closing) feeds the stillness check of resume-on-match: Zigbee
+ covers re-report attributes (linkquality) every few seconds, so
+ state.last_updated never looks still;
+ - whether the cover moved at all since our last command, even during
+ the settle time: a user counter-order (up/stop) during our move
+ brings the cover back to its start position, which must not be
+ read as a lost command and resent.
+ """
+ new_pos = self._raw_position(new_state)
+ moving = new_state.state in ("opening", "closing")
+ old_usable = old_state is not None and old_state.state not in _UNUSABLE_STATES
+ old_pos = self._raw_position(old_state) if old_usable else None
+ if moving or not old_usable or old_state.state != new_state.state or old_pos != new_pos:
+ self._runtime_dict("_position_changed_at")[entity_id] = time_mod.monotonic()
+ move = self._move_start.get(entity_id)
+ if move is not None and (
+ moving or (new_pos is not None and abs(new_pos - move[1]) > MANUAL_OVERRIDE_TOLERANCE)
+ ):
+ self._runtime_set("_moved_since_command").add(entity_id)
+
+ def _data_raw_target(
+ self, entity_id: str, cover_raw: dict[str, Any], status: CoverStatus
+ ) -> int | None:
+ """Raw position the current cycle data asks for (as the apply cycle sends it)."""
+ cover_data = ((self.data or {}).get("covers") or {}).get(entity_id) or {}
+ target = cover_data.get("target_position")
+ if not isinstance(target, (int, float)) or isinstance(target, bool):
+ return None
+ target = int(target)
+ if status == CoverStatus.VENTING:
+ vent_min = self._cover_val(cover_raw, "vent_position")
+ if isinstance(vent_min, (int, float)) and not isinstance(vent_min, bool):
+ target = max(target, int(vent_min))
+ if cover_raw.get("inverted", False):
+ target = 100 - target
+ return self._effective_position(entity_id, target)
+
+ def _resend_if_lost(self, entity_id: str, current: int) -> bool:
+ """Resend a position command the cover did not react to at all.
+
+ After the settle time, a cover still at the start position of our
+ own move (raw positions) most likely never received the command
+ (radio/cloud command lost). That is not a manual override: resend
+ the command, at most MAX_LOST_COMMAND_RETRIES times, instead of
+ pausing the automation. Returns True when the command was resent.
+
+ Never resent when the cover moved at all since the command (a user
+ counter-order also ends at the start position), when the automation
+ is off, or when the command is no longer what the rules ask for.
+ """
+ if not self.storage.enabled:
+ return False
+ move = self._move_start.get(entity_id)
+ if move is None:
+ return False
+ if entity_id in self._runtime_set("_moved_since_command"):
+ _LOGGER.debug("[%s] Cover moved since our command: not a lost command", entity_id)
+ return False
+ _, start_pos, target = move
+ if (
+ abs(current - start_pos) > MANUAL_OVERRIDE_TOLERANCE
+ or abs(current - target) <= MANUAL_OVERRIDE_TOLERANCE
+ or self._last_positions.get(entity_id) != target
+ or self._wind_protected
+ ):
+ return False
+ # Only moves the apply cycle may still drive: AUTO/VENTING, or a
+ # paused/manual cover held by a safety rule (never LOCKED / wind).
+ status = self._cover_states.get(entity_id, CoverStatus.AUTO)
+ if status in (CoverStatus.LOCKED, CoverStatus.WIND_PROTECTED) or (
+ status not in (CoverStatus.AUTO, CoverStatus.VENTING)
+ and entity_id not in self._safety_holds
+ ):
+ return False
+ cover_raw = self.storage.get_cover_raw(entity_id)
+ if cover_raw is None or self._data_raw_target(entity_id, cover_raw, status) != target:
+ return False
+ if self._blocked_by_unknown_lock(entity_id, cover_raw, target, current):
+ return False
+ if self._lost_command_retries is None:
+ self._lost_command_retries = {}
+ retries = self._lost_command_retries.get(entity_id, 0)
+ if retries >= MAX_LOST_COMMAND_RETRIES:
+ _LOGGER.warning(
+ "[%s] Cover did not move to %d%% after %d resends (still at %d%%)",
+ entity_id, target, retries, current,
+ )
+ self._lost_command_retries.pop(entity_id, None)
+ self._move_start.pop(entity_id, None)
+ return False
+ self._lost_command_retries[entity_id] = retries + 1
+ _LOGGER.warning(
+ "[%s] Command %d%% apparently lost (cover still at %d%%), resending (%d/%d)",
+ entity_id, target, current, retries + 1, MAX_LOST_COMMAND_RETRIES,
+ )
+ now = time_mod.monotonic()
+ self._last_command_time[entity_id] = now
+ self._move_start[entity_id] = (now, start_pos, target)
+ self._pending_settle.add(entity_id)
+ self.hass.async_create_task(self._position_call(entity_id, target))
+ return True
+
+ def _blocked_by_unknown_lock(
+ self, entity_id: str, cover_raw: dict[str, Any], raw_target: int, raw_current: int
+ ) -> bool:
+ """Whether a move must wait: it closes a cover on a possibly open window.
+
+ While the lock sensor (window contact) is unavailable/unknown the
+ window may be open: a cover is never driven down below its
+ lock_position then. Upward moves stay allowed. Positions are raw
+ (HA scale); inverted covers are mirrored here.
+ """
+ blocked_logged = self._runtime_set("_move_blocked_logged")
+ if not self._is_sensor_unknown(cover_raw, "lock_sensor"):
+ blocked_logged.discard(entity_id)
+ return False
+ if cover_raw.get("inverted", False):
+ target, current = 100 - raw_target, 100 - raw_current
+ else:
+ target, current = raw_target, raw_current
+ lock_pos = self._cover_val(cover_raw, "lock_position")
+ if not isinstance(lock_pos, (int, float)) or isinstance(lock_pos, bool):
+ lock_pos = 100
+ if target < current and target < lock_pos:
+ _LOGGER.debug(
+ "[%s] Window state unknown: not lowering %d%% -> %d%% (lock position %d%%)",
+ entity_id, current, target, lock_pos,
+ )
+ # Once per blocking episode: the apply cycle re-checks every scan
+ if entity_id not in blocked_logged:
+ blocked_logged.add(entity_id)
+ sensor = cover_raw.get("lock_sensor")
+ self._log(LOG_EVENT_STATUS, entity_id,
+ f"Move to {target}% blocked: window {sensor} state unknown",
+ {"key": "move_blocked_window_unknown", "sensor": sensor,
+ "position": target})
+ return True
+ blocked_logged.discard(entity_id)
+ return False
+
+ def _travel_time(self, entity_id: str) -> float | None:
+ """Full travel time of a cover.
+
+ Order: value entered on the cover, then the measured value, then the
+ global default travel time; None when none is known (30 s settle).
+ """
+ def _positive(value: Any) -> float | None:
+ if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
+ return float(value)
+ return None
+
+ cover_raw = self.storage.get_cover_raw(entity_id)
+ if isinstance(cover_raw, dict):
+ for key in ("travel_time", "measured_travel_time"):
+ if (value := _positive(cover_raw.get(key))) is not None:
+ return value
+ return _positive(getattr(self.storage, "default_travel_time", None))
+
+ def _settle_time(self, entity_id: str) -> float:
+ """Seconds after our own command during which moves are not manual.
+
+ At least SETTLE_TIME; longer for slow covers whose full travel time
+ (configured or measured) plus a margin exceeds it.
+ """
+ travel = self._travel_time(entity_id)
+ if travel is None:
+ return SETTLE_TIME
+ return max(SETTLE_TIME, travel + TRAVEL_TIME_MARGIN)
+
+ def _measure_travel(self, entity_id: str, new_state: Any) -> None:
+ """Measure the full travel time when a commanded move reaches its target.
+
+ Only moves of at least MIN_MEASURED_DISTANCE % are used, scaled to a
+ full 0-100 % travel, and smoothed with the previous measurement. A
+ polled cover reports its arrival late (next poll): a new sample is
+ capped at MAX_TRAVEL_GROWTH x the previous value so one late poll
+ cannot inflate the settle time.
+ """
+ start = self._move_start.get(entity_id)
+ if start is None:
+ return
+ started_at, start_pos, target = start
+ elapsed = time_mod.monotonic() - started_at
+ if elapsed > MAX_MEASURED_TRAVEL:
+ self._move_start.pop(entity_id, None)
+ return
+ if new_state.state in ("opening", "closing"):
+ return
+ try:
+ position = int(new_state.attributes.get("current_position"))
+ except (ValueError, TypeError):
+ return
+ if abs(position - target) > MANUAL_OVERRIDE_TOLERANCE:
+ return
+ self._move_start.pop(entity_id, None)
+ # The command arrived: a later lost command gets fresh resends
+ if self._lost_command_retries:
+ self._lost_command_retries.pop(entity_id, None)
+ distance = abs(start_pos - target)
+ if distance < MIN_MEASURED_DISTANCE or elapsed < 1:
+ return
+ full = min(elapsed * 100 / distance, MAX_MEASURED_TRAVEL)
+ cover_raw = self.storage.get_cover_raw(entity_id)
+ previous = cover_raw.get("measured_travel_time") if isinstance(cover_raw, dict) else None
+ if isinstance(previous, (int, float)) and not isinstance(previous, bool) and previous > 0:
+ if full > previous * MAX_TRAVEL_GROWTH:
+ # Capped sample: already a bounded step up. Averaging it with
+ # the previous value again halved the growth, so a cover whose
+ # first measurement was too short needed many moves to reach
+ # its real travel time (settle time too short meanwhile).
+ full = previous * MAX_TRAVEL_GROWTH
+ else:
+ full = (previous + full) / 2
+ full = round(full, 1)
+ _LOGGER.debug("[%s] Measured travel time: %.1f s", entity_id, full)
+ self.storage.update_cover_measured_travel(entity_id, full)
+
+ def _is_progressing_to_target(
+ self, entity_id: str, old_state: Any, current_position: int
+ ) -> bool:
+ """Return True if the cover moved strictly closer to its commanded target."""
+ target = self._last_positions.get(entity_id)
+ if target is None or old_state is None:
+ return False
+ try:
+ old_position = int(old_state.attributes.get("current_position"))
+ except (ValueError, TypeError):
+ return False
+ return abs(current_position - target) < abs(old_position - target)
+
+ def pause_cover(self, cover: CoverConfig, manual: bool = True) -> None:
+ """Pause automation for a cover.
+
+ Protective statuses (wind, window lock) and disabled automation
+ (MANUAL) take precedence over a pause: overwriting them would only be
+ undone by the next sync cycle, re-sending protective commands.
+
+ manual: the pause comes from a detected manual override (the cover
+ was moved by hand). Explicit pauses (pause / pause_all services) pass
+ False: resume-on-match must not end them -- the user asked for a
+ pause, not for a one-off position.
+ """
prev = self._cover_states.get(cover.entity_id, CoverStatus.AUTO)
+ if prev in (CoverStatus.WIND_PROTECTED, CoverStatus.LOCKED, CoverStatus.MANUAL):
+ _LOGGER.debug(
+ "[%s] Pause ignored: cover is %s", cover.entity_id, prev.value
+ )
+ return
self._cover_states[cover.entity_id] = CoverStatus.PAUSED
+ self._runtime_dict("_pause_manual")[cover.entity_id] = manual
if prev != CoverStatus.PAUSED:
- self._log(LOG_EVENT_STATUS, cover.entity_id, f"{prev.value} -> paused")
+ if self._paused_at is None:
+ self._paused_at = {}
+ self._paused_at[cover.entity_id] = time_mod.monotonic()
+ self._log(LOG_EVENT_STATUS, cover.entity_id, f"{prev.value} -> paused",
+ {"key": "status_change", "from": prev.value, "to": "paused"})
duration = cover.pause_duration if cover.pause_duration is not None else self.storage.pause_duration
pause_until = dt_util.now().timestamp() + (duration * 60)
self.storage.update_cover_status(
@@ -861,32 +1834,57 @@ def pause_cover(self, cover: CoverConfig) -> None:
)
if prev != CoverStatus.PAUSED:
self._logbook(
- f"paused for {duration} min (manual override)",
+ i18n.text(self.hass, "paused", minutes=duration),
cover.entity_id,
)
if self.data is not None:
self.async_set_updated_data(self.data)
- def resume_cover(self, entity_id: str) -> None:
- """Resume automation for a cover."""
+ def resume_cover(self, entity_id: str, logbook_text: str | None = None) -> None:
+ """Resume automation for a cover.
+
+ logbook_text replaces the plain "resumed" logbook entry (automatic
+ resume, with its reason).
+ """
# Wind protection cannot be overridden manually
if self._wind_protected:
return
cover_raw = self.storage.get_cover_raw(entity_id)
if cover_raw:
- # Don't override LOCKED status if lock/vent sensor is still active
+ # Don't override LOCKED status if lock/vent sensor is still active,
+ # nor while the window state is unknown (possibly open window).
+ # A refused resume is still the user's wish: the unlock restores
+ # AUTO instead of the recorded pre-lock status (e.g. MANUAL).
if self._cover_states.get(entity_id) == CoverStatus.LOCKED:
if self._is_sensor_open(cover_raw, "lock_sensor") or self._is_sensor_open(cover_raw, "vent_sensor"):
+ self._pre_lock_states[entity_id] = CoverStatus.AUTO
+ self._persist_runtime_state()
+ return
+ if self._is_sensor_unknown(cover_raw, "lock_sensor"):
+ _LOGGER.info("[%s] Resume refused: window state unknown, cover stays locked", entity_id)
+ self._pre_lock_states[entity_id] = CoverStatus.AUTO
+ self._persist_runtime_state()
return
+ prev = self._cover_states.get(entity_id, CoverStatus.AUTO)
+ # Resuming ends any protective episode: a stale pre-lock status
+ # would otherwise be restored by a later unlock.
+ self._pre_lock_states.pop(entity_id, None)
+ self._runtime_dict("_pause_manual").pop(entity_id, None)
# Resume to VENTING if vent sensor is still open
if self._is_sensor_open(cover_raw, "vent_sensor"):
+ target = CoverStatus.VENTING
self._cover_states[entity_id] = CoverStatus.VENTING
self.storage.update_cover_status(entity_id, CoverStatus.VENTING.value, None)
- self._logbook("resumed (venting)", entity_id)
+ self._logbook(i18n.text(self.hass, "resumed_venting"), entity_id)
else:
+ target = CoverStatus.AUTO
self._cover_states[entity_id] = CoverStatus.AUTO
self.storage.update_cover_status(entity_id, CoverStatus.AUTO.value, None)
- self._logbook("resumed", entity_id)
+ self._logbook(logbook_text or i18n.text(self.hass, "resumed"), entity_id)
+ if prev != target:
+ _LOGGER.debug("[%s] Resumed: %s -> %s", entity_id, prev.value, target.value)
+ self._log(LOG_EVENT_STATUS, entity_id, f"{prev.value} -> {target.value}",
+ {"key": "status_change", "from": prev.value, "to": target.value})
# Sync expected position to current to prevent immediate re-pause
self._update_last_position_from_state(entity_id)
if self.data is not None:
@@ -897,37 +1895,53 @@ def _sync_cover_statuses(self) -> None:
Updates _cover_states and storage for all covers. Called once per
update cycle to avoid side effects in property accessors.
- Priority: WIND_PROTECTED > LOCKED > VENTING > PAUSED > AUTO > MANUAL.
+ Priority: LOCKED (window open) > WIND_PROTECTED > VENTING > PAUSED
+ > AUTO > MANUAL.
"""
- # Check wind protection first (global, highest priority)
+ # Check wind protection first (global)
self._check_wind_protection()
for entity_id in self.storage._data.get("covers", {}):
cover_raw = self.storage.get_cover_raw(entity_id)
if cover_raw is None:
continue
+ self._track_missing_lock_sensor(entity_id, cover_raw)
- # Wind protection has highest priority - skip all other checks
+ # Wind protection: above everything except an open window (lock)
if self._wind_protected:
- if self._cover_states.get(entity_id) != CoverStatus.WIND_PROTECTED:
- self._activate_wind_protection()
+ self._protect_one_from_wind(entity_id, cover_raw)
continue
+ # An unavailable lock/vent sensor is not "closed": keep the
+ # protective status unchanged this cycle (no lock, no unlock).
+ sensor_unknown = (
+ self._is_sensor_unknown(cover_raw, "lock_sensor")
+ or self._is_sensor_unknown(cover_raw, "vent_sensor")
+ )
+
# Check lock sensor state (window contact)
if self._is_sensor_open(cover_raw, "lock_sensor"):
if self._cover_states.get(entity_id) != CoverStatus.LOCKED:
lock_pos = self._cover_val(cover_raw, "lock_position")
current = self._get_current_position(entity_id)
- if current is None or current < lock_pos:
+ if self._lock_moves(cover_raw, current, lock_pos):
lock_tilt = self._cover_val(cover_raw, "lock_tilt_position")
self._lock_cover(entity_id, lock_pos, lock_tilt=lock_tilt)
else:
prev = self._cover_states.get(entity_id, CoverStatus.AUTO)
self._pre_lock_states[entity_id] = CoverStatus.AUTO if prev == CoverStatus.PAUSED else prev
- self._cover_states[entity_id] = CoverStatus.LOCKED
- self.storage.update_cover_status(entity_id, CoverStatus.LOCKED.value, None)
- self._update_last_position_from_state(entity_id)
+ self._lock_in_place(entity_id, cover_raw)
+ self._sensor_unknown_kept.discard(entity_id)
+ continue
+
+ if sensor_unknown and self._cover_states.get(entity_id) in (
+ CoverStatus.LOCKED, CoverStatus.VENTING,
+ ):
+ self._keep_status_sensor_unknown(entity_id, cover_raw)
continue
+ if entity_id in self._sensor_unknown_kept:
+ self._sensor_unknown_kept.discard(entity_id)
+ _LOGGER.debug("[%s] Lock/vent sensor available again", entity_id)
# Check vent sensor state - also above auto_enabled
if self._is_sensor_open(cover_raw, "vent_sensor"):
@@ -936,30 +1950,15 @@ def _sync_cover_statuses(self) -> None:
if current_status == CoverStatus.PAUSED:
pause_until = cover_raw.get("pause_until")
if pause_until and dt_util.now().timestamp() > pause_until:
- # Pause expired -> back to VENTING
- self._cover_states[entity_id] = CoverStatus.VENTING
- self.storage.update_cover_status(entity_id, CoverStatus.VENTING.value, None)
- self._update_last_position_from_state(entity_id)
+ # Pause expired -> back to VENTING. The cover may have
+ # been lowered manually during the pause: raise it to
+ # the vent position again (logs the status change).
+ _LOGGER.debug("[%s] Pause expired: paused -> venting", entity_id)
+ self._enter_venting(entity_id, cover_raw, "pause expired, vent open")
elif current_status != CoverStatus.VENTING:
- vent_pos = self._cover_val(cover_raw, "vent_position")
- current = self._get_current_position(entity_id)
- if current is not None and current < vent_pos:
- inverted = cover_raw.get("inverted", False)
- actual = (100 - vent_pos) if inverted else vent_pos
- self._last_positions[entity_id] = actual
- self._last_command_time[entity_id] = time_mod.monotonic()
- self._pending_settle.add(entity_id)
- self.hass.async_create_task(
- self.hass.services.async_call(
- "cover", "set_cover_position",
- {"entity_id": entity_id, "position": actual},
- blocking=False,
- )
- )
- else:
- self._update_last_position_from_state(entity_id)
- self._cover_states[entity_id] = CoverStatus.VENTING
- self.storage.update_cover_status(entity_id, CoverStatus.VENTING.value, None)
+ self._enter_venting(
+ entity_id, cover_raw, f"vent sensor {cover_raw.get('vent_sensor')} open"
+ )
continue
# If was locked/venting but sensors now closed, restore auto
@@ -972,7 +1971,11 @@ def _sync_cover_statuses(self) -> None:
# status survives a restart (which resets all statuses to AUTO)
# and the panel, which renders the persisted cover status,
# reflects the disabled automation instead of showing AUTO.
- if self._cover_states.get(entity_id) != CoverStatus.MANUAL:
+ prev = self._cover_states.get(entity_id, CoverStatus.AUTO)
+ if prev != CoverStatus.MANUAL:
+ _LOGGER.debug("[%s] Automation disabled: %s -> manual", entity_id, prev.value)
+ self._log(LOG_EVENT_STATUS, entity_id, f"{prev.value} -> manual",
+ {"key": "status_change", "from": prev.value, "to": "manual"})
self._cover_states[entity_id] = CoverStatus.MANUAL
self.storage.update_cover_status(entity_id, CoverStatus.MANUAL.value, None)
continue
@@ -982,21 +1985,51 @@ def _sync_cover_statuses(self) -> None:
if status == CoverStatus.PAUSED:
pause_until = cover_raw.get("pause_until")
if pause_until and dt_util.now().timestamp() > pause_until:
+ _LOGGER.debug("[%s] Pause expired: paused -> auto", entity_id)
+ self._log(LOG_EVENT_STATUS, entity_id, "paused -> auto",
+ {"key": "status_change", "from": "paused", "to": "auto"})
self._cover_states[entity_id] = CoverStatus.AUTO
self.storage.update_cover_status(entity_id, CoverStatus.AUTO.value, None)
# Sync expected position to prevent false override after resume
self._update_last_position_from_state(entity_id)
+ # Protective state must survive a restart (see _restore_cover_states)
+ self._persist_runtime_state()
+
+ def _keep_status_sensor_unknown(self, entity_id: str, cover_raw: dict[str, Any]) -> None:
+ """Keep LOCKED/VENTING while a lock/vent sensor is unavailable (log once)."""
+ status = self._cover_states.get(entity_id, CoverStatus.AUTO)
+ if entity_id in self._sensor_unknown_kept:
+ _LOGGER.debug("[%s] Lock/vent sensor unavailable, keeping %s", entity_id, status.value)
+ return
+ self._sensor_unknown_kept.add(entity_id)
+ sensors = ", ".join(
+ s for s in (cover_raw.get("lock_sensor"), cover_raw.get("vent_sensor")) if s
+ )
+ current = self._get_current_position(entity_id)
+ _LOGGER.debug(
+ "[%s] Sensor %s unavailable: keeping %s (kept at %s%%)",
+ entity_id, sensors, status.value, current,
+ )
+ self._log(LOG_EVENT_STATUS, entity_id, f"{status.value} kept (sensor unavailable)",
+ {"key": "sensor_unavailable", "status": status.value,
+ "sensor": sensors, "position": current})
+
def get_cover_status(self, entity_id: str) -> CoverStatus:
"""Get automation status for a cover (read-only, no side effects)."""
cover_raw = self.storage.get_cover_raw(entity_id)
if cover_raw is None:
return CoverStatus.MANUAL
+ live = self._cover_states.get(entity_id, CoverStatus.AUTO)
if not cover_raw.get("auto_enabled", True):
+ # Protective statuses also act on covers with automation off
+ # (window open -> locked, storm -> wind position): report them.
+ if live in (CoverStatus.LOCKED, CoverStatus.WIND_PROTECTED):
+ return live
return CoverStatus.MANUAL
- return self._cover_states.get(entity_id, CoverStatus.AUTO)
+ return live
def get_active_rules(self) -> dict[str, list[str]]:
"""Get currently active rules and their matched covers.
@@ -1044,6 +2077,41 @@ def _live_cover_entry(self, entity_id: str, cover_data: dict[str, Any]) -> dict[
"last_change": cover_raw.get("last_position_change") if cover_raw else None,
}
+ # ---- Public read-only accessors for entities / dashboard card ----
+
+ @property
+ def wind_protected(self) -> bool:
+ """Whether wind protection is currently active."""
+ return self._wind_protected
+
+ def get_wind_speed(self) -> float | None:
+ """Current wind speed from the configured sensor (None if unknown)."""
+ return self._get_wind_speed()
+
+ def get_logical_position(self, entity_id: str) -> int | None:
+ """Current position on the rules' scale (inverted covers mirrored)."""
+ return self._get_current_position(entity_id)
+
+ def get_cover_live(self, entity_id: str) -> dict[str, Any]:
+ """Live runtime data of one cover (rule, target, pause, comfort)."""
+ cover_data = (self.data or {}).get("covers", {}).get(entity_id, {})
+ cover_raw = self.storage.get_cover_raw(entity_id) or {}
+ rule_id = cover_data.get("matching_rule_id")
+ rule_name = None
+ if rule_id:
+ rule = self.storage.rules.get(rule_id)
+ rule_name = rule.name if rule else rule_id
+ comfort = self.engine._last_comfort_mode.get(entity_id)
+ return {
+ "target_position": cover_data.get("target_position"),
+ "rule_id": rule_id,
+ "rule_name": rule_name,
+ "safety": bool(cover_data.get("safety", False)),
+ "pause_until": cover_raw.get("pause_until"),
+ "comfort_mode": comfort.value if comfort else None,
+ "last_change": cover_raw.get("last_position_change"),
+ }
+
def get_live_facade_data(self) -> dict[str, dict[str, Any]]:
"""Get live runtime data for facades (sun on facade)."""
if self.data:
@@ -1064,7 +2132,7 @@ def _logbook(self, message: str, entity_id: str | None = None) -> None:
return
async_log_entry(
self.hass,
- "Cover Automatic",
+ i18n.text(self.hass, "logbook_name"),
message,
domain=DOMAIN,
entity_id=entity_id,
@@ -1072,6 +2140,14 @@ def _logbook(self, message: str, entity_id: str | None = None) -> None:
async def _async_update_data(self) -> dict[str, Any]:
"""Update data and evaluate rules."""
+ # Compile native Home Assistant conditions (async) so the rule
+ # evaluation below can run them synchronously; track new entities.
+ try:
+ if await self.engine.async_prepare_ha_conditions():
+ self._setup_state_tracking()
+ except Exception: # noqa: BLE001 -- never block the update cycle
+ _LOGGER.exception("Failed to prepare Home Assistant conditions")
+
# Sync cover statuses from sensors before evaluation
self._sync_cover_statuses()
@@ -1088,20 +2164,32 @@ async def _async_update_data(self) -> dict[str, Any]:
"sun_on_facade": is_sun_on_facade(self.hass, facade),
}
+ # Safety takeovers/releases only act once commands may be sent
+ in_grace = (time_mod.monotonic() - self._startup_time) < STARTUP_GRACE_PERIOD
+
for entity_id, cover in self.storage.covers.items():
status = self.get_cover_status(entity_id)
target_position: int | None = None
target_tilt_position: int | None = None
matching_rule_id: str | None = None
- if status in (CoverStatus.AUTO, CoverStatus.VENTING) and self.storage.enabled:
- engine_result = self.engine.evaluate_cover(cover)
- if engine_result is not None:
- target_position = engine_result.position
- target_tilt_position = engine_result.tilt_position
- matching_rule_id = engine_result.rule_id
- if matching_rule_id:
- active_rules.setdefault(matching_rule_id, []).append(entity_id)
+ if not in_grace and status == CoverStatus.PAUSED and self._resume_on_match(cover):
+ status = self.get_cover_status(entity_id)
+ engine_result = self._evaluate_cover_rules(cover, status)
+ safety = _is_safety(engine_result)
+ if not in_grace and self._update_safety_hold(
+ entity_id, engine_result if safety else None, status
+ ):
+ # Released a paused cover: evaluate it as AUTO right away
+ status = self.get_cover_status(entity_id)
+ engine_result = self._evaluate_cover_rules(cover, status)
+ safety = _is_safety(engine_result)
+ if engine_result is not None:
+ target_position = engine_result.position
+ target_tilt_position = engine_result.tilt_position
+ matching_rule_id = engine_result.rule_id
+ if matching_rule_id:
+ active_rules.setdefault(matching_rule_id, []).append(entity_id)
# Always refresh comfort mode for live display (independent of rules)
self.engine._get_comfort_mode(cover)
@@ -1115,7 +2203,8 @@ async def _async_update_data(self) -> dict[str, Any]:
rn = rule_name.name if rule_name else matching_rule_id
self._log(
LOG_EVENT_RULE, entity_id, f"{rn} -> {target_position}%",
- {"rule_id": matching_rule_id, "position": target_position},
+ {"key": "rule", "rule_id": matching_rule_id, "rule_name": rn,
+ "position": target_position},
)
result["covers"][entity_id] = {
@@ -1124,6 +2213,7 @@ async def _async_update_data(self) -> dict[str, Any]:
"target_tilt_position": target_tilt_position,
"facade_id": cover.facade_id,
"matching_rule_id": matching_rule_id,
+ "safety": safety,
}
result["active_rules"] = active_rules
@@ -1162,8 +2252,197 @@ async def _async_update_data(self) -> dict[str, Any]:
return result
+ def _resume_on_match(self, cover: CoverConfig) -> bool:
+ """End a manual pause when the cover is back at its rule's position.
+
+ Two cases: the cover was put back by hand where the rule wants it, or
+ the winning rule changed and now asks for the position the cover was
+ left at. Only a PAUSED cover (never MANUAL, i.e. automation switched
+ off, nor a protective status). Conditions: option enabled (cover or
+ Settings), grace period after the pause started, cover still (not
+ moving, no position report for SETTLE_TIME), a rule gives a target
+ and the position (and the rule's tilt, when set) match it within the
+ minimum position change. Returns True when the cover was resumed.
+ """
+ entity_id = cover.entity_id
+ if self._cover_states.get(entity_id) != CoverStatus.PAUSED or not self.storage.enabled:
+ return False
+ enabled = cover.pause_resume_on_match
+ if enabled is None:
+ enabled = self.storage.pause_resume_on_match
+ if not enabled:
+ return False
+ # Only pauses from a manual override; an explicit pause (service)
+ # lasts its duration. Origin unknown (restored after a restart):
+ # treated as manual, the previous behaviour.
+ if not self._runtime_dict("_pause_manual").get(entity_id, True):
+ return False
+ now_mono = time_mod.monotonic()
+ paused_at = (self._paused_at or {}).get(entity_id)
+ if paused_at is not None and now_mono - paused_at < RESUME_MATCH_GRACE:
+ return False
+ state = self.hass.states.get(entity_id)
+ if state is None or state.state in (*_UNUSABLE_STATES, "opening", "closing"):
+ return False
+ # Stillness from the last real position/motion change: last_updated
+ # also moves on attribute-only reports (Zigbee linkquality), so a
+ # still cover never looked still. Unknown (no change seen since
+ # startup): fall back to the state's last_changed.
+ changed_at = self._runtime_dict("_position_changed_at").get(entity_id)
+ if changed_at is not None:
+ if now_mono - changed_at < SETTLE_TIME:
+ return False
+ else:
+ last_changed = getattr(state, "last_changed", None)
+ if isinstance(last_changed, datetime) and (
+ (dt_util.utcnow() - last_changed).total_seconds() < SETTLE_TIME
+ ):
+ return False
+ current = self._raw_position(state)
+ if current is None:
+ return False
+ result = self.engine.evaluate_cover(cover)
+ if result is None or result.position is None:
+ return False
+ cover_raw = self.storage.get_cover_raw(entity_id) or {}
+ target = int(result.position)
+ if self._is_sensor_open(cover_raw, "vent_sensor"):
+ target = max(target, int(self._cover_val(cover_raw, "vent_position")))
+ if cover_raw.get("inverted", False):
+ target = 100 - target
+ target = self._effective_position(entity_id, target)
+ tolerance = self._cover_val(cover_raw, "min_position_change")
+ if not isinstance(tolerance, (int, float)) or isinstance(tolerance, bool):
+ tolerance = MANUAL_OVERRIDE_TOLERANCE
+ if abs(current - target) > tolerance:
+ return False
+ tilt_target = getattr(result, "tilt_position", None)
+ if tilt_target is not None and cover_raw.get("supports_tilt", False):
+ tilt_now = state.attributes.get("current_tilt_position")
+ if tilt_now is not None:
+ try:
+ tilt_now = int(tilt_now)
+ except (TypeError, ValueError):
+ tilt_now = None
+ if tilt_now is not None:
+ wanted = 100 - int(tilt_target) if cover_raw.get("inverted_tilt", False) else int(tilt_target)
+ if abs(tilt_now - wanted) > tolerance:
+ return False
+ rule = self.storage.rules.get(result.rule_id) if result.rule_id else None
+ rule_name = rule.name if rule else (result.rule_id or "-")
+ _LOGGER.info(
+ "[%s] Pause ended: position %d%% matches rule %s", entity_id, current, rule_name,
+ )
+ self.resume_cover(
+ entity_id, logbook_text=i18n.text(self.hass, "resumed_match", rule=rule_name),
+ )
+ if self._cover_states.get(entity_id) == CoverStatus.PAUSED:
+ return False # resume refused (wind protection)
+ (self._paused_at or {}).pop(entity_id, None)
+ return True
+
+ def _evaluate_cover_rules(self, cover: CoverConfig, status: CoverStatus) -> Any:
+ """Evaluate the rules that may drive a cover in its current status.
+
+ AUTO/VENTING covers with the automation enabled use all rules. Covers
+ that are paused, manual, wind protected, or with the global automation
+ disabled only follow safety rules. LOCKED covers follow none.
+ """
+ if status in (CoverStatus.AUTO, CoverStatus.VENTING) and self.storage.enabled:
+ return self.engine.evaluate_cover(cover)
+ if status == CoverStatus.LOCKED:
+ return None
+ result = self.engine.evaluate_cover(cover, safety_only=True)
+ return result if _is_safety(result) else None
+
+ def _update_safety_hold(
+ self, entity_id: str, target: Any, status: CoverStatus
+ ) -> bool:
+ """Track which covers a safety rule drives; handle takeover/release.
+
+ On release a paused cover returns to AUTO, a wind protected one goes
+ back to the wind position (wind still active), manual covers and a
+ disabled automation get no further command.
+ Returns True when the cover status changed (pause cancelled).
+ """
+ new_rule = target.rule_id if target is not None else None
+ prev_rule = self._safety_holds.get(entity_id)
+ if new_rule == prev_rule:
+ return False
+ if new_rule:
+ self._safety_holds[entity_id] = new_rule
+ if prev_rule is not None:
+ _LOGGER.debug("[%s] Safety rule %s -> %s", entity_id, prev_rule, new_rule)
+ return False
+ rule_name = target.rule_name or new_rule
+ status_val = status.value if self.storage.enabled else f"{status.value}, automation off"
+ _LOGGER.info(
+ "[%s] Safety rule '%s' takes over (%s) -> %d%%",
+ entity_id, rule_name, status_val, target.position,
+ )
+ self._log(LOG_EVENT_RULE, entity_id,
+ f"Safety rule {rule_name} takes over ({status_val}) -> {target.position}%",
+ {"key": "safety_takeover", "rule_id": new_rule, "rule_name": rule_name,
+ "status": status.value, "position": target.position})
+ return False
+
+ # Released
+ self._safety_holds.pop(entity_id, None)
+ rule = self.storage.rules.get(prev_rule)
+ rule_name = rule.name if rule else prev_rule
+ live = self._cover_states.get(entity_id, CoverStatus.AUTO)
+ changed = False
+ if status == CoverStatus.PAUSED and live == CoverStatus.PAUSED:
+ # The pause is cancelled: the cover returns to normal automation
+ self._cover_states[entity_id] = CoverStatus.AUTO
+ self.storage.update_cover_status(entity_id, CoverStatus.AUTO.value, None)
+ self._update_last_position_from_state(entity_id)
+ self._log(LOG_EVENT_STATUS, entity_id, "paused -> auto",
+ {"key": "status_change", "from": "paused", "to": "auto"})
+ released_to = "paused -> auto"
+ changed = True
+ elif live == CoverStatus.WIND_PROTECTED and self._wind_protected:
+ self._send_wind_position(entity_id)
+ released_to = f"wind protection -> {self._wind_position()}%"
+ else:
+ released_to = status.value if self.storage.enabled else "automation off"
+ _LOGGER.info("[%s] Safety rule '%s' released (%s)", entity_id, rule_name, released_to)
+ self._log(LOG_EVENT_RULE, entity_id, f"Safety rule {rule_name} released ({released_to})",
+ {"key": "safety_release", "rule_id": prev_rule, "rule_name": rule_name,
+ "status": status.value})
+ return changed
+
+ def rename_rule_references(self, old_id: str, new_id: str) -> None:
+ """Follow a rule id rename in runtime state (no spurious rule change)."""
+ for mapping in (self._last_move_rule, self._last_matching_rules, self._safety_holds):
+ for key, value in mapping.items():
+ if value == old_id:
+ mapping[key] = new_id
+ if not isinstance(self.data, dict):
+ return
+ for cover_data in (self.data.get("covers") or {}).values():
+ if isinstance(cover_data, dict) and cover_data.get("matching_rule_id") == old_id:
+ cover_data["matching_rule_id"] = new_id
+ active = self.data.get("active_rules")
+ if isinstance(active, dict) and old_id in active:
+ self.data["active_rules"] = {
+ (new_id if key == old_id else key): value for key, value in active.items()
+ }
+
async def async_apply_positions(self) -> None:
- """Apply calculated positions to covers with hysteresis."""
+ """Apply calculated positions to covers with hysteresis.
+
+ Serialised: an apply cycle sleeping in the command stagger must not
+ interleave with the next one (a stale target could be sent after a
+ newer one, or both cycles command the same cover).
+ """
+ if self._apply_lock is None:
+ self._apply_lock = asyncio.Lock()
+ async with self._apply_lock:
+ await self._async_apply_positions_locked()
+
+ async def _async_apply_positions_locked(self) -> None:
+ """Apply calculated positions (caller holds the apply lock)."""
if not self.data:
return
@@ -1174,12 +2453,16 @@ async def async_apply_positions(self) -> None:
for entity_id, cover_data in self.data.get("covers", {}).items():
# Use live status from _cover_states instead of snapshot
status = self._cover_states.get(entity_id, CoverStatus.AUTO)
- if status not in (CoverStatus.AUTO, CoverStatus.VENTING):
+ # A safety rule also drives paused/manual/wind covers (never LOCKED)
+ safety = cover_data.get("safety") is True and status != CoverStatus.LOCKED
+ if status not in (CoverStatus.AUTO, CoverStatus.VENTING) and not safety:
# A new protective/paused state supersedes a pending
# protective-exit bypass -- drop it so it cannot skip the
# time hysteresis on an unrelated move later.
self._post_protective_exit.discard(entity_id)
self._hysteresis_info[entity_id] = None
+ if cover_data.get("target_position") is not None:
+ _LOGGER.debug("[%s] No command: status %s", entity_id, status.value)
continue
target = cover_data.get("target_position")
@@ -1204,15 +2487,48 @@ async def async_apply_positions(self) -> None:
if state.state in ("opening", "closing"):
continue
- try:
- current = int(state.attributes.get("current_position", 0))
- except (ValueError, TypeError):
+ current = self._raw_position(state)
+ if current is None:
continue
+ # Enforce vent minimum position (logical, before inversion)
+ original_target = target
+ if status == CoverStatus.VENTING:
+ vent_min = self._cover_val(cover_raw, "vent_position")
+ if target < vent_min:
+ target = vent_min
+ _LOGGER.debug("[%s] VENTING: clamped %d%% -> %d%% (vent min)", entity_id, original_target, target)
+
+ # Handle inverted covers (100% = closed)
+ if cover_raw.get("inverted", False):
+ target = 100 - target
+ # Open/close-only covers reach 0 or 100 only: compare with that,
+ # otherwise an intermediate target is re-commanded every cycle.
+ target = self._effective_position(entity_id, target)
+
+ # Our own target changed (rule / scenario / rule disabled / rule
+ # edited) while the cover is still travelling to the position this
+ # cycle commanded before. The stale position it reports is not a
+ # manual override: send the new target right away instead of
+ # waiting for the settle time and misreading the travel.
+ expected_before = self._last_positions.get(entity_id)
+ own_target_change = (
+ entity_id in self._pending_settle
+ and expected_before is not None
+ and self._last_sent_target.get(entity_id) == expected_before
+ and target != expected_before
+ )
+ if own_target_change:
+ _LOGGER.info(
+ "[%s] Target changed during travel (%d%% -> %d%%), re-commanding",
+ entity_id, expected_before, target,
+ )
+ self._pending_settle.discard(entity_id)
+
# Skip covers still settling after our commands (vent/lock moves, rule commands)
last_cmd_t = self._last_command_time.get(entity_id, 0)
if entity_id in self._pending_settle:
- if (time_mod.monotonic() - last_cmd_t) < SETTLE_TIME:
+ if (time_mod.monotonic() - last_cmd_t) < self._settle_time(entity_id):
continue
# Settle time passed: check if position deviated significantly (manual override)
self._pending_settle.discard(entity_id)
@@ -1223,6 +2539,9 @@ async def async_apply_positions(self) -> None:
"[%s] Post-settle: large deviation %d%% vs expected %d%%",
entity_id, current, expected_target,
)
+ # Not moved at all: lost command, resent (not a manual move)
+ if self._resend_if_lost(entity_id, current):
+ continue
# Don't sync -- let override check below detect the manual move
else:
self._last_positions[entity_id] = current
@@ -1232,9 +2551,12 @@ async def async_apply_positions(self) -> None:
# Detect manual override missed by state-change handler (e.g. during settle time)
expected = self._last_positions.get(entity_id)
if (
- expected is not None
+ not own_target_change
+ and not safety
+ and expected is not None
and abs(current - expected) > MANUAL_OVERRIDE_TOLERANCE
- and (time_mod.monotonic() - self._last_command_time.get(entity_id, 0)) >= SETTLE_TIME
+ and (time_mod.monotonic() - self._last_command_time.get(entity_id, 0))
+ >= self._settle_time(entity_id)
and entity_id not in self._pending_settle
and status in (CoverStatus.AUTO, CoverStatus.VENTING)
):
@@ -1247,22 +2569,27 @@ async def async_apply_positions(self) -> None:
self.pause_cover(cover)
continue
- # Enforce vent minimum position (logical, before inversion)
- original_target = target
- if status == CoverStatus.VENTING:
- vent_min = self._cover_val(cover_raw, "vent_position")
- if target < vent_min:
- target = vent_min
- _LOGGER.debug("[%s] VENTING: clamped %d%% -> %d%% (vent min)", entity_id, original_target, target)
-
- # Handle inverted covers (100% = closed)
- if cover_raw.get("inverted", False):
- target = 100 - target
+ # Window state unknown (lock sensor unavailable, e.g. after a
+ # restart): never lower the cover below its lock position.
+ if self._blocked_by_unknown_lock(entity_id, cover_raw, target, current):
+ self._hysteresis_info[entity_id] = None
+ continue
# Check hysteresis: minimum position change
+ # Fully open/closed targets are honoured once even below
+ # min_change: otherwise a cover resting a few percent short of an
+ # end stop (e.g. 3% vs 0%) would never reach it. Once that end
+ # stop has been commanded, normal hysteresis applies again so a
+ # motor that settles at 1% is not re-commanded forever.
min_change = self._cover_val(cover_raw, "min_position_change")
position_diff = abs(current - target)
- if position_diff < min_change and position_diff > 0:
+ endpoint_pending = (
+ target in (0, 100)
+ and self._last_sent_target.get(entity_id) != target
+ )
+ # A safety rule's new target is sent once regardless of min_change
+ safety_pending = safety and self._last_sent_target.get(entity_id) != target
+ if 0 < position_diff < min_change and not endpoint_pending and not safety_pending:
_LOGGER.debug(
"Skipping %s: position change %d < min %d",
entity_id, position_diff, min_change
@@ -1270,6 +2597,9 @@ async def async_apply_positions(self) -> None:
self._hysteresis_info[entity_id] = "position"
self._last_positions[entity_id] = current
self._sync_tilt_from_state(entity_id, state)
+ # The position is close enough, but a tilt-only change of the
+ # rule must still reach the slats.
+ self._apply_rule_tilt(entity_id, cover_raw, cover_data, position_changed=False)
continue
# Check hysteresis: minimum time between changes.
@@ -1291,6 +2621,8 @@ async def async_apply_positions(self) -> None:
and (now - last_change) < min_time
and not rule_changed
and not status_just_reset
+ and not own_target_change
+ and not safety
):
_LOGGER.debug(
"Skipping %s: only %ds since last change (min %ds)",
@@ -1299,46 +2631,58 @@ async def async_apply_positions(self) -> None:
self._hysteresis_info[entity_id] = "time"
self._last_positions[entity_id] = current
self._sync_tilt_from_state(entity_id, state)
+ self._apply_rule_tilt(entity_id, cover_raw, cover_data, position_changed=False)
continue
# Past the time-hysteresis check: consume the one-shot bypass.
self._post_protective_exit.discard(entity_id)
- # Determine target tilt (if applicable)
- target_tilt = cover_data.get("target_tilt_position")
- supports_tilt = cover_raw.get("supports_tilt", False) and self._supports_tilt(entity_id)
- actual_tilt: int | None = None
- if target_tilt is not None and supports_tilt:
- actual_tilt = target_tilt
- if cover_raw.get("inverted_tilt", False):
- actual_tilt = 100 - target_tilt
-
position_changed = current != target
if position_changed:
if command_sent and stagger > 0:
await asyncio.sleep(stagger)
+ # The status may have changed during the stagger (manual
+ # pause, window opened, wind): don't send a stale command.
+ if (
+ (not safety and (self._wind_protected or not self.storage.enabled))
+ or self._cover_states.get(entity_id, CoverStatus.AUTO) != status
+ ):
+ _LOGGER.debug(
+ "[%s] Status changed during stagger, skipping command", entity_id
+ )
+ continue
_LOGGER.info("[%s] Moving %d%% -> %d%%", entity_id, current, target)
self._hysteresis_info[entity_id] = None
self._last_positions[entity_id] = target
+ self._last_sent_target[entity_id] = target
self._last_command_time[entity_id] = time_mod.monotonic()
+ self._move_start[entity_id] = (time_mod.monotonic(), current, target)
+ self._runtime_set("_moved_since_command").discard(entity_id)
self._last_move_rule[entity_id] = cover_data.get("matching_rule_id")
self._pending_settle.add(entity_id)
+ # A new command: lost-command resends start from zero
+ if self._lost_command_retries:
+ self._lost_command_retries.pop(entity_id, None)
self._log(
LOG_EVENT_POSITION, entity_id, f"{current}% -> {target}%",
- {"from": current, "to": target, "rule_id": cover_data.get("matching_rule_id")},
+ {"key": "position", "from": current, "to": target,
+ "rule_id": cover_data.get("matching_rule_id")},
)
rule_id = cover_data.get("matching_rule_id")
rule_obj = self.storage.rules.get(rule_id) if rule_id else None
- rule_suffix = f" (rule: {rule_obj.name})" if rule_obj else ""
+ if rule_obj:
+ logbook_msg = i18n.text(
+ self.hass, "moved_rule",
+ from_pos=current, to_pos=target, rule=rule_obj.name,
+ )
+ else:
+ logbook_msg = i18n.text(
+ self.hass, "moved", from_pos=current, to_pos=target
+ )
self._logbook(
- f"moved {current}% -> {target}%{rule_suffix}",
+ logbook_msg,
entity_id,
)
- await self.hass.services.async_call(
- "cover",
- "set_cover_position",
- {"entity_id": entity_id, "position": target},
- blocking=False,
- )
+ await self._async_send_position(entity_id, target)
self.storage.update_cover_last_change(entity_id, now)
command_sent = True
else:
@@ -1346,15 +2690,36 @@ async def async_apply_positions(self) -> None:
self._last_positions[entity_id] = current
self._sync_tilt_from_state(entity_id, state)
- # Send tilt if changed (after position with delay, or immediately)
- if actual_tilt is not None:
- last_tilt = self._last_tilt_positions.get(entity_id)
- if last_tilt is None or abs(actual_tilt - last_tilt) > MANUAL_OVERRIDE_TOLERANCE:
- self._last_tilt_positions[entity_id] = actual_tilt
- tilt_delay = TILT_COMMAND_DELAY if position_changed else 0
- if not position_changed:
- self._last_command_time[entity_id] = time_mod.monotonic()
- self._schedule_tilt(entity_id, actual_tilt, tilt_delay)
+ self._apply_rule_tilt(entity_id, cover_raw, cover_data, position_changed)
+
+ def _apply_rule_tilt(
+ self,
+ entity_id: str,
+ cover_raw: dict[str, Any],
+ cover_data: dict[str, Any],
+ position_changed: bool,
+ ) -> None:
+ """Send the rule's tilt when it differs (after a move with a delay)."""
+ target_tilt = cover_data.get("target_tilt_position")
+ if target_tilt is None:
+ return
+ if not (cover_raw.get("supports_tilt", False) and self._supports_tilt(entity_id)):
+ return
+ actual_tilt = 100 - target_tilt if cover_raw.get("inverted_tilt", False) else target_tilt
+ last_tilt = self._last_tilt_positions.get(entity_id)
+ # Always re-send after a position move: most venetian blind
+ # actuators reset the slat angle while travelling, so an
+ # unchanged target tilt would otherwise be lost.
+ if (
+ position_changed
+ or last_tilt is None
+ or abs(actual_tilt - last_tilt) > MANUAL_OVERRIDE_TOLERANCE
+ ):
+ self._last_tilt_positions[entity_id] = actual_tilt
+ tilt_delay = TILT_COMMAND_DELAY if position_changed else 0
+ if not position_changed:
+ self._last_command_time[entity_id] = time_mod.monotonic()
+ self._schedule_tilt(entity_id, actual_tilt, tilt_delay)
def _supports_tilt(self, entity_id: str) -> bool:
"""Check if a cover entity supports tilt via HA features."""
@@ -1396,17 +2761,67 @@ async def _send_tilt_delayed(
if self._tilt_tasks.get(entity_id) is asyncio.current_task():
del self._tilt_tasks[entity_id]
+ @callback
+ def async_sync_entities(self) -> None:
+ """Sync HA entities with the configured covers/facades.
+
+ Removes registry entries of deleted covers/facades and notifies the
+ platforms (coordinator listeners) so they add entities for new ones.
+ """
+ entry = getattr(self, "config_entry", None)
+ if entry is not None:
+ from .entities import async_cleanup_orphan_entities
+
+ async_cleanup_orphan_entities(self.hass, entry, self.storage)
+ self.async_update_listeners()
+
def set_cover_manual(self, entity_id: str) -> None:
"""Set a cover's status to MANUAL (public API for platforms)."""
+ prev = self._cover_states.get(entity_id, CoverStatus.AUTO)
+ if prev in (CoverStatus.LOCKED, CoverStatus.VENTING) or (
+ self._wind_protected and prev == CoverStatus.WIND_PROTECTED
+ ):
+ # Protections keep acting on a disabled cover: the status stays
+ # (no re-broadcast of the wind position to every cover, no lost
+ # window lock). Overwriting LOCKED/VENTING would also make the
+ # next sync lock/vent the cover again. After the protection ends
+ # the sync sets MANUAL from auto_enabled=False.
+ _LOGGER.debug("[%s] Automation disabled while %s: status kept", entity_id, prev.value)
+ return
+ if prev != CoverStatus.MANUAL:
+ _LOGGER.debug("[%s] Automation disabled: %s -> manual", entity_id, prev.value)
+ self._log(LOG_EVENT_STATUS, entity_id, f"{prev.value} -> manual",
+ {"key": "status_change", "from": prev.value, "to": "manual"})
self._cover_states[entity_id] = CoverStatus.MANUAL
self.storage.update_cover_status(entity_id, CoverStatus.MANUAL.value, None)
+ def reconcile_after_import(self) -> None:
+ """Align the cover statuses with auto_enabled after an import.
+
+ An import can switch a cover's automation on or off without going
+ through the switch entity: a re-enabled cover would stay MANUAL (and
+ never be driven again), a disabled one would stay AUTO until the next
+ sync. Protective statuses are kept by resume_cover/set_cover_manual.
+ """
+ for entity_id, cover_raw in list(self.storage._data.get("covers", {}).items()):
+ if not isinstance(cover_raw, dict):
+ continue
+ if cover_raw.get("auto_enabled", True):
+ if self._cover_states.get(entity_id, CoverStatus.AUTO) == CoverStatus.MANUAL:
+ self.resume_cover(entity_id)
+ else:
+ self.set_cover_manual(entity_id)
+
async def async_shutdown(self) -> None:
"""Shut down coordinator."""
if self._unsub_update_listener:
self._unsub_update_listener()
self._unsub_update_listener = None
+ # Pending staggered wind commands must not run after unload
+ self._cancel_wind_tasks()
+ self._persist_runtime_state()
+
# Cancel pending tilt tasks
for task in self._tilt_tasks.values():
if not task.done():
@@ -1426,3 +2841,7 @@ async def async_shutdown(self) -> None:
await self.log_storage.async_save()
except Exception:
_LOGGER.warning("Failed to flush pending save during shutdown")
+
+ # Base class: cancel the scheduled refresh and shut the debouncer
+ # down, so no refresh (and no cover command) can run after unload.
+ await super().async_shutdown()
diff --git a/custom_components/cover_automatic/engine.py b/custom_components/cover_automatic/engine.py
index 943c945..c74d0e9 100644
--- a/custom_components/cover_automatic/engine.py
+++ b/custom_components/cover_automatic/engine.py
@@ -6,10 +6,22 @@
from time import monotonic
from typing import TYPE_CHECKING, Any
+from homeassistant.const import SUN_EVENT_SUNSET
from homeassistant.util import dt as dt_util
+from . import ha_condition as hac
from .models import ComfortMode, Condition, ConditionType, CoverConfig, CoverTarget, Rule
-from .sun import get_sun_position, get_sunrise_time, get_sunset_time, is_sun_on_facade
+from .storage import resolve_active_scenario
+from .sun import (
+ SUN_EVENT_DUSK,
+ get_dawn_time,
+ get_dusk_time,
+ get_sun_event_time,
+ get_sun_position,
+ get_sunrise_time,
+ get_sunset_time,
+ is_sun_on_facade,
+)
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
@@ -23,20 +35,45 @@
# outages from dropping the shading rule and triggering a fallback movement.
COMFORT_SENSOR_GRACE_PERIOD = 900 # seconds
+# Minimum delay before a failed ha_condition compilation is retried (a
+# transient failure at startup must recover, a truly invalid one must not be
+# recompiled on every refresh).
+HA_CONDITION_RETRY_INTERVAL = 60 # seconds
+
+# Age beyond which a remembered hysteresis state (threshold deadband, previous
+# comfort mode) is no longer trusted: about 10x the default update interval
+# (60 s). A condition that was not evaluated for that long (e.g. its rule was
+# skipped, or sun_on_facade short-circuited before the comfort check) starts
+# again from the hard boundary instead of a state from hours ago.
+HYSTERESIS_STATE_MAX_AGE = 600 # seconds
+
+_UNAVAILABLE_STATES = ("unavailable", "unknown")
+
_WEATHER_MAP: dict[str, set[str]] = {
"sunny": {"sunny", "clear", "clear-night"},
"clear": {"clear", "clear-night"},
"cloudy": {"cloudy", "fog", "hazy", "overcast", "partlycloudy", "partly-cloudy"},
"rainy": {"rainy", "pouring", "lightning", "lightning-rainy", "hail"},
"snowy": {"snowy", "snowy-rainy"},
- "windy": {"windy", "exceptional"},
+ "windy": {"windy", "windy-variant", "exceptional"},
}
# Condition types whose result depends on a specific cover/facade context
# (facade azimuth, per-cover comfort range/sensor). They cannot be previewed
# in the rule editor without one and report evaluable=False.
_CONTEXT_DEPENDENT_TYPES: frozenset = frozenset(
- {ConditionType.SUN_ON_FACADE, ConditionType.TEMPERATURE_COMFORT}
+ {
+ ConditionType.SUN_ON_FACADE,
+ ConditionType.TEMPERATURE_COMFORT,
+ ConditionType.OUTDOOR_VS_INDOOR,
+ ConditionType.ROOM_OCCUPIED,
+ }
+)
+
+# States meaning "occupied" when a cover sets no explicit list (compared
+# case-insensitively): binary sensors, person/device trackers, input_select.
+_DEFAULT_OCCUPIED_STATES = frozenset(
+ {"on", "home", "present", "présent", "occupied", "occupé", "detected", "true", "1"}
)
@@ -53,20 +90,75 @@ def __init__(self, hass: HomeAssistant, storage: CoverAutomaticStorage) -> None:
# (sensor_id, threshold, above, hysteresis) so it survives rule edits
# and is shared only by genuinely identical checks.
self._threshold_states: dict[tuple[str, float, bool, float], bool] = {}
-
- def evaluate_cover(self, cover: CoverConfig) -> CoverTarget | None:
+ # monotonic() of the last update of each _threshold_states entry: an
+ # entry older than HYSTERESIS_STATE_MAX_AGE is ignored, then purged.
+ self._threshold_stamps: dict[tuple[str, float, bool, float], float] = {}
+ self._threshold_purged_at = monotonic()
+ # Compiled native HA conditions, keyed by hac.config_key(config)
+ self._ha_compiled: dict[str, hac.CompiledCondition] = {}
+ # monotonic() of the last failed compilation per key (for retries)
+ self._ha_failed_at: dict[str, float] = {}
+ # Covers already warned about an invalid comfort range override
+ self._comfort_range_warned: set[str] = set()
+ # Threshold entities currently unreadable (warned once per outage)
+ self._threshold_entity_failed: set[str] = set()
+ # Condition errors already logged with a traceback (once each)
+ self._condition_errors_logged: set[str] = set()
+
+ def _log_condition_error(self, condition: Condition, err: Exception) -> None:
+ """Log a failing condition once with its traceback, then at debug level.
+
+ The same error would otherwise fill the log every cycle for every cover.
+ """
+ key = f"{condition.type}:{type(err).__name__}:{err}"
+ if key in self._condition_errors_logged:
+ _LOGGER.debug("Error evaluating condition %s: %s", condition.type, err)
+ return
+ if len(self._condition_errors_logged) > 200:
+ self._condition_errors_logged.clear()
+ self._condition_errors_logged.add(key)
+ _LOGGER.error("Error evaluating condition %s: %s", condition.type, err, exc_info=True)
+
+ def forget_covers_except(self, keep: set[str]) -> None:
+ """Drop per-cover caches of covers that no longer exist."""
+ for cache in (self._last_comfort_mode, self._last_comfort_read):
+ for entity_id in set(cache) - keep:
+ cache.pop(entity_id, None)
+ self._comfort_range_warned &= keep
+
+ def _forget_comfort(self, entity_id: str) -> None:
+ """No usable comfort mode any more: stop reporting a stale one."""
+ self._last_comfort_mode.pop(entity_id, None)
+ self._last_comfort_read.pop(entity_id, None)
+
+ def evaluate_cover(
+ self, cover: CoverConfig, *, safety_only: bool = False
+ ) -> CoverTarget | None:
"""Evaluate rules for a cover and return target position/tilt.
+ safety_only=True considers safety rules only (covers that are paused,
+ manual, wind protected or with the automation disabled).
+
+ Among matching rules the highest priority wins, ties broken by rule
+ id. The safety flag never changes that order: it only lets a rule act
+ while the cover is paused/manual/wind protected.
+
Returns:
CoverTarget with position (and optional tilt) or None if no rule matches.
"""
- active_scenario = self.storage.active_scenario
+ # Unknown/empty scenario resolves like the select entity (first one)
+ active_scenario = resolve_active_scenario(
+ self.storage.active_scenario, self.storage.scenarios
+ )
matching_rules: list[tuple[int, str, Rule]] = []
for rule in self.storage.rules.values():
if not rule.enabled:
continue
+ if safety_only and not rule.safety:
+ continue
+
if not self._rule_applies_to_cover(rule, cover):
continue
@@ -77,23 +169,117 @@ def evaluate_cover(self, cover: CoverConfig) -> CoverTarget | None:
matching_rules.append((rule.priority, rule.id, rule))
if not matching_rules:
- _LOGGER.debug("[%s] No rule matched", cover.entity_id)
+ _LOGGER.debug(
+ "[%s] No %srule matched", cover.entity_id, "safety " if safety_only else ""
+ )
return None
# Sort by priority desc, then by rule ID asc for deterministic order
matching_rules.sort(key=lambda x: (-x[0], x[1]))
winner = matching_rules[0][2]
_LOGGER.debug(
- "[%s] Rule '%s' (P%d) -> position %d",
- cover.entity_id, winner.name, winner.priority, winner.target_position,
+ "[%s] Rule '%s' (P%d%s) -> position %d",
+ cover.entity_id, winner.name, winner.priority,
+ ", safety" if winner.safety else "", winner.target_position,
)
return CoverTarget(
position=winner.target_position,
tilt_position=winner.target_tilt_position,
rule_id=winner.id,
rule_name=winner.name,
+ safety=bool(winner.safety),
)
+ # ------------------------------------------------------------------
+ # Native Home Assistant conditions
+ # ------------------------------------------------------------------
+
+ def ha_compiled(self, condition: Condition) -> hac.CompiledCondition | None:
+ """Return the compiled form of an ha_condition (None if not prepared)."""
+ return self._ha_compiled.get(hac.config_key(condition.params.get("config")))
+
+ async def async_prepare_ha_conditions(
+ self, conditions: list[Condition] | None = None
+ ) -> bool:
+ """Compile the ha_conditions of all rules (or the given ones).
+
+ Compilation is async in Home Assistant, evaluation is sync: rules are
+ compiled here before each update cycle, then evaluated cheaply.
+ Unused compiled entries are dropped when preparing all rules.
+ Returns True if the set of referenced entities changed.
+ """
+ before = self.ha_condition_entities()
+ if conditions is None:
+ conditions = [
+ c for rule in self.storage.rules.values() for c in rule.conditions
+ if c.type == ConditionType.HA_CONDITION
+ ]
+ wanted = {hac.config_key(c.params.get("config")) for c in conditions}
+ for key in list(self._ha_compiled):
+ if key not in wanted:
+ del self._ha_compiled[key]
+ self._ha_failed_at.pop(key, None)
+ for cond in conditions:
+ if cond.type != ConditionType.HA_CONDITION:
+ continue
+ # One broken condition must never stop the others from compiling
+ try:
+ await self._async_prepare_one(cond)
+ except Exception as err: # noqa: BLE001
+ config = cond.params.get("config")
+ _LOGGER.warning("Cannot compile Home Assistant condition %s: %s", config, err)
+ key = hac.config_key(config)
+ self._ha_compiled[key] = hac.CompiledCondition(
+ error=str(err), error_code="invalid"
+ )
+ self._ha_failed_at[key] = monotonic()
+ return self.ha_condition_entities() != before
+
+ async def _async_prepare_one(self, cond: Condition) -> None:
+ """Compile one ha_condition (or refresh its template entities)."""
+ config = cond.params.get("config")
+ key = hac.config_key(config)
+ compiled = self._ha_compiled.get(key)
+ if compiled is not None:
+ failed_at = self._ha_failed_at.get(key)
+ if failed_at is None:
+ # Valid: templates may reference other entities now (e.g.
+ # {{ states(states('input_text.x')) }}), re-read them so the
+ # coordinator listens to the right entities.
+ hac.refresh_template_entities(self.hass, compiled)
+ return
+ # Failed compilations are retried after a delay (the failed
+ # entry stays cached meanwhile so the panel sees the error).
+ if monotonic() - failed_at < HA_CONDITION_RETRY_INTERVAL:
+ return
+ compiled = await hac.async_compile(self.hass, config)
+ if compiled.error:
+ _LOGGER.debug("Invalid Home Assistant condition %s: %s", config, compiled.error)
+ self._ha_failed_at[key] = monotonic()
+ else:
+ self._ha_failed_at.pop(key, None)
+ self._ha_compiled[key] = compiled
+
+ def ha_condition_entities(self) -> set[str]:
+ """Entities referenced by the compiled Home Assistant conditions."""
+ entities: set[str] = set()
+ for compiled in self._ha_compiled.values():
+ entities |= compiled.entities
+ return entities
+
+ def ha_condition_status(self, condition: Condition) -> dict[str, Any] | None:
+ """Validation status of an ha_condition for the panel."""
+ if condition.type != ConditionType.HA_CONDITION:
+ return None
+ compiled = self.ha_compiled(condition)
+ if compiled is None:
+ return {"valid": False, "error": None, "error_code": "pending"}
+ return {
+ "valid": compiled.valid,
+ "error": compiled.error,
+ "error_code": compiled.error_code,
+ }
+
def _rule_applies_to_cover(self, rule: Rule, cover: CoverConfig) -> bool:
"""Check if rule applies to cover.
@@ -115,26 +301,259 @@ def _rule_applies_to_cover(self, rule: Rule, cover: CoverConfig) -> bool:
def _rule_active_in_scenario(self, rule: Rule, scenario_id: str) -> bool:
"""Check if rule is active in current scenario.
- Uses simple blacklist logic: rules are active unless explicitly
- disabled in the scenario's rules_disabled list.
+ A rule is active in a scenario when it belongs to it (its
+ scenario_ids) and is not switched off in the scenario's
+ rules_disabled list.
"""
scenario = self.storage.scenarios.get(scenario_id)
if scenario is None:
return True
+ # The rule must belong to the scenario (None = all scenarios) ...
+ if rule.scenario_ids is not None and scenario_id not in rule.scenario_ids:
+ return False
+ # ... and not be switched off inside it
return rule.id not in scenario.rules_disabled
def _evaluate_conditions(self, rule: Rule, cover: CoverConfig) -> bool:
"""Evaluate all conditions of a rule.
- Supports AND and OR operators:
- - AND (default): All conditions must be true
- - OR: At least one condition must be true
+ Conditions are organised in groups. Inside a group they are combined
+ with the group's operator (group_operators), the groups themselves
+ with the rule's condition_operator -- e.g. (A or B) and (C or D).
+ With a single group only its own operator applies. Empty groups are
+ ignored; a condition can be negated (NOT).
+ """
+ groups = rule.condition_groups()
+ if not groups:
+ return True
+
+ # Evaluate every condition first (no short-circuit) so the
+ # hysteresis state of each threshold condition stays current.
+ evaluated = [
+ (op, [self._evaluate_final(c, cover) for c in members])
+ for op, members in groups
+ ]
+
+ def group_result(op: str, results: list[bool]) -> bool:
+ return any(results) if op == "or" else all(results)
+
+ if len(evaluated) == 1:
+ return group_result(*evaluated[0])
+ group_results = [group_result(op, results) for op, results in evaluated]
+ return any(group_results) if rule.condition_operator == "or" else all(group_results)
+
+ def _evaluate_final(
+ self, condition: Condition, cover: CoverConfig | None, *, update_state: bool = True
+ ) -> bool:
+ """Evaluate a condition including its NOT flag.
+
+ A negated condition is only met when its input is actually known:
+ an unavailable sensor must not turn "NOT rain" into "met".
+ """
+ if not condition.negate:
+ return self._evaluate_condition(condition, cover, update_state=update_state) # type: ignore[arg-type]
+ return self._evaluate_tristate(condition, cover, update_state=update_state) is False
+
+ def _evaluate_tristate(
+ self, condition: Condition, cover: CoverConfig | None, *, update_state: bool = True
+ ) -> bool | None:
+ """Evaluate a condition (without NOT): True/False, or None when unknown.
+
+ Unknown = the input cannot be read, the condition is misconfigured
+ (missing or non-numeric threshold/offset), the evaluation raised, or
+ the native HA condition failed to evaluate.
+ """
+ if condition.type == ConditionType.HA_CONDITION:
+ return hac.evaluate_tristate(self.hass, self.ha_compiled(condition))
+ if not self._params_valid(condition):
+ _LOGGER.debug("Condition %s misconfigured: %s", condition.type, condition.params)
+ return None
+ try:
+ result = self._dispatch_condition(condition, cover, update_state=update_state) # type: ignore[arg-type]
+ except Exception as err: # noqa: BLE001
+ self._log_condition_error(condition, err)
+ return None
+ if not self._input_available(condition, cover):
+ return None
+ return result
+
+ @staticmethod
+ def _params_valid(condition: Condition) -> bool:
+ """Whether a condition's own parameters can be evaluated.
+
+ The evaluators return False on invalid parameters; for a negated
+ condition that would read as "met", so _evaluate_tristate reports
+ such a condition as unknown instead (NOT is then not met either).
+ Mirrors the parsing done by the evaluators.
+ """
+ params = condition.params
+
+ def is_number(value: Any) -> bool:
+ try:
+ float(value)
+ except (TypeError, ValueError):
+ return False
+ return True
+
+ match condition.type:
+ case ConditionType.NUMERIC_STATE:
+ return is_number(params.get("value")) and is_number(
+ params.get("hysteresis") or 0
+ )
+ case ConditionType.TEMPERATURE_ABOVE | ConditionType.TEMPERATURE_BELOW:
+ temp = params.get("temperature")
+ return is_number(temp if temp is not None else params.get("value", 0))
+ case ConditionType.SUN_ELEVATION_ABOVE | ConditionType.SUN_ELEVATION_BELOW:
+ elev = params.get("elevation")
+ return is_number(elev if elev is not None else params.get("value", 0))
+ case (
+ ConditionType.TIME_AFTER_SUNRISE | ConditionType.TIME_BEFORE_SUNRISE
+ | ConditionType.TIME_AFTER_SUNSET | ConditionType.TIME_BEFORE_SUNSET
+ | ConditionType.TIME_AFTER_DAWN | ConditionType.TIME_BEFORE_DAWN
+ | ConditionType.TIME_AFTER_DUSK | ConditionType.TIME_BEFORE_DUSK
+ ):
+ try:
+ int(params.get("offset", 0))
+ except (TypeError, ValueError):
+ return False
+ return True
+ case ConditionType.STATE_IS:
+ return params.get("state") is not None
+ case _:
+ return True
+
+ def _input_available(self, condition: Condition, cover: CoverConfig | None) -> bool:
+ """Whether the value a condition reads is currently known."""
+ params = condition.params
+ match condition.type:
+ case ConditionType.STATE_IS:
+ entity_id = params.get("entity_id") or params.get("entity")
+ if not entity_id:
+ return False
+ if str(params.get("state")) in _UNAVAILABLE_STATES:
+ # Checking for "unavailable" itself: any existing state is known
+ return self.hass.states.get(entity_id) is not None
+ return self._read_state(entity_id) is not None
+ case ConditionType.NUMERIC_STATE:
+ entity_id = params.get("entity_id") or params.get("entity")
+ return self._read_float_state(entity_id) is not None
+ case ConditionType.TEMPERATURE_ABOVE | ConditionType.TEMPERATURE_BELOW:
+ sensor_id = params.get("sensor") or self.storage.outdoor_temp_sensor
+ return self._read_float_state(sensor_id) is not None
+ case ConditionType.WEATHER_IS:
+ return self._read_state(params.get("entity") or self.storage.weather_entity) is not None
+ case ConditionType.WORKDAY:
+ return self._read_state(params.get("entity_id") or self.storage.workday_sensor) is not None
+ case ConditionType.TEMPERATURE_COMFORT:
+ return cover is not None and self._comfort_known(cover)
+ case ConditionType.OUTDOOR_VS_INDOOR:
+ return cover is not None and all(
+ self._read_float_state(s) is not None
+ for s in self._outdoor_indoor_sensors(cover)
+ )
+ case ConditionType.ROOM_OCCUPIED:
+ sensor = cover.occupancy_sensor if cover is not None else None
+ return not sensor or self._read_state(sensor) is not None
+ case ConditionType.SUN_ON_FACADE:
+ return self._sun_on_facade_known(condition, cover)
+ case ConditionType.SUN_ELEVATION_ABOVE | ConditionType.SUN_ELEVATION_BELOW:
+ return get_sun_position(self.hass) is not None
+ case ConditionType.TIME_BEFORE_SUNRISE:
+ return get_sunrise_time(self.hass) is not None
+ case ConditionType.TIME_AFTER_SUNSET:
+ return get_sunset_time(self.hass) is not None
+ case ConditionType.TIME_BEFORE_DAWN:
+ return get_dawn_time(self.hass) is not None
+ case ConditionType.TIME_AFTER_DUSK:
+ return get_dusk_time(self.hass) is not None
+ # Daytime windows need both of today's events
+ case ConditionType.TIME_AFTER_SUNRISE | ConditionType.TIME_BEFORE_SUNSET:
+ return (
+ get_sunrise_time(self.hass) is not None
+ and get_sunset_time(self.hass) is not None
+ )
+ case ConditionType.TIME_AFTER_DAWN | ConditionType.TIME_BEFORE_DUSK:
+ return (
+ get_dawn_time(self.hass) is not None
+ and get_dusk_time(self.hass) is not None
+ )
+ case ConditionType.HA_CONDITION:
+ compiled = self.ha_compiled(condition)
+ return compiled is not None and compiled.checker is not None
+ case _:
+ return True
+
+ def _sun_on_facade_known(self, condition: Condition, cover: CoverConfig | None) -> bool:
+ """Whether sun_on_facade can be decided (mirrors _eval_sun_on_facade)."""
+ if cover is None or get_sun_position(self.hass) is None:
+ return False
+ facade_id = condition.params.get("facade") or cover.facade_id
+ facade = self.storage.facades.get(facade_id) if facade_id else None
+ if not facade:
+ return False
+ # The comfort mode only matters while the sun is on the facade
+ if not self._sun_uses_comfort(cover):
+ return True
+ if not is_sun_on_facade(self.hass, facade):
+ return True
+ if not self._comfort_known(cover):
+ return False
+ # Neutral mode with preemptive shading: the answer depends on the
+ # solar sensor, unknown while it cannot be read.
+ _, neutral_ignore, preemptive = self._sun_flags(cover)
+ if (
+ neutral_ignore
+ and preemptive
+ and self._last_comfort_mode.get(cover.entity_id) == ComfortMode.NEUTRAL
+ ):
+ return self._solar_known()
+ return True
+
+ def _solar_known(self) -> bool:
+ """Whether the preemptive-shading solar check can be decided."""
+ sensor_id = self.storage.solar_sensor
+ if not sensor_id or self.effective_solar_threshold() <= 0:
+ return True # no sensor: preemptive shading simply never applies
+ return self._read_float_state(sensor_id) is not None
+
+ def _sun_flags(self, cover: CoverConfig) -> tuple[bool, bool, bool]:
+ """Effective (heating_ignore, neutral_ignore, preemptive) for a cover.
+
+ A per-cover value overrides the global setting when not None.
"""
- if not rule.conditions:
+ def pick(own: bool | None, default: bool) -> bool:
+ return default if own is None else bool(own)
+
+ return (
+ pick(cover.sun_heating_ignore, self.storage.sun_heating_ignore),
+ pick(cover.sun_neutral_ignore, self.storage.sun_neutral_ignore),
+ pick(cover.preemptive_shading, self.storage.preemptive_shading),
+ )
+
+ def _sun_uses_comfort(self, cover: CoverConfig) -> bool:
+ """Whether sun_on_facade depends on the comfort mode for this cover.
+
+ Needs an indoor sensor and at least one active ignore switch;
+ otherwise only the sun position counts.
+ """
+ if not (cover.indoor_temp_sensor or self.storage.indoor_temp_sensor):
+ return False
+ heating_ignore, neutral_ignore, _ = self._sun_flags(cover)
+ return heating_ignore or neutral_ignore
+
+ def _comfort_known(self, cover: CoverConfig) -> bool:
+ """Whether a comfort mode is known (sensor readable or held in grace)."""
+ sensor_id = cover.indoor_temp_sensor or self.storage.indoor_temp_sensor
+ if not sensor_id:
+ return False
+ if self._read_float_state(sensor_id) is not None:
return True
- evaluator = any if rule.condition_operator == "or" else all
- return evaluator(
- self._evaluate_condition(c, cover) for c in rule.conditions
+ prev = self._last_comfort_mode.get(cover.entity_id)
+ last_read = self._last_comfort_read.get(cover.entity_id)
+ return (
+ prev is not None
+ and last_read is not None
+ and monotonic() - last_read < COMFORT_SENSOR_GRACE_PERIOD
)
def _evaluate_condition(
@@ -144,54 +563,93 @@ def _evaluate_condition(
update_state=False keeps threshold hysteresis state untouched, so the
rule editor's live preview cannot shift the runtime deadband.
+ Errors count as not met.
"""
try:
- match condition.type:
- case ConditionType.SUN_ON_FACADE:
- return self._eval_sun_on_facade(condition, cover)
- case ConditionType.SUN_ELEVATION_ABOVE:
- return self._eval_sun_elevation(condition, above=True)
- case ConditionType.SUN_ELEVATION_BELOW:
- return self._eval_sun_elevation(condition, above=False)
- case ConditionType.TEMPERATURE_ABOVE:
- return self._eval_temp_threshold(
- condition, above=True, update_state=update_state
- )
- case ConditionType.TEMPERATURE_BELOW:
- return self._eval_temp_threshold(
- condition, above=False, update_state=update_state
- )
- case ConditionType.TIME_BETWEEN:
- return self._eval_time_between(condition)
- case ConditionType.TIME_AFTER_SUNRISE:
- return self._eval_time_after_sun_event(condition, get_sunrise_time)
- case ConditionType.TIME_AFTER_SUNSET:
- return self._eval_time_after_sun_event(condition, get_sunset_time)
- case ConditionType.TIME_BEFORE_SUNRISE:
- return self._eval_time_before_sun_event(condition, get_sunrise_time)
- case ConditionType.TIME_BEFORE_SUNSET:
- return self._eval_time_before_sun_event(condition, get_sunset_time)
- case ConditionType.STATE_IS:
- return self._eval_state_is(condition)
- case ConditionType.NUMERIC_STATE:
- return self._eval_numeric_state(
- condition, update_state=update_state
- )
- case ConditionType.TEMPERATURE_COMFORT:
- return self._eval_temp_comfort(condition, cover)
- case ConditionType.WEATHER_IS:
- return self._eval_weather_is(condition)
- case ConditionType.DAY_OF_WEEK:
- return self._eval_day_of_week(condition)
- case ConditionType.WORKDAY:
- return self._eval_workday(condition)
- case _:
- _LOGGER.warning("Unknown condition type: %s", condition.type)
- return False
- except Exception as err:
- _LOGGER.error("Error evaluating condition %s: %s", condition.type, err, exc_info=True)
+ return self._dispatch_condition(condition, cover, update_state=update_state)
+ except Exception as err: # noqa: BLE001
+ self._log_condition_error(condition, err)
return False
+ def _dispatch_condition(
+ self, condition: Condition, cover: CoverConfig, *, update_state: bool = True
+ ) -> bool:
+ """Run the evaluator of a condition type (may raise)."""
+ match condition.type:
+ case ConditionType.SUN_ON_FACADE:
+ return self._eval_sun_on_facade(condition, cover)
+ case ConditionType.SUN_ELEVATION_ABOVE:
+ return self._eval_sun_elevation(condition, above=True)
+ case ConditionType.SUN_ELEVATION_BELOW:
+ return self._eval_sun_elevation(condition, above=False)
+ case ConditionType.TEMPERATURE_ABOVE:
+ return self._eval_temp_threshold(
+ condition, above=True, update_state=update_state
+ )
+ case ConditionType.TEMPERATURE_BELOW:
+ return self._eval_temp_threshold(
+ condition, above=False, update_state=update_state
+ )
+ case ConditionType.TIME_BETWEEN:
+ return self._eval_time_between(condition)
+ case ConditionType.TIME_AFTER_SUNRISE:
+ return self._eval_time_after_morning_event(
+ condition, get_sunrise_time, get_sunset_time,
+ ConditionType.TIME_AFTER_SUNSET,
+ )
+ case ConditionType.TIME_AFTER_SUNSET:
+ return self._eval_time_after_sunset(condition)
+ case ConditionType.TIME_BEFORE_SUNRISE:
+ return self._eval_time_before_morning_event(
+ condition, get_sunrise_time, get_sunset_time
+ )
+ case ConditionType.TIME_BEFORE_SUNSET:
+ return self._eval_time_before_evening_event(
+ condition, get_sunset_time, get_sunrise_time,
+ ConditionType.TIME_BEFORE_SUNRISE,
+ )
+ case ConditionType.TIME_AFTER_DAWN:
+ return self._eval_time_after_morning_event(
+ condition, get_dawn_time, get_dusk_time,
+ ConditionType.TIME_AFTER_DUSK,
+ )
+ case ConditionType.TIME_BEFORE_DAWN:
+ return self._eval_time_before_morning_event(
+ condition, get_dawn_time, get_dusk_time
+ )
+ case ConditionType.TIME_AFTER_DUSK:
+ return self._eval_time_after_dusk(condition)
+ case ConditionType.TIME_BEFORE_DUSK:
+ return self._eval_time_before_evening_event(
+ condition, get_dusk_time, get_dawn_time,
+ ConditionType.TIME_BEFORE_DAWN,
+ )
+ case ConditionType.STATE_IS:
+ return self._eval_state_is(condition)
+ case ConditionType.NUMERIC_STATE:
+ return self._eval_numeric_state(
+ condition, update_state=update_state
+ )
+ case ConditionType.TEMPERATURE_COMFORT:
+ return self._eval_temp_comfort(condition, cover)
+ case ConditionType.OUTDOOR_VS_INDOOR:
+ return self._eval_outdoor_vs_indoor(
+ condition, cover, update_state=update_state
+ )
+ case ConditionType.ROOM_OCCUPIED:
+ return self._eval_room_occupied(cover)
+ case ConditionType.WEATHER_IS:
+ return self._eval_weather_is(condition)
+ case ConditionType.DAY_OF_WEEK:
+ return self._eval_day_of_week(condition)
+ case ConditionType.WORKDAY:
+ return self._eval_workday(condition)
+ case ConditionType.HA_CONDITION:
+ return hac.evaluate(self.hass, self.ha_compiled(condition))
+ case _:
+ _LOGGER.warning("Unknown condition type: %s", condition.type)
+ return False
+
def preview_condition(self, condition: Condition) -> dict[str, Any]:
"""Evaluate a condition for the rule editor's live preview.
@@ -207,9 +665,7 @@ def preview_condition(self, condition: Condition) -> dict[str, Any]:
return {"evaluable": False}
try:
# cover is unused for global condition types (filtered above)
- matched = self._evaluate_condition(
- condition, None, update_state=False, # type: ignore[arg-type]
- )
+ matched = self._evaluate_final(condition, None, update_state=False)
actual, kind = self._preview_actual(condition)
return {"evaluable": True, "matched": matched, "actual": actual, "kind": kind}
except Exception as err: # defensive: preview must never raise
@@ -219,6 +675,9 @@ def preview_condition(self, condition: Condition) -> dict[str, Any]:
def _preview_actual(self, condition: Condition) -> tuple[Any, str | None]:
"""Return (actual_value, kind) for a global condition's current reading."""
t = condition.type
+ if t == ConditionType.HA_CONDITION:
+ reading = hac.simple_reading(self.hass, condition.params.get("config"))
+ return reading, ("ha_reading" if reading else None)
if t in (ConditionType.SUN_ELEVATION_ABOVE, ConditionType.SUN_ELEVATION_BELOW):
position = get_sun_position(self.hass)
return (round(position[1], 1) if position else None), "elevation"
@@ -230,6 +689,8 @@ def _preview_actual(self, condition: Condition) -> tuple[Any, str | None]:
if t in (
ConditionType.TIME_AFTER_SUNRISE, ConditionType.TIME_BEFORE_SUNRISE,
ConditionType.TIME_AFTER_SUNSET, ConditionType.TIME_BEFORE_SUNSET,
+ ConditionType.TIME_AFTER_DAWN, ConditionType.TIME_BEFORE_DAWN,
+ ConditionType.TIME_AFTER_DUSK, ConditionType.TIME_BEFORE_DUSK,
):
return self._preview_sun_time(condition), "sun_time"
if t == ConditionType.NUMERIC_STATE:
@@ -251,9 +712,15 @@ def _preview_actual(self, condition: Condition) -> tuple[Any, str | None]:
def _preview_sun_time(self, condition: Condition) -> str | None:
"""Compute the threshold clock time (sun event + offset) as HH:MM."""
- event_fn = (
- get_sunrise_time if "sunrise" in condition.type.value else get_sunset_time
- )
+ value = condition.type.value
+ if "dawn" in value:
+ event_fn = get_dawn_time
+ elif "dusk" in value:
+ event_fn = get_dusk_time
+ elif "sunrise" in value:
+ event_fn = get_sunrise_time
+ else:
+ event_fn = get_sunset_time
event_time = event_fn(self.hass)
if event_time is None:
return None
@@ -288,12 +755,15 @@ def _read_float_state(self, entity_id: str | None) -> float | None:
def _eval_sun_on_facade(self, condition: Condition, cover: CoverConfig) -> bool:
"""Evaluate sun_on_facade condition.
- Automatically considers indoor comfort temperature when a sensor is
- configured (per-cover > global fallback). In HEATING mode, returns
- False to let sunlight in and save heating energy.
- When a sensor is configured but unavailable, the last known comfort
- mode is held for COMFORT_SENSOR_GRACE_PERIOD; beyond that (or without
- a prior reading) returns False (wait for reliable data before acting).
+ Considers the indoor comfort mode when a sensor is configured
+ (per-cover > global fallback) and an ignore switch is active
+ (sun_heating_ignore / sun_neutral_ignore, per-cover > global):
+ COOLING -> True; HEATING -> not heating_ignore; NEUTRAL -> True
+ unless neutral_ignore, then preemptive shading with solar above
+ threshold. With both switches off only the sun position counts.
+ When the sensor is unavailable, the last known comfort mode is held
+ for COMFORT_SENSOR_GRACE_PERIOD; beyond that (or without a prior
+ reading) returns False (wait for reliable data before acting).
"""
facade_id = condition.params.get("facade") or cover.facade_id
if not facade_id:
@@ -306,35 +776,35 @@ def _eval_sun_on_facade(self, condition: Condition, cover: CoverConfig) -> bool:
if not is_sun_on_facade(self.hass, facade):
return False
- # Auto comfort check: shade in COOLING, optionally in NEUTRAL with solar trigger
- sensor_id = cover.indoor_temp_sensor or self.storage.indoor_temp_sensor
- if sensor_id:
- comfort_mode = self._get_comfort_mode(cover)
- if comfort_mode is None:
- _LOGGER.debug(
- "[%s] sun_on_facade: sensor unavailable, deferring",
- cover.entity_id,
- )
- return False
- if comfort_mode == ComfortMode.COOLING:
- return True
- if (
- comfort_mode == ComfortMode.NEUTRAL
- and cover.preemptive_shading
- and self._check_solar_intensity()
- ):
- _LOGGER.debug(
- "[%s] sun_on_facade: preemptive shading (solar above threshold)",
- cover.entity_id,
- )
- return True
+ # Comfort check (indoor sensor + at least one ignore switch active)
+ if not self._sun_uses_comfort(cover):
+ return True
+ heating_ignore, neutral_ignore, preemptive = self._sun_flags(cover)
+ comfort_mode = self._get_comfort_mode(cover)
+ if comfort_mode is None:
_LOGGER.debug(
- "[%s] sun_on_facade: skipping shading (%s mode)",
- cover.entity_id, comfort_mode.value,
+ "[%s] sun_on_facade: sensor unavailable, deferring",
+ cover.entity_id,
)
return False
-
- return True
+ if comfort_mode == ComfortMode.COOLING:
+ return True
+ if comfort_mode == ComfortMode.HEATING:
+ if not heating_ignore:
+ return True
+ elif not neutral_ignore:
+ return True
+ elif preemptive and self._check_solar_intensity():
+ _LOGGER.debug(
+ "[%s] sun_on_facade: preemptive shading (solar above threshold)",
+ cover.entity_id,
+ )
+ return True
+ _LOGGER.debug(
+ "[%s] sun_on_facade: skipping shading (%s mode)",
+ cover.entity_id, comfort_mode.value,
+ )
+ return False
def _get_comfort_mode(self, cover: CoverConfig) -> ComfortMode | None:
"""Determine comfort mode from indoor temperature sensor.
@@ -347,9 +817,14 @@ def _get_comfort_mode(self, cover: CoverConfig) -> ComfortMode | None:
COMFORT_SENSOR_GRACE_PERIOD so brief outages (e.g. Zigbee bridge
restart) do not drop active rules. Returns None if no sensor is
configured, or unavailable beyond the grace period.
+
+ The previous mode only feeds the hysteresis when it was computed
+ less than HYSTERESIS_STATE_MAX_AGE ago; otherwise the hard
+ boundaries decide, as on the first evaluation.
"""
sensor_id = cover.indoor_temp_sensor or self.storage.indoor_temp_sensor
if not sensor_id:
+ self._forget_comfort(cover.entity_id)
return None
state = self.hass.states.get(sensor_id)
@@ -371,16 +846,23 @@ def _get_comfort_mode(self, cover: CoverConfig) -> ComfortMode | None:
cover.entity_id, sensor_id, prev.value,
)
return prev
+ self._forget_comfort(cover.entity_id)
return None
- self._last_comfort_read[cover.entity_id] = monotonic()
- h = self.storage.comfort_hysteresis
+ now = monotonic()
+ last_read = self._last_comfort_read.get(cover.entity_id)
prev = self._last_comfort_mode.get(cover.entity_id)
- comfort_min = cover.comfort_temp_min if cover.comfort_temp_min is not None else self.storage.comfort_temp_min
- comfort_max = cover.comfort_temp_max if cover.comfort_temp_max is not None else self.storage.comfort_temp_max
-
+ if last_read is None or now - last_read > HYSTERESIS_STATE_MAX_AGE:
+ # Not evaluated for a while (e.g. sun_on_facade stops before the
+ # comfort check while the sun is off the facade): a mode from
+ # hours ago must not bias the bands, use the hard boundaries.
+ prev = None
+ self._last_comfort_read[cover.entity_id] = now
+ h = self.storage.comfort_hysteresis
+ comfort_min, comfort_max = self._comfort_range(cover)
if comfort_min >= comfort_max:
_LOGGER.warning("[%s] comfort_min (%.1f) >= comfort_max (%.1f)", cover.entity_id, comfort_min, comfort_max)
+ self._forget_comfort(cover.entity_id)
return None
# Hard boundaries first, then hysteresis in the transition bands.
@@ -406,6 +888,111 @@ def _get_comfort_mode(self, cover: CoverConfig) -> ComfortMode | None:
self._last_comfort_mode[cover.entity_id] = mode
return mode
+ def _setting_entity(self, key: str) -> str | None:
+ """Return a global entity id setting (None when unset)."""
+ value = getattr(self.storage, key, None)
+ return value if isinstance(value, str) and value else None
+
+ def _read_threshold_entity(self, entity_id: str, fallback: Any) -> float | None:
+ """Numeric value of a threshold entity, None when not readable.
+
+ Logs once when the entity becomes unreadable (and once when it
+ recovers), not on every evaluation.
+ """
+ state = self.hass.states.get(entity_id)
+ value: float | None = None
+ if state is not None and state.state not in _UNAVAILABLE_STATES:
+ try:
+ value = float(state.state)
+ except (ValueError, TypeError):
+ value = None
+ if value is not None and (value != value or abs(value) == float("inf")):
+ value = None
+ if value is None:
+ if entity_id not in self._threshold_entity_failed:
+ self._threshold_entity_failed.add(entity_id)
+ _LOGGER.warning(
+ "Threshold entity %s is unavailable or not numeric (%s), using %s",
+ entity_id, state.state if state is not None else "missing",
+ fallback if fallback is not None else "the next fallback",
+ )
+ return None
+ if entity_id in self._threshold_entity_failed:
+ self._threshold_entity_failed.discard(entity_id)
+ _LOGGER.info("Threshold entity %s is readable again (%s)", entity_id, value)
+ return value
+
+ def _threshold_level(self, entity_id: str | None, number: Any) -> Any:
+ """Value of one threshold level: the entity when readable, else the number."""
+ if entity_id:
+ value = self._read_threshold_entity(entity_id, number)
+ if value is not None:
+ return value
+ return number
+
+ def effective_solar_threshold(self) -> float:
+ """Solar threshold: entity value when readable, else the static number."""
+ return float(self._threshold_level(
+ self._setting_entity("solar_threshold_entity"), self.storage.solar_threshold
+ ))
+
+ def _global_comfort_range(self) -> tuple[float, float]:
+ """Global comfort range (entities override the static numbers).
+
+ An invalid pair coming from entities falls back to the static numbers,
+ which the API keeps valid.
+ """
+ static_min = self.storage.comfort_temp_min
+ static_max = self.storage.comfort_temp_max
+ global_min = self._threshold_level(
+ self._setting_entity("comfort_temp_min_entity"), static_min
+ )
+ global_max = self._threshold_level(
+ self._setting_entity("comfort_temp_max_entity"), static_max
+ )
+ if global_min >= global_max and (global_min, global_max) != (static_min, static_max):
+ if "__global__" not in self._comfort_range_warned:
+ self._comfort_range_warned.add("__global__")
+ _LOGGER.warning(
+ "Invalid comfort range %.1f-%.1f from entities, using %.1f-%.1f",
+ global_min, global_max, static_min, static_max,
+ )
+ return static_min, static_max
+ self._comfort_range_warned.discard("__global__")
+ return global_min, global_max
+
+ def _comfort_range(self, cover: CoverConfig) -> tuple[float, float]:
+ """Effective comfort range of a cover.
+
+ Per level (cover, then global) a readable entity wins over the
+ number. A per-cover override mixed with a changed global value can
+ produce min >= max; the global pair is then used instead of
+ disabling comfort.
+ """
+ global_min, global_max = self._global_comfort_range()
+ comfort_min = self._threshold_level(
+ getattr(cover, "comfort_temp_min_entity", None), cover.comfort_temp_min
+ )
+ comfort_max = self._threshold_level(
+ getattr(cover, "comfort_temp_max_entity", None), cover.comfort_temp_max
+ )
+ if comfort_min is None:
+ comfort_min = global_min
+ if comfort_max is None:
+ comfort_max = global_max
+ if comfort_min < comfort_max:
+ self._comfort_range_warned.discard(cover.entity_id)
+ return comfort_min, comfort_max
+ if (comfort_min, comfort_max) != (global_min, global_max):
+ if cover.entity_id not in self._comfort_range_warned:
+ self._comfort_range_warned.add(cover.entity_id)
+ _LOGGER.warning(
+ "[%s] Invalid comfort range %.1f-%.1f, using global range %.1f-%.1f",
+ cover.entity_id, comfort_min, comfort_max, global_min, global_max,
+ )
+ return global_min, global_max
+ return comfort_min, comfort_max
+
def _check_solar_intensity(self, *, update_state: bool = True) -> bool:
"""Check if solar intensity exceeds threshold for preemptive shading.
@@ -417,7 +1004,8 @@ def _check_solar_intensity(self, *, update_state: bool = True) -> bool:
sensor_id = self.storage.solar_sensor
if not sensor_id:
return False
- threshold = self.storage.solar_threshold
+ threshold_entity = self._setting_entity("solar_threshold_entity")
+ threshold = self.effective_solar_threshold()
if threshold <= 0:
return False
state = self.hass.states.get(sensor_id)
@@ -430,13 +1018,36 @@ def _check_solar_intensity(self, *, update_state: bool = True) -> bool:
return self._threshold_with_hysteresis(
sensor_id, value, float(threshold),
above=True, update_state=update_state,
- hysteresis=self.storage.solar_hysteresis,
+ hysteresis=self._solar_hysteresis(float(threshold)),
+ # A threshold read from an entity may change: keep one state
+ # instead of a new entry (and lost hysteresis) per value.
+ state_key=(
+ (f"{sensor_id}|{threshold_entity}", 0.0, True, self.storage.solar_hysteresis)
+ if threshold_entity else None
+ ),
)
+ def _solar_hysteresis(self, threshold: float) -> float:
+ """Solar deadband, bounded so the shading can be released again.
+
+ The sensor never reads below 0, so a buffer >= threshold would put the
+ release point (threshold - h) at or below 0: once on, preemptive
+ shading would never turn off. Such a buffer is clamped to half the
+ threshold.
+ """
+ h = self.storage.solar_hysteresis
+ if threshold > 0 and h >= threshold:
+ return threshold / 2
+ return h
+
def _eval_sun_elevation(self, condition: Condition, *, above: bool) -> bool:
"""Evaluate sun elevation above/below threshold."""
elev = condition.params.get("elevation")
threshold = elev if elev is not None else condition.params.get("value", 0)
+ try:
+ threshold = float(threshold)
+ except (ValueError, TypeError):
+ return False
position = get_sun_position(self.hass)
if position is None:
return False
@@ -486,6 +1097,7 @@ def _threshold_with_hysteresis(
above: bool,
update_state: bool,
hysteresis: float | None = None,
+ state_key: tuple[str, float, bool, float] | None = None,
) -> bool:
"""Compare value against threshold with a hysteresis deadband.
@@ -496,10 +1108,17 @@ def _threshold_with_hysteresis(
buffers, and a shared bool would let them overwrite each other.
hysteresis=None falls back to the global setting (degrees) -- callers
working in other units must pass their own value.
+
+ A state older than HYSTERESIS_STATE_MAX_AGE (the condition was not
+ evaluated for a while) is ignored like a missing one: the hard
+ boundary decides, not a result remembered from hours ago.
"""
h = self.storage.threshold_hysteresis if hysteresis is None else hysteresis
- key = (sensor_id, threshold, above, h)
+ key = state_key or (sensor_id, threshold, above, h)
+ now = monotonic()
prev = self._threshold_states.get(key)
+ if prev is not None and now - self._threshold_stamps.get(key, 0.0) > HYSTERESIS_STATE_MAX_AGE:
+ prev = None
if prev is None or h <= 0:
result = value > threshold if above else value < threshold
@@ -510,8 +1129,20 @@ def _threshold_with_hysteresis(
if update_state:
self._threshold_states[key] = result
+ self._threshold_stamps[key] = now
+ self._purge_threshold_states(now)
return result
+ def _purge_threshold_states(self, now: float) -> None:
+ """Drop expired hysteresis states (at most once per max age)."""
+ if now - self._threshold_purged_at < HYSTERESIS_STATE_MAX_AGE:
+ return
+ self._threshold_purged_at = now
+ for key in list(self._threshold_states):
+ if now - self._threshold_stamps.get(key, 0.0) > HYSTERESIS_STATE_MAX_AGE:
+ self._threshold_states.pop(key, None)
+ self._threshold_stamps.pop(key, None)
+
def _eval_time_between(self, condition: Condition) -> bool:
"""Evaluate time_between condition."""
start_str = condition.params.get("start_time") or condition.params.get("start", "00:00")
@@ -529,8 +1160,8 @@ def _eval_time_between(self, condition: Condition) -> bool:
now = dt_util.now().time()
- # Same start and end means all day
- if start_time == end_time:
+ # Same start and end (HH:MM) means all day
+ if (start_time.hour, start_time.minute) == (end_time.hour, end_time.minute):
return True
if start_time <= end_time:
@@ -552,6 +1183,130 @@ def _eval_time_after_sun_event(self, condition: Condition, event_fn) -> bool:
target_time = event_time + (offset_minutes * 60)
return dt_util.now().timestamp() >= target_time
+ def _eval_time_after_sunset(self, condition: Condition) -> bool:
+ """Evaluate time_after_sunset: true from (sunset + offset) until sunrise.
+
+ Comparing against today's sunset only made the condition turn false
+ at midnight, so a night rule stopped matching (and a lower-priority
+ rule could take over) in the middle of the night. After midnight the
+ condition now stays true until today's sunrise as long as yesterday's
+ sunset + offset has passed.
+ """
+ if self._eval_time_after_sun_event(condition, get_sunset_time):
+ return True
+ try:
+ offset_minutes = int(condition.params.get("offset", 0))
+ except (ValueError, TypeError):
+ return False
+ sunrise = get_sunrise_time(self.hass)
+ yesterday_sunset = get_sun_event_time(self.hass, SUN_EVENT_SUNSET, -1)
+ if sunrise is None or yesterday_sunset is None:
+ return False
+ now = dt_util.now().timestamp()
+ return yesterday_sunset + offset_minutes * 60 <= now < sunrise
+
+ def _eval_time_after_dusk(self, condition: Condition) -> bool:
+ """Evaluate time_after_dusk: true from (dusk + offset) until dawn.
+
+ Same night-spanning semantics as time_after_sunset: after midnight it
+ stays true until today's dawn when yesterday's dusk + offset passed.
+ """
+ if self._eval_time_after_sun_event(condition, get_dusk_time):
+ return True
+ try:
+ offset_minutes = int(condition.params.get("offset", 0))
+ except (ValueError, TypeError):
+ return False
+ dawn = get_dawn_time(self.hass)
+ yesterday_dusk = get_sun_event_time(self.hass, SUN_EVENT_DUSK, -1)
+ if dawn is None or yesterday_dusk is None:
+ return False
+ now = dt_util.now().timestamp()
+ return yesterday_dusk + offset_minutes * 60 <= now < dawn
+
+ def _eval_time_before_morning_event(
+ self, condition: Condition, morning_fn, evening_fn
+ ) -> bool:
+ """time_before_sunrise / time_before_dawn, spanning the night.
+
+ Mirror of time_after_sunset / time_after_dusk: true from the evening
+ event (sunset / dusk) until the next morning event + offset, not only
+ between midnight and the morning event.
+ """
+ if self._eval_time_before_sun_event(condition, morning_fn):
+ return True
+ evening = evening_fn(self.hass)
+ return evening is not None and dt_util.now().timestamp() >= evening
+
+ def _configured_offsets(self, condition_type: ConditionType) -> list[int]:
+ """Offsets (minutes) of the conditions of this type in the enabled rules.
+
+ Read from storage on each call (a few rules, cheap): always in step
+ with the configured rules.
+ """
+ offsets: list[int] = []
+ for rule in self.storage.rules.values():
+ if not rule.enabled:
+ continue
+ for cond in rule.conditions:
+ if cond.type != condition_type:
+ continue
+ try:
+ offsets.append(int(cond.params.get("offset", 0)))
+ except (TypeError, ValueError):
+ continue
+ return offsets
+
+ def _eval_time_after_morning_event(
+ self, condition: Condition, morning_fn, evening_fn,
+ closing_type: ConditionType,
+ ) -> bool:
+ """time_after_sunrise / time_after_dawn: a daytime window.
+
+ True from (today's morning event + offset) until today's evening
+ event (sunset / dusk) EXTENDED by the largest positive offset of the
+ night condition taking over (closing_type: time_after_sunset /
+ time_after_dusk) in the enabled rules. Without it, a closing rule
+ "after dusk +15" left 15 minutes where neither matched and a
+ low-priority fallback rule won. Without positive offsets it ends at
+ the evening event: the exact complement of time_before_sunrise /
+ time_before_dawn. Staying true until midnight overlapped the night
+ conditions in the evening. Unknown events (polar day/night) give
+ False, reported as unknown for NOT.
+ """
+ if not self._eval_time_after_sun_event(condition, morning_fn):
+ return False
+ evening = evening_fn(self.hass)
+ if evening is None:
+ return False
+ extension = max([0, *self._configured_offsets(closing_type)])
+ return dt_util.now().timestamp() < evening + extension * 60
+
+ def _eval_time_before_evening_event(
+ self, condition: Condition, evening_fn, morning_fn,
+ night_type: ConditionType,
+ ) -> bool:
+ """time_before_sunset / time_before_dusk: a daytime window.
+
+ True from today's morning event (sunrise / dawn) until (today's
+ evening event + offset). The start is brought FORWARD by the largest
+ negative offset of the night condition ending in the morning
+ (night_type: time_before_sunrise / time_before_dawn) in the enabled
+ rules, so "before sunrise -15" followed by this condition leaves no
+ gap. Without negative offsets it starts at the morning event: the
+ exact complement of time_after_sunset / time_after_dusk. Being true
+ from midnight overlapped the night conditions before sunrise.
+ Unknown events (polar day/night) give False, reported as unknown
+ for NOT.
+ """
+ if not self._eval_time_before_sun_event(condition, evening_fn):
+ return False
+ morning = morning_fn(self.hass)
+ if morning is None:
+ return False
+ lead = -min([0, *self._configured_offsets(night_type)])
+ return dt_util.now().timestamp() >= morning - lead * 60
+
def _eval_time_before_sun_event(self, condition: Condition, event_fn) -> bool:
"""Evaluate time before sunrise/sunset with offset.
@@ -617,6 +1372,62 @@ def _eval_numeric_state(
above=above, update_state=update_state, hysteresis=hysteresis,
)
+ def _outdoor_indoor_sensors(self, cover: CoverConfig) -> tuple[str | None, str | None]:
+ """(outdoor sensor, indoor sensor of the cover's room)."""
+ return (
+ self.storage.outdoor_temp_sensor,
+ cover.indoor_temp_sensor or self.storage.indoor_temp_sensor,
+ )
+
+ def _eval_outdoor_vs_indoor(
+ self, condition: Condition, cover: CoverConfig | None, *, update_state: bool = True
+ ) -> bool:
+ """Outdoor air warmer/cooler than the cover's room.
+
+ operator "warmer": outdoor > indoor + delta; "cooler": outdoor <
+ indoor - delta. The rule temperature hysteresis applies to the
+ difference, so two sensors a few tenths apart do not flip the rule
+ on every reading. False when a temperature cannot be read.
+ """
+ if cover is None:
+ return False
+ outdoor_id, indoor_id = self._outdoor_indoor_sensors(cover)
+ outdoor = self._read_float_state(outdoor_id)
+ indoor = self._read_float_state(indoor_id)
+ if outdoor is None or indoor is None:
+ return False
+ try:
+ delta = abs(float(condition.params.get("delta") or 0))
+ except (TypeError, ValueError):
+ delta = 0.0
+ warmer = str(condition.params.get("operator", "cooler")) == "warmer"
+ threshold = delta if warmer else -delta
+ h = self.storage.threshold_hysteresis
+ return self._threshold_with_hysteresis(
+ f"{outdoor_id}|{indoor_id}", round(outdoor - indoor, 1), threshold,
+ above=warmer, update_state=update_state,
+ state_key=(f"ovi|{outdoor_id}|{indoor_id}", threshold, warmer, h),
+ )
+
+ def _eval_room_occupied(self, cover: CoverConfig | None) -> bool:
+ """The cover's room is occupied according to its occupancy sensor.
+
+ No sensor: never occupied. Unavailable sensor: False, and the input
+ is reported unknown so "NOT occupied" is not met either (the cover
+ does not open onto a possibly occupied room).
+ """
+ sensor = cover.occupancy_sensor if cover is not None else None
+ if not sensor:
+ return False
+ state = self._read_state(sensor)
+ if state is None:
+ return False
+ if cover.occupancy_states:
+ occupied = {s.strip().casefold() for s in cover.occupancy_states.split(",") if s.strip()}
+ else:
+ occupied = _DEFAULT_OCCUPIED_STATES
+ return state.strip().casefold() in occupied
+
def _eval_temp_comfort(self, condition: Condition, cover: CoverConfig) -> bool:
"""Evaluate temperature_comfort condition.
diff --git a/custom_components/cover_automatic/entities.py b/custom_components/cover_automatic/entities.py
new file mode 100644
index 0000000..e8c6419
--- /dev/null
+++ b/custom_components/cover_automatic/entities.py
@@ -0,0 +1,81 @@
+"""Entity/device registry housekeeping for CoverAutomatic.
+
+Entities are created per managed cover and per facade. Covers and facades can
+be added or removed at runtime from the panel, so the registries have to be
+kept in sync: new objects get entities via coordinator listeners in the
+platforms, removed objects are cleaned up here.
+"""
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING
+
+from homeassistant.core import callback
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers import entity_registry as er
+
+from .const import DOMAIN
+
+if TYPE_CHECKING:
+ from homeassistant.config_entries import ConfigEntry
+ from homeassistant.core import HomeAssistant
+
+ from .storage import CoverAutomaticStorage
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def expected_unique_ids(entry_id: str, storage: CoverAutomaticStorage) -> set[str]:
+ """Return the unique_ids of all entities the current config should have."""
+ ids = {
+ f"{DOMAIN}_master",
+ f"{DOMAIN}_{entry_id}_scenario",
+ f"{DOMAIN}_{entry_id}_wind_protection",
+ f"{DOMAIN}_{entry_id}_covers_paused",
+ f"{DOMAIN}_{entry_id}_covers_manual",
+ f"{DOMAIN}_{entry_id}_covers_locked",
+ }
+ for entity_id in storage._data.get("covers", {}):
+ ids.add(f"{DOMAIN}_{entity_id}_auto")
+ for suffix in ("status", "rule", "target", "position", "comfort", "pause_end"):
+ ids.add(f"{DOMAIN}_{entity_id}_{suffix}")
+ for facade_id in storage._data.get("facades", {}):
+ ids.add(f"{DOMAIN}_facade_{facade_id}_sun")
+ ids.add(f"{DOMAIN}_facade_{facade_id}_sun_entry")
+ ids.add(f"{DOMAIN}_facade_{facade_id}_sun_exit")
+ return ids
+
+
+def expected_device_identifiers(entry_id: str, storage: CoverAutomaticStorage) -> set[str]:
+ """Return the (DOMAIN, x) identifier values the current config should have."""
+ idents = {entry_id}
+ idents.update(storage._data.get("covers", {}).keys())
+ idents.update(f"facade_{fid}" for fid in storage._data.get("facades", {}))
+ return idents
+
+
+@callback
+def async_cleanup_orphan_entities(
+ hass: HomeAssistant, entry: ConfigEntry, storage: CoverAutomaticStorage
+) -> None:
+ """Remove registry entities/devices whose cover or facade no longer exists.
+
+ Also removes legacy entities from older versions (e.g. the per-cover
+ pause_duration number entities removed in 1.52.0).
+ """
+ expected = expected_unique_ids(entry.entry_id, storage)
+ ent_reg = er.async_get(hass)
+ for reg_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
+ if reg_entry.platform == DOMAIN and reg_entry.unique_id not in expected:
+ _LOGGER.info("Removing orphan entity %s", reg_entry.entity_id)
+ ent_reg.async_remove(reg_entry.entity_id)
+
+ expected_devices = expected_device_identifiers(entry.entry_id, storage)
+ dev_reg = dr.async_get(hass)
+ for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id):
+ own = {value for (domain, value) in device.identifiers if domain == DOMAIN}
+ if own and not own & expected_devices:
+ _LOGGER.info("Removing orphan device %s", device.name)
+ dev_reg.async_update_device(
+ device.id, remove_config_entry_id=entry.entry_id
+ )
diff --git a/custom_components/cover_automatic/ha_condition.py b/custom_components/cover_automatic/ha_condition.py
new file mode 100644
index 0000000..966f926
--- /dev/null
+++ b/custom_components/cover_automatic/ha_condition.py
@@ -0,0 +1,218 @@
+"""Native Home Assistant conditions inside CoverAutomatic rules.
+
+A rule condition of type ``ha_condition`` stores a condition written in the
+same format as the ``condition:`` section of a Home Assistant automation
+(state, numeric_state, template, zone, and/or/not, ...). Home Assistant
+validates and evaluates it; this module wraps that for the rule engine.
+
+The raw configuration (plain JSON-compatible dict) is what gets stored. It is
+compiled into a checker once and cached by the engine; evaluation is then a
+cheap synchronous call.
+"""
+from __future__ import annotations
+
+import copy
+import json
+import logging
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
+
+import voluptuous as vol
+import yaml
+
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import condition as ha_condition
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.template import Template
+
+if TYPE_CHECKING:
+ from homeassistant.core import HomeAssistant
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass(slots=True)
+class CompiledCondition:
+ """A validated and compiled Home Assistant condition."""
+
+ checker: Any = None
+ entities: set[str] = field(default_factory=set)
+ error: str | None = None
+ error_code: str | None = None
+ # Entities found statically (entity_id fields) and the templates whose
+ # referenced entities are re-read by refresh_template_entities().
+ static_entities: set[str] = field(default_factory=set)
+ templates: list[Any] = field(default_factory=list)
+
+ @property
+ def valid(self) -> bool:
+ """Return True if the condition compiled successfully."""
+ return self.checker is not None
+
+
+def config_key(config: Any) -> str:
+ """Stable cache key for a raw condition configuration."""
+ return json.dumps(config, sort_keys=True, default=str)
+
+
+def parse_yaml(text: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
+ """Parse YAML text into a condition config.
+
+ A list of conditions is accepted and wrapped in an ``and`` condition,
+ like the ``condition:`` list of an automation.
+ Returns (config, error). error is a dict {code, detail, line, column}
+ so the panel can localize the message.
+ """
+ if not isinstance(text, str) or not text.strip():
+ return None, None
+ try:
+ data = yaml.safe_load(text)
+ except yaml.YAMLError as err:
+ mark = getattr(err, "problem_mark", None)
+ return None, {
+ "code": "yaml",
+ "detail": getattr(err, "problem", None) or str(err),
+ "line": mark.line + 1 if mark else None,
+ "column": mark.column + 1 if mark else None,
+ }
+ if isinstance(data, list):
+ data = {"condition": "and", "conditions": data}
+ if not isinstance(data, dict):
+ return None, {"code": "not_mapping", "detail": None, "line": None, "column": None}
+ return data, None
+
+
+def dump_yaml(config: dict[str, Any] | None) -> str:
+ """Serialize a condition config to YAML (key order preserved)."""
+ if not config:
+ return ""
+ return yaml.safe_dump(config, allow_unicode=True, sort_keys=False, default_flow_style=False)
+
+
+def _find_templates(validated: Any) -> list[Template]:
+ """Templates contained in a validated config."""
+ templates: list[Template] = []
+ stack = [validated]
+ while stack:
+ item = stack.pop()
+ if isinstance(item, Template):
+ templates.append(item)
+ elif isinstance(item, dict):
+ stack.extend(item.values())
+ elif isinstance(item, (list, tuple)):
+ stack.extend(item)
+ return templates
+
+
+def _render_entities(hass: HomeAssistant, templates: list[Template]) -> set[str]:
+ """Entities the templates currently reference (best effort).
+
+ A template's entities can change from one rendering to the next (e.g.
+ {{ states(states('input_text.target')) }}), so this is re-run by
+ refresh_template_entities() before every update cycle. Limitation: the
+ referenced set is only as current as the last refresh; a change of the
+ indirection entity is picked up at the next cycle, not instantly.
+ """
+ entities: set[str] = set()
+ for template in templates:
+ try:
+ if template.hass is None:
+ template.hass = hass
+ entities |= set(template.async_render_to_info().entities)
+ except Exception: # noqa: BLE001 -- best effort, rendering may fail
+ pass
+ return entities
+
+
+def _template_entities(hass: HomeAssistant, validated: Any) -> set[str]:
+ """Collect entities referenced by templates inside a validated config."""
+ return _render_entities(hass, _find_templates(validated))
+
+
+def refresh_template_entities(hass: HomeAssistant, compiled: CompiledCondition) -> bool:
+ """Re-render the templates of a compiled condition; True if entities changed."""
+ if compiled.checker is None or not compiled.templates:
+ return False
+ entities = compiled.static_entities | _render_entities(hass, compiled.templates)
+ if entities == compiled.entities:
+ return False
+ compiled.entities = entities
+ return True
+
+
+async def async_compile(hass: HomeAssistant, config: Any) -> CompiledCondition:
+ """Validate and compile a raw condition config (never raises)."""
+ if not isinstance(config, dict) or not config:
+ return CompiledCondition(error="empty condition", error_code="empty")
+ try:
+ validated = cv.CONDITION_SCHEMA(copy.deepcopy(config))
+ validated = await ha_condition.async_validate_condition_config(hass, validated)
+ checker = await ha_condition.async_from_config(hass, validated)
+ except (vol.Invalid, HomeAssistantError, ValueError, KeyError, TypeError) as err:
+ return CompiledCondition(error=str(err), error_code="invalid")
+ except Exception as err: # noqa: BLE001 -- e.g. a failing integration platform
+ _LOGGER.warning("Unexpected error compiling Home Assistant condition %s: %s", config, err)
+ return CompiledCondition(error=str(err) or type(err).__name__, error_code="invalid")
+ try:
+ static = set(ha_condition.async_extract_entities(validated))
+ except Exception as err: # noqa: BLE001 -- entities are only used to listen
+ _LOGGER.debug("Cannot extract entities of %s: %s", config, err)
+ static = set()
+ templates = _find_templates(validated)
+ entities = static | _render_entities(hass, templates)
+ return CompiledCondition(
+ checker=checker, entities=entities,
+ static_entities=static, templates=templates,
+ )
+
+
+def evaluate(hass: HomeAssistant, compiled: CompiledCondition | None) -> bool:
+ """Evaluate a compiled condition. Invalid or failing conditions are False.
+
+ A disabled condition (``enabled: false``) returns None from HA and is
+ treated as met, like HA ignores it inside an automation.
+ """
+ return evaluate_tristate(hass, compiled) is True
+
+
+def evaluate_tristate(hass: HomeAssistant, compiled: CompiledCondition | None) -> bool | None:
+ """Evaluate a compiled condition: True/False, or None when unknown.
+
+ None = not compiled, invalid, or the evaluation raised (e.g. an entity
+ without a numeric state), so a negated condition is never met by it.
+ """
+ if compiled is None or compiled.checker is None:
+ return None
+ try:
+ result = compiled.checker(hass, None)
+ except Exception as err: # noqa: BLE001 -- ConditionError & template errors
+ _LOGGER.debug("Home Assistant condition not evaluable (error: %s)", err)
+ return None
+ return True if result is None else bool(result)
+
+
+def simple_reading(hass: HomeAssistant, config: Any) -> dict[str, Any] | None:
+ """Return the current value a simple state/numeric_state condition reads.
+
+ Used by the rule editor preview to show e.g. "Absent" next to the
+ condition. Only for a single entity (optionally inside one ``not``).
+ """
+ cfg = config
+ if isinstance(cfg, dict) and cfg.get("condition") == "not":
+ subs = cfg.get("conditions") or []
+ cfg = subs[0] if len(subs) == 1 else None
+ if not isinstance(cfg, dict) or cfg.get("condition") not in ("state", "numeric_state"):
+ return None
+ entity_id = cfg.get("entity_id")
+ if isinstance(entity_id, list):
+ entity_id = entity_id[0] if len(entity_id) == 1 else None
+ if not isinstance(entity_id, str):
+ return None
+ state = hass.states.get(entity_id)
+ if state is None:
+ return None
+ attribute = cfg.get("attribute")
+ value = state.attributes.get(attribute) if attribute else state.state
+ if value is None:
+ return None
+ return {"entity_id": entity_id, "attribute": attribute, "value": value}
diff --git a/custom_components/cover_automatic/i18n.py b/custom_components/cover_automatic/i18n.py
new file mode 100644
index 0000000..0dbe536
--- /dev/null
+++ b/custom_components/cover_automatic/i18n.py
@@ -0,0 +1,124 @@
+"""Backend texts that Home Assistant's translation files cannot cover.
+
+Entity names/states, device names, services and exceptions are translated
+through strings.json / translations/*.json. The texts below are generated at
+runtime and stored as plain strings (HA logbook entries, default scenario
+names, device models), so they are localized here using the language
+configured in Home Assistant (hass.config.language).
+"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from homeassistant.core import HomeAssistant
+
+SUPPORTED_LANGUAGES = ("en", "de", "fr")
+
+_TEXTS: dict[str, dict[str, str]] = {
+ "en": {
+ # HA logbook entries
+ "logbook_name": "Cover Automatic",
+ "wind_activated": "wind protection activated ({speed} >= {threshold})",
+ "wind_deactivated": "wind protection deactivated ({speed} <= {threshold})",
+ "wind_disabled": "wind protection deactivated (disabled in settings)",
+ "locked": "locked at {position}% (window open)",
+ "unlocked": "unlocked (window closed)",
+ "paused": "paused for {minutes} min (manual override)",
+ "resumed": "resumed",
+ "resumed_venting": "resumed (venting)",
+ "resumed_match": "resumed: position matches the rule ({rule})",
+ "moved": "moved {from_pos}% -> {to_pos}%",
+ "moved_rule": "moved {from_pos}% -> {to_pos}% (rule: {rule})",
+ # Default scenario names
+ "scenario_everyday": "Everyday",
+ "scenario_summer": "Summer",
+ "scenario_winter": "Winter",
+ "scenario_vacation": "Vacation",
+ "scenario_cinema": "Cinema",
+ "scenario_manual": "Manual",
+ # Device models
+ "model_controller": "Controller",
+ "model_cover": "Cover controller",
+ "no_rule": "No rule",
+ "model_facade": "Facade",
+ },
+ "de": {
+ "logbook_name": "Cover Automatic",
+ "wind_activated": "Windschutz aktiviert ({speed} >= {threshold})",
+ "wind_deactivated": "Windschutz deaktiviert ({speed} <= {threshold})",
+ "wind_disabled": "Windschutz deaktiviert (in den Einstellungen ausgeschaltet)",
+ "locked": "gesperrt bei {position}% (Fenster offen)",
+ "unlocked": "entsperrt (Fenster geschlossen)",
+ "paused": "für {minutes} min pausiert (manuelle Bedienung)",
+ "resumed": "fortgesetzt",
+ "resumed_venting": "fortgesetzt (Lüften)",
+ "resumed_match": "fortgesetzt: Position entspricht der Regel ({rule})",
+ "moved": "gefahren {from_pos}% -> {to_pos}%",
+ "moved_rule": "gefahren {from_pos}% -> {to_pos}% (Regel: {rule})",
+ "scenario_everyday": "Alltag",
+ "scenario_summer": "Sommer",
+ "scenario_winter": "Winter",
+ "scenario_vacation": "Urlaub",
+ "scenario_cinema": "Kino",
+ "scenario_manual": "Manuell",
+ "model_controller": "Steuerung",
+ "model_cover": "Rollladensteuerung",
+ "no_rule": "Keine Regel",
+ "model_facade": "Fassade",
+ },
+ "fr": {
+ "logbook_name": "Cover Automatic",
+ "wind_activated": "protection vent activée ({speed} >= {threshold})",
+ "wind_deactivated": "protection vent désactivée ({speed} <= {threshold})",
+ "wind_disabled": "protection vent désactivée (désactivée dans les réglages)",
+ "locked": "verrouillé à {position} % (fenêtre ouverte)",
+ "unlocked": "déverrouillé (fenêtre fermée)",
+ "paused": "en pause pendant {minutes} min (commande manuelle)",
+ "resumed": "reprise de l'automatisation",
+ "resumed_venting": "reprise de l'automatisation (aération)",
+ "resumed_match": "reprise automatique : position conforme à la règle ({rule})",
+ "moved": "déplacé {from_pos} % -> {to_pos} %",
+ "moved_rule": "déplacé {from_pos} % -> {to_pos} % (règle : {rule})",
+ "scenario_everyday": "Quotidien",
+ "scenario_summer": "Été",
+ "scenario_winter": "Hiver",
+ "scenario_vacation": "Vacances",
+ "scenario_cinema": "Cinéma",
+ "scenario_manual": "Manuel",
+ "model_controller": "Contrôleur",
+ "model_cover": "Contrôleur de volet",
+ "no_rule": "Aucune règle",
+ "model_facade": "Façade",
+ },
+}
+
+# Default scenario ids created on first setup
+DEFAULT_SCENARIO_IDS = ("everyday", "summer", "winter", "vacation", "cinema", "manual")
+
+
+def language(hass: HomeAssistant | None) -> str:
+ """Return the supported language matching the HA configuration (fallback en)."""
+ lang = getattr(getattr(hass, "config", None), "language", None)
+ if not isinstance(lang, str):
+ return "en"
+ lang = lang.lower()
+ if lang in _TEXTS:
+ return lang
+ base = lang.split("-")[0].split("_")[0]
+ return base if base in _TEXTS else "en"
+
+
+def text(hass: HomeAssistant | None, key: str, **params: Any) -> str:
+ """Return the localized text for key, formatted with params."""
+ template = _TEXTS[language(hass)].get(key) or _TEXTS["en"].get(key, key)
+ try:
+ return template.format(**params)
+ except (KeyError, IndexError, ValueError):
+ return template
+
+
+def default_scenario_names(scenario_id: str) -> set[str]:
+ """Return the default names of a built-in scenario in all languages."""
+ key = f"scenario_{scenario_id}"
+ return {texts[key] for texts in _TEXTS.values() if key in texts}
diff --git a/custom_components/cover_automatic/manifest.json b/custom_components/cover_automatic/manifest.json
index 0442a5d..e0c0e69 100644
--- a/custom_components/cover_automatic/manifest.json
+++ b/custom_components/cover_automatic/manifest.json
@@ -1,15 +1,29 @@
{
"domain": "cover_automatic",
"name": "CoverAutomatic",
- "after_dependencies": ["logbook"],
- "codeowners": ["@crandler"],
+ "after_dependencies": [
+ "logbook",
+ "lovelace"
+ ],
+ "codeowners": [
+ "@crandler"
+ ],
"config_flow": true,
- "dependencies": ["frontend", "http", "sun", "websocket_api"],
+ "dependencies": [
+ "frontend",
+ "http",
+ "sun",
+ "websocket_api"
+ ],
"documentation": "https://github.com/crandler/CoverAutomatic",
"integration_type": "service",
"iot_class": "calculated",
"issue_tracker": "https://github.com/crandler/CoverAutomatic/issues",
"requirements": [],
"single_config_entry": true,
+<<<<<<< Updated upstream
"version": "1.62.1"
+=======
+ "version": "2.0.0"
+>>>>>>> Stashed changes
}
diff --git a/custom_components/cover_automatic/models.py b/custom_components/cover_automatic/models.py
index ca01d59..eec92e7 100644
--- a/custom_components/cover_automatic/models.py
+++ b/custom_components/cover_automatic/models.py
@@ -2,12 +2,148 @@
from __future__ import annotations
import logging
+import math
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
+import voluptuous as vol
+
_LOGGER = logging.getLogger(__name__)
+# Upper bound on condition groups per rule (keeps the editor readable).
+MAX_CONDITION_GROUPS = 10
+
+
+def _to_int(
+ value: Any, default: int | None, *, lo: int | None = None, hi: int | None = None
+) -> int | None:
+ """Coerce a stored/imported value to int (clamped), default on garbage.
+
+ Accepts ints, floats and numeric strings ("10", "10.0"); None, booleans
+ and anything unparsable fall back to the default.
+ """
+ if value is None or isinstance(value, bool):
+ return default
+ try:
+ result = int(float(value))
+ except (TypeError, ValueError, OverflowError):
+ return default
+ if lo is not None:
+ result = max(lo, result)
+ if hi is not None:
+ result = min(hi, result)
+ return result
+
+
+def _opt_bool(value: Any) -> bool | None:
+ """Coerce a tri-state flag (None = follow the global setting)."""
+ if value is None or isinstance(value, bool):
+ return value
+ if isinstance(value, (int, float)):
+ return bool(value)
+ if isinstance(value, str):
+ text = value.strip().lower()
+ if text in ("true", "on", "yes", "1"):
+ return True
+ if text in ("false", "off", "no", "0"):
+ return False
+ return None
+
+
+def _to_float(value: Any, default: float | None) -> float | None:
+ """Coerce a stored/imported value to a finite float, default on garbage."""
+ if value is None or isinstance(value, bool):
+ return default
+ try:
+ result = float(value)
+ except (TypeError, ValueError, OverflowError):
+ return default
+ return result if math.isfinite(result) else default
+
+
+def _to_bool(value: Any, default: bool) -> bool:
+ """Coerce a stored/imported flag ("false"/"0" strings are False)."""
+ if value is None:
+ return default
+ if isinstance(value, str):
+ return value.strip().lower() in ("true", "1", "yes", "on")
+ return bool(value)
+
+
+def _entity_id(value: Any) -> str | None:
+ """Coerce an optional entity id (non-empty string, else None)."""
+ return value if isinstance(value, str) and value else None
+
+
+def _str_list(value: Any) -> list[str]:
+ """Coerce a list of ids: only a list/tuple of non-empty strings is kept.
+
+ Anything else (notably a bare string, which list() would split into
+ characters) gives an empty list. Duplicates are dropped, order kept.
+ """
+ if not isinstance(value, (list, tuple)):
+ return []
+ return list(dict.fromkeys(v for v in value if isinstance(v, str) and v))
+
+
+def finite_float(value: Any) -> float:
+ """Voluptuous validator: a finite float (rejects NaN/inf, "nan", booleans)."""
+ if isinstance(value, bool):
+ raise vol.Invalid("expected a number")
+ try:
+ result = float(value)
+ except (TypeError, ValueError, OverflowError) as err:
+ raise vol.Invalid(f"expected a number, got {value!r}") from err
+ if not math.isfinite(result):
+ raise vol.Invalid(f"expected a finite number, got {value!r}")
+ return result
+
+
+def finite_range(min_value: float | None = None, max_value: float | None = None) -> Any:
+ """Validator: finite float within [min_value, max_value]."""
+ return vol.All(finite_float, vol.Range(min=min_value, max=max_value))
+
+
+def int_range(min_value: int | None = None, max_value: int | None = None) -> Any:
+ """Validator: integer (from a finite number) within [min_value, max_value]."""
+ return vol.All(finite_float, vol.Coerce(int), vol.Range(min=min_value, max=max_value))
+
+
+def optional_entity_id(value: Any) -> str | None:
+ """Validate an optional entity id: None/"" -> None, else "domain.object"."""
+ if value is None or value == "":
+ return None
+ if isinstance(value, str):
+ domain, dot, object_id = value.strip().partition(".")
+ if dot and domain and object_id:
+ return value.strip()
+ raise vol.Invalid(f"Invalid entity id: {value!r}")
+
+
+# Condition params read as numbers by the engine: a non-finite value (NaN,
+# inf, "nan") is refused so it can neither be stored nor crash an update.
+_NUMERIC_PARAMS = ("value", "elevation", "temperature", "hysteresis", "delta", "offset")
+
+
+def _check_numeric_params(params: dict[str, Any]) -> None:
+ """Raise ValueError when a numeric condition param is not finite."""
+ for key in _NUMERIC_PARAMS:
+ raw = params.get(key)
+ if raw is None or isinstance(raw, bool):
+ continue
+ try:
+ number = float(raw)
+ except (TypeError, ValueError, OverflowError):
+ continue # not numeric: left to the engine (e.g. an entity state)
+ if not math.isfinite(number):
+ raise ValueError(f"param '{key}' must be a finite number, got {raw!r}")
+
+
+def _position(value: Any, default: int | None) -> int | None:
+ """Coerce a 0-100 position."""
+ return _to_int(value, default, lo=0, hi=100)
+
class CoverStatus(StrEnum):
"""Cover automation status."""
@@ -29,16 +165,26 @@ class ConditionType(StrEnum):
TEMPERATURE_ABOVE = "temperature_above"
TEMPERATURE_BELOW = "temperature_below"
TEMPERATURE_COMFORT = "temperature_comfort"
+ # Outdoor air warmer/cooler than the cover's room (params: operator, delta)
+ OUTDOOR_VS_INDOOR = "outdoor_vs_indoor"
+ # The cover's room is occupied (per-cover occupancy sensor)
+ ROOM_OCCUPIED = "room_occupied"
TIME_BETWEEN = "time_between"
TIME_AFTER_SUNRISE = "time_after_sunrise"
TIME_AFTER_SUNSET = "time_after_sunset"
TIME_BEFORE_SUNRISE = "time_before_sunrise"
TIME_BEFORE_SUNSET = "time_before_sunset"
+ TIME_AFTER_DAWN = "time_after_dawn"
+ TIME_BEFORE_DAWN = "time_before_dawn"
+ TIME_AFTER_DUSK = "time_after_dusk"
+ TIME_BEFORE_DUSK = "time_before_dusk"
STATE_IS = "state_is"
NUMERIC_STATE = "numeric_state"
WEATHER_IS = "weather_is"
DAY_OF_WEEK = "day_of_week"
WORKDAY = "workday"
+ # Native Home Assistant condition (params: {"config": {...}, "yaml": str, "ui": str})
+ HA_CONDITION = "ha_condition"
class ComfortMode(StrEnum):
@@ -57,6 +203,8 @@ class CoverTarget:
tilt_position: int | None = None
rule_id: str | None = None
rule_name: str | None = None
+ # True when the winning rule is a safety rule (overrides pause/manual/wind)
+ safety: bool = False
@dataclass(slots=True)
@@ -86,14 +234,22 @@ def to_dict(self) -> dict[str, Any]:
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Facade:
"""Create from dictionary."""
+ azimuths = []
+ for key in ("azimuth_start", "azimuth_end"):
+ value = _to_float(data[key], None)
+ if value is None:
+ # No sensible default for an orientation: the entry is corrupt
+ raise ValueError(f"{key} must be a finite number, got {data[key]!r}")
+ azimuths.append(value % 360)
+ min_elevation = _to_float(data.get("min_elevation"), 0.0)
return cls(
id=data["id"],
name=data["name"],
- azimuth_start=float(data["azimuth_start"]) % 360,
- azimuth_end=float(data["azimuth_end"]) % 360,
+ azimuth_start=azimuths[0],
+ azimuth_end=azimuths[1],
direction=data.get("direction", "south"),
- min_elevation=float(data.get("min_elevation", 0.0)),
- cover_ids=list(data.get("cover_ids") or []),
+ min_elevation=max(-90.0, min(90.0, min_elevation)),
+ cover_ids=_str_list(data.get("cover_ids")),
)
@@ -103,20 +259,38 @@ class Condition:
type: ConditionType
params: dict[str, Any] = field(default_factory=dict)
+ # Index of the condition group this condition belongs to (0 = first).
+ group: int = 0
+ # Invert the result (NOT). An unavailable input never becomes "met".
+ negate: bool = False
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
- return {"type": self.type.value, "params": self.params}
+ return {
+ "type": self.type.value,
+ "params": self.params,
+ "group": self.group,
+ "negate": self.negate,
+ }
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Condition:
"""Create from dictionary."""
+ if not isinstance(data, dict):
+ raise ValueError(f"condition must be a dict, got {type(data).__name__}")
raw_params = data.get("params", {})
if not isinstance(raw_params, dict):
raise ValueError(f"params must be a dict, got {type(raw_params).__name__}")
+ _check_numeric_params(raw_params)
+ try:
+ group = max(0, int(data.get("group") or 0))
+ except (TypeError, ValueError):
+ group = 0
return cls(
type=ConditionType(data["type"]),
params=dict(raw_params),
+ group=min(group, MAX_CONDITION_GROUPS - 1),
+ negate=_to_bool(data.get("negate"), False),
)
@@ -128,12 +302,42 @@ class Rule:
name: str
enabled: bool = True
priority: int = 10
- condition_operator: str = "and" # "and" or "or"
+ # Operator BETWEEN condition groups ("and"/"or"). With a single group it
+ # is unused: the group's own operator (group_operators[0]) applies.
+ condition_operator: str = "and"
facade_ids: list[str] = field(default_factory=list)
cover_ids: list[str] = field(default_factory=list)
conditions: list[Condition] = field(default_factory=list)
target_position: int = 0
target_tilt_position: int | None = None
+ # Scenarios this rule belongs to (None = all scenarios). Within a member
+ # scenario the rule can still be switched off via Scenario.rules_disabled.
+ scenario_ids: list[str] | None = None
+ # Operator INSIDE each condition group ("and"/"or"), one per group.
+ # None = a single group using condition_operator (pre-groups behaviour).
+ group_operators: list[str] | None = None
+ # Safety rule: also drives covers that are paused, manual, wind protected
+ # or with the automation disabled (never window-locked ones).
+ safety: bool = False
+
+ def effective_group_operators(self) -> list[str]:
+ """Operators per group, falling back to the single-group form."""
+ return list(self.group_operators or [self.condition_operator])
+
+ def condition_groups(self) -> list[tuple[str, list[Condition]]]:
+ """Return (operator, conditions) per group, empty groups skipped."""
+ operators = self.effective_group_operators()
+ count = len(operators)
+ for cond in self.conditions:
+ count = max(count, cond.group + 1)
+ groups: list[tuple[str, list[Condition]]] = []
+ for index in range(count):
+ members = [c for c in self.conditions if c.group == index]
+ if not members:
+ continue
+ op = operators[index] if index < len(operators) else "and"
+ groups.append((op, members))
+ return groups
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
@@ -148,29 +352,74 @@ def to_dict(self) -> dict[str, Any]:
"conditions": [c.to_dict() for c in self.conditions],
"target_position": self.target_position,
"target_tilt_position": self.target_tilt_position,
+ "scenario_ids": self.scenario_ids,
+ "group_operators": self.effective_group_operators(),
+ "safety": self.safety,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Rule:
"""Create from dictionary."""
conditions: list[Condition] = []
- for c in data.get("conditions", []):
+ dropped = 0
+ raw_conditions = data.get("conditions")
+ for c in raw_conditions if isinstance(raw_conditions, list) else []:
try:
conditions.append(Condition.from_dict(c))
except (ValueError, KeyError) as err:
+ dropped += 1
_LOGGER.warning("Skipping invalid condition in rule '%s': %s", data.get("name", "?"), err)
operator = data.get("condition_operator", "and")
+ if operator not in ("and", "or"):
+ operator = "and"
+ raw_groups = data.get("group_operators")
+ if isinstance(raw_groups, list) and raw_groups:
+ group_operators = [
+ op if op in ("and", "or") else "and"
+ for op in raw_groups[:MAX_CONDITION_GROUPS]
+ ]
+ else:
+ # Before condition groups: one group using the rule's operator.
+ group_operators = [operator]
+ facade_ids = _str_list(data.get("facade_ids"))
+ cover_ids = _str_list(data.get("cover_ids"))
+ enabled = _to_bool(data.get("enabled"), True)
+ if enabled and not (facade_ids or cover_ids) and (
+ data.get("facade_ids") or data.get("cover_ids")
+ ):
+ # Unusable assignments must not turn the rule into a global one
+ _LOGGER.warning(
+ "Rule '%s' has invalid cover/facade references and was disabled",
+ data.get("name", "?"),
+ )
+ enabled = False
+ if enabled and dropped:
+ # Without the dropped condition(s) the rule would match more
+ # often than configured -- always, when none is left. Keep it
+ # (the user can fix it) but disabled. A rule configured without
+ # conditions is not concerned (dropped == 0).
+ _LOGGER.warning(
+ "Rule '%s' had %d invalid condition(s) and was disabled",
+ data.get("name", "?"), dropped,
+ )
+ enabled = False
+ # Missing or not a list (never split a string): every scenario
+ raw_scenarios = data.get("scenario_ids")
+ scenario_ids = _str_list(raw_scenarios) if isinstance(raw_scenarios, list) else None
return cls(
id=data["id"],
name=data["name"],
- enabled=data.get("enabled", True),
- priority=data.get("priority", 10),
- condition_operator=operator if operator in ("and", "or") else "and",
- facade_ids=list(data.get("facade_ids") or []),
- cover_ids=list(data.get("cover_ids") or []),
+ enabled=enabled,
+ priority=_to_int(data.get("priority"), 10),
+ condition_operator=operator,
+ facade_ids=facade_ids,
+ cover_ids=cover_ids,
conditions=conditions,
- target_position=data.get("target_position", 0),
- target_tilt_position=data.get("target_tilt_position"),
+ target_position=_position(data.get("target_position"), 0),
+ target_tilt_position=_position(data.get("target_tilt_position"), None),
+ scenario_ids=scenario_ids,
+ group_operators=group_operators,
+ safety=_to_bool(data.get("safety"), False),
)
@@ -199,7 +448,7 @@ def from_dict(cls, data: dict[str, Any]) -> Scenario:
id=data["id"],
name=data["name"],
icon=data.get("icon", "mdi:home"),
- rules_disabled=list(data.get("rules_disabled") or []),
+ rules_disabled=_str_list(data.get("rules_disabled")),
)
@@ -226,10 +475,28 @@ class CoverConfig:
indoor_temp_sensor: str | None = None
comfort_temp_min: float | None = None
comfort_temp_max: float | None = None
- preemptive_shading: bool = True
+ # Entities (input_number/number/sensor) overriding the comfort band
+ comfort_temp_min_entity: str | None = None
+ comfort_temp_max_entity: str | None = None
+ # Sun-on-facade comfort switches: None = follow the global setting
+ preemptive_shading: bool | None = None
+ sun_heating_ignore: bool | None = None
+ sun_neutral_ignore: bool | None = None
+ # End a manual pause once the cover is back at its rule's position
+ # (None = follow the global setting)
+ pause_resume_on_match: bool | None = None
+ # Window open: keep the current position (True) instead of moving to lock_position
+ lock_hold_position: bool = False
+ # Room occupancy (condition "room occupied"): entity and the states that
+ # mean occupied (comma-separated; None = usual defaults, e.g. on/home/Présent)
+ occupancy_sensor: str | None = None
+ occupancy_states: str | None = None
min_position_change: int | None = None
min_time_between_changes: int | None = None
last_position_change: float | None = None
+ # Full 0-100 % travel time in seconds: configured, and learned from moves
+ travel_time: int | None = None
+ measured_travel_time: float | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
@@ -253,10 +520,20 @@ def to_dict(self) -> dict[str, Any]:
"indoor_temp_sensor": self.indoor_temp_sensor,
"comfort_temp_min": self.comfort_temp_min,
"comfort_temp_max": self.comfort_temp_max,
+ "comfort_temp_min_entity": self.comfort_temp_min_entity,
+ "comfort_temp_max_entity": self.comfort_temp_max_entity,
"preemptive_shading": self.preemptive_shading,
+ "sun_heating_ignore": self.sun_heating_ignore,
+ "sun_neutral_ignore": self.sun_neutral_ignore,
+ "pause_resume_on_match": self.pause_resume_on_match,
+ "lock_hold_position": self.lock_hold_position,
+ "occupancy_sensor": self.occupancy_sensor,
+ "occupancy_states": self.occupancy_states,
"min_position_change": self.min_position_change,
"min_time_between_changes": self.min_time_between_changes,
"last_position_change": self.last_position_change,
+ "travel_time": self.travel_time,
+ "measured_travel_time": self.measured_travel_time,
}
@classmethod
@@ -279,25 +556,37 @@ def from_dict(cls, data: dict[str, Any]) -> CoverConfig:
return cls(
entity_id=data["entity_id"],
name=data["name"],
- facade_id=data.get("facade_id"),
- auto_enabled=data.get("auto_enabled", True),
- pause_duration=data.get("pause_duration"),
+ facade_id=_entity_id(data.get("facade_id")),
+ auto_enabled=_to_bool(data.get("auto_enabled"), True),
+ # Minimum 1 minute (a 0 stored by an older version becomes 1)
+ pause_duration=_to_int(data.get("pause_duration"), None, lo=1),
status=status,
- pause_until=data.get("pause_until"),
- lock_sensor=data.get("lock_sensor"),
- lock_position=data.get("lock_position"),
- vent_sensor=data.get("vent_sensor"),
- vent_position=data.get("vent_position"),
- inverted=data.get("inverted", False),
- supports_tilt=data.get("supports_tilt", False),
- lock_tilt_position=data.get("lock_tilt_position"),
- vent_tilt_position=data.get("vent_tilt_position"),
- inverted_tilt=data.get("inverted_tilt", False),
- indoor_temp_sensor=data.get("indoor_temp_sensor"),
- comfort_temp_min=data.get("comfort_temp_min"),
- comfort_temp_max=data.get("comfort_temp_max"),
- preemptive_shading=data.get("preemptive_shading", True),
- min_position_change=data.get("min_position_change"),
- min_time_between_changes=data.get("min_time_between_changes"),
- last_position_change=data.get("last_position_change"),
+ pause_until=_to_float(data.get("pause_until"), None),
+ lock_sensor=_entity_id(data.get("lock_sensor")),
+ lock_position=_position(data.get("lock_position"), None),
+ vent_sensor=_entity_id(data.get("vent_sensor")),
+ vent_position=_position(data.get("vent_position"), None),
+ inverted=_to_bool(data.get("inverted"), False),
+ supports_tilt=_to_bool(data.get("supports_tilt"), False),
+ lock_tilt_position=_position(data.get("lock_tilt_position"), None),
+ vent_tilt_position=_position(data.get("vent_tilt_position"), None),
+ inverted_tilt=_to_bool(data.get("inverted_tilt"), False),
+ indoor_temp_sensor=_entity_id(data.get("indoor_temp_sensor")),
+ comfort_temp_min=_to_float(data.get("comfort_temp_min"), None),
+ comfort_temp_max=_to_float(data.get("comfort_temp_max"), None),
+ comfort_temp_min_entity=_entity_id(data.get("comfort_temp_min_entity")),
+ comfort_temp_max_entity=_entity_id(data.get("comfort_temp_max_entity")),
+ preemptive_shading=_opt_bool(data.get("preemptive_shading")),
+ sun_heating_ignore=_opt_bool(data.get("sun_heating_ignore")),
+ sun_neutral_ignore=_opt_bool(data.get("sun_neutral_ignore")),
+ pause_resume_on_match=_opt_bool(data.get("pause_resume_on_match")),
+ lock_hold_position=_to_bool(data.get("lock_hold_position"), False),
+ occupancy_sensor=_entity_id(data.get("occupancy_sensor")),
+ occupancy_states=(str(data["occupancy_states"]).strip() or None)
+ if data.get("occupancy_states") is not None else None,
+ min_position_change=_to_int(data.get("min_position_change"), None, lo=0, hi=100),
+ min_time_between_changes=_to_int(data.get("min_time_between_changes"), None, lo=0),
+ last_position_change=_to_float(data.get("last_position_change"), None),
+ travel_time=_to_int(data.get("travel_time"), None, lo=0),
+ measured_travel_time=_to_float(data.get("measured_travel_time"), None),
)
diff --git a/custom_components/cover_automatic/panel/cover-automatic-card.js b/custom_components/cover_automatic/panel/cover-automatic-card.js
new file mode 100644
index 0000000..424bb09
--- /dev/null
+++ b/custom_components/cover_automatic/panel/cover-automatic-card.js
@@ -0,0 +1,656 @@
+/*
+ * CoverAutomatic dashboard card.
+ *
+ * type: custom:cover-automatic-card
+ * title: Volets # optional
+ * covers: # optional, default: every managed cover
+ * - cover.volet_du_salon
+ * show_rule: true # optional
+ * show_auto: true # optional (automation switch + resume button)
+ *
+ * Everything comes from the per-cover "Status" sensors of the integration
+ * (their attributes carry the rule, target, pause end and the automation
+ * switch), plus the live cover state for the position. No websocket calls,
+ * so it works for non-admin users too.
+ */
+(() => {
+ const CARD_TAG = "cover-automatic-card";
+ const EDITOR_TAG = "cover-automatic-card-editor";
+ if (customElements.get(CARD_TAG)) return;
+
+ const I18N = {
+ en: {
+ no_covers: "No CoverAutomatic cover found. Is the integration set up (version 1.79 or later)?",
+ no_rule: "No rule",
+ resume: "Resume automation",
+ auto: "Automation",
+ safety: "Safety rule",
+ target: "Target",
+ remaining: "{m} min left",
+ inverted: "Inverted cover: HA reports {raw}%",
+ ed_title: "Title",
+ ed_covers: "Covers (empty = all)",
+ ed_show_rule: "Show the active rule",
+ ed_show_auto: "Show the automation switch and the resume button",
+ desc: "Covers managed by CoverAutomatic: position, active rule and status.",
+ scenario: "Scenario",
+ master: "Automation",
+ resume_all: "Resume all",
+ resume_all_title: "End every pause (covers moved by hand)",
+ wind: "Wind",
+ wind_active: "Wind protection",
+ paused: "paused",
+ manual: "manual",
+ locked: "locked",
+ render_error: "Display error, retrying…",
+ ed_show_header: "Show the global part (scenario, automation, statuses)",
+ missing: "Covers not found (not managed by CoverAutomatic?): {ids}",
+ },
+ de: {
+ no_covers: "Kein CoverAutomatic-Rollladen gefunden. Ist die Integration eingerichtet (ab Version 1.79)?",
+ no_rule: "Keine Regel",
+ resume: "Automatik fortsetzen",
+ auto: "Automatik",
+ safety: "Sicherheitsregel",
+ target: "Ziel",
+ remaining: "noch {m} Min.",
+ inverted: "Invertierter Rollladen: HA meldet {raw} %",
+ ed_title: "Titel",
+ ed_covers: "Rollläden (leer = alle)",
+ ed_show_rule: "Aktive Regel anzeigen",
+ ed_show_auto: "Automatik-Schalter und Fortsetzen-Knopf anzeigen",
+ desc: "Von CoverAutomatic gesteuerte Rollläden: Position, aktive Regel und Status.",
+ scenario: "Szenario",
+ master: "Automatik",
+ resume_all: "Alle fortsetzen",
+ resume_all_title: "Alle Pausen beenden (von Hand bewegte Rollläden)",
+ wind: "Wind",
+ wind_active: "Windschutz",
+ paused: "pausiert",
+ manual: "manuell",
+ locked: "gesperrt",
+ render_error: "Anzeigefehler, neuer Versuch…",
+ ed_show_header: "Globalen Teil anzeigen (Szenario, Automatik, Status)",
+ missing: "Rollläden nicht gefunden (nicht von CoverAutomatic gesteuert?): {ids}",
+ },
+ fr: {
+ no_covers: "Aucun volet CoverAutomatic trouvé. L'intégration est-elle installée (version 1.79 ou plus) ?",
+ no_rule: "Aucune règle",
+ resume: "Reprendre l'automatisation",
+ auto: "Automatisation",
+ safety: "Règle de sécurité",
+ target: "Cible",
+ remaining: "encore {m} min",
+ inverted: "Volet inversé : HA indique {raw} %",
+ ed_title: "Titre",
+ ed_covers: "Volets (vide = tous)",
+ ed_show_rule: "Afficher la règle active",
+ ed_show_auto: "Afficher l'interrupteur d'automatisation et le bouton de reprise",
+ desc: "Volets gérés par CoverAutomatic : position, règle active et statut.",
+ scenario: "Scénario",
+ master: "Automatisation",
+ resume_all: "Tout reprendre",
+ resume_all_title: "Lever toutes les pauses (volets bougés à la main)",
+ wind: "Vent",
+ wind_active: "Protection vent",
+ paused: "en pause",
+ manual: "en manuel",
+ locked: "verrouillé(s)",
+ render_error: "Erreur d'affichage, nouvel essai…",
+ ed_show_header: "Afficher la partie globale (scénario, automatisation, statuts)",
+ missing: "Volets introuvables (non gérés par CoverAutomatic\u00A0?)\u00A0: {ids}",
+ },
+ };
+
+ const lang = (hass) => {
+ const l = ((hass && (hass.locale?.language || hass.language)) || "en").slice(0, 2);
+ return I18N[l] ? l : "en";
+ };
+ const t = (hass, key) => I18N[lang(hass)][key] ?? I18N.en[key] ?? key;
+ const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
+
+ const STATUS_COLORS = {
+ auto: "var(--success-color, #43a047)",
+ paused: "var(--warning-color, #ff9800)",
+ manual: "var(--secondary-text-color, #727272)",
+ locked: "var(--error-color, #db4437)",
+ venting: "var(--info-color, #039be5)",
+ wind_protected: "var(--info-color, #039be5)",
+ };
+
+ // Candidate sensor ids, cached per registry object (hass.entities only
+ // changes when the entity registry does) instead of scanning every state
+ // on every update.
+ const _candidateCache = new WeakMap();
+ function candidateIds(hass) {
+ if (hass.entities) {
+ let ids = _candidateCache.get(hass.entities);
+ if (!ids) {
+ ids = Object.values(hass.entities)
+ .filter((e) => e && e.platform === "cover_automatic" && String(e.entity_id).startsWith("sensor."))
+ .map((e) => e.entity_id);
+ _candidateCache.set(hass.entities, ids);
+ }
+ return ids;
+ }
+ // Older frontends without the registry map: scan the states
+ return Object.keys(hass.states).filter((eid) => eid.startsWith("sensor."));
+ }
+
+ // Status sensors of the integration, keyed by cover entity id.
+ function findStatusSensors(hass) {
+ const out = {};
+ if (!hass || !hass.states) return out;
+ for (const eid of candidateIds(hass)) {
+ const st = hass.states[eid];
+ const cover = st && st.attributes && st.attributes.cover_entity_id;
+ if (cover) out[cover] = st;
+ }
+ return out;
+ }
+
+ // Global entities of the integration, keyed by their translation key
+ const _globalCache = new WeakMap();
+ const GLOBAL_KEYS = {
+ scenario: "scenario", master_enabled: "master", wind_protection: "wind",
+ covers_paused: "paused", covers_manual: "manual", covers_locked: "locked",
+ };
+ function findGlobals(hass) {
+ if (!hass || !hass.entities) return {};
+ let out = _globalCache.get(hass.entities);
+ if (!out) {
+ out = {};
+ for (const e of Object.values(hass.entities)) {
+ if (!e || e.platform !== "cover_automatic") continue;
+ const key = GLOBAL_KEYS[e.translation_key];
+ if (key) out[key] = e.entity_id;
+ }
+ _globalCache.set(hass.entities, out);
+ }
+ return out;
+ }
+
+ const num = (v) => (v == null || v === "" || isNaN(Number(v)) ? null : Math.round(Number(v)));
+
+ class CoverAutomaticCard extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ this._config = {};
+ this._hass = null;
+ this._rowSigs = new Map();
+ this._skeleton = "";
+ this._timer = null;
+ this.shadowRoot.addEventListener("click", (e) => this._onClick(e));
+ this.shadowRoot.addEventListener("change", (e) => this._onChange(e));
+ }
+
+ static getConfigElement() {
+ return document.createElement(EDITOR_TAG);
+ }
+
+ static getStubConfig() {
+ return { show_header: true, show_rule: true, show_auto: true };
+ }
+
+ // Never throws on a questionable option (a thrown error shows a red
+ // "Configuration error" card): odd values fall back to the defaults.
+ setConfig(config) {
+ const c = config && typeof config === "object" ? { ...config } : {};
+ if (c.covers != null && !Array.isArray(c.covers)) c.covers = typeof c.covers === "string" ? [c.covers] : undefined;
+ this._config = { show_header: true, show_rule: true, show_auto: true, ...c };
+ this._skeleton = "";
+ this._render();
+ }
+
+ set hass(hass) {
+ this._hass = hass;
+ this._render();
+ }
+
+ // A failed render (unexpected state while HA reconnects...) must not
+ // leave a broken card: show a short notice and retry on the next update.
+ _render() {
+ try {
+ this._renderInner();
+ } catch (err) {
+ console.error("cover-automatic-card:", err);
+ this._skeleton = "";
+ if (this.shadowRoot) {
+ this.shadowRoot.innerHTML = `