From bad217c36982c8796c2d73edce68a3a350297eb8 Mon Sep 17 00:00:00 2001 From: The_miro Date: Thu, 30 Jul 2026 13:40:04 +0200 Subject: [PATCH] Add identity registration system and Phase 18 door panel identity/ (Phase 6, rewritten from the original Node-RED sketch): a person <-> BLE-identifier registry built directly as a Python service. Solves multi-phone (multiple identifiers per person), anti-spoofing (only allowlisted IRK-resolved/fixed-tag entities are ever accepted as registration candidates, never a raw or randomized MAC), device-less people (a grandmother without a smartphone gets a no_device registration plus a hand-operated home/away toggle, reported as "unknown" rather than a false "away"), and anonymous guests (POST /register/guest, no name needed). Every person gets an automatic profile picture from their most recent registration photo. /presence also reports a best-effort room per person as groundwork for an eventual floor-plan view (not built). Registration is single-utterance voice ("register me as ") or a touchscreen form; ambiguous/conflicting candidates never auto-commit. hosts/door-panel/ (Phase 18, new host): structurally kitchen-display's twin - one Sway workspace, one Chromium kiosk window - defaulting to identity's weather+clothing/who's-home/groceries-running-low dashboard, with voice registration as its actual purpose (mic on by default, unlike every other host's opt-in). hosts/kitchen-display/: adds opt-in voice satellite and a "Show registration" screen pointed at identity's register.html, reusing the same camera-equipped-endpoint registration flow. pantry-vision/: adds GET /shopping-list (Grocy's own volatile/missing products, reshaped) for the door panel's "running low" section. setup-container-host.sh: wires ENABLE_IDENTITY (identity + identity-web, published like pantry-vision since kiosk browsers call it directly, plus a persistent SQLite/photos volume unlike pantry-vision's stateless design) and fixes the HA_URL example to the host's real LAN IP (HA runs network_mode: host, unreachable by container name - the same situation Node-RED's own config already documents). docs/: Phase 6 rewritten, Phase 18 added, hardware/software/guardrail/ open-decision entries throughout project-plan.md and README.md. components.md gains RuView presence nodes (one per room), fixed BLE tags, and 2 spare webcams, with the price estimate updated to match. Nothing here has been run against real hardware, a real HA instance, or real Private BLE Device entities - TRUSTED_ENTITY_PREFIXES above all needs checking against a live instance before registration finds anything. See identity/README.md and hosts/door-panel/README.md for the itemized verification lists. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K6CrKjW3yVptUnjG35HjC1 --- README.md | 21 +- docs/components.md | 72 +- docs/project-plan.md | 142 +++- .../scripts/setup-container-host.sh | 98 ++- hosts/door-panel/README.md | 126 +++ .../door-panel/agent/door-panel-agent.service | 20 + .../agent/door_panel_agent/__init__.py | 3 + .../door-panel/agent/door_panel_agent/main.py | 140 ++++ .../agent/door_panel_agent/mqtt_discovery.py | 96 +++ .../agent/door_panel_agent/session.py | 53 ++ hosts/door-panel/agent/requirements.txt | 4 + hosts/door-panel/configs/greetd/config.toml | 9 + hosts/door-panel/configs/greetd/kiosk-session | 17 + hosts/door-panel/configs/sway/config | 54 ++ hosts/door-panel/configs/sway/home-kiosk | 54 ++ hosts/door-panel/configs/sway/identity-kiosk | 50 ++ .../hooks/normal/0100-user-setup.hook.chroot | 42 + .../hooks/normal/0200-greetd.hook.chroot | 19 + .../normal/0300-door-panel-agent.hook.chroot | 12 + .../normal/0400-voice-satellite.hook.chroot | 69 ++ .../package-lists/door-panel.list.chroot | 57 ++ .../door-panel/live-build/config/preseed.cfg | 3 + .../scripts/build-door-panel-iso.sh | 306 +++++++ hosts/kitchen-display/README.md | 53 +- .../agent/kitchen_display_agent/main.py | 13 +- .../kitchen_display_agent/mqtt_discovery.py | 4 + .../agent/kitchen_display_agent/session.py | 20 +- .../configs/sway/identity-kiosk | 57 ++ .../kitchen-display/configs/sway/pantry-kiosk | 9 +- .../normal/0400-voice-satellite.hook.chroot | 71 ++ .../package-lists/kitchen-display.list.chroot | 22 +- .../scripts/build-kitchen-display-iso.sh | 65 +- identity/Dockerfile | 21 + identity/README.md | 237 ++++++ identity/frontend/dashboard.html | 45 + identity/frontend/dashboard.js | 206 +++++ identity/frontend/register.html | 65 ++ identity/frontend/register.js | 202 +++++ identity/frontend/style.css | 196 +++++ identity/identity.env.example | 58 ++ identity/requirements.txt | 1 + identity/server.py | 786 ++++++++++++++++++ pantry-vision/README.md | 9 +- pantry-vision/server.py | 34 + 44 files changed, 3586 insertions(+), 55 deletions(-) create mode 100644 hosts/door-panel/README.md create mode 100644 hosts/door-panel/agent/door-panel-agent.service create mode 100644 hosts/door-panel/agent/door_panel_agent/__init__.py create mode 100644 hosts/door-panel/agent/door_panel_agent/main.py create mode 100644 hosts/door-panel/agent/door_panel_agent/mqtt_discovery.py create mode 100644 hosts/door-panel/agent/door_panel_agent/session.py create mode 100644 hosts/door-panel/agent/requirements.txt create mode 100644 hosts/door-panel/configs/greetd/config.toml create mode 100755 hosts/door-panel/configs/greetd/kiosk-session create mode 100644 hosts/door-panel/configs/sway/config create mode 100755 hosts/door-panel/configs/sway/home-kiosk create mode 100755 hosts/door-panel/configs/sway/identity-kiosk create mode 100755 hosts/door-panel/live-build/config/hooks/normal/0100-user-setup.hook.chroot create mode 100755 hosts/door-panel/live-build/config/hooks/normal/0200-greetd.hook.chroot create mode 100755 hosts/door-panel/live-build/config/hooks/normal/0300-door-panel-agent.hook.chroot create mode 100755 hosts/door-panel/live-build/config/hooks/normal/0400-voice-satellite.hook.chroot create mode 100644 hosts/door-panel/live-build/config/package-lists/door-panel.list.chroot create mode 100644 hosts/door-panel/live-build/config/preseed.cfg create mode 100755 hosts/door-panel/scripts/build-door-panel-iso.sh create mode 100755 hosts/kitchen-display/configs/sway/identity-kiosk create mode 100755 hosts/kitchen-display/live-build/config/hooks/normal/0400-voice-satellite.hook.chroot create mode 100644 identity/Dockerfile create mode 100644 identity/README.md create mode 100644 identity/frontend/dashboard.html create mode 100644 identity/frontend/dashboard.js create mode 100644 identity/frontend/register.html create mode 100644 identity/frontend/register.js create mode 100644 identity/frontend/style.css create mode 100644 identity/identity.env.example create mode 100644 identity/requirements.txt create mode 100755 identity/server.py diff --git a/README.md b/README.md index 3676a03..1c2096b 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ 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 quarter-daily -LLM-generated digest. ++ 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. @@ -29,14 +29,23 @@ hosts/ kitchen-display/ Single-purpose Sway kiosk for the fridge/pantry: one Chromium window showing pantry-vision's Scan/Inventory/ Recipes frontend, camera capture via the browser itself + 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) -identity/ Face<->MAC<->name correlation logic (Node-RED flow - export once stabilized, or a Python 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 (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 @@ -62,7 +71,6 @@ pantry-vision/ Kitchen-display backend: a photo held up to the cam - [ ] Grocy kiosk (Pi + touchscreen) setup - [ ] LLM host (Ollama) setup script - [ ] CalDAV / Nextcloud calendar integration notes -- [ ] Identity correlation flow (Node-RED) - [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 @@ -70,7 +78,8 @@ pantry-vision/ Kitchen-display backend: a photo held up to the cam - [ ] 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` -- [ ] 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 and Grocy's recipes — 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` +- [ ] 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 — 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` +- [ ] `identity` + door panel (`identity/`, `hosts/door-panel/`) — the person <-> BLE-identifier registry: "register me as ``" 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` ## Quick start diff --git a/docs/components.md b/docs/components.md index 33bbb79..5b387dc 100644 --- a/docs/components.md +++ b/docs/components.md @@ -12,17 +12,20 @@ Components 1xMedia Station/TVPC (doesn't technically need to be in the same room) -> 1xThinClient (tiny is sufficient) 1xVoice Reciever - Round screen thingy 1xSound System (already have it, see #Have) +1xRuView Presence Node (ESP32-S3 CSI board) #Loggia 1xMiniscreen System (Lenovo all-in-one PC, large built-in touchscreen — already own it, free, see #Have) 1xSound System 1xVoice Reciever - Round screen thingy +1xRuView Presence Node (ESP32-S3 CSI board) #Linus Room 1xVoice Reciever - Round screen thingy 1xSound System +1xRuView Presence Node (ESP32-S3 CSI board) ??? #Amirs Room @@ -31,6 +34,7 @@ Components 1xThinClient (tiny is sufficient) 1xHA Voice PE 1xSound System +1xRuView Presence Node (ESP32-S3 CSI board) #Kitchen @@ -38,6 +42,7 @@ Components 1xCamera 1xVoice Reciever - Round screen thingy 1xSound System +1xRuView Presence Node (ESP32-S3 CSI board) ##Lighting @@ -62,16 +67,18 @@ ecosystem. See #Need for the specific pick and why. 5x Sound System (all confirmed: Living Room, Loggia, Linus Room, Amirs Room, Kitchen) 1x Tiny PC with Mini Touchscreen (Kitchen) 1x Camera (Kitchen) +2x Spare webcam (destination TBD) +5x RuView Presence Node (ESP32-S3 CSI board — one per room: Living Room, Loggia, Linus Room, Amirs Room, Kitchen) #Lighting ~16x RGB smart bulb (Zigbee) — count/room split not finalized #By room -Living Room: 1x ThinClient, 1x Voice Reciever, 1x Sound System (have) -Loggia: 1x Miniscreen System (have), 1x Sound System, 1x Voice Reciever -Linus Room: 1x Voice Reciever, 1x Sound System, +1 unresolved (???) -Amirs Room: 1x Beamer, 1x ThinClient, 1x HA Voice PE, 1x Sound System -Kitchen: 1x Tiny PC w/ Touchscreen, 1x Camera, 1x Voice Reciever, 1x Sound System +Living Room: 1x ThinClient, 1x Voice Reciever, 1x Sound System (have), 1x RuView node +Loggia: 1x Miniscreen System (have), 1x Sound System, 1x Voice Reciever, 1x RuView node +Linus Room: 1x Voice Reciever, 1x Sound System, 1x RuView node, +1 unresolved (???) +Amirs Room: 1x Beamer, 1x ThinClient, 1x HA Voice PE, 1x Sound System, 1x RuView node +Kitchen: 1x Tiny PC w/ Touchscreen, 1x Camera, 1x Voice Reciever, 1x Sound System, 1x RuView node #Open items - Linus Room's "???" line is still undecided. @@ -124,6 +131,20 @@ is a kit of: 4x board + 4x power adapter (+ cable if not bundled). +#RuView Presence Node (Living Room, Loggia, Linus Room, Amirs Room, Kitchen) — need 5 +This is `firmware/ruview/` from this repo (Phase 2/§1.6) — anonymous room-level CSI +presence, still an unbuilt placeholder in this repo (no firmware written yet), but +the hardware spec is fixed regardless: a plain ESP32-**S3** board, no camera, no +extra peripherals — CSI presence reads WiFi channel state, not a camera. Separate +boards from the Bermuda BLE-proxy ESP32s (§1.5) — one chip runs one firmware. +- [Espressif ESP32-S3-DevKitC-1-N8R2](https://www.amazon.com/Espressif-ESP32-S3-DevKitC-1-N8R2-Development-Board/dp/B09D3S7T3M) + (~€8–12 each per docs/project-plan.md §1.6) — the official board, minimal + 8MB-flash/2MB-PSRAM variant; RuView's own firmware has no published minimum spec + yet (nothing's been written), so this is picked for being the plain, cheap, + official option rather than a specific requirement. + +5x boards, one per room. + #Sound System (all 5 rooms confirmed: Living Room, Loggia, Linus Room, Amirs Room, Kitchen) — need 4 Have 1 already (earmarked for Living Room), need 5 total, all confirmed → need 4 more. Each unit is a kit of: @@ -149,6 +170,40 @@ are not: (see link for price) — any UVC webcam works; picked for wide availability/driver support, not a specific requirement +#Spare webcams — need 2 +Destination not decided yet — going to whichever future camera-equipped clients need +one next (see docs/project-plan.md Phase 6/18: any camera-equipped endpoint can run +the identity registration flow, not just kitchen-display/door-panel). +- [Logitech C270 HD Webcam, 720p](https://www.amazon.com/Logitech-C270-Webcam-Megapixel-Interpolated/dp/B01IFBKK3W) + (see link for price, typically the cheapest widely-stocked UVC webcam) — cheaper + than the C920 above on purpose: these two are general-purpose spares, not tied to + pantry-vision's food-photo use case that specifically wants the C920's autofocus, + so there's no reason to pay for that here. + +#Fixed BLE tags — need ~4 to start +For household members who don't (or shouldn't have to) carry a phone for presence to +work — a grandmother without a smartphone is the concrete case, but this is also just +generally the more reliable anchor `docs/project-plan.md` §1.5 already recommends over +a phone's own randomizing MAC. `identity/`'s registration flow supports registering +someone with **no** device at all (a manual home/away toggle instead), but a physical +tag is the better long-term answer once it's worth the ~€5–8 — see +`identity/README.md`'s "no device" section. +- [The Smart Anti Lost Tracker iTag-Tiny](https://www.amazon.com/iTag-Tiny-Innovative-Bluetooth-Anything-bidirectional/dp/B07FG7XJ9D) + (see link for price, typically €3–8) — a plain Bluetooth 4.0 beacon with a + replaceable coin-cell battery, not locked into Apple Find My/Google Find Hub's + proprietary rotating-identifier scheme (most "key finder" listings on Amazon *are* + locked to one of those and won't work here) — this is the generic style the HA/ + Bermuda community actually uses. **Verify before buying more than one**: cheap + tags in this class often advertise a BLE *static random* address (technically + "random" address-type, but stable for the tag's lifetime, unlike a phone's + *resolvable private* address which deliberately rotates) — community reports say + they "show as Random MAC but are stable," which is what actually matters for + `identity`'s `TRUSTED_ENTITY_PREFIXES` allowlist, not the address-type label + itself. Confirm one tag's address is genuinely stable (watch it in HA's Bluetooth + debug log over a few hours) before buying a batch. +4x to start (one for the concrete grandmother case plus a few spares/guests); buy +more as more device-less household members come up. + #Lighting — need ~16 RGB smart bulbs (count TBD) **Zigbee, not WiFi/cloud bulbs** — the whole point of the Zigbee2MQTT backbone this project already runs (§1.3) is one local mesh instead of N different manufacturer @@ -207,10 +262,13 @@ local pricing/VAT/shipping. | 16AWG speaker wire, 100ft spool *(one-time)* | 1 | €15–22 | €15–22 | | 10.3" portable USB-C touchscreen monitor | 1 | €85–130 | €85–130 | | Logitech HD Pro Webcam C920 | 1 | €55–75 | €55–75 | +| Logitech C270 spare webcam | 2 | €25–35 | €50–70 | | innr RB 285 C Zigbee RGB bulb | ~16 (TBD) | €15–20 | €240–320 | +| iTag-Tiny fixed BLE tag | 4 | €3–8 | €12–32 | +| Espressif ESP32-S3-DevKitC-1-N8R2 (RuView) | 5 | €8–12 | €40–60 | -**Subtotal (excludes the optional beamer mount): ~€1,729–2,020** -**Subtotal, including the optional beamer mount: ~€1,744–2,045** +**Subtotal (excludes the optional beamer mount): ~€1,831–2,182** +**Subtotal, including the optional beamer mount: ~€1,846–2,207** All 5 Sound Systems (Living Room, Loggia, Linus Room, Amirs Room, Kitchen) are confirmed — see `#Open items` above — so all 4 needed amp+speaker kits are in the diff --git a/docs/project-plan.md b/docs/project-plan.md index df704d1..8d11852 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -1,6 +1,6 @@ # AI-Managed Smart Home — Full Build Plan (v2) -Local-first, open-source stack: Home Assistant + RuView presence + Bermuda BLE identity + local LLM (Ollama) + Zigbee sensors/lighting + Frigate (peephole face recognition) + Grocy kitchen kiosk + Nextcloud calendar sync + Node-RED glue logic + a Sway thin-client media station + a Sway touch panel + a camera-vision kitchen/fridge display + a quarter-daily LLM digest. +Local-first, open-source stack: Home Assistant + RuView presence + Bermuda BLE identity + local LLM (Ollama) + Zigbee sensors/lighting + Frigate (peephole face recognition) + Grocy kitchen kiosk + Nextcloud calendar sync + Node-RED glue logic + 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 digest. --- @@ -117,6 +117,18 @@ from Phase 1, same as every other MQTT-connected host in this plan.)* Phase 1 host, calling the existing Phase 3/9 LLM host for vision inference and the already-running `grocy` container for storage.)* +### 1.16 Door/wardrobe panel hardware (Phase 18) +| Item | Est. Price (EUR) | Notes | +|---|---|---| +| Mini PC or SBC with a touchscreen, mounted by the door/wardrobe | €150–300 | Same native Wayland `wl_touch` preference as §1.14/§1.15 — no specific unit chosen | +| USB microphone + speaker (or a combo device) | €20–40 | This device's actual purpose is voice registration ("register me as ``"), unlike every other host's opt-in mic — budget for one from the start rather than treating it as optional | + +*(No camera required — unlike §1.15, identity resolution here is BLE/IRK-based, not +camera-based; a registration photo is a nice-to-have via the same webcam pool as +§1.15's spares if one ends up mounted, not a requirement. No new container-host +hardware — `identity` is a container on the existing Phase 1 host, same as +`pantry-vision`.)* + --- ## 2. Software (all open source / self-hosted) @@ -189,6 +201,11 @@ already-running `grocy` container for storage.)* | Grocery inventory backend | **pantry-vision** (custom Python, stdlib `http.server`) | `POST /identify` (photo → proposal), `POST /confirm` (human-reviewed proposal → Grocy stock write), `GET /inventory`/`GET /recipes` (proxy Grocy, reshaped). Bearer-token gated, **and — unlike admin-canvas — LAN-published**, since the kitchen display's kiosk browser calls it directly rather than through Home Assistant | | Grocery inventory storage | **Grocy** (already running, Phase 1/7) | The single source of truth for stock/best-before-dates/recipes; `pantry-vision` is a client of Grocy's own REST API, not a replacement for it | | Kitchen-display static serving | **pantry-web** (nginx:alpine) | Serves `pantry-vision/frontend/`'s Scan/Inventory/Recipes single-page app read-only to the kitchen display — same role `digest-web`/`admin-web` play for their own hosts | +| Identity registry backend | **identity** (custom Python, stdlib `http.server` + `sqlite3`) | Person <-> BLE-identifier registry: registration (voice or touchscreen), presence resolution, a weather proxy. **Published**, unlike admin-canvas — `hosts/kitchen-display/`'s and `hosts/door-panel/`'s kiosk browsers call it directly, bearer-token gated | +| Identity static serving | **identity-web** (nginx:alpine) | Serves `identity/frontend/`'s `register.html`/`dashboard.html` read-only — same role as `pantry-web`/`digest-web`/`admin-web` | +| Door-panel OS build | **live-build** (custom config, `hosts/door-panel/live-build/`) | Reuses the thin client's build tool/convention, structurally `hosts/kitchen-display/`'s twin — see Phase 18 | +| Door-panel scripted control | **door-panel-agent** (custom) | HA MQTT-discovery entity for **Show home/registration** only — identical security shape to every other host's agent | +| Door-panel voice | **wyoming-satellite** + **openWakeWord** | Same components as the thin client's Phase 11.8 rooms and `hosts/kitchen-display/`'s opt-in mic, but **on by default** here — voice registration is this device's actual purpose | --- @@ -226,10 +243,82 @@ already-running `grocy` container for storage.)* 3. Enroll known faces; automation for known vs. unknown at the door. 4. Set conservative confidence thresholds given the narrow FOV/low light. -### Phase 6 — Identity correlation (face ↔ MAC ↔ name) -1. Build the correlation logic as a **Node-RED flow** first: listen to Frigate face-recognition MQTT events + Bermuda/BLE MQTT topics, tally co-occurrence, apply a confidence threshold. -2. Expose LLM tool calls (`propose_person_link`, `confirm_person`, `rename_person`) so the LLM can name people conversationally and propose merges — **never auto-commit a merge silently**, require confirmation. -3. Once the logic stabilizes, consider porting to a small Python service if it outgrows Node-RED's comfort zone (git/test-ability). +### Phase 6 — Identity registration (multi-device, anti-spoofing, voice) + +New top-level `identity/` directory (container-host service + frontend). Backs +`hosts/door-panel/`'s and `hosts/kitchen-display/`'s registration and presence +surfaces (Phases 17/18). + +**Built directly as a Python service, not a Node-RED flow first** — a deliberate +deviation from this phase's original sketch (passive Frigate-face + Bermuda-BLE +co-occurrence tallying in Node-RED). Once "usable via voice, defends against MAC +spoofing, and handles multiple phones per person" became the actual bar, the shape +that falls out of it — real request/response semantics for a multi-step registration +attempt, a relational multi-identifier-per-person data model, unit-testable +candidate-resolution logic — fits a small Python HTTP service (the same +`admin-canvas`/`pantry-vision` shape) far better than a visual flow tool. Passive +co-occurrence tallying itself was **dropped, not deferred**: registration is now +always an explicit, human-initiated act (a spoken command or a touchscreen tap), not +something inferred from ambient signal over time — a stronger, simpler invariant than +the original confidence-threshold design, and the reason `propose_person_link`/ +`confirm_person`/`rename_person` from the original sketch don't appear in the +shipped API at all. + +1. **The model**: a person has zero or more identifiers, each a Home Assistant + `entity_id`. Multi-phone support isn't a special case — register twice under the + same spoken name (private phone, then work phone) and the second identifier just + joins the same person record. See `identity/README.md`'s "The model". +2. **Anti-spoofing is an allowlist, not a filter**: a raw Bluetooth MAC — especially + a randomized one, the iOS/Android default — is never accepted as a candidate + identifier at all. Only `entity_id`s matching `TRUSTED_ENTITY_PREFIXES` are + eligible, meant to contain exclusively HA's Private BLE Device (IRK-resolved) + entities and manually provisioned fixed-MAC BLE tag entities (§1.5). This is a + defense against passive/opportunistic spoofing, explicitly **not** a claim of + cryptographic non-repudiation — see `identity/README.md`'s full threat-model + section for the honest boundary (a compromised phone/IRK is out of scope). +3. **Voice is single-utterance, not multi-turn**: "register me as ``," one + sentence, no follow-up question. HA Assist's multi-turn/continue-conversation + support is newer and more version-sensitive than a single custom-sentence intent + with a captured slot, and this is a materially more robust thing to build + against. **Nothing under this repo builds the HA-side custom-sentence/intent- + script/`rest_command` wiring** — same convention as every other HA integration + point in this project (digest-engine, admin-canvas) — `identity/README.md` has a + worked, unverified-against-a-real-instance example. +4. **Never auto-commit on ambiguity**: zero candidates, more than one, or an + already-claimed one — nothing gets written, and a human disambiguates on the + touchscreen. The one case that *does* commit within a single call is the clean + one (exactly one trusted, unclaimed candidate), because the spoken command itself + is the human confirmation — this mirrors, not weakens, the original phase's + "never auto-commit a merge silently" rule. +5. **People with no device at all are a first-class case, not an edge case** — a + household member without a smartphone (the concrete example that drove this: a + grandmother). `POST /register` with `no_device: true` creates a person with zero + identifiers; `POST /presence/manual` gives them a hand-operated Home/Away toggle + on the door panel's dashboard, since there's nothing to resolve automatically. + Reporting them as "away" by default (rather than "unknown") would be actively + wrong the moment they're actually home, not just uninformative — see + `identity/server.py`'s `presence()` docstring. +6. **A separate "doesn't need to know who it is" path**: `POST /register/guest`, + no name, no device, always a new "Guest N" record (never deduped the way named + people are). `DELETE /people/` cleans up a stale one afterwards. +7. **Every person gets a profile picture, automatically** — whichever registration + photo was captured most recently (`identity/README.md`'s "Every person gets a + profile picture" section), fetched via a bearer-token-gated endpoint + blob URL, + not a bare ``. The photo is **never run through face-matching** — it's + an audit/reference artifact only; BLE/IRK resolution is what actually decides who + registered. Camera-based identity, if ever wanted, is a Frigate face-recognition + integration (Phase 5), not a new pipeline here. +8. **Floor-plan groundwork, not the floor plan**: `/presence` reports a best-effort + `room` per person, read from whichever area/room attribute your BLE presence + integration (Bermuda) attaches to a trusted entity's state + (`AREA_ATTRIBUTE`, unverified default). The actual floor-plan UI — an image, a + room↔coordinate mapping, any rendering — is deliberately **not** built: there's no + floor plan or fixed room list to design a format against yet, and building one + now would be guessing, not engineering. This is groundwork specifically so that + future UI doesn't require `identity`'s data model to change again. +9. Nothing here has been run against a real HA instance, real Private BLE Device + entities, or a real voice pipeline — see the itemized list in + `identity/README.md`, `TRUSTED_ENTITY_PREFIXES`' defaults above all. ### Phase 7 — Kitchen inventory kiosk 1. Deploy Grocy via Compose. @@ -464,6 +553,39 @@ service) plus `hosts/kitchen-display/` (a third, simpler kiosk image). or a real Grocy instance. See the itemized lists in `pantry-vision/README.md` and `hosts/kitchen-display/README.md`. +### Phase 18 — Door/wardrobe panel (identity + ambient dashboard) + +New hardware: §1.16. New host `hosts/door-panel/` — no new backend directory; it's +entirely a client of Phase 6's `identity` (and, for one dashboard section, Phase +17's `pantry-vision`). + +1. **Structurally `hosts/kitchen-display/`'s twin, not a new device shape**: one + Sway workspace, one Chromium kiosk window, a thin agent that only ever switches + which page is showing (`Show home` / `Show registration`, mirroring "Show scan/ + inventory/recipes"). The differences are what it shows by default + (`identity`'s `dashboard.html` instead of `pantry-vision`'s scan/inventory/ + recipes) and that its microphone is the device's actual purpose, not an + edge-case opt-in — `ENABLE_VOICE_SATELLITE` defaults to `true` in this host's own + build script, the only host in this project where that's the case. +2. **The dashboard**: weather + a deterministic clothing suggestion (a plain + temperature/condition lookup table, `identity/frontend/dashboard.js`'s + `clothingSuggestion()` — not an LLM call; this is a solved-enough problem that a + round trip to Ollama would only add latency and a failure mode for no real gain), + who's home (`identity`'s `/presence`, three-state: home / away / unknown, plus a + manual toggle for anyone with no device), and groceries running low + (`pantry-vision`'s `/shopping-list`, a thin reshape of Grocy's own + `/api/stock/volatile` `missing_products` — distinct from Phase 17's "soonest to + expire" sort, this is "below minimum stock" instead). +3. **No camera is required on this device**, unlike `hosts/kitchen-display/` — + identity resolution here is BLE/IRK-based, not camera-based; the registration + photo `identity` captures is a nice-to-have audit artifact/profile picture, never + load-bearing for who gets registered. +4. Nothing built or run against real hardware — no door-panel unit has been chosen, + and the full voice-registration chain (wyoming-satellite → HA Assist → the + custom intent script → `identity`'s `/register`) has the most untested moving + pieces of any single interaction in this project. See the itemized list in + `hosts/door-panel/README.md`. + ### Testing checklist before calling any phase "done" - Does the reactive path (presence → light on) work with the LLM host powered off? (It must.) - Does a bad/slow LLM response ever block a light switch? (It must not.) @@ -502,10 +624,15 @@ service) plus `hosts/kitchen-display/` (a third, simpler kiosk image). - Does `pantry-vision` ever accept a request without a valid bearer token, on any of its four endpoints — including the two GETs? (It must not — unlike admin-canvas, this service is LAN-published, so the token is the actual boundary, not network placement.) - Can the LLM reach the kitchen display through any path other than HA service call → MQTT → `kitchen-display-agent`, for *which screen is showing*? (It must not — reading/writing the actual inventory is a separate, intentionally-published path through `pantry-vision` itself, not a violation of this rule.) - If the vision model's response is unparseable or the call fails outright, does `/identify` ever return a broken/blank result instead of a flagged, low-confidence placeholder proposal? (It must not.) +- Does `identity` ever accept a raw/unresolved Bluetooth MAC (as opposed to an entity_id matching `TRUSTED_ENTITY_PREFIXES`) as a registration candidate? (It must not — that allowlist is the entire anti-spoofing boundary.) +- Does `POST /register` ever commit a registration when zero, more than one, or an already-claimed-by-someone-else candidate was found? (It must not — only the single-unambiguous-candidate case commits within one call; every other case requires a human to disambiguate.) +- Does `/presence` ever report a device-less person (no identifiers, no manual override set) as `home: false`? (It must report `null`/unknown — defaulting to "away" would be actively wrong the moment they're actually home, not just imprecise.) +- Can the LLM reach the door panel through any path other than HA service call → MQTT → `door-panel-agent`, for *which screen is showing*? (It must not — registration and presence/weather/groceries reads are separate, intentionally-published paths through `identity`/`pantry-vision` themselves, not a violation of this rule.) +- Does the door panel's voice registration path ever bypass HA's Assist pipeline (i.e. the kiosk device talking to `identity` on its own initiative from a wake word, with no HA intent script in between)? (It must not — voice is HA Assist → a custom intent script → `identity`'s API, same "HA mediates" shape as every other voice/tool-call path in this project.) --- -## 4. Open decisions (Phases 11–17) +## 4. Open decisions (Phases 6, 11–18) These need a decision before their respective implementation steps can be built — everything above is written to accommodate any answer, but nothing should be built against an unresolved item. @@ -528,3 +655,6 @@ These need a decision before their respective implementation steps can be built 17. **The touch panel's Spotify/Chromium integration details are assumed, not confirmed** (new, Phase 16) — the Flathub app ID `com.spotify.Client`, the Spotify Flatpak's real MPRIS bus name (assumed `spotify`), and Chromium's Wayland `app_id` for a `--app=` kiosk window (assumed to start with `chromium`) are all flagged for on-hardware verification in `hosts/touch-panel/README.md`. 18. **No vision-capable Ollama model has been picked, pulled, or benchmarked** (new, Phase 17) — `pantry-vision`'s `OLLAMA_VISION_MODEL` defaults to `llava` with no confirmation it's the right choice for any given LLM host's hardware tier, and grocery-item identification latency/accuracy is completely unmeasured. This is the single highest-risk unknown in Phase 17: if it's too slow or too inaccurate, "hold item up to camera" stops being a usable interaction and the phase needs a different approach (a smaller/faster model, a barcode-scan fallback via Grocy's existing scanning support, or both). 19. **`pantry-vision`'s Grocy API integration is written from documentation, not a live instance** (new, Phase 17) — `GET /api/stock`'s response shape, `POST /api/objects/products`'s minimum required fields, and the Recipes/fulfillment endpoints' setup requirements are all assumed; `pantry-vision/README.md` points at each real Grocy instance's own live OpenAPI spec (`/api/openapi/specification`) as the way to check before trusting any of it. `GROCY_DEFAULT_LOCATION_ID`/`GROCY_DEFAULT_QU_ID` are fresh-install-default guesses that need confirming against Settings → Locations/Quantity units on the real instance too. +20. **`identity`'s `TRUSTED_ENTITY_PREFIXES` default is a guess, and it's the single highest-risk unknown in Phase 6** (new) — the whole anti-spoofing design rests on this allowlist actually matching real Private BLE Device / fixed-tag entity IDs; until it's checked against Developer Tools -> States on a real HA instance, registration will most likely just report "no candidate" for everything. Same open dependency as §1.5's original Bermuda/Private BLE Device setup, which itself has never been built (see the top-level README status checklist). +21. **Identity's HA-side voice wiring (custom sentence + intent script + `rest_command`) is written from HA's documented shape, not tested** (new, Phase 6) — `identity/README.md` has the worked example; nothing under this repo builds or verifies it, same convention as admin-canvas's/digest-engine's own HA-side integration points. +22. **The floor-plan UI itself doesn't exist** (new, Phase 6) — `identity`'s `/presence` reports a best-effort `room` per person as groundwork, but there is no floor-plan image, room↔coordinate mapping, or rendering anywhere in this repo, and `AREA_ATTRIBUTE`'s default is an unconfirmed guess at what Bermuda actually publishes. Needs a real floor plan and room list before there's anything to design a coordinate format against — deliberately deferred rather than built against a guess. diff --git a/hosts/container-host/scripts/setup-container-host.sh b/hosts/container-host/scripts/setup-container-host.sh index a33a8c1..7ffcaee 100755 --- a/hosts/container-host/scripts/setup-container-host.sh +++ b/hosts/container-host/scripts/setup-container-host.sh @@ -32,6 +32,11 @@ # backend, optional, off by default — needs pantry-vision/ from this repo # checked out on this host, see PANTRY_VISION_SRC below and # pantry-vision/README.md) +# - identity + identity-web (Phase 6 person <-> BLE-identifier registry — +# multi-phone support, anti-spoofing registration, presence/weather for +# hosts/door-panel's and hosts/kitchen-display's dashboards — optional, off by +# default, needs identity/ from this repo checked out on this host, see +# IDENTITY_SRC below and identity/README.md) # # Run as: sudo ./setup-container-host.sh # @@ -111,6 +116,17 @@ PANTRY_VISION_SRC="/opt/smart-home/src/pantry-vision" PANTRY_VISION_PORT="8095" # LAN-facing — the kitchen display's kiosk browser calls this directly PANTRY_WEB_PORT="8096" # LAN-facing read-only static serving (the kiosk's frontend) +# --- Person <-> BLE-identifier registry (Phase 6) — off by default until +# --- IDENTITY_TOKEN, HA_TOKEN, and TRUSTED_ENTITY_PREFIXES are provisioned. See +# --- identity/README.md. Same "published, unlike admin-canvas" reasoning as +# --- ENABLE_PANTRY_VISION above — hosts/kitchen-display's and hosts/door-panel's +# --- kiosk browsers call this directly. +ENABLE_IDENTITY="false" +# Where this repo's identity/ directory lives on THIS host (build context). +IDENTITY_SRC="/opt/smart-home/src/identity" +IDENTITY_PORT="8097" # LAN-facing — kiosk browsers call this directly +IDENTITY_WEB_PORT="8098" # LAN-facing read-only static serving (register.html/dashboard.html) + # --------------------------------------------------------------------------- # Sanity checks # --------------------------------------------------------------------------- @@ -153,6 +169,12 @@ if [[ "$ENABLE_PANTRY_VISION" == "true" && ! -d "$PANTRY_VISION_SRC" ]]; then echo " build context for the pantry-vision image — then re-run." fi +if [[ "$ENABLE_IDENTITY" == "true" && ! -d "$IDENTITY_SRC" ]]; then + echo "Warning: ENABLE_IDENTITY=true but $IDENTITY_SRC does not exist." + echo " Copy or clone this repo's identity/ directory there — it is the build" + echo " context for the identity image — then re-run." +fi + if [[ "$ENABLE_WHATSAPP_INGEST" == "true" ]]; then echo "WARNING: WhatsApp ingestion is enabled." echo " There is no officially sanctioned way to read WhatsApp programmatically." @@ -286,6 +308,18 @@ if [[ "$ENABLE_PANTRY_VISION" == "true" ]]; then echo " PANTRY_VISION_TOKEN and GROCY_API_KEY (Grocy's own UI: Settings -> Manage API keys)." fi fi +if [[ "$ENABLE_IDENTITY" == "true" ]]; then + # Unlike pantry-vision (stateless, all state lives in Grocy), identity owns its + # own SQLite DB and registration photos — both need a persistent bind mount. + mkdir -p "$BASE_DIR"/identity/data/photos + if [[ ! -f "$BASE_DIR/identity/identity.env" ]]; then + cp "$IDENTITY_SRC/identity.env.example" "$BASE_DIR/identity/identity.env" + chmod 600 "$BASE_DIR/identity/identity.env" + echo " Seeded $BASE_DIR/identity/identity.env from the template — fill in a real" + echo " IDENTITY_TOKEN, HA_TOKEN (HA's own UI: profile -> Security -> Long-Lived" + echo " Access Tokens), and TRUSTED_ENTITY_PREFIXES (Developer Tools -> States)." + fi +fi # --------------------------------------------------------------------------- # 4. Mosquitto config @@ -699,6 +733,52 @@ if [[ "$ENABLE_PANTRY_VISION" == "true" ]]; then " fi +# identity (Phase 6) — the person <-> BLE-identifier registry. Same published-port +# reasoning as pantry-vision above (hosts/kitchen-display's and hosts/door-panel's +# kiosk browsers call it directly), plus its own SQLite DB + registration-photo +# volume, since unlike pantry-vision it owns its own state instead of deferring to +# Grocy. `depends_on: homeassistant` is a startup-order hint only — it does NOT make +# Home Assistant reachable by container name, since homeassistant runs with +# `network_mode: host` and is off this compose network entirely (same situation as +# Node-RED's own HA access below); identity.env's HA_URL has to be the host's real +# LAN IP, not "homeassistant". +IDENTITY_BLOCK="" +IDENTITY_WEB_BLOCK="" +if [[ "$ENABLE_IDENTITY" == "true" ]]; then + IDENTITY_BLOCK=" + identity: + build: ${IDENTITY_SRC} + image: smart-home/identity:local + container_name: identity + restart: unless-stopped + depends_on: + - homeassistant + - mosquitto + ports: + - \"${IDENTITY_PORT}:${IDENTITY_PORT}\" + env_file: + - ${BASE_DIR}/identity/identity.env + volumes: + - ${BASE_DIR}/identity/data:/data + environment: + - IDENTITY_PORT=${IDENTITY_PORT} + - TZ=${TIMEZONE} +" + + IDENTITY_WEB_BLOCK=" + identity-web: + image: nginx:alpine + container_name: identity-web + restart: unless-stopped + ports: + - \"${IDENTITY_WEB_PORT}:80\" + volumes: + - ${IDENTITY_SRC}/frontend:/usr/share/nginx/html:ro + environment: + - TZ=${TIMEZONE} +" +fi + if [[ "$ENABLE_DIGEST_ENGINE" == "true" && "$ENABLE_WHATSAPP_INGEST" == "true" ]]; then # Long-lived, unlike digest-engine: holds the logged-in WhatsApp Web session # open and appends to /data/messages.jsonl, which digest-engine drains each @@ -795,7 +875,7 @@ ${FRIGATE_DEVICES} - PUID=1000 - PGID=1000 - TZ=${TIMEZONE} -${MEALIE_BLOCK}${NODERED_BLOCK}${NETDATA_BLOCK}${HOMEPAGE_BLOCK}${NTFY_BLOCK}${PORTAINER_BLOCK}${GALLERY_SMB_BLOCK}${DIGEST_ENGINE_BLOCK}${DIGEST_WEB_BLOCK}${WHATSAPP_BRIDGE_BLOCK}${ADMIN_CANVAS_BLOCK}${ADMIN_WEB_BLOCK}${PANTRY_VISION_BLOCK}${PANTRY_WEB_BLOCK} +${MEALIE_BLOCK}${NODERED_BLOCK}${NETDATA_BLOCK}${HOMEPAGE_BLOCK}${NTFY_BLOCK}${PORTAINER_BLOCK}${GALLERY_SMB_BLOCK}${DIGEST_ENGINE_BLOCK}${DIGEST_WEB_BLOCK}${WHATSAPP_BRIDGE_BLOCK}${ADMIN_CANVAS_BLOCK}${ADMIN_WEB_BLOCK}${PANTRY_VISION_BLOCK}${PANTRY_WEB_BLOCK}${IDENTITY_BLOCK}${IDENTITY_WEB_BLOCK} EOF # --------------------------------------------------------------------------- @@ -958,6 +1038,11 @@ if [[ "$ENABLE_PANTRY_VISION" == "true" ]]; then echo " Pantry API : http://${HOST_IP}:${PANTRY_VISION_PORT} (bearer-token gated)" echo " Pantry display : http://${HOST_IP}:${PANTRY_WEB_PORT}/index.html?api=http://${HOST_IP}:${PANTRY_VISION_PORT}&token=" fi +if [[ "$ENABLE_IDENTITY" == "true" ]]; then + echo " Identity API : http://${HOST_IP}:${IDENTITY_PORT} (bearer-token gated)" + echo " Register page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/register.html?api=http://${HOST_IP}:${IDENTITY_PORT}&token=&device=" + echo " Dashboard page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/dashboard.html?identity_api=http://${HOST_IP}:${IDENTITY_PORT}&identity_token=" +fi if [[ "$ENABLE_GALLERY_SMB" == "true" ]]; then echo " Gallery SMB : \\\\${HOST_IP}\\gallery (user: ${GALLERY_SMB_USERNAME})" fi @@ -1014,6 +1099,17 @@ if [[ "$ENABLE_PANTRY_VISION" == "true" ]]; then echo " (e.g. 'ollama pull llava') — OLLAMA_VISION_MODEL defaults to one that is NOT" echo " confirmed pulled or even correct for your setup. See pantry-vision/README.md." fi +if [[ "$ENABLE_IDENTITY" == "true" ]]; then + echo " 16. Fill in $BASE_DIR/identity/identity.env before registration/presence can work:" + echo " IDENTITY_TOKEN (also goes into hosts/kitchen-display's and" + echo " hosts/door-panel's build scripts — every side needs the SAME value), HA_URL" + echo " (this host's real LAN IP, NOT 'homeassistant' — see the .env template's own" + echo " comment for why), HA_TOKEN (HA's own UI: profile -> Security -> Long-Lived" + echo " Access Tokens), and TRUSTED_ENTITY_PREFIXES (Developer Tools -> States, after" + echo " Bermuda's Private BLE Device integration and/or fixed BLE tags are set up —" + echo " see docs/project-plan.md §1.5). See identity/README.md for the worked HA" + echo " custom-sentence/intent-script example that makes 'register me as ' work." +fi echo echo "Updating later: cd $BASE_DIR && docker compose pull && docker compose up -d" echo "Backing up manually: sudo $BASE_DIR/backup.sh (requires ENABLE_BACKUPS=true was run once)" diff --git a/hosts/door-panel/README.md b/hosts/door-panel/README.md new file mode 100644 index 0000000..77f3564 --- /dev/null +++ b/hosts/door-panel/README.md @@ -0,0 +1,126 @@ +# Sway door panel + +Phase 18 of `docs/project-plan.md`. Builds a Debian 12 live ISO for a wall panel by +the door/wardrobe: weather and what to wear on the way out, who's home, groceries +running low, and — the household's actual registration point — "register me as +``" by voice. + +**Structurally this is `../kitchen-display/`'s twin**, not a new shape: one Sway +workspace, one Chromium kiosk window, a thin agent that only ever switches which +page is showing. The differences are what it shows by default (`identity`'s +`dashboard.html` instead of `pantry-vision`'s scan/inventory/recipes) and that its +microphone is expected to actually be used, not an edge-case opt-in. + +**The actual logic does not live in this directory** — `../../identity/` owns +person/presence data and the registration flow; `../../pantry-vision/` owns the +"running low" data. This host is deliberately thin: Sway, one kiosk window, and +`door-panel-agent`, which only controls *which screen is showing* — the same split +every other kiosk host in this project already uses. + +| | | +|---|---| +| Compositor | Sway, one workspace, one Chromium kiosk window | +| Autologin | greetd, straight into `/usr/local/bin/kiosk-session` | +| Touch input | Native Wayland `wl_touch`, same assumption as `../touch-panel/`/`../kitchen-display/` | +| Default screen | `identity`'s `dashboard.html` — weather + clothing suggestion, who's home, groceries running low | +| Registration | `identity`'s `register.html` — voice ("register me as ``") or the touchscreen form | +| Voice | wyoming-satellite + openWakeWord — opt-in like every other host's mic, but **this device's actual purpose**, so the build script defaults it to on | +| Remote control (HA/LLM) | `door-panel-agent` — **Show home / Show registration** buttons only | +| Remote control (human) | SSH only — no wayvnc, same scope decision as `../touch-panel/` | + +## Hardware + +**No specific unit has been chosen.** What's assumed: a touchscreen (native +`wl_touch`, same caveat as every other touch host in this project) mounted by the +door or wardrobe, with a microphone and speaker for voice registration/Assist +playback. Unlike `../kitchen-display/`, **no camera is strictly required** — the +photo captured during registration (`identity`'s profile-picture feature) is a nice- +to-have audit artifact, not load-bearing, since identity resolution here is BLE/IRK- +based, not camera-based. See `docs/project-plan.md` §1.16 for the (unverified, +non-specific) hardware line item. + +## Before you build + +Deploy `identity`/`identity-web` first (`ENABLE_IDENTITY` in +`hosts/container-host/scripts/setup-container-host.sh`) — this image builds and boots +fine without it, but the dashboard will show connection errors until it exists. +`pantry-vision` is optional (only "Running low" needs it). Then edit the +`# CONFIGURATION` block at the top of +[`scripts/build-door-panel-iso.sh`](scripts/build-door-panel-iso.sh): + +| Variable | What to put in it | +|---|---| +| `MQTT_BROKER_HOST` | LAN IP of the container host running Mosquitto | +| `IDENTITY_WEB_URL` / `IDENTITY_URL` / `IDENTITY_TOKEN` | `identity`'s static-serving and API URLs; token must match `identity/identity.env`'s own | +| `PANTRY_VISION_URL` / `PANTRY_VISION_TOKEN` | Optional — only "Running low" needs these; must match `pantry-vision/pantry-vision.env`'s own token | +| `ENABLE_VOICE_SATELLITE` | Defaults to `true` here (unlike every other host's identical flag) — this device's whole point is voice registration, but it still needs a real mic on the specific unit before flashing it | +| `KIOSK_USERNAME` / `IMAGE_HOSTNAME` / `DOOR_PANEL_NAME` | Per-device identity | +| `SSH_AUTHORIZED_KEY` | Optional — password auth is disabled and there is no wayvnc | + +## Build + +```sh +sudo ./scripts/build-door-panel-iso.sh +``` + +Same directory-split convention as every other host: `configs/` and `agent/` are +human-edited and git-tracked; `live-build/config/includes.chroot/` is generated, +gitignored, never hand-edited. + +## Why one workspace, two kiosk destinations, not three + +`../touch-panel/` juggles three real apps (Spotify, Home Assistant, a browser) and +needs a persistent touch dock to switch between them. This device only ever shows +one of two pages — the everyday dashboard, or the registration form — and switches +between them rarely (on an MQTT command or a voice trigger), so a dock would be +overhead with nothing to navigate day-to-day. `configs/sway/home-kiosk` and +`configs/sway/identity-kiosk` are separate scripts on separate Chromium profiles, +same shape as `../kitchen-display/`'s `pantry-kiosk`/`identity-kiosk` pair — see that +host's README for why both scripts kill *any* Chromium instance before launching +(this device also has only one workspace). + +## Home Assistant entities + +`door-panel-agent` publishes two buttons on connect: **Show home**, **Show +registration** — each kills and relaunches the kiosk Chromium window at the +corresponding page. That's the entire MQTT surface. Reading presence/weather/ +groceries, or registering a person, is a **separate** path — Home Assistant/the LLM +calling `identity`'s or `pantry-vision`'s own published APIs directly, not through +this agent. Voice registration in particular never touches this agent at all — see +`../../identity/README.md`'s worked HA intent-script example. + +### Security boundary + +Same shape and reasoning as every other host's agent in this project: +`door_panel_agent/mqtt_discovery.py` is the entire inbound MQTT control surface of +this machine — LLM tool call → HA service call → MQTT → this agent, no HTTP +listener, no websocket, no exposed Sway IPC socket, no VNC. `identity` and +`pantry-vision` are **separate, deliberately published** services with their own +bearer-token boundaries — see their own READMEs' "A real network listener, unlike +admin-canvas" sections for why that trust model is different on purpose. + +## Manual verification still outstanding + +None of this has been run on hardware. In rough order — on top of everything already +flagged as unverified in `../../identity/README.md` (`TRUSTED_ENTITY_PREFIXES` +above all): + +1. The ISO builds at all. +2. greetd lands in Sway with no login prompt. +3. Touch input as native `wl_touch` — same open item as every other touch host. +4. `door-panel-agent` connects to Mosquitto and the device appears in HA. +5. Register via the touchscreen form (`Show registration`) first, before trying + voice — this exercises the whole identity path without needing HA's intent-script + wiring set up. See `identity/README.md`'s worked example for that next step. +6. Voice registration end-to-end: wake word → "register me as ``" → a spoken + confirmation. This is the number of moving pieces (wyoming-satellite, HA's Assist + pipeline, the custom sentence/intent script, `identity`'s `/register`) that has + the most to go wrong and the least individual testing so far. +7. The dashboard's clothing suggestion (`identity/frontend/dashboard.js`'s + `clothingSuggestion()`) is a plain lookup table, untuned against real weather + payloads or real household clothing preferences — reasonable starting thresholds, + not measured. +8. Switching between `home-kiosk` and `identity-kiosk` actually replaces the window + rather than leaving two Chromium instances up — same untested assumption as + `../kitchen-display/`'s identical pair. +9. Idle-blank timeout (20 minutes, `configs/sway/config`) and touch-resume — untested. diff --git a/hosts/door-panel/agent/door-panel-agent.service b/hosts/door-panel/agent/door-panel-agent.service new file mode 100644 index 0000000..5f9a4db --- /dev/null +++ b/hosts/door-panel/agent/door-panel-agent.service @@ -0,0 +1,20 @@ +[Unit] +Description=Door panel agent (Home Assistant MQTT control surface) +Documentation=file:///opt/door-panel-agent +After=network.target + +[Service] +Type=simple +User=@KIOSK_USERNAME@ +Group=@KIOSK_USERNAME@ +WorkingDirectory=/opt/door-panel-agent +Environment=PYTHONPATH=/opt/door-panel-agent +Environment=PYTHONUNBUFFERED=1 +EnvironmentFile=-/etc/door-panel-agent/config.env +ExecStart=/usr/bin/python3 -m door_panel_agent.main +Restart=always +RestartSec=5 +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target diff --git a/hosts/door-panel/agent/door_panel_agent/__init__.py b/hosts/door-panel/agent/door_panel_agent/__init__.py new file mode 100644 index 0000000..3e2fff6 --- /dev/null +++ b/hosts/door-panel/agent/door_panel_agent/__init__.py @@ -0,0 +1,3 @@ +"""door-panel-agent — Home Assistant MQTT control surface for the door/wardrobe panel.""" + +__version__ = "0.1.0" diff --git a/hosts/door-panel/agent/door_panel_agent/main.py b/hosts/door-panel/agent/door_panel_agent/main.py new file mode 100644 index 0000000..b8a5df0 --- /dev/null +++ b/hosts/door-panel/agent/door_panel_agent/main.py @@ -0,0 +1,140 @@ +"""door-panel-agent entrypoint.""" + +from __future__ import annotations + +import logging +import os +import signal +import socket +import sys +import threading + +import paho.mqtt.client as mqtt + +from .mqtt_discovery import Discovery +from .session import launch_home_kiosk, launch_identity_kiosk + +CONFIG_PATH = os.environ.get("DOOR_PANEL_AGENT_CONFIG", "/etc/door-panel-agent/config.env") + +CONFIG_KEYS = ( + "MQTT_BROKER_HOST", + "MQTT_BROKER_PORT", + "MQTT_USERNAME", + "MQTT_PASSWORD", + "IDENTITY_WEB_URL", + "IDENTITY_URL", + "IDENTITY_TOKEN", + "PANTRY_VISION_URL", + "PANTRY_VISION_TOKEN", + "KIOSK_USERNAME", + "DOOR_PANEL_NAME", +) + +log = logging.getLogger("door-panel-agent") + + +def load_config(path: str = CONFIG_PATH) -> dict[str, str]: + values: dict[str, str] = {} + try: + with open(path, encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + values[key.strip()] = value.strip().strip('"').strip("'") + except OSError as exc: + log.warning("could not read %s (%s); falling back to the environment", path, exc) + + for key in CONFIG_KEYS: + if key in os.environ: + values[key] = os.environ[key] + + return values + + +def make_client(client_id: str) -> mqtt.Client: + callback_api = getattr(mqtt, "CallbackAPIVersion", None) + if callback_api is not None: + return mqtt.Client(callback_api.VERSION1, client_id=client_id) + return mqtt.Client(client_id=client_id) + + +def main() -> int: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + stream=sys.stdout, + ) + + config = load_config() + hostname = socket.gethostname() + node_id = "".join(c if c.isalnum() else "_" for c in hostname).strip("_") or "doorpanel" + friendly_name = config.get("DOOR_PANEL_NAME") or f"Door panel ({hostname})" + + broker_host = config.get("MQTT_BROKER_HOST", "") + broker_port = int(config.get("MQTT_BROKER_PORT") or 1883) + + client = make_client(f"door-panel-agent-{node_id}") + if config.get("MQTT_USERNAME"): + client.username_pw_set(config["MQTT_USERNAME"], config.get("MQTT_PASSWORD") or None) + + discovery = Discovery(client, node_id, friendly_name) + + def on_show(screen: str) -> None: + if screen == "register": + launch_identity_kiosk() + else: + launch_home_kiosk() + + def on_connect(_client, _userdata, _flags, rc): + if rc != 0: + log.error("MQTT connection refused (rc=%s)", rc) + return + log.info("connected to MQTT broker %s:%s", broker_host, broker_port) + discovery.register_screens(on_show) + discovery.subscribe_all() + discovery.publish_available(True) + + def on_disconnect(_client, _userdata, rc): + log.warning("disconnected from MQTT broker (rc=%s); paho will retry", rc) + + def on_message(_client, _userdata, message): + discovery.dispatch(message.topic, message.payload.decode("utf-8", "replace")) + + client.on_connect = on_connect + client.on_disconnect = on_disconnect + client.on_message = on_message + client.will_set(discovery.availability_topic, "offline", qos=1, retain=True) + + stop_event = threading.Event() + + def handle_signal(_signum, _frame): + stop_event.set() + + signal.signal(signal.SIGTERM, handle_signal) + signal.signal(signal.SIGINT, handle_signal) + + if not broker_host: + log.error("MQTT_BROKER_HOST is not set in %s — running without HA control", CONFIG_PATH) + else: + # connect_async + loop_start, never a blocking connect(): same "reactive + # path never depends on a remote service" rule as every other host's agent. + client.connect_async(broker_host, broker_port, keepalive=60) + client.loop_start() + + log.info("door-panel-agent %s started (node_id=%s)", node_id, node_id) + try: + stop_event.wait() + finally: + log.info("shutting down") + if broker_host: + discovery.publish_available(False) + client.loop_stop() + client.disconnect() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hosts/door-panel/agent/door_panel_agent/mqtt_discovery.py b/hosts/door-panel/agent/door_panel_agent/mqtt_discovery.py new file mode 100644 index 0000000..2deeb64 --- /dev/null +++ b/hosts/door-panel/agent/door_panel_agent/mqtt_discovery.py @@ -0,0 +1,96 @@ +"""Home Assistant MQTT Discovery payloads and command dispatch. + +SECURITY BOUNDARY — this module is the entire remote-control API of the door panel. +Same principle as every other host's agent in this project: the local LLM never gets +a network path to this machine directly. The only chain is LLM tool call -> Home +Assistant service call -> MQTT -> this dispatcher. + +This is deliberately NOT how the LLM registers a person or reads presence/weather/ +groceries — those are `identity`'s and `pantry-vision`'s own published APIs, called +directly by HA or the kiosk browser, not through this agent. This agent only ever +controls which screen the physical panel is showing. +""" + +from __future__ import annotations + +import json +import logging +from typing import Callable + +from . import __version__ + +log = logging.getLogger(__name__) + +DISCOVERY_PREFIX = "homeassistant" + + +class Discovery: + def __init__(self, client, node_id: str, friendly_name: str): + self.client = client + self.node_id = node_id + self.friendly_name = friendly_name + self.base = f"doorpanel/{node_id}" + self.availability_topic = f"{self.base}/availability" + self._handlers: dict[str, Callable[[str], None]] = {} + + self.device = { + "identifiers": [f"doorpanel_{node_id}"], + "name": friendly_name, + "manufacturer": "SmartestHome", + "model": "Sway door panel", + "sw_version": __version__, + } + + def _publish_config(self, component: str, object_id: str, payload: dict) -> None: + payload = { + "availability_topic": self.availability_topic, + "device": self.device, + "unique_id": f"{self.node_id}_{object_id}", + **payload, + } + topic = f"{DISCOVERY_PREFIX}/{component}/{self.node_id}/{object_id}/config" + self.client.publish(topic, json.dumps(payload), qos=1, retain=True) + + def _command_topic(self, suffix: str, handler) -> str: + topic = f"{self.base}/{suffix}" + self._handlers[topic] = handler + return topic + + def subscribe_all(self) -> None: + for topic in self._handlers: + self.client.subscribe(topic, qos=1) + + def dispatch(self, topic: str, payload: str) -> None: + handler = self._handlers.get(topic) + if handler is None: + log.warning("no handler for %s", topic) + return + try: + handler(payload) + except Exception: + log.exception("handler for %s failed", topic) + + def publish_available(self, available: bool = True) -> None: + self.client.publish( + self.availability_topic, + "online" if available else "offline", + qos=1, + retain=True, + ) + + def register_screens(self, on_show) -> None: + for object_id, name, screen, icon in ( + ("show_home", "Show home", "home", "mdi:home-outline"), + ("show_register", "Show registration", "register", "mdi:account-plus"), + ): + self._publish_config( + "button", + object_id, + { + "name": name, + "command_topic": self._command_topic( + f"show/{screen}", lambda _payload, screen=screen: on_show(screen) + ), + "icon": icon, + }, + ) diff --git a/hosts/door-panel/agent/door_panel_agent/session.py b/hosts/door-panel/agent/door_panel_agent/session.py new file mode 100644 index 0000000..188dc2c --- /dev/null +++ b/hosts/door-panel/agent/door_panel_agent/session.py @@ -0,0 +1,53 @@ +"""Session-environment resolution and process launching. + +Same shape as hosts/kitchen-display's session.py: one Sway workspace, two possible +kiosk destinations (identity's dashboard, the default; identity's registration page, +on demand) — no workspace-switching or window-focus surface needed. +""" + +from __future__ import annotations + +import glob +import logging +import os +import subprocess + +log = logging.getLogger(__name__) + + +def runtime_dir() -> str: + return os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" + + +def session_env() -> dict[str, str]: + env = dict(os.environ) + env["XDG_RUNTIME_DIR"] = runtime_dir() + env.setdefault("DBUS_SESSION_BUS_ADDRESS", f"unix:path={runtime_dir()}/bus") + env.setdefault("WAYLAND_DISPLAY", "wayland-1") + matches = sorted(glob.glob(os.path.join(runtime_dir(), "sway-ipc.*.sock"))) + if matches: + env["SWAYSOCK"] = matches[-1] + return env + + +def _launch(command: list[str]) -> None: + log.info("launching %s", " ".join(command)) + try: + subprocess.Popen( + command, + env=session_env(), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError as exc: + log.error("could not launch %s: %s", command[0], exc) + + +def launch_home_kiosk() -> None: + _launch(["/usr/local/bin/home-kiosk"]) + + +def launch_identity_kiosk() -> None: + _launch(["/usr/local/bin/identity-kiosk"]) diff --git a/hosts/door-panel/agent/requirements.txt b/hosts/door-panel/agent/requirements.txt new file mode 100644 index 0000000..4463c04 --- /dev/null +++ b/hosts/door-panel/agent/requirements.txt @@ -0,0 +1,4 @@ +# The ISO installs this from apt (python3-paho-mqtt) rather than pip — see +# live-build/config/package-lists/door-panel.list.chroot. This file is for running +# the agent outside the image (development, a venv on a test box). +paho-mqtt>=1.6 diff --git a/hosts/door-panel/configs/greetd/config.toml b/hosts/door-panel/configs/greetd/config.toml new file mode 100644 index 0000000..627d659 --- /dev/null +++ b/hosts/door-panel/configs/greetd/config.toml @@ -0,0 +1,9 @@ +# greetd — autologin straight into the kiosk Sway session, no greeter UI. Same +# shape as hosts/kitchen-display's/hosts/touch-panel's own config.toml. + +[terminal] +vt = 1 + +[default_session] +command = "/usr/local/bin/kiosk-session" +user = "@KIOSK_USERNAME@" diff --git a/hosts/door-panel/configs/greetd/kiosk-session b/hosts/door-panel/configs/greetd/kiosk-session new file mode 100755 index 0000000..848b9ef --- /dev/null +++ b/hosts/door-panel/configs/greetd/kiosk-session @@ -0,0 +1,17 @@ +#!/bin/sh +# greetd's default_session command. Installed to /usr/local/bin/kiosk-session. +set -eu + +[ -r /etc/door-panel-agent/config.env ] && . /etc/door-panel-agent/config.env +export MQTT_BROKER_HOST IDENTITY_WEB_URL IDENTITY_URL IDENTITY_TOKEN PANTRY_VISION_URL PANTRY_VISION_TOKEN + +export XDG_CURRENT_DESKTOP=sway +export XDG_SESSION_TYPE=wayland +export XDG_SESSION_DESKTOP=sway +export MOZ_ENABLE_WAYLAND=1 +export OZONE_PLATFORM=wayland + +: "${XDG_RUNTIME_DIR:=/run/user/$(id -u)}" +export XDG_RUNTIME_DIR + +exec sway diff --git a/hosts/door-panel/configs/sway/config b/hosts/door-panel/configs/sway/config new file mode 100644 index 0000000..f35786b --- /dev/null +++ b/hosts/door-panel/configs/sway/config @@ -0,0 +1,54 @@ +# Sway kiosk session for the door/wardrobe panel (docs/project-plan.md Phase 18). +# +# Installed to /home//.config/sway/config by +# build-door-panel-iso.sh. One workspace, one Chromium kiosk window — same shape as +# hosts/kitchen-display/, just pointed at identity's dashboard.html by default +# instead of pantry-vision, with the same "Show registration" alternate screen. + +set $mod Mod4 +set $ws_home 1:home + +input type:keyboard { + xkb_layout @KEYBOARD_LAYOUT@ +} + +# Real touchscreen hardware — native Wayland wl_touch, same assumption and same +# caveat as hosts/touch-panel/ and hosts/kitchen-display/. +input type:touch { + map_to_output "*" +} + +output * bg #101014 solid_color + +default_border none +default_floating_border none +hide_edge_borders both +gaps inner 0 +gaps outer 0 + +# door-panel-agent is NOT started here — systemd owns it, same reasoning as every +# other agent in this project. + +for_window [app_id="chromium.*"] fullscreen enable +for_window [app_id="chromium.*"] inhibit_idle fullscreen + +exec sh -c '[ -n "$IDENTITY_WEB_URL" ] && { swaymsg workspace $ws_home; /usr/local/bin/home-kiosk; }' + +# Blank after 20 minutes; any touch resumes it. No lock screen — this is an ambient +# always-on display by design (weather/who's-home/groceries), same reasoning as every +# other kiosk in this project. +exec swayidle -w \ + timeout 1200 'swaymsg "output * power off"' \ + resume 'swaymsg "output * power on"' + +bindsym $mod+Shift+Ctrl+m exec foot --title maintenance-shell +for_window [title="maintenance-shell"] floating enable, resize set width 800 height 500, move position center + +bindsym $mod+Return exec foot +bindsym $mod+q kill +bindsym $mod+Shift+c reload + +# Deliberately no exit binding — same reasoning as every other kiosk image in this +# project. + +workspace $ws_home diff --git a/hosts/door-panel/configs/sway/home-kiosk b/hosts/door-panel/configs/sway/home-kiosk new file mode 100755 index 0000000..9d2f44b --- /dev/null +++ b/hosts/door-panel/configs/sway/home-kiosk @@ -0,0 +1,54 @@ +#!/bin/sh +# Chromium kiosk window pointed at identity's dashboard.html — weather + what-to- +# wear, who's home, groceries running low. Installed to /usr/local/bin/home-kiosk. +# Called both at session start and by door-panel-agent on a "Show home" MQTT command. +# +# Same kill-and-relaunch shape as hosts/kitchen-display's pantry-kiosk. Kills ANY +# chromium instance before launching (not just its own profile) — this device has +# only one workspace, same reasoning as hosts/kitchen-display's pantry-kiosk/ +# identity-kiosk pair (see that host's README for the full explanation). +set -eu + +PROFILE_DIR="${HOME:-/home/$(id -un)}/.config/door-panel-chromium" + +BASE_URL="${IDENTITY_WEB_URL:-}" +[ -n "$BASE_URL" ] || { echo "home-kiosk: IDENTITY_WEB_URL is unset" >&2; exit 1; } +[ -n "${IDENTITY_URL:-}" ] || { echo "home-kiosk: IDENTITY_URL is unset" >&2; exit 1; } +[ -n "${IDENTITY_TOKEN:-}" ] || { echo "home-kiosk: IDENTITY_TOKEN is unset" >&2; exit 1; } + +encode() { printf '%s' "$1" | sed 's/\//%2F/g; s/:/%3A/g'; } + +URL="${BASE_URL%/}/dashboard.html?identity_api=$(encode "$IDENTITY_URL")&identity_token=${IDENTITY_TOKEN}" +if [ -n "${PANTRY_VISION_URL:-}" ] && [ -n "${PANTRY_VISION_TOKEN:-}" ]; then + URL="${URL}&pantry_api=$(encode "$PANTRY_VISION_URL")&pantry_token=${PANTRY_VISION_TOKEN}" +else + echo "home-kiosk: PANTRY_VISION_URL/TOKEN unset — 'Running low' will show as unconfigured" >&2 +fi + +if command -v chromium >/dev/null 2>&1; then + CHROMIUM=chromium +elif command -v chromium-browser >/dev/null 2>&1; then + CHROMIUM=chromium-browser +else + echo "home-kiosk: no chromium/chromium-browser binary found" >&2 + exit 1 +fi + +mkdir -p "$PROFILE_DIR" + +pkill -u "$(id -u)" -f "$CHROMIUM --user-data-dir=" 2>/dev/null || true +i=0 +while pgrep -u "$(id -u)" -f "$CHROMIUM --user-data-dir=" >/dev/null 2>&1 && [ "$i" -lt 20 ]; do + sleep 0.25 + i=$((i + 1)) +done + +exec "$CHROMIUM" \ + --user-data-dir="$PROFILE_DIR" \ + --ozone-platform=wayland \ + --kiosk --app="$URL" \ + --start-fullscreen \ + --noerrdialogs --disable-infobars --disable-session-crashed-bubble \ + --overscroll-history-navigation=0 \ + --touch-events=enabled \ + --check-for-update-interval=31536000 diff --git a/hosts/door-panel/configs/sway/identity-kiosk b/hosts/door-panel/configs/sway/identity-kiosk new file mode 100755 index 0000000..8694f47 --- /dev/null +++ b/hosts/door-panel/configs/sway/identity-kiosk @@ -0,0 +1,50 @@ +#!/bin/sh +# Chromium kiosk window pointed at identity's registration page. Installed to +# /usr/local/bin/identity-kiosk. Called by door-panel-agent when a "Show +# registration" MQTT command arrives — the fallback/manual path; the primary path is +# voice ("register me as "), which never touches this script at all. See +# ../../../identity/README.md. +# +# Identical script to hosts/kitchen-display/configs/sway/identity-kiosk — kept as a +# separate copy per this project's "duplicated, not shared, across hosts" convention. +set -eu + +PROFILE_DIR="${HOME:-/home/$(id -un)}/.config/door-panel-chromium-identity" + +BASE_URL="${IDENTITY_WEB_URL:-}" +[ -n "$BASE_URL" ] || { echo "identity-kiosk: IDENTITY_WEB_URL is unset" >&2; exit 1; } +[ -n "${IDENTITY_URL:-}" ] || { echo "identity-kiosk: IDENTITY_URL is unset" >&2; exit 1; } +[ -n "${IDENTITY_TOKEN:-}" ] || { echo "identity-kiosk: IDENTITY_TOKEN is unset" >&2; exit 1; } + +DEVICE_ID="$(hostname)" +API_ENCODED="$(printf '%s' "$IDENTITY_URL" | sed 's/\//%2F/g; s/:/%3A/g')" +URL="${BASE_URL%/}/register.html?api=${API_ENCODED}&token=${IDENTITY_TOKEN}&device=${DEVICE_ID}" + +if command -v chromium >/dev/null 2>&1; then + CHROMIUM=chromium +elif command -v chromium-browser >/dev/null 2>&1; then + CHROMIUM=chromium-browser +else + echo "identity-kiosk: no chromium/chromium-browser binary found" >&2 + exit 1 +fi + +mkdir -p "$PROFILE_DIR" + +pkill -u "$(id -u)" -f "$CHROMIUM --user-data-dir=" 2>/dev/null || true +i=0 +while pgrep -u "$(id -u)" -f "$CHROMIUM --user-data-dir=" >/dev/null 2>&1 && [ "$i" -lt 20 ]; do + sleep 0.25 + i=$((i + 1)) +done + +exec "$CHROMIUM" \ + --user-data-dir="$PROFILE_DIR" \ + --ozone-platform=wayland \ + --kiosk --app="$URL" \ + --use-fake-ui-for-media-stream \ + --start-fullscreen \ + --noerrdialogs --disable-infobars --disable-session-crashed-bubble \ + --overscroll-history-navigation=0 \ + --touch-events=enabled \ + --check-for-update-interval=31536000 diff --git a/hosts/door-panel/live-build/config/hooks/normal/0100-user-setup.hook.chroot b/hosts/door-panel/live-build/config/hooks/normal/0100-user-setup.hook.chroot new file mode 100755 index 0000000..d6c05a7 --- /dev/null +++ b/hosts/door-panel/live-build/config/hooks/normal/0100-user-setup.hook.chroot @@ -0,0 +1,42 @@ +#!/bin/sh +# Creates the kiosk account. Identical logic to hosts/kitchen-display's/ +# hosts/touch-panel's own 0100-user-setup.hook.chroot. +set -eu + +. /etc/door-panel-agent/config.env + +if ! id "$KIOSK_USERNAME" >/dev/null 2>&1; then + useradd --create-home --shell /bin/bash --comment "Door panel kiosk session" "$KIOSK_USERNAME" +fi + +for grp in audio video input render dialout netdev plugdev seat _seatd; do + if getent group "$grp" >/dev/null 2>&1; then + adduser "$KIOSK_USERNAME" "$grp" >/dev/null + fi +done + +passwd --lock "$KIOSK_USERNAME" >/dev/null +adduser "$KIOSK_USERNAME" sudo >/dev/null + +cat > "/etc/sudoers.d/010-${KIOSK_USERNAME}" < /etc/ssh/sshd_config.d/10-door-panel.conf <<'EOF' +PermitRootLogin no +PasswordAuthentication no +KbdInteractiveAuthentication no +PubkeyAuthentication yes +EOF + +if [ -d "/home/${KIOSK_USERNAME}/.ssh" ]; then + chmod 700 "/home/${KIOSK_USERNAME}/.ssh" + [ -f "/home/${KIOSK_USERNAME}/.ssh/authorized_keys" ] && \ + chmod 600 "/home/${KIOSK_USERNAME}/.ssh/authorized_keys" +fi + +chown -R "${KIOSK_USERNAME}:${KIOSK_USERNAME}" "/home/${KIOSK_USERNAME}" + +systemctl enable ssh >/dev/null 2>&1 || true diff --git a/hosts/door-panel/live-build/config/hooks/normal/0200-greetd.hook.chroot b/hosts/door-panel/live-build/config/hooks/normal/0200-greetd.hook.chroot new file mode 100755 index 0000000..7e35826 --- /dev/null +++ b/hosts/door-panel/live-build/config/hooks/normal/0200-greetd.hook.chroot @@ -0,0 +1,19 @@ +#!/bin/sh +# Makes greetd the boot target. Identical logic to hosts/kitchen-display's own +# 0200-greetd.hook.chroot. +set -eu + +. /etc/door-panel-agent/config.env + +chmod 0755 /usr/local/bin/kiosk-session + +if ! id greeter >/dev/null 2>&1; then + useradd --system --create-home --home-dir /var/lib/greetd --shell /usr/sbin/nologin greeter +fi + +systemctl enable greetd +systemctl set-default graphical.target +systemctl mask getty@tty1.service + +mkdir -p "/home/${KIOSK_USERNAME}/.config/sway" +chown -R "${KIOSK_USERNAME}:${KIOSK_USERNAME}" "/home/${KIOSK_USERNAME}/.config" diff --git a/hosts/door-panel/live-build/config/hooks/normal/0300-door-panel-agent.hook.chroot b/hosts/door-panel/live-build/config/hooks/normal/0300-door-panel-agent.hook.chroot new file mode 100755 index 0000000..eca94a5 --- /dev/null +++ b/hosts/door-panel/live-build/config/hooks/normal/0300-door-panel-agent.hook.chroot @@ -0,0 +1,12 @@ +#!/bin/sh +# Installs the door-panel-agent systemd unit. Identical logic to +# hosts/kitchen-display's own 0300-kitchen-display-agent.hook.chroot. +set -eu + +install -m 0644 /opt/door-panel-agent/door-panel-agent.service \ + /etc/systemd/system/door-panel-agent.service + +chmod 0644 /etc/door-panel-agent/config.env +chown -R root:root /opt/door-panel-agent + +systemctl enable door-panel-agent diff --git a/hosts/door-panel/live-build/config/hooks/normal/0400-voice-satellite.hook.chroot b/hosts/door-panel/live-build/config/hooks/normal/0400-voice-satellite.hook.chroot new file mode 100755 index 0000000..75772aa --- /dev/null +++ b/hosts/door-panel/live-build/config/hooks/normal/0400-voice-satellite.hook.chroot @@ -0,0 +1,69 @@ +#!/bin/sh +# wyoming-satellite + openWakeWord, opt-in — identical logic to +# hosts/kitchen-display's own 0400-voice-satellite.hook.chroot. Voice is central to +# this device's actual purpose (voice registration, "register me as "), so the +# build script's own example config defaults this to true, unlike kitchen-display's +# — but the underlying mechanism is identical and still requires real hardware +# (a mic on this specific unit) before it means anything. +set -eu + +. /etc/door-panel-agent/config.env + +if [ "${ENABLE_VOICE_SATELLITE}" != "true" ]; then + echo "0400-voice-satellite: ENABLE_VOICE_SATELLITE is false, skipping (voice" + echo " registration needs a real microphone; don't enable this until one is" + echo " attached to this specific unit)." + exit 0 +fi + +VENV=/opt/voice-satellite/venv + +python3 -m venv "$VENV" +"$VENV/bin/pip" install --upgrade pip wheel setuptools + +if ! "$VENV/bin/pip" install wyoming-satellite wyoming-openwakeword; then + echo "0400-voice-satellite: WARNING — pip install failed. Fall back to the upstream" + echo " git-clone install: https://github.com/rhasspy/wyoming-satellite" + exit 0 +fi + +cat > /etc/systemd/system/wyoming-openwakeword.service < /etc/systemd/system/wyoming-satellite.service <") — opt-in like every other host's mic, but this device's whole point, +# so expect it on for a real deployment +# +# Reuses hosts/thin-client's live-build toolchain and directory-split convention, not +# its live-build tree — same relationship hosts/touch-panel, hosts/audio-endpoint, +# and hosts/kitchen-display already have to the thin client's. Structurally this is +# hosts/kitchen-display's twin: same one-workspace-two-kiosk-destinations shape, +# different default content and a mic that's actually expected to be used. +# +# Run as: sudo ./build-door-panel-iso.sh +# +# EDIT THE VARIABLES BELOW BEFORE RUNNING. + +set -euo pipefail + +# --------------------------------------------------------------------------- +# CONFIGURATION — edit these before running +# --------------------------------------------------------------------------- +DEBIAN_RELEASE="bookworm" +KIOSK_USERNAME="kiosk" +IMAGE_HOSTNAME="door-panel" +DOOR_PANEL_NAME="Door panel" + +KEYBOARD_LAYOUT="de" + +ENABLE_INSTALLER="false" + +# --- Where the door panel talks to ------------------------------------------- +MQTT_BROKER_HOST="192.168.1.10" # <-- EDIT: container-host IP running Mosquitto +MQTT_BROKER_PORT="1883" +MQTT_USERNAME="" +MQTT_PASSWORD="" + +# identity's dashboard/registration pages and API — ENABLE_IDENTITY in +# setup-container-host.sh. Placeholders until that's deployed; the image builds and +# boots fine without it, the kiosk window just shows a connection error. +IDENTITY_WEB_URL="http://192.168.1.10:8098" # <-- EDIT once identity-web is deployed +IDENTITY_URL="http://192.168.1.10:8097" # <-- EDIT once identity is deployed +# Must match identity/identity.env's own token — no way for this repo to push it +# between the two hosts for you. +IDENTITY_TOKEN="" # <-- EDIT + +# pantry-vision — only needed for the dashboard's "Running low" section; the rest of +# the dashboard (weather, who's home) works without it. Same placeholder handling. +PANTRY_VISION_URL="http://192.168.1.10:8095" # <-- EDIT once pantry-vision is deployed +PANTRY_VISION_TOKEN="" # <-- EDIT: must match pantry-vision's own token + +# --- Voice registration ("register me as ") — this device's whole point, so +# --- true is the expected real-deployment value, unlike hosts/kitchen-display's +# --- identical flag — but still requires a real mic on this specific unit. +ENABLE_VOICE_SATELLITE="true" +VOICE_SATELLITE_NAME="Door panel" +VOICE_WAKE_WORD="ok_nabu" + +SSH_AUTHORIZED_KEY="" + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DOOR_PANEL_DIR="$(dirname "$SCRIPT_DIR")" +CONFIGS_DIR="${DOOR_PANEL_DIR}/configs" +AGENT_DIR="${DOOR_PANEL_DIR}/agent" +LIVE_BUILD_DIR="${DOOR_PANEL_DIR}/live-build" +INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot" +PACKAGE_LIST="${LIVE_BUILD_DIR}/config/package-lists/door-panel.list.chroot" + +# --------------------------------------------------------------------------- +# Sanity checks +# --------------------------------------------------------------------------- +if [[ $EUID -ne 0 ]]; then + echo "Warning: not running as root. 'lb build' needs root; re-run with: sudo $0" + echo " Continuing anyway so you can at least regenerate includes.chroot..." +fi + +if ! command -v lb &> /dev/null; then + if [[ $EUID -eq 0 ]]; then + echo "--- Installing live-build ---" + apt-get update + apt-get install -y live-build + else + echo "live-build is not installed and this script is not running as root." >&2 + echo " Install it first: sudo apt-get install live-build" >&2 + exit 1 + fi +else + echo "--- live-build already installed, skipping ---" +fi + +if [[ ! -f "$PACKAGE_LIST" ]]; then + echo "Missing package list: $PACKAGE_LIST" >&2 + exit 1 +fi + +if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then + echo "Warning: MQTT_BROKER_HOST is still the placeholder IP — edit it before building." +fi + +if [[ -z "$IDENTITY_TOKEN" ]]; then + echo "Warning: IDENTITY_TOKEN is empty. The dashboard will load but every call to" + echo " identity will fail (401) until this matches the token in" + echo " identity/identity.env on the container host." +fi + +if [[ -z "$PANTRY_VISION_TOKEN" ]]; then + echo "Warning: PANTRY_VISION_TOKEN is empty. 'Running low' will show as unconfigured" + echo " until this matches the token in pantry-vision/pantry-vision.env." +fi + +if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then + echo "Note: ENABLE_VOICE_SATELLITE=true — this image expects a real microphone on" + echo " the physical unit. Don't flash it to hardware that doesn't have one." +fi + +echo +echo "=== Smart Home Door-Panel ISO Builder ===" +echo "Debian release : $DEBIAN_RELEASE" +echo "Kiosk user : $KIOSK_USERNAME" +echo "Image hostname : $IMAGE_HOSTNAME" +echo "MQTT broker : ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}" +echo "identity-web : $IDENTITY_WEB_URL" +echo "identity : $IDENTITY_URL" +echo "pantry-vision : $PANTRY_VISION_URL" +echo "Voice satellite : $ENABLE_VOICE_SATELLITE" +echo "Keyboard layout : $KEYBOARD_LAYOUT" +echo + +# --------------------------------------------------------------------------- +# 1. Regenerate includes.chroot from configs/ and agent/ +# --------------------------------------------------------------------------- +echo "--- Regenerating $INCLUDES ---" +rm -rf "$INCLUDES" +mkdir -p \ + "$INCLUDES/etc/greetd" \ + "$INCLUDES/etc/door-panel-agent" \ + "$INCLUDES/usr/local/bin" \ + "$INCLUDES/opt/door-panel-agent" \ + "$INCLUDES/home/${KIOSK_USERNAME}/.config/sway" \ + "$INCLUDES/home/${KIOSK_USERNAME}/.ssh" + +subst() { + sed -e "s/@KIOSK_USERNAME@/${KIOSK_USERNAME}/g" \ + -e "s/@KEYBOARD_LAYOUT@/${KEYBOARD_LAYOUT}/g" "$1" > "$2" +} + +subst "${CONFIGS_DIR}/greetd/config.toml" "$INCLUDES/etc/greetd/config.toml" +subst "${CONFIGS_DIR}/sway/config" "$INCLUDES/home/${KIOSK_USERNAME}/.config/sway/config" +subst "${AGENT_DIR}/door-panel-agent.service" "$INCLUDES/opt/door-panel-agent/door-panel-agent.service" + +install -m 0755 "${CONFIGS_DIR}/greetd/kiosk-session" "$INCLUDES/usr/local/bin/kiosk-session" +install -m 0755 "${CONFIGS_DIR}/sway/home-kiosk" "$INCLUDES/usr/local/bin/home-kiosk" +install -m 0755 "${CONFIGS_DIR}/sway/identity-kiosk" "$INCLUDES/usr/local/bin/identity-kiosk" + +mkdir -p "$INCLUDES/etc/default" +cat > "$INCLUDES/etc/default/keyboard" < "$INCLUDES/home/${KIOSK_USERNAME}/.ssh/authorized_keys" + chmod 600 "$INCLUDES/home/${KIOSK_USERNAME}/.ssh/authorized_keys" + echo " Baked an SSH authorized_keys entry for ${KIOSK_USERNAME}." +else + echo " No SSH_AUTHORIZED_KEY set — SSH admin access will not be possible on this image." +fi + +# --------------------------------------------------------------------------- +# 2. Runtime config +# --------------------------------------------------------------------------- +echo "--- Writing /etc/door-panel-agent/config.env into includes.chroot ---" +cat > "$INCLUDES/etc/door-panel-agent/config.env" <' and confirm it works." +fi +echo +echo "Then pull the power on the container host and re-check: the panel must still" +echo "boot (the dashboard will show connection errors, which is the expected degraded" +echo "state — same acceptance already documented for hosts/touch-panel's Home" +echo "workspace and hosts/kitchen-display's Scan/Inventory/Recipes tabs)." +echo +echo "Rebuilding later: edit hosts/door-panel/configs/* or agent/*, then re-run this" +echo "script — includes.chroot is regenerated from them every time." diff --git a/hosts/kitchen-display/README.md b/hosts/kitchen-display/README.md index 21514d6..e575858 100644 --- a/hosts/kitchen-display/README.md +++ b/hosts/kitchen-display/README.md @@ -9,9 +9,16 @@ inventory (soonest-to-expire first) and Grocy's recipes, all touch-driven. that's [`../../pantry-vision/`](../../pantry-vision/README.md), a container-host service this device's kiosk browser calls directly. This host is deliberately thin: a Sway session, one Chromium kiosk window, and `kitchen-display-agent`, which only -ever controls *which screen is showing* (Scan / Inventory / Recipes) over MQTT — the -same "the agent controls the surface, a separate write API owns the content" split -`hosts/thin-client`'s admin canvas already established. +ever controls *which screen is showing* (Scan / Inventory / Recipes, plus +Registration — see below) over MQTT — the same "the agent controls the surface, a +separate write API owns the content" split `hosts/thin-client`'s admin canvas already +established. + +**This is also one of the camera-equipped endpoints [`../../identity/`](../../identity/README.md)'s +person registration flow can run on** — "register me as ``" by voice (needs +`ENABLE_VOICE_SATELLITE=true` and a real mic on this specific unit) or the +touchscreen form either way. See identity's own README for the actual design +(multi-phone support, anti-spoofing); this host only adds the mic/kiosk-page plumbing. **A third, simpler sibling of `../thin-client/` and `../touch-panel/`**, not a variant of either — one workspace, one app, no dock, no music, no general browsing. It reuses @@ -23,8 +30,9 @@ the thin client's live-build toolchain and `configs/`+`agent/` split. | Autologin | greetd, straight into `/usr/local/bin/kiosk-session` | | Touch input | Native Wayland `wl_touch`, same assumption as `../touch-panel/` | | Camera | Browser-native `getUserMedia()` inside the Chromium kiosk page — no separate native capture app on this device at all | -| Content | `pantry-vision`'s frontend (`../../pantry-vision/frontend/`), served by `pantry-web`; in-page tabs, not separate Sway workspaces | -| Remote control (HA/LLM) | `kitchen-display-agent` — **Show scan / Show inventory / Show recipes** buttons only | +| Content | `pantry-vision`'s frontend (`../../pantry-vision/frontend/`) by default; `identity`'s registration page on demand | +| Voice | wyoming-satellite + openWakeWord — **opt-in, off by default**, needed for "register me as ``" | +| Remote control (HA/LLM) | `kitchen-display-agent` — **Show scan / Show inventory / Show recipes / Show registration** buttons only | | Remote control (human) | SSH only — no wayvnc, same scope decision as `../touch-panel/` | ## Hardware @@ -50,6 +58,8 @@ the `# CONFIGURATION` block at the top of | `PANTRY_WEB_URL` | The `pantry-web` service's URL, e.g. `http://192.168.1.10:8096` | | `PANTRY_VISION_URL` | The `pantry-vision` API's URL, e.g. `http://192.168.1.10:8095` — read by `app.js` from the kiosk window's own URL query params (see `pantry-kiosk`'s comment), never baked into the served frontend files | | `PANTRY_VISION_TOKEN` | **Must exactly match** `PANTRY_VISION_TOKEN` in `pantry-vision/pantry-vision.env` on the container host — there's no way for this repo to push that value between the two hosts for you | +| `IDENTITY_WEB_URL` / `IDENTITY_URL` / `IDENTITY_TOKEN` | Same shape as the `PANTRY_*` ones above, for `../../identity/` (`ENABLE_IDENTITY` in `setup-container-host.sh`) — `IDENTITY_TOKEN` must match `identity/identity.env`'s own token | +| `ENABLE_VOICE_SATELLITE` | `false` unless this specific unit has a real microphone attached — see `../thin-client/README.md`'s identical flag for the same per-image reasoning | | `KIOSK_USERNAME` / `IMAGE_HOSTNAME` / `KITCHEN_DISPLAY_NAME` | Per-device identity | | `SSH_AUTHORIZED_KEY` | Optional — password auth is disabled and there is no wayvnc | @@ -76,16 +86,21 @@ would otherwise sit unanswered on a screen nobody is there to click "Allow" on. ## Home Assistant entities -`kitchen-display-agent` publishes three buttons on connect: **Show scan**, **Show -inventory**, **Show recipes** — each kills and relaunches the kiosk Chromium window -at `PANTRY_WEB_URL/index.html?...#`. That's the entire MQTT surface; there is no -media_player, no workspace select (one workspace), no capture/audio/remote-desktop -entities the way the thin client has. Reading or writing the actual inventory (from -voice, e.g. "what's about to expire?" or "add three eggs") is a **separate** path — -Home Assistant/the LLM calling `pantry-vision`'s own published API directly, not -through this agent, exactly mirroring the split `hosts/thin-client`'s admin canvas -already established between "which screen is showing" (MQTT, this agent) and "what's -actually on it" (a dedicated write API). +`kitchen-display-agent` publishes four buttons on connect: **Show scan**, **Show +inventory**, **Show recipes**, **Show registration** — the first three kill and +relaunch the kiosk Chromium window at `PANTRY_WEB_URL/index.html?...#`; the +fourth relaunches it at `IDENTITY_WEB_URL/register.html?...` instead (a different +backend, `identity`, behind the same "Show X" shape — see +`kitchen_display_agent/main.py`'s `on_show()`). That's the entire MQTT surface; there +is no media_player, no workspace select (one workspace), no capture/audio/ +remote-desktop entities the way the thin client has. Reading or writing the actual +inventory, or registering a person, is a **separate** path — Home Assistant/the LLM +calling `pantry-vision`'s or `identity`'s own published API directly, not through +this agent, exactly mirroring the split `hosts/thin-client`'s admin canvas already +established between "which screen is showing" (MQTT, this agent) and "what's +actually on it" (a dedicated write API). Voice registration in particular never +touches this agent at all — see `../../identity/README.md`'s worked HA intent-script +example. ### Security boundary @@ -123,3 +138,11 @@ the vision model's accuracy/latency, which this device's whole usefulness rides only, which is enough for a bare `http://host:port` but not for a URL with a path or query string of its own. 8. Idle-blank timeout (20 minutes, `configs/sway/config`) and touch-resume — untested. +9. Switching between `pantry-kiosk` and `identity-kiosk` (both `configs/sway/`) + actually replaces the window rather than leaving two Chromium instances up — both + scripts kill any chromium process, not just their own profile, specifically + because this device has only one workspace; never exercised against a real + in-progress scan or registration getting interrupted mid-flow by the other. +10. Everything in `../../identity/README.md`'s own verification list, especially + `TRUSTED_ENTITY_PREFIXES` — registration will not find a candidate at all until + that's set to match your real HA entity IDs. diff --git a/hosts/kitchen-display/agent/kitchen_display_agent/main.py b/hosts/kitchen-display/agent/kitchen_display_agent/main.py index a2ac355..1223c7e 100644 --- a/hosts/kitchen-display/agent/kitchen_display_agent/main.py +++ b/hosts/kitchen-display/agent/kitchen_display_agent/main.py @@ -12,7 +12,7 @@ import threading import paho.mqtt.client as mqtt from .mqtt_discovery import Discovery -from .session import launch_pantry_kiosk +from .session import launch_identity_kiosk, launch_pantry_kiosk CONFIG_PATH = os.environ.get("KITCHEN_DISPLAY_AGENT_CONFIG", "/etc/kitchen-display-agent/config.env") @@ -24,6 +24,9 @@ CONFIG_KEYS = ( "PANTRY_WEB_URL", "PANTRY_VISION_URL", "PANTRY_VISION_TOKEN", + "IDENTITY_WEB_URL", + "IDENTITY_URL", + "IDENTITY_TOKEN", "KIOSK_USERNAME", "KITCHEN_DISPLAY_NAME", ) @@ -80,7 +83,13 @@ def main() -> int: discovery = Discovery(client, node_id, friendly_name) def on_show(fragment: str) -> None: - launch_pantry_kiosk(fragment) + # "register" is a different backend (identity's register.html) behind the + # same "Show X" MQTT shape as pantry-vision's scan/inventory/recipes — + # everything else stays on the pantry-web SPA. + if fragment == "register": + launch_identity_kiosk() + else: + launch_pantry_kiosk(fragment) def on_connect(_client, _userdata, _flags, rc): if rc != 0: diff --git a/hosts/kitchen-display/agent/kitchen_display_agent/mqtt_discovery.py b/hosts/kitchen-display/agent/kitchen_display_agent/mqtt_discovery.py index f23de99..ec5e48d 100644 --- a/hosts/kitchen-display/agent/kitchen_display_agent/mqtt_discovery.py +++ b/hosts/kitchen-display/agent/kitchen_display_agent/mqtt_discovery.py @@ -91,6 +91,10 @@ class Discovery: ("show_scan", "Show scan", "scan", "mdi:camera"), ("show_inventory", "Show inventory", "inventory", "mdi:fridge-outline"), ("show_recipes", "Show recipes", "recipes", "mdi:chef-hat"), + # A different backend (identity, not pantry-vision) behind the same + # "Show X" shape — main.py's on_show() is what routes "register" + # differently from the other three fragments, not this module. + ("show_register", "Show registration", "register", "mdi:account-plus"), ): self._publish_config( "button", diff --git a/hosts/kitchen-display/agent/kitchen_display_agent/session.py b/hosts/kitchen-display/agent/kitchen_display_agent/session.py index d0197f0..872ea74 100644 --- a/hosts/kitchen-display/agent/kitchen_display_agent/session.py +++ b/hosts/kitchen-display/agent/kitchen_display_agent/session.py @@ -1,9 +1,10 @@ """Session-environment resolution and process launching. A trimmed version of hosts/thin-client's/hosts/touch-panel's sway_control.py: this -device has only one Sway workspace and one app (the pantry-kiosk Chromium window), so -there is no workspace-switching or window-focus surface to wrap — just enough to run -`/usr/local/bin/pantry-kiosk [fragment]` with the graphical session's environment. +device has only one Sway workspace and two possible kiosk destinations — pantry-vision +(the everyday screen) and identity's registration page (on demand) — so there is no +workspace-switching or window-focus surface to wrap, just enough to run the right +launcher script with the graphical session's environment. """ from __future__ import annotations @@ -31,8 +32,7 @@ def session_env() -> dict[str, str]: return env -def launch_pantry_kiosk(fragment: str) -> None: - command = ["/usr/local/bin/pantry-kiosk", fragment] if fragment else ["/usr/local/bin/pantry-kiosk"] +def _launch(command: list[str]) -> None: log.info("launching %s", " ".join(command)) try: subprocess.Popen( @@ -44,4 +44,12 @@ def launch_pantry_kiosk(fragment: str) -> None: start_new_session=True, ) except OSError as exc: - log.error("could not launch pantry-kiosk: %s", exc) + log.error("could not launch %s: %s", command[0], exc) + + +def launch_pantry_kiosk(fragment: str) -> None: + _launch(["/usr/local/bin/pantry-kiosk", fragment] if fragment else ["/usr/local/bin/pantry-kiosk"]) + + +def launch_identity_kiosk() -> None: + _launch(["/usr/local/bin/identity-kiosk"]) diff --git a/hosts/kitchen-display/configs/sway/identity-kiosk b/hosts/kitchen-display/configs/sway/identity-kiosk new file mode 100755 index 0000000..5d1cd26 --- /dev/null +++ b/hosts/kitchen-display/configs/sway/identity-kiosk @@ -0,0 +1,57 @@ +#!/bin/sh +# Chromium kiosk window pointed at identity's registration page. Installed to +# /usr/local/bin/identity-kiosk. Called by kitchen-display-agent when a "Show +# registration" MQTT command arrives — the fallback/manual path; the primary path is +# voice ("register me as "), which never touches this script at all. See +# ../../../identity/README.md. +# +# Same kill-and-relaunch shape as pantry-kiosk, on its own separate Chromium profile +# (a distinct --user-data-dir) so the two scripts' profile locks can never collide — +# but unlike hosts/thin-client's digest-browser/admin-browser (which coexist on +# separate Sway *workspaces*), this device has only ONE workspace, so this script +# also kills ANY chromium instance regardless of profile before launching, not just +# its own — otherwise switching "Show registration" -> "Show scan" would leave the +# old registration window sitting behind the new one instead of actually replacing it. +set -eu + +PROFILE_DIR="${HOME:-/home/$(id -un)}/.config/kitchen-display-chromium-identity" + +BASE_URL="${IDENTITY_WEB_URL:-}" +[ -n "$BASE_URL" ] || { echo "identity-kiosk: IDENTITY_WEB_URL is unset" >&2; exit 1; } +[ -n "${IDENTITY_URL:-}" ] || { echo "identity-kiosk: IDENTITY_URL is unset" >&2; exit 1; } +[ -n "${IDENTITY_TOKEN:-}" ] || { echo "identity-kiosk: IDENTITY_TOKEN is unset" >&2; exit 1; } + +DEVICE_ID="$(hostname)" +API_ENCODED="$(printf '%s' "$IDENTITY_URL" | sed 's/\//%2F/g; s/:/%3A/g')" +URL="${BASE_URL%/}/register.html?api=${API_ENCODED}&token=${IDENTITY_TOKEN}&device=${DEVICE_ID}" + +if command -v chromium >/dev/null 2>&1; then + CHROMIUM=chromium +elif command -v chromium-browser >/dev/null 2>&1; then + CHROMIUM=chromium-browser +else + echo "identity-kiosk: no chromium/chromium-browser binary found" >&2 + exit 1 +fi + +mkdir -p "$PROFILE_DIR" + +pkill -u "$(id -u)" -f "$CHROMIUM --user-data-dir=" 2>/dev/null || true +i=0 +while pgrep -u "$(id -u)" -f "$CHROMIUM --user-data-dir=" >/dev/null 2>&1 && [ "$i" -lt 20 ]; do + sleep 0.25 + i=$((i + 1)) +done + +# --use-fake-ui-for-media-stream: same reasoning as pantry-kiosk's identical flag — +# auto-accept the camera permission prompt rather than leave it unanswered. +exec "$CHROMIUM" \ + --user-data-dir="$PROFILE_DIR" \ + --ozone-platform=wayland \ + --kiosk --app="$URL" \ + --use-fake-ui-for-media-stream \ + --start-fullscreen \ + --noerrdialogs --disable-infobars --disable-session-crashed-bubble \ + --overscroll-history-navigation=0 \ + --touch-events=enabled \ + --check-for-update-interval=31536000 diff --git a/hosts/kitchen-display/configs/sway/pantry-kiosk b/hosts/kitchen-display/configs/sway/pantry-kiosk index 596199e..a377bc6 100755 --- a/hosts/kitchen-display/configs/sway/pantry-kiosk +++ b/hosts/kitchen-display/configs/sway/pantry-kiosk @@ -8,6 +8,11 @@ # the page underneath (pantry-vision/frontend/) is cheap to reload. Camera capture is # momentary (hold item up, tap Capture) so losing an in-progress scan to a "Show # inventory" command is an acceptable, rare edge case, not a design flaw to solve here. +# +# Kills ANY chromium instance before launching, not just this script's own profile — +# unlike hosts/thin-client's digest-browser/admin-browser (separate Sway workspaces, +# can coexist), this device has only ONE workspace, so identity-kiosk's window (a +# different profile) has to actually be replaced, not left running behind this one. set -eu PROFILE_DIR="${HOME:-/home/$(id -un)}/.config/kitchen-display-chromium" @@ -34,9 +39,9 @@ fi mkdir -p "$PROFILE_DIR" -pkill -u "$(id -u)" -f "$CHROMIUM .*--user-data-dir=$PROFILE_DIR" 2>/dev/null || true +pkill -u "$(id -u)" -f "$CHROMIUM --user-data-dir=" 2>/dev/null || true i=0 -while pgrep -u "$(id -u)" -f "$CHROMIUM .*--user-data-dir=$PROFILE_DIR" >/dev/null 2>&1 && [ "$i" -lt 20 ]; do +while pgrep -u "$(id -u)" -f "$CHROMIUM --user-data-dir=" >/dev/null 2>&1 && [ "$i" -lt 20 ]; do sleep 0.25 i=$((i + 1)) done diff --git a/hosts/kitchen-display/live-build/config/hooks/normal/0400-voice-satellite.hook.chroot b/hosts/kitchen-display/live-build/config/hooks/normal/0400-voice-satellite.hook.chroot new file mode 100755 index 0000000..dafc930 --- /dev/null +++ b/hosts/kitchen-display/live-build/config/hooks/normal/0400-voice-satellite.hook.chroot @@ -0,0 +1,71 @@ +#!/bin/sh +# wyoming-satellite + openWakeWord, opt-in (docs/project-plan.md Phase 6/17) — this is +# what makes voice registration ("register me as ") possible on this device. +# Identical logic to hosts/thin-client's 0600-voice-satellite.hook.chroot. +set -eu + +. /etc/kitchen-display-agent/config.env + +if [ "${ENABLE_VOICE_SATELLITE}" != "true" ]; then + echo "0400-voice-satellite: ENABLE_VOICE_SATELLITE is false, skipping (this is the" + echo " default — voice registration needs a real microphone; don't enable this" + echo " until one is attached)." + exit 0 +fi + +VENV=/opt/voice-satellite/venv + +python3 -m venv "$VENV" +"$VENV/bin/pip" install --upgrade pip wheel setuptools + +# VERIFY BEFORE THE FIRST REAL BUILD — same caveat as the thin client's identical +# hook: upstream's documented install path is a git clone + script/setup rather than +# PyPI. If either name isn't on PyPI, fall back to that instead. +if ! "$VENV/bin/pip" install wyoming-satellite wyoming-openwakeword; then + echo "0400-voice-satellite: WARNING — pip install failed. Fall back to the upstream" + echo " git-clone install: https://github.com/rhasspy/wyoming-satellite" + exit 0 +fi + +cat > /etc/systemd/system/wyoming-openwakeword.service < /etc/systemd/system/wyoming-satellite.service <", needs +# ENABLE_VOICE_SATELLITE=true and a real mic) or the touchscreen form either way # # Reuses hosts/thin-client's live-build toolchain and directory-split convention, not # its live-build tree — same relationship hosts/touch-panel and hosts/audio-endpoint @@ -52,6 +55,19 @@ PANTRY_VISION_URL="http://192.168.1.10:8095" # <-- EDIT once pantry-vision is # credential pair that spans two machines in this project. PANTRY_VISION_TOKEN="" # <-- EDIT: must match pantry-vision's own token +# identity's registration page (Phase 6) — ENABLE_IDENTITY in setup-container-host.sh. +# Same placeholder handling as the pantry-vision block above. +IDENTITY_WEB_URL="http://192.168.1.10:8098" # <-- EDIT once identity-web is deployed +IDENTITY_URL="http://192.168.1.10:8097" # <-- EDIT once identity is deployed +IDENTITY_TOKEN="" # <-- EDIT: must match identity's own token + +# --- Voice registration ("register me as ") — OFF BY DEFAULT until a real mic +# --- is attached to this specific unit. Same per-image opt-in shape as the thin +# --- client's ENABLE_VOICE_SATELLITE (project-plan Phase 11.8). +ENABLE_VOICE_SATELLITE="false" +VOICE_SATELLITE_NAME="Kitchen display" +VOICE_WAKE_WORD="ok_nabu" + SSH_AUTHORIZED_KEY="" # --------------------------------------------------------------------------- @@ -102,6 +118,17 @@ if [[ -z "$PANTRY_VISION_TOKEN" ]]; then echo " pantry-vision/pantry-vision.env on the container host." fi +if [[ -z "$IDENTITY_TOKEN" ]]; then + echo "Warning: IDENTITY_TOKEN is empty. 'Show registration' will load but every" + echo " call to identity will fail (401) until this matches the token in" + echo " identity/identity.env on the container host." +fi + +if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then + echo "Note: ENABLE_VOICE_SATELLITE=true — this image expects a real microphone on" + echo " the physical unit. Don't flash it to hardware that doesn't have one." +fi + echo echo "=== Smart Home Kitchen-Display ISO Builder ===" echo "Debian release : $DEBIAN_RELEASE" @@ -110,6 +137,9 @@ echo "Image hostname : $IMAGE_HOSTNAME" echo "MQTT broker : ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}" echo "pantry-web : $PANTRY_WEB_URL" echo "pantry-vision : $PANTRY_VISION_URL" +echo "identity-web : $IDENTITY_WEB_URL" +echo "identity : $IDENTITY_URL" +echo "Voice satellite : $ENABLE_VOICE_SATELLITE" echo "Keyboard layout : $KEYBOARD_LAYOUT" echo @@ -137,6 +167,7 @@ subst "${AGENT_DIR}/kitchen-display-agent.service" "$INCLUDES/opt/kitchen-displa install -m 0755 "${CONFIGS_DIR}/greetd/kiosk-session" "$INCLUDES/usr/local/bin/kiosk-session" install -m 0755 "${CONFIGS_DIR}/sway/pantry-kiosk" "$INCLUDES/usr/local/bin/pantry-kiosk" +install -m 0755 "${CONFIGS_DIR}/sway/identity-kiosk" "$INCLUDES/usr/local/bin/identity-kiosk" mkdir -p "$INCLUDES/etc/default" cat > "$INCLUDES/etc/default/keyboard" <' and confirm the same" + echo " result happens via voice." +fi echo echo "Then pull the power on the container host and re-check: the kiosk must still" echo "boot (Scan/Inventory/Recipes will show connection errors, which is the expected" diff --git a/identity/Dockerfile b/identity/Dockerfile new file mode 100644 index 0000000..f51e2ef --- /dev/null +++ b/identity/Dockerfile @@ -0,0 +1,21 @@ +# identity — the person <-> BLE-identifier registry (docs/project-plan.md Phase 6). +# `restart: unless-stopped`, same as admin-canvas/pantry-vision. +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +# Not stdlib-only, unlike admin-canvas/pantry-vision: this is the first +# container-host service that's also an MQTT client (subscribes to +# smarthome/weather/current for the door-panel/kitchen-display dashboards), so it +# needs paho-mqtt. sqlite3 is stdlib, no separate DB dependency. +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY server.py ./ + +RUN mkdir -p /data /data/photos + +CMD ["python", "server.py"] diff --git a/identity/README.md b/identity/README.md new file mode 100644 index 0000000..949d314 --- /dev/null +++ b/identity/README.md @@ -0,0 +1,237 @@ +# identity + +The household's person <-> BLE-identifier registry, from +[Phase 6 of the project plan](../docs/project-plan.md). Solves two concrete problems +in one design: + +1. **Multiple phones per person** (the classic private/work phone situation). +2. **MAC address spoofing/randomization**, so registration can't be tricked or + accidentally fed garbage by a phone's own privacy features. +3. **People with no device at all** (a grandmother without a smartphone, a one-off + guest) — the system stays useful instead of just not knowing they exist. + +Also backs the "who's home" and "register me" pieces of `hosts/door-panel/` and +`hosts/kitchen-display/`, and proxies the household weather topic those two +dashboards both need. + +## The model + +A **person** has zero or more **identifiers**. An identifier is a Home Assistant +`entity_id` that resolves presence for one physical device. That's the entire schema +(`server.py`'s `people`/`identifiers` tables) — multi-phone support isn't a special +case, it falls straight out of it: register once with your private phone, register +again later with your work phone in hand, same spoken name, and you now have two +identifiers under one person. + +## Anti-spoofing — the actual security boundary + +**A raw Bluetooth MAC address is never accepted as an identifier by itself, +especially not a randomized one** (the iOS/Android default — a phone's advertised +MAC rotates every few minutes specifically so it *can't* be tracked as a stable +identifier by anyone, including this system). Registration only ever looks at +`entity_id`s matching `TRUSTED_ENTITY_PREFIXES` — meant to contain **only**: + +- Home Assistant's **Private BLE Device** integration entities (Bermuda/HA resolve + the rotating MAC back to a stable identity via the device's IRK — a cryptographic + resolution, not string-matching a MAC), or +- manually provisioned **fixed-MAC BLE tag** entities (a physical tag handed to a + person specifically because its MAC doesn't rotate). + +An attacker broadcasting an arbitrary spoofed MAC never produces a trusted candidate +— it just doesn't show up in `TRUSTED_ENTITY_PREFIXES` at all, because the untrusted +raw entity is a different `entity_id` than the resolved one. Spoofing a *specific* +person's resolved identity would require their device's actual IRK secret, a +materially higher bar than MAC spoofing. **This is a defense against passive/ +opportunistic spoofing, not a claim of cryptographic non-repudiation** — if a +household member's phone (and its IRK) is itself compromised, this system has no way +to know that. Threat-model it as "keeps a stranger's phone from registering itself +as you," not "biometric-grade proof of identity." + +## Never auto-commit on ambiguity + +If a registration attempt finds zero, more than one, or an already-claimed +candidate, **nothing is written**. The caller gets a reason back (and, for the +ambiguous case, the candidate list) and a human disambiguates on the touchscreen — +calling `POST /register` again with an explicit `entity_id`. The one case that *does* +commit within a single call is the clean one (exactly one trusted, unclaimed +candidate) — because the spoken "register me as ``" command **is** the human +confirmation; requiring a second round-trip for the unambiguous case would be pure +friction with no safety benefit. This mirrors, rather than weakens, this project's +existing "an identity merge must never auto-commit silently" rule: ambiguity is +exactly the case that still needs a person. + +## The photo is an audit trail, not face recognition + +`POST /register/photo` stores whatever the calling kiosk's camera captured at +registration time, purely as a **"who did this, when" reference photo** — it is +**never run through any face-matching or biometric pipeline**. Building that would +mean either standing up a new ML pipeline from scratch or wiring this device's camera +into Frigate's existing face recognition (Phase 5) as a second camera source — both +real, both out of scope for this pass. BLE/IRK resolution, not the camera, is what +actually decides who's registering. **If you want camera-based identity later, +Frigate's own face-recognition + enrollment (0.16+) is the piece to wire in, not a +new pipeline here.** + +## People without a device + +Two paths, distinct on purpose because they solve different problems: + +- **A known person with no device** (the grandmother case) — `POST /register` with + `"no_device": true` and a real name. Skips candidate lookup entirely; creates the + person (or reuses them by name, same dedup as the normal path) with **zero** + identifiers. On the touchscreen this is the "I don't have a phone or tag" checkbox + next to the name field; there's no voice phrasing for it yet (a boolean flag + doesn't fit the single-utterance design cleanly — say it on the touchscreen for + now). The long-term fix for this exact case is a physical fixed-address BLE tag + (docs/components.md's "Fixed BLE tags" line) so they *do* get automatic presence + eventually — this flag is what makes the household not have to wait for that + before the person exists in the system at all. +- **Someone the system doesn't need to identify** (a one-off guest) — `POST + /register/guest`, no name, no device. Always creates a new record ("Guest 1", + "Guest 2", ...; never reused/deduped, unlike named people) — the touchscreen's + "Add a guest" button. `DELETE /people/` cleans up a stale one afterwards. + +Neither path can ever resolve automatic presence (there's no identifier to check a +state on) — that's what `POST /presence/manual` (`{"person_id", "home"}`) is for: a +hand-operated Home/Away toggle, surfaced directly on `hosts/door-panel/`'s dashboard +next to anyone with `has_device: false` in `/presence`'s response. Until it's tapped +at least once, `/presence` reports `home: null` ("unknown") for that person — **never +`false`**, since defaulting a device-less person to "away" would be actively wrong +the moment they're actually sitting in the next room, not just uninformative. + +## Floor-plan groundwork (not the floor plan itself) + +`/presence` also reports a best-effort `room` per person (`server.py`'s +`AREA_ATTRIBUTE`, default `area_id`) — read from whichever HA area/room attribute +your room-presence integration (Bermuda) attaches to a trusted entity's state, so a +future floor-plan UI has live room-level data to plot without this service changing +again. **The floor plan itself — an image, a room<->coordinate mapping, any +rendering — is deliberately not built here.** There's no floor plan or fixed room +list to design a coordinate format against yet; building one now would be guessing, +not engineering. `AREA_ATTRIBUTE`'s exact name is also a guess — verify it against a +real Bermuda-tracked entity's attributes (Developer Tools -> States) before relying +on `room` being populated at all; it degrades to `null` if missing, never breaks the +response. + +## Voice: single-utterance, not multi-turn + +The whole flow is designed around one spoken sentence: **"register me as ``"** +— not a multi-turn conversation ("what's your name?" / *reply* / "confirm?"). This is +deliberate: HA Assist's multi-turn/continue-conversation support is newer and more +version-sensitive than a single custom-sentence intent with a captured `{name}` slot, +and a one-shot command is materially more robust to build against. The tradeoff is +explicit up front: say your name in the same breath as the command, or use the +touchscreen's own form instead. + +**Nothing under this repo builds the HA-side wiring** — same convention as +digest-engine's/admin-canvas's HA integration points. You need, in Home Assistant's +own config: + +```yaml +# configuration.yaml (excerpt) — a custom sentence + intent script that calls this +# service's /register endpoint. VERIFY against your own HA version; this is a worked +# example, not a tested one. +intent_script: + RegisterPerson: + speech: + text: "{{ message }}" + action: + - service: rest_command.identity_register + data: + name: "{{ name }}" + device_id: "{{ trigger.device_id | default('unknown') }}" + response_variable: reg_result + - variables: + message: "{{ reg_result.content.message }}" + +rest_command: + identity_register: + url: "http://:8097/register" + method: POST + headers: + Authorization: "Bearer !secret identity_token" + Content-Type: "application/json" + payload: '{"name": "{{ name }}", "device_id": "{{ device_id }}"}' +``` + +Plus a custom sentence file (`custom_sentences/en/register.yaml`) mapping +`"register me as {name}"` to the `RegisterPerson` intent — see +[HA's custom sentences docs](https://www.home-assistant.io/voice_control/custom_sentences/). +The same `IDENTITY_TOKEN` from `identity.env` has to be pasted into HA's `secrets.yaml` +by hand; there's no way for this repo to push it there for you. + +## Configure + +```sh +cp identity/identity.env.example /opt/smart-home/identity/identity.env +openssl rand -hex 32 # IDENTITY_TOKEN +chmod 600 /opt/smart-home/identity/identity.env +$EDITOR /opt/smart-home/identity/identity.env +``` + +Two things that must be filled in with real values before this does anything useful: + +- **`HA_TOKEN`** — a Long-Lived Access Token from HA's own UI (profile -> Security). +- **`TRUSTED_ENTITY_PREFIXES`** — the actual `entity_id` prefixes your Private BLE + Device / fixed-tag setup produces. The shipped default + (`device_tracker.pble_,device_tracker.bletag_`) is a plausible guess, **not + confirmed against a real HA instance** — check Developer Tools -> States yourself. + +## API + +All endpoints are bearer-token gated (`Authorization: Bearer `), +including the GETs — same reasoning as `pantry-vision`: this service has a published +port because kiosk browsers call it directly, so the token is the actual boundary, +not network placement. + +| Endpoint | What it does | +|---|---| +| `POST /register/photo` | raw image bytes -> `{"photo_id": "..."}` — an audit artifact, and also becomes the person's profile picture (see below) | +| `POST /register` | `{"name", "device_id", "photo_id"?, "entity_id"?, "no_device"?}` -> registers, or returns a reason it couldn't (see above) | +| `POST /register/guest` | `{"device_id", "photo_id"?}` -> registers "Guest N", no name needed | +| `GET /people` | admin/audit list of every registered person + their identifiers + `has_photo` | +| `GET /people//photo` | the person's profile picture (raw JPEG) — their most recent registration photo | +| `DELETE /people//identifiers/` | revoke a mistaken or compromised identifier | +| `DELETE /people/` | remove a person entirely (their identifiers go with them) — mainly for cleaning up stale Guest records | +| `POST /presence/manual` | `{"person_id", "home"}` — hand-operated Home/Away for anyone with no identifiers | +| `GET /presence` | `{"people": [{"id", "name", "home", "room", "has_device", "has_photo"}], "generated_at"}` — `home` is `true`/`false`/`null` (unknown), `room` is best-effort floor-plan groundwork (see below) | +| `GET /weather` | proxies `smarthome/weather/current`, same JSON shape (`temperature`/`condition`/`location`) `hosts/thin-client`'s weather overlay already uses | + +**Every person gets a profile picture, automatically** — whichever registration photo +was captured most recently for them (`_set_profile_photo()` in `server.py`), no +separate upload step. A device-less registration or a flaky camera just means no +photo yet, not a missing feature — both frontends fall back to a plain circular +placeholder (`👤`) until one exists. Fetched via `GET /people//photo` with a blob ++ `createObjectURL()` on the frontend side, not a bare `` — the +endpoint is bearer-token gated like everything else here, and a plain `` tag has +no way to send an `Authorization` header. + +## Manual verification still outstanding + +1. `TRUSTED_ENTITY_PREFIXES`' defaults are guessed, not confirmed against a real + Private BLE Device / Bermuda setup — the single biggest thing to check before + trusting registration at all. +2. The worked `intent_script`/`rest_command`/custom-sentence YAML above is written + against HA's documented shape, not tested against a running HA instance. +3. `PRESENT_STATES = {"home"}` assumes Private BLE Device's `device_tracker` entities + use the standard `home`/`not_home` vocabulary — check yours actually does. +4. The `>1 candidate` (ambiguous) and `already_claimed` (conflict) paths are + logically covered but never exercised against two real phones in the same room. +5. Multi-device dedup relies on exact-name case-insensitive matching + (`WHERE name = ? COLLATE NOCASE`) — two different people who happen to share a + first name would collide into one record. Register with full names if that's a + real risk in your household; nothing here disambiguates same-name people. +6. SQLite at `/data/identity.db` has no backup wiring yet — if `ENABLE_BACKUPS` is on + in `setup-container-host.sh`, confirm `/opt/smart-home/identity` is actually + covered by whatever paths restic is pointed at (photos under `/data/photos` too — + losing them just loses profile pictures, not the person records themselves, but + still worth covering). +7. `AREA_ATTRIBUTE`'s default (`area_id`) is a guess at what Bermuda actually + attaches to a trusted entity's state — unconfirmed, and the whole `room` field in + `/presence` degrades to `null` silently if it's wrong, so this could easily go + unnoticed until someone builds the actual floor-plan UI and finds it empty. +8. The blob+`createObjectURL()` profile-picture fetch (both frontends) has not been + checked for a memory leak from never calling `URL.revokeObjectURL()` on the old + blob URL when `/people`/`/presence` refreshes and re-fetches the same photo — + likely fine at household scale and dashboard.js's 60s poll cadence, not measured + over a multi-day uptime. diff --git a/identity/frontend/dashboard.html b/identity/frontend/dashboard.html new file mode 100644 index 0000000..81b967b --- /dev/null +++ b/identity/frontend/dashboard.html @@ -0,0 +1,45 @@ + + + + + +Home + + + + +
+
+

