From ab69c46c312c98cf0b7383f7d887d263447d08ca Mon Sep 17 00:00:00 2001 From: Leik Lima-Eriksen Date: Wed, 22 Jul 2026 05:50:59 +0000 Subject: [PATCH 1/4] CI: guard checks on the files they verify existing pytest, hassfest and the HACS action skip while the tree does not yet contain tests or a manifest, so the stacked bring-up PRs stay green. No-ops on a complete tree. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6404dc4..06ebc45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: +# The steps below guard on the files they check existing, so the +# stacked bring-up PRs (docs -> core -> adapter) stay green while the +# integration is assembled. The guards are no-ops on a complete tree. jobs: lint: runs-on: ubuntu-latest @@ -26,6 +29,7 @@ jobs: with: python-version: "3.14" - name: Run tests + if: ${{ hashFiles('tests/**/*.py') != '' }} run: uv run pytest -q --cov --cov-report=term-missing hassfest: @@ -33,12 +37,14 @@ jobs: steps: - uses: actions/checkout@v4 - uses: home-assistant/actions/hassfest@master + if: ${{ hashFiles('custom_components/*/manifest.json') != '' }} hacs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: hacs/action@main + if: ${{ hashFiles('custom_components/*/manifest.json') != '' }} with: category: integration # Not (yet) in the home-assistant/brands repo — expected for a From 29fe3d9ecc3f8a62724fae2fafc829f9784a8306 Mon Sep 17 00:00:00 2001 From: Leik Lima-Eriksen Date: Wed, 22 Jul 2026 05:50:59 +0000 Subject: [PATCH 2/4] Design docs: engine spec and decision record ENGINE_SPEC.md is the normative contract (numbered rules, cited by code and tests). DECISION.md records why: plant-day budget over rolling 24h, one unified visibility model (the legacy weekday morning/arrival clauses were provably redundant; the weekend latch is dropped deliberately), refuge zones instead of workday-calendar guessing, greedy scheduling with hysteresis, fail-safe toward light-off, truth-based accounting. Co-Authored-By: Claude Fable 5 --- docs/DECISION.md | 95 +++++++++++++++++++++++ docs/ENGINE_SPEC.md | 178 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 docs/DECISION.md create mode 100644 docs/ENGINE_SPEC.md diff --git a/docs/DECISION.md b/docs/DECISION.md new file mode 100644 index 0000000..fd11a2a --- /dev/null +++ b/docs/DECISION.md @@ -0,0 +1,95 @@ +# Architecture Decision Record + +Decisions that shaped the engine, in the order they were made. Context: +this integration replaces two hand-grown template binary sensors +("Sofakrok/Spisebord Plantelys Required State"), two history_stats +helpers, two enforce-state automations, and the night-movement toggle +plumbing on the live instance. + +## 1. Plant-day budget, not a rolling 24-hour window + +The legacy system measured delivered light with a history_stats sensor +over a rolling 24 h window. A rolling window refills continuously, which +invites cap-flapping (at the cap, a minute of headroom trickles back +every minute) and makes "a day of light" hard to reason about. The +engine instead resets the budget at a fixed **anchor** (default 22:00 — +the same boundary the legacy away-schedule used). This is also closer to +what the plants experience: a photoperiod per day, followed by a dark +tail once the target is met. No carry-over between days: deficits are +accepted (fail-safe), never compensated where the household could see it. + +## 2. One unified visibility model instead of weekday/weekend branches + +Analysis of the legacy weekday template showed its `is_morning_routine` +and `arrival_cutoff` clauses were **redundant**: whenever the household +was awake and home, `should_be_on_by_schedule` was already false, so the +extra clauses never changed the outcome. The effective legacy semantics +were simply *(asleep ∧ no night movement ∧ no TV) ∨ nobody home*, capped +by the budget. The engine formalizes exactly that as §1 and drops the +workday/vacation inputs entirely — they only fed the redundant branches. + +The legacy **weekend latch** (light stays on after waking, while someone +is home, until the cap) was deliberately dropped: the owner's stated +requirement is to never see the light, on any day. Weekend behavior now +equals weekday behavior. + +## 3. Refuge zones instead of guessing "at work" from the workday sensor + +The legacy system could not tell "away at work" from "working from +home"; the owner compensated by hand. Room-granular occupancy (Presence +Conductor) makes this solvable directly: if a refuge zone (home office) +is continuously occupied and no viewer zone is active, the light may run +while the household is awake at home. This replaces calendar heuristics +with observed reality — a sick day on the sofa keeps the light off, a +WFH day in the office lights the plants. Refuge requires *positive, +confirmed* evidence (rule 1.7); absence of viewer activity alone is +never enough while someone is awake at home, because uncovered rooms +(bathroom, hallway) would otherwise flicker the light every time the +occupant passes through them. + +## 4. Greedy scheduling, no prediction + +The engine lights whenever it may (§1) until the budget is spent. It +does not try to predict sleep or absence windows to place blocks +"optimally". Greedy naturally front-loads light into the sleep window +(one long block starting shortly after bedtime), matches the legacy +behavior the plants have thrived under, and keeps the core small and +fully deterministic. The continuity refinements are hysteresis, not +prediction: `clear_hold`, `refuge_confirm`, and `min_block`. + +## 5. Fail-safe direction: unknown ⇒ observed ⇒ light off + +Presence Conductor fails toward "someone is present" to avoid cutting +audio; Grow Conductor fails the same direction, which here means **light +off**. The worst case of a sensor outage is a starved plant day; the +worst case of the opposite default is an ugly purple glow during dinner. +Exception (rule 1.8): unknown viewer/veto entities count as inactive, +because the sleep/home gates already protect the household and a dead +motion sensor must not permanently disable the schedule — this matches +the legacy templates, where an unavailable occupancy sensor simply never +triggered night movement. + +## 6. Budget accrues from the physical switch, not from commands + +The lights are exposed to HomeKit and have wall access; the legacy +history_stats counted every source of on-time. Rule 2.2 keeps that +property: manual light is real light and consumes the day's budget. +Consequence: while the enabled switch is off, the ledger still runs. + +## 7. Single writer while enabled + +The legacy enforce-automation only reacted to template-state *changes*, +so a manual flip stuck until the next transition. The controller instead +reconciles on every external flip (rule 4.1). Manual experimentation is +what the enabled switch is for — mixing two writers on one switch is how +the old system got into silently-wrong states. + +## 8. Core purity and testing conventions + +Inherited unchanged from sonos-conductor and presence-conductor: pure +core (`core/` has zero `homeassistant` imports, enforced by ruff TID251 +plus a poisoned-import test), event-driven engine with injected clocks, +adapter as a thin normalization + enforcement layer, uv tooling, +pytest-homeassistant-custom-component pinned to the production HA +version, and recorder discipline (no per-second entity churn; the +lit-hours sensor quantizes and rate-limits its publishes). diff --git a/docs/ENGINE_SPEC.md b/docs/ENGINE_SPEC.md new file mode 100644 index 0000000..da51869 --- /dev/null +++ b/docs/ENGINE_SPEC.md @@ -0,0 +1,178 @@ +# Grow Conductor — Engine Specification + +This document is the **normative contract** for the pure core in +`custom_components/grow_conductor/core/`. Code and tests cite these rule +numbers. If code and spec disagree, one of them has a bug — fix whichever +is wrong, never silently drift. + +The core never imports `homeassistant` (CI-enforced by ruff TID251 and +`tests/test_purity.py`). The adapter layer normalizes Home Assistant +entities into the input signals defined here and enforces the engine's +decisions on the physical switch. + +## 0. Problem statement and vocabulary + +A **grow light** must deliver up to `target_hours` of light per day to +plants that receive essentially no natural daylight. Because the light is +ugly, it may only run while **nobody can see it**. Subject to that hard +constraint, the engine maximizes delivered light and prefers **few, long +blocks** over many short ones (plants and humans both dislike flicker). + +- **Viewer zones** — the places from which the light is visible + (occupancy sensors for the room it is in and any room with sight lines). +- **Refuge zones** — places whose occupancy proves the occupants are + settled *elsewhere* and cannot see the light (e.g. a home office on a + work-from-home day). +- **Veto entities** — signals that force the light off regardless of + occupancy detection (e.g. the TV is playing, so someone is almost + certainly looking toward the light even if a sensor disagrees). +- **Plant day** — the scheduling period. It starts at the **anchor** + time (default 22:00 local, the household's typical sleep boundary) and + runs to the next anchor. +- **OBSERVED / UNOBSERVED** — the two visibility verdicts of §1. The + light may only be lit while UNOBSERVED. + +The engine is event-driven and deterministic: the adapter feeds it input +snapshots, light-state reports, and setting changes, each stamped with an +aware `datetime`; the engine returns a `Decision` (want the light on or +off, a display state, and the next time it must be re-evaluated even if +no input changes). The engine never reads clocks itself. + +## 1. Visibility + +Rule 1.1 (verdict). At any instant the household is either OBSERVED +(someone could see the light) or UNOBSERVED. The light may be commanded +on only while UNOBSERVED. The verdict is evaluated in the priority order +1.2 → 1.6; the first rule that applies wins. + +Rule 1.2 (veto). If any veto entity is active, the verdict is OBSERVED. +Veto release has no extra hold: if a veto clears and no other rule makes +the household OBSERVED, the light may return immediately. + +Rule 1.3 (viewer activity). If any viewer zone is active, or was last +active less than `clear_hold` ago (default 600 s), the verdict is +OBSERVED. The engine tracks the most recent viewer activity at all +times — including while asleep or away — so every transition into +UNOBSERVED implicitly waits out the hold. This single rule covers night +movement (waking up to visit the bathroom), the morning routine (moving +around before leaving), and "walked out the door but might step back in". + +Rule 1.4 (asleep). Otherwise, if the household is asleep, the verdict is +UNOBSERVED (reason `asleep`). This is the prime window. + +Rule 1.5 (away). Otherwise, if nobody is home, the verdict is UNOBSERVED +(reason `away`). + +Rule 1.6 (awake at home). Otherwise, someone is awake at home. The +verdict is UNOBSERVED (reason `refuge`) only if the refuge condition of +rule 1.7 holds; in every other case it is OBSERVED. There is no +weekend/weekday distinction and no "stay on after waking" latch: awake at +home without proof of refuge always means OBSERVED (see DECISION.md). + +Rule 1.7 (refuge confirmation). The refuge condition holds when at least +one refuge zone is active and has been **continuously** active for at +least `refuge_confirm` (default 180 s). Any gap resets the confirmation +clock. Refuge release is immediate — the moment no refuge zone is +active, rule 1.6 falls back to OBSERVED. (Sensors with built-in decay, +such as Presence Conductor room occupancy, make both directions smooth.) +If no refuge zones are configured, the refuge condition never holds. + +Rule 1.8 (fail-safe unknowns). Unknown inputs resolve toward OBSERVED: +an unknown sleep signal counts as awake; an unknown home signal counts +as home; an unknown refuge zone counts as inactive. Unknown viewer zones +and vetoes count as inactive (a dead sensor must not permanently veto +the schedule; the sleep/home gates above still protect the household — +this matches the legacy template behavior). The consequence is that a +total sensor outage starves the plants rather than exposing the light. + +## 2. Photoperiod budget + +Rule 2.1 (plant day). Delivered light is accounted per plant day. At +each anchor crossing the spent budget resets to zero. There is no +carry-over of deficit or surplus between plant days: a starved day is +accepted (fail-safe) and never causes compensation the household would +see. + +Rule 2.2 (accrual from truth). The budget accrues from the **reported +physical light state**, not from the engine's commands. Time the switch +spends on — regardless of who turned it on (the engine, a wall button, +HomeKit) — counts toward `target_hours`. While the light state is +unknown, nothing accrues. + +Rule 2.3 (cap). While spent ≥ `target_hours`, the engine wants the light +off (state `sated`) until the next anchor, regardless of visibility. + +Rule 2.4 (open blocks split at the anchor). A block burning across the +anchor keeps burning (continuity is preferred); the portion before the +anchor is charged to the old day and the portion after it to the new day. + +Rule 2.5 (minimum block). A **new** block may only start if the +remaining headroom is at least `min_block` (default 900 s); otherwise +the engine stays off (state `sated`, reason `low_headroom`). A block +already burning runs exactly to the cap. This prevents end-of-day +dribble without ever cutting a healthy block short. + +## 3. Decisions, timing, and continuity + +Rule 3.1 (decision). `want_on` is true iff: enabled (§4.3), UNOBSERVED +(§1), spent < target (2.3), and — when the light is currently off — the +minimum-block guard (2.5) passes. + +Rule 3.2 (next review). Every decision carries `next_review`, the +earliest future instant at which the verdict could change with no input +event: the cap instant while burning, the `clear_hold` expiry while +waiting on viewer quiet, the `refuge_confirm` instant while a refuge is +confirming, and the next anchor otherwise. The adapter must re-evaluate +at `next_review`; between events and reviews the decision is stable. + +Rule 3.3 (display state). The engine publishes exactly one of: +`lit` (with reason `asleep`/`away`/`refuge`), `observed` (with reason +`veto`/`viewers`/`awake_home`), `cooldown` (UNOBSERVED but waiting out +rule 1.3's hold — split from `observed` so the user can see the +countdown is working), `sated` (2.3 or 2.5, with reason +`target_reached`/`low_headroom`), or `standby` (disabled, §4.3). + +## 4. Enforcement and manual control + +Rule 4.1 (single writer). While enabled, the adapter reconciles the +physical switch to `want_on` — immediately on every decision change +**and** on every external flip of the switch. There is no manual +override while enabled; the escape hatch is rule 4.3. + +Rule 4.2 (idempotence). The adapter only issues a command when the +reported state differs from `want_on`; the engine's decisions must be +stable under redundant reports (same light state twice, unchanged +snapshots). + +Rule 4.3 (enabled switch). Each device has a restorable enabled switch. +While off: the engine issues no commands and reports state `standby`, +but keeps tracking visibility and accruing budget from reports (2.2), so +re-enabling is warm and manual light time is not double-delivered. + +## 5. Persistence + +Rule 5.1 (restore). Across a restart the adapter re-seeds the engine +with the persisted (plant-day start, spent seconds) pair. A seed whose +plant day differs from the current one is discarded — the ledger then +starts fresh at the current plant day's anchor (2.1 applies as if the +anchor were just crossed; mid-day restarts under-count at worst one +block, which fail-safes toward less light, never toward visible light). + +Rule 5.2 (seeding inputs). On startup the engine treats input history as +empty: no viewer activity is remembered (a restart during sleep lights +up without waiting `clear_hold` — acceptable, the household is asleep), +and refuge confirmation starts from zero. + +## 6. Tunables + +| Tunable | Default | Unit | Meaning | +| ---------------- | ------- | ------- | ----------------------------------------- | +| `target_hours` | 12.0 | h/day | Budget per plant day (number entity) | +| `anchor` | 22:00 | local | Plant-day boundary (1.x windows pivot) | +| `clear_hold` | 600 | s | Viewer quiet time before lighting (1.3) | +| `refuge_confirm` | 180 | s | Continuous refuge before lighting (1.7) | +| `min_block` | 900 | s | Smallest headroom worth starting (2.5) | + +`target_hours` is a live number entity (0–20 h, step 0.5); the rest are +options-flow settings. `target_hours` is clamped to 20 so every plant +day keeps at least a 4-hour dark period (photoperiod hygiene). From 5bb293c41924aa59b57065d26adabcfc5b7220a0 Mon Sep 17 00:00:00 2001 From: Leik Lima-Eriksen Date: Thu, 23 Jul 2026 18:00:53 +0000 Subject: [PATCH 3/4] Spec: trigger entities (rule 1.3b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Momentary movement signals (bedroom door contact) whose transitions pulse the viewer-activity clock without sustaining it. Instant cut on door-open during sleep, normal relight after clear_hold — while a door left open all day can never block the schedule, unlike a viewer zone. Co-Authored-By: Claude Fable 5 --- docs/ENGINE_SPEC.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/ENGINE_SPEC.md b/docs/ENGINE_SPEC.md index da51869..0b864ec 100644 --- a/docs/ENGINE_SPEC.md +++ b/docs/ENGINE_SPEC.md @@ -26,6 +26,10 @@ blocks** over many short ones (plants and humans both dislike flicker). - **Veto entities** — signals that force the light off regardless of occupancy detection (e.g. the TV is playing, so someone is almost certainly looking toward the light even if a sensor disagrees). +- **Trigger entities** — momentary movement signals at the boundary of + the sleep area (e.g. the bedroom door contact). Only their + *transitions* carry information; their level does not — an open door + all day says nothing about who can currently see the light. - **Plant day** — the scheduling period. It starts at the **anchor** time (default 22:00 local, the household's typical sleep boundary) and runs to the next anchor. @@ -57,6 +61,15 @@ UNOBSERVED implicitly waits out the hold. This single rule covers night movement (waking up to visit the bathroom), the morning routine (moving around before leaving), and "walked out the door but might step back in". +Rule 1.3b (trigger pulses). Any state transition of a trigger entity +counts as **instantaneous** viewer activity: it stamps rule 1.3's +activity clock — cutting the light at once and starting the +`clear_hold` — but never sustains it. The *level* of a trigger entity +is never read: a door left open cannot block the schedule (contrast a +viewer zone, whose sustained activity keeps the household OBSERVED). +Transitions to or from unknown/unavailable are ignored, so a flapping +sensor cannot suppress lighting either. + Rule 1.4 (asleep). Otherwise, if the household is asleep, the verdict is UNOBSERVED (reason `asleep`). This is the prime window. From 4eeda2321525b459a1f62af82739d7269ec5580b Mon Sep 17 00:00:00 2001 From: Leik Lima-Eriksen Date: Thu, 23 Jul 2026 19:22:38 +0000 Subject: [PATCH 4/4] Spec: transient exposure and refuge hold (rules 1.3c, 1.7) Owner decision (DECISION.md 8): during confirmed refuge, continuity beats absolute invisibility. Viewer activity cuts only once sustained for exposure_grace (300 s); shorter episodes and trigger pulses neither cut nor impose a hold. Confirmed refuge persists across evidence gaps up to refuge_hold (600 s), so office-sensor decay during a coffee run does not release it. Asleep/away contexts unchanged: instant cut. Co-Authored-By: Claude Fable 5 --- docs/DECISION.md | 18 +++++++++++++++++- docs/ENGINE_SPEC.md | 32 +++++++++++++++++++++++++------- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/docs/DECISION.md b/docs/DECISION.md index fd11a2a..6ff628c 100644 --- a/docs/DECISION.md +++ b/docs/DECISION.md @@ -84,7 +84,23 @@ reconciles on every external flip (rule 4.1). Manual experimentation is what the enabled switch is for — mixing two writers on one switch is how the old system got into silently-wrong states. -## 8. Core purity and testing conventions +## 8. Refuge continuity over absolute invisibility (2026-07-23) + +Owner decision: on a work-from-home day, a coffee run through the +living room must not toggle the light — continuity of the block beats +absolute invisibility. Under the original rules every kitchen trip cost +~13 minutes of light (instant cut + `clear_hold` + refuge +re-confirmation, with the office sensor's decay releasing refuge on +top), several times a day: flicker for the human, starvation for the +plants. During **confirmed refuge only**, the engine therefore +tolerates transient viewer exposure (rule 1.3c, `exposure_grace`) and +holds refuge evidence across short gaps (rule 1.7, `refuge_hold`); +settling within sight of the light still cuts it once activity +sustains. The trade is explicit and accepted: brief glimpses of the +light while awake at home in refuge. Night movement, arrivals, and the +away context are untouched — every blip there still cuts instantly. + +## 9. Core purity and testing conventions Inherited unchanged from sonos-conductor and presence-conductor: pure core (`core/` has zero `homeassistant` imports, enforced by ruff TID251 diff --git a/docs/ENGINE_SPEC.md b/docs/ENGINE_SPEC.md index 0b864ec..3e6aa69 100644 --- a/docs/ENGINE_SPEC.md +++ b/docs/ENGINE_SPEC.md @@ -60,6 +60,8 @@ times — including while asleep or away — so every transition into UNOBSERVED implicitly waits out the hold. This single rule covers night movement (waking up to visit the bathroom), the morning routine (moving around before leaving), and "walked out the door but might step back in". +Exception: while the household is in confirmed refuge, rule 1.3c +applies instead. Rule 1.3b (trigger pulses). Any state transition of a trigger entity counts as **instantaneous** viewer activity: it stamps rule 1.3's @@ -70,6 +72,18 @@ viewer zone, whose sustained activity keeps the household OBSERVED). Transitions to or from unknown/unavailable are ignored, so a flapping sensor cannot suppress lighting either. +Rule 1.3c (transient exposure during refuge). While the underlying +verdict is REFUGE (1.7), viewer evidence is tolerated briefly: viewer +activity flips the verdict to OBSERVED only once it has been +**continuously** active for `exposure_grace` (default 300 s); shorter +episodes — and trigger pulses (1.3b) — neither cut the light nor impose +a hold afterwards. An episode that does reach `exposure_grace` cuts at +that instant, and its falling edge imposes the normal `clear_hold` +before the light may return. Rationale (owner decision, DECISION.md +§9): a coffee run through a viewer room must not toggle a +work-from-home block. Only the refuge context is softened — while +asleep or away, every blip still cuts instantly per 1.3/1.3b. + Rule 1.4 (asleep). Otherwise, if the household is asleep, the verdict is UNOBSERVED (reason `asleep`). This is the prime window. @@ -82,13 +96,15 @@ rule 1.7 holds; in every other case it is OBSERVED. There is no weekend/weekday distinction and no "stay on after waking" latch: awake at home without proof of refuge always means OBSERVED (see DECISION.md). -Rule 1.7 (refuge confirmation). The refuge condition holds when at least -one refuge zone is active and has been **continuously** active for at -least `refuge_confirm` (default 180 s). Any gap resets the confirmation -clock. Refuge release is immediate — the moment no refuge zone is -active, rule 1.6 falls back to OBSERVED. (Sensors with built-in decay, -such as Presence Conductor room occupancy, make both directions smooth.) -If no refuge zones are configured, the refuge condition never holds. +Rule 1.7 (refuge confirmation and hold). The refuge condition engages +when at least one refuge zone has been **continuously** active for +`refuge_confirm` (default 180 s); while unconfirmed, any gap resets the +confirmation clock. Once confirmed, refuge is **held** across gaps in +the evidence: it persists until no refuge zone has been active for +`refuge_hold` (default 600 s), so sensor decay during a coffee run does +not release it. A gap longer than `refuge_hold` releases refuge and a +later re-engagement must re-confirm from zero. If no refuge zones are +configured, the refuge condition never holds. Rule 1.8 (fail-safe unknowns). Unknown inputs resolve toward OBSERVED: an unknown sleep signal counts as awake; an unknown home signal counts @@ -184,6 +200,8 @@ and refuge confirmation starts from zero. | `anchor` | 22:00 | local | Plant-day boundary (1.x windows pivot) | | `clear_hold` | 600 | s | Viewer quiet time before lighting (1.3) | | `refuge_confirm` | 180 | s | Continuous refuge before lighting (1.7) | +| `refuge_hold` | 600 | s | Confirmed refuge persists across gaps (1.7) | +| `exposure_grace` | 300 | s | Viewer activity tolerated in refuge (1.3c) | | `min_block` | 900 | s | Smallest headroom worth starting (2.5) | `target_hours` is a live number entity (0–20 h, step 0.5); the rest are