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 <name>") 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CrKjW3yVptUnjG35HjC1
digest-per-person-and-agendas
Amir Alexander Abdelbaki 2026-07-30 13:40:04 +02:00
parent 87455c5343
commit bad217c369
44 changed files with 3586 additions and 55 deletions

View File

@ -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 `<name>`" by voice or touchscreen, multi-phone support (multiple identifiers per person), anti-spoofing (only allowlisted IRK-resolved/fixed-tag entities are ever accepted as candidates, never a raw MAC), device-less people (a "no device" flag plus a hand-operated Home/Away toggle — the concrete case: a grandmother without a smartphone), and an anonymous "Guest" path. Backs `hosts/door-panel/`'s weather+clothing/who's-home/groceries-running-low dashboard and `hosts/kitchen-display/`'s "Show registration" screen — built and wired into `setup-container-host.sh` (`ENABLE_IDENTITY`, off by default), **nothing run against a real HA instance, real Private BLE Device entities, or a real voice pipeline**`TRUSTED_ENTITY_PREFIXES` above all needs checking against Developer Tools -> States, see `identity/README.md` and `hosts/door-panel/README.md`
## Quick start

View File

@ -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)
(~€812 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 ~€58 — 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 €38) — 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 | €1522 | €1522 |
| 10.3" portable USB-C touchscreen monitor | 1 | €85130 | €85130 |
| Logitech HD Pro Webcam C920 | 1 | €5575 | €5575 |
| Logitech C270 spare webcam | 2 | €2535 | €5070 |
| innr RB 285 C Zigbee RGB bulb | ~16 (TBD) | €1520 | €240320 |
| iTag-Tiny fixed BLE tag | 4 | €38 | €1232 |
| Espressif ESP32-S3-DevKitC-1-N8R2 (RuView) | 5 | €812 | €4060 |
**Subtotal (excludes the optional beamer mount): ~€1,7292,020**
**Subtotal, including the optional beamer mount: ~€1,7442,045**
**Subtotal (excludes the optional beamer mount): ~€1,8312,182**
**Subtotal, including the optional beamer mount: ~€1,8462,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

View File

@ -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 | €150300 | Same native Wayland `wl_touch` preference as §1.14/§1.15 — no specific unit chosen |
| USB microphone + speaker (or a combo device) | €2040 | This device's actual purpose is voice registration ("register me as `<name>`"), 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 `<name>`," 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/<id>` 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 `<img src>`. 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 1117)
## 4. Open decisions (Phases 6, 1118)
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.

View File

@ -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=<PANTRY_VISION_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=<IDENTITY_TOKEN>&device=<name>"
echo " Dashboard page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/dashboard.html?identity_api=http://${HOST_IP}:${IDENTITY_PORT}&identity_token=<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 <name>' 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)"

126
hosts/door-panel/README.md Normal file
View File

@ -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
`<name>`" 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 `<name>`") 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 `<name>`" → 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.

View File

@ -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

View File

@ -0,0 +1,3 @@
"""door-panel-agent — Home Assistant MQTT control surface for the door/wardrobe panel."""
__version__ = "0.1.0"

View File

@ -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())

View File

@ -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,
},
)

View File

@ -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"])

View File

@ -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

View File

@ -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@"

View File

@ -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

View File

@ -0,0 +1,54 @@
# Sway kiosk session for the door/wardrobe panel (docs/project-plan.md Phase 18).
#
# Installed to /home/<KIOSK_USERNAME>/.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

View File

@ -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

View File

@ -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 <name>"), 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

View File

@ -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}" <<EOF
${KIOSK_USERNAME} ALL=(ALL) NOPASSWD: ALL
EOF
chmod 0440 "/etc/sudoers.d/010-${KIOSK_USERNAME}"
mkdir -p /etc/ssh/sshd_config.d
cat > /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

View File

@ -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"

View File

@ -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

View File

