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
22 changes: 20 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,22 @@ Triggers (OR-combined, no arming): `hueman security on|off` (writes the
adapter (e.g. Home Assistant) watches the cue file / webhook and plays the
matching sound. That adapter is out of this repo's scope.

## Rhythm engine — observe-stage day-phase inference

`rhythm:` (optional, alongside `circadian_daemon:`) runs a closed-loop
day-phase inference engine (`rhythm_control.py` + `presence.py`) inside the
daemon: it infers `dawn`/`morning`/`daylight`/`evening`/`wind_down`/`night`/
`sleep` from MotionAware motion (with pet discounting) plus optional phone
signals, and learns wake/bed-time anchors over time. **Stage 1 (`stage:
"observe"`) is read-only — it never writes to the bridge.** Evidence lines
carry a `rhythm:` prefix in the daemon log: every phase *change* logs at INFO
with its full evidence dict and rewrites `rhythm.state_file` (the path comes
from config; delete it to reset learning); individual motion judgments appear
at DEBUG; unchanged "hold" ticks are silent. `stage: "mornings"` and `stage:
"full"` parse but the daemon deliberately refuses to start with either — only
`observe` ships in stage 1. See the README's "Rhythm engine (observe stage)"
section (the design spec lives in the deployment/ops repository, not here).

## Architecture

The codebase is split into a **pure decision layer** (fully unit-tested, no I/O) and a **thin I/O layer** (needs a real bridge):
Expand All @@ -193,6 +209,8 @@ The codebase is split into a **pure decision layer** (fully unit-tested, no I/O)
| `engine.py` | Per-area state machine (`PolicyEngine`) for the legacy `watch` runtime. Consumes `MotionPolicy` + explicit `ts` floats; emits `Action` values. Four phases: `STANDBY`, `ACTIVE`, `DIMMING`, `OVERRIDDEN`. |
| `payload.py` | Converts engine `TargetState` → CLIP v2 request bodies (sRGB `#rrggbb` → CIE xy). |
| `nightmotion.py` | Pure night-motion helpers: builds CLIP `scene` bodies, transforms the MotionAware `behavior_instance`, and `scene_actions_match` (tolerant scene-look diff, reused by the circadian reconciler). |
| `presence.py` | Pure pet-discounting activity judge for `rhythm` (`PresenceTracker`): light-change / progression / solo-motion rules, quiet-time and confirm-window summaries. |
| `rhythm_control.py` | Pure day-phase state machine for `rhythm` (`RhythmEngine`, `AnchorStore`, observe stage): phase transitions, sleep vote, wake confirmation, learned wake/bed anchors. |
| `reconcile.py` | Terraform-style planner. `Planner` runs `AreaReconciler` (room/zone membership), `SensitivityReconciler` (sensor sensitivity), `SmartSceneReconciler` (re-time an existing scene), `CircadianSceneReconciler` (generate the smooth circadian cycle), and `NightMotionReconciler` (night soft-red guidance). `Change.change_type` is one of `CREATE`, `UPDATE`, `NOOP`, `BLOCKED`. |

### I/O layer
Expand All @@ -202,9 +220,9 @@ The codebase is split into a **pure decision layer** (fully unit-tested, no I/O)
| `client.py` | CLIP API v2 HTTP client (`HueClient`). |
| `pin.py` | Trust-on-first-use TLS pinning (SHA-256 of bridge cert → `.hue-pin.json`). |
| `state.py` | Loads live bridge state; resolves resource names → ids (`BridgeState`), including MotionAware areas/services. |
| `circadian_daemon.py` | The resident daemon (`CircadianDaemon`): 60s curve ticks + SSE event loop + TV-bias triggers (probe thread / SSE / control files) + security show, all serialised under one lock. |
| `circadian_daemon.py` | The resident daemon (`CircadianDaemon`): 60s curve ticks + SSE event loop + TV-bias triggers (probe thread / SSE / control files) + security show + rhythm-engine ticks, all serialised under one lock. |
| `watch.py` | **Legacy** SSE event loop (`MotionController`) for `motion_policies`. Its echo-buffer override detection predates the bridge's periodic re-emission of settled values (the daemon's settle-and-compare replaced it), and it requires legacy PIR sensors. |
| `cli.py` | `argparse`-based CLI (`Cli`). Subcommands: `auth`, `validate`, `preview`, `inventory`, `plan`, `apply`, `circadian run\|resume`, `security on\|off\|status`, `watch`. |
| `cli.py` | `argparse`-based CLI (`Cli`). Subcommands: `validate`, `auth`, `inventory`, `plan`, `apply`, `preview`, `watch`, `circadian run\|resume`, `rhythm`, `security on\|off\|status`. |

