diff --git a/README.md b/README.md index 72b3db3..ab9880d 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,10 @@ disabled.) |---|---| | `select._shade_mode` | current mode — **the only thing policy writes** | | `sensor._glare_position` | calculator output; attrs: `gamma`, `profile_angle`, `sun_in_window`, `constraint` (`direct` / `reflected` / `none` — what bound the position) | -| `sensor._shade_target` | what the actuator wants; attrs: `mode`, `last_decision` (`command` / `in_sync` / `rate_limited` / `hold_active`), `hold_until`, `last_command` | +| `sensor._shade_target` | what the actuator wants; attrs: `zone_id`, `covers`, `enabled`, `mode`, `last_decision` (`command` / `in_sync` / `rate_limited` / `hold_active` / `disabled`), `hold_until`, `last_command` | | `binary_sensor._sun_in_window` | direct sun geometrically possible now | -| `binary_sensor._shade_hold` | a human moved a cover; engine is standing down | +| `binary_sensor._shade_hold` | a human moved a cover; engine is standing down; attr `hold_until` | +| `switch._shade_control` | master on/off for the zone — off means the engine never commands these covers (no expiry, survives restarts); turning it back on reconciles immediately | ## Services @@ -155,6 +156,47 @@ disabled.) | `shade_engine.release` | clear a hold and reconcile — use in automations that must win over a manual move (e.g. privacy close at dusk) | | `shade_engine.reconcile` | evaluate immediately, bypassing rate limit (`zone` optional) | +## Dashboard card + +The integration bundles a Lovelace card and registers it as a frontend +resource automatically — no HACS frontend install, no manual resource entry. +Add it to any dashboard: + +```yaml +type: custom:shade-engine-card +entity: sensor.kitchen_shade_target +``` + +or, equivalently, by zone id from your YAML config: + +```yaml +type: custom:shade-engine-card +zone: kitchen +``` + +One card per zone shows: + +- **Target / current / glare** positions side by side (current reads the + covers live; multiple covers show as `42 / 40`). +- **Mode chips** — every configured mode, tap to switch (writes the same + `select` your automations do). +- **Sun-in-window** indicator in the header. +- **Manual hold banner** with a live countdown to `hold_until` and a + **Release** button (`shade_engine.release`); when no hold is active, a + **Hold** button pauses the zone for its configured `hold_duration`. +- **Control toggle** — the zone's `switch._shade_control`; off greys + the card and the engine stands down entirely. +- A status badge explaining the last decision (`In sync`, `Rate limited — + retrying`, `Manual hold`, `Control off`, `Moving`). + +All sibling entities are derived from the target sensor's object-id prefix. +If you've renamed entities, point the card at them explicitly with +`mode_entity`, `hold_entity`, `sun_entity`, `glare_entity`, and +`switch_entity`; `title` overrides the header. + +The card appears in the dashboard card picker as **Shade Engine Card** +(after one browser refresh following installation or upgrade). + ## Behavior guarantees - **Deferred, never dropped.** A move suppressed by the rate limit or @@ -165,6 +207,10 @@ disabled.) `binary_sensor` with a `hold_until` timestamp; `shade_engine.release` clears it. A `forced` evaluation (mode change, reconcile service) bypasses rate limiting but **never** bypasses a hold. +- **Off means off.** `switch._shade_control` is a hard gate: while it + is off the engine never commands the zone's covers, manual moves are + adopted silently (no hold), and nothing — not even a forced reconcile — + overrides it. It restores across restarts. - **Every non-move is explained.** `sensor._shade_target` always says why the engine last declined to act. diff --git a/custom_components/shade_engine/__init__.py b/custom_components/shade_engine/__init__.py index 9bd3eae..33bd38b 100644 --- a/custom_components/shade_engine/__init__.py +++ b/custom_components/shade_engine/__init__.py @@ -12,9 +12,12 @@ import logging import math +from pathlib import Path import voluptuous as vol +from homeassistant.components.frontend import add_extra_js_url +from homeassistant.components.http import StaticPathConfig from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, @@ -30,6 +33,7 @@ async_track_time_interval, ) from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import async_get_integration from homeassistant.util import dt as dt_util from datetime import timedelta @@ -80,11 +84,19 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.SELECT] +PLATFORMS = [ + Platform.SENSOR, + Platform.BINARY_SENSOR, + Platform.SELECT, + Platform.SWITCH, +] SUN_ENTITY = "sun.sun" TICK_INTERVAL = timedelta(seconds=60) +CARD_FILENAME = "shade-engine-card.js" +CARD_URL = f"/{DOMAIN}/{CARD_FILENAME}" + def _mode_target(value): """Validate one mode's target: an int, "glare", or a clamp mapping.""" @@ -411,6 +423,25 @@ def restore_mode(self, zone_id: str, mode: str) -> None: if mode in zone.core.modes: zone.core.mode = mode + async def async_set_enabled(self, zone_id: str, enabled: bool) -> None: + """Turn the engine on or off for one zone. + + Re-enabling reconciles immediately (bypassing the rate limit, but + never a hold) so the zone converges without waiting for a tick. + """ + zone = self.zones[zone_id] + if zone.core.enabled == enabled: + return + zone.core.enabled = enabled + _LOGGER.info("[%s] control %s", zone_id, "enabled" if enabled else "disabled") + async_dispatcher_send(self.hass, signal_zone_update(zone_id)) + await self._evaluate(zone, forced=enabled) + + @callback + def restore_enabled(self, zone_id: str, enabled: bool) -> None: + """Adopt a restored on/off state at startup without commanding.""" + self.zones[zone_id].core.enabled = enabled + def _schedule_hold_expiry(self, zone: Zone) -> None: if (timer := self._hold_timers.pop(zone.zone_id, None)) is not None: timer() @@ -449,6 +480,22 @@ async def async_reconcile(self, zone_id: str | None) -> None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Shade Engine from YAML configuration.""" + # Serve the bundled Lovelace card and register it as a frontend resource + # so `custom:shade-engine-card` works with zero manual resource setup. + # Registered even when the YAML is gone, so existing dashboards degrade + # to the card's own "entity not found" message rather than a red box. + await hass.http.async_register_static_paths( + [ + StaticPathConfig( + CARD_URL, + str(Path(__file__).parent / "www" / CARD_FILENAME), + cache_headers=True, + ) + ] + ) + integration = await async_get_integration(hass, DOMAIN) + add_extra_js_url(hass, f"{CARD_URL}?v={integration.version}") + conf = config.get(DOMAIN) if conf is None: # YAML was removed; drop the imported entry so entities don't linger. diff --git a/custom_components/shade_engine/core.py b/custom_components/shade_engine/core.py index 4960707..1eaea34 100644 --- a/custom_components/shade_engine/core.py +++ b/custom_components/shade_engine/core.py @@ -16,6 +16,7 @@ # Reasons an evaluation may decline to command. Exposed on the target sensor # so "why didn't it move?" is answerable from the UI. REASON_COMMAND = "command" +REASON_DISABLED = "disabled" REASON_HOLD = "hold_active" REASON_IN_SYNC = "in_sync" REASON_RATE_LIMITED = "rate_limited" @@ -68,6 +69,7 @@ class ZoneCore: modes: dict[str, ModeTarget] motion: MotionConfig mode: str + enabled: bool = True last_commanded: dict[str, int] = field(default_factory=dict) last_command_ts: float | None = None hold_until: float | None = None @@ -117,6 +119,11 @@ def report_position(self, cover: str, position: int, now: float) -> bool: return False # A human moved this cover: adopt their position and stand down. self.last_commanded[cover] = position + if not self.enabled: + # Control is off; the engine wasn't going to move anyway, so a + # manual move needs no hold. Adopting the baseline above keeps a + # later re-enable from misreading this position as manual. + return False self.start_hold(now) return True @@ -133,7 +140,7 @@ def evaluate( ``current`` maps cover -> reported position (None when unavailable). ``forced`` bypasses rate limiting (mode changes, explicit services) - but never bypasses an active hold. + but never bypasses an active hold or a disabled zone. """ # Adopt baselines for covers we have never commanded. Without this, # the first manual move after startup is mistaken for the baseline in @@ -144,6 +151,9 @@ def evaluate( if position is not None: self.last_commanded.setdefault(cover, position) + if not self.enabled: + return Decision(REASON_DISABLED) + if self.hold_active(now): return Decision(REASON_HOLD) diff --git a/custom_components/shade_engine/manifest.json b/custom_components/shade_engine/manifest.json index b52b309..cbf0fd3 100644 --- a/custom_components/shade_engine/manifest.json +++ b/custom_components/shade_engine/manifest.json @@ -3,12 +3,12 @@ "name": "Shade Engine", "codeowners": ["@vfilby"], "config_flow": true, - "dependencies": ["sun"], + "dependencies": ["frontend", "http", "sun"], "documentation": "https://github.com/vfilby/shade-engine", "integration_type": "hub", "iot_class": "calculated", "issue_tracker": "https://github.com/vfilby/shade-engine/issues", "requirements": [], "single_config_entry": true, - "version": "0.3.0" + "version": "0.4.0" } diff --git a/custom_components/shade_engine/sensor.py b/custom_components/shade_engine/sensor.py index 91fdc21..a8add24 100644 --- a/custom_components/shade_engine/sensor.py +++ b/custom_components/shade_engine/sensor.py @@ -93,6 +93,11 @@ def extra_state_attributes(self) -> dict: core = self._zone.core decision = self._zone.last_decision return { + # zone_id and covers let the bundled Lovelace card find the zone + # (service calls take the zone id) and show live cover positions. + "zone_id": self._zone.zone_id, + "covers": list(core.covers), + "enabled": core.enabled, "mode": core.mode, "last_decision": decision.reason if decision else None, "hold_until": ( diff --git a/custom_components/shade_engine/switch.py b/custom_components/shade_engine/switch.py new file mode 100644 index 0000000..338d549 --- /dev/null +++ b/custom_components/shade_engine/switch.py @@ -0,0 +1,72 @@ +"""Per-zone control switch: is the engine allowed to move this zone at all.""" + +from __future__ import annotations + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import DOMAIN, signal_zone_update +from .entity import zone_device_info + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + engine = hass.data[DOMAIN] + async_add_entities( + ShadeControlSwitch(engine, zone) for zone in engine.zones.values() + ) + + +class ShadeControlSwitch(SwitchEntity, RestoreEntity): + """Off means the engine never commands this zone's covers. + + Unlike a hold this has no expiry; it survives restarts. Turning it back + on reconciles immediately (bypassing the rate limit, never a hold). + """ + + _attr_should_poll = False + _attr_has_entity_name = True + _attr_icon = "mdi:robot" + + def __init__(self, engine, zone) -> None: + self._engine = engine + self._zone = zone + self._attr_unique_id = f"{DOMAIN}_{zone.zone_id}_control" + self._attr_name = "Shade control" + self._attr_device_info = zone_device_info(zone) + + @property + def is_on(self) -> bool: + return self._zone.core.enabled + + async def async_turn_on(self, **kwargs) -> None: + await self._engine.async_set_enabled(self._zone.zone_id, True) + + async def async_turn_off(self, **kwargs) -> None: + await self._engine.async_set_enabled(self._zone.zone_id, False) + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + if (last := await self.async_get_last_state()) is not None: + self._engine.restore_enabled( + self._zone.zone_id, last.state != STATE_OFF + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + signal_zone_update(self._zone.zone_id), + self._handle_update, + ) + ) + + @callback + def _handle_update(self) -> None: + self.async_write_ha_state() diff --git a/custom_components/shade_engine/www/shade-engine-card.js b/custom_components/shade_engine/www/shade-engine-card.js new file mode 100644 index 0000000..33b85a5 --- /dev/null +++ b/custom_components/shade_engine/www/shade-engine-card.js @@ -0,0 +1,323 @@ +/* Shade Engine Lovelace card. + * + * Served by the integration at /shade_engine/shade-engine-card.js and + * auto-registered as a frontend resource — no manual resource setup. + * + * Minimal config (everything else is derived from the target sensor): + * type: custom:shade-engine-card + * entity: sensor.kitchen_shade_target + * or: + * type: custom:shade-engine-card + * zone: kitchen + * + * Optional overrides: title, mode_entity, hold_entity, sun_entity, + * glare_entity, switch_entity (for renamed entities). + */ + +const CARD_VERSION = "0.4.0"; + +const REASON_LABELS = { + command: ["Moving", "accent"], + in_sync: ["In sync", "ok"], + rate_limited: ["Rate limited — retrying", "warn"], + hold_active: ["Manual hold", "hold"], + disabled: ["Control off", "muted"], + no_target: ["No target", "muted"], +}; + +class ShadeEngineCard extends HTMLElement { + setConfig(config) { + if (!config.entity && !config.zone) { + throw new Error("shade-engine-card: set `entity` (the shade_target sensor) or `zone`"); + } + this._config = config; + this._targetId = config.entity || null; + this._rendered = null; + } + + getCardSize() { + return 3; + } + + static getStubConfig(hass) { + const id = Object.keys(hass.states).find( + (k) => k.startsWith("sensor.") && hass.states[k].attributes.zone_id !== undefined + ); + return id ? { entity: id } : { zone: "kitchen" }; + } + + set hass(hass) { + this._hass = hass; + if (!this._config) return; + if (!this._targetId) this._resolveTarget(); + const watched = this._watchedIds(); + const snapshot = watched.map((id) => hass.states[id]); + if (this._rendered && snapshot.every((s, i) => s === this._rendered[i])) { + return; // nothing this card shows has changed + } + this._rendered = snapshot; + this._render(); + } + + connectedCallback() { + if (this._hass && this._config) this._render(); + } + + disconnectedCallback() { + this._stopCountdown(); + } + + // -- entity resolution ---------------------------------------------------- + + _resolveTarget() { + const zone = this._config.zone; + this._targetId = + Object.keys(this._hass.states).find( + (k) => + k.startsWith("sensor.") && + this._hass.states[k].attributes.zone_id === zone && + this._hass.states[k].attributes.covers !== undefined + ) || null; + } + + _ids() { + const c = this._config; + // All of a zone's entities share the object_id prefix derived from the + // device name ("Kitchen Shade target" -> kitchen_shade_target), so + // siblings are reachable by swapping the suffix. + const prefix = (this._targetId || "sensor.unknown_shade_target") + .replace(/^sensor\./, "") + .replace(/_shade_target$/, ""); + return { + target: this._targetId, + mode: c.mode_entity || `select.${prefix}_shade_mode`, + hold: c.hold_entity || `binary_sensor.${prefix}_shade_hold`, + sun: c.sun_entity || `binary_sensor.${prefix}_sun_in_window`, + glare: c.glare_entity || `sensor.${prefix}_glare_position`, + control: c.switch_entity || `switch.${prefix}_shade_control`, + }; + } + + _watchedIds() { + if (!this._targetId) return []; + const ids = Object.values(this._ids()); + const target = this._hass.states[this._targetId]; + return ids.concat(target ? target.attributes.covers || [] : []); + } + + // -- rendering ------------------------------------------------------------ + + _render() { + const hass = this._hass; + if (!this._targetId || !hass.states[this._targetId]) { + this._renderShell( + `
Shade Engine zone not found` + + (this._config.zone ? ` (zone: ${this._config.zone})` : ` (${this._config.entity})`) + + `. Is the integration loaded?
` + ); + return; + } + + const ids = this._ids(); + const target = hass.states[ids.target]; + const mode = hass.states[ids.mode]; + const hold = hass.states[ids.hold]; + const sun = hass.states[ids.sun]; + const glare = hass.states[ids.glare]; + const control = hass.states[ids.control]; + const attrs = target.attributes; + const enabled = control ? control.state === "on" : attrs.enabled !== false; + const holdActive = enabled && hold && hold.state === "on"; + const reason = !enabled ? "disabled" : holdActive ? "hold_active" : attrs.last_decision; + const [reasonText, reasonClass] = REASON_LABELS[reason] || ["Waiting", "muted"]; + const sunOn = sun ? sun.state === "on" : glare && glare.attributes.sun_in_window; + + const covers = attrs.covers || []; + const positions = covers + .map((id) => hass.states[id]) + .map((s) => + s && s.attributes.current_position != null ? Math.round(s.attributes.current_position) : null + ); + const known = positions.filter((p) => p !== null); + const current = known.length ? known.join(" / ") : "—"; + + const title = + this._config.title || + (attrs.friendly_name || "").replace(/ Shade target$/i, "") || + this._prettify(attrs.zone_id); + + const chips = mode + ? (mode.attributes.options || []) + .map( + (o) => + `` + ) + .join("") + : ""; + + const holdUntil = (hold && hold.attributes.hold_until) || attrs.hold_until; + const holdBanner = holdActive + ? `
+ + Manual hold · ${this._remaining(holdUntil)} left + +
` + : ""; + + this._renderShell(` +
+
${title}
+ + +
+
+
+
${target.state}%
target
+
${current}
current
+
${glare ? glare.state + "%" : "—"}
glare
+
+ ${chips ? `
${chips}
` : ""} +
+
+ ${reasonText} + ${holdActive || !enabled ? "" : ``} +
+ ${holdBanner} + `); + + this._wire(ids, attrs); + if (holdActive && holdUntil) this._startCountdown(holdUntil); + else this._stopCountdown(); + } + + _renderShell(inner) { + this.innerHTML = ` + + + ${inner} + + `; + } + + _wire(ids, attrs) { + const zone = attrs.zone_id || this._config.zone; + this.querySelectorAll(".chip").forEach((chip) => + chip.addEventListener("click", () => + this._hass.callService("select", "select_option", { + entity_id: ids.mode, + option: chip.dataset.mode, + }) + ) + ); + const control = this.querySelector('[data-action="control"]'); + if (control) + control.addEventListener("change", () => + this._hass.callService("switch", control.checked ? "turn_on" : "turn_off", { + entity_id: ids.control, + }) + ); + const release = this.querySelector('[data-action="release"]'); + if (release) + release.addEventListener("click", () => + this._hass.callService("shade_engine", "release", { zone }) + ); + const holdBtn = this.querySelector('[data-action="hold"]'); + if (holdBtn) + holdBtn.addEventListener("click", () => + this._hass.callService("shade_engine", "hold", { zone }) + ); + } + + // -- hold countdown ------------------------------------------------------- + + _remaining(iso) { + const secs = Math.max(0, Math.round((new Date(iso).getTime() - Date.now()) / 1000)); + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + const s = secs % 60; + const mm = String(m).padStart(2, "0"); + const ss = String(s).padStart(2, "0"); + return h ? `${h}:${mm}:${ss}` : `${m}:${ss}`; + } + + _startCountdown(iso) { + this._stopCountdown(); + this._timer = setInterval(() => { + const el = this.querySelector("[data-countdown]"); + if (!el) return this._stopCountdown(); + el.textContent = this._remaining(iso); + // At zero the backend flips the hold sensor and the card re-renders. + if (el.textContent === "0:00") this._stopCountdown(); + }, 1000); + } + + _stopCountdown() { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + } + + // -- misc ----------------------------------------------------------------- + + _prettify(slug) { + return (slug || "") + .replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); + } +} + +customElements.define("shade-engine-card", ShadeEngineCard); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: "shade-engine-card", + name: "Shade Engine Card", + description: + "Per-zone shade status and control: mode, target, manual-hold countdown, engine on/off.", +}); + +console.info(`%c SHADE-ENGINE-CARD %c ${CARD_VERSION} `, "background:#4a3aa7;color:#fff", ""); diff --git a/docs/simulator.html b/docs/simulator.html index 5aae36a..d101af7 100644 --- a/docs/simulator.html +++ b/docs/simulator.html @@ -381,7 +381,7 @@

Room cross-section

/* ---- core.py port --------------------------------------------------- */ -const REASON = { COMMAND: 'command', HOLD: 'hold_active', IN_SYNC: 'in_sync', RATE: 'rate_limited' }; +const REASON = { COMMAND: 'command', DISABLED: 'disabled', HOLD: 'hold_active', IN_SYNC: 'in_sync', RATE: 'rate_limited' }; function resolveMode(target, glarePos) { if (!target.dynamic) return target.fixed; @@ -394,6 +394,7 @@

Room cross-section

this.modes = modes; // name -> {fixed}|{dynamic,min,max} this.motion = motion; // {deadband, minInterval, holdDuration, settle} seconds this.mode = mode; + this.enabled = true; // the per-zone control switch; sim never flips it this.lastCommanded = new Map(); this.lastCommandTs = null; this.holdUntil = null; @@ -410,6 +411,7 @@

Room cross-section

if (Math.abs(position - commanded) <= this.motion.deadband) return false; if (this.lastCommandTs !== null && now - this.lastCommandTs < this.motion.settle) return false; this.lastCommanded.set(cover, position); + if (!this.enabled) return false; // adopt baseline, but no hold while off this.startHold(now); return true; } @@ -417,6 +419,7 @@

Room cross-section

for (const [cover, position] of current) { if (position !== null && !this.lastCommanded.has(cover)) this.lastCommanded.set(cover, position); } + if (!this.enabled) return { reason: REASON.DISABLED, target: null, covers: [] }; if (this.holdActive(now)) return { reason: REASON.HOLD, target: null, covers: [] }; const target = this.target(glarePos); const movers = this.covers.filter(c => { diff --git a/tests/test_core.py b/tests/test_core.py index 005afa2..2e93c07 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -7,6 +7,7 @@ from core import ( # noqa: E402 REASON_COMMAND, + REASON_DISABLED, REASON_HOLD, REASON_IN_SYNC, REASON_RATE_LIMITED, @@ -153,3 +154,53 @@ def test_unavailable_cover_is_skipped(): d = zone.evaluate(1000, 100, {"cover.a": None, "cover.b": 37}) assert d.reason == REASON_COMMAND assert d.covers == ["cover.b"] + + +def test_disabled_never_commands_even_forced(): + zone = make_zone("open") + zone.enabled = False + d = zone.evaluate(1000, 100, {"cover.a": 37, "cover.b": 37}, forced=True) + assert d.reason == REASON_DISABLED + assert d.covers == [] + assert zone.last_command_ts is None + + +def test_disabled_takes_precedence_over_hold(): + zone = make_zone("open") + zone.start_hold(1000) + zone.enabled = False + d = zone.evaluate(1001, 100, {"cover.a": 37, "cover.b": 37}) + assert d.reason == REASON_DISABLED + + +def test_reenable_converges(): + zone = make_zone("open") + zone.enabled = False + zone.evaluate(1000, 100, {"cover.a": 37, "cover.b": 37}) + zone.enabled = True + d = zone.evaluate(1002, 100, {"cover.a": 37, "cover.b": 37}, forced=True) + assert d.reason == REASON_COMMAND + assert d.covers == ["cover.a", "cover.b"] + + +def test_manual_move_while_disabled_adopts_without_hold(): + zone = make_zone("open") + d = zone.evaluate(1000, 100, {"cover.a": 100, "cover.b": 100}) + assert d.reason == REASON_IN_SYNC + zone.enabled = False + # Human moves a cover while control is off: adopt, but no hold. + assert not zone.report_position("cover.a", 25, now=20000) + assert not zone.hold_active(20001) + assert zone.last_commanded["cover.a"] == 25 + # Re-enabling still converges to the mode target (no hold in the way). + zone.enabled = True + d = zone.evaluate(20060, 100, {"cover.a": 25, "cover.b": 100}, forced=True) + assert d.reason == REASON_COMMAND + assert d.covers == ["cover.a"] + + +def test_disabled_still_seeds_baselines(): + zone = make_zone("open") + zone.enabled = False + zone.evaluate(1000, 100, {"cover.a": 42, "cover.b": None}) + assert zone.last_commanded == {"cover.a": 42}