@ -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 <name>"), 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 <<EOF
[Unit]
Description=Wyoming openWakeWord
After=network.target
[Service]
Type=simple
ExecStart=${VENV}/bin/wyoming-openwakeword --uri tcp://127.0.0.1:10400 --preload-model ${VOICE_WAKE_WORD}
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
cat > /etc/systemd/system/wyoming-satellite.service <<EOF
[Unit]
Description=Wyoming satellite (${VOICE_SATELLITE_NAME})
Wants=wyoming-openwakeword.service
After=network.target wyoming-openwakeword.service
[Service]
Type=simple
User=${KIOSK_USERNAME}
ExecStart=${VENV}/bin/wyoming-satellite \\
--name '${VOICE_SATELLITE_NAME}' \\
--uri tcp://0.0.0.0:10700 \\
--mic-command 'arecord -D default -r 16000 -c 1 -f S16_LE -t raw' \\
--snd-command 'aplay -D default -r 22050 -c 1 -f S16_LE -t raw' \\
--wake-uri tcp://127.0.0.1:10400 \\
--wake-word-name '${VOICE_WAKE_WORD}'
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl enable wyoming-openwakeword
systemctl enable wyoming-satellite

View File

@ -0,0 +1,57 @@
# Door-panel package list (live-build .list.chroot format: one package per line).
# Phase 18/6, docs/project-plan.md.
# --- Wayland compositor / session ---
sway
swayidle
swaybg
greetd
foot
# --- Browser (the only app on this device — a Chromium kiosk window pointed at
# identity's dashboard.html by default, register.html on demand) ---
chromium
# --- Agent runtime ---
python3
python3-venv
python3-paho-mqtt
procps
# --- Audio (mic + speaker) — voice registration is this device's whole purpose,
# so this is expected to actually be used here, unlike kitchen-display's
# identical opt-in ---
pipewire
pipewire-pulse
wireplumber
alsa-utils
# --- Build/runtime deps for the pip-installed voice satellite venv ---
python3-dev
build-essential
libopenblas0
git
# --- Networking ---
network-manager
# --- Out-of-band admin — no wayvnc, same scope decision as hosts/touch-panel and
# hosts/kitchen-display ---
openssh-server
sudo
# --- Camera troubleshooting ---
v4l-utils
# --- Firmware for real hardware ---
firmware-linux
firmware-iwlwifi
firmware-realtek
firmware-misc-nonfree
# --- Misc ---
ca-certificates
curl
openssl
less
vim-tiny

View File

@ -0,0 +1,3 @@
# Only relevant when ENABLE_INSTALLER="true" in build-door-panel-iso.sh (off by
# default). Identical mechanism/caveat as the other hosts' preseed.cfg.
d-i keyboard-configuration/xkb-keymap select de

View File