### Data flow

Expand Down
101 changes: 97 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ hueman -c my-home.yaml apply # converge the bridge to the declared st
hueman -c my-home.yaml circadian run # run the circadian + TV-bias daemon (foreground)
hueman -c my-home.yaml circadian resume # clear a manual-override suspension out-of-band
hueman -c my-home.yaml security on|off # arm / disarm the panic show
hueman -c my-home.yaml rhythm # print the rhythm engine's phase, anchors, evidence
hueman -c my-home.yaml watch # legacy live motion controller (see Limitations)
```

Expand Down Expand Up @@ -203,21 +204,111 @@ Key sections:
photosensitivity floor on luminance flashing, arm/disarm triggers, and a
decoupled sound-cue contract for an external audio adapter.
- `motion_policies` — per-sensor policies for the legacy `watch` runtime.
- `rhythm` — closed-loop day-phase inference (observe stage); see below.

## Rhythm engine (observe stage)

`rhythm:` runs a closed-loop day-phase inference engine inside the circadian
daemon: it watches MotionAware motion (in your bedroom and elsewhere) plus
optional phone signals, infers which phase of your day you're in, logs every
phase change with its evidence, and learns your real wake/bed-time anchors
over time.

**Stage 1 ("observe") never writes to the bridge.** It's a read-only shadow
layer — nothing it infers changes what your lights do. The config accepts
`stage: "mornings"` and `stage: "full"` so a config can be staged ahead of the
code, but the daemon deliberately refuses to start with either; only
`stage: "observe"` is implemented.

**Phase model.** Phases move forward only, one step per tick:
`dawn → morning → daylight → evening → wind_down → night → sleep`, with three
event-driven exceptions:
- The **sleep vote** can cut `wind_down`/`night` straight to `sleep` once the
house has been quiet for `presence.quiet`, the TV and the driven zone are
both off, and either the bedroom was the last active room or the phone is
charging. The vote never passes before *any* human activity has been
observed, so a daemon restart during actual sleep can't fabricate an onset.
- **Confirmed wake evidence** ends `dawn`/`sleep` and starts `morning`: enough
motion (`presence.wake_confirm_events` events, or 2+ distinct rooms) within
`presence.wake_confirm_window`, plus the bedroom being involved or a light
change — so a hallway cat patrol can't read as "awake." Wake evidence only
counts when the clock is plausibly morning (from ~2h before the dawn window
opens): a 01:30 bathroom trip is logged as a night waking and ignored, so it
can't poison the learned wake anchor. A missed wake
(alarm passed, no motion) force-advances to `morning` after two hours as a
failsafe.
- After midnight, the same wake evidence (same plausibly-morning gate) also
ends `night` and starts `morning` — the escape hatch for a restart during
sleep, which seeds `night` and (per the human-seen gate above) can never
reach `sleep`.

`wind_down` starts `wind_down_lead` before `bed_target`; `dawn` starts
`dawn_lead` before the wake anchor, which resolves in order: a phone alarm due
within 18h, else your learned median wake time for weekday/weekend (weekend
capped at `weekend_drift_cap` past weekday), else `wake_default`.

**Signal files.** Two optional files, written by an external automation (e.g.
Home Assistant) and re-read every tick — the engine never consumes or deletes
them:
- `signals.next_alarm_file` — epoch seconds of the phone's next alarm
(`0`/empty = none set).
- `signals.charging_file` — its mere *existence* means the phone is on the
charger (a bedtime proxy).

Either can be unset or unreadable; the engine just falls back to learned
anchors or config defaults.

**Pet discounting.** MotionAware areas report presence, not identity. In a
one-human-plus-pets home: a light change always counts as human (pets don't
use switches); motion counts as human when a *different* room was active
within `presence.pet_progression` minutes (room-to-room movement) **or** when
any light change happened within that same window (a human is clearly up and
about); solo single-room motion is otherwise discounted as a pet. Known blind
spot: a human
sitting nearly still in one room for a long stretch degrades to "pet"
judgments — but the sleep vote that consumes this signal also requires
lights-out and TV-off, so a reader with a lamp on is never mistaken for an
empty house.

**Checking on it:**

```
hueman -c my-home.yaml rhythm
```

prints the current phase and when it was last read, today's bed/wake
anchors, the learned weekday/weekend wake and sleep-onset medians, how far
the learned weekday sleep-onset has drifted from `bed_target` (once at least
one onset has been observed), and the JSON evidence behind the most recent
phase change.

**The state file** (`state_file`, default `rhythm-state.json`) holds the
learned anchors plus the latest snapshot, written atomically after every
phase change. Delete it to reset learning — the engine falls back to config
defaults until it re-learns.

**Debugging.** Evidence lines carry a `rhythm:` prefix — grep the daemon log
for it. Every phase *change* is logged at INFO with its full evidence dict
(and rewrites the state file); manual overrides fed in as human activity also
log at INFO; individual per-event motion judgments appear at DEBUG; quiet
"hold" ticks that change nothing are silent.

## What needs which hardware

- `plan`/`apply` areas, smart scenes, the circadian daemon, TV bias, and
security mode work on any **CLIP v2** bridge (square Hue bridge or Bridge Pro).
- `night_motion` and motion-area inventory need a **Hue Bridge Pro** running
**MotionAware** (motion sensed by the bulb grid — no PIR sensors required).
`rhythm` also wants MotionAware for its motion evidence; without it, presence
inference sees only manual-override activity (still functional, just blinder).
- The legacy `watch` runtime needs old-style PIR **Hue motion sensors**.

## Limitations and notes

- The pure decision layer (config, sun, circadian, daemon controller, bias,
security, engine, reconcile, nightmotion) is covered by 300+ unit tests.
`client`, `pin`, and the daemon's I/O shell talk to a real bridge and should
be exercised on your network.
security, engine, reconcile, nightmotion, presence, rhythm) is covered by
300+ unit tests. `client`, `pin`, and the daemon's I/O shell talk to a real
bridge and should be exercised on your network.
- **`watch` is legacy and known-inadequate against current bridge firmware**:
its echo-buffer override detection predates the bridge's periodic re-emission
of settled `grouped_light` values, which it can misread as manual overrides,
Expand Down Expand Up @@ -252,11 +343,13 @@ unit-tested; the network surface is thin and replaceable.
| `engine.py` | Pure motion/timing state machine for `watch` (`PolicyEngine`) |
| `payload.py` | Decision output → CLIP request bodies, incl. sRGB → CIE xy |
| `nightmotion.py` | Pure night-motion/scene helpers: scene bodies, automation transform, tolerant scene-look diff |
| `presence.py` | Pure activity judging for `rhythm`: pet-discounting rules, quiet/confirm-window summaries (`PresenceTracker`) |
| `rhythm_control.py` | Pure day-phase state machine for `rhythm` (observe stage): phases, sleep/wake votes, learned anchors (`RhythmEngine`, `AnchorStore`) |
| `reconcile.py` | Terraform-style plan/apply: `Planner` + area, sensitivity, smart-scene, circadian-scene, and night-motion reconcilers |
| `state.py` | Index the live bridge (incl. MotionAware areas); resolve names → ids |
| `client.py` | CLIP API v2 client |
| `pin.py` | Trust-on-first-use TLS certificate pinning |
| `circadian_daemon.py` | The resident daemon: curve ticks, SSE events, TV-bias triggers, security show |
| `circadian_daemon.py` | The resident daemon: curve ticks, SSE events, TV-bias triggers, security show, rhythm-engine ticks |
| `watch.py` | Legacy live runtime: bridge event stream → `PolicyEngine` → commands |
| `cli.py` | The `hueman` command-line interface |

Expand Down
25 changes: 25 additions & 0 deletions examples/home.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,31 @@ night_motion:
hex: "#ff1400" # deep red, easy on night-adjusted eyes
brightness: 3

# ---------------------------------------------------------------------------
# Closed-loop day-rhythm inference (stage 1: observe-only — no light control).
# The daemon infers dawn/morning/.../sleep from MotionAware areas plus optional
# phone signals, logs every phase change with evidence, and learns wake/bed anchors.
# Inspect with `hueman rhythm`. See README "Rhythm engine (observe stage)".
# ---------------------------------------------------------------------------
rhythm:
stage: "observe"
bedroom: "Bedroom" # room name whose motion anchors sleep/wake
bed_target: "23:00"
wake_default: "07:00"
weekend_drift_cap: "90m"
state_file: "/data/rhythm-state.json"
signals:
# Written by an external automation (e.g. Home Assistant):
# next_alarm_file contains the epoch seconds of the phone's next alarm;
# charging_file's existence means the phone is on the charger.
next_alarm_file: "/mnt/signals/.next-alarm"
charging_file: "/mnt/signals/.phone-charging"
presence:
quiet: "30m"
wake_confirm_events: 3
wake_confirm_window: "10m"
pet_progression: "5m"

# ---------------------------------------------------------------------------
# Legacy `watch` runtime (per-PIR-sensor motion policies). Predates the daemon
# and MotionAware — see README "Limitations" before relying on it.
Expand Down
Loading
Loading