diff --git a/README.md b/README.md index 80273ef..558d2a4 100644 --- a/README.md +++ b/README.md @@ -61,13 +61,23 @@ 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 + 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, -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/__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..a2457f5 --- /dev/null +++ b/custom_components/grow_conductor/config.py @@ -0,0 +1,78 @@ +"""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_EXPOSURE_GRACE, + CONF_HOME, + CONF_LIGHT, + CONF_MIN_BLOCK, + CONF_REFUGE_CONFIRM, + CONF_REFUGE_HOLD, + CONF_REFUGES, + CONF_SLEEP, + CONF_TRIGGERS, + 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, ...] = () + trigger_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, ())), + 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)), + 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)), + ), + ) + + @property + def input_entities(self) -> tuple[str, ...]: + """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 new file mode 100644 index 0000000..5914f5e --- /dev/null +++ b/custom_components/grow_conductor/config_flow.py @@ -0,0 +1,196 @@ +"""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_EXPOSURE_GRACE, + CONF_HOME, + CONF_LIGHT, + CONF_MIN_BLOCK, + CONF_REFUGE_CONFIRM, + CONF_REFUGE_HOLD, + CONF_REFUGES, + CONF_SLEEP, + CONF_TRIGGERS, + 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, + vol.Optional( + CONF_TRIGGERS, default=list(defaults.get(CONF_TRIGGERS, [])) + ): _MULTI_OCCUPANCY_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_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), + } + ) + + +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, + CONF_TRIGGERS, + ) + } + 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..9c934db --- /dev/null +++ b/custom_components/grow_conductor/const.py @@ -0,0 +1,53 @@ +"""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). +- ``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`` / + ``refuge_hold_seconds`` / ``exposure_grace_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_TRIGGERS = "trigger_entities" +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" + +# 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..76f9280 --- /dev/null +++ b/custom_components/grow_conductor/controller.py @@ -0,0 +1,258 @@ +"""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 + ) + ) + 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 + ) + ) + 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_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() + 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..9d7efa0 --- /dev/null +++ b/custom_components/grow_conductor/strings.json @@ -0,0 +1,131 @@ +{ + "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", + "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.", + "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." + } + } + }, + "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", + "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.", + "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": { + "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", + "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.", + "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." + } + } + } + }, + "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..9d7efa0 --- /dev/null +++ b/custom_components/grow_conductor/translations/en.json @@ -0,0 +1,131 @@ +{ + "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", + "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.", + "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." + } + } + }, + "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", + "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.", + "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": { + "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", + "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.", + "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." + } + } + } + }, + "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..d25475c --- /dev/null +++ b/custom_components/grow_conductor/translations/nb.json @@ -0,0 +1,131 @@ +{ + "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", + "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.", + "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." + } + } + }, + "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", + "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.", + "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": { + "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", + "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.", + "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." + } + } + } + }, + "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..63ff022 --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,69 @@ +# 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: 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 + +**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) | +| Movement triggers | `binary_sensor.soverom_dor` | +| 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) | +| Movement triggers | `binary_sensor.soverom_dor` | +| 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..93a3479 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,133 @@ +"""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_TRIGGERS, + 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"], + CONF_TRIGGERS: ["binary_sensor.soverom_dor"], + }, + ) + 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"], + CONF_TRIGGERS: ["binary_sensor.soverom_dor"], + } + + +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: [], CONF_TRIGGERS: []}, + ) + 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..7130209 --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,314 @@ +"""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_TRIGGERS, + 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" +DOOR = "binary_sensor.soverom_dor" + +OPTIONS: dict[str, Any] = { + CONF_LIGHT: LIGHT, + CONF_SLEEP: SLEEP, + CONF_HOME: HOME, + CONF_VIEWERS: [VIEWER], + CONF_REFUGES: [REFUGE], + CONF_VETOES: [VETO], + CONF_TRIGGERS: [DOOR], +} + + +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") + hass.states.async_set(DOOR, "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") + hass.states.async_set(DOOR, "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 + + +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" + + +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" 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"