@ -0,0 +1,306 @@
#!/usr/bin/env bash
#
# Smart Home Door-Panel ISO Builder
# Target: builds a Debian 12 (Bookworm) live ISO on a Debian/Ubuntu build machine
#
# Drives `lb config && lb build` over hosts/door-panel/live-build/ to produce the
# door/wardrobe panel described in docs/project-plan.md Phase 18:
# - greetd autologin straight into a kiosk Sway session (no greeter UI)
# - ONE Chromium kiosk window, default screen identity's dashboard.html (weather +
# what-to-wear, who's home, groceries running low), "Show registration" on demand
# - door-panel-agent (Python, systemd) — HA MQTT "Show home/registration" buttons
# only; presence/weather/groceries/registration all come from identity's and
# pantry-vision's own published APIs, called directly by the browser
# - wyoming-satellite + openWakeWord for voice registration ("register me as
# <name>") — 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 <name>") — 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" <<EOF
XKBMODEL="pc105"
XKBLAYOUT="${KEYBOARD_LAYOUT}"
XKBVARIANT=""
XKBOPTIONS=""
BACKSPACE="guess"
EOF
cp -r "${AGENT_DIR}/door_panel_agent" "$INCLUDES/opt/door-panel-agent/"
install -m 0644 "${AGENT_DIR}/requirements.txt" "$INCLUDES/opt/door-panel-agent/requirements.txt"
find "$INCLUDES/opt/door-panel-agent" -name '__pycache__' -type d -prune -exec rm -rf {} +
if [[ -n "$SSH_AUTHORIZED_KEY" ]]; then
echo "$SSH_AUTHORIZED_KEY" > "$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" <<EOF
# Generated by hosts/door-panel/scripts/build-door-panel-iso.sh — do not hand-edit
# here; edit the CONFIGURATION block in that script and rebuild.
KIOSK_USERNAME=${KIOSK_USERNAME}
DOOR_PANEL_NAME=${DOOR_PANEL_NAME}
MQTT_BROKER_HOST=${MQTT_BROKER_HOST}
MQTT_BROKER_PORT=${MQTT_BROKER_PORT}
MQTT_USERNAME=${MQTT_USERNAME}
MQTT_PASSWORD=${MQTT_PASSWORD}
IDENTITY_WEB_URL=${IDENTITY_WEB_URL}
IDENTITY_URL=${IDENTITY_URL}
IDENTITY_TOKEN=${IDENTITY_TOKEN}
PANTRY_VISION_URL=${PANTRY_VISION_URL}
PANTRY_VISION_TOKEN=${PANTRY_VISION_TOKEN}
ENABLE_VOICE_SATELLITE=${ENABLE_VOICE_SATELLITE}
VOICE_SATELLITE_NAME=${VOICE_SATELLITE_NAME}
VOICE_WAKE_WORD=${VOICE_WAKE_WORD}
EOF
chmod 0644 "$INCLUDES/etc/door-panel-agent/config.env"
# ---------------------------------------------------------------------------
# 3. lb config
# ---------------------------------------------------------------------------
cd "$LIVE_BUILD_DIR"
chmod +x config/hooks/normal/*.hook.chroot
if [[ -e .build ]]; then
echo "--- Previous build found, running 'lb clean' (package cache is kept) ---"
lb clean
fi
INSTALLER_MODE="none"
if [[ "$ENABLE_INSTALLER" == "true" ]]; then
INSTALLER_MODE="live"
fi
echo "--- Running lb config ---"
lb config \
--distribution "$DEBIAN_RELEASE" \
--architectures amd64 \
--linux-flavours amd64 \
--archive-areas "main contrib non-free-firmware" \
--binary-images iso-hybrid \
--debian-installer "$INSTALLER_MODE" \
--iso-application "SmartestHome Door Panel" \
--iso-publisher "SmartestHome" \
--iso-volume "smarthome-door-panel" \
--memtest none \
--bootappend-live "boot=live components quiet splash noautologin username=${KIOSK_USERNAME} hostname=${IMAGE_HOSTNAME}"
# ---------------------------------------------------------------------------
# 4. lb build
# ---------------------------------------------------------------------------
echo "--- Running lb build (this takes a while and needs network) ---"
lb build
ISO_PATH="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name '*.iso' -print -quit)"
ISO_PATH="${ISO_PATH:-${LIVE_BUILD_DIR}/live-image-amd64.hybrid.iso}"
echo
echo "=== Done ==="
echo "ISO written to:"
echo " ${ISO_PATH}"
echo
echo "What's in it:"
echo " greetd : autologin as '${KIOSK_USERNAME}' straight into Sway on vt1"
echo " Sway : one workspace, one Chromium kiosk window"
echo " Default screen : ${IDENTITY_WEB_URL}/dashboard.html (weather+clothing,"
echo " who's home, groceries running low)"
echo " Registration : ${IDENTITY_WEB_URL}/register.html, on 'Show registration'"
echo " (MQTT) or by voice — see identity/README.md"
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
echo " Voice satellite : wyoming-satellite + openWakeWord ('${VOICE_WAKE_WORD}'), room"
echo " '${VOICE_SATELLITE_NAME}'"
else
echo " Voice satellite : NOT installed (ENABLE_VOICE_SATELLITE=false) — registration"
echo " still works via the touchscreen form"
fi
echo " door-panel-agent : system service, MQTT ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}"
echo " Keyboard layout : ${KEYBOARD_LAYOUT} (console + Sway)"
echo " Maintenance shell : Super+Shift+Ctrl+M opens a floating terminal locally"
echo " Remote admin : SSH only — no wayvnc, same scope decision as hosts/touch-panel"
echo
echo "Next steps:"
echo " 1. Write the ISO to a USB stick:"
echo " sudo dd if=${ISO_PATH} of=/dev/sdX bs=4M status=progress oflag=sync"
echo " (double-check /dev/sdX with 'lsblk' first)"
echo " 2. Boot the target machine — a touchscreen, mic, and speaker are required"
echo " hardware, none of it built in or chosen yet. See the README."
echo " 3. Confirm greetd autologin lands in Sway with the dashboard up."
echo " 4. Register yourself via the touchscreen form (tap 'Show registration') to"
echo " exercise the identity path before setting up HA's voice intent wiring."
echo " 5. Confirm door-panel-agent connected and registered:"
echo " systemctl status door-panel-agent"
echo " In Home Assistant, a '${DOOR_PANEL_NAME}' device should appear under the MQTT"
echo " integration with: Show home / Show registration (buttons)."
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
echo " 6. Once HA's custom-sentence + intent script (identity/README.md's worked"
echo " example) are wired up, say 'register me as <name>' 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."

View File

@ -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 `<name>`" 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 `<name>`" |
| 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?...#<tab>`. 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?...#<tab>`; 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.

View File

@ -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,6 +83,12 @@ def main() -> int:
discovery = Discovery(client, node_id, friendly_name)
def on_show(fragment: str) -> None:
# "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):

View File

@ -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",

View File

@ -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"])

View File

@ -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 <name>"), 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

View File

@ -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

View File

@ -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 <name>") 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 <<EOF
[Unit]
Description=Wyoming openWakeWord
After=network.target
[Service]
Type=simple
ExecStart=${VENV}/bin/wyoming-openwakeword --uri tcp://127.0.0.1:10400 --preload-model ${VOICE_WAKE_WORD}
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
# --mic-command/--snd-command use the ALSA default device — no kitchen-display
# hardware has been chosen yet, same open item as every other host in this project.
cat > /etc/systemd/system/wyoming-satellite.service <<EOF
[Unit]
Description=Wyoming satellite (${VOICE_SATELLITE_NAME})
Wants=wyoming-openwakeword.service
After=network.target wyoming-openwakeword.service
[Service]
Type=simple
User=${KIOSK_USERNAME}
ExecStart=${VENV}/bin/wyoming-satellite \\
--name '${VOICE_SATELLITE_NAME}' \\
--uri tcp://0.0.0.0:10700 \\
--mic-command 'arecord -D default -r 16000 -c 1 -f S16_LE -t raw' \\
--snd-command 'aplay -D default -r 22050 -c 1 -f S16_LE -t raw' \\
--wake-uri tcp://127.0.0.1:10400 \\
--wake-word-name '${VOICE_WAKE_WORD}'
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl enable wyoming-openwakeword
systemctl enable wyoming-satellite

View File

@ -1,5 +1,5 @@
# Kitchen-display package list (live-build .list.chroot format: one package per
# line). Phase 17, docs/project-plan.md.
# line). Phase 17/6, docs/project-plan.md.
# --- Wayland compositor / session ---
sway
@ -8,9 +8,9 @@ swaybg
greetd
foot
# --- Browser (the only app on this device — a Chromium kiosk window pointed at
# pantry-web's frontend, which uses getUserMedia for camera capture directly in
# the browser rather than a native capture app) ---
# --- Browser (usually one of two kiosk destinations — pantry-vision or identity's
# registration page — a Chromium kiosk window, using getUserMedia for camera
# capture directly in the browser rather than a native capture app) ---
chromium
# --- Agent runtime ---
@ -19,6 +19,20 @@ python3-venv
python3-paho-mqtt
procps
# --- Audio (mic + speaker) — needed once voice registration is enabled
# (0400-voice-satellite.hook.chroot, OPT-IN per image) ---
pipewire
pipewire-pulse
wireplumber
alsa-utils
# --- Build/runtime deps for the pip-installed voice satellite venv (same as
# hosts/thin-client's identical opt-in feature) ---
python3-dev
build-essential
libopenblas0
git
# --- Networking ---
network-manager

View File

@ -9,9 +9,12 @@
# - greetd autologin straight into a kiosk Sway session (no greeter UI)
# - ONE Chromium kiosk window pointed at pantry-web's frontend (Scan / Inventory /
# Recipes is in-page tab navigation, not separate Sway workspaces)
# - kitchen-display-agent (Python, systemd) — HA MQTT "Show scan/inventory/recipes"
# buttons only; the actual camera capture and Grocy read/write path is
# pantry-vision's own published API, called directly by the browser
# - kitchen-display-agent (Python, systemd) — HA MQTT "Show scan/inventory/recipes/
# registration" buttons only; the actual camera capture, Grocy read/write, and
# identity registration paths are pantry-vision's/identity's own published APIs,
# called directly by the browser
# - identity's registration page (Phase 6) — voice ("register me as <name>", 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 <name>") — 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" <<EOF
@ -178,6 +209,14 @@ MQTT_PASSWORD=${MQTT_PASSWORD}
PANTRY_WEB_URL=${PANTRY_WEB_URL}
PANTRY_VISION_URL=${PANTRY_VISION_URL}
PANTRY_VISION_TOKEN=${PANTRY_VISION_TOKEN}
IDENTITY_WEB_URL=${IDENTITY_WEB_URL}
IDENTITY_URL=${IDENTITY_URL}
IDENTITY_TOKEN=${IDENTITY_TOKEN}
ENABLE_VOICE_SATELLITE=${ENABLE_VOICE_SATELLITE}
VOICE_SATELLITE_NAME=${VOICE_SATELLITE_NAME}
VOICE_WAKE_WORD=${VOICE_WAKE_WORD}
EOF
chmod 0644 "$INCLUDES/etc/kitchen-display-agent/config.env"
@ -233,6 +272,16 @@ echo " Kiosk page : ${PANTRY_WEB_URL}/index.html (Scan / Inventory / R
echo " Camera : getUserMedia in Chromium (--use-fake-ui-for-media-stream"
echo " auto-accepts the permission prompt), not a native capture app"
echo " kitchen-display-agent: system service, MQTT ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}"
echo " Registration : ${IDENTITY_WEB_URL}/register.html, on 'Show registration'"
echo " (MQTT) or by voice once ENABLE_VOICE_SATELLITE=true — see"
echo " identity/README.md"
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
echo " Voice satellite : wyoming-satellite + openWakeWord ('${VOICE_WAKE_WORD}'), room"
echo " '${VOICE_SATELLITE_NAME}'"
else
echo " Voice satellite : NOT installed (ENABLE_VOICE_SATELLITE=false) — registration"
echo " still works via the touchscreen form"
fi
echo " Keyboard layout : ${KEYBOARD_LAYOUT} (console + Sway)"
echo " Maintenance shell : Super+Shift+Ctrl+M opens a floating terminal locally"
echo " Remote admin : SSH only — no wayvnc, same scope decision as hosts/touch-panel"
@ -255,6 +304,16 @@ echo " MQTT integration with: Show scan / Show inventory / Show recipes (but
echo " 6. Hold a grocery item up to the camera, tap Capture, and confirm a proposal"
echo " comes back from pantry-vision within a few seconds — first checkpoint for"
echo " whether the chosen Ollama vision model's latency is workable at all."
echo " 7. Tap 'Show registration' (or the HA button) and register yourself via the"
echo " touchscreen form — this exercises the whole identity path without needing"
echo " voice or HA's intent-script wiring set up first. See identity/README.md"
echo " for TRUSTED_ENTITY_PREFIXES — registration will report 'no candidate' until"
echo " that's set to match your real Private BLE Device / fixed-tag entity IDs."
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
echo " 8. Once HA's custom-sentence + intent script (identity/README.md's worked"
echo " example) are wired up, say 'register me as <name>' 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"

21
identity/Dockerfile Normal file
View File

@ -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"]

237
identity/README.md Normal file
View File

@ -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 `<name>`" 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/<id>` 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 `<name>`"**
— 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://<container-host>: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 <IDENTITY_TOKEN>`),
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/<id>/photo` | the person's profile picture (raw JPEG) — their most recent registration photo |
| `DELETE /people/<id>/identifiers/<id>` | revoke a mistaken or compromised identifier |
| `DELETE /people/<id>` | 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/<id>/photo` with a blob
+ `createObjectURL()` on the frontend side, not a bare `<img src="...">` — the
endpoint is bearer-token gated like everything else here, and a plain `<img>` 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.

View File

@ -0,0 +1,45 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>Home</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!--
hosts/door-panel/'s default screen (docs/project-plan.md Phase 18): weather +
what-to-wear, who's home, and groceries running low, all at a glance on the way
out the door. Pulls from two backends — identity (weather, presence) and
pantry-vision (shopping list) — both configured via URL query params, same
pattern as pantry-vision/frontend/app.js. "Register" is a separate page
(register.html), reached via an MQTT "Show register" command
(hosts/door-panel/agent), not an in-page tab here.
-->
<main class="no-tabs">
<section class="block">
<h2>Weather</h2>
<div id="weather-hero" class="weather-hero">
<span class="weather-temp">--</span>
</div>
<div id="clothing-suggestion" class="clothing-suggestion" hidden></div>
</section>
<section class="block">
<h2>Who's home</h2>
<div id="presence-list" class="card-list">
<p class="hint">Loading…</p>
</div>
</section>
<section class="block">
<h2>Running low</h2>
<div id="shopping-list" class="card-list">
<p class="hint">Loading…</p>
</div>
</section>
</main>
<script src="dashboard.js"></script>
</body>
</html>

View File

@ -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 =
'<p class="error" style="padding:24px">identity not configured — missing ' +
"?identity_api=&identity_token= in the URL.</p>";
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 = '<span class="hint">No weather data yet</span>';
suggestion.hidden = true;
return;
}
hero.innerHTML =
`<span class="weather-temp">${escapeHtml(data.temperature || "")}</span>` +
`<span class="weather-condition">${escapeHtml(data.condition || "")}</span>`;
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 = '<span class="hint">Weather unavailable</span>';
});
}
// --- 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 <img src="..."> 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 = '<p class="hint">Nobody registered yet.</p>';
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
? ""
: `<button type="button" class="big-btn secondary" data-mark="${p.id}" data-home="1" style="min-height:40px;min-width:0;padding:0 12px;font-size:13px;margin-left:6px">Home</button>
<button type="button" class="big-btn secondary" data-mark="${p.id}" data-home="0" style="min-height:40px;min-width:0;padding:0 12px;font-size:13px;margin-left:6px">Away</button>`;
return `<div class="card">
<span class="avatar" data-person="${p.id}">👤</span>
<span class="card-name">${escapeHtml(p.name)}</span>
<span class="card-meta ${statusClass(p)}">${escapeHtml(statusLabel(p))}</span>
${manualButtons}
</div>`;
})
.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 = `<p class="error">Could not load presence: ${escapeHtml(err.message)}</p>`;
});
}
// --- Running low -----------------------------------------------------------------
function loadShoppingList() {
const el = document.getElementById("shopping-list");
if (!PANTRY_API || !PANTRY_TOKEN) {
el.innerHTML = '<p class="hint">pantry-vision not configured for this display.</p>';
return;
}
pantryApi("/shopping-list")
.then((data) => {
const items = data.items || [];
if (!items.length) {
el.innerHTML = '<p class="hint">Nothing running low.</p>';
return;
}
el.innerHTML = items
.map(
(item) =>
`<div class="card">
<span class="card-name">${escapeHtml(item.name)}</span>
<span class="card-meta">need ${escapeHtml(String(item.amount_missing ?? ""))}</span>
</div>`
)
.join("");
})
.catch((err) => {
el.innerHTML = `<p class="error">Could not load shopping list: ${escapeHtml(err.message)}</p>`;
});
}
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);

View File

@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>Register</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!--
identity's registration page (docs/project-plan.md Phase 6). The touch/type flow
here is the FALLBACK path — the primary path is voice ("register me as <name>",
see ../README.md), which never loads this page at all. This page exists for: the
no-mic case, disambiguating an "ambiguous" (multiple candidates found) result, and
reviewing who's already registered.
Reused as-is by hosts/kitchen-display/ and hosts/door-panel/ — config (?api=&
token=&device=) comes entirely from the kiosk's own launch URL, same pattern as
pantry-vision/frontend/app.js. Nothing in this file is host-specific.
-->
<main class="no-tabs">
<section class="block">
<h2>Register</h2>
<div id="camera-wrap">
<video id="camera-preview" autoplay playsinline muted style="width:100%;max-width:480px;border-radius:12px;background:#000"></video>
<canvas id="captured-frame" style="display:none"></canvas>
<p id="camera-error" class="error" hidden></p>
</div>
<form id="register-form" style="margin-top:16px;display:flex;flex-direction:column;gap:12px;max-width:480px">
<label style="display:flex;flex-direction:column;gap:4px">
Your name
<input id="f-name" type="text" required
style="min-height:48px;font-size:16px;padding:8px 12px;border-radius:8px;border:1px solid rgba(255,255,255,0.16);background:#1a1a20;color:#e8e8ec">
</label>
<label style="display:flex;align-items:center;gap:8px;font-size:15px">
<input id="f-no-device" type="checkbox" style="width:22px;height:22px">
I don't have a phone or tag
</label>
<button type="submit" id="register-btn" class="big-btn">Register</button>
<p id="register-status" class="hint"></p>
</form>
<div id="candidate-picker" hidden style="margin-top:16px">
<p class="hint">More than one device nearby — which one is yours?</p>
<div id="candidate-list" class="card-list"></div>
</div>
<div style="margin-top:20px;max-width:480px">
<button type="button" id="guest-btn" class="big-btn secondary">Add a guest (no name needed)</button>
<p id="guest-status" class="hint"></p>
</div>
</section>
<section class="block">
<h2>Already registered</h2>
<div id="people-list" class="card-list">
<p class="hint">Loading…</p>
</div>
</section>
</main>
<script src="register.js"></script>
</body>
</html>

View File

@ -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 =
'<p class="error" style="padding:24px">identity not configured — missing ' +
"?api=&token= in the URL.</p>";
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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) =>
`<button type="button" class="card" style="width:100%;text-align:left;border:none;cursor:pointer" data-entity="${escapeHtml(c.entity_id)}">
<span class="card-name">${escapeHtml(c.friendly_name)}</span>
</button>`
)
.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 <img src="...">, because every identity endpoint
// (including this one) requires an Authorization header — an <img> 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 = '<p class="hint">Nobody registered yet.</p>';
return;
}
el.innerHTML = people
.map(
(p) =>
`<div class="card">
<span class="avatar" data-person="${p.id}">👤</span>
<span class="card-name">${escapeHtml(p.name)}</span>
<span class="card-meta">${p.identifiers.length} device${p.identifiers.length === 1 ? "" : "s"}</span>
</div>`
)
.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 = `<p class="error">Could not load: ${escapeHtml(err.message)}</p>`;
});
}
loadPeople();

196
identity/frontend/style.css Normal file
View File

@ -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;
}

View File

@ -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

View File

@ -0,0 +1 @@
paho-mqtt>=1.6

786
identity/server.py Executable file
View File

@ -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 <name>" 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/<id>/identifiers/<id> 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/<id>/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())

View File

@ -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

View File

@ -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(