diff --git a/CLAUDE.md b/CLAUDE.md index 93ff20e..e7c9441 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. @@ -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 @@ -62,7 +62,7 @@ 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 @@ -70,12 +70,12 @@ 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. @@ -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. diff --git a/Dockerfile b/Dockerfile index e741979..14d1d56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 5bd08ed..182d9a0 100644 --- a/README.md +++ b/README.md @@ -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 @@ -21,7 +17,7 @@ watch, and steps aside the moment you touch a dimmer. Built for the local flowchart LR yaml["hue.yaml
(desired state)"] - subgraph cli["hue-iac"] + subgraph cli["hueman"] plan["plan / apply
(diff + converge)"] daemon["circadian daemon
(circadian run)"] end @@ -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. @@ -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 @@ -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: @@ -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), @@ -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. @@ -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 | diff --git a/deploy/synology/README-docker.md b/deploy/synology/README-docker.md index b76300c..bc81ae5 100644 --- a/deploy/synology/README-docker.md +++ b/deploy/synology/README-docker.md @@ -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: @@ -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. > @@ -44,18 +44,18 @@ Throughout this document, substitute `` 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/ @:/volume1//hue-iac/build/hue_iac/ -rsync pyproject.toml Dockerfile @:/volume1//hue-iac/build/ +rsync -r --delete hueman/ @:/volume1//hueman/build/hueman/ +rsync pyproject.toml Dockerfile @:/volume1//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//hue-iac/build +sudo /usr/local/bin/docker build -t hueman:latest /volume1//hueman/build ``` Alternatively, import a pre-built tarball via Container Manager UI: @@ -66,7 +66,7 @@ Container Manager → Image → Import → select the `.tar` file exported from ## Step 2 — Start the daemon ```sh -HUE_IAC_HOME=/volume1//hue-iac +HUE_IAC_HOME=/volume1//hueman docker run -d \ --name hue-circadian \ @@ -77,7 +77,7 @@ docker run -d \ -v "$HUE_IAC_HOME/logs":/data/logs \ -v /volume1//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 @@ -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. @@ -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 ``` --- @@ -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//hue-iac/build +sudo /usr/local/bin/docker build -t hueman:latest /volume1//hueman/build sudo /usr/local/bin/docker rm -f hue-circadian # Re-run the docker run command from Step 2 ``` @@ -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. diff --git a/deploy/synology/README.md b/deploy/synology/README.md index 37c439a..aa51fce 100644 --- a/deploy/synology/README.md +++ b/deploy/synology/README.md @@ -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//hue-iac +$HUE_IAC_HOME/ # e.g. /volume1//hueman src/ # git clone of this repo .venv/ # python3 -m venv; pip install -e ./src hue.yaml .hue-key .hue-pin.json .hue-backup/ @@ -15,7 +15,7 @@ $HUE_IAC_HOME/ # e.g. /volume1//hue-iac ## Install (over SSH, one time) ```sh -HUE_IAC_HOME=/volume1//hue-iac +HUE_IAC_HOME=/volume1//hueman mkdir -p "$HUE_IAC_HOME" && cd "$HUE_IAC_HOME" git clone https://github.com/cnewkirk/hueman.git src python3 -m venv .venv @@ -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//hue-iac/bin/re-anchor.sh` +- **Run command:** `sh /volume1//hueman/bin/re-anchor.sh` - **Notification:** email → "only when the script terminates abnormally" Then press **Run** once and check `logs/re-anchor.log`. diff --git a/examples/home.yaml b/examples/home.yaml index 326c045..fbfd365 100644 --- a/examples/home.yaml +++ b/examples/home.yaml @@ -1,24 +1,24 @@ # --------------------------------------------------------------------------- -# hue-iac example configuration +# hueman example configuration # # Copy this file (`cp examples/home.yaml my-home.yaml`), point bridge.host at # your bridge, and replace the light/room/zone names with yours — run -# `hue-iac -c my-home.yaml inventory` to see what your bridge actually has. +# `hueman -c my-home.yaml inventory` to see what your bridge actually has. # # Declarative desired state for a Hue bridge (CLIP v2; night_motion needs a # Bridge Pro running MotionAware). Then: -# hue-iac -c my-home.yaml validate # check syntax (no bridge needed) -# hue-iac -c my-home.yaml preview # see the circadian curve -# hue-iac -c my-home.yaml plan # diff against the bridge (read-only) -# hue-iac -c my-home.yaml apply # provision areas/scenes/night-red -# hue-iac -c my-home.yaml circadian run # run the circadian + TV-bias daemon -# hue-iac -c my-home.yaml watch # legacy motion controller +# hueman -c my-home.yaml validate # check syntax (no bridge needed) +# hueman -c my-home.yaml preview # see the circadian curve +# hueman -c my-home.yaml plan # diff against the bridge (read-only) +# hueman -c my-home.yaml apply # provision areas/scenes/night-red +# hueman -c my-home.yaml circadian run # run the circadian + TV-bias daemon +# hueman -c my-home.yaml watch # legacy motion controller # --------------------------------------------------------------------------- bridge: host: 192.0.2.2 # or set $HUE_BRIDGE_HOST and omit this # The application key is read from $HUE_APPLICATION_KEY by default so it - # never lives in version control. Run `hue-iac auth` to create one. + # never lives in version control. Run `hueman auth` to create one. application_key_env: HUE_APPLICATION_KEY tls: mode: pin # pin (trust-on-first-use) | cacert | insecure @@ -71,7 +71,7 @@ circadian: ramp_minutes: 90 # smooth 90-min dawn/dusk transitions # --------------------------------------------------------------------------- -# The circadian daemon (`hue-iac circadian run`) — the resident runtime. +# The circadian daemon (`hueman circadian run`) — the resident runtime. # Drives one zone smoothly along the solar curve from `start` to `hand_off`, # steps back when it detects a manual override (settle-and-compare), and holds # a TV-bias look on the viewing lights while the TV is on. @@ -85,7 +85,7 @@ circadian_daemon: fade_off: 90s # the hand-off fade to dark manual_override: detect: true - control_file: .hue-circadian-resume # `hue-iac circadian resume` touches this + control_file: .hue-circadian-resume # `hueman circadian resume` touches this resume_on_power_cycle: true daily_safety_resume: true # TV-aware bias hold: while the TV is on, each light below holds its `look`; diff --git a/hue_iac/__init__.py b/hueman/__init__.py similarity index 62% rename from hue_iac/__init__.py rename to hueman/__init__.py index a6d7f20..99b9e63 100644 --- a/hue_iac/__init__.py +++ b/hueman/__init__.py @@ -1,9 +1,9 @@ -"""hue-iac: declarative, Terraform-style management of Philips Hue lighting. +"""hueman: declarative, Terraform-style management of Philips Hue lighting. The package is intentionally split so the pure-logic pieces (config parsing, circadian math, sun times, plan diffing, motion translation) carry no network dependency and are unit-testable, while the I/O surface (the CLIP API v2 client -and TLS pinning) is isolated in :mod:`hue_iac.client` and :mod:`hue_iac.pin`. +and TLS pinning) is isolated in :mod:`hueman.client` and :mod:`hueman.pin`. """ __version__ = "0.1.0" diff --git a/hue_iac/bias_control.py b/hueman/bias_control.py similarity index 96% rename from hue_iac/bias_control.py rename to hueman/bias_control.py index ed2c6c9..e393174 100644 --- a/hue_iac/bias_control.py +++ b/hueman/bias_control.py @@ -1,10 +1,10 @@ """Pure decision core for the daemon's TV bias hold (no clock, no I/O). -Given the parsed :class:`~hue_iac.config.BiasSpec`, whether the TV is on, and the +Given the parsed :class:`~hueman.config.BiasSpec`, whether the TV is on, and the current circadian curve sample, :func:`bias_actions` decides the per-light action for this tick. :class:`TriggerAggregator` folds on/off edges from the (I/O) trigger sources into a single ``tv_on`` signal with optional debounce. This -mirrors the pure/I-O split of :mod:`hue_iac.circadian_control`: everything here is +mirrors the pure/I-O split of :mod:`hueman.circadian_control`: everything here is a pure function of explicit inputs, so it is fully unit-tested without a bridge. """ diff --git a/hue_iac/circadian.py b/hueman/circadian.py similarity index 100% rename from hue_iac/circadian.py rename to hueman/circadian.py diff --git a/hue_iac/circadian_control.py b/hueman/circadian_control.py similarity index 99% rename from hue_iac/circadian_control.py rename to hueman/circadian_control.py index 4fa4b8f..f3b27b0 100644 --- a/hue_iac/circadian_control.py +++ b/hueman/circadian_control.py @@ -4,7 +4,7 @@ action for this tick — drive the zone to the current curve point, fade it off at hand-off, or hold — and tracks the daemon mode. Time and events are explicit arguments so the whole state machine is unit-tested without a bridge, mirroring -:class:`hue_iac.engine.PolicyEngine`. +:class:`hueman.engine.PolicyEngine`. """ from __future__ import annotations diff --git a/hue_iac/circadian_daemon.py b/hueman/circadian_daemon.py similarity index 98% rename from hue_iac/circadian_daemon.py rename to hueman/circadian_daemon.py index 3bc24c8..dfecdd4 100644 --- a/hue_iac/circadian_daemon.py +++ b/hueman/circadian_daemon.py @@ -2,9 +2,9 @@ Where ``apply`` provisions native bridge resources, this module is the *daemon* alternative: a persistent process that, every tick, asks the pure -:class:`~hue_iac.circadian_control.CircadianController` what the zone should look +:class:`~hueman.circadian_control.CircadianController` what the zone should look like *right now* and writes that to the zone's ``grouped_light`` service with a -smooth transition. It mirrors :class:`hue_iac.watch.MotionController` almost +smooth transition. It mirrors :class:`hueman.watch.MotionController` almost exactly — a tick thread plus an SSE event loop and a capped-backoff reconnect loop — but for one zone and the circadian state machine instead of per-area motion engines. @@ -62,7 +62,7 @@ from .sun import SolarCalculator from .watch import BridgeEvent, HueEventStream -_LOG = logging.getLogger("hue_iac.circadian_daemon") +_LOG = logging.getLogger("hueman.circadian_daemon") class CircadianDaemon: @@ -70,14 +70,14 @@ class CircadianDaemon: Args: client: An authenticated bridge client. - state: A loaded :class:`~hue_iac.state.BridgeState` (used to resolve the + state: A loaded :class:`~hueman.state.BridgeState` (used to resolve the zone's ``grouped_light`` id and an optional resume-trigger scene). config: The parsed IaC configuration; ``config.circadian_daemon`` must be present. clock: Callable returning epoch seconds; injectable for testing. sleep: Sleep function used for reconnect backoff; injectable for testing. stream_factory: Builds the SSE stream each (re)connect; defaults to a - :class:`~hue_iac.watch.HueEventStream` over ``client``. + :class:`~hueman.watch.HueEventStream` over ``client``. """ def __init__( @@ -102,7 +102,7 @@ def __init__( raise ConfigError( "circadian_daemon.bias references light(s) not on the bridge: " + ", ".join(repr(name) for name in missing) - + " — run `hue-iac inventory` to list available light names" + + " — run `hueman inventory` to list available light names" ) self._bias_sse_on_rid = self._resolve_resume_trigger(state, spec.bias.sse_on) self._bias_sse_off_rid = self._resolve_resume_trigger(state, spec.bias.sse_off) @@ -114,7 +114,7 @@ def __init__( raise ConfigError( "security.groups references group(s) not on the bridge: " + ", ".join(repr(g) for g in missing) - + " — run `hue-iac inventory` to list available group names" + + " — run `hueman inventory` to list available group names" ) no_gl = [g for g in sec.groups if state.group(g).grouped_light_rid is None] if no_gl: @@ -321,7 +321,7 @@ def run(self, max_reconnects: int | None = None) -> None: A daemon thread ticks the controller every ``interval`` seconds; the main thread consumes the SSE stream inside a capped-backoff reconnect loop, routing each event to :meth:`_handle_event`. Mirrors - :meth:`hue_iac.watch.MotionController.run`'s stop/lock/backoff discipline. + :meth:`hueman.watch.MotionController.run`'s stop/lock/backoff discipline. Args: max_reconnects: Stop after this many connect attempts (for tests); diff --git a/hue_iac/circadian_scene.py b/hueman/circadian_scene.py similarity index 95% rename from hue_iac/circadian_scene.py rename to hueman/circadian_scene.py index d45fc55..9472bbf 100644 --- a/hue_iac/circadian_scene.py +++ b/hueman/circadian_scene.py @@ -1,9 +1,9 @@ """Pure generator for the smooth-circadian smart scene. -Samples the sun-elevation circadian curve (:mod:`hue_iac.circadian`) at a handful +Samples the sun-elevation circadian curve (:mod:`hueman.circadian`) at a handful of afternoon-weighted *knee* times so a native ``smart_scene`` — capped by the bridge at six timeslots — can fade continuously through the day instead of -snapping between a few hand-built looks. I/O-free: :mod:`hue_iac.reconcile` +snapping between a few hand-built looks. I/O-free: :mod:`hueman.reconcile` owns the bridge writes. Knees are weighted toward the afternoon decline: sunrise, solar noon, two diff --git a/hue_iac/cli.py b/hueman/cli.py similarity index 97% rename from hue_iac/cli.py rename to hueman/cli.py index 39fbf7e..0ce6c97 100644 --- a/hue_iac/cli.py +++ b/hueman/cli.py @@ -1,4 +1,4 @@ -"""Command-line interface for hue-iac. +"""Command-line interface for hueman. Subcommands: auth Pair with the bridge (press the link button) and print the key. @@ -49,8 +49,8 @@ def __init__(self) -> None: def _build_parser(self) -> argparse.ArgumentParser: """Construct the argument parser and its subcommands.""" - parser = argparse.ArgumentParser(prog="hue-iac", description="Declarative Philips Hue management.") - parser.add_argument("--version", action="version", version=f"hue-iac {__version__}") + parser = argparse.ArgumentParser(prog="hueman", description="Declarative Philips Hue management.") + parser.add_argument("--version", action="version", version=f"hueman {__version__}") parser.add_argument( "-c", "--config", default="hue.yaml", help="path to the config file (default: hue.yaml)" ) @@ -188,7 +188,7 @@ def _cmd_watch(self, args: argparse.Namespace) -> int: state = BridgeState(client).load() controller = MotionController(client, state, config, dry_run=args.dry_run) mode = " (dry-run)" if args.dry_run else "" - print(f"hue-iac watching {len(config.motion_policies)} policy/policies{mode}; Ctrl-C to stop.") + print(f"hueman watching {len(config.motion_policies)} policy/policies{mode}; Ctrl-C to stop.") try: controller.run() except KeyboardInterrupt: @@ -211,7 +211,7 @@ def _cmd_circadian(self, args: argparse.Namespace) -> int: client = HueClient(config.bridge) state = BridgeState(client).load() daemon = CircadianDaemon(client, state, config) - print(f"hue-iac circadian daemon driving '{config.circadian_daemon.zone}'; Ctrl-C to stop.") + print(f"hueman circadian daemon driving '{config.circadian_daemon.zone}'; Ctrl-C to stop.") try: daemon.run() except KeyboardInterrupt: @@ -357,7 +357,7 @@ def _confirm(count: int) -> bool: def main(argv: list[str] | None = None) -> int: - """Module entry point used by the ``hue-iac`` console script.""" + """Module entry point used by the ``hueman`` console script.""" return Cli().run(argv) diff --git a/hue_iac/client.py b/hueman/client.py similarity index 97% rename from hue_iac/client.py rename to hueman/client.py index e394310..cf0d7e8 100644 --- a/hue_iac/client.py +++ b/hueman/client.py @@ -2,7 +2,7 @@ Covers exactly what the IaC workflow needs: link-button key creation, typed resource GET/POST/PUT/DELETE, and a couple of convenience lookups. TLS identity -is established up-front via :mod:`hue_iac.pin` (trust-on-first-use) or a CA +is established up-front via :mod:`hueman.pin` (trust-on-first-use) or a CA bundle; we never blindly disable verification without having pinned first. API surface used here is the documented v2 model @@ -21,7 +21,7 @@ from .errors import AuthError, BridgeError from .pin import verify_or_pin -_KEY_CREATE_DEVICETYPE = "hue-iac#cli" +_KEY_CREATE_DEVICETYPE = "hueman#cli" class _FingerprintAdapter(HTTPAdapter): @@ -84,7 +84,7 @@ def _url(self, path: str) -> str: def _request(self, method: str, path: str, *, json_body=None, need_key: bool = True) -> dict: if need_key and not self.bridge.application_key: - raise AuthError("no application key; run 'hue-iac auth' to pair with the bridge") + raise AuthError("no application key; run 'hueman auth' to pair with the bridge") try: resp = self._session.request( method, self._url(path), json=json_body, timeout=self.timeout diff --git a/hue_iac/config.py b/hueman/config.py similarity index 99% rename from hue_iac/config.py rename to hueman/config.py index 2e66419..37e6cff 100644 --- a/hue_iac/config.py +++ b/hueman/config.py @@ -1,6 +1,6 @@ """Declarative configuration model and loader. -This is the user-facing "language" of hue-iac. A YAML document is parsed into a +This is the user-facing "language" of hueman. A YAML document is parsed into a tree of frozen dataclasses with eager validation, so an invalid config fails at ``validate``/``plan`` time with a precise message rather than mid-apply against the live bridge. @@ -61,7 +61,7 @@ def parse_time_ref(value: Any, *, ctx: str) -> str: """Validate a time reference: ``HH:MM``, ``sunrise``, or ``sunset``. Returned verbatim; resolution to a concrete minute happens later against - the day's :class:`~hue_iac.sun.SunTimes` so the same config works all year. + the day's :class:`~hueman.sun.SunTimes` so the same config works all year. """ if value in ("sunrise", "sunset"): return value diff --git a/hue_iac/engine.py b/hueman/engine.py similarity index 98% rename from hue_iac/engine.py rename to hueman/engine.py index 987b3f3..a0acd3f 100644 --- a/hue_iac/engine.py +++ b/hueman/engine.py @@ -3,7 +3,7 @@ This is the brain that turns sensor events into light commands. It is kept free of any I/O: it consumes already-classified events (motion, ambient light level, a manual-override signal, and clock ticks) and emits :class:`Action` values that -the runtime (:mod:`hue_iac.watch`) executes against the bridge. Time is passed +the runtime (:mod:`hueman.watch`) executes against the bridge. Time is passed in as an explicit epoch-seconds float so tests drive it directly. Why a custom engine instead of the bridge's built-in motion behaviour? Three of @@ -150,7 +150,7 @@ def current_timeslot(self, ts: float) -> Timeslot: ts: Epoch seconds of the moment to evaluate. Returns: - The :class:`~hue_iac.config.Timeslot` currently in effect. + The :class:`~hueman.config.Timeslot` currently in effect. """ sun = self._sun_for(self._local_dt(ts).date()) now = self._minute_of_day(ts) diff --git a/hue_iac/errors.py b/hueman/errors.py similarity index 95% rename from hue_iac/errors.py rename to hueman/errors.py index 557b30a..34e5a5b 100644 --- a/hue_iac/errors.py +++ b/hueman/errors.py @@ -1,4 +1,4 @@ -"""Exception hierarchy for hue-iac. +"""Exception hierarchy for hueman. Everything user-facing inherits from :class:`HueIacError` so the CLI can catch a single type, print a clean message, and exit non-zero without a traceback. diff --git a/hue_iac/nightmotion.py b/hueman/nightmotion.py similarity index 99% rename from hue_iac/nightmotion.py rename to hueman/nightmotion.py index e8d9724..a903a9d 100644 --- a/hue_iac/nightmotion.py +++ b/hueman/nightmotion.py @@ -5,7 +5,7 @@ config so every timeslot targets the whole-apartment zone, with the night timeslot rewritten to recall the soft-red scene and switch off quickly. -The reconciler in :mod:`hue_iac.reconcile` owns the bridge I/O; this module only +The reconciler in :mod:`hueman.reconcile` owns the bridge I/O; this module only produces request bodies from inputs. """ diff --git a/hue_iac/payload.py b/hueman/payload.py similarity index 100% rename from hue_iac/payload.py rename to hueman/payload.py diff --git a/hue_iac/pin.py b/hueman/pin.py similarity index 97% rename from hue_iac/pin.py rename to hueman/pin.py index 38e4226..43c63e6 100644 --- a/hue_iac/pin.py +++ b/hueman/pin.py @@ -10,7 +10,7 @@ This module only captures/loads the pinned fingerprint (a raw TLS handshake at startup, TOFU on first run). Enforcement is NOT a one-shot pre-flight check — that alone would leave a TOCTOU window between the probe and the real requests. -Instead :class:`hue_iac.client._FingerprintAdapter` passes the fingerprint to +Instead :class:`hueman.client._FingerprintAdapter` passes the fingerprint to urllib3 as ``assert_fingerprint``, so *every* connection the session opens (including the long-lived SSE stream) verifies the leaf certificate against the pin at handshake time. diff --git a/hue_iac/reconcile.py b/hueman/reconcile.py similarity index 99% rename from hue_iac/reconcile.py rename to hueman/reconcile.py index 1496b9a..c9f5b22 100644 --- a/hue_iac/reconcile.py +++ b/hueman/reconcile.py @@ -8,7 +8,7 @@ The only persistent bridge resource the motion policies own is the motion sensor's *sensitivity*; the live colour/timing behaviour is enforced at runtime -by :mod:`hue_iac.watch`. Keeping sensitivity here means a single ``apply`` fully +by :mod:`hueman.watch`. Keeping sensitivity here means a single ``apply`` fully provisions the declarative surface, and the reconciler base class leaves room for additional resource kinds (such as scenes) without changing the CLI. """ @@ -86,7 +86,7 @@ class Reconciler(abc.ABC): Args: client: An authenticated bridge client. - state: A loaded :class:`~hue_iac.state.BridgeState`. + state: A loaded :class:`~hueman.state.BridgeState`. config: The parsed IaC configuration. """ @@ -647,8 +647,8 @@ def _backup(self, inst: dict) -> None: class CircadianSceneReconciler(Reconciler): """Generates a smooth, sun-anchored circadian ``smart_scene`` from the curve. - Samples :mod:`hue_iac.circadian` at the day's knee times (via - :func:`hue_iac.circadian_scene.circadian_timeslots`) into up to six zone scenes + Samples :mod:`hueman.circadian` at the day's knee times (via + :func:`hueman.circadian_scene.circadian_timeslots`) into up to six zone scenes and wires them to a ``smart_scene`` whose long ``transition_duration`` makes the bridge fade continuously between them — a native, daemon-free circadian cycle that re-anchors to the real sun on every apply. Built on the same scene/backup @@ -912,7 +912,7 @@ class Planner: Args: client: An authenticated bridge client. - state: A loaded :class:`~hue_iac.state.BridgeState`. + state: A loaded :class:`~hueman.state.BridgeState`. config: The parsed IaC configuration. """ diff --git a/hue_iac/security_control.py b/hueman/security_control.py similarity index 98% rename from hue_iac/security_control.py rename to hueman/security_control.py index bbe1e11..f4cabc3 100644 --- a/hue_iac/security_control.py +++ b/hueman/security_control.py @@ -1,6 +1,6 @@ """Pure decision core for the daemon's security mode (no clock, no I/O). -Given the parsed :class:`~hue_iac.config.SecuritySpec` and an injected elapsed +Given the parsed :class:`~hueman.config.SecuritySpec` and an injected elapsed time + frame index, decides the light frame for an escalating ALERT -> CHAOS show. The safety cap is **luminance-only**: a unit's brightness changes no faster than ``min_flash_interval`` (out of the 3-30 Hz photosensitive-seizure @@ -10,8 +10,8 @@ in a rotating budget (``lights_per_frame``) instead of whole groups, so the apartment churns as a decorrelated patchwork. The ALERT phase stays whole-group and legible. Everything here is a pure function of explicit inputs, so it is -fully unit-tested without a bridge, mirroring :mod:`hue_iac.bias_control` and -:mod:`hue_iac.circadian_control`. +fully unit-tested without a bridge, mirroring :mod:`hueman.bias_control` and +:mod:`hueman.circadian_control`. """ from __future__ import annotations diff --git a/hue_iac/state.py b/hueman/state.py similarity index 99% rename from hue_iac/state.py rename to hueman/state.py index 6ed2efd..5b52d68 100644 --- a/hue_iac/state.py +++ b/hueman/state.py @@ -106,7 +106,7 @@ class BridgeState: """Loads and indexes the bridge resources the tool operates on. Args: - client: An authenticated :class:`~hue_iac.client.HueClient`. + client: An authenticated :class:`~hueman.client.HueClient`. """ def __init__(self, client: HueClient) -> None: diff --git a/hue_iac/sun.py b/hueman/sun.py similarity index 100% rename from hue_iac/sun.py rename to hueman/sun.py diff --git a/hue_iac/watch.py b/hueman/watch.py similarity index 98% rename from hue_iac/watch.py rename to hueman/watch.py index 0d65bc3..67b364a 100644 --- a/hue_iac/watch.py +++ b/hueman/watch.py @@ -14,7 +14,7 @@ ``plan``/``apply`` provision the declarative surface; this module is the live controller. It subscribes to the CLIP v2 Server-Sent Events stream (``/eventstream/clip/v2``), feeds motion, light-level and manual-control events -into the per-area :class:`~hue_iac.engine.PolicyEngine` instances, and writes the +into the per-area :class:`~hueman.engine.PolicyEngine` instances, and writes the resulting commands back to the relevant ``grouped_light`` services. Manual-override detection is a trust heuristic: every command we send is @@ -25,7 +25,7 @@ This is the one module that genuinely needs the hardware to exercise end to end, so its decision logic is delegated to the pure, tested engine and its -serialisation to the pure, tested :mod:`hue_iac.payload`; what remains here is +serialisation to the pure, tested :mod:`hueman.payload`; what remains here is event plumbing. """ @@ -48,7 +48,7 @@ from .payload import GroupedLightCommand from .state import BridgeState -_LOG = logging.getLogger("hue_iac.watch") +_LOG = logging.getLogger("hueman.watch") #: How long a command we issued stays in the echo buffer before a matching #: bridge event is assumed to be a human action instead of our own write. @@ -107,7 +107,7 @@ def events(self) -> Iterator[BridgeEvent]: if response.status_code in (401, 403): raise AuthError( f"bridge rejected the application key (HTTP {response.status_code}); " - "re-run 'hue-iac auth'" + "re-run 'hueman auth'" ) response.raise_for_status() # other 4xx/5xx -> retryable HTTPError for line in response.iter_lines(decode_unicode=True): @@ -143,7 +143,7 @@ class MotionController: Args: client: An authenticated bridge client. - state: A loaded :class:`~hue_iac.state.BridgeState`. + state: A loaded :class:`~hueman.state.BridgeState`. config: The parsed IaC configuration. clock: Callable returning epoch seconds; injectable for testing. dry_run: When ``True``, log actions instead of writing to the bridge. diff --git a/pyproject.toml b/pyproject.toml index 3b9067d..02df3ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,12 +3,12 @@ requires = ["setuptools>=61"] build-backend = "setuptools.build_meta" [project] -name = "hue-iac" +name = "hueman" version = "0.1.0" description = "Declarative, Terraform-style management of Philips Hue lighting policies with circadian, motion and timing optimisation." readme = "README.md" requires-python = ">=3.10" -license = { text = "MIT" } +license = "MIT" dependencies = [ "requests>=2.28", "PyYAML>=6.0", @@ -21,10 +21,11 @@ dev = [ ] [project.scripts] -hue-iac = "hue_iac.cli:main" +hueman = "hueman.cli:main" +hue-iac = "hueman.cli:main" # transitional alias, remove after one release [tool.setuptools.packages.find] -include = ["hue_iac*"] +include = ["hueman*"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/conftest.py b/tests/conftest.py index 18c5e89..ab74456 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ import datetime as _dt -from hue_iac.config import Config +from hueman.config import Config TZ_OFFSET = -5.0 diff --git a/tests/test_areas.py b/tests/test_areas.py index 6a5aed9..a750517 100644 --- a/tests/test_areas.py +++ b/tests/test_areas.py @@ -4,10 +4,10 @@ import pytest -from hue_iac.config import Config -from hue_iac.errors import ConfigError -from hue_iac.reconcile import AreaReconciler, ChangeType -from hue_iac.state import BridgeState +from hueman.config import Config +from hueman.errors import ConfigError +from hueman.reconcile import AreaReconciler, ChangeType +from hueman.state import BridgeState class FakeClient: diff --git a/tests/test_bias_control.py b/tests/test_bias_control.py index 11c33e9..ba763bd 100644 --- a/tests/test_bias_control.py +++ b/tests/test_bias_control.py @@ -2,7 +2,7 @@ from __future__ import annotations -from hue_iac.bias_control import ( +from hueman.bias_control import ( BiasDrive, BiasHold, BiasOff, @@ -10,8 +10,8 @@ bias_actions, unknown_bias_lights, ) -from hue_iac.circadian_control import DriveTo -from hue_iac.config import BiasLight, BiasSpec, Color, LightState +from hueman.circadian_control import DriveTo +from hueman.config import BiasLight, BiasSpec, Color, LightState def _spec(*lights: tuple[str, str]) -> BiasSpec: diff --git a/tests/test_circadian.py b/tests/test_circadian.py index edacaa5..24be4ba 100644 --- a/tests/test_circadian.py +++ b/tests/test_circadian.py @@ -2,7 +2,7 @@ from __future__ import annotations -from hue_iac.circadian import CircadianCurve, CircadianParams +from hueman.circadian import CircadianCurve, CircadianParams P = CircadianParams() NOON = 65.0 # a representative solar-noon elevation (degrees) diff --git a/tests/test_circadian_control.py b/tests/test_circadian_control.py index a448f69..2247f22 100644 --- a/tests/test_circadian_control.py +++ b/tests/test_circadian_control.py @@ -1,11 +1,11 @@ from __future__ import annotations import datetime as _dt -from hue_iac.circadian import CircadianParams -from hue_iac.circadian_control import CircadianController, DriveTo, FadeOff, Hold -from hue_iac.config import CircadianDaemonSpec -from hue_iac.config import Anchor -from hue_iac.sun import SolarCalculator +from hueman.circadian import CircadianParams +from hueman.circadian_control import CircadianController, DriveTo, FadeOff, Hold +from hueman.config import CircadianDaemonSpec +from hueman.config import Anchor +from hueman.sun import SolarCalculator TZ = -7.0 # PDT, matches the test date SOLAR = SolarCalculator(45.5152, -122.6784, TZ) # Portland diff --git a/tests/test_circadian_daemon.py b/tests/test_circadian_daemon.py index e5e092f..5d30a22 100644 --- a/tests/test_circadian_daemon.py +++ b/tests/test_circadian_daemon.py @@ -6,10 +6,10 @@ import pytest import requests -from hue_iac.circadian_daemon import CircadianDaemon, _LOG -from hue_iac.config import Config -from hue_iac.errors import AuthError, BridgeError -from hue_iac.watch import BridgeEvent +from hueman.circadian_daemon import CircadianDaemon, _LOG +from hueman.config import Config +from hueman.errors import AuthError, BridgeError +from hueman.watch import BridgeEvent @pytest.fixture(autouse=True) @@ -253,7 +253,7 @@ def test_init_raises_bridge_error_if_zone_has_no_grouped_light(): Without this guard every write silently fails (PUT to a None rid is a no-op) and the daemon runs indefinitely without driving any lights. """ - from hue_iac.state import Group + from hueman.state import Group class _StateNoGl: def group(self, name: str) -> Group: @@ -273,8 +273,8 @@ def test_init_raises_clear_error_listing_all_unknown_bias_lights(): """A bias light absent from the bridge fails fast at startup, naming *every* bad name at once, instead of crash-looping on a cryptic first-name resolution error. """ - from hue_iac.errors import ConfigError - from hue_iac.state import Group, LightRef + from hueman.errors import ConfigError + from hueman.state import Group, LightRef class _State: def group(self, name): @@ -607,7 +607,7 @@ def test_bias_edge_logs_flip_at_info(caplog): cfg = _cfg_bias({"probe": {"enabled": True, "host": "192.0.2.50", "debounce": "0s"}}) d = CircadianDaemon.for_test(_FakeClient(), cfg, grouped_light_rid="GL") d._bias_rids = {"Couch": "Lcouch"} - caplog.set_level(logging.INFO, logger="hue_iac.circadian_daemon") + caplog.set_level(logging.INFO, logger="hueman.circadian_daemon") t = _epoch(13, 14) d._tick_once(t) import unittest.mock as _mock @@ -624,7 +624,7 @@ def test_bias_edge_logs_flip_at_info(caplog): def test_tick_logs_drive_at_info(caplog): """A daytime tick emits an INFO record mentioning 'drive' and the zone name.""" daemon = CircadianDaemon.for_test(_FakeClient(), _cfg(), grouped_light_rid="GL") - caplog.set_level(logging.INFO, logger="hue_iac.circadian_daemon") + caplog.set_level(logging.INFO, logger="hueman.circadian_daemon") daemon._tick_once(_epoch(13, 14)) assert any( r.levelno == logging.INFO and "drive" in r.message and "Night Guide" in r.message @@ -635,7 +635,7 @@ def test_tick_logs_drive_at_info(caplog): def test_external_event_logs_override(caplog): """A brightness that SETTLES far from target logs INFO 'override' + 'suspended'.""" daemon = CircadianDaemon.for_test(_FakeClient(), _cfg(), grouped_light_rid="GL") - caplog.set_level(logging.INFO, logger="hue_iac.circadian_daemon") + caplog.set_level(logging.INFO, logger="hueman.circadian_daemon") t0 = _epoch(12, 0) daemon._tick_once(t0) daemon._handle_event(_dim(30.0), t0 + 0.4) # moves (from None) @@ -795,8 +795,8 @@ def test_security_control_file_arms(tmp_path): def test_init_raises_listing_unknown_security_groups(): - from hue_iac.errors import ConfigError - from hue_iac.state import Group + from hueman.errors import ConfigError + from hueman.state import Group class _State: def group(self, name): @@ -1016,7 +1016,7 @@ def update_resource(self, rtype, rid, body): _FlakyClient(), _cfg_security(alert_seconds=1, max_duration="2s"), grouped_light_rid="GL") d._security_rids = {"Night Guide": "GLng", "TV Viewing": "GLtv"} - caplog.set_level(_logging.INFO, logger="hue_iac.circadian_daemon") + caplog.set_level(_logging.INFO, logger="hueman.circadian_daemon") monkeypatch.setattr(d._stop_event, "wait", lambda *a, **k: None) start = _epoch(2, 0) seq = iter(start + 0.25 * i for i in range(0, 400)) diff --git a/tests/test_circadian_scene.py b/tests/test_circadian_scene.py index 770ce9b..06fac1c 100644 --- a/tests/test_circadian_scene.py +++ b/tests/test_circadian_scene.py @@ -5,13 +5,13 @@ import datetime as _dt import pytest -from hue_iac.circadian import CircadianCurve, CircadianParams -from hue_iac.circadian_scene import MAX_TIMESLOTS, circadian_timeslots -from hue_iac.config import Config -from hue_iac.nightmotion import scene_body -from hue_iac.reconcile import ChangeType, CircadianSceneReconciler -from hue_iac.state import BridgeState -from hue_iac.sun import SolarCalculator +from hueman.circadian import CircadianCurve, CircadianParams +from hueman.circadian_scene import MAX_TIMESLOTS, circadian_timeslots +from hueman.config import Config +from hueman.nightmotion import scene_body +from hueman.reconcile import ChangeType, CircadianSceneReconciler +from hueman.state import BridgeState +from hueman.sun import SolarCalculator SOLAR = SolarCalculator(40.7128, -74.0060, -4) # NYC, EDT PARAMS = CircadianParams() # day 233/100, evening 370/60, night 454/15 diff --git a/tests/test_cli.py b/tests/test_cli.py index 92e267e..83232f3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,13 +4,13 @@ import pytest -from hue_iac.cli import Cli -from hue_iac.errors import HueIacError -from hue_iac.reconcile import Change, ChangeType +from hueman.cli import Cli +from hueman.errors import HueIacError +from hueman.reconcile import Change, ChangeType def test_circadian_resume_writes_control_file(tmp_path): - import hue_iac.cli as climod + import hueman.cli as climod cfgfile = tmp_path / "hue.yaml" ctrl = tmp_path / ".resume" cfgfile.write_text( @@ -24,7 +24,7 @@ def test_circadian_resume_writes_control_file(tmp_path): def test_circadian_parser_has_run_and_resume(): - from hue_iac.cli import Cli + from hueman.cli import Cli args = Cli()._build_parser().parse_args(["circadian", "run"]) assert args.command == "circadian" and args.circadian_cmd == "run" diff --git a/tests/test_client.py b/tests/test_client.py index 9ecd750..093d550 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -9,9 +9,9 @@ import pytest -from hue_iac import client as client_mod -from hue_iac.client import HueClient -from hue_iac.config import Bridge, TlsConfig +from hueman import client as client_mod +from hueman.client import HueClient +from hueman.config import Bridge, TlsConfig FP = "9142" + "0" * 60 # 64 hex chars @@ -56,7 +56,7 @@ def test_create_application_key_non_json_response_is_clean_error(monkeypatch): CLI can print, not a raw requests JSONDecodeError traceback.""" import requests - from hue_iac.errors import HueIacError + from hueman.errors import HueIacError monkeypatch.setattr(client_mod, "verify_or_pin", lambda host, pin_file: FP) c = HueClient(_bridge("pin")) diff --git a/tests/test_config.py b/tests/test_config.py index 445748e..0e1588c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,8 +4,8 @@ import pytest -from hue_iac.config import Color, Config, SecuritySpec, parse_duration, parse_time_ref -from hue_iac.errors import ConfigError +from hueman.config import Color, Config, SecuritySpec, parse_duration, parse_time_ref +from hueman.errors import ConfigError from tests.conftest import make_config @@ -89,7 +89,7 @@ def _cfg_doc(extra: dict) -> dict: def test_circadian_scene_parses_full() -> None: """A full circadian_scene block parses into a typed spec.""" - from hue_iac.config import Config + from hueman.config import Config cfg = Config.parse(_cfg_doc({"circadian_scene": { "smart_scene": "Golden hours", "zone": "Night Guide", @@ -104,7 +104,7 @@ def test_circadian_scene_parses_full() -> None: def test_circadian_scene_explicit_transition_and_default_handoff() -> None: """An explicit duration parses to ms; hand_off defaults to 22:34.""" - from hue_iac.config import Config + from hueman.config import Config cfg = Config.parse(_cfg_doc({"circadian_scene": { "smart_scene": "X", "zone": "Z", "transition": "90m"}})) @@ -115,7 +115,7 @@ def test_circadian_scene_explicit_transition_and_default_handoff() -> None: def test_circadian_scene_requires_zone() -> None: """A circadian_scene without a zone is rejected.""" - from hue_iac.config import Config + from hueman.config import Config with pytest.raises(ConfigError): Config.parse(_cfg_doc({"circadian_scene": {"smart_scene": "X"}})) @@ -123,7 +123,7 @@ def test_circadian_scene_requires_zone() -> None: def test_circadian_scene_rejects_sun_handoff() -> None: """hand_off must be a clock time, not a sun anchor.""" - from hue_iac.config import Config + from hueman.config import Config with pytest.raises(ConfigError): Config.parse(_cfg_doc({"circadian_scene": { @@ -132,7 +132,7 @@ def test_circadian_scene_rejects_sun_handoff() -> None: def test_no_circadian_scene_is_none() -> None: """Omitting circadian_scene leaves it unset.""" - from hue_iac.config import Config + from hueman.config import Config assert Config.parse(_cfg_doc({})).circadian_scene is None @@ -151,14 +151,14 @@ def test_duplicate_policy_names_rejected() -> None: "motion_policies": [base, dict(base)], } with pytest.raises(ConfigError): - from hue_iac.config import Config + from hueman.config import Config Config.parse(doc) def test_location_tz_only_backfills_a_float_offset() -> None: """`tz` without tz_offset_hours parses; offset is back-filled to a float.""" - from hue_iac.config import Config + from hueman.config import Config cfg = Config.parse(_cfg_doc( {"location": {"lat": 45.5, "lon": -122.7, "tz": "America/Los_Angeles"}} @@ -169,7 +169,7 @@ def test_location_tz_only_backfills_a_float_offset() -> None: def test_location_tz_and_offset_both_retained() -> None: """When both are given, both are kept (tz drives per-date derivation downstream).""" - from hue_iac.config import Config + from hueman.config import Config cfg = Config.parse(_cfg_doc( {"location": {"lat": 45.5, "lon": -122.7, "tz_offset_hours": -7, "tz": "America/Los_Angeles"}} @@ -180,7 +180,7 @@ def test_location_tz_and_offset_both_retained() -> None: def test_location_unknown_tz_rejected() -> None: """A bogus IANA name is a config error, not a runtime crash.""" - from hue_iac.config import Config + from hueman.config import Config with pytest.raises(ConfigError): Config.parse(_cfg_doc({"location": {"lat": 45.5, "lon": -122.7, "tz": "Not/A_Zone"}})) @@ -188,7 +188,7 @@ def test_location_unknown_tz_rejected() -> None: def test_location_requires_tz_or_offset() -> None: """Neither tz nor tz_offset_hours -> ConfigError (unchanged contract).""" - from hue_iac.config import Config + from hueman.config import Config with pytest.raises(ConfigError): Config.parse(_cfg_doc({"location": {"lat": 45.5, "lon": -122.7}})) @@ -196,7 +196,7 @@ def test_location_requires_tz_or_offset() -> None: def test_location_empty_or_malformed_tz_rejected() -> None: """Empty string and path-like tz values are ConfigErrors, not ValueError crashes.""" - from hue_iac.config import Config + from hueman.config import Config with pytest.raises(ConfigError): Config.parse(_cfg_doc({"location": {"lat": 45.5, "lon": -122.7, "tz": ""}})) @@ -207,14 +207,14 @@ def test_location_empty_or_malformed_tz_rejected() -> None: def test_location_tz_must_be_a_string() -> None: """A non-string tz (e.g. an integer) is a ConfigError.""" - from hue_iac.config import Config + from hueman.config import Config with pytest.raises(ConfigError): Config.parse(_cfg_doc({"location": {"lat": 45.5, "lon": -122.7, "tz": 42}})) def test_circadian_daemon_defaults(): - from hue_iac.config import Config + from hueman.config import Config cfg = Config.parse(_cfg_doc({"circadian_daemon": {"zone": "Night Guide"}})) d = cfg.circadian_daemon assert d.zone == "Night Guide" @@ -240,7 +240,7 @@ def test_circadian_daemon_defaults(): def test_circadian_daemon_overrides_and_anchor(): - from hue_iac.config import Config + from hueman.config import Config cfg = Config.parse(_cfg_doc({"circadian_daemon": { "zone": "Z", "start": "sunrise+30m", "hand_off": "23:00", "interval": "90s", "transition": "120s", "fade_off": "2m", @@ -268,13 +268,13 @@ def test_circadian_daemon_overrides_and_anchor(): def test_circadian_daemon_requires_zone(): - from hue_iac.config import Config + from hueman.config import Config with pytest.raises(ConfigError): Config.parse(_cfg_doc({"circadian_daemon": {}})) def test_circadian_daemon_absent_is_none(): - from hue_iac.config import Config + from hueman.config import Config assert Config.parse(_cfg_doc({})).circadian_daemon is None @@ -283,13 +283,13 @@ def test_circadian_daemon_absent_is_none(): # --------------------------------------------------------------------------- # def _daemon_bias(bias: dict): """Parse a config whose circadian_daemon carries the given bias block.""" - from hue_iac.config import Config + from hueman.config import Config cfg = Config.parse(_cfg_doc({"circadian_daemon": {"zone": "Night Guide", "bias": bias}})) return cfg.circadian_daemon.bias def test_bias_parses_full() -> None: - from hue_iac.config import BiasSpec + from hueman.config import BiasSpec bias = _daemon_bias({ "lights": { "Play bars": {"look": {"mirek": 153, "brightness": 28}, "idle": "off"}, @@ -365,7 +365,7 @@ def test_bias_edge_transition_explicit() -> None: def test_bias_absent_is_none() -> None: - from hue_iac.config import Config + from hueman.config import Config cfg = Config.parse(_cfg_doc({"circadian_daemon": {"zone": "Night Guide"}})) assert cfg.circadian_daemon.bias is None @@ -486,7 +486,7 @@ def test_circadian_night_start_is_rejected_as_removed() -> None: loudly instead of silently doing nothing.""" doc = _cfg_doc({}) doc["circadian"] = {"day_mirek": 233, "night_start": "23:00"} - from hue_iac.config import Config + from hueman.config import Config with pytest.raises(ConfigError, match="night_start"): Config.parse(doc) diff --git a/tests/test_engine.py b/tests/test_engine.py index 58392c6..2fd808c 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -7,7 +7,7 @@ from __future__ import annotations -from hue_iac.engine import Phase, PolicyEngine +from hueman.engine import Phase, PolicyEngine from tests.conftest import epoch_at, make_config LOCATION = (40.7128, -74.0060, -5.0) diff --git a/tests/test_nightmotion.py b/tests/test_nightmotion.py index 41d1c33..b71ec61 100644 --- a/tests/test_nightmotion.py +++ b/tests/test_nightmotion.py @@ -8,11 +8,11 @@ import pytest -from hue_iac.config import Config -from hue_iac.errors import ConfigError -from hue_iac.nightmotion import scene_actions_match, scene_body, transform_automation -from hue_iac.reconcile import ChangeType, NightMotionReconciler -from hue_iac.state import BridgeState +from hueman.config import Config +from hueman.errors import ConfigError +from hueman.nightmotion import scene_actions_match, scene_body, transform_automation +from hueman.reconcile import ChangeType, NightMotionReconciler +from hueman.state import BridgeState FIXTURE = json.loads( (pathlib.Path(__file__).parent / "fixtures" / "main_room_behavior.json").read_text() diff --git a/tests/test_payload.py b/tests/test_payload.py index 18916a6..2f4ef22 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -2,8 +2,8 @@ from __future__ import annotations -from hue_iac.engine import TargetState -from hue_iac.payload import ColorConverter, GroupedLightCommand +from hueman.engine import TargetState +from hueman.payload import ColorConverter, GroupedLightCommand def test_red_hex_maps_to_red_corner_of_gamut() -> None: @@ -61,7 +61,7 @@ def test_build_without_transition_unchanged() -> None: def test_light_command_matches_grouped_body() -> None: """Per-light writes reuse the same body shape as grouped_light.""" - from hue_iac.payload import LightCommand + from hueman.payload import LightCommand t = TargetState(on=True, brightness=28.0, mirek=153, hex=None) assert LightCommand.build(t, transition_ms=500) == GroupedLightCommand.build(t, transition_ms=500) @@ -74,7 +74,7 @@ def test_light_command_matches_grouped_body() -> None: def test_light_command_hex_and_off() -> None: - from hue_iac.payload import LightCommand + from hueman.payload import LightCommand assert "color" in LightCommand.build(TargetState(on=True, brightness=5.0, hex="1a0a00")) assert LightCommand.build(TargetState.off(), transition_ms=90000) == { diff --git a/tests/test_pin.py b/tests/test_pin.py index e968c2c..9937c3b 100644 --- a/tests/test_pin.py +++ b/tests/test_pin.py @@ -10,8 +10,8 @@ import pytest -from hue_iac import pin -from hue_iac.errors import PinError +from hueman import pin +from hueman.errors import PinError FP_A = "a" * 64 FP_B = "b" * 64 diff --git a/tests/test_scenes.py b/tests/test_scenes.py index 851d2c6..5c255ee 100644 --- a/tests/test_scenes.py +++ b/tests/test_scenes.py @@ -4,10 +4,10 @@ import pytest -from hue_iac.config import Color, Config, LightState -from hue_iac.errors import ConfigError -from hue_iac.reconcile import ChangeType, Planner, SceneReconciler -from hue_iac.state import BridgeState +from hueman.config import Color, Config, LightState +from hueman.errors import ConfigError +from hueman.reconcile import ChangeType, Planner, SceneReconciler +from hueman.state import BridgeState def _doc(scenes): diff --git a/tests/test_security_control.py b/tests/test_security_control.py index 1fd324a..c142a11 100644 --- a/tests/test_security_control.py +++ b/tests/test_security_control.py @@ -2,8 +2,8 @@ from __future__ import annotations -from hue_iac.config import Config -from hue_iac.security_control import ( +from hueman.config import Config +from hueman.security_control import ( PHASE_ALERT, PHASE_CHAOS, SecurityController, diff --git a/tests/test_smartscene.py b/tests/test_smartscene.py index 8499d70..f241e95 100644 --- a/tests/test_smartscene.py +++ b/tests/test_smartscene.py @@ -6,11 +6,11 @@ import pytest -from hue_iac.config import Anchor, Config -from hue_iac.errors import ConfigError, HueIacError -from hue_iac.reconcile import Change, ChangeType, SmartSceneReconciler -from hue_iac.state import BridgeState -from hue_iac.sun import SolarCalculator +from hueman.config import Anchor, Config +from hueman.errors import ConfigError, HueIacError +from hueman.reconcile import Change, ChangeType, SmartSceneReconciler +from hueman.state import BridgeState +from hueman.sun import SolarCalculator def _doc(smart_scenes): diff --git a/tests/test_state.py b/tests/test_state.py index 095df3a..263043a 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -8,7 +8,7 @@ from __future__ import annotations -from hue_iac.state import BridgeState +from hueman.state import BridgeState class FakeClient: diff --git a/tests/test_sun.py b/tests/test_sun.py index 53fe6ac..34bd52e 100644 --- a/tests/test_sun.py +++ b/tests/test_sun.py @@ -6,7 +6,7 @@ import pytest -from hue_iac.sun import SolarCalculator, zone_offset_hours +from hueman.sun import SolarCalculator, zone_offset_hours def test_summer_solstice_nyc_sunrise_sunset_reasonable() -> None: diff --git a/tests/test_watch.py b/tests/test_watch.py index 23db544..4846448 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -10,11 +10,11 @@ import pytest import requests -from hue_iac.config import Config -from hue_iac.engine import Action, Phase, TargetState -from hue_iac.errors import AuthError, BridgeError -from hue_iac.state import BridgeState -from hue_iac.watch import BridgeEvent, MotionController +from hueman.config import Config +from hueman.engine import Action, Phase, TargetState +from hueman.errors import AuthError, BridgeError +from hueman.state import BridgeState +from hueman.watch import BridgeEvent, MotionController GL = "gl1" # grouped_light rid for the Office room MOT = "mot1" # motion service rid