Weather

+
+ -- +
+ +
+ +
+

Who's home

+
+

Loading…

+
+
+ +
+

Running low

+
+

Loading…

+
+
+
+ + + + diff --git a/identity/frontend/dashboard.js b/identity/frontend/dashboard.js new file mode 100644 index 0000000..f12473c --- /dev/null +++ b/identity/frontend/dashboard.js @@ -0,0 +1,206 @@ +// hosts/door-panel/'s dashboard logic. Vanilla JS, no framework, no build step — +// same "vendored, dependency-free" choice as every other kiosk frontend in this +// project. See dashboard.html's top comment for the two-backend split. +"use strict"; + +const params = new URLSearchParams(location.search); +const IDENTITY_API = (params.get("identity_api") || "").replace(/\/$/, ""); +const IDENTITY_TOKEN = params.get("identity_token") || ""; +const PANTRY_API = (params.get("pantry_api") || "").replace(/\/$/, ""); +const PANTRY_TOKEN = params.get("pantry_token") || ""; + +if (!IDENTITY_API || !IDENTITY_TOKEN) { + document.body.innerHTML = + '

identity not configured — missing ' + + "?identity_api=&identity_token= in the URL.

"; + throw new Error("dashboard: missing identity query params"); +} + +function identityApi(path) { + return fetch(`${IDENTITY_API}${path}`, { headers: { Authorization: `Bearer ${IDENTITY_TOKEN}` } }).then((res) => { + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return res.json(); + }); +} + +function pantryApi(path) { + return fetch(`${PANTRY_API}${path}`, { headers: { Authorization: `Bearer ${PANTRY_TOKEN}` } }).then((res) => { + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return res.json(); + }); +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} + +// --- Weather + clothing -------------------------------------------------------- +// A plain lookup table, not an LLM call — this is a deterministic-enough problem +// (temperature + a couple of condition keywords) that a round trip to Ollama would +// only add latency and a failure mode for no real gain. See ../README.md. +function clothingSuggestion(tempC, condition) { + const cond = (condition || "").toLowerCase(); + const layers = + tempC === null + ? null + : tempC < 0 + ? "Heavy coat, hat, gloves" + : tempC < 10 + ? "Warm coat or jacket" + : tempC < 16 + ? "Light jacket or sweater" + : tempC < 21 + ? "Light layers" + : "T-shirt weather"; + const wet = /rain|drizzle|shower|storm|snow|sleet/.test(cond) ? " — bring an umbrella/waterproofs" : ""; + return layers ? layers + wet : null; +} + +function loadWeather() { + identityApi("/weather") + .then((data) => { + const hero = document.getElementById("weather-hero"); + const suggestion = document.getElementById("clothing-suggestion"); + if (!data.available) { + hero.innerHTML = 'No weather data yet'; + suggestion.hidden = true; + return; + } + hero.innerHTML = + `${escapeHtml(data.temperature || "")}` + + `${escapeHtml(data.condition || "")}`; + + const match = /(-?\d+(\.\d+)?)/.exec(data.temperature || ""); + const tempC = match ? parseFloat(match[1]) : null; + const text = clothingSuggestion(tempC, data.condition); + if (text) { + suggestion.textContent = text; + suggestion.hidden = false; + } else { + suggestion.hidden = true; + } + }) + .catch(() => { + document.getElementById("weather-hero").innerHTML = 'Weather unavailable'; + }); +} + +// --- Who's home ------------------------------------------------------------------ +// Three states, not two: home / away / unknown. "Unknown" covers both an +// unreachable HA and a device-less person (grandmother, a guest) who was never +// manually marked either way — see identity/server.py's presence() docstring for +// why defaulting that case to "away" would be actively wrong, not just vague. +function statusLabel(p) { + if (p.home === true) return p.room ? `Home — ${p.room}` : "Home"; + if (p.home === false) return "Away"; + return "Unknown"; +} + +function statusClass(p) { + if (p.home === true) return "status-home"; + if (p.home === false) return "status-away"; + return ""; +} + +function markPresenceRequest(personId, home) { + return fetch(`${IDENTITY_API}/presence/manual`, { + method: "POST", + headers: { Authorization: `Bearer ${IDENTITY_TOKEN}`, "Content-Type": "application/json" }, + body: JSON.stringify({ person_id: personId, home }), + }).then(loadPresence); +} + +// Same blob-fetch approach as register.js's loadAvatar — see that file's comment +// for why a plain can't be used against a bearer-token-gated endpoint. +function loadAvatar(imgContainer, personId) { + fetch(`${IDENTITY_API}/people/${personId}/photo`, { headers: { Authorization: `Bearer ${IDENTITY_TOKEN}` } }) + .then((res) => (res.ok ? res.blob() : Promise.reject())) + .then((blob) => { + const img = document.createElement("img"); + img.src = URL.createObjectURL(blob); + imgContainer.replaceChildren(img); + }) + .catch(() => {}); +} + +function loadPresence() { + const el = document.getElementById("presence-list"); + identityApi("/presence") + .then((data) => { + const people = data.people || []; + if (!people.length) { + el.innerHTML = '

