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
24 changes: 12 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,16 @@ python3 -m pytest tests/test_engine.py
python3 -m pytest tests/test_engine.py -k "test_motion_on"

# CLI (after install)
hue-iac -c examples/home.yaml validate
hue-iac -c examples/home.yaml preview
hue-iac -c examples/home.yaml plan
hue-iac -c examples/home.yaml apply
hue-iac -c examples/home.yaml circadian run # the resident daemon (foreground)
hue-iac -c examples/home.yaml watch --dry-run # legacy motion controller
hueman -c examples/home.yaml validate
hueman -c examples/home.yaml preview
hueman -c examples/home.yaml plan
hueman -c examples/home.yaml apply
hueman -c examples/home.yaml circadian run # the resident daemon (foreground)
hueman -c examples/home.yaml watch --dry-run # legacy motion controller
```

Bridge-touching commands need `HUE_APPLICATION_KEY` — set it from your bridge
pairing (`hue-iac auth` performs trust-on-first-use pairing and TLS pinning).
pairing (`hueman auth` performs trust-on-first-use pairing and TLS pinning).
The bridge host lives in the config (`bridge.host`); `HUE_BRIDGE_HOST` only
overrides it.

Expand All @@ -48,7 +48,7 @@ mean there are no sensors.** MotionAware surfaces under its own resource types:
`sensitivity{sensitivity, sensitivity_max}`

`state.py` indexes these (`MotionArea`, `_index_motion_areas`) and
`hue-iac inventory` prints them. Only the legacy `watch.py` runtime is blind to
`hueman inventory` prints them. Only the legacy `watch.py` runtime is blind to
MotionAware (it routes just `motion`/`light_level`/`grouped_light` events and
requires a legacy PIR sensor to even construct). MotionAware **sensitivity is a
live-only bridge setting**: no reconciler manages it, and a daily apply neither
Expand All @@ -62,20 +62,20 @@ reverts nor recreates it.

## The two day-cycle deployment modes

**Mode 1 — the circadian daemon** (`hue-iac circadian run`, typically a Docker
**Mode 1 — the circadian daemon** (`hueman circadian run`, typically a Docker
container near the bridge): re-samples the solar curve every 60 s and drives one
grouped_light zone with long cross-fades from sunrise to `hand_off`, detects
manual overrides by settle-and-compare, and owns the TV-bias hold. Night
guidance stays native on the bridge (`night_motion:` → a MotionAware
`behavior_instance`), provisioned by `apply`.

**Mode 2 — native, daemon-less**: `CircadianSceneReconciler` (+ pure
`hue_iac/circadian_scene.py`) generates ≤6 sun-anchored knee scenes + a
`hueman/circadian_scene.py`) generates ≤6 sun-anchored knee scenes + a
`smart_scene` with long cross-fades; a daily cron `apply` re-anchors the
timeslot times to real sun (idempotent — the looks are curve regime-constants).

The reconcilers in `reconcile.py` (all provisioned by `apply`):
- `NightMotionReconciler` (+ pure `hue_iac/nightmotion.py`) tunes a MotionAware `behavior_instance` for night soft-red guidance on a guide zone, backing it up first. `plan` detects zone-scene look drift via the tolerant `scene_actions_match`; apply re-PUTs the automation only when its wiring actually changes. Sun-anchored timeslots it can't re-time surface as `BLOCKED`, not a crash.
- `NightMotionReconciler` (+ pure `hueman/nightmotion.py`) tunes a MotionAware `behavior_instance` for night soft-red guidance on a guide zone, backing it up first. `plan` detects zone-scene look drift via the tolerant `scene_actions_match`; apply re-PUTs the automation only when its wiring actually changes. Sun-anchored timeslots it can't re-time surface as `BLOCKED`, not a crash.
- `CircadianSceneReconciler` — the native day-cycle generator (mode 2 above).
- `SmartSceneReconciler` re-times/prunes an *existing* (e.g. app-built) `smart_scene` to real sun. Empty-floor prune → `BLOCKED` (never PUTs an empty scene); timeslots whose scene rid is unresolved are preserved, not silently dropped; backs up before the PUT.

Expand Down Expand Up @@ -165,7 +165,7 @@ show built into the circadian daemon (`circadian_daemon.py` + pure
high frame rates (the daemon logs drop stats on exit); real per-frame control
would need the Entertainment streaming API.

Triggers (OR-combined, no arming): `hue-iac security on|off` (writes the
Triggers (OR-combined, no arming): `hueman security on|off` (writes the
`control_file` flags), and/or a Hue button/scene the daemon sees on SSE
(`triggers.sse`). On startup security is always OFF and stale control-files are cleared.

Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml README.md ./
COPY hue_iac ./hue_iac
COPY hueman ./hueman
RUN pip install --no-cache-dir .
# Config + secrets + logs are mounted at runtime (not baked in).
WORKDIR /data
ENTRYPOINT ["hue-iac", "-c", "/data/hue.yaml", "circadian", "run"]
ENTRYPOINT ["hueman", "-c", "/data/hue.yaml", "circadian", "run"]
51 changes: 24 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@ Declarative, Terraform-style management of Philips Hue lighting — a sun-anchor
circadian day cycle, TV-aware bias lighting, night motion guidance, a panic
mode, and honoured manual overrides.

> The CLI and Python package are currently named `hue-iac`; they will be renamed
> to `hueman` in an upcoming release. Everywhere you see `hue-iac` below, that's
> this project.

You describe the desired state of your lights in a YAML file; `hue-iac` diffs it
You describe the desired state of your lights in a YAML file; `hueman` diffs it
against the bridge (`plan`), converges it (`apply`), and — for the behaviours a
bridge can't run natively — ships a small resident **circadian daemon** that
drives a zone smoothly along the solar curve, holds a TV-bias look while you
Expand All @@ -21,7 +17,7 @@ watch, and steps aside the moment you touch a dimmer. Built for the local
flowchart LR
yaml["hue.yaml<br/>(desired state)"]

subgraph cli["hue-iac"]
subgraph cli["hueman"]
plan["plan / apply<br/>(diff + converge)"]
daemon["circadian daemon<br/>(circadian run)"]
end
Expand Down Expand Up @@ -77,15 +73,15 @@ timeline
## The commands

```
hue-iac -c my-home.yaml validate # parse + validate the config (no bridge needed)
hue-iac -c my-home.yaml preview # print the circadian colour curve for a day
hue-iac -c my-home.yaml inventory # list everything on your bridge, by name
hue-iac -c my-home.yaml plan # show what apply would change (read-only)
hue-iac -c my-home.yaml apply # converge the bridge to the declared state
hue-iac -c my-home.yaml circadian run # run the circadian + TV-bias daemon (foreground)
hue-iac -c my-home.yaml circadian resume # clear a manual-override suspension out-of-band
hue-iac -c my-home.yaml security on|off # arm / disarm the panic show
hue-iac -c my-home.yaml watch # legacy live motion controller (see Limitations)
hueman -c my-home.yaml validate # parse + validate the config (no bridge needed)
hueman -c my-home.yaml preview # print the circadian colour curve for a day
hueman -c my-home.yaml inventory # list everything on your bridge, by name
hueman -c my-home.yaml plan # show what apply would change (read-only)
hueman -c my-home.yaml apply # converge the bridge to the declared state
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 watch # legacy live motion controller (see Limitations)
```

`-c` defaults to `./hue.yaml` if you omit it.
Expand All @@ -94,11 +90,12 @@ hue-iac -c my-home.yaml watch # legacy live motion controller (see Li

```bash
git clone https://github.com/cnewkirk/hueman && cd hueman
python3 -m pip install -e . # installs the `hue-iac` console script
python3 -m pip install -e . # installs the `hueman` console script
# contributors: python3 -m pip install -e ".[dev]" && python3 -m pytest
```

Requires Python 3.10+.
Requires Python 3.10+. A transitional `hue-iac` alias for the `hueman` command
also installs (the project's former name); it will be removed in a future release.

## First-time setup

Expand All @@ -111,7 +108,7 @@ Requires Python 3.10+.
`my-home.yaml` (or `export HUE_BRIDGE_HOST=...`).
3. **Pair** — press the bridge's physical link button, then:
```bash
hue-iac -c my-home.yaml auth
hueman -c my-home.yaml auth
```
It prints an application key. Keep it in an environment variable so it never
lands in a file:
Expand All @@ -122,28 +119,28 @@ Requires Python 3.10+.
areas by name, then edit `my-home.yaml`'s `areas:` (and light names
throughout) to match your home:
```bash
hue-iac -c my-home.yaml inventory
hueman -c my-home.yaml inventory
```
5. **Validate and preview** (no bridge writes):
```bash
hue-iac -c my-home.yaml validate
hue-iac -c my-home.yaml preview # prints your circadian curve for today
hueman -c my-home.yaml validate
hueman -c my-home.yaml preview # prints your circadian curve for today
```
6. **Plan, then apply** — `plan` is read-only and shows exactly what `apply`
would change; nothing touches the bridge until you apply:
```bash
hue-iac -c my-home.yaml plan
hue-iac -c my-home.yaml apply
hueman -c my-home.yaml plan
hueman -c my-home.yaml apply
```
7. **Run the daemon** (foreground; use Docker/systemd to keep it up — a Synology
Docker runbook lives in `deploy/synology/README-docker.md`):
```bash
hue-iac -c my-home.yaml circadian run
hueman -c my-home.yaml circadian run
```

## TLS

The bridge serves a self-signed certificate. `hue-iac` defaults to
The bridge serves a self-signed certificate. `hueman` defaults to
`tls.mode: pin` — trust-on-first-use: it records the bridge certificate's
SHA-256 fingerprint in `.hue-pin.json` on first connect and enforces it on
**every** connection the session opens (including the long-lived event stream),
Expand Down Expand Up @@ -229,7 +226,7 @@ Key sections:
`watch --dry-run` logs intended commands without sending them.
- The daemon runs in the foreground; pair it with Docker (`--restart always`),
`systemd`, or similar. On a manual override it suspends until a power-cycle of
the zone, `hue-iac circadian resume`, or the daily safety resume.
the zone, `hueman circadian resume`, or the daily safety resume.
- Security mode's chaos runs over the REST API, which rate-limits under load —
the show degrades gracefully (the daemon logs drop stats). True per-frame
control would need the Entertainment streaming API.
Expand Down Expand Up @@ -260,7 +257,7 @@ unit-tested; the network surface is thin and replaceable.
| `pin.py` | Trust-on-first-use TLS certificate pinning |
| `circadian_daemon.py` | The resident daemon: curve ticks, SSE events, TV-bias triggers, security show |
| `watch.py` | Legacy live runtime: bridge event stream → `PolicyEngine` → commands |
| `cli.py` | The `hue-iac` command-line interface |
| `cli.py` | The `hueman` command-line interface |

</details>

Expand Down
36 changes: 18 additions & 18 deletions deploy/synology/README-docker.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# hue-iac circadian daemon — Synology Container Manager runbook
# hueman circadian daemon — Synology Container Manager runbook

This document covers running the circadian daemon as a Docker container on the NAS
via Synology Container Manager. The container replaces the re-anchor cron approach:
Expand All @@ -16,16 +16,16 @@ daemon.** The cron job is retired once this container is confirmed running.
- `.hue-key` — bridge application key (chmod 600); read by the **host shell** to
populate `$HUE_APPLICATION_KEY`, **not** mounted into the container
- `.hue-pin.json` — TLS pin (chmod 600); **must be pre-pinned** — the read-only
container mount cannot perform trust-on-first-use (run `hue-iac auth` once from a
container mount cannot perform trust-on-first-use (run `hueman auth` once from a
host shell to create it if starting fresh)

> **`hue.yaml` MUST contain a `circadian_daemon:` block.** `hue-iac circadian run`
> **`hue.yaml` MUST contain a `circadian_daemon:` block.** `hueman circadian run`
> raises `HueIacError("no 'circadian_daemon' block in config")` and the container exits
> immediately (crash-loops under `--restart always`) if it is missing. At minimum the
> block needs `zone:` (the zone the daemon drives). The daemon tunables also
> live here — notably `manual_override.control_file` (the resume-signal file, default
> `.hue-circadian-resume`) and `log.path` (default `logs/circadian.log`, which lands
> under the mounted `/data/logs`). See `hue_iac/config.py` (`CircadianDaemonSpec`) for the
> under the mounted `/data/logs`). See `hueman/config.py` (`CircadianDaemonSpec`) for the
> full set of keys and their defaults. If yours doesn't have the block yet, add
> it before building.
>
Expand All @@ -44,18 +44,18 @@ Throughout this document, substitute `<share>` with the actual share name
## Step 1 — Build the image on the NAS

The NAS keeps a plain **build context** at `$HUE_IAC_HOME/build/` (Dockerfile,
`hue_iac/`, `pyproject.toml`, `README.md`) — **not a git clone** (the live deploy
`hueman/`, `pyproject.toml`, `README.md`) — **not a git clone** (the live deploy
has no `src/` checkout; source is copied in from the dev machine). From your dev machine,
sync the current `main` into it, then build over SSH:

```sh
# On your dev machine, from a checkout of this repo at origin/main:
rsync -r --delete hue_iac/ <user>@<nas>:/volume1/<share>/hue-iac/build/hue_iac/
rsync pyproject.toml Dockerfile <user>@<nas>:/volume1/<share>/hue-iac/build/
rsync -r --delete hueman/ <user>@<nas>:/volume1/<share>/hueman/build/hueman/
rsync pyproject.toml Dockerfile <user>@<nas>:/volume1/<share>/hueman/build/
# (scp also works but needs -O — Synology has SFTP disabled.)

