Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,19 +29,22 @@ jobs:
with:
python-version: "3.14"
- name: Run tests
if: ${{ hashFiles('tests/**/*.py') != '' }}
run: uv run pytest -q --cov --cov-report=term-missing

hassfest:
runs-on: ubuntu-latest
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
Expand Down
111 changes: 111 additions & 0 deletions docs/DECISION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# 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. 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
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).
209 changes: 209 additions & 0 deletions docs/ENGINE_SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
# 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).
- **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.
- **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".
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
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.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.

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 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
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) |
| `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
options-flow settings. `target_hours` is clamped to 20 so every plant
day keeps at least a 4-hour dark period (photoperiod hygiene).
Loading