Nobody registered yet.

'; + return; + } + el.innerHTML = people + .map((p) => { + // Device-less people (has_device === false) get manual Home/Away buttons + // right here — the only way their presence is ever set, since there's no + // phone to track. See identity/README.md's "no device" section. + const manualButtons = p.has_device + ? "" + : ` + `; + return `
+ 👤 + ${escapeHtml(p.name)} + ${escapeHtml(statusLabel(p))} + ${manualButtons} +
`; + }) + .join(""); + el.querySelectorAll("[data-mark]").forEach((btn) => { + btn.addEventListener("click", () => markPresenceRequest(Number(btn.dataset.mark), btn.dataset.home === "1")); + }); + people.forEach((p) => { + if (p.has_photo) { + const avatarEl = el.querySelector(`.avatar[data-person="${p.id}"]`); + if (avatarEl) loadAvatar(avatarEl, p.id); + } + }); + }) + .catch((err) => { + el.innerHTML = `

Could not load presence: ${escapeHtml(err.message)}

`; + }); +} + +// --- Running low ----------------------------------------------------------------- +function loadShoppingList() { + const el = document.getElementById("shopping-list"); + if (!PANTRY_API || !PANTRY_TOKEN) { + el.innerHTML = '

pantry-vision not configured for this display.

'; + return; + } + pantryApi("/shopping-list") + .then((data) => { + const items = data.items || []; + if (!items.length) { + el.innerHTML = '

Nothing running low.

'; + return; + } + el.innerHTML = items + .map( + (item) => + `
+ ${escapeHtml(item.name)} + need ${escapeHtml(String(item.amount_missing ?? ""))} +
` + ) + .join(""); + }) + .catch((err) => { + el.innerHTML = `

