# AI-Managed Smart Home

Local-first, open-source smart home: Home Assistant + Zigbee + RuView (CSI presence)
+ Bermuda (BLE identity) + local LLM (Ollama) + Frigate (peephole face recognition)
+ Grocy (kitchen kiosk) + Nextcloud calendar sync + a Sway thin-client media station
+ a Sway touch panel + a camera-vision kitchen/fridge display + a voice/touch
identity-registration door panel + a quarter-daily LLM-generated digest.

See [`docs/project-plan.md`](docs/project-plan.md) for the full hardware list,
software stack, and phased implementation plan.

## Repo layout

```
docs/                       Project plan and design notes
hosts/
  container-host/           Docker Compose stack: HA, Mosquitto, Zigbee2MQTT,
                             Frigate, Grocy, Node-RED, monitoring, etc.
    configs/                Per-service config files (mosquitto.conf, etc.)
  llm-host/                 Ollama + GPU host (separate physical machine)
  thin-client/              Sway kiosk/media-station ISO (live-build) + thinclient-agent
  audio-endpoint/           Headless Spotify Connect appliance for rooms with no thin
                             client — arm64 (rpi-image-gen) + amd64 (live-build) images
  touch-panel/              Touch-driven Sway panel: full Spotify GUI, a dedicated
                             Home Assistant Chromium kiosk window, and a general web
                             browser, switched via an always-on touch dock or by HA/
                             the local LLM over MQTT
  kitchen-display/           Single-purpose Sway kiosk for the fridge/pantry: one
                             Chromium window showing pantry-vision's unload/consume/
                             expired/edit frontend, camera capture via the browser
                             itself
  steam-tv-box/               Living-room gaming box: boots into Steam Big Picture and
                             runs games LOCALLY (real GPU — not thin-client's Steam
                             Link streaming). Prism Launcher runs through Steam so
                             Steam Input is live for Minecraft. The media apps
                             (Firefox+uBlock Origin, Spotify, mpv) are launched lazily,
                             the first time somebody leaves Big Picture
  door-panel/                 Single-purpose Sway kiosk by the door/wardrobe:
                             identity's weather+clothing/who's-home/groceries-low
                             dashboard by default, registration on demand — this
                             host's twin relationship to kitchen-display, but the
                             mic is the point here, not an opt-in edge case
firmware/
  ruview/                   RuView ESP32-S3 CSI presence node configs
  esphome-ble-proxy/        ESPHome configs for Bermuda BLE proxy nodes
  esp32-s3-touch-lcd-1.85c/ ESPHome voice satellite + status display (round LCD,
                            media/cover-art priority over an idle weather/time/
                            date cycle, voice-state visualizer)
tools/                      All build + setup scripts, driven by one
                             CoreSystemConfig.json: twinned container-host/LLM-host
                             ISO pair, every kiosk ISO, and a validator that refuses
                             a build on port collisions, placeholder tokens, or a
                             kiosk pointed at a disabled service
identity/                   Person <-> BLE-identifier registry: multi-phone support,
                             anti-spoofing (allowlisted IRK-resolved/fixed-tag
                             entities only, never a raw MAC), voice/touch
                             registration, presence resolution, visit history
                             ("who was home when, with whom"), nicknames, per-device
                             rights, and an admin panel (write API + frontend/
                             static serving, consumed by kitchen-display and
                             door-panel)
digest-engine/              Quarter-daily LLM digest: mail/message/news/financial
                            ingestion, LLM synthesis, digest-canvas SDK rendering
admin-canvas/               On-demand sys-admin-llm display surface for the thin
                             clients: stats/graphics/media, pushed on demand
                             rather than on a schedule (write API + admin-web
                             static serving)
pantry-vision/               Kitchen-display backend: a photo held up to the camera
                             -> an Ollama vision-model proposal -> human-confirmed
                             write into Grocy stock. All four stock movements are
                             camera-driven (unload / consume / bin what expired /
                             correct by hand), counted in individual units and folded
                             brand-free; also proxies Grocy's inventory
                             (soonest-expiring first) and recipes to the kiosk
                             frontend (write API + frontend/ static serving)
trash-calendar/             Reads Kennelbach's personal trash-collection ICS feed,
                             writes matching events onto the shared CalDAV calendar
                             (daily systemd timer, read-only against the feed)
transit/                    Public transit: GET /departures (static GTFS lookup)
                             and GET /plan (OpenTripPlanner proxy, voice-usable via
                             HA Assist) — weekly GTFS refresh via systemd timer
chores/                      Presence/calendar-driven household chore nudging + a
                             passive fairness tally + camera-verified trash-bin/
                             dishes/litter checks (systemd-timed oneshot, no
                             long-lived service, no LLM-picked assignment)
stream-dock/                 Desk-side MiraBox N4 Pro (10 keys, 4 encoders) driving
                             one room's colour lamps through OpenDeck: an HA script
                             package for the relative-colour maths, generated dial/key
                             bindings, and a knob-ring colour service gated on the
                             dock's lighting layer
```