# On the NAS (docker is not on the non-interactive PATH — use the full path):
sudo /usr/local/bin/docker build -t hue-iac:latest /volume1/<share>/hue-iac/build
sudo /usr/local/bin/docker build -t hueman:latest /volume1/<share>/hueman/build
```

Alternatively, import a pre-built tarball via Container Manager UI:
Expand All @@ -66,7 +66,7 @@ Container Manager → Image → Import → select the `.tar` file exported from
## Step 2 — Start the daemon

```sh
HUE_IAC_HOME=/volume1/<share>/hue-iac
HUE_IAC_HOME=/volume1/<share>/hueman

docker run -d \
--name hue-circadian \
Expand All @@ -77,7 +77,7 @@ docker run -d \
-v "$HUE_IAC_HOME/logs":/data/logs \
-v /volume1/<share>/homeassistant/tv-signal:/data/tvsig \
-e HUE_APPLICATION_KEY="$(cat $HUE_IAC_HOME/.hue-key)" \
hue-iac:latest
hueman:latest
```

**Why the `tv-signal` mount (REQUIRED for TV-bias):** TV detection runs through
Expand All @@ -95,7 +95,7 @@ ensures the container can reach that address and keep an SSE stream open without
interference.

**Key delivery:** the key is read **only** from the `HUE_APPLICATION_KEY` environment
variable — no code path in `hue_iac/` reads a mounted key file at runtime (the only
variable — no code path in `hueman/` reads a mounted key file at runtime (the only
`.hue-key` reference is the `auth` command printing an `export HUE_APPLICATION_KEY=...`
line). The `-e HUE_APPLICATION_KEY="$(cat $HUE_IAC_HOME/.hue-key)"` flag has the **host
shell** read the file and pass its contents as the env var; keep it exactly as shown.
Expand Down Expand Up @@ -139,11 +139,11 @@ first circadian scene applied.

## Step 4 — Trigger a manual circadian resume

If the daemon is paused (e.g. you ran `hue-iac circadian pause` for debugging) and you
If the daemon is paused (e.g. you ran `hueman circadian pause` for debugging) and you
want to resume without restarting the container:

```sh
docker exec hue-circadian hue-iac -c /data/hue.yaml circadian resume
docker exec hue-circadian hueman -c /data/hue.yaml circadian resume
```

---
Expand All @@ -154,9 +154,9 @@ Same as Step 1: re-sync the source from your dev machine into `build/`, rebuild,
recreate the container:

```sh
# Mac: rsync hue_iac/ pyproject.toml Dockerfile into …/hue-iac/build/ (Step 1)
# Mac: rsync hueman/ pyproject.toml Dockerfile into …/hueman/build/ (Step 1)
# NAS:
sudo /usr/local/bin/docker build -t hue-iac:latest /volume1/<share>/hue-iac/build
sudo /usr/local/bin/docker build -t hueman:latest /volume1/<share>/hueman/build
sudo /usr/local/bin/docker rm -f hue-circadian
# Re-run the docker run command from Step 2
```
Expand All @@ -170,14 +170,14 @@ sudo /usr/local/bin/docker rm -f hue-circadian
| Container exits immediately | Bad config / missing file mount | `docker logs hue-circadian` for the error |
| SSE stream drops / reconnects | Bridge rebooted or LAN blip | Daemon auto-reconnects; monitor logs |
| TLS pin mismatch | Bridge cert rotated | Refresh `.hue-pin.json` from your dev machine, restart container |
| `BLOCKED` on scene apply | Zone or scene not found | Run `hue-iac plan` from your dev machine to diagnose |
| `BLOCKED` on scene apply | Zone or scene not found | Run `hueman plan` from your dev machine to diagnose |

---

## Relationship to the re-anchor cron

The daily `re-anchor.sh` cron (`README.md`) calls `hue-iac apply` once a day to keep
the circadian smart-scene timed to the real sun. This Docker daemon runs `hue-iac
The daily `re-anchor.sh` cron (`README.md`) calls `hueman apply` once a day to keep
the circadian smart-scene timed to the real sun. This Docker daemon runs `hueman
circadian run` continuously, reacting to SSE events in real time — it includes its own
daily re-anchor logic. Once this daemon is confirmed stable, disable or delete
the Task Scheduler job created in `README.md` to avoid double-applying.
18 changes: 9 additions & 9 deletions deploy/synology/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Synology daily re-anchor — runbook