Could not load shopping list: ${escapeHtml(err.message)}

`; + }); +} + +function refreshAll() { + loadWeather(); + loadPresence(); + loadShoppingList(); +} + +refreshAll(); +// An ambient always-on display, not an interactive app — poll rather than needing a +// tap to refresh. 60s matches the weather MQTT topic's own realistic update cadence +// (an HA automation on a state-change trigger, not a fast poll itself). +setInterval(refreshAll, 60000); diff --git a/identity/frontend/register.html b/identity/frontend/register.html new file mode 100644 index 0000000..3041d39 --- /dev/null +++ b/identity/frontend/register.html @@ -0,0 +1,65 @@ + + + + + +Register + + + + +
+
+

Register

+
+ + + +
+ +
+ + + +

+
+ + + +
+ +

+
+
+ +
+

Already registered

+
+

Loading…

+
+
+
+ + + + diff --git a/identity/frontend/register.js b/identity/frontend/register.js new file mode 100644 index 0000000..61b063a --- /dev/null +++ b/identity/frontend/register.js @@ -0,0 +1,202 @@ +// identity's registration page logic. Vanilla JS, no framework — see +// register.html's top comment for why this is the fallback path, not the primary +// (voice) one. +"use strict"; + +const params = new URLSearchParams(location.search); +const API = (params.get("api") || "").replace(/\/$/, ""); +const TOKEN = params.get("token") || ""; +const DEVICE_ID = params.get("device") || "unknown"; + +if (!API || !TOKEN) { + document.body.innerHTML = + '

identity not configured — missing ' + + "?api=&token= in the URL.

"; + throw new Error("identity: missing ?api=/&token= query params"); +} + +function api(path, options) { + options = options || {}; + options.headers = Object.assign({ Authorization: `Bearer ${TOKEN}` }, options.headers || {}); + return fetch(`${API}${path}`, options).then((res) => + res.json().then((body) => { + if (!res.ok && res.status !== 409) throw new Error(body.error || `${res.status} ${res.statusText}`); + return body; + }) + ); +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} + +// --- Camera (best-effort — registration still works with a text name + voice-found +// candidate even if the camera fails; the photo is an audit artifact, never load- +// bearing for the actual identity decision, see ../README.md) ----------------- +const video = document.getElementById("camera-preview"); +const cameraError = document.getElementById("camera-error"); +let stream = null; + +navigator.mediaDevices + ?.getUserMedia({ video: { facingMode: "user" }, audio: false }) + .then((s) => { + stream = s; + video.srcObject = s; + }) + .catch((err) => { + cameraError.textContent = `Camera unavailable: ${err.message} (registration still works without it)`; + cameraError.hidden = false; + }); + +function capturePhoto() { + if (!stream) return Promise.resolve(null); + const canvas = document.getElementById("captured-frame"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.getContext("2d").drawImage(video, 0, 0); + return new Promise((resolve) => { + canvas.toBlob( + (blob) => { + if (!blob) return resolve(null); + api("/register/photo", { method: "POST", body: blob, headers: { "Content-Type": "image/jpeg" } }) + .then((r) => resolve(r.photo_id || null)) + .catch(() => resolve(null)); + }, + "image/jpeg", + 0.85 + ); + }); +} + +// --- Registration ------------------------------------------------------------ +const form = document.getElementById("register-form"); +const status = document.getElementById("register-status"); +const picker = document.getElementById("candidate-picker"); +const candidateList = document.getElementById("candidate-list"); + +function attemptRegister(name, entityId, noDevice) { + status.textContent = "Registering…"; + picker.hidden = true; + return capturePhoto().then((photoId) => + api("/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + device_id: DEVICE_ID, + photo_id: photoId, + entity_id: entityId, + no_device: !!noDevice, + }), + }) + ).then((result) => { + if (result.ok) { + status.textContent = result.message; + form.reset(); + loadPeople(); + return; + } + if (result.reason === "ambiguous") { + status.textContent = result.message; + candidateList.innerHTML = result.candidates + .map( + (c) => + `` + ) + .join(""); + picker.hidden = false; + candidateList.querySelectorAll("[data-entity]").forEach((btn) => { + btn.addEventListener("click", () => attemptRegister(name, btn.dataset.entity, false)); + }); + return; + } + status.textContent = result.message || "Could not register."; + }).catch((err) => { + status.textContent = `Error: ${err.message}`; + }); +} + +form.addEventListener("submit", (event) => { + event.preventDefault(); + const name = document.getElementById("f-name").value.trim(); + const noDevice = document.getElementById("f-no-device").checked; + if (name) attemptRegister(name, null, noDevice); +}); + +// --- Guest (no name, no device — see identity/README.md) ----------------------- +const guestBtn = document.getElementById("guest-btn"); +const guestStatus = document.getElementById("guest-status"); + +guestBtn.addEventListener("click", () => { + guestStatus.textContent = "Adding guest…"; + capturePhoto() + .then((photoId) => + api("/register/guest", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ device_id: DEVICE_ID, photo_id: photoId }), + }) + ) + .then((result) => { + guestStatus.textContent = result.message; + loadPeople(); + }) + .catch((err) => { + guestStatus.textContent = `Error: ${err.message}`; + }); +}); + +// --- Profile pictures ---------------------------------------------------------- +// Fetched via blob, not a plain , because every identity endpoint +// (including this one) requires an Authorization header — an tag has no way +// to send one, so a bare src= URL would just 401. See identity/README.md for why +// every registration also updates the person's profile photo to whatever the +// kiosk's camera last captured. +function loadAvatar(imgContainer, personId) { + fetch(`${API}/people/${personId}/photo`, { headers: { Authorization: `Bearer ${TOKEN}` } }) + .then((res) => (res.ok ? res.blob() : Promise.reject())) + .then((blob) => { + const img = document.createElement("img"); + img.src = URL.createObjectURL(blob); + imgContainer.replaceChildren(img); + }) + .catch(() => { + /* no photo — leave the plain circle placeholder */ + }); +} + +// --- Already-registered list --------------------------------------------------- +function loadPeople() { + const el = document.getElementById("people-list"); + api("/people") + .then((data) => { + const people = data.people || []; + if (!people.length) { + el.innerHTML = '

Nobody registered yet.

'; + return; + } + el.innerHTML = people + .map( + (p) => + `
+ 👤 + ${escapeHtml(p.name)} + ${p.identifiers.length} device${p.identifiers.length === 1 ? "" : "s"} +
` + ) + .join(""); + people.forEach((p) => { + if (p.has_photo) { + const el2 = el.querySelector(`.avatar[data-person="${p.id}"]`); + if (el2) loadAvatar(el2, p.id); + } + }); + }) + .catch((err) => { + el.innerHTML = `

