From ff262139a5b371475b7aa5ff682d7147a53b859c Mon Sep 17 00:00:00 2001 From: Leik Lima-Eriksen Date: Wed, 22 Jul 2026 05:50:59 +0000 Subject: [PATCH 1/3] Adapter: controller, config flow, entities, e2e tests Controller normalizes HA entities into input snapshots (domain-aware: zones count occupants, media players are truthy while playing), feeds physical light reports to the ledger, reconciles the switch as single writer (armed only after entity restores land), and keeps one timer for next_review tightened to a publish cadence while burning. Per-light device with number.target_hours (restorable knob), switch.enabled (standby escape hatch), an enum state sensor with reason attribute, and a lit-today duration sensor (0.1 h quantized, publish- gated for recorder discipline, persists the exact ledger for rule 5.1 restore). Config flow: light + visibility signals; options flow adds timing tunables. en + nb translations, icons, migration guide for the live instance. 23 adapter tests including full-day e2e scenarios and a zero-writes-under-churn recorder sweep. Co-Authored-By: Claude Fable 5 --- custom_components/grow_conductor/__init__.py | 35 +++ custom_components/grow_conductor/config.py | 64 +++++ .../grow_conductor/config_flow.py | 174 +++++++++++++ custom_components/grow_conductor/const.py | 46 ++++ .../grow_conductor/controller.py | 237 ++++++++++++++++++ custom_components/grow_conductor/entity.py | 48 ++++ custom_components/grow_conductor/icons.json | 29 +++ .../grow_conductor/manifest.json | 18 ++ custom_components/grow_conductor/number.py | 49 ++++ custom_components/grow_conductor/sensor.py | 133 ++++++++++ custom_components/grow_conductor/strings.json | 123 +++++++++ custom_components/grow_conductor/switch.py | 55 ++++ .../grow_conductor/translations/en.json | 123 +++++++++ .../grow_conductor/translations/nb.json | 123 +++++++++ docs/MIGRATION.md | 65 +++++ tests/test_config_flow.py | 129 ++++++++++ tests/test_controller.py | 50 ++++ tests/test_e2e.py | 237 ++++++++++++++++++ tests/test_init.py | 74 ++++++ tests/test_recorder_discipline.py | 47 ++++ 20 files changed, 1859 insertions(+) create mode 100644 custom_components/grow_conductor/__init__.py create mode 100644 custom_components/grow_conductor/config.py create mode 100644 custom_components/grow_conductor/config_flow.py create mode 100644 custom_components/grow_conductor/const.py create mode 100644 custom_components/grow_conductor/controller.py create mode 100644 custom_components/grow_conductor/entity.py create mode 100644 custom_components/grow_conductor/icons.json create mode 100644 custom_components/grow_conductor/manifest.json create mode 100644 custom_components/grow_conductor/number.py create mode 100644 custom_components/grow_conductor/sensor.py create mode 100644 custom_components/grow_conductor/strings.json create mode 100644 custom_components/grow_conductor/switch.py create mode 100644 custom_components/grow_conductor/translations/en.json create mode 100644 custom_components/grow_conductor/translations/nb.json create mode 100644 docs/MIGRATION.md create mode 100644 tests/test_config_flow.py create mode 100644 tests/test_controller.py create mode 100644 tests/test_e2e.py create mode 100644 tests/test_init.py create mode 100644 tests/test_recorder_discipline.py diff --git a/custom_components/grow_conductor/__init__.py b/custom_components/grow_conductor/__init__.py new file mode 100644 index 0000000..60b8af3 --- /dev/null +++ b/custom_components/grow_conductor/__init__.py @@ -0,0 +1,35 @@ +"""Grow Conductor — photoperiod scheduling for grow lights nobody should see.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .config import Config +from .const import DOMAIN, PLATFORMS +from .controller import GrowConductorController + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + controller = GrowConductorController(hass, entry, Config.from_options(dict(entry.options))) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller + + await controller.async_start() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Entity restores (enabled, target, budget seed) have been applied + # during platform setup — enforcement may begin (rule 4.1). + controller.arm() + + entry.async_on_unload(entry.add_update_listener(_async_options_updated)) + return True + + +async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + controller: GrowConductorController = hass.data[DOMAIN].pop(entry.entry_id) + controller.async_stop() + return unloaded diff --git a/custom_components/grow_conductor/config.py b/custom_components/grow_conductor/config.py new file mode 100644 index 0000000..d0bbee8 --- /dev/null +++ b/custom_components/grow_conductor/config.py @@ -0,0 +1,64 @@ +"""Typed view of the options contract (see const.py).""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .const import ( + CONF_ANCHOR, + CONF_CLEAR_HOLD, + CONF_HOME, + CONF_LIGHT, + CONF_MIN_BLOCK, + CONF_REFUGE_CONFIRM, + CONF_REFUGES, + CONF_SLEEP, + CONF_VETOES, + CONF_VIEWERS, + DEFAULT_ANCHOR, +) +from .core.tunables import Tunables + + +def _anchor_minutes(anchor: str) -> int: + hours, minutes = anchor.split(":")[:2] + return int(hours) * 60 + int(minutes) + + +@dataclass(frozen=True) +class Config: + """Validated per-entry configuration.""" + + light_entity: str + sleep_entity: str | None = None + home_entity: str | None = None + viewer_entities: tuple[str, ...] = () + refuge_entities: tuple[str, ...] = () + veto_entities: tuple[str, ...] = () + tunables: Tunables = field(default_factory=Tunables) + + @classmethod + def from_options(cls, options: dict[str, Any]) -> Config: + return cls( + light_entity=options[CONF_LIGHT], + sleep_entity=options.get(CONF_SLEEP) or None, + home_entity=options.get(CONF_HOME) or None, + viewer_entities=tuple(options.get(CONF_VIEWERS, ())), + refuge_entities=tuple(options.get(CONF_REFUGES, ())), + veto_entities=tuple(options.get(CONF_VETOES, ())), + tunables=Tunables( + anchor_minutes=_anchor_minutes(options.get(CONF_ANCHOR, DEFAULT_ANCHOR)), + clear_hold_s=float(options.get(CONF_CLEAR_HOLD, Tunables().clear_hold_s)), + refuge_confirm_s=float( + options.get(CONF_REFUGE_CONFIRM, Tunables().refuge_confirm_s) + ), + min_block_s=float(options.get(CONF_MIN_BLOCK, Tunables().min_block_s)), + ), + ) + + @property + def input_entities(self) -> tuple[str, ...]: + """Every entity that feeds the input snapshot (not the light itself).""" + singles = tuple(e for e in (self.sleep_entity, self.home_entity) if e) + return singles + self.viewer_entities + self.refuge_entities + self.veto_entities diff --git a/custom_components/grow_conductor/config_flow.py b/custom_components/grow_conductor/config_flow.py new file mode 100644 index 0000000..fb3c0e5 --- /dev/null +++ b/custom_components/grow_conductor/config_flow.py @@ -0,0 +1,174 @@ +"""Config and options flows. + +One config entry per grow light. ``entry.data`` stays empty; every +setting lives in options (see const.py for the contract) so the options +flow can edit everything. The initial flow asks only for the light and +its visibility signals — timing tunables keep spec defaults and are +editable under Options → Timing. +""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.core import callback +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + TextSelector, + TimeSelector, +) + +from .const import ( + CONF_ANCHOR, + CONF_CLEAR_HOLD, + CONF_HOME, + CONF_LIGHT, + CONF_MIN_BLOCK, + CONF_REFUGE_CONFIRM, + CONF_REFUGES, + CONF_SLEEP, + CONF_VETOES, + CONF_VIEWERS, + DEFAULT_ANCHOR, + DOMAIN, +) +from .core.tunables import Tunables + +CONF_NAME = "name" + +_LIGHT_SELECTOR = EntitySelector(EntitySelectorConfig(domain=["switch", "light", "input_boolean"])) +_BINARY_SELECTOR = EntitySelector(EntitySelectorConfig(domain=["binary_sensor", "input_boolean"])) +_HOME_SELECTOR = EntitySelector( + EntitySelectorConfig( + domain=["zone", "person", "device_tracker", "binary_sensor", "input_boolean", "sensor"] + ) +) +_MULTI_OCCUPANCY_SELECTOR = EntitySelector( + EntitySelectorConfig(domain=["binary_sensor", "input_boolean"], multiple=True) +) +_VETO_SELECTOR = EntitySelector( + EntitySelectorConfig( + domain=["media_player", "binary_sensor", "switch", "input_boolean"], multiple=True + ) +) + + +def _signals_schema(defaults: dict[str, Any]) -> vol.Schema: + def suggested(key: str) -> dict[str, Any]: + return {"suggested_value": defaults[key]} if defaults.get(key) else {} + + return vol.Schema( + { + vol.Optional(CONF_SLEEP, description=suggested(CONF_SLEEP)): _BINARY_SELECTOR, + vol.Optional(CONF_HOME, description=suggested(CONF_HOME)): _HOME_SELECTOR, + vol.Optional( + CONF_VIEWERS, default=list(defaults.get(CONF_VIEWERS, [])) + ): _MULTI_OCCUPANCY_SELECTOR, + vol.Optional( + CONF_REFUGES, default=list(defaults.get(CONF_REFUGES, [])) + ): _MULTI_OCCUPANCY_SELECTOR, + vol.Optional(CONF_VETOES, default=list(defaults.get(CONF_VETOES, []))): _VETO_SELECTOR, + } + ) + + +def _seconds_selector(max_value: float) -> NumberSelector: + return NumberSelector( + NumberSelectorConfig( + min=0, max=max_value, step=10, mode=NumberSelectorMode.BOX, unit_of_measurement="s" + ) + ) + + +def _timing_schema(defaults: dict[str, Any]) -> vol.Schema: + tunables = Tunables() + return vol.Schema( + { + vol.Required( + CONF_ANCHOR, default=defaults.get(CONF_ANCHOR, DEFAULT_ANCHOR) + ): TimeSelector(), + vol.Required( + CONF_CLEAR_HOLD, default=defaults.get(CONF_CLEAR_HOLD, tunables.clear_hold_s) + ): _seconds_selector(7200), + vol.Required( + CONF_REFUGE_CONFIRM, + default=defaults.get(CONF_REFUGE_CONFIRM, tunables.refuge_confirm_s), + ): _seconds_selector(3600), + vol.Required( + CONF_MIN_BLOCK, default=defaults.get(CONF_MIN_BLOCK, tunables.min_block_s) + ): _seconds_selector(7200), + } + ) + + +class GrowConductorConfigFlow(ConfigFlow, domain=DOMAIN): + VERSION = 1 + + def __init__(self) -> None: + self._name: str | None = None + self._light: str | None = None + + async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_LIGHT]) + self._abort_if_unique_id_configured() + self._name = user_input[CONF_NAME] + self._light = user_input[CONF_LIGHT] + return await self.async_step_signals() + + schema = vol.Schema( + { + vol.Required(CONF_NAME): TextSelector(), + vol.Required(CONF_LIGHT): _LIGHT_SELECTOR, + } + ) + return self.async_show_form(step_id="user", data_schema=schema) + + async def async_step_signals( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + if user_input is not None: + assert self._name is not None and self._light is not None + options = {CONF_LIGHT: self._light, **user_input} + return self.async_create_entry(title=self._name, data={}, options=options) + + defaults = {CONF_HOME: "zone.home" if self.hass.states.get("zone.home") else None} + return self.async_show_form(step_id="signals", data_schema=_signals_schema(defaults)) + + @staticmethod + @callback + def async_get_options_flow(config_entry) -> GrowConductorOptionsFlow: + return GrowConductorOptionsFlow() + + +class GrowConductorOptionsFlow(OptionsFlow): + async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: + return self.async_show_menu(step_id="init", menu_options=["signals", "timing"]) + + async def async_step_signals( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + if user_input is not None: + options = { + k: v + for k, v in dict(self.config_entry.options).items() + if k not in (CONF_SLEEP, CONF_HOME, CONF_VIEWERS, CONF_REFUGES, CONF_VETOES) + } + options.update(user_input) + return self.async_create_entry(data=options) + return self.async_show_form( + step_id="signals", data_schema=_signals_schema(dict(self.config_entry.options)) + ) + + async def async_step_timing(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: + if user_input is not None: + return self.async_create_entry(data={**dict(self.config_entry.options), **user_input}) + return self.async_show_form( + step_id="timing", data_schema=_timing_schema(dict(self.config_entry.options)) + ) diff --git a/custom_components/grow_conductor/const.py b/custom_components/grow_conductor/const.py new file mode 100644 index 0000000..29965dd --- /dev/null +++ b/custom_components/grow_conductor/const.py @@ -0,0 +1,46 @@ +"""Constants and the options contract. + +Options contract (house convention: ``entry.data`` stays empty, every +setting lives in ``entry.options`` so the options flow can edit all of +it; ``entry.title`` is the device name): + +- ``light_entity`` (str, required) — the switch/light to conduct. +- ``sleep_entity`` (str, optional) — household-asleep binary signal. +- ``home_entity`` (str, optional) — anyone-home signal (zone, person, + device_tracker, binary sensor, or numeric count). +- ``viewer_entities`` (list[str]) — occupancy in rooms that can see the + light (ENGINE_SPEC rule 1.3). +- ``refuge_entities`` (list[str]) — occupancy that proves everyone is + settled out of sight (rule 1.7). +- ``veto_entities`` (list[str]) — hard "someone is watching" signals, + e.g. the TV media_player (rule 1.2). +- ``anchor`` (str "HH:MM:SS") — plant-day boundary (rule 2.1). +- ``clear_hold_seconds`` / ``refuge_confirm_seconds`` / + ``min_block_seconds`` (number) — ENGINE_SPEC §6 tunables. + +Runtime knobs (``number.target_hours``, ``switch.enabled``) are entity +state restored by HA, never options. +""" + +from __future__ import annotations + +DOMAIN = "grow_conductor" + +CONF_LIGHT = "light_entity" +CONF_SLEEP = "sleep_entity" +CONF_HOME = "home_entity" +CONF_VIEWERS = "viewer_entities" +CONF_REFUGES = "refuge_entities" +CONF_VETOES = "veto_entities" +CONF_ANCHOR = "anchor" +CONF_CLEAR_HOLD = "clear_hold_seconds" +CONF_REFUGE_CONFIRM = "refuge_confirm_seconds" +CONF_MIN_BLOCK = "min_block_seconds" + +DEFAULT_ANCHOR = "22:00:00" + +# While the light burns with no events due, re-publish the lit-hours +# sensor on this cadence (quantization keeps the recorder quiet). +PUBLISH_INTERVAL_S = 360.0 + +PLATFORMS = ["number", "sensor", "switch"] diff --git a/custom_components/grow_conductor/controller.py b/custom_components/grow_conductor/controller.py new file mode 100644 index 0000000..c993247 --- /dev/null +++ b/custom_components/grow_conductor/controller.py @@ -0,0 +1,237 @@ +"""Adapter between Home Assistant and the pure engine. + +Responsibilities (ENGINE_SPEC §1 normalization, §4 enforcement): + +- Normalize the configured input entities into an + :class:`~.core.model.InputSnapshot` on every relevant state change. +- Feed physical light-state reports into the budget ledger (rule 2.2). +- Reconcile the physical switch to the engine's ``want_on`` (rule 4.1), + as a single writer, idempotently (rule 4.2). +- Keep exactly one timer armed for the decision's ``next_review`` + (rule 3.2), tightened to a publish cadence while the light burns so + the lit-hours sensor keeps moving between events. +- Fan out updates to entities over a per-entry dispatcher signal. + +The controller stamps every event with ``dt_util.now()`` (local time — +the plant-day anchor is a local-clock concept) on arrival, which keeps +the engine's monotonic-time requirement satisfied. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_point_in_time, + async_track_state_change_event, +) +from homeassistant.util import dt as dt_util + +from .config import Config +from .const import DOMAIN, PUBLISH_INTERVAL_S +from .core.engine import Engine +from .core.model import Decision, InputSnapshot + +# media_player states that mean "someone is actively watching/listening". +_ACTIVE_MEDIA_STATES = {"playing", "paused", "buffering", "on"} +_UNKNOWN_STATES = {"unknown", "unavailable"} + + +def normalize_signal(state: State | None) -> bool | None: + """Map an entity state onto a tri-state signal (rule 1.8 leaves the + fail-safe interpretation of ``None`` to the core). + + Domain-aware: zones and numeric sensors count occupants, person and + device_tracker entities are truthy at home, media players are truthy + while actively playing, everything else follows on/off. + """ + if state is None or state.state in _UNKNOWN_STATES: + return None + domain = state.entity_id.split(".", 1)[0] + value = state.state + if domain in ("person", "device_tracker"): + return value == "home" + if domain == "media_player": + return value in _ACTIVE_MEDIA_STATES + if value in ("on", "off"): + return value == "on" + try: + return float(value) > 0 # zone.home occupant count, numeric sensors + except ValueError: + return None + + +def normalize_light(state: State | None) -> bool | None: + """The physical light state; None while unknown (rule 2.2 accrues nothing).""" + if state is None or state.state in _UNKNOWN_STATES: + return None + return state.state == "on" + + +class GrowConductorController: + """Single writer for one grow light (one config entry).""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, config: Config) -> None: + self.hass = hass + self.entry = entry + self.config = config + self.engine = Engine(config.tunables) + self.signal = f"{DOMAIN}_{entry.entry_id}" + self._armed = False # no commands until restores have been applied + self._unsubs: list[callback] = [] + self._timer_unsub: callback | None = None + self._timer_at: datetime | None = None + + # -- lifecycle ------------------------------------------------------------ + + async def async_start(self) -> None: + """Subscribe and take the first (command-less) decision.""" + if self.config.input_entities: + self._unsubs.append( + async_track_state_change_event( + self.hass, self.config.input_entities, self._on_input_event + ) + ) + self._unsubs.append( + async_track_state_change_event( + self.hass, [self.config.light_entity], self._on_light_event + ) + ) + now = dt_util.now() + self.engine.light_reported(self._read_light(), now) + decision = self.engine.handle_snapshot(self._build_snapshot(), now) + self._after_decision(decision, now) + + @callback + def arm(self) -> None: + """Start enforcing (rule 4.1) — called once entity restores are in. + + Restores (enabled flag, target hours, budget seed) are pushed into + the engine while the platforms load; enforcing before they land + could flash the light on stale defaults. + """ + self._armed = True + now = dt_util.now() + self._after_decision(self.engine.tick(now), now) + + @callback + def async_stop(self) -> None: + for unsub in self._unsubs: + unsub() + self._unsubs.clear() + self._cancel_timer() + + # -- entity-facing commands ------------------------------------------------- + + @callback + def set_enabled(self, enabled: bool) -> None: + now = dt_util.now() + self._after_decision(self.engine.set_enabled(enabled, now), now) + + @callback + def set_target(self, hours: float) -> None: + now = dt_util.now() + self._after_decision(self.engine.set_target(hours, now), now) + + @callback + def seed_budget(self, day_start: datetime, spent_s: float) -> bool: + """Restore the persisted plant-day ledger (rule 5.1).""" + now = dt_util.now() + adopted = self.engine.seed_budget(day_start, spent_s, now) + self._after_decision(self.engine.tick(now), now) + return adopted + + # -- input plumbing ------------------------------------------------------------ + + def _state(self, entity_id: str) -> State | None: + return self.hass.states.get(entity_id) + + def _build_snapshot(self) -> InputSnapshot: + def any_active(entities: tuple[str, ...]) -> bool: + return any(normalize_signal(self._state(e)) is True for e in entities) + + return InputSnapshot( + asleep=( + normalize_signal(self._state(self.config.sleep_entity)) + if self.config.sleep_entity + else None + ), + anyone_home=( + normalize_signal(self._state(self.config.home_entity)) + if self.config.home_entity + else None + ), + viewer_active=any_active(self.config.viewer_entities), + refuge_active=any_active(self.config.refuge_entities), + veto_active=any_active(self.config.veto_entities), + ) + + def _read_light(self) -> bool | None: + return normalize_light(self._state(self.config.light_entity)) + + @callback + def _on_input_event(self, event: Event[EventStateChangedData]) -> None: + now = dt_util.now() + self._after_decision(self.engine.handle_snapshot(self._build_snapshot(), now), now) + + @callback + def _on_light_event(self, event: Event[EventStateChangedData]) -> None: + now = dt_util.now() + self._after_decision(self.engine.light_reported(self._read_light(), now), now) + + # -- decisions out ---------------------------------------------------------------- + + @callback + def _on_timer(self, when: datetime) -> None: + self._timer_unsub = None + self._timer_at = None + now = dt_util.now() + self._after_decision(self.engine.tick(now), now) + + @callback + def _after_decision(self, decision: Decision, now: datetime) -> None: + self._enforce(decision) + self._schedule_review(decision, now) + async_dispatcher_send(self.hass, self.signal) + + @callback + def _enforce(self, decision: Decision) -> None: + """Rule 4.1/4.2: command only while armed+enabled, only on a real delta.""" + if not self._armed or not self.engine.enabled: + return + actual = self.engine.light_on + if actual is None or actual == decision.want_on: + return + self.hass.async_create_task( + self.hass.services.async_call( + "homeassistant", + "turn_on" if decision.want_on else "turn_off", + {"entity_id": self.config.light_entity}, + ) + ) + + @callback + def _schedule_review(self, decision: Decision, now: datetime) -> None: + target = decision.next_review + if self.engine.light_on is True: + publish_at = now + timedelta(seconds=PUBLISH_INTERVAL_S) + target = min(target, publish_at) if target else publish_at + if target is None or target <= now: + self._cancel_timer() + return + if self._timer_at == target: + return + self._cancel_timer() + self._timer_at = target + self._timer_unsub = async_track_point_in_time(self.hass, self._on_timer, target) + + @callback + def _cancel_timer(self) -> None: + if self._timer_unsub is not None: + self._timer_unsub() + self._timer_unsub = None + self._timer_at = None diff --git a/custom_components/grow_conductor/entity.py b/custom_components/grow_conductor/entity.py new file mode 100644 index 0000000..54f93d0 --- /dev/null +++ b/custom_components/grow_conductor/entity.py @@ -0,0 +1,48 @@ +"""Shared device info and dispatcher-driven entity base.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + +if TYPE_CHECKING: + from .controller import GrowConductorController + + +def conductor_device_info(entry: ConfigEntry) -> DeviceInfo: + """One device per grow light, named after the config entry.""" + return DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + manufacturer="Grow Conductor", + model="Photoperiod scheduler", + entry_type=DeviceEntryType.SERVICE, + ) + + +class ConductorEntity(Entity): + """Base for conductor entities: dispatcher-driven, never polled.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__(self, controller: GrowConductorController) -> None: + self.controller = controller + self._attr_device_info = conductor_device_info(controller.entry) + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect(self.hass, self.controller.signal, self._on_controller_update) + ) + + @callback + def _on_controller_update(self) -> None: + self.async_write_ha_state() diff --git a/custom_components/grow_conductor/icons.json b/custom_components/grow_conductor/icons.json new file mode 100644 index 0000000..7698ddc --- /dev/null +++ b/custom_components/grow_conductor/icons.json @@ -0,0 +1,29 @@ +{ + "entity": { + "switch": { + "enabled": { + "default": "mdi:sprout" + } + }, + "number": { + "target_hours": { + "default": "mdi:sun-clock-outline" + } + }, + "sensor": { + "state": { + "default": "mdi:eye-off-outline", + "state": { + "lit": "mdi:lightbulb-on", + "observed": "mdi:eye", + "cooldown": "mdi:timer-sand", + "sated": "mdi:check-circle-outline", + "standby": "mdi:pause-circle-outline" + } + }, + "lit_today": { + "default": "mdi:timer-outline" + } + } + } +} diff --git a/custom_components/grow_conductor/manifest.json b/custom_components/grow_conductor/manifest.json new file mode 100644 index 0000000..473fa5a --- /dev/null +++ b/custom_components/grow_conductor/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "grow_conductor", + "name": "Grow Conductor", + "codeowners": [ + "@leiklier" + ], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/leiklier/grow-conductor", + "integration_type": "device", + "iot_class": "calculated", + "issue_tracker": "https://github.com/leiklier/grow-conductor/issues", + "loggers": [ + "custom_components.grow_conductor" + ], + "requirements": [], + "version": "0.1.0" +} diff --git a/custom_components/grow_conductor/number.py b/custom_components/grow_conductor/number.py new file mode 100644 index 0000000..e7a3a9a --- /dev/null +++ b/custom_components/grow_conductor/number.py @@ -0,0 +1,49 @@ +"""The target photoperiod knob (ENGINE_SPEC §6).""" + +from __future__ import annotations + +from homeassistant.components.number import NumberMode, RestoreNumber +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .controller import GrowConductorController +from .core.tunables import MAX_TARGET_HOURS +from .entity import ConductorEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback +) -> None: + controller: GrowConductorController = hass.data[DOMAIN][entry.entry_id] + async_add_entities([TargetHoursNumber(controller)]) + + +class TargetHoursNumber(ConductorEntity, RestoreNumber): + """Hours of light the plants should get per plant day.""" + + _attr_translation_key = "target_hours" + _attr_native_min_value = 0.0 + _attr_native_max_value = MAX_TARGET_HOURS + _attr_native_step = 0.5 + _attr_native_unit_of_measurement = UnitOfTime.HOURS + _attr_mode = NumberMode.SLIDER + + def __init__(self, controller: GrowConductorController) -> None: + super().__init__(controller) + self._attr_unique_id = f"{controller.entry.entry_id}_target_hours" + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + data = await self.async_get_last_number_data() + if data is not None and data.native_value is not None: + self.controller.set_target(data.native_value) + + @property + def native_value(self) -> float: + return self.controller.engine.target_hours + + async def async_set_native_value(self, value: float) -> None: + self.controller.set_target(value) diff --git a/custom_components/grow_conductor/sensor.py b/custom_components/grow_conductor/sensor.py new file mode 100644 index 0000000..e0ac62d --- /dev/null +++ b/custom_components/grow_conductor/sensor.py @@ -0,0 +1,133 @@ +"""Diagnostic sensors: scheduler state and delivered light. + +Recorder discipline (house rule): both sensors gate their own writes — +the state sensor publishes only on a (state, reason) change and the +lit-hours sensor only when its 0.1 h quantized value or plant day +changes, so per-event dispatcher churn never becomes recorder rows. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import ClassVar + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTime +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .controller import GrowConductorController +from .core.model import LightState +from .entity import ConductorEntity + +ATTR_REASON = "reason" +ATTR_SPENT_SECONDS = "spent_seconds" +ATTR_DAY_START = "day_start" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback +) -> None: + controller: GrowConductorController = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SchedulerStateSensor(controller), LitTodaySensor(controller)]) + + +class SchedulerStateSensor(ConductorEntity, SensorEntity): + """What the engine is doing and why (ENGINE_SPEC rule 3.3).""" + + _attr_translation_key = "state" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options: ClassVar[list[str]] = [state.value for state in LightState] + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, controller: GrowConductorController) -> None: + super().__init__(controller) + self._attr_unique_id = f"{controller.entry.entry_id}_state" + self._published: tuple[str | None, str | None] | None = None + + @property + def native_value(self) -> str | None: + decision = self.controller.engine.decision + return decision.state.value if decision else None + + @property + def extra_state_attributes(self) -> dict[str, str | None]: + decision = self.controller.engine.decision + return {ATTR_REASON: decision.reason.value if decision and decision.reason else None} + + @callback + def _on_controller_update(self) -> None: + decision = self.controller.engine.decision + current = ( + (decision.state.value, decision.reason.value if decision.reason else None) + if decision + else None + ) + if current == self._published: + return + self._published = current + self.async_write_ha_state() + + +class LitTodaySensor(ConductorEntity, SensorEntity, RestoreEntity): + """Light delivered this plant day (rules 2.1/2.2), quantized to 0.1 h. + + Persists the exact ledger in attributes and re-seeds the engine on + restore (rule 5.1). ``last_reset`` tracks the plant-day anchor so + long-term statistics understand the daily reset. + """ + + _attr_translation_key = "lit_today" + _attr_device_class = SensorDeviceClass.DURATION + _attr_state_class = SensorStateClass.TOTAL + _attr_native_unit_of_measurement = UnitOfTime.HOURS + _attr_suggested_display_precision = 1 + + def __init__(self, controller: GrowConductorController) -> None: + super().__init__(controller) + self._attr_unique_id = f"{controller.entry.entry_id}_lit_today" + self._published: tuple[float, datetime] | None = None + + async def async_added_to_hass(self) -> None: + last = await self.async_get_last_state() + if last is not None: + day_start_raw = last.attributes.get(ATTR_DAY_START) + spent_raw = last.attributes.get(ATTR_SPENT_SECONDS) + day_start = dt_util.parse_datetime(day_start_raw) if day_start_raw else None + if day_start is not None and spent_raw is not None: + self.controller.seed_budget(day_start, float(spent_raw)) + await super().async_added_to_hass() + + def _current(self) -> tuple[float, datetime]: + now = dt_util.now() + spent = self.controller.engine.spent_s(now) + return round(spent / 360.0) / 10.0, self.controller.engine.day_start(now) + + @property + def native_value(self) -> float: + return self._current()[0] + + @property + def last_reset(self) -> datetime: + return self._current()[1] + + @property + def extra_state_attributes(self) -> dict[str, object]: + now = dt_util.now() + return { + ATTR_SPENT_SECONDS: round(self.controller.engine.spent_s(now)), + ATTR_DAY_START: self.controller.engine.day_start(now).isoformat(), + } + + @callback + def _on_controller_update(self) -> None: + current = self._current() + if current == self._published: + return + self._published = current + self.async_write_ha_state() diff --git a/custom_components/grow_conductor/strings.json b/custom_components/grow_conductor/strings.json new file mode 100644 index 0000000..5dd6fc8 --- /dev/null +++ b/custom_components/grow_conductor/strings.json @@ -0,0 +1,123 @@ +{ + "config": { + "step": { + "user": { + "title": "New grow light", + "description": "Grow Conductor keeps a grow light on for a daily target of hours, but only while nobody can see it.", + "data": { + "name": "Name", + "light_entity": "Grow light switch" + }, + "data_description": { + "name": "Device name, e.g. 'Sofakrok plantelys'.", + "light_entity": "The switch or light that powers the grow light." + } + }, + "signals": { + "title": "Visibility signals", + "description": "How the conductor knows whether someone could see the light. Every signal is optional, but with none configured the light will never turn on.", + "data": { + "sleep_entity": "Household asleep", + "home_entity": "Anyone home", + "viewer_entities": "Rooms with a view of the light", + "refuge_entities": "Out-of-sight rooms", + "veto_entities": "Hard vetoes" + }, + "data_description": { + "sleep_entity": "On while the household sleeps — the prime window for running the light.", + "home_entity": "A zone, person, tracker or sensor that tells whether anyone is home.", + "viewer_entities": "Occupancy sensors covering every spot the light is visible from. Any activity here cuts the light immediately (night movement, morning routine).", + "refuge_entities": "Occupancy sensors for rooms the light is NOT visible from, e.g. a home office. Sustained presence there lets the light run while you are awake at home (working from home).", + "veto_entities": "Signals that force the light off regardless of occupancy, e.g. the TV in the same room." + } + } + }, + "abort": { + "already_configured": "This grow light is already conducted." + } + }, + "options": { + "step": { + "init": { + "menu_options": { + "signals": "Visibility signals", + "timing": "Timing" + } + }, + "signals": { + "title": "Visibility signals", + "data": { + "sleep_entity": "Household asleep", + "home_entity": "Anyone home", + "viewer_entities": "Rooms with a view of the light", + "refuge_entities": "Out-of-sight rooms", + "veto_entities": "Hard vetoes" + }, + "data_description": { + "sleep_entity": "On while the household sleeps — the prime window for running the light.", + "home_entity": "A zone, person, tracker or sensor that tells whether anyone is home.", + "viewer_entities": "Occupancy sensors covering every spot the light is visible from.", + "refuge_entities": "Occupancy in rooms without sight of the light; sustained presence allows daytime runs.", + "veto_entities": "Signals that force the light off regardless of occupancy." + } + }, + "timing": { + "title": "Timing", + "data": { + "anchor": "Plant-day start", + "clear_hold_seconds": "Quiet time before lighting", + "refuge_confirm_seconds": "Refuge confirmation time", + "min_block_seconds": "Minimum worthwhile block" + }, + "data_description": { + "anchor": "When the daily light budget resets — ideally around your usual bedtime.", + "clear_hold_seconds": "How long the viewed rooms must stay quiet before the light may turn on.", + "refuge_confirm_seconds": "How long an out-of-sight room must be continuously occupied before it counts as refuge.", + "min_block_seconds": "Never start a light block shorter than this; avoids end-of-day dribble." + } + } + } + }, + "entity": { + "switch": { + "enabled": { + "name": "Enabled" + } + }, + "number": { + "target_hours": { + "name": "Target hours" + } + }, + "sensor": { + "state": { + "name": "State", + "state": { + "lit": "Lit", + "observed": "Observed", + "cooldown": "Cooldown", + "sated": "Sated", + "standby": "Standby" + }, + "state_attributes": { + "reason": { + "name": "Reason", + "state": { + "asleep": "Household asleep", + "away": "Nobody home", + "refuge": "Occupants out of sight", + "veto": "Hard veto active", + "viewers": "Activity in view", + "awake_home": "Awake at home", + "target_reached": "Daily target reached", + "low_headroom": "Remaining budget too small" + } + } + } + }, + "lit_today": { + "name": "Lit today" + } + } + } +} diff --git a/custom_components/grow_conductor/switch.py b/custom_components/grow_conductor/switch.py new file mode 100644 index 0000000..cfa09f5 --- /dev/null +++ b/custom_components/grow_conductor/switch.py @@ -0,0 +1,55 @@ +"""The per-light enabled switch (ENGINE_SPEC rule 4.3).""" + +from __future__ import annotations + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import DOMAIN +from .controller import GrowConductorController +from .entity import ConductorEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback +) -> None: + controller: GrowConductorController = hass.data[DOMAIN][entry.entry_id] + async_add_entities([EnabledSwitch(controller)]) + + +class EnabledSwitch(ConductorEntity, SwitchEntity, RestoreEntity): + """Engine enabled flag, restored across restarts. + + While off the engine issues no commands but keeps tracking visibility + and budget (rule 4.3) — this is the manual-control escape hatch. + """ + + _attr_translation_key = "enabled" + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, controller: GrowConductorController) -> None: + super().__init__(controller) + self._attr_unique_id = f"{controller.entry.entry_id}_enabled" + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + last = await self.async_get_last_state() + if last is not None and last.state in (STATE_ON, STATE_OFF): + restored = last.state == STATE_ON + if restored != self.controller.engine.enabled: + self.controller.set_enabled(restored) + + @property + def is_on(self) -> bool: + return self.controller.engine.enabled + + async def async_turn_on(self, **kwargs: object) -> None: + self.controller.set_enabled(True) + + async def async_turn_off(self, **kwargs: object) -> None: + self.controller.set_enabled(False) diff --git a/custom_components/grow_conductor/translations/en.json b/custom_components/grow_conductor/translations/en.json new file mode 100644 index 0000000..5dd6fc8 --- /dev/null +++ b/custom_components/grow_conductor/translations/en.json @@ -0,0 +1,123 @@ +{ + "config": { + "step": { + "user": { + "title": "New grow light", + "description": "Grow Conductor keeps a grow light on for a daily target of hours, but only while nobody can see it.", + "data": { + "name": "Name", + "light_entity": "Grow light switch" + }, + "data_description": { + "name": "Device name, e.g. 'Sofakrok plantelys'.", + "light_entity": "The switch or light that powers the grow light." + } + }, + "signals": { + "title": "Visibility signals", + "description": "How the conductor knows whether someone could see the light. Every signal is optional, but with none configured the light will never turn on.", + "data": { + "sleep_entity": "Household asleep", + "home_entity": "Anyone home", + "viewer_entities": "Rooms with a view of the light", + "refuge_entities": "Out-of-sight rooms", + "veto_entities": "Hard vetoes" + }, + "data_description": { + "sleep_entity": "On while the household sleeps — the prime window for running the light.", + "home_entity": "A zone, person, tracker or sensor that tells whether anyone is home.", + "viewer_entities": "Occupancy sensors covering every spot the light is visible from. Any activity here cuts the light immediately (night movement, morning routine).", + "refuge_entities": "Occupancy sensors for rooms the light is NOT visible from, e.g. a home office. Sustained presence there lets the light run while you are awake at home (working from home).", + "veto_entities": "Signals that force the light off regardless of occupancy, e.g. the TV in the same room." + } + } + }, + "abort": { + "already_configured": "This grow light is already conducted." + } + }, + "options": { + "step": { + "init": { + "menu_options": { + "signals": "Visibility signals", + "timing": "Timing" + } + }, + "signals": { + "title": "Visibility signals", + "data": { + "sleep_entity": "Household asleep", + "home_entity": "Anyone home", + "viewer_entities": "Rooms with a view of the light", + "refuge_entities": "Out-of-sight rooms", + "veto_entities": "Hard vetoes" + }, + "data_description": { + "sleep_entity": "On while the household sleeps — the prime window for running the light.", + "home_entity": "A zone, person, tracker or sensor that tells whether anyone is home.", + "viewer_entities": "Occupancy sensors covering every spot the light is visible from.", + "refuge_entities": "Occupancy in rooms without sight of the light; sustained presence allows daytime runs.", + "veto_entities": "Signals that force the light off regardless of occupancy." + } + }, + "timing": { + "title": "Timing", + "data": { + "anchor": "Plant-day start", + "clear_hold_seconds": "Quiet time before lighting", + "refuge_confirm_seconds": "Refuge confirmation time", + "min_block_seconds": "Minimum worthwhile block" + }, + "data_description": { + "anchor": "When the daily light budget resets — ideally around your usual bedtime.", + "clear_hold_seconds": "How long the viewed rooms must stay quiet before the light may turn on.", + "refuge_confirm_seconds": "How long an out-of-sight room must be continuously occupied before it counts as refuge.", + "min_block_seconds": "Never start a light block shorter than this; avoids end-of-day dribble." + } + } + } + }, + "entity": { + "switch": { + "enabled": { + "name": "Enabled" + } + }, + "number": { + "target_hours": { + "name": "Target hours" + } + }, + "sensor": { + "state": { + "name": "State", + "state": { + "lit": "Lit", + "observed": "Observed", + "cooldown": "Cooldown", + "sated": "Sated", + "standby": "Standby" + }, + "state_attributes": { + "reason": { + "name": "Reason", + "state": { + "asleep": "Household asleep", + "away": "Nobody home", + "refuge": "Occupants out of sight", + "veto": "Hard veto active", + "viewers": "Activity in view", + "awake_home": "Awake at home", + "target_reached": "Daily target reached", + "low_headroom": "Remaining budget too small" + } + } + } + }, + "lit_today": { + "name": "Lit today" + } + } + } +} diff --git a/custom_components/grow_conductor/translations/nb.json b/custom_components/grow_conductor/translations/nb.json new file mode 100644 index 0000000..c06748a --- /dev/null +++ b/custom_components/grow_conductor/translations/nb.json @@ -0,0 +1,123 @@ +{ + "config": { + "step": { + "user": { + "title": "Nytt plantelys", + "description": "Grow Conductor holder et plantelys på i et daglig timemål, men bare når ingen kan se det.", + "data": { + "name": "Navn", + "light_entity": "Plantelysbryter" + }, + "data_description": { + "name": "Enhetsnavn, f.eks. 'Sofakrok plantelys'.", + "light_entity": "Bryteren eller lyset som styrer plantelyset." + } + }, + "signals": { + "title": "Synlighetssignaler", + "description": "Slik vet dirigenten om noen kan se lyset. Alle signaler er valgfrie, men uten noen konfigurert vil lyset aldri slå seg på.", + "data": { + "sleep_entity": "Husstanden sover", + "home_entity": "Noen hjemme", + "viewer_entities": "Rom med sikt til lyset", + "refuge_entities": "Rom uten sikt", + "veto_entities": "Harde vetoer" + }, + "data_description": { + "sleep_entity": "På mens husstanden sover — det beste vinduet for å kjøre lyset.", + "home_entity": "En sone, person, sporer eller sensor som forteller om noen er hjemme.", + "viewer_entities": "Bevegelsessensorer som dekker alle steder lyset er synlig fra. Aktivitet her slukker lyset umiddelbart (nattlig bevegelse, morgenrutine).", + "refuge_entities": "Bevegelsessensorer for rom lyset IKKE er synlig fra, f.eks. hjemmekontor. Vedvarende tilstedeværelse der lar lyset stå på mens du er våken hjemme (hjemmekontor).", + "veto_entities": "Signaler som tvinger lyset av uansett, f.eks. TV-en i samme rom." + } + } + }, + "abort": { + "already_configured": "Dette plantelyset dirigeres allerede." + } + }, + "options": { + "step": { + "init": { + "menu_options": { + "signals": "Synlighetssignaler", + "timing": "Tidsinnstillinger" + } + }, + "signals": { + "title": "Synlighetssignaler", + "data": { + "sleep_entity": "Husstanden sover", + "home_entity": "Noen hjemme", + "viewer_entities": "Rom med sikt til lyset", + "refuge_entities": "Rom uten sikt", + "veto_entities": "Harde vetoer" + }, + "data_description": { + "sleep_entity": "På mens husstanden sover — det beste vinduet for å kjøre lyset.", + "home_entity": "En sone, person, sporer eller sensor som forteller om noen er hjemme.", + "viewer_entities": "Bevegelsessensorer som dekker alle steder lyset er synlig fra.", + "refuge_entities": "Bevegelse i rom uten sikt til lyset; vedvarende tilstedeværelse tillater kjøring på dagtid.", + "veto_entities": "Signaler som tvinger lyset av uansett." + } + }, + "timing": { + "title": "Tidsinnstillinger", + "data": { + "anchor": "Plantedagens start", + "clear_hold_seconds": "Stilletid før tenning", + "refuge_confirm_seconds": "Bekreftelsestid for rom uten sikt", + "min_block_seconds": "Minste blokk verdt å starte" + }, + "data_description": { + "anchor": "Når det daglige lysbudsjettet nullstilles — helst rundt vanlig leggetid.", + "clear_hold_seconds": "Hvor lenge rommene med sikt må være stille før lyset kan tennes.", + "refuge_confirm_seconds": "Hvor lenge et rom uten sikt må være sammenhengende i bruk før det teller som tilflukt.", + "min_block_seconds": "Start aldri en lysblokk kortere enn dette; unngår småstubber på slutten av dagen." + } + } + } + }, + "entity": { + "switch": { + "enabled": { + "name": "Aktivert" + } + }, + "number": { + "target_hours": { + "name": "Måltimer" + } + }, + "sensor": { + "state": { + "name": "Tilstand", + "state": { + "lit": "Tent", + "observed": "Observert", + "cooldown": "Nedkjøling", + "sated": "Mett", + "standby": "Hvilemodus" + }, + "state_attributes": { + "reason": { + "name": "Årsak", + "state": { + "asleep": "Husstanden sover", + "away": "Ingen hjemme", + "refuge": "Beboerne er ute av syne", + "veto": "Hardt veto aktivt", + "viewers": "Aktivitet med sikt til lyset", + "awake_home": "Våken hjemme", + "target_reached": "Dagsmålet nådd", + "low_headroom": "For lite budsjett igjen" + } + } + } + }, + "lit_today": { + "name": "Tent i dag" + } + } + } +} diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..54df391 --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,65 @@ +# Migrating the live instance from the template automations + +This replaces, per grow light, the legacy stack of: a "… Plantelys +Required State" template binary sensor, an "… Plantelys - Enforce State" +automation, and an "… Plantelys On Last 24 Hours" history_stats helper. +The night-movement plumbing (`input_boolean.night_movement` and its +automation) **stays** — path lighting still uses it; Grow Conductor +simply doesn't need it, because viewer occupancy covers night movement +directly (rule 1.3). + +## 1. Create the devices + +**Sofakrok plantelys** + +| Field | Value | +| --- | --- | +| Grow light switch | `switch.sofakrok_plantelys` | +| Household asleep | `binary_sensor.household_sleep_mode` | +| Anyone home | `zone.home` | +| Rooms with a view | `binary_sensor.sofakrok_occupancy`, `binary_sensor.spisebord_occupancy`, `binary_sensor.kjokken_occupancy` | +| Out-of-sight rooms | `binary_sensor.kontor_occupancy` | +| Hard vetoes | — (viewer occupancy already covers TV watchers in sofakrok) | +| Target hours | 14 (legacy: 16 weekdays / 12 weekends) | + +**Spisebord plantelys** + +| Field | Value | +| --- | --- | +| Grow light switch | `switch.spisebord_plantelys` | +| Household asleep | `binary_sensor.household_sleep_mode` | +| Anyone home | `zone.home` | +| Rooms with a view | `binary_sensor.spisebord_occupancy`, `binary_sensor.sofakrok_occupancy`, `binary_sensor.kjokken_occupancy` | +| Out-of-sight rooms | `binary_sensor.kontor_occupancy` | +| Hard vetoes | `media_player.sofakrok_tv` (legacy behavior: TV on ⇒ off) | +| Target hours | 13 (legacy: 14 weekdays / 12 weekends) | + +Presence Conductor room occupancy entities are the intended viewer/ +refuge inputs once the cutover to them happens; the template occupancy +helpers above work identically in the meantime. + +Behavior changes to be aware of (deliberate, see DECISION.md): no +weekend "stay on after waking" latch, and the away schedule follows the +anchor + budget instead of the fixed 22:00–14:00 window. The workday and +vacation inputs are gone — WFH is detected via the office, and vacation +is just "away". + +## 2. Shadow period (recommended) + +Add both devices but leave each `switch.…_enabled` **off**. The state +sensor shows what the conductor *would* do (`lit` vs the legacy template +state) without touching the lights. Compare for a few days. + +## 3. Cutover, per light + +1. Disable the legacy automation "… Plantelys - Enforce State". +2. Turn `switch.…_enabled` on. +3. After a week of good behavior, delete the legacy template sensor and + (optionally) the history_stats helper — `sensor.…_lit_today` replaces + it. Keeping the history_stats a while longer is harmless and gives an + independent cross-check of delivered hours. + +## 4. Rollback + +Turn `switch.…_enabled` off and re-enable the legacy automation. Nothing +in the legacy stack is modified by this integration. diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..892ef6f --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,129 @@ +"""Config and options flows: options contract, duplicate guard, editing.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.grow_conductor.const import ( + CONF_ANCHOR, + CONF_CLEAR_HOLD, + CONF_HOME, + CONF_LIGHT, + CONF_MIN_BLOCK, + CONF_REFUGE_CONFIRM, + CONF_REFUGES, + CONF_SLEEP, + CONF_VETOES, + CONF_VIEWERS, + DOMAIN, +) + + +async def test_full_flow_creates_entry_with_options_contract(hass: HomeAssistant) -> None: + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"}) + assert result["type"] == "form" and result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"name": "Sofakrok plantelys", CONF_LIGHT: "switch.sofakrok_plantelys"}, + ) + assert result["type"] == "form" and result["step_id"] == "signals" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SLEEP: "binary_sensor.household_sleep_mode", + CONF_HOME: "zone.home", + CONF_VIEWERS: ["binary_sensor.sofakrok_occupancy"], + CONF_REFUGES: ["binary_sensor.kontor_occupancy"], + CONF_VETOES: ["media_player.sofakrok_tv"], + }, + ) + assert result["type"] == "create_entry" + assert result["title"] == "Sofakrok plantelys" + assert result["data"] == {} # house convention: everything in options + assert result["options"] == { + CONF_LIGHT: "switch.sofakrok_plantelys", + CONF_SLEEP: "binary_sensor.household_sleep_mode", + CONF_HOME: "zone.home", + CONF_VIEWERS: ["binary_sensor.sofakrok_occupancy"], + CONF_REFUGES: ["binary_sensor.kontor_occupancy"], + CONF_VETOES: ["media_player.sofakrok_tv"], + } + + +async def test_duplicate_light_aborts(hass: HomeAssistant) -> None: + MockConfigEntry( + domain=DOMAIN, + unique_id="switch.sofakrok_plantelys", + options={CONF_LIGHT: "switch.sofakrok_plantelys"}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"name": "Duplicate", CONF_LIGHT: "switch.sofakrok_plantelys"} + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_options_flow_timing_preserves_signals(hass: HomeAssistant) -> None: + entry = MockConfigEntry( + domain=DOMAIN, + title="Sofakrok plantelys", + options={ + CONF_LIGHT: "switch.sofakrok_plantelys", + CONF_SLEEP: "binary_sensor.household_sleep_mode", + CONF_VIEWERS: ["binary_sensor.sofakrok_occupancy"], + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == "menu" + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "timing"} + ) + assert result["step_id"] == "timing" + result = await hass.config_entries.options.async_configure( + result["flow_id"], + { + CONF_ANCHOR: "21:30:00", + CONF_CLEAR_HOLD: 300, + CONF_REFUGE_CONFIRM: 120, + CONF_MIN_BLOCK: 600, + }, + ) + assert result["type"] == "create_entry" + assert result["data"][CONF_ANCHOR] == "21:30:00" + assert result["data"][CONF_CLEAR_HOLD] == 300 + # Signals untouched by the timing step. + assert result["data"][CONF_SLEEP] == "binary_sensor.household_sleep_mode" + assert result["data"][CONF_VIEWERS] == ["binary_sensor.sofakrok_occupancy"] + + +async def test_options_flow_signals_can_clear_entities(hass: HomeAssistant) -> None: + entry = MockConfigEntry( + domain=DOMAIN, + title="Sofakrok plantelys", + options={ + CONF_LIGHT: "switch.sofakrok_plantelys", + CONF_SLEEP: "binary_sensor.household_sleep_mode", + CONF_ANCHOR: "21:30:00", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "signals"} + ) + # Submit without the sleep entity: it is cleared, timing survives. + result = await hass.config_entries.options.async_configure( + result["flow_id"], {CONF_VIEWERS: [], CONF_REFUGES: [], CONF_VETOES: []} + ) + assert result["type"] == "create_entry" + assert CONF_SLEEP not in result["data"] + assert result["data"][CONF_LIGHT] == "switch.sofakrok_plantelys" + assert result["data"][CONF_ANCHOR] == "21:30:00" diff --git a/tests/test_controller.py b/tests/test_controller.py new file mode 100644 index 0000000..5ee3f5a --- /dev/null +++ b/tests/test_controller.py @@ -0,0 +1,50 @@ +"""Adapter normalization (ENGINE_SPEC §1 input mapping, rule 1.8).""" + +from __future__ import annotations + +from homeassistant.core import State + +from custom_components.grow_conductor.controller import normalize_light, normalize_signal + + +def test_unknown_and_unavailable_are_none() -> None: + assert normalize_signal(None) is None + assert normalize_signal(State("binary_sensor.x", "unavailable")) is None + assert normalize_signal(State("binary_sensor.x", "unknown")) is None + assert normalize_light(None) is None + assert normalize_light(State("switch.x", "unavailable")) is None + + +def test_binary_domains() -> None: + assert normalize_signal(State("binary_sensor.x", "on")) is True + assert normalize_signal(State("input_boolean.x", "off")) is False + assert normalize_signal(State("switch.x", "on")) is True + + +def test_zone_counts_occupants() -> None: + assert normalize_signal(State("zone.home", "0")) is False + assert normalize_signal(State("zone.home", "2")) is True + + +def test_person_and_tracker_are_home_truthy() -> None: + assert normalize_signal(State("person.leik", "home")) is True + assert normalize_signal(State("person.leik", "not_home")) is False + assert normalize_signal(State("device_tracker.phone", "home")) is True + assert normalize_signal(State("device_tracker.phone", "work")) is False + + +def test_media_player_active_states() -> None: + """A playing/paused TV is a watcher; off/standby/idle is not.""" + for active in ("playing", "paused", "buffering", "on"): + assert normalize_signal(State("media_player.tv", active)) is True + for inactive in ("off", "standby", "idle"): + assert normalize_signal(State("media_player.tv", inactive)) is False + + +def test_unparseable_states_are_none() -> None: + assert normalize_signal(State("sensor.weird", "banana")) is None + + +def test_light_state() -> None: + assert normalize_light(State("switch.x", "on")) is True + assert normalize_light(State("switch.x", "off")) is False diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..4187c19 --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,237 @@ +"""End-to-end: real config entry, real input_boolean as the grow light. + +Drives the integration through Home Assistant state changes and time and +asserts on the physical light, mirroring the core scenario tests at the +adapter level (single writer, rule 4.1). +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, + async_fire_time_changed, +) + +from custom_components.grow_conductor.const import ( + CONF_HOME, + CONF_LIGHT, + CONF_REFUGES, + CONF_SLEEP, + CONF_VETOES, + CONF_VIEWERS, + DOMAIN, +) + +LIGHT = "input_boolean.plantelys" +SLEEP = "binary_sensor.sleep" +HOME = "zone.home" +VIEWER = "binary_sensor.stue" +REFUGE = "binary_sensor.kontor" +VETO = "media_player.tv" + +OPTIONS: dict[str, Any] = { + CONF_LIGHT: LIGHT, + CONF_SLEEP: SLEEP, + CONF_HOME: HOME, + CONF_VIEWERS: [VIEWER], + CONF_REFUGES: [REFUGE], + CONF_VETOES: [VETO], +} + + +def utc(hour: int, minute: int = 0, day: int = 5) -> str: + return f"2026-01-{day:02d} {hour:02d}:{minute:02d}:00+00:00" + + +async def setup_conductor(hass: HomeAssistant) -> MockConfigEntry: + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component( + hass, "input_boolean", {"input_boolean": {"plantelys": None}} + ) + hass.states.async_set(SLEEP, "off") + hass.states.async_set(HOME, "1") + hass.states.async_set(VIEWER, "off") + hass.states.async_set(REFUGE, "off") + hass.states.async_set(VETO, "off") + await hass.async_block_till_done() + + entry = MockConfigEntry(domain=DOMAIN, title="Stue plantelys", data={}, options=OPTIONS) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +def entity_id(hass: HomeAssistant, entry: MockConfigEntry, platform: str, suffix: str) -> str: + registry = er.async_get(hass) + found = registry.async_get_entity_id(platform, DOMAIN, f"{entry.entry_id}_{suffix}") + assert found is not None + return found + + +def light_is_on(hass: HomeAssistant) -> bool: + return hass.states.get(LIGHT).state == "on" + + +async def advance(hass: HomeAssistant, freezer, seconds: float) -> None: + freezer.tick(timedelta(seconds=seconds)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + +async def test_sleep_wake_and_night_movement(hass: HomeAssistant, freezer) -> None: + freezer.move_to(utc(22, 30)) + entry = await setup_conductor(hass) + state_sensor = entity_id(hass, entry, "sensor", "state") + + # Awake at home: observed, light off. + assert not light_is_on(hass) + assert hass.states.get(state_sensor).state == "observed" + + # Sleep mode on: prime time (no viewer memory at startup, rule 5.2). + hass.states.async_set(SLEEP, "on") + await hass.async_block_till_done() + assert light_is_on(hass) + assert hass.states.get(state_sensor).state == "lit" + assert hass.states.get(state_sensor).attributes["reason"] == "asleep" + + # Night movement cuts immediately (rule 1.3). + hass.states.async_set(VIEWER, "on") + await hass.async_block_till_done() + assert not light_is_on(hass) + + # Movement stops: cooldown, then relight after clear_hold (600 s). + hass.states.async_set(VIEWER, "off") + await hass.async_block_till_done() + assert hass.states.get(state_sensor).state == "cooldown" + assert not light_is_on(hass) + await advance(hass, freezer, 601) + assert light_is_on(hass) + + # Waking up cuts the light (morning routine is just OBSERVED). + hass.states.async_set(SLEEP, "off") + await hass.async_block_till_done() + assert not light_is_on(hass) + assert hass.states.get(state_sensor).attributes["reason"] == "awake_home" + + +async def test_veto_and_refuge(hass: HomeAssistant, freezer) -> None: + freezer.move_to(utc(22, 30)) + entry = await setup_conductor(hass) + state_sensor = entity_id(hass, entry, "sensor", "state") + + # Work-from-home: office occupancy confirms after refuge_confirm (180 s). + hass.states.async_set(REFUGE, "on") + await hass.async_block_till_done() + assert not light_is_on(hass) + await advance(hass, freezer, 181) + assert light_is_on(hass) + assert hass.states.get(state_sensor).attributes["reason"] == "refuge" + + # TV starts playing: hard veto (rule 1.2). + hass.states.async_set(VETO, "playing") + await hass.async_block_till_done() + assert not light_is_on(hass) + assert hass.states.get(state_sensor).attributes["reason"] == "veto" + + # TV off again: immediate relight (no release hold). + hass.states.async_set(VETO, "off") + await hass.async_block_till_done() + assert light_is_on(hass) + + +async def test_budget_cap_and_lit_today(hass: HomeAssistant, freezer) -> None: + freezer.move_to(utc(22, 30)) + entry = await setup_conductor(hass) + target = entity_id(hass, entry, "number", "target_hours") + lit_today = entity_id(hass, entry, "sensor", "lit_today") + state_sensor = entity_id(hass, entry, "sensor", "state") + + await hass.services.async_call( + "number", "set_value", {"entity_id": target, "value": 1.0}, blocking=True + ) + hass.states.async_set(SLEEP, "on") + await hass.async_block_till_done() + assert light_is_on(hass) + + # One hour later the cap hits (rule 2.3): light off, sated. + await advance(hass, freezer, 3601) + assert not light_is_on(hass) + assert hass.states.get(state_sensor).state == "sated" + assert hass.states.get(state_sensor).attributes["reason"] == "target_reached" + assert float(hass.states.get(lit_today).state) == 1.0 + + # Next anchor (22:00 next day): budget resets, still asleep → relight. + freezer.move_to(utc(22, 0, day=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert light_is_on(hass) + assert float(hass.states.get(lit_today).state) == 0.0 + + +async def test_manual_flip_is_reconciled(hass: HomeAssistant, freezer) -> None: + """Rule 4.1: while enabled, an external flip is corrected immediately.""" + freezer.move_to(utc(22, 30)) + await setup_conductor(hass) + assert not light_is_on(hass) + + await hass.services.async_call("input_boolean", "turn_on", {"entity_id": LIGHT}, blocking=True) + await hass.async_block_till_done() + assert not light_is_on(hass) # conductor turned it right back off + + +async def test_disabled_stops_commands_but_keeps_accounting(hass: HomeAssistant, freezer) -> None: + """Rule 4.3: standby leaves the switch alone; manual light still accrues.""" + freezer.move_to(utc(22, 30)) + entry = await setup_conductor(hass) + enabled = entity_id(hass, entry, "switch", "enabled") + lit_today = entity_id(hass, entry, "sensor", "lit_today") + state_sensor = entity_id(hass, entry, "sensor", "state") + + await hass.services.async_call("switch", "turn_off", {"entity_id": enabled}, blocking=True) + assert hass.states.get(state_sensor).state == "standby" + + # Sleep would normally light it — standby does not command. + hass.states.async_set(SLEEP, "on") + await hass.async_block_till_done() + assert not light_is_on(hass) + + # Manual light for 30 minutes accrues budget (rule 2.2). + await hass.services.async_call("input_boolean", "turn_on", {"entity_id": LIGHT}, blocking=True) + await hass.async_block_till_done() # let the report land before time jumps + await advance(hass, freezer, 1800) + assert light_is_on(hass) # still untouched + assert float(hass.states.get(lit_today).state) == 0.5 + + +async def test_restore_across_reload(hass: HomeAssistant, freezer) -> None: + """Rules 4.3 + 5.1: enabled flag, target and budget survive a reload.""" + freezer.move_to(utc(22, 30)) + entry = await setup_conductor(hass) + target = entity_id(hass, entry, "number", "target_hours") + + await hass.services.async_call( + "number", "set_value", {"entity_id": target, "value": 6.0}, blocking=True + ) + hass.states.async_set(SLEEP, "on") + await hass.async_block_till_done() + assert light_is_on(hass) + await advance(hass, freezer, 7200) # two hours of light delivered + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + controller = hass.data[DOMAIN][entry.entry_id] + assert controller.engine.target_hours == 6.0 + # The two delivered hours survived the reload (rule 5.1). + lit_today = entity_id(hass, entry, "sensor", "lit_today") + assert float(hass.states.get(lit_today).state) == 2.0 diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..c2a3fbc --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,74 @@ +"""Entry lifecycle: setup, entity inventory, unload, options reload.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.grow_conductor.const import ( + CONF_ANCHOR, + CONF_LIGHT, + DOMAIN, +) + + +async def make_entry(hass: HomeAssistant) -> MockConfigEntry: + assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("switch.plantelys", "off") + entry = MockConfigEntry( + domain=DOMAIN, + title="Test plantelys", + data={}, + options={CONF_LIGHT: "switch.plantelys"}, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +async def test_setup_creates_device_and_entities(hass: HomeAssistant) -> None: + entry = await make_entry(hass) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + assert device is not None + assert device.name == "Test plantelys" + + entity_registry = er.async_get(hass) + unique_ids = { + e.unique_id.removeprefix(f"{entry.entry_id}_") + for e in er.async_entries_for_config_entry(entity_registry, entry.entry_id) + } + assert unique_ids == {"enabled", "target_hours", "state", "lit_today"} + + +async def test_unload_cleans_up(hass: HomeAssistant) -> None: + entry = await make_entry(hass) + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.entry_id not in hass.data[DOMAIN] + + +async def test_options_update_reloads(hass: HomeAssistant) -> None: + entry = await make_entry(hass) + controller_before = hass.data[DOMAIN][entry.entry_id] + hass.config_entries.async_update_entry( + entry, options={**entry.options, CONF_ANCHOR: "21:00:00"} + ) + await hass.async_block_till_done() + controller_after = hass.data[DOMAIN][entry.entry_id] + assert controller_after is not controller_before + assert controller_after.engine.tunables.anchor_minutes == 21 * 60 + + +async def test_minimal_config_never_lights(hass: HomeAssistant) -> None: + """No signals configured: rule 1.8 keeps the household OBSERVED forever.""" + entry = await make_entry(hass) + controller = hass.data[DOMAIN][entry.entry_id] + decision = controller.engine.decision + assert decision is not None + assert not decision.want_on diff --git a/tests/test_recorder_discipline.py b/tests/test_recorder_discipline.py new file mode 100644 index 0000000..60be446 --- /dev/null +++ b/tests/test_recorder_discipline.py @@ -0,0 +1,47 @@ +"""Recorder discipline: dispatcher churn must not become state writes. + +House rule (learned the hard way in presence-conductor): every +state_changed event writes a recorder row, so entities may only publish +when their *published* value actually changes. This drives 24 rounds of +input churn through the controller and asserts zero writes anywhere. +""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from tests.test_e2e import ( + REFUGE, + SLEEP, + entity_id, + light_is_on, + setup_conductor, + utc, +) + + +async def test_no_writes_on_unrelated_churn(hass: HomeAssistant, freezer) -> None: + freezer.move_to(utc(22, 30)) + entry = await setup_conductor(hass) + + hass.states.async_set(SLEEP, "on") + await hass.async_block_till_done() + assert light_is_on(hass) + + watched = [ + entity_id(hass, entry, "sensor", "lit_today"), + entity_id(hass, entry, "sensor", "state"), + entity_id(hass, entry, "switch", "enabled"), + entity_id(hass, entry, "number", "target_hours"), + ] + before = {e: hass.states.get(e).last_updated for e in watched} + + # Refuge flapping while asleep: 24 snapshots, decision never changes + # (rule 1.4 outranks refuge) — no entity may write a single row. + for i in range(24): + hass.states.async_set(REFUGE, "on" if i % 2 == 0 else "off") + await hass.async_block_till_done() + + assert light_is_on(hass) + for e in watched: + assert hass.states.get(e).last_updated == before[e], f"{e} wrote during churn" From 953f9d241e34cb70fcfa026f344ac057449504fc Mon Sep 17 00:00:00 2001 From: Leik Lima-Eriksen Date: Thu, 23 Jul 2026 19:10:37 +0000 Subject: [PATCH 2/3] Adapter: movement-trigger entities (rule 1.3b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New trigger_entities option: the controller subscribes to their transitions separately (their level never enters the snapshot) and pulses the engine's activity clock on every real state change — unknown/unavailable flaps and attribute-only writes are filtered. Config-flow field, en+nb translations, README; migration guide now maps binary_sensor.soverom_dor as a trigger for both lights, reproducing the legacy instant cut on door-open without an open door ever blocking the schedule. e2e test: night cut, relight-while-open, unavailable flap. Co-Authored-By: Claude Fable 5 --- README.md | 5 +++ custom_components/grow_conductor/config.py | 10 +++++- .../grow_conductor/config_flow.py | 14 +++++++- custom_components/grow_conductor/const.py | 4 +++ .../grow_conductor/controller.py | 21 +++++++++++ custom_components/grow_conductor/strings.json | 12 ++++--- .../grow_conductor/translations/en.json | 12 ++++--- .../grow_conductor/translations/nb.json | 12 ++++--- docs/MIGRATION.md | 8 +++-- tests/test_config_flow.py | 6 +++- tests/test_e2e.py | 36 +++++++++++++++++++ 11 files changed, 123 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 80273ef..e810b5c 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,11 @@ The flow asks for the light switch and the visibility signals: while you are awake at home — the work-from-home case. - **Hard vetoes** — signals that force the light off regardless, e.g. the TV in the same room. +- **Movement triggers** — entities whose state *changes* mean someone + is on the move near the sleep boundary, e.g. the bedroom door + contact: every change cuts the light instantly and starts the + quiet-time hold. Only transitions count — a door left open all day + never blocks the schedule. All signals are optional, but with none configured the fail-safe keeps the light permanently off. Timing tunables (anchor, quiet-time hold, diff --git a/custom_components/grow_conductor/config.py b/custom_components/grow_conductor/config.py index d0bbee8..a143365 100644 --- a/custom_components/grow_conductor/config.py +++ b/custom_components/grow_conductor/config.py @@ -14,6 +14,7 @@ CONF_REFUGE_CONFIRM, CONF_REFUGES, CONF_SLEEP, + CONF_TRIGGERS, CONF_VETOES, CONF_VIEWERS, DEFAULT_ANCHOR, @@ -36,6 +37,7 @@ class Config: viewer_entities: tuple[str, ...] = () refuge_entities: tuple[str, ...] = () veto_entities: tuple[str, ...] = () + trigger_entities: tuple[str, ...] = () tunables: Tunables = field(default_factory=Tunables) @classmethod @@ -47,6 +49,7 @@ def from_options(cls, options: dict[str, Any]) -> Config: viewer_entities=tuple(options.get(CONF_VIEWERS, ())), refuge_entities=tuple(options.get(CONF_REFUGES, ())), veto_entities=tuple(options.get(CONF_VETOES, ())), + trigger_entities=tuple(options.get(CONF_TRIGGERS, ())), tunables=Tunables( anchor_minutes=_anchor_minutes(options.get(CONF_ANCHOR, DEFAULT_ANCHOR)), clear_hold_s=float(options.get(CONF_CLEAR_HOLD, Tunables().clear_hold_s)), @@ -59,6 +62,11 @@ def from_options(cls, options: dict[str, Any]) -> Config: @property def input_entities(self) -> tuple[str, ...]: - """Every entity that feeds the input snapshot (not the light itself).""" + """Every entity that feeds the input snapshot (not the light itself). + + Trigger entities are deliberately absent: their level is never + part of the snapshot (rule 1.3b) — the controller subscribes to + their transitions separately. + """ singles = tuple(e for e in (self.sleep_entity, self.home_entity) if e) return singles + self.viewer_entities + self.refuge_entities + self.veto_entities diff --git a/custom_components/grow_conductor/config_flow.py b/custom_components/grow_conductor/config_flow.py index fb3c0e5..5a41ec8 100644 --- a/custom_components/grow_conductor/config_flow.py +++ b/custom_components/grow_conductor/config_flow.py @@ -33,6 +33,7 @@ CONF_REFUGE_CONFIRM, CONF_REFUGES, CONF_SLEEP, + CONF_TRIGGERS, CONF_VETOES, CONF_VIEWERS, DEFAULT_ANCHOR, @@ -74,6 +75,9 @@ def suggested(key: str) -> dict[str, Any]: CONF_REFUGES, default=list(defaults.get(CONF_REFUGES, [])) ): _MULTI_OCCUPANCY_SELECTOR, vol.Optional(CONF_VETOES, default=list(defaults.get(CONF_VETOES, []))): _VETO_SELECTOR, + vol.Optional( + CONF_TRIGGERS, default=list(defaults.get(CONF_TRIGGERS, [])) + ): _MULTI_OCCUPANCY_SELECTOR, } ) @@ -158,7 +162,15 @@ async def async_step_signals( options = { k: v for k, v in dict(self.config_entry.options).items() - if k not in (CONF_SLEEP, CONF_HOME, CONF_VIEWERS, CONF_REFUGES, CONF_VETOES) + if k + not in ( + CONF_SLEEP, + CONF_HOME, + CONF_VIEWERS, + CONF_REFUGES, + CONF_VETOES, + CONF_TRIGGERS, + ) } options.update(user_input) return self.async_create_entry(data=options) diff --git a/custom_components/grow_conductor/const.py b/custom_components/grow_conductor/const.py index 29965dd..5d21ebb 100644 --- a/custom_components/grow_conductor/const.py +++ b/custom_components/grow_conductor/const.py @@ -14,6 +14,9 @@ settled out of sight (rule 1.7). - ``veto_entities`` (list[str]) — hard "someone is watching" signals, e.g. the TV media_player (rule 1.2). +- ``trigger_entities`` (list[str]) — momentary movement signals whose + state *transitions* pulse the viewer-activity clock (rule 1.3b), + e.g. the bedroom door contact. Their level is never read. - ``anchor`` (str "HH:MM:SS") — plant-day boundary (rule 2.1). - ``clear_hold_seconds`` / ``refuge_confirm_seconds`` / ``min_block_seconds`` (number) — ENGINE_SPEC §6 tunables. @@ -32,6 +35,7 @@ CONF_VIEWERS = "viewer_entities" CONF_REFUGES = "refuge_entities" CONF_VETOES = "veto_entities" +CONF_TRIGGERS = "trigger_entities" CONF_ANCHOR = "anchor" CONF_CLEAR_HOLD = "clear_hold_seconds" CONF_REFUGE_CONFIRM = "refuge_confirm_seconds" diff --git a/custom_components/grow_conductor/controller.py b/custom_components/grow_conductor/controller.py index c993247..76f9280 100644 --- a/custom_components/grow_conductor/controller.py +++ b/custom_components/grow_conductor/controller.py @@ -96,6 +96,12 @@ async def async_start(self) -> None: self.hass, self.config.input_entities, self._on_input_event ) ) + if self.config.trigger_entities: + self._unsubs.append( + async_track_state_change_event( + self.hass, self.config.trigger_entities, self._on_trigger_event + ) + ) self._unsubs.append( async_track_state_change_event( self.hass, [self.config.light_entity], self._on_light_event @@ -178,6 +184,21 @@ def _on_input_event(self, event: Event[EventStateChangedData]) -> None: now = dt_util.now() self._after_decision(self.engine.handle_snapshot(self._build_snapshot(), now), now) + @callback + def _on_trigger_event(self, event: Event[EventStateChangedData]) -> None: + """Rule 1.3b: real transitions pulse the activity clock; the level, + unknown flaps, and attribute-only writes never do.""" + old = event.data.get("old_state") + new = event.data.get("new_state") + if old is None or new is None: + return + if old.state in _UNKNOWN_STATES or new.state in _UNKNOWN_STATES: + return + if old.state == new.state: + return + now = dt_util.now() + self._after_decision(self.engine.activity_pulse(now), now) + @callback def _on_light_event(self, event: Event[EventStateChangedData]) -> None: now = dt_util.now() diff --git a/custom_components/grow_conductor/strings.json b/custom_components/grow_conductor/strings.json index 5dd6fc8..0c89ca0 100644 --- a/custom_components/grow_conductor/strings.json +++ b/custom_components/grow_conductor/strings.json @@ -21,14 +21,16 @@ "home_entity": "Anyone home", "viewer_entities": "Rooms with a view of the light", "refuge_entities": "Out-of-sight rooms", - "veto_entities": "Hard vetoes" + "veto_entities": "Hard vetoes", + "trigger_entities": "Movement triggers" }, "data_description": { "sleep_entity": "On while the household sleeps — the prime window for running the light.", "home_entity": "A zone, person, tracker or sensor that tells whether anyone is home.", "viewer_entities": "Occupancy sensors covering every spot the light is visible from. Any activity here cuts the light immediately (night movement, morning routine).", "refuge_entities": "Occupancy sensors for rooms the light is NOT visible from, e.g. a home office. Sustained presence there lets the light run while you are awake at home (working from home).", - "veto_entities": "Signals that force the light off regardless of occupancy, e.g. the TV in the same room." + "veto_entities": "Signals that force the light off regardless of occupancy, e.g. the TV in the same room.", + "trigger_entities": "Entities whose state CHANGES mean someone is moving near the sleep boundary, e.g. the bedroom door contact. Each change cuts the light and starts the quiet-time hold; the state itself is ignored, so a door left open never blocks the schedule." } } }, @@ -51,14 +53,16 @@ "home_entity": "Anyone home", "viewer_entities": "Rooms with a view of the light", "refuge_entities": "Out-of-sight rooms", - "veto_entities": "Hard vetoes" + "veto_entities": "Hard vetoes", + "trigger_entities": "Movement triggers" }, "data_description": { "sleep_entity": "On while the household sleeps — the prime window for running the light.", "home_entity": "A zone, person, tracker or sensor that tells whether anyone is home.", "viewer_entities": "Occupancy sensors covering every spot the light is visible from.", "refuge_entities": "Occupancy in rooms without sight of the light; sustained presence allows daytime runs.", - "veto_entities": "Signals that force the light off regardless of occupancy." + "veto_entities": "Signals that force the light off regardless of occupancy.", + "trigger_entities": "Entities whose state CHANGES mean someone is moving near the sleep boundary, e.g. the bedroom door contact. Each change cuts the light and starts the quiet-time hold; the state itself is ignored, so a door left open never blocks the schedule." } }, "timing": { diff --git a/custom_components/grow_conductor/translations/en.json b/custom_components/grow_conductor/translations/en.json index 5dd6fc8..0c89ca0 100644 --- a/custom_components/grow_conductor/translations/en.json +++ b/custom_components/grow_conductor/translations/en.json @@ -21,14 +21,16 @@ "home_entity": "Anyone home", "viewer_entities": "Rooms with a view of the light", "refuge_entities": "Out-of-sight rooms", - "veto_entities": "Hard vetoes" + "veto_entities": "Hard vetoes", + "trigger_entities": "Movement triggers" }, "data_description": { "sleep_entity": "On while the household sleeps — the prime window for running the light.", "home_entity": "A zone, person, tracker or sensor that tells whether anyone is home.", "viewer_entities": "Occupancy sensors covering every spot the light is visible from. Any activity here cuts the light immediately (night movement, morning routine).", "refuge_entities": "Occupancy sensors for rooms the light is NOT visible from, e.g. a home office. Sustained presence there lets the light run while you are awake at home (working from home).", - "veto_entities": "Signals that force the light off regardless of occupancy, e.g. the TV in the same room." + "veto_entities": "Signals that force the light off regardless of occupancy, e.g. the TV in the same room.", + "trigger_entities": "Entities whose state CHANGES mean someone is moving near the sleep boundary, e.g. the bedroom door contact. Each change cuts the light and starts the quiet-time hold; the state itself is ignored, so a door left open never blocks the schedule." } } }, @@ -51,14 +53,16 @@ "home_entity": "Anyone home", "viewer_entities": "Rooms with a view of the light", "refuge_entities": "Out-of-sight rooms", - "veto_entities": "Hard vetoes" + "veto_entities": "Hard vetoes", + "trigger_entities": "Movement triggers" }, "data_description": { "sleep_entity": "On while the household sleeps — the prime window for running the light.", "home_entity": "A zone, person, tracker or sensor that tells whether anyone is home.", "viewer_entities": "Occupancy sensors covering every spot the light is visible from.", "refuge_entities": "Occupancy in rooms without sight of the light; sustained presence allows daytime runs.", - "veto_entities": "Signals that force the light off regardless of occupancy." + "veto_entities": "Signals that force the light off regardless of occupancy.", + "trigger_entities": "Entities whose state CHANGES mean someone is moving near the sleep boundary, e.g. the bedroom door contact. Each change cuts the light and starts the quiet-time hold; the state itself is ignored, so a door left open never blocks the schedule." } }, "timing": { diff --git a/custom_components/grow_conductor/translations/nb.json b/custom_components/grow_conductor/translations/nb.json index c06748a..f3fe459 100644 --- a/custom_components/grow_conductor/translations/nb.json +++ b/custom_components/grow_conductor/translations/nb.json @@ -21,14 +21,16 @@ "home_entity": "Noen hjemme", "viewer_entities": "Rom med sikt til lyset", "refuge_entities": "Rom uten sikt", - "veto_entities": "Harde vetoer" + "veto_entities": "Harde vetoer", + "trigger_entities": "Bevegelsestriggere" }, "data_description": { "sleep_entity": "På mens husstanden sover — det beste vinduet for å kjøre lyset.", "home_entity": "En sone, person, sporer eller sensor som forteller om noen er hjemme.", "viewer_entities": "Bevegelsessensorer som dekker alle steder lyset er synlig fra. Aktivitet her slukker lyset umiddelbart (nattlig bevegelse, morgenrutine).", "refuge_entities": "Bevegelsessensorer for rom lyset IKKE er synlig fra, f.eks. hjemmekontor. Vedvarende tilstedeværelse der lar lyset stå på mens du er våken hjemme (hjemmekontor).", - "veto_entities": "Signaler som tvinger lyset av uansett, f.eks. TV-en i samme rom." + "veto_entities": "Signaler som tvinger lyset av uansett, f.eks. TV-en i samme rom.", + "trigger_entities": "Enheter der en tilstandsendring betyr at noen beveger seg ved sovegrensen, f.eks. soveromsdørens kontakt. Hver endring slukker lyset og starter stilletiden; selve tilstanden ignoreres, så en åpen dør blokkerer aldri lysplanen." } } }, @@ -51,14 +53,16 @@ "home_entity": "Noen hjemme", "viewer_entities": "Rom med sikt til lyset", "refuge_entities": "Rom uten sikt", - "veto_entities": "Harde vetoer" + "veto_entities": "Harde vetoer", + "trigger_entities": "Bevegelsestriggere" }, "data_description": { "sleep_entity": "På mens husstanden sover — det beste vinduet for å kjøre lyset.", "home_entity": "En sone, person, sporer eller sensor som forteller om noen er hjemme.", "viewer_entities": "Bevegelsessensorer som dekker alle steder lyset er synlig fra.", "refuge_entities": "Bevegelse i rom uten sikt til lyset; vedvarende tilstedeværelse tillater kjøring på dagtid.", - "veto_entities": "Signaler som tvinger lyset av uansett." + "veto_entities": "Signaler som tvinger lyset av uansett.", + "trigger_entities": "Enheter der en tilstandsendring betyr at noen beveger seg ved sovegrensen, f.eks. soveromsdørens kontakt. Hver endring slukker lyset og starter stilletiden; selve tilstanden ignoreres, så en åpen dør blokkerer aldri lysplanen." } }, "timing": { diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 54df391..63ff022 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -5,8 +5,10 @@ Required State" template binary sensor, an "… Plantelys - Enforce State" automation, and an "… Plantelys On Last 24 Hours" history_stats helper. The night-movement plumbing (`input_boolean.night_movement` and its automation) **stays** — path lighting still uses it; Grow Conductor -simply doesn't need it, because viewer occupancy covers night movement -directly (rule 1.3). +simply doesn't need it: viewer occupancy covers night movement directly +(rule 1.3), and the bedroom door as a movement trigger (rule 1.3b) +reproduces the legacy instant cut on door-open — without an open door +ever blocking the schedule. ## 1. Create the devices @@ -20,6 +22,7 @@ directly (rule 1.3). | Rooms with a view | `binary_sensor.sofakrok_occupancy`, `binary_sensor.spisebord_occupancy`, `binary_sensor.kjokken_occupancy` | | Out-of-sight rooms | `binary_sensor.kontor_occupancy` | | Hard vetoes | — (viewer occupancy already covers TV watchers in sofakrok) | +| Movement triggers | `binary_sensor.soverom_dor` | | Target hours | 14 (legacy: 16 weekdays / 12 weekends) | **Spisebord plantelys** @@ -32,6 +35,7 @@ directly (rule 1.3). | Rooms with a view | `binary_sensor.spisebord_occupancy`, `binary_sensor.sofakrok_occupancy`, `binary_sensor.kjokken_occupancy` | | Out-of-sight rooms | `binary_sensor.kontor_occupancy` | | Hard vetoes | `media_player.sofakrok_tv` (legacy behavior: TV on ⇒ off) | +| Movement triggers | `binary_sensor.soverom_dor` | | Target hours | 13 (legacy: 14 weekdays / 12 weekends) | Presence Conductor room occupancy entities are the intended viewer/ diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 892ef6f..93a3479 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -14,6 +14,7 @@ CONF_REFUGE_CONFIRM, CONF_REFUGES, CONF_SLEEP, + CONF_TRIGGERS, CONF_VETOES, CONF_VIEWERS, DOMAIN, @@ -38,6 +39,7 @@ async def test_full_flow_creates_entry_with_options_contract(hass: HomeAssistant CONF_VIEWERS: ["binary_sensor.sofakrok_occupancy"], CONF_REFUGES: ["binary_sensor.kontor_occupancy"], CONF_VETOES: ["media_player.sofakrok_tv"], + CONF_TRIGGERS: ["binary_sensor.soverom_dor"], }, ) assert result["type"] == "create_entry" @@ -50,6 +52,7 @@ async def test_full_flow_creates_entry_with_options_contract(hass: HomeAssistant CONF_VIEWERS: ["binary_sensor.sofakrok_occupancy"], CONF_REFUGES: ["binary_sensor.kontor_occupancy"], CONF_VETOES: ["media_player.sofakrok_tv"], + CONF_TRIGGERS: ["binary_sensor.soverom_dor"], } @@ -121,7 +124,8 @@ async def test_options_flow_signals_can_clear_entities(hass: HomeAssistant) -> N ) # Submit without the sleep entity: it is cleared, timing survives. result = await hass.config_entries.options.async_configure( - result["flow_id"], {CONF_VIEWERS: [], CONF_REFUGES: [], CONF_VETOES: []} + result["flow_id"], + {CONF_VIEWERS: [], CONF_REFUGES: [], CONF_VETOES: [], CONF_TRIGGERS: []}, ) assert result["type"] == "create_entry" assert CONF_SLEEP not in result["data"] diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 4187c19..dba5993 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -23,6 +23,7 @@ CONF_LIGHT, CONF_REFUGES, CONF_SLEEP, + CONF_TRIGGERS, CONF_VETOES, CONF_VIEWERS, DOMAIN, @@ -34,6 +35,7 @@ VIEWER = "binary_sensor.stue" REFUGE = "binary_sensor.kontor" VETO = "media_player.tv" +DOOR = "binary_sensor.soverom_dor" OPTIONS: dict[str, Any] = { CONF_LIGHT: LIGHT, @@ -42,6 +44,7 @@ CONF_VIEWERS: [VIEWER], CONF_REFUGES: [REFUGE], CONF_VETOES: [VETO], + CONF_TRIGGERS: [DOOR], } @@ -59,6 +62,7 @@ async def setup_conductor(hass: HomeAssistant) -> MockConfigEntry: hass.states.async_set(VIEWER, "off") hass.states.async_set(REFUGE, "off") hass.states.async_set(VETO, "off") + hass.states.async_set(DOOR, "off") await hass.async_block_till_done() entry = MockConfigEntry(domain=DOMAIN, title="Stue plantelys", data={}, options=OPTIONS) @@ -142,6 +146,7 @@ async def test_veto_and_refuge(hass: HomeAssistant, freezer) -> None: # TV off again: immediate relight (no release hold). hass.states.async_set(VETO, "off") + hass.states.async_set(DOOR, "off") await hass.async_block_till_done() assert light_is_on(hass) @@ -235,3 +240,34 @@ async def test_restore_across_reload(hass: HomeAssistant, freezer) -> None: # The two delivered hours survived the reload (rule 5.1). lit_today = entity_id(hass, entry, "sensor", "lit_today") assert float(hass.states.get(lit_today).state) == 2.0 + + +async def test_door_trigger_cuts_but_open_door_never_blocks(hass: HomeAssistant, freezer) -> None: + """Rule 1.3b end-to-end: transitions pulse, the level is ignored.""" + freezer.move_to(utc(22, 30)) + entry = await setup_conductor(hass) + state_sensor = entity_id(hass, entry, "sensor", "state") + + hass.states.async_set(SLEEP, "on") + await hass.async_block_till_done() + assert light_is_on(hass) + + # Bedroom door opens at night: instant cut, before any occupancy fires. + hass.states.async_set(DOOR, "on") + await hass.async_block_till_done() + assert not light_is_on(hass) + assert hass.states.get(state_sensor).state == "cooldown" + + # The door STAYS open — after the hold the light returns anyway, + # because a trigger's level is never read. + await advance(hass, freezer, 601) + assert light_is_on(hass) + assert hass.states.get(state_sensor).state == "lit" + + # Unavailable flaps are not transitions: no pulse, no cut. + hass.states.async_set(DOOR, "unavailable") + await hass.async_block_till_done() + hass.states.async_set(DOOR, "on") + await hass.async_block_till_done() + assert light_is_on(hass) + assert hass.states.get(state_sensor).state == "lit" From 180132a13fdae40dee84412eb8588becba853932 Mon Sep 17 00:00:00 2001 From: Leik Lima-Eriksen Date: Thu, 23 Jul 2026 19:29:48 +0000 Subject: [PATCH 3/3] Adapter: refuge-hold and exposure-grace tunables (rules 1.3c, 1.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Options → Timing gains refuge_hold_seconds and exposure_grace_seconds; en+nb translations, README. e2e test: a coffee run (kitchen viewer firing while the office sensor decays) never toggles the WFH block, a door pulse during refuge is ignored, and someone settling within sight still cuts after the grace. Co-Authored-By: Claude Fable 5 --- README.md | 9 +++- custom_components/grow_conductor/config.py | 6 +++ .../grow_conductor/config_flow.py | 10 +++++ custom_components/grow_conductor/const.py | 3 ++ custom_components/grow_conductor/strings.json | 8 +++- .../grow_conductor/translations/en.json | 8 +++- .../grow_conductor/translations/nb.json | 8 +++- tests/test_e2e.py | 41 +++++++++++++++++++ 8 files changed, 85 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e810b5c..558d2a4 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,11 @@ The flow asks for the light switch and the visibility signals: the quiet-time hold (night movement, morning routine — same rule). - **Out-of-sight rooms** — occupancy for rooms that *cannot* see the light (home office). Sustained presence there allows daytime runs - while you are awake at home — the work-from-home case. + while you are awake at home — the work-from-home case. Refuge blocks + are deliberately tolerant: a coffee run through a viewer room neither + cuts the light nor resets anything (exposure grace), and short sensor + dropouts don't release the block (refuge hold). Settling within sight + of the light still cuts it. - **Hard vetoes** — signals that force the light off regardless, e.g. the TV in the same room. - **Movement triggers** — entities whose state *changes* mean someone @@ -72,7 +76,8 @@ The flow asks for the light switch and the visibility signals: All signals are optional, but with none configured the fail-safe keeps the light permanently off. Timing tunables (anchor, quiet-time hold, -refuge confirmation, minimum block) live under Options → Timing. +refuge confirmation/hold, exposure grace, minimum block) live under +Options → Timing. ## Releases & channels diff --git a/custom_components/grow_conductor/config.py b/custom_components/grow_conductor/config.py index a143365..a2457f5 100644 --- a/custom_components/grow_conductor/config.py +++ b/custom_components/grow_conductor/config.py @@ -8,10 +8,12 @@ from .const import ( CONF_ANCHOR, CONF_CLEAR_HOLD, + CONF_EXPOSURE_GRACE, CONF_HOME, CONF_LIGHT, CONF_MIN_BLOCK, CONF_REFUGE_CONFIRM, + CONF_REFUGE_HOLD, CONF_REFUGES, CONF_SLEEP, CONF_TRIGGERS, @@ -56,6 +58,10 @@ def from_options(cls, options: dict[str, Any]) -> Config: refuge_confirm_s=float( options.get(CONF_REFUGE_CONFIRM, Tunables().refuge_confirm_s) ), + refuge_hold_s=float(options.get(CONF_REFUGE_HOLD, Tunables().refuge_hold_s)), + exposure_grace_s=float( + options.get(CONF_EXPOSURE_GRACE, Tunables().exposure_grace_s) + ), min_block_s=float(options.get(CONF_MIN_BLOCK, Tunables().min_block_s)), ), ) diff --git a/custom_components/grow_conductor/config_flow.py b/custom_components/grow_conductor/config_flow.py index 5a41ec8..5914f5e 100644 --- a/custom_components/grow_conductor/config_flow.py +++ b/custom_components/grow_conductor/config_flow.py @@ -27,10 +27,12 @@ from .const import ( CONF_ANCHOR, CONF_CLEAR_HOLD, + CONF_EXPOSURE_GRACE, CONF_HOME, CONF_LIGHT, CONF_MIN_BLOCK, CONF_REFUGE_CONFIRM, + CONF_REFUGE_HOLD, CONF_REFUGES, CONF_SLEEP, CONF_TRIGGERS, @@ -104,6 +106,14 @@ def _timing_schema(defaults: dict[str, Any]) -> vol.Schema: CONF_REFUGE_CONFIRM, default=defaults.get(CONF_REFUGE_CONFIRM, tunables.refuge_confirm_s), ): _seconds_selector(3600), + vol.Required( + CONF_REFUGE_HOLD, + default=defaults.get(CONF_REFUGE_HOLD, tunables.refuge_hold_s), + ): _seconds_selector(7200), + vol.Required( + CONF_EXPOSURE_GRACE, + default=defaults.get(CONF_EXPOSURE_GRACE, tunables.exposure_grace_s), + ): _seconds_selector(3600), vol.Required( CONF_MIN_BLOCK, default=defaults.get(CONF_MIN_BLOCK, tunables.min_block_s) ): _seconds_selector(7200), diff --git a/custom_components/grow_conductor/const.py b/custom_components/grow_conductor/const.py index 5d21ebb..9c934db 100644 --- a/custom_components/grow_conductor/const.py +++ b/custom_components/grow_conductor/const.py @@ -19,6 +19,7 @@ e.g. the bedroom door contact. Their level is never read. - ``anchor`` (str "HH:MM:SS") — plant-day boundary (rule 2.1). - ``clear_hold_seconds`` / ``refuge_confirm_seconds`` / + ``refuge_hold_seconds`` / ``exposure_grace_seconds`` / ``min_block_seconds`` (number) — ENGINE_SPEC §6 tunables. Runtime knobs (``number.target_hours``, ``switch.enabled``) are entity @@ -39,6 +40,8 @@ CONF_ANCHOR = "anchor" CONF_CLEAR_HOLD = "clear_hold_seconds" CONF_REFUGE_CONFIRM = "refuge_confirm_seconds" +CONF_REFUGE_HOLD = "refuge_hold_seconds" +CONF_EXPOSURE_GRACE = "exposure_grace_seconds" CONF_MIN_BLOCK = "min_block_seconds" DEFAULT_ANCHOR = "22:00:00" diff --git a/custom_components/grow_conductor/strings.json b/custom_components/grow_conductor/strings.json index 0c89ca0..9d7efa0 100644 --- a/custom_components/grow_conductor/strings.json +++ b/custom_components/grow_conductor/strings.json @@ -71,13 +71,17 @@ "anchor": "Plant-day start", "clear_hold_seconds": "Quiet time before lighting", "refuge_confirm_seconds": "Refuge confirmation time", - "min_block_seconds": "Minimum worthwhile block" + "min_block_seconds": "Minimum worthwhile block", + "refuge_hold_seconds": "Refuge hold", + "exposure_grace_seconds": "Exposure grace" }, "data_description": { "anchor": "When the daily light budget resets — ideally around your usual bedtime.", "clear_hold_seconds": "How long the viewed rooms must stay quiet before the light may turn on.", "refuge_confirm_seconds": "How long an out-of-sight room must be continuously occupied before it counts as refuge.", - "min_block_seconds": "Never start a light block shorter than this; avoids end-of-day dribble." + "min_block_seconds": "Never start a light block shorter than this; avoids end-of-day dribble.", + "refuge_hold_seconds": "How long confirmed refuge survives gaps in the evidence — covers sensor decay while fetching coffee.", + "exposure_grace_seconds": "How long viewer activity is tolerated during refuge before the light cuts. Set to 0 for instant cuts even while working from home." } } } diff --git a/custom_components/grow_conductor/translations/en.json b/custom_components/grow_conductor/translations/en.json index 0c89ca0..9d7efa0 100644 --- a/custom_components/grow_conductor/translations/en.json +++ b/custom_components/grow_conductor/translations/en.json @@ -71,13 +71,17 @@ "anchor": "Plant-day start", "clear_hold_seconds": "Quiet time before lighting", "refuge_confirm_seconds": "Refuge confirmation time", - "min_block_seconds": "Minimum worthwhile block" + "min_block_seconds": "Minimum worthwhile block", + "refuge_hold_seconds": "Refuge hold", + "exposure_grace_seconds": "Exposure grace" }, "data_description": { "anchor": "When the daily light budget resets — ideally around your usual bedtime.", "clear_hold_seconds": "How long the viewed rooms must stay quiet before the light may turn on.", "refuge_confirm_seconds": "How long an out-of-sight room must be continuously occupied before it counts as refuge.", - "min_block_seconds": "Never start a light block shorter than this; avoids end-of-day dribble." + "min_block_seconds": "Never start a light block shorter than this; avoids end-of-day dribble.", + "refuge_hold_seconds": "How long confirmed refuge survives gaps in the evidence — covers sensor decay while fetching coffee.", + "exposure_grace_seconds": "How long viewer activity is tolerated during refuge before the light cuts. Set to 0 for instant cuts even while working from home." } } } diff --git a/custom_components/grow_conductor/translations/nb.json b/custom_components/grow_conductor/translations/nb.json index f3fe459..d25475c 100644 --- a/custom_components/grow_conductor/translations/nb.json +++ b/custom_components/grow_conductor/translations/nb.json @@ -71,13 +71,17 @@ "anchor": "Plantedagens start", "clear_hold_seconds": "Stilletid før tenning", "refuge_confirm_seconds": "Bekreftelsestid for rom uten sikt", - "min_block_seconds": "Minste blokk verdt å starte" + "min_block_seconds": "Minste blokk verdt å starte", + "refuge_hold_seconds": "Tilfluktshold", + "exposure_grace_seconds": "Eksponeringsfrist" }, "data_description": { "anchor": "Når det daglige lysbudsjettet nullstilles — helst rundt vanlig leggetid.", "clear_hold_seconds": "Hvor lenge rommene med sikt må være stille før lyset kan tennes.", "refuge_confirm_seconds": "Hvor lenge et rom uten sikt må være sammenhengende i bruk før det teller som tilflukt.", - "min_block_seconds": "Start aldri en lysblokk kortere enn dette; unngår småstubber på slutten av dagen." + "min_block_seconds": "Start aldri en lysblokk kortere enn dette; unngår småstubber på slutten av dagen.", + "refuge_hold_seconds": "Hvor lenge bekreftet tilflukt overlever hull i sensorbeviset — dekker sensorutfall mens du henter kaffe.", + "exposure_grace_seconds": "Hvor lenge aktivitet med sikt til lyset tolereres under tilflukt før lyset slukkes. Sett til 0 for umiddelbar slukking også på hjemmekontordager." } } } diff --git a/tests/test_e2e.py b/tests/test_e2e.py index dba5993..7130209 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -271,3 +271,44 @@ async def test_door_trigger_cuts_but_open_door_never_blocks(hass: HomeAssistant, await hass.async_block_till_done() assert light_is_on(hass) assert hass.states.get(state_sensor).state == "lit" + + +async def test_coffee_run_does_not_toggle_wfh_block(hass: HomeAssistant, freezer) -> None: + """Rules 1.3c + 1.7 end-to-end: the WFH block survives a coffee run.""" + freezer.move_to(utc(22, 30)) + entry = await setup_conductor(hass) + state_sensor = entity_id(hass, entry, "sensor", "state") + + # Settle into the office; refuge confirms after 180 s. + hass.states.async_set(REFUGE, "on") + await hass.async_block_till_done() + await advance(hass, freezer, 181) + assert light_is_on(hass) + + # Coffee run: kitchen viewer fires AND the office sensor decays. + hass.states.async_set(VIEWER, "on") + hass.states.async_set(REFUGE, "off") + await hass.async_block_till_done() + assert light_is_on(hass) # tolerated exposure — no toggle + assert hass.states.get(state_sensor).state == "lit" + + # Two minutes later, back at the desk: still on, never flickered. + await advance(hass, freezer, 120) + hass.states.async_set(VIEWER, "off") + hass.states.async_set(REFUGE, "on") + await hass.async_block_till_done() + assert light_is_on(hass) + assert hass.states.get(state_sensor).state == "lit" + + # A door pulse during refuge is ignored too (rule 1.3b + 1.3c). + hass.states.async_set(DOOR, "on") + await hass.async_block_till_done() + assert light_is_on(hass) + + # But someone SETTLING in the living room still cuts after the grace. + hass.states.async_set(VIEWER, "on") + await hass.async_block_till_done() + assert light_is_on(hass) + await advance(hass, freezer, 301) + assert not light_is_on(hass) + assert hass.states.get(state_sensor).attributes["reason"] == "viewers"