Runs `hue-iac apply` once a day on the NAS so the bridge's circadian cycle
Runs `hueman apply` once a day on the NAS so the bridge's circadian cycle
re-anchors to the real sun. DST is handled inside the tool (`location.tz`), so
nothing here needs a seasonal edit.

## Layout
```
$HUE_IAC_HOME/ # e.g. /volume1/<share>/hue-iac
$HUE_IAC_HOME/ # e.g. /volume1/<share>/hueman
src/ # git clone of this repo
.venv/ # python3 -m venv; pip install -e ./src
hue.yaml .hue-key .hue-pin.json .hue-backup/
Expand All @@ -15,7 +15,7 @@ $HUE_IAC_HOME/ # e.g. /volume1/<share>/hue-iac

## Install (over SSH, one time)
```sh
HUE_IAC_HOME=/volume1/<share>/hue-iac
HUE_IAC_HOME=/volume1/<share>/hueman
mkdir -p "$HUE_IAC_HOME" && cd "$HUE_IAC_HOME"
git clone https://github.com/cnewkirk/hueman.git src
python3 -m venv .venv
Expand All @@ -32,19 +32,19 @@ chmod 600 .hue-key .hue-pin.json
```sh
cd "$HUE_IAC_HOME"
export HUE_APPLICATION_KEY=$(cat .hue-key)
.venv/bin/hue-iac -c hue.yaml validate
.venv/bin/hue-iac -c hue.yaml plan # NOOP / time deltas, ZERO blocked
.venv/bin/hueman -c hue.yaml validate
.venv/bin/hueman -c hue.yaml plan # NOOP / time deltas, ZERO blocked
sh bin/re-anchor.sh && tail -n 20 logs/re-anchor.log # exit 0, "re-anchor OK"
.venv/bin/hue-iac -c hue.yaml plan # NOOP (idempotent)
.venv/bin/hue-iac -c hue.yaml preview --date 2026-12-15
.venv/bin/hue-iac -c hue.yaml preview --date 2026-07-15 # sunrise ~1h apart => DST works
.venv/bin/hueman -c hue.yaml plan # NOOP (idempotent)
.venv/bin/hueman -c hue.yaml preview --date 2026-12-15
.venv/bin/hueman -c hue.yaml preview --date 2026-07-15 # sunrise ~1h apart => DST works
```

## Schedule (DSM GUI — the one manual step)
Control Panel → Task Scheduler → Create → Scheduled Task → User-defined script
- **User:** `root`
- **Schedule:** Daily, **03:30**
- **Run command:** `sh /volume1/<share>/hue-iac/bin/re-anchor.sh`
- **Run command:** `sh /volume1/<share>/hueman/bin/re-anchor.sh`
- **Notification:** email → "only when the script terminates abnormally"

Then press **Run** once and check `logs/re-anchor.log`.
Expand Down
Loading
Loading