Could not load: ${escapeHtml(err.message)}

`; + }); +} + +loadPeople(); diff --git a/identity/frontend/style.css b/identity/frontend/style.css new file mode 100644 index 0000000..65f8721 --- /dev/null +++ b/identity/frontend/style.css @@ -0,0 +1,196 @@ +/* Shared styling for identity's kiosk pages (register.html, dashboard.html). + * Same dark, big-touch-target theme as pantry-vision/frontend/style.css — these are + * sibling kiosk frontends and should read as one system. */ + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + height: 100%; + background: #101014; + color: #e8e8ec; + font-family: sans-serif; + overflow: hidden; +} + +#tabs { + display: flex; + height: 84px; + background: rgba(16, 16, 20, 0.96); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.tab { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + background: transparent; + border: none; + color: #9a9aa6; + font-size: 26px; +} + +.tab span { + font-size: 13px; + font-weight: 500; +} + +.tab.active { + color: #e8e8ec; + background: rgba(110, 168, 254, 0.16); +} + +main { + height: calc(100% - 84px); + overflow-y: auto; + padding: 16px; +} + +main.no-tabs { + height: 100%; +} + +.panel { + display: none; +} + +.panel.active { + display: block; +} + +.big-btn { + min-height: 64px; + min-width: 200px; + font-size: 18px; + font-weight: 600; + border-radius: 12px; + border: none; + color: #101014; + background: #6ea8fe; +} + +.big-btn.secondary { + background: rgba(255, 255, 255, 0.14); + color: #e8e8ec; +} + +.hint { + color: #9a9aa6; + font-size: 14px; +} + +.error { + color: #ff8080; + font-size: 14px; +} + +.card-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.card { + display: flex; + align-items: center; + gap: 12px; + min-height: 56px; + padding: 8px 16px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.06); +} + +.card-name { + flex: 1; + font-size: 16px; + font-weight: 500; +} + +.avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.12); + flex-shrink: 0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.card-meta { + font-size: 14px; + color: #9a9aa6; +} + +.status-home { + color: #7cf0a0; +} + +.status-away { + color: #9a9aa6; +} + +.urgent-week { + background: rgba(255, 200, 80, 0.10); +} +.urgent-week .card-meta { + color: #ffc850; +} + +.urgent-soon { + background: rgba(255, 140, 80, 0.14); +} +.urgent-soon .card-meta { + color: #ff8c50; +} + +section.block { + margin-bottom: 24px; +} + +section.block h2 { + font-size: 15px; + font-weight: 600; + color: #9a9aa6; + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 0 0 10px; +} + +.weather-hero { + display: flex; + align-items: baseline; + gap: 16px; + margin-bottom: 6px; +} + +.weather-temp { + font-size: 48px; + font-weight: 700; +} + +.weather-condition { + font-size: 18px; + color: #9a9aa6; +} + +.clothing-suggestion { + font-size: 16px; + padding: 10px 14px; + border-radius: 10px; + background: rgba(110, 168, 254, 0.14); + display: inline-block; +} diff --git a/identity/identity.env.example b/identity/identity.env.example new file mode 100644 index 0000000..1305d2e --- /dev/null +++ b/identity/identity.env.example @@ -0,0 +1,58 @@ +# identity configuration template. +# +# Copy this to the container host as (for example) +# /opt/smart-home/identity/identity.env, fill in real values, and chmod 600 it. +# Same never-commit handling as admin-canvas.env / pantry-vision.env. + +# --------------------------------------------------------------------------- +# Auth — required. identity fails closed (rejects every request) while this is +# empty. Also has to be baked into every kiosk that calls this service directly +# (hosts/kitchen-display/, hosts/door-panel/), same reasoning as +# PANTRY_VISION_TOKEN. Generate one with: +# openssl rand -hex 32 +# --------------------------------------------------------------------------- +IDENTITY_TOKEN= + +# --------------------------------------------------------------------------- +# Home Assistant — required for registration and presence lookups. +# +# NOT http://homeassistant:8123 — the homeassistant container runs with +# `network_mode: host` (setup-container-host.sh), so it's off the compose bridge +# network entirely and unreachable by container name from here, the same reason +# Node-RED's own setup notes point at the host's real LAN IP instead. Use that IP. +# +# HA_TOKEN is a Long-Lived Access Token: HA's own UI, click your profile (bottom +# left) -> Security tab -> Long-Lived Access Tokens -> Create Token. There is no +# way for this repo to generate or push this for you. +# --------------------------------------------------------------------------- +HA_URL=http://192.168.1.10:8123 +HA_TOKEN= + +# --------------------------------------------------------------------------- +# Registration candidate allowlist — THE anti-spoofing boundary. Comma-separated +# entity_id PREFIXES. MUST be edited to match your real HA entity IDs (Developer +# Tools -> States, after setting up Bermuda's Private BLE Device integration and/or +# fixed-MAC BLE tags per docs/project-plan.md §1.5) before registration will ever +# find a candidate. Only put IRK-resolved Private BLE Device entities or fixed-tag +# entities here — NEVER a raw bluetooth_le_tracker/device_tracker entity backed by +# an unresolved randomized MAC. See server.py's module docstring for why. +# --------------------------------------------------------------------------- +TRUSTED_ENTITY_PREFIXES=device_tracker.pble_,device_tracker.bletag_ + +# --------------------------------------------------------------------------- +# MQTT — for the /weather proxy only (smarthome/weather/current, the same +# household-wide topic hosts/thin-client's idle-gallery overlay already reads). +# --------------------------------------------------------------------------- +MQTT_BROKER_HOST=mosquitto +MQTT_BROKER_PORT=1883 +MQTT_USERNAME= +MQTT_PASSWORD= + +# --------------------------------------------------------------------------- +# Run behaviour +# --------------------------------------------------------------------------- +IDENTITY_PORT=8097 +IDENTITY_DB_PATH=/data/identity.db +IDENTITY_PHOTO_DIR=/data/photos +IDENTITY_MAX_IMAGE_MB=15 +LOG_LEVEL=INFO diff --git a/identity/requirements.txt b/identity/requirements.txt new file mode 100644 index 0000000..a6f1f90 --- /dev/null +++ b/identity/requirements.txt @@ -0,0 +1 @@ +paho-mqtt>=1.6 diff --git a/identity/server.py b/identity/server.py new file mode 100755 index 0000000..b8a0573 --- /dev/null +++ b/identity/server.py @@ -0,0 +1,786 @@ +"""identity — the household's person <-> BLE-identifier registry, from +docs/project-plan.md Phase 6. + +Solves two problems, both from the same design: a person owning **multiple phones** +(private + work) and defending registration against **MAC spoofing/randomization**. + +THE MODEL: a person has zero or more *identifiers*. An identifier is a Home Assistant +entity_id that resolves presence for one physical device. Multi-phone support falls +out of this for free — the same person just accumulates a second identifier the +second time they register with their other phone in hand. + +ANTI-SPOOFING, the actual point of this module: a raw Bluetooth MAC address — +especially a randomized one, which is the default on iOS/Android — is never accepted +as an identifier by itself. Only entity_ids matching `TRUSTED_ENTITY_PREFIXES` are +eligible candidates at registration time, and that allowlist is meant to contain only +HA's own IRK-resolved Private BLE Device entities (survive MAC rotation because HA +resolved them cryptographically, not by matching a MAC string) and manually +provisioned fixed-MAC tag entities. An attacker spoofing an arbitrary advertised MAC +never produces a trusted candidate; spoofing a specific person's *resolved* identity +would require their device's actual IRK secret, a materially harder bar. This is a +defense against passive/opportunistic spoofing, not a claim of cryptographic +non-repudiation — see README.md's threat-model section for the honest boundary. + +NEVER AUTO-COMMIT ON AMBIGUITY: if registration finds zero, more than one, or an +already-claimed candidate, nothing is written — the caller gets back a reason and (for +the ambiguous case) the candidate list, and a human has to disambiguate via the +touchscreen (POST /register again with an explicit entity_id). The one case that DOES +commit in a single call is the clean one (exactly one trusted, unclaimed candidate), +because the spoken "register me as " command *is* the human confirmation — +requiring a second round-trip for the unambiguous case would just be friction for no +safety benefit. This mirrors, rather than weakens, this project's existing "an +identity merge must never auto-commit silently" rule (docs/project-plan.md's Identity +store row): ambiguity is exactly the case that still requires a human. + +Five endpoints: + - POST /register/photo raw image bytes -> stored as an audit artifact only, + NOT run through any face-matching (see README.md) + - POST /register the main call, described above + - GET /people admin/audit list of registered people + identifiers + - DELETE /people//identifiers/ revoke a mistaken/compromised identifier + - GET /presence who's currently home, resolved from registered identifiers + - GET /weather proxies the household smarthome/weather/current MQTT topic +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import sqlite3 +import sys +import threading +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlsplit + +import paho.mqtt.client as mqtt + +LOG = logging.getLogger("identity") + +TOKEN = os.environ.get("IDENTITY_TOKEN", "") + +DB_PATH = Path(os.environ.get("IDENTITY_DB_PATH", "/data/identity.db")) +PHOTO_DIR = Path(os.environ.get("IDENTITY_PHOTO_DIR", "/data/photos")) + +HA_URL = os.environ.get("HA_URL", "http://homeassistant:8123").rstrip("/") +HA_TOKEN = os.environ.get("HA_TOKEN", "") + +# Comma-separated entity_id PREFIXES that are trusted as registration candidates. +# MUST be edited to match your real HA entity IDs (Developer Tools -> States) — these +# defaults are plausible-looking placeholders, not confirmed against a real HA +# instance. Only put Private BLE Device / fixed-tag entities here — never a raw +# bluetooth_le_tracker/device_tracker entity backed by an unresolved random MAC. +TRUSTED_ENTITY_PREFIXES = tuple( + p.strip() for p in os.environ.get( + "TRUSTED_ENTITY_PREFIXES", "device_tracker.pble_,device_tracker.bletag_" + ).split(",") if p.strip() +) + +# HA states considered "this device is present/nearby right now." device_tracker's +# own vocabulary is home/not_home; Bermuda-style room-level sensors may report a room +# name instead — PRESENT_STATES intentionally does not try to guess a room-level +# state string, since which room a device is in isn't needed for registration, only +# whether it's near the registering endpoint at all. Extend this list if your trusted +# entities use different state values (VERIFY against your instance). +PRESENT_STATES = {"home"} + +# Floor-plan groundwork (docs/project-plan.md Phase 6 open decisions): the attribute +# key Bermuda (or whatever room-presence integration you use) attaches to a trusted +# entity's state to say which room/Area it's currently closest to — VERIFY the exact +# name against your own instance's Developer Tools -> States (Bermuda's own area- +# reporting attribute name has not been confirmed here; this is a plausible guess, +# same honesty rule as TRUSTED_ENTITY_PREFIXES). presence() reads this opportunistically +# and degrades to `room: null` if it's missing or unset, never breaking the payload — +# this is what lets a future floor-plan UI show "which room," not just "home/away," +# without this service needing to change again once that UI actually gets built. The +# rendering itself (a floor-plan image, room<->coordinate mapping) is deliberately NOT +# built here — same "don't build against a guess" rule as everywhere else in this +# project; there's no floor plan or room list to design against yet. +AREA_ATTRIBUTE = os.environ.get("AREA_ATTRIBUTE", "area_id") + +MAX_IMAGE_BYTES = int(os.environ.get("IDENTITY_MAX_IMAGE_MB", "15")) * 1024 * 1024 +MAX_JSON_BYTES = 32 * 1024 + +NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 '.-]{0,63}$") +PHOTO_ID_RE = re.compile(r"^[a-f0-9]{16}$") + +_db_lock = threading.Lock() + +_weather_lock = threading.Lock() +_last_weather: dict = {"available": False} + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _db() -> sqlite3.Connection: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH, timeout=10) + conn.execute("PRAGMA foreign_keys = ON") + conn.row_factory = sqlite3.Row + return conn + + +def init_db() -> None: + with _db_lock, _db() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS people ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + created_at TEXT NOT NULL, + photo_path TEXT + ); + CREATE TABLE IF NOT EXISTS identifiers ( + id INTEGER PRIMARY KEY, + person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE, + ha_entity_id TEXT NOT NULL UNIQUE, + registered_at TEXT NOT NULL, + registered_via_device TEXT + ); + CREATE TABLE IF NOT EXISTS registration_events ( + id INTEGER PRIMARY KEY, + person_id INTEGER, + device_id TEXT, + photo_path TEXT, + outcome TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS manual_presence ( + person_id INTEGER PRIMARY KEY REFERENCES people(id) ON DELETE CASCADE, + home INTEGER NOT NULL, + updated_at TEXT NOT NULL + ); + """ + ) + + +def _ha_get(path: str): + if not HA_TOKEN: + raise RuntimeError("HA_TOKEN is not configured") + req = urllib.request.Request(f"{HA_URL}{path}") + req.add_header("Authorization", f"Bearer {HA_TOKEN}") + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read()) + + +def _trusted_present_candidates() -> list[dict]: + """Entity_ids matching TRUSTED_ENTITY_PREFIXES whose current HA state indicates + presence right now. This is the ONLY source of registration candidates — see the + module docstring for why raw/unresolved MACs never reach this list. + """ + states = _ha_get("/api/states") + candidates = [] + for entity in states: + entity_id = entity.get("entity_id", "") + if not entity_id.startswith(TRUSTED_ENTITY_PREFIXES): + continue + if entity.get("state") not in PRESENT_STATES: + continue + candidates.append( + { + "entity_id": entity_id, + "friendly_name": (entity.get("attributes") or {}).get("friendly_name", entity_id), + } + ) + return candidates + + +def _already_claimed_by(conn: sqlite3.Connection, entity_id: str) -> sqlite3.Row | None: + return conn.execute( + """ + SELECT people.id, people.name FROM identifiers + JOIN people ON people.id = identifiers.person_id + WHERE identifiers.ha_entity_id = ? + """, + (entity_id,), + ).fetchone() + + +def _find_or_create_person(conn: sqlite3.Connection, name: str) -> tuple[int, bool]: + row = conn.execute("SELECT id FROM people WHERE name = ? COLLATE NOCASE", (name,)).fetchone() + if row: + return row["id"], False + cur = conn.execute("INSERT INTO people (name, created_at) VALUES (?, ?)", (name, _now())) + assert cur.lastrowid is not None + return cur.lastrowid, True + + +def _set_profile_photo(conn: sqlite3.Connection, person_id: int, photo_path: str | None) -> None: + """Every successful registration that captured a photo updates the person's + profile picture to it — "most recent registration photo wins" rather than + "first one wins," a simple, defensible default with no extra UI needed to pick + one. A no-op if this call didn't have a photo (camera unavailable, etc.) — + doesn't clear an existing profile photo just because a later re-registration + happened to skip the camera. + """ + if photo_path is None: + return + conn.execute("UPDATE people SET photo_path = ? WHERE id = ?", (photo_path, person_id)) + + +def register( + name: str, device_id: str, photo_path: str | None, forced_entity_id: str | None, no_device: bool = False +) -> dict: + name = name.strip() + if not NAME_RE.match(name): + return {"ok": False, "reason": "bad_name", "message": "That name doesn't look valid."} + + # THE "MY GRANDMOTHER DOESN'T HAVE A PHONE" CASE: registration doesn't have to + # find a device at all. A person with zero identifiers is a fully valid record — + # just one /presence can never resolve automatically (see presence()'s "unknown", + # not "away", handling below, and set_manual_presence() for the hand-operated + # alternative). This is an explicit opt-in flag, not a fallback for "no candidate + # found" — those stay separate cases (a real phone that just isn't nearby right + # now is a different situation from someone who was never going to have one). + if no_device: + with _db_lock, _db() as conn: + person_id, is_new_person = _find_or_create_person(conn, name) + _set_profile_photo(conn, person_id, photo_path) + _log_event(conn, person_id, device_id, photo_path, "registered_no_device") + return { + "ok": True, + "person_id": person_id, + "person_name": name, + "entity_id": None, + "is_new_person": is_new_person, + "is_new_device": False, + "message": ( + f"Welcome, {name}! You're registered without a device — " + "use the door panel to mark yourself home or away by hand." + ) if is_new_person else f"{name} is already registered.", + } + + try: + candidates = _trusted_present_candidates() + except (urllib.error.URLError, urllib.error.HTTPError, RuntimeError) as exc: + LOG.warning("identity: could not reach HA for candidates", exc_info=True) + return {"ok": False, "reason": "ha_unreachable", "message": f"Could not reach Home Assistant: {exc}"} + + with _db_lock, _db() as conn: + if forced_entity_id: + matches = [c for c in candidates if c["entity_id"] == forced_entity_id] + if not matches: + return { + "ok": False, + "reason": "not_a_candidate", + "message": "That device isn't showing as nearby right now.", + } + chosen = matches[0] + elif len(candidates) == 0: + _log_event(conn, None, device_id, photo_path, "no_candidate") + return { + "ok": False, + "reason": "no_candidate", + "message": "I couldn't find your phone nearby — make sure Bluetooth is on and try again.", + } + elif len(candidates) > 1: + _log_event(conn, None, device_id, photo_path, "ambiguous") + return { + "ok": False, + "reason": "ambiguous", + "candidates": candidates, + "message": "I found more than one device nearby — pick yours on the screen.", + } + else: + chosen = candidates[0] + + claimed_by = _already_claimed_by(conn, chosen["entity_id"]) + if claimed_by is not None and claimed_by["name"].lower() != name.lower(): + _log_event(conn, claimed_by["id"], device_id, photo_path, "already_claimed") + return { + "ok": False, + "reason": "already_claimed", + "message": "That device is already registered to someone else.", + } + if claimed_by is not None: + # Same person re-registering the same device — a harmless no-op, not an + # error (e.g. re-running the flow after a network hiccup). + _set_profile_photo(conn, claimed_by["id"], photo_path) + _log_event(conn, claimed_by["id"], device_id, photo_path, "already_registered") + return { + "ok": True, + "person_id": claimed_by["id"], + "person_name": claimed_by["name"], + "entity_id": chosen["entity_id"], + "is_new_person": False, + "is_new_device": False, + "message": f"You're already registered, {claimed_by['name']}.", + } + + person_id, is_new_person = _find_or_create_person(conn, name) + conn.execute( + "INSERT INTO identifiers (person_id, ha_entity_id, registered_at, registered_via_device) " + "VALUES (?, ?, ?, ?)", + (person_id, chosen["entity_id"], _now(), device_id), + ) + _set_profile_photo(conn, person_id, photo_path) + _log_event(conn, person_id, device_id, photo_path, "registered") + + device_count = conn.execute( + "SELECT COUNT(*) AS n FROM identifiers WHERE person_id = ?", (person_id,) + ).fetchone()["n"] + + message = ( + f"Welcome, {name}!" if is_new_person + else f"Got it — that's device number {device_count} for {name}." + ) + return { + "ok": True, + "person_id": person_id, + "person_name": name, + "entity_id": chosen["entity_id"], + "is_new_person": is_new_person, + "is_new_device": True, + "device_count": device_count, + "message": message, + } + + +def _log_event(conn, person_id, device_id, photo_path, outcome) -> None: + conn.execute( + "INSERT INTO registration_events (person_id, device_id, photo_path, outcome, created_at) " + "VALUES (?, ?, ?, ?, ?)", + (person_id, device_id, photo_path, outcome, _now()), + ) + + +def list_people() -> list[dict]: + with _db_lock, _db() as conn: + people = conn.execute( + "SELECT id, name, created_at, photo_path FROM people ORDER BY name COLLATE NOCASE" + ).fetchall() + result = [] + for person in people: + identifiers = conn.execute( + "SELECT id, ha_entity_id, registered_at, registered_via_device " + "FROM identifiers WHERE person_id = ? ORDER BY registered_at", + (person["id"],), + ).fetchall() + result.append( + { + "id": person["id"], + "name": person["name"], + "created_at": person["created_at"], + # The path itself is never exposed — an internal container + # filesystem detail — just whether GET /people//photo has + # anything to serve. + "has_photo": person["photo_path"] is not None, + "identifiers": [dict(i) for i in identifiers], + } + ) + return result + + +def get_person_photo(person_id: int) -> bytes | None: + with _db_lock, _db() as conn: + row = conn.execute("SELECT photo_path FROM people WHERE id = ?", (person_id,)).fetchone() + if row is None or row["photo_path"] is None: + return None + path = Path(row["photo_path"]) + if not path.is_file(): + return None + return path.read_bytes() + + +def delete_identifier(person_id: int, identifier_id: int) -> bool: + with _db_lock, _db() as conn: + cur = conn.execute( + "DELETE FROM identifiers WHERE id = ? AND person_id = ?", (identifier_id, person_id) + ) + return cur.rowcount > 0 + + +def delete_person(person_id: int) -> bool: + """Mainly for cleaning up stale/anonymous Guest records (see register_guest) — + equally usable for any person, ON DELETE CASCADE takes their identifiers with + them. + """ + with _db_lock, _db() as conn: + cur = conn.execute("DELETE FROM people WHERE id = ?", (person_id,)) + return cur.rowcount > 0 + + +def register_guest(device_id: str, photo_path: str | None) -> dict: + """The "doesn't need to know who it is" path — no name, no BLE candidate lookup + at all, just a household record that someone who isn't a registered resident is + around. Always creates a NEW person ("Guest 1", "Guest 2", ...) rather than + reusing one — unlike named registration, two guest visits are not assumed to be + the same person the way two registrations of "Amir" are. See delete_person() for + cleaning up a stale guest entry afterwards; nothing here expires them + automatically. + """ + with _db_lock, _db() as conn: + existing = conn.execute("SELECT COUNT(*) AS n FROM people WHERE name LIKE 'Guest %'").fetchone()["n"] + name = f"Guest {existing + 1}" + person_id, _ = _find_or_create_person(conn, name) + _set_profile_photo(conn, person_id, photo_path) + _log_event(conn, person_id, device_id, photo_path, "registered_guest") + return { + "ok": True, + "person_id": person_id, + "person_name": name, + "entity_id": None, + "is_new_person": True, + "is_new_device": False, + "message": f"Added {name}.", + } + + +def set_manual_presence(person_id: int, home: bool) -> bool: + """The hand-operated alternative to BLE presence, for anyone with zero + identifiers (grandmother, a guest) — see presence()'s handling below. Silently + accepted for a person who *does* have identifiers too (harmless, just ignored by + presence() in that case) rather than rejected, since there's no safety reason to + forbid it and one fewer edge case to special-case in the caller. + """ + with _db_lock, _db() as conn: + exists = conn.execute("SELECT 1 FROM people WHERE id = ?", (person_id,)).fetchone() + if not exists: + return False + conn.execute( + "INSERT INTO manual_presence (person_id, home, updated_at) VALUES (?, ?, ?) " + "ON CONFLICT(person_id) DO UPDATE SET home = excluded.home, updated_at = excluded.updated_at", + (person_id, int(home), _now()), + ) + return True + + +def presence() -> dict: + """Who's home. For a person with at least one registered identifier, resolved + from that identifier's current HA state, same as before. For a person with + ZERO identifiers (grandmother, a guest — see register()'s no_device path and + register_guest()) there is nothing to resolve automatically, so this reports + `home: null` ("unknown") rather than `false` ("away") unless a manual override + has been set via set_manual_presence() — reporting them as away by default would + be actively wrong, not just uninformative, the moment they're actually home. + Does not assume or require pre-existing HA `person.*` entities, since this + service is itself the source of truth for name<->identifier mapping. Degrades to + an empty list (never an error page) if nobody is registered yet or HA is + unreachable, same "degrade, don't blank" rule as every other renderer in this + project. + """ + people = list_people() + if not people: + return {"people": [], "generated_at": _now()} + + try: + states = {s["entity_id"]: s for s in _ha_get("/api/states")} + ha_ok = True + except (urllib.error.URLError, urllib.error.HTTPError, RuntimeError): + LOG.warning("identity: could not reach HA for presence", exc_info=True) + states = {} + ha_ok = False + + with _db_lock, _db() as conn: + manual = { + row["person_id"]: bool(row["home"]) + for row in conn.execute("SELECT person_id, home FROM manual_presence") + } + + result = [] + for person in people: + room = None + if person["identifiers"] and ha_ok: + entity_states = [states[i["ha_entity_id"]] for i in person["identifiers"] if i["ha_entity_id"] in states] + home = any(s.get("state") in PRESENT_STATES for s in entity_states) + # Floor-plan groundwork — see AREA_ATTRIBUTE's module-level comment. + # First identifier that actually reports one wins; a person with two + # phones in two different rooms is a real but rare edge case not worth + # more than "pick one" for a v1 that has no map to show it on yet anyway. + for s in entity_states: + area = (s.get("attributes") or {}).get(AREA_ATTRIBUTE) + if area: + room = area + break + elif person["identifiers"]: + home = None # HA unreachable + else: + home = manual.get(person["id"]) # None if never manually set either + + result.append( + { + "id": person["id"], + "name": person["name"], + "home": home, + "room": room, + "has_device": bool(person["identifiers"]), + "has_photo": person["has_photo"], + } + ) + + payload = {"people": result, "generated_at": _now()} + if not ha_ok: + payload["error"] = "ha_unreachable" + return payload + + +def _on_mqtt_message(_client, _userdata, message) -> None: + try: + payload = json.loads(message.payload.decode("utf-8", "replace")) + except ValueError: + return + with _weather_lock: + global _last_weather + _last_weather = {**payload, "available": True} + + +def start_mqtt(broker_host: str, broker_port: int, username: str, password: str) -> None: + if not broker_host: + LOG.warning("identity: MQTT_BROKER_HOST not set — /weather will always report unavailable") + return + + callback_api = getattr(mqtt, "CallbackAPIVersion", None) + client = mqtt.Client(callback_api.VERSION1) if callback_api is not None else mqtt.Client() + if username: + client.username_pw_set(username, password or None) + + def on_connect(c, _userdata, _flags, rc): + if rc == 0: + c.subscribe("smarthome/weather/current", qos=1) + + client.on_connect = on_connect + client.on_message = _on_mqtt_message + client.connect_async(broker_host, broker_port, keepalive=60) + client.loop_start() + + +class Handler(BaseHTTPRequestHandler): + server_version = "identity/1" + + def log_message(self, format, *args): # noqa: A002 + LOG.info("%s - %s", self.address_string(), format % args) + + def _authorized(self) -> bool: + if not TOKEN: + return False + return self.headers.get("Authorization", "") == f"Bearer {TOKEN}" + + def _respond(self, status: HTTPStatus, payload) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + + def _read_body(self, max_bytes: int) -> bytes: + try: + length = int(self.headers.get("Content-Length") or 0) + except ValueError: + raise ValueError("missing or invalid Content-Length") from None + if length <= 0: + return b"" + if length > max_bytes: + raise ValueError(f"body too large ({length} > {max_bytes} bytes)") + return self.rfile.read(length) + + def do_OPTIONS(self): # noqa: N802 + self.send_response(HTTPStatus.NO_CONTENT) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type") + self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + self.end_headers() + + def do_GET(self): # noqa: N802 + if not self._authorized(): + self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"}) + return + path = urlsplit(self.path).path + photo_match = re.match(r"^/people/(\d+)/photo$", path) + if path == "/people": + self._respond(HTTPStatus.OK, {"people": list_people()}) + elif path == "/presence": + self._respond(HTTPStatus.OK, presence()) + elif path == "/weather": + with _weather_lock: + self._respond(HTTPStatus.OK, dict(_last_weather)) + elif photo_match: + self._handle_get_photo(int(photo_match.group(1))) + else: + self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"}) + + def _handle_get_photo(self, person_id: int) -> None: + data = get_person_photo(person_id) + if data is None: + self._respond(HTTPStatus.NOT_FOUND, {"error": "no photo for this person"}) + return + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "image/jpeg") + self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write(data) + + def do_POST(self): # noqa: N802 + if not self._authorized(): + self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"}) + return + path = urlsplit(self.path).path + if path == "/register/photo": + self._handle_register_photo() + elif path == "/register": + self._handle_register() + elif path == "/register/guest": + self._handle_register_guest() + elif path == "/presence/manual": + self._handle_presence_manual() + else: + self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"}) + + def do_DELETE(self): # noqa: N802 + if not self._authorized(): + self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"}) + return + path = urlsplit(self.path).path + + id_match = re.match(r"^/people/(\d+)/identifiers/(\d+)$", path) + if id_match: + ok = delete_identifier(int(id_match.group(1)), int(id_match.group(2))) + self._respond( + HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND, + {"ok": True} if ok else {"error": "no such identifier"}, + ) + return + + person_match = re.match(r"^/people/(\d+)$", path) + if person_match: + ok = delete_person(int(person_match.group(1))) + self._respond( + HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND, + {"ok": True} if ok else {"error": "no such person"}, + ) + return + + self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"}) + + def _handle_register_photo(self) -> None: + try: + data = self._read_body(MAX_IMAGE_BYTES) + except ValueError as exc: + self._respond(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": str(exc)}) + return + if not data: + self._respond(HTTPStatus.BAD_REQUEST, {"error": "empty image body"}) + return + + photo_id = f"{int(time.time())}{os.getpid() % 10000:04d}" + PHOTO_DIR.mkdir(parents=True, exist_ok=True) + photo_path = PHOTO_DIR / f"{photo_id}.jpg" + photo_path.write_bytes(data) + LOG.info("identity: stored registration photo %s (%d bytes)", photo_id, len(data)) + self._respond(HTTPStatus.OK, {"photo_id": photo_id}) + + def _handle_register(self) -> None: + try: + raw = self._read_body(MAX_JSON_BYTES) + payload = json.loads(raw or b"{}") + except (ValueError, json.JSONDecodeError) as exc: + self._respond(HTTPStatus.BAD_REQUEST, {"error": f"bad request body: {exc}"}) + return + + name = str(payload.get("name", "")) + device_id = str(payload.get("device_id", "unknown")) + photo_id = payload.get("photo_id") + forced_entity_id = payload.get("entity_id") + no_device = bool(payload.get("no_device", False)) + + ok, photo_path = self._resolve_photo_path(photo_id) + if not ok: + return # error response already sent + + result = register(name, device_id, photo_path, forced_entity_id, no_device) + self._respond(HTTPStatus.OK if result.get("ok") else HTTPStatus.CONFLICT, result) + + def _resolve_photo_path(self, photo_id) -> tuple[bool, str | None]: + """Shared by /register and /register/guest. `(True, None)` if no photo_id + was given, `(True, path)` if it resolves to a real stored photo, `(False, + None)` if photo_id was present but invalid — callers must check the first + element and return early on False, since the error response is already sent + by the time this returns it. + """ + if not photo_id: + return True, None + if not PHOTO_ID_RE.match(str(photo_id)): + self._respond(HTTPStatus.BAD_REQUEST, {"error": "invalid photo_id"}) + return False, None + candidate_path = PHOTO_DIR / f"{photo_id}.jpg" + return True, (str(candidate_path) if candidate_path.exists() else None) + + def _handle_register_guest(self) -> None: + try: + raw = self._read_body(MAX_JSON_BYTES) + payload = json.loads(raw or b"{}") + except (ValueError, json.JSONDecodeError) as exc: + self._respond(HTTPStatus.BAD_REQUEST, {"error": f"bad request body: {exc}"}) + return + + device_id = str(payload.get("device_id", "unknown")) + ok, photo_path = self._resolve_photo_path(payload.get("photo_id")) + if not ok: + return + + result = register_guest(device_id, photo_path) + self._respond(HTTPStatus.OK, result) + + def _handle_presence_manual(self) -> None: + try: + raw = self._read_body(MAX_JSON_BYTES) + payload = json.loads(raw or b"{}") + except (ValueError, json.JSONDecodeError) as exc: + self._respond(HTTPStatus.BAD_REQUEST, {"error": f"bad request body: {exc}"}) + return + + try: + person_id = int(payload.get("person_id")) + home = bool(payload.get("home")) + except (TypeError, ValueError): + self._respond(HTTPStatus.BAD_REQUEST, {"error": "'person_id' (int) and 'home' (bool) are required"}) + return + + ok = set_manual_presence(person_id, home) + self._respond(HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND, {"ok": ok}) + + +def main() -> int: + logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", + ) + + if not TOKEN: + LOG.error("IDENTITY_TOKEN is not set — every request will be rejected until it is.") + if not HA_TOKEN: + LOG.warning("HA_TOKEN is not set — /register and /presence will fail until it is configured.") + + init_db() + + start_mqtt( + os.environ.get("MQTT_BROKER_HOST", ""), + int(os.environ.get("MQTT_BROKER_PORT") or 1883), + os.environ.get("MQTT_USERNAME", ""), + os.environ.get("MQTT_PASSWORD", ""), + ) + + port = int(os.environ.get("IDENTITY_PORT", "8097")) + server = ThreadingHTTPServer(("0.0.0.0", port), Handler) + LOG.info("identity listening on :%d (HA: %s, db: %s)", port, HA_URL, DB_PATH) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pantry-vision/README.md b/pantry-vision/README.md index 310f1a9..1ef33dd 100644 --- a/pantry-vision/README.md +++ b/pantry-vision/README.md @@ -10,9 +10,12 @@ Grocy's recipes, on request. - **`pantry-vision`** (this directory) — a small always-on Python HTTP service. `POST /identify` (a photo → a proposal via an Ollama vision model), `POST /confirm` - (a human-reviewed proposal → written into Grocy stock), `GET /inventory` and - `GET /recipes` (proxy Grocy, reshaped for the frontend). All four endpoints are - bearer-token gated. + (a human-reviewed proposal → written into Grocy stock), `GET /inventory`, + `GET /recipes`, and `GET /shopping-list` (all three proxy Grocy, reshaped for a + frontend). All five endpoints are bearer-token gated. `/shopping-list` is also + consumed by `hosts/door-panel/`'s dashboard (Phase 18, "groceries running low") — + it's a thin reshape of Grocy's own `/api/stock/volatile` `missing_products`, not + new inventory logic. - **`frontend/`** — the static single-page app the kitchen display's kiosk browser loads: Scan / Inventory / Recipes, vanilla JS, no build step, no framework — same "vendored, dependency-free" choice as the digest/admin canvas SDKs. Served diff --git a/pantry-vision/server.py b/pantry-vision/server.py index 05ab617..25510f7 100644 --- a/pantry-vision/server.py +++ b/pantry-vision/server.py @@ -246,6 +246,8 @@ class Handler(BaseHTTPRequestHandler): self._handle_inventory() elif path == "/recipes": self._handle_recipes() + elif path == "/shopping-list": + self._handle_shopping_list() else: self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"}) @@ -362,6 +364,38 @@ class Handler(BaseHTTPRequestHandler): self._respond(HTTPStatus.OK, {"recipes": results}) + def _handle_shopping_list(self) -> None: + """"Groceries running low" for hosts/door-panel/'s dashboard (Phase 18) — + distinct from /inventory's soonest-to-expire sort: this is about quantity + below Grocy's own per-product minimum stock amount, not expiry date. Grocy's + `/api/stock/volatile` endpoint already computes exactly this + (`missing_products`) natively, so this is a thin reshape, not new logic — + same "don't duplicate what Grocy already tracks" rule as /inventory and + /recipes. + """ + try: + volatile = _grocy_get("/api/stock/volatile?missing_days=0") + except (urllib.error.URLError, urllib.error.HTTPError) as exc: + LOG.warning("pantry-vision: Grocy /api/stock/volatile unreachable", exc_info=True) + self._respond(HTTPStatus.BAD_GATEWAY, {"error": f"Grocy unreachable: {exc}"}) + return + + # VERIFY: assumes `missing_products` entries carry "name" and + # "amount_missing" fields directly — Grocy's documented shape, not checked + # against a live instance. Falls back to "product_id" as the display name + # rather than dropping the row, same degrade rule as /inventory. + missing = volatile.get("missing_products") if isinstance(volatile, dict) else None + items = [] + for row in missing if isinstance(missing, list) else []: + items.append( + { + "name": row.get("name") or f"Product #{row.get('product_id')}", + "amount_missing": row.get("amount_missing"), + } + ) + + self._respond(HTTPStatus.OK, {"items": items}) + def main() -> int: logging.basicConfig(