## Status

- [x] Project plan drafted
- [ ] **Stream Dock lighting controls** (`stream-dock/`) — four rotary encoders on a desk-side **MiraBox N4 Pro** (the xVSDinside-branded one; Ajazz AKP05 family) as R / G / B / brightness for one room, through **OpenDeck**. **No new plugin was written, deliberately**: [streamdeck-homeassistant](https://github.com/cgiesche/streamdeck-homeassistant) already does HA-over-websocket with encoder actions, and [opendeck-akp05](https://github.com/aroaxinping/opendeck-akp05) already teaches OpenDeck this non-Elgato hardware — what was missing was the configuration between them, which is what this directory is. The one thing neither plugin can do is **relative colour**: an encoder emits "three ticks clockwise" and HA has `brightness_step_pct` but no equivalent for a colour channel, so the dock sends only *which channel, how many ticks* and `ha-package/stream_dock.yaml` does the read-clamp-write against the lamp's current `rgb_color`. Rings 1–3 glow their own channel's value in their own colour and ring 4 glows in **what the room is actually emitting** (rgb scaled by brightness); off the dock's lighting layer they fall back to the desktop's own `#E40046`/`#5018DD`/`#F50505` chase and Home Assistant stops being polled at all. **Nothing has touched hardware.** The ring colours are computed, debounced and written correctly — verified against a stub HA — but the akp05 plugin reads `leds.toml` only at startup and holds the USB device open, so the last hop is an `apply_command` hook that ships empty; the real fix is a file-watch upstream. The layer gate ships answering *"I cannot tell"* rather than guessing OpenDeck's undocumented profile-state schema, and `{{ticks}}` is the first thing to test before binding four dials — see `stream-dock/README.md` §5–7
- [x] Container host setup script v1 (HA, Mosquitto, Zigbee2MQTT USB, Frigate, Grocy)
- [x] Node-RED + monitoring (Netdata) + dashboard (Homepage) + ntfy + Portainer added to compose stack
- [x] Backup (restic) setup — scripted, off by default until a backup target is picked (`ENABLE_BACKUPS`)
- [ ] Bermuda / ESPHome BLE proxy configs — `firmware/esphome-ble-proxy/` built (stock ESPHome `bluetooth_proxy` component), not yet flashed to real hardware, see that directory's README
- [ ] RuView node configs — `firmware/ruview/` documents the real upstream project ([github.com/ruvnet/ruview](https://github.com/ruvnet/ruview), integrated not forked) + a per-room provisioning wrapper + `automations.yaml.example` (sleep → dim lights, possible-distress → whole-household alert, concurrent elevated heart rate → colored lighting, bathroom occupancy → an external door indicator). **Every automation's entity_id is an unconfirmed placeholder**, and the concurrent-two-person-heart-rate rule rests on an unconfirmed assumption about RuView's multi-target vital-sign capability — see `firmware/ruview/README.md` §5–6 and `docs/project-plan.md` open decisions #32–33 before relying on any of it
- [ ] Frigate peephole camera config (real RTSP details)
- [ ] Grocy kiosk (Pi + touchscreen) setup
- [ ] LLM host (Ollama) setup script — `hosts/llm-host/` written: auto-detects a GPU vs CPU-only tier, runs Ollama as a pinned container (no `curl | sh` into root), pulls the Phase 3 text model + a vision model for `pantry-vision`/`chores`, and takes a reasoned position on Assist-vs-digest GPU contention (`MAX_LOADED_MODELS=1` — swap predictably rather than OOM mid-request). **Never run — no Debian machine, no GPU, no Ollama server**; the script is syntax-checked and its generated compose validated as YAML for both tiers, and that's the whole of the testing. The vision-model pick is still unmade (open decision #18), and the contention defaults are reasoned, not measured (#4) — see `hosts/llm-host/README.md`
- [x] CalDAV / Nextcloud calendar integration notes — `docs/caldav-integration.md`: the four independent clients (digest-engine read-only, chores' busy-check read-only, trash-calendar create-only, HA's own bridge), why they share one Nextcloud **app password** and what that costs (rotation touches three env files; **the read-only invariant is a code property, not a permission boundary** — an app password can't be scoped read-only or to one calendar), `trash-calendar`'s UID-prefix ownership invariant, and the two traps that cost real debugging time (unexpanded recurrence reporting a meeting on the day it was created; `CALDAV_VERIFY_TLS=false`). Nextcloud itself is pre-existing — nothing here deploys it
- [x] Sway thin-client ISO (live-build) + thinclient-agent — built, not yet boot-tested on real hardware; RDP replaced by wayvnc (resolved), remaining open items (mic-enabled rooms, exact hardware target, wayvnc password provisioning) in `docs/project-plan.md` §4
- [ ] Thin-client follow-ups in progress: fullscreen-aware now-playing widget (cover art + controls), minimal Firefox chrome + uBlock Origin/SponsorBlock, persistent audio-output selection, outbound RDP/VNC client (`rdp-vnc.json`), HA mobile-app browser remote control (text input + mouse buttons), capture-card ("receiver box") video source selection on a new `5:capture` workspace, idle-gallery weather/clock overlay (clock always, weather via a new `smarthome/weather/current` MQTT topic an HA automation has to publish) — built, not yet tried against real capture-card hardware or a real weather automation, see `hosts/thin-client/README.md`
- [x] Quarter-daily digest engine (mail/Signal/Telegram/Discord/WhatsApp, news, financial ingestion; LLM synthesis; digest-canvas SDK) — built and wired into `setup-container-host.sh` (`ENABLE_DIGEST_ENGINE`, off by default), not yet run against real credentials; household/calendar ingest (CalDAV/Grocy) still needs a real data source wired in, see `docs/project-plan.md` §4
- [x] admin-canvas + admin-web (sys-admin-llm on-demand display surface for the thin clients) — built and wired into `setup-container-host.sh` (`ENABLE_ADMIN_CANVAS`, off by default); the HA-side tool/rest_command wiring and the specific entities it surfaces (e.g. power-monitoring) are still undecided, see `docs/project-plan.md` §4
- [ ] ESP32-S3-Touch-LCD-1.85C-V2 voice satellite + status display (`firmware/esp32-s3-touch-lcd-1.85c/`) — ESPHome config written and passes `esphome config`, not yet flashed to real hardware; `media_player`/`weather` entity IDs still need to be chosen, see `docs/project-plan.md` §4
- [ ] Headless audio endpoint (`hosts/audio-endpoint/`) — per-room independent Spotify Connect appliance for rooms without a thin client, arm64 (Raspberry Pi + HiFiBerry Amp2, rpi-image-gen) and amd64 (mini PC + USB DAC/amp, live-build) build pipelines written, **neither built/flashed/booted on real hardware** — rpi-image-gen's exact config schema in particular is unverified, see `hosts/audio-endpoint/README.md`
- [ ] Sway touch panel (`hosts/touch-panel/`) — touch-driven Sway image: full Spotify GUI (Flathub), a dedicated Home Assistant Chromium kiosk window, a general web browser, an always-on touch dock for app switching, an on-screen keyboard (toggled manually, no auto-show), and `touchpanel-agent` (HA MQTT control, same LLM-mediated-through-HA security model as the thin client) — built, **no touch-panel hardware chosen and nothing booted on real metal**, see `hosts/touch-panel/README.md`. **Discord is now an optional fourth app** (`kiosks[].enable_discord`, validator-restricted to this image type), which is what the Loggia panel — the Lenovo all-in-one already owned — is for: the point is a voice call that survives stepping outside for a cigarette, so it starts logged-in at session boot and every control path (dock button, HA **Show Discord**, the **Screen** select, whose options are now derived from the app table rather than fixed) focuses the running app instead of launching it. It is an XWayland window, so sway matches `class` rather than `app_id`, touch is the emulated-pointer tier, and it deliberately does not inhibit idle — a call must not hold the panel's screen on in an empty room
- [ ] Steam TV box (`hosts/steam-tv-box/`) — living-room machine that boots straight into **Steam Big Picture** and plays games **locally** (native `steam-installer` + i386 multiarch + full Mesa/Vulkan stack, `steam-devices`, gamemode, gamescope when the release has it) — the opposite end of `hosts/thin-client/`'s Steam Link streaming, and the only host here pinned to **trixie** (per-kiosk `debian_release` override; bookworm's Mesa 22.3 is too old for a machine that renders). **Prism Launcher** is installed from Flathub and registered as a *non-Steam game* so Minecraft runs through Steam with Steam Input and the Steam Controller API live. Leaving Big Picture drops into the same media set as the other monitor clients — Firefox with uBlock Origin/SponsorBlock, Spotify, mpv — **launched lazily**, on two independent triggers (the Steam process exiting, and a sway workspace-focus IPC watcher), both idempotent, and never torn down when you go back into a game. `steamtv-agent` adds a session-mode sensor, mode/screen/audio-output selects, app-launch buttons and a CEC display switch over the same MQTT-only control surface as the other kiosks. Written and validated end-to-end through `validate-config.py`/`config-export.py`, **never built, never flashed, never booted, no hardware chosen** — the `shortcuts.vdf` binary format, the Flathub app IDs, gamescope's availability and the NVIDIA package names are all reasoned rather than verified, see `hosts/steam-tv-box/README.md`
- [ ] Kitchen/fridge display + `pantry-vision` (`hosts/kitchen-display/`, `pantry-vision/`) — hold a grocery item up to the camera, an Ollama vision model proposes what it is and roughly how long it keeps, a human confirms (never auto-committed) before it's written into Grocy stock; the display then shows inventory sorted by soonest-to-expire, groceries running low, and Grocy's recipes. All four stock movements now run off the camera — **unload** (a scan loop, one confirm per item, with pack size and where to put it away), **consume** (asks which brand and how many), **list expired** (cleared by scanning what you're binning, booked out as spoiled), and **edit inventory** (the deliberately camera-free correction screen) — with two invariants: stock counts *individual units* (a twelve-pack of eggs is twelve) and folds *brand-free* via Grocy product groups (12 of brand X + 10 of brand Y = 22 eggs, expandable per brand). Built and wired into `setup-container-host.sh` (`ENABLE_PANTRY_VISION`, off by default), **nothing run against a real camera, vision model, or Grocy instance** — the Grocy API call shapes in particular are written from documentation only, see `pantry-vision/README.md` and `hosts/kitchen-display/README.md`
- [x] Where-is-it-actually, for multiple fridges — `docs/fridge-item-location.md`: separates "which appliance" (a software-only change: Grocy locations + a transfer action) from "which shelf" (a *hint* at best) and "exact position" (occlusion makes it unbuildable), and rules out interior cameras on power/condensation/−18 °C grounds — which is exactly the compartment the question starts from. **Built**: appliances as Grocy locations, `POST /transfer` with a "Move to…" picker on the edit screen, and the door-sensor→camera→hint path (`pantry-vision/doorway.py`, `POST /doorway-event` — an HA automation on a Zigbee contact sensor, answered 202 with the camera burst running off-thread; what it recognises is stored in its own SQLite file with a timestamp and a confidence and is **never** written to stock). **Not bought, not tested**: no door sensor, no doorway camera, and the assumption the camera half rests on — that a local vision model can identify an item in a moving hand at ~1.5 m — has never been checked (open decision #40). An appliance can be configured with a sensor and no camera, which is still the recommended way to start
- [x] Per-person **colour** and a settable **profile picture** in `identity` — eight colours assigned automatically at registration, avoiding any colour already worn by somebody with the same initial (an Anna and an Amir are two identical "A"s on a wall panel, and the colour is what makes that readable), then least-used overall; editable in the admin panel, backfilled oldest-first for existing people so nobody's colour reshuffles on restart. `color` + `initial` now ride on `/people`, `/presence` and every `/floorplan/presence` occupant, so no consumer derives an initial or invents a palette. New `POST /people/<id>/photo` sets a picture without a walk to the door panel — the registration capture was the only source before, which left a device-less household member unable to have a face at all. **The palette values sit on the 2-bits-per-channel lattice a colour Pebble renders natively**, so the colour on a watch is the colour in the panel, see below
- [x] **Rooms are one vocabulary, and devices declare theirs** — `docs/rooms-and-endpoints.md`: the room id is an HA `area_id` everywhere (`CoreSystemConfig.json` → `config-export.py` → the ISO builder → the agent → `suggested_area` in MQTT discovery), which is the same string `identity`'s floorplan rooms join on. Every kiosk and audio endpoint now carries `room`, `tools/validate-config.py` rejects anything that isn't already an area_id (helpfully slugifying it is how you end up with two rooms) and warns rather than errors when it's missing. **The honest limit: `suggested_area` is only honoured at first discovery** — move a device and you move it in HA once, by hand
- [x] **`workshop/`** — built: project notebook (`workshop.db`), a **never-pruned knowledge store** (`workshop-knowledge.db`: workflow instructions by activity, keyword facts, project learnings, and the household's **one** hardware inventory), `GET /context` to hand an assistant everything that applies at once, a **web inventory editor**, **Gitea repos with append-only history** (commit/push/branch yes; force-push/rebase/reset/filter-repo never, enforced by branch protection server-side, with a print-only scrub-request for the token-in-history case), a **health poller** for CheckMK + every OPNsense firewall, a **cameras** tab over go2rtc, and a **fleet-scripts admin surface**: one monitoring-agent script per kind of machine (Debian x86, arm64 Pi, the Docker host, the GPU host, plus slots for the ESP32s and network appliances that *can't* run a script and whose CheckMK-server-side config goes there instead), fetched by each endpoint's `fleet-bootstrap` timer. It's remote code execution by design, so the constraints are the design: **upload is a draft, publishing is a separate click**, the service never executes anything, scripts live in SQLite rather than on the writable share (a share credential must not be a whole-fleet root-execution credential), every version is kept, and the endpoint verifies the checksum, runs a version once, and **reports back pass or fail** — a script that was served is not a script that succeeded. Off by default (`ENABLE_WORKSHOP`), **nothing run against a real deployment** — the CheckMK API shape and Gitea's branch-protection payload are both written from documentation and have version-sensitive field names
- [x] **Multi-firewall + CheckMK, and a config that says so** — `opnsense` is a **list** now (each with its own name and key pair, keyed by name rather than by index), `checkmk` is a first-class block, and both feed the digest (`ingest/infra_health.py`, which reads the *poller* rather than polling, so the digest can say "critical since Tuesday" instead of quoting a six-hour-old snapshot) **and** the workshop's `infra_status` table. Three states — `ok`/`problem`/**`unreachable`** — because "I could not ask" is not "nothing is wrong". Validator rejects duplicate firewall names and half-configured credentials for both
- [x] **Photo web frontend (Immich)** alongside the SMB share — `ENABLE_PHOTOS_WEB`, search/albums/faces over the **same** directory, mounted **read-only**: two writers to one photo tree with different ideas of the layout is how a collection gets quietly reorganised. The share stays right for bulk copy and for keeping photos openable with no software at all
- [x] **Keycloak + FreeIPA config blocks** — `identity_provider` and `freeipa`, validated and exported, **declaration-only**: nothing implements SSO or the directory mirror yet, and the validator says so out loud rather than letting a filled-in block imply it works. **They are one decision, not two** — Keycloak federates FreeIPA rather than being its own user store, so the validator errors on either configured alone; a Keycloak without the directory behind it is a second parallel set of household accounts, which is the thing a directory exists to prevent. Groups follow **`USR_HA_<parameter>`** (`USR_HA_household`, `USR_HA_chore_exempt`, `USR_HA_admins`), warned-not-errored so an existing directory's convention isn't overridden by a config file. Two rules recorded before the work: the mirror is **one-way** (a directory serving real logins must not become a store of chore-reminder preferences), and a group may **grant** a chore exemption but never **remove** one somebody set by hand
- [ ] Workshop/office assistant — `docs/workshop-assistant.md`: **read the label, don't recognise the object** (a closed T480 and T490 are the same black rectangle; the identity is in the service tag and the PCB silkscreen, so the pipeline is OCR/barcode-first with the VLM only locating the label). Specs must be **quoted from a fetched document with its URL, never generated** — a hallucinated pinout destroys hardware — which also makes this the first component here that deliberately reaches the open internet (outbound-only, allowlisted, cached). Per-room scoping = the room selects the toolset, riding the `room` plumbing above. An SMB **workspace share** for everything it produces (reusing the existing Samba container — a second one would collide on 445, which the gallery already holds — with its own volume, own account, and read-write where the gallery is read-only). Display widgets collapse to **one `svg` window kind plus server-side renderers** (Graphviz for code-flow/data-structure, netlistsvg for schematics, KiCad export for board plans), because a megabyte of JS on a kiosk buys a picture the server could render once — **IEC/EU notation is a symbol-library decision**, so "can I supply my own symbols?" disqualifies a tool before output quality does. Purple/magenta holo theme is one CSS variable override over the existing `glow.css`, with the drawings left untinted on purpose. Analysis only, nothing built
- [x] **Now-playing visualiser + 3D floorplan** — built, in `render/` (shared, vendored, dependency-free, config from `?query=params`). `media-visualiser/`: circular spectrum, album-art palette, LRC lyrics — **two-tier by necessity**, since most endpoints have no local audio (a kitchen panel showing what the living room plays cannot analyse anything), so it is real FFT where audio is local and a tempo-driven ring elsewhere, **which says on screen that it is not an analysis**. Palette rejects near-greys/near-blacks before ranking and lifts each colour until it clears the background — the step whose absence makes art-coloured visualisers invisible on dark covers. Plain lyrics are shown but never auto-scrolled at a guessed rate. `floorplan-3d/`: the same `/floorplan/presence` payload as the Pebble app, extruded — **canvas 2D, not three.js**, a change from the plan made while building it: the scene is prisms on a plane, which an isometric projection with painter's sorting draws in ~200 lines, predictably on weak panels, with the frontend still at zero dependencies. Lit/dark rooms plus the dashed third state for rooms HA never reports on, photos-or-initials in the person's colour ring, and a visible shelf for people who are home but unlocatable. Geometry unit-tested headlessly; **never opened in a real browser**
- [ ] ~~Now-playing visualiser + 3D floorplan (design)~~ — `docs/endpoint-surfaces.md`: a circular CAVA-style ring behind every playing screen, coloured from the album art, lyrics under the cover when they exist; and the Pebble app's presence view in 3D on any endpoint. Two findings shape both: **most endpoints have no local audio** (a kitchen panel showing what the living room plays cannot analyse anything), so the visualiser is explicitly two-tier — real FFT where audio is local, tempo-driven "mood light" elsewhere, and the synthetic tier must never claim to be the real one; and the 3D plan is an **extrusion of the existing 2D polygons**, not a hand-authored model, so it can't go stale when a room is redrawn. Three.js is ~1MB and a deliberate break with the dependency-free SDK rule — make it once, explicitly. Analysis only, nothing built
- [x] **Pebble watchapp built** — `pebble-presence/`: the floorplan and who's in each room, plus Up/Down room cycling into a plain-text list (with "Somewhere in the house" as the last entry in the cycle — the people it can't place are exactly who you picked the watch up to find). Lit/dark/dotted room states, positions when fused, hollow rings for radar targets nobody can name. The last plan is persisted and drawn **with its age** on launch, because the app's JS only lives while it's open so every launch starts with a fetch. **The wire format is round-trip tested** between the JS writer and the C reader against a stub SDK — two implementations of one format in two languages that never share a process, where drift decodes as plausible garbage rather than an error; it already caught one real disagreement. Unverified: the Round 2's platform identifier and its round-screen detection (nothing hardcodes a resolution, so both are one-liners once the SDK names them), and it has never run on a watch
- [ ] ~~Pebble presence watchapp (design)~~ — `docs/pebble-presence-watchface.md`: the floorplan drawn as quantised polygons (~200–250 bytes for a whole floor, against a ~2 KB AppMessage budget), occupants as initial-on-colour rather than photos (a face gets ~20×20px in 64 colours — four skin-toned blobs, and three of them exceed the message budget the entire floor fits in). Targets the **Pebble Round 2** that's actually owned: the plan is inscribed in the circle (`w = D·a/√(a²+1)`, so ~170×170 for a squarish plan at a 240px usable diameter — comparable to a Time 2 after chrome), the projection change is one line of phone-side JS, and the only real loss is corner furniture like a status footer. Occupancy reads as **dark room / lit room** in lightness rather than hue, since colour is already spoken for by *who* — with a third dashed state for rooms HA never reports on, because drawing "no data" as "empty" is a quiet lie. **Button-cycling through rooms forces a watchapp, not a watchface** — Pebble gives watchfaces no button events and restricts touch to apps — and the watchapp is the decision: Up/Down cycles rooms into a plain-text list of who is in each (plus a final "somewhere in the house" entry for people who are home but unlocatable, who are exactly who you picked the watch up to find). The cost is that it's a menu entry rather than your default screen, and its data only lives while it's open; a watchface variant later is a second `main()`, not a second project. Analysis only, nothing built. The finding that actually decides it is upstream: room-level presence has never been measured in this house, and the admin panel's floorplan tab with **Live** ticked tests it today for free
- [ ] "Put the groceries away" chore — `chores`' new `groceries_out_of_place` watch point: `litter`'s twin (same "whoever the camera just saw" attribution, same exemption-proof, same unassignable) with a **30-minute** neglect fuse instead of four hours, because the failure is spoiled food rather than untidiness. Its prompt names what to *ignore* (fruit bowls, bread bins, coffee, cookware) as well as what to look for — the general question gets a YES for any normal kitchen, and a false chore every two hours is how a household learns to mute the channel (open decision #41). Knows food is out, never what the food is; nothing here touches Grocy. Untested against a real camera or model, like every other watch point
- [ ] `identity` + door panel (`identity/`, `hosts/door-panel/`) — the person <-> BLE-identifier registry: "register me as `<name>`" by voice or touchscreen, multi-phone support (multiple identifiers per person), anti-spoofing (only allowlisted IRK-resolved/fixed-tag entities are ever accepted as candidates, never a raw MAC), device-less people (a "no device" flag plus a hand-operated Home/Away toggle — the concrete case: a grandmother without a smartphone), and an anonymous "Guest" path. Backs `hosts/door-panel/`'s weather+clothing/who's-home/groceries-running-low dashboard and `hosts/kitchen-display/`'s "Show registration" screen — built and wired into `setup-container-host.sh` (`ENABLE_IDENTITY`, off by default), **nothing run against a real HA instance, real Private BLE Device entities, or a real voice pipeline** — `TRUSTED_ENTITY_PREFIXES` above all needs checking against Developer Tools -> States, see `identity/README.md` and `hosts/door-panel/README.md`
- [ ] `identity` also corroborates presence from Frigate face recognition (Phase 20, Tapo pan/tilt cameras) — an OR-ed-in second signal only, **never** a registration signal; and owns the per-person chore-system settings (`chore_exempt`, `chore_reminder_style`, plus chore assignment) consumed by `chores/`, see `identity/README.md`
- [ ] `identity`'s admin panel (`identity/frontend/admin.html`, Phase 6b) — managing people/guests: edit every field, **nicknames** (an input alias only — `/resolve` accepts them, but the assistant always speaks the real `speak_name`), **visit history** sampled from `/presence` plus a derived "who was home with whom" view, **"select all that last visited before `<date>`"** pruning (the filter selects, a human confirms the exact list, the filter is never re-run at delete time), **per-device rights** for self-entry via a smart lock (`identity` only ever *answers* `GET /device-access` — HA asks and HA acts, deny is the default), chore assignment, a **floorplan editor** (draw levels and rooms as polygons, map each to an HA area, and watch occupied rooms light up — resolves the long-deferred open decision #22), and **opt-in arrival push notifications** ("tell me when someone gets home", via the self-hosted ntfy this stack already runs — `identity` itself never touches the WAN; ntfy stays LAN-only and remote delivery rides a WireGuard split tunnel — see `docs/network-integration.md` §2.2 for why a DMZ/port-forward was weighed and rejected). Deliberately **not** a kiosk page and not linked from any wall panel. Covered by API-level tests; **never opened in a real browser**, and `DEPARTURE_GRACE_SECONDS` is an untuned guess — see `identity/README.md`
- [ ] `trash-calendar` + `transit` (Phase 19, Kennelbach AT trash pickup + Vorarlberg public transit) — built and wired into `setup-container-host.sh` (`ENABLE_TRASH_CALENDAR`/`ENABLE_TRANSIT`/`ENABLE_TRIP_PLANNING`, all off by default), **nothing run against a live ICS feed, a live GTFS feed, or a real OpenTripPlanner instance** — trip planning also needs a manually-built OTP graph this repo does not build for you, see `trash-calendar/README.md` and `transit/README.md`'s "Route planning scope"
- [ ] `chores` (Phase 20) — presence/calendar-driven household chore nudging: "I don't care who does it, as long as it gets done" — prefers whoever's been assigned a chore in `identity`'s admin panel but falls through to whoever's actually home rather than waiting (`CHORE_ASSIGNMENT_STRICT` flips that), redirects to someone else if a chore goes neglected, keeps a passive fairness tally that never feeds back into who gets nudged, and camera-checks trash bins/dishes/litter/groceries-left-out via Frigate + an Ollama vision model. **Litter and groceries-left-out remain the exception to everything** — both ignore chore-exemption and assignment, because cleaning up what you left out was never a task anyone could be assigned; groceries additionally get a 30-minute fuse instead of four hours, since that failure spoils food rather than merely looking untidy. Built and wired into `setup-container-host.sh` (`ENABLE_CHORES`, off by default, every-2-hours systemd timer), **no Tapo camera hardware chosen and nothing run against real hardware**, see `chores/README.md`
- [ ] Music Assistant (optional, additive multi-room audio) — wired into `setup-container-host.sh` (`ENABLE_MUSIC_ASSISTANT`, off by default), **its default port is an unverified guess that collides with `PANTRY_VISION_PORT`** if both are enabled together, see `docs/project-plan.md` open decision #31
- [ ] `docs/network-integration.md` (OPNsense VLAN segmentation, the WireGuard split tunnel that carries arrival notifications, and why nothing here — ntfy included — gets port-forwarded to the WAN) — written, not run against a real OPNsense instance
- [ ] `tools/` + `CoreSystemConfig.json` — every build and setup script in one place, reading one config. The container host and LLM host build as a **twinned pair**: you set two last octets and the container host's `OLLAMA_HOST` is *derived* from the LLM host's, so the two ISOs cannot be built disagreeing about where the other one is; every kiosk's service URLs derive from the container host's address the same way. `build-all.sh` builds the set, `validate-config.py` refuses a build on duplicate ports (the `music_assistant`/`pantry_vision` 8095 clash, open decision #31), placeholder or padded tokens, duplicate hostnames, or a kiosk pointed at a disabled service. All secrets are burnt into the images so installs are unattended — **which makes every ISO a credential**; the filled-in config and `iso-out/` are gitignored. **No ISO has ever been built with this** (`lb build` needs live-build, root and a long fetch) — what is tested is config validation/derivation and every generated artifact, with `lb` stubbed. See `tools/README.md`

## Quick start

Everything is built from **one** config file — `CoreSystemConfig.json` — so an address
or token can only ever be wrong in one place:

```bash
cp CoreSystemConfig.json.template CoreSystemConfig.json
$EDITOR CoreSystemConfig.json          # subnet, two host octets, tokens, your kiosks
tools/validate-config.py               # catches typos in seconds, not after a 40-min build
sudo -E tools/build-all.sh             # every ISO, all agreeing with each other
```

The container host and LLM host come out as a **twinned pair**: you give each a last
octet, and the container host's `OLLAMA_HOST` is *derived* from the LLM host's — change
one and the other follows on the next build, with nothing to keep in sync by hand. Every
kiosk's service URLs derive from the container host's address the same way. See
[`tools/README.md`](tools/README.md).

Built ISOs contain every secret in the config, by design (nothing to configure
post-install) — which makes each one a credential. `.gitignore` covers both the
filled-in config and `iso-out/`.

To set up a host by hand instead of from an ISO:

```bash
sudo -E tools/setup-container-host.sh
```

Edit the variables at the top of the script first (timezone, Zigbee USB device
path, Mealie/hardware-accel toggles, and whether to enable Node-RED/Netdata/
Homepage/ntfy/Portainer and restic backups — all but backups are on by
default). See `docs/project-plan.md` for the full phased rollout order — don't
skip straight to Phase 4+ automations before the Phase 2 reactive baseline
(presence -> light, no LLM in the loop) is working.

The script brings up everything that runs on this one Debian host: Home
Assistant, Mosquitto, Zigbee2MQTT, Node-RED, Frigate, Grocy, Netdata, a
Homepage dashboard, ntfy, and Portainer, plus an optional scheduled restic
backup timer and an optional quarter-daily digest engine (`ENABLE_DIGEST_ENGINE`,
off by default — needs `digest-engine/` checked out on the host and its `.env`
filled in first, see `digest-engine/README.md`). What it can't do for you,
because they need separate hardware, credentials, or physical setup: pairing
Zigbee sensors, flashing RuView/ESPHome/Bermuda BLE proxy boards, pointing
Frigate at a real camera RTSP URL, the Grocy kiosk touchscreen, the separate
LLM/GPU host, wiring up the Nextcloud CalDAV integration, building/flashing
the thin-client ISO (`hosts/thin-client/`), and provisioning real credentials
for the digest engine's mail/message/news/financial sources — see the Status
checklist above and `docs/project-plan.md` for those.
