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
parent
87455c5343
commit
bad217c369
21
README.md
21
README.md
|
|
@ -3,8 +3,8 @@
|
||||||
Local-first, open-source smart home: Home Assistant + Zigbee + RuView (CSI presence)
|
Local-first, open-source smart home: Home Assistant + Zigbee + RuView (CSI presence)
|
||||||
+ Bermuda (BLE identity) + local LLM (Ollama) + Frigate (peephole face recognition)
|
+ Bermuda (BLE identity) + local LLM (Ollama) + Frigate (peephole face recognition)
|
||||||
+ Grocy (kitchen kiosk) + Nextcloud calendar sync + a Sway thin-client media station
|
+ 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
|
+ a Sway touch panel + a camera-vision kitchen/fridge display + a voice/touch
|
||||||
LLM-generated digest.
|
identity-registration door panel + a quarter-daily LLM-generated digest.
|
||||||
|
|
||||||
See [`docs/project-plan.md`](docs/project-plan.md) for the full hardware list,
|
See [`docs/project-plan.md`](docs/project-plan.md) for the full hardware list,
|
||||||
software stack, and phased implementation plan.
|
software stack, and phased implementation plan.
|
||||||
|
|
@ -29,14 +29,23 @@ hosts/
|
||||||
kitchen-display/ Single-purpose Sway kiosk for the fridge/pantry: one
|
kitchen-display/ Single-purpose Sway kiosk for the fridge/pantry: one
|
||||||
Chromium window showing pantry-vision's Scan/Inventory/
|
Chromium window showing pantry-vision's Scan/Inventory/
|
||||||
Recipes frontend, camera capture via the browser itself
|
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/
|
firmware/
|
||||||
ruview/ RuView ESP32-S3 CSI presence node configs
|
ruview/ RuView ESP32-S3 CSI presence node configs
|
||||||
esphome-ble-proxy/ ESPHome configs for Bermuda BLE proxy nodes
|
esphome-ble-proxy/ ESPHome configs for Bermuda BLE proxy nodes
|
||||||
esp32-s3-touch-lcd-1.85c/ ESPHome voice satellite + status display (round LCD,
|
esp32-s3-touch-lcd-1.85c/ ESPHome voice satellite + status display (round LCD,
|
||||||
media/cover-art priority over an idle weather/time/
|
media/cover-art priority over an idle weather/time/
|
||||||
date cycle, voice-state visualizer)
|
date cycle, voice-state visualizer)
|
||||||
identity/ Face<->MAC<->name correlation logic (Node-RED flow
|
identity/ Person <-> BLE-identifier registry: multi-phone support,
|
||||||
export once stabilized, or a Python service)
|
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
|
digest-engine/ Quarter-daily LLM digest: mail/message/news/financial
|
||||||
ingestion, LLM synthesis, digest-canvas SDK rendering
|
ingestion, LLM synthesis, digest-canvas SDK rendering
|
||||||
admin-canvas/ On-demand sys-admin-llm display surface for the thin
|
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
|
- [ ] Grocy kiosk (Pi + touchscreen) setup
|
||||||
- [ ] LLM host (Ollama) setup script
|
- [ ] LLM host (Ollama) setup script
|
||||||
- [ ] CalDAV / Nextcloud calendar integration notes
|
- [ ] 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
|
- [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`
|
- [ ] Thin-client follow-ups in progress: fullscreen-aware now-playing widget (cover art + controls), minimal Firefox chrome + uBlock Origin/SponsorBlock, persistent audio-output selection, outbound RDP/VNC client (`rdp-vnc.json`), HA mobile-app browser remote control (text input + mouse buttons), capture-card ("receiver box") video source selection on a new `5:capture` workspace, idle-gallery weather/clock overlay (clock always, weather via a new `smarthome/weather/current` MQTT topic an HA automation has to publish) — built, not yet tried against real capture-card hardware or a real weather automation, see `hosts/thin-client/README.md`
|
||||||
- [x] Quarter-daily digest engine (mail/Signal/Telegram/Discord/WhatsApp, news, financial ingestion; LLM synthesis; digest-canvas SDK) — built and wired into `setup-container-host.sh` (`ENABLE_DIGEST_ENGINE`, off by default), not yet run against real credentials; household/calendar ingest (CalDAV/Grocy) still needs a real data source wired in, see `docs/project-plan.md` §4
|
- [x] 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
|
- [ ] 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`
|
- [ ] 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`
|
- [ ] 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
|
## Quick start
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,20 @@ Components
|
||||||
1xMedia Station/TVPC (doesn't technically need to be in the same room) -> 1xThinClient (tiny is sufficient)
|
1xMedia Station/TVPC (doesn't technically need to be in the same room) -> 1xThinClient (tiny is sufficient)
|
||||||
1xVoice Reciever - Round screen thingy
|
1xVoice Reciever - Round screen thingy
|
||||||
1xSound System (already have it, see #Have)
|
1xSound System (already have it, see #Have)
|
||||||
|
1xRuView Presence Node (ESP32-S3 CSI board)
|
||||||
|
|
||||||
#Loggia
|
#Loggia
|
||||||
|
|
||||||
1xMiniscreen System (Lenovo all-in-one PC, large built-in touchscreen — already own it, free, see #Have)
|
1xMiniscreen System (Lenovo all-in-one PC, large built-in touchscreen — already own it, free, see #Have)
|
||||||
1xSound System
|
1xSound System
|
||||||
1xVoice Reciever - Round screen thingy
|
1xVoice Reciever - Round screen thingy
|
||||||
|
1xRuView Presence Node (ESP32-S3 CSI board)
|
||||||
|
|
||||||
#Linus Room
|
#Linus Room
|
||||||
|
|
||||||
1xVoice Reciever - Round screen thingy
|
1xVoice Reciever - Round screen thingy
|
||||||
1xSound System
|
1xSound System
|
||||||
|
1xRuView Presence Node (ESP32-S3 CSI board)
|
||||||
???
|
???
|
||||||
|
|
||||||
#Amirs Room
|
#Amirs Room
|
||||||
|
|
@ -31,6 +34,7 @@ Components
|
||||||
1xThinClient (tiny is sufficient)
|
1xThinClient (tiny is sufficient)
|
||||||
1xHA Voice PE
|
1xHA Voice PE
|
||||||
1xSound System
|
1xSound System
|
||||||
|
1xRuView Presence Node (ESP32-S3 CSI board)
|
||||||
|
|
||||||
#Kitchen
|
#Kitchen
|
||||||
|
|
||||||
|
|
@ -38,6 +42,7 @@ Components
|
||||||
1xCamera
|
1xCamera
|
||||||
1xVoice Reciever - Round screen thingy
|
1xVoice Reciever - Round screen thingy
|
||||||
1xSound System
|
1xSound System
|
||||||
|
1xRuView Presence Node (ESP32-S3 CSI board)
|
||||||
|
|
||||||
##Lighting
|
##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)
|
5x Sound System (all confirmed: Living Room, Loggia, Linus Room, Amirs Room, Kitchen)
|
||||||
1x Tiny PC with Mini Touchscreen (Kitchen)
|
1x Tiny PC with Mini Touchscreen (Kitchen)
|
||||||
1x Camera (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
|
#Lighting
|
||||||
~16x RGB smart bulb (Zigbee) — count/room split not finalized
|
~16x RGB smart bulb (Zigbee) — count/room split not finalized
|
||||||
|
|
||||||
#By room
|
#By room
|
||||||
Living Room: 1x ThinClient, 1x Voice Reciever, 1x Sound System (have)
|
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
|
Loggia: 1x Miniscreen System (have), 1x Sound System, 1x Voice Reciever, 1x RuView node
|
||||||
Linus Room: 1x Voice Reciever, 1x Sound System, +1 unresolved (???)
|
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
|
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
|
Kitchen: 1x Tiny PC w/ Touchscreen, 1x Camera, 1x Voice Reciever, 1x Sound System, 1x RuView node
|
||||||
|
|
||||||
#Open items
|
#Open items
|
||||||
- Linus Room's "???" line is still undecided.
|
- Linus Room's "???" line is still undecided.
|
||||||
|
|
@ -124,6 +131,20 @@ is a kit of:
|
||||||
|
|
||||||
4x board + 4x power adapter (+ cable if not bundled).
|
4x board + 4x power adapter (+ cable if not bundled).
|
||||||
|
|
||||||
|
#RuView Presence Node (Living Room, Loggia, Linus Room, Amirs Room, Kitchen) — need 5
|
||||||
|
This is `firmware/ruview/` from this repo (Phase 2/§1.6) — anonymous room-level CSI
|
||||||
|
presence, still an unbuilt placeholder in this repo (no firmware written yet), but
|
||||||
|
the hardware spec is fixed regardless: a plain ESP32-**S3** board, no camera, no
|
||||||
|
extra peripherals — CSI presence reads WiFi channel state, not a camera. Separate
|
||||||
|
boards from the Bermuda BLE-proxy ESP32s (§1.5) — one chip runs one firmware.
|
||||||
|
- [Espressif ESP32-S3-DevKitC-1-N8R2](https://www.amazon.com/Espressif-ESP32-S3-DevKitC-1-N8R2-Development-Board/dp/B09D3S7T3M)
|
||||||
|
(~€8–12 each per docs/project-plan.md §1.6) — the official board, minimal
|
||||||
|
8MB-flash/2MB-PSRAM variant; RuView's own firmware has no published minimum spec
|
||||||
|
yet (nothing's been written), so this is picked for being the plain, cheap,
|
||||||
|
official option rather than a specific requirement.
|
||||||
|
|
||||||
|
5x boards, one per room.
|
||||||
|
|
||||||
#Sound System (all 5 rooms confirmed: Living Room, Loggia, Linus Room, Amirs Room, Kitchen) — need 4
|
#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.
|
Have 1 already (earmarked for Living Room), need 5 total, all confirmed → need 4 more.
|
||||||
Each unit is a kit of:
|
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
|
(see link for price) — any UVC webcam works; picked for wide availability/driver
|
||||||
support, not a specific requirement
|
support, not a specific requirement
|
||||||
|
|
||||||
|
#Spare webcams — need 2
|
||||||
|
Destination not decided yet — going to whichever future camera-equipped clients need
|
||||||
|
one next (see docs/project-plan.md Phase 6/18: any camera-equipped endpoint can run
|
||||||
|
the identity registration flow, not just kitchen-display/door-panel).
|
||||||
|
- [Logitech C270 HD Webcam, 720p](https://www.amazon.com/Logitech-C270-Webcam-Megapixel-Interpolated/dp/B01IFBKK3W)
|
||||||
|
(see link for price, typically the cheapest widely-stocked UVC webcam) — cheaper
|
||||||
|
than the C920 above on purpose: these two are general-purpose spares, not tied to
|
||||||
|
pantry-vision's food-photo use case that specifically wants the C920's autofocus,
|
||||||
|
so there's no reason to pay for that here.
|
||||||
|
|
||||||
|
#Fixed BLE tags — need ~4 to start
|
||||||
|
For household members who don't (or shouldn't have to) carry a phone for presence to
|
||||||
|
work — a grandmother without a smartphone is the concrete case, but this is also just
|
||||||
|
generally the more reliable anchor `docs/project-plan.md` §1.5 already recommends over
|
||||||
|
a phone's own randomizing MAC. `identity/`'s registration flow supports registering
|
||||||
|
someone with **no** device at all (a manual home/away toggle instead), but a physical
|
||||||
|
tag is the better long-term answer once it's worth the ~€5–8 — see
|
||||||
|
`identity/README.md`'s "no device" section.
|
||||||
|
- [The Smart Anti Lost Tracker iTag-Tiny](https://www.amazon.com/iTag-Tiny-Innovative-Bluetooth-Anything-bidirectional/dp/B07FG7XJ9D)
|
||||||
|
(see link for price, typically €3–8) — a plain Bluetooth 4.0 beacon with a
|
||||||
|
replaceable coin-cell battery, not locked into Apple Find My/Google Find Hub's
|
||||||
|
proprietary rotating-identifier scheme (most "key finder" listings on Amazon *are*
|
||||||
|
locked to one of those and won't work here) — this is the generic style the HA/
|
||||||
|
Bermuda community actually uses. **Verify before buying more than one**: cheap
|
||||||
|
tags in this class often advertise a BLE *static random* address (technically
|
||||||
|
"random" address-type, but stable for the tag's lifetime, unlike a phone's
|
||||||
|
*resolvable private* address which deliberately rotates) — community reports say
|
||||||
|
they "show as Random MAC but are stable," which is what actually matters for
|
||||||
|
`identity`'s `TRUSTED_ENTITY_PREFIXES` allowlist, not the address-type label
|
||||||
|
itself. Confirm one tag's address is genuinely stable (watch it in HA's Bluetooth
|
||||||
|
debug log over a few hours) before buying a batch.
|
||||||
|
4x to start (one for the concrete grandmother case plus a few spares/guests); buy
|
||||||
|
more as more device-less household members come up.
|
||||||
|
|
||||||
#Lighting — need ~16 RGB smart bulbs (count TBD)
|
#Lighting — need ~16 RGB smart bulbs (count TBD)
|
||||||
**Zigbee, not WiFi/cloud bulbs** — the whole point of the Zigbee2MQTT backbone this
|
**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
|
project already runs (§1.3) is one local mesh instead of N different manufacturer
|
||||||
|
|
@ -207,10 +262,13 @@ local pricing/VAT/shipping.
|
||||||
| 16AWG speaker wire, 100ft spool *(one-time)* | 1 | €15–22 | €15–22 |
|
| 16AWG speaker wire, 100ft spool *(one-time)* | 1 | €15–22 | €15–22 |
|
||||||
| 10.3" portable USB-C touchscreen monitor | 1 | €85–130 | €85–130 |
|
| 10.3" portable USB-C touchscreen monitor | 1 | €85–130 | €85–130 |
|
||||||
| Logitech HD Pro Webcam C920 | 1 | €55–75 | €55–75 |
|
| Logitech HD Pro Webcam C920 | 1 | €55–75 | €55–75 |
|
||||||
|
| Logitech C270 spare webcam | 2 | €25–35 | €50–70 |
|
||||||
| innr RB 285 C Zigbee RGB bulb | ~16 (TBD) | €15–20 | €240–320 |
|
| innr RB 285 C Zigbee RGB bulb | ~16 (TBD) | €15–20 | €240–320 |
|
||||||
|
| iTag-Tiny fixed BLE tag | 4 | €3–8 | €12–32 |
|
||||||
|
| Espressif ESP32-S3-DevKitC-1-N8R2 (RuView) | 5 | €8–12 | €40–60 |
|
||||||
|
|
||||||
**Subtotal (excludes the optional beamer mount): ~€1,729–2,020**
|
**Subtotal (excludes the optional beamer mount): ~€1,831–2,182**
|
||||||
**Subtotal, including the optional beamer mount: ~€1,744–2,045**
|
**Subtotal, including the optional beamer mount: ~€1,846–2,207**
|
||||||
|
|
||||||
All 5 Sound Systems (Living Room, Loggia, Linus Room, Amirs Room, Kitchen) are
|
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
|
confirmed — see `#Open items` above — so all 4 needed amp+speaker kits are in the
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# AI-Managed Smart Home — Full Build Plan (v2)
|
# 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
|
Phase 1 host, calling the existing Phase 3/9 LLM host for vision inference and the
|
||||||
already-running `grocy` container for storage.)*
|
already-running `grocy` container for storage.)*
|
||||||
|
|
||||||
|
### 1.16 Door/wardrobe panel hardware (Phase 18)
|
||||||
|
| Item | Est. Price (EUR) | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Mini PC or SBC with a touchscreen, mounted by the door/wardrobe | €150–300 | Same native Wayland `wl_touch` preference as §1.14/§1.15 — no specific unit chosen |
|
||||||
|
| USB microphone + speaker (or a combo device) | €20–40 | This device's actual purpose is voice registration ("register me as `<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)
|
## 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 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 |
|
| 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 |
|
| 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.
|
3. Enroll known faces; automation for known vs. unknown at the door.
|
||||||
4. Set conservative confidence thresholds given the narrow FOV/low light.
|
4. Set conservative confidence thresholds given the narrow FOV/low light.
|
||||||
|
|
||||||
### Phase 6 — Identity correlation (face ↔ MAC ↔ name)
|
### Phase 6 — Identity registration (multi-device, anti-spoofing, voice)
|
||||||
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.
|
New top-level `identity/` directory (container-host service + frontend). Backs
|
||||||
3. Once the logic stabilizes, consider porting to a small Python service if it outgrows Node-RED's comfort zone (git/test-ability).
|
`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
|
### Phase 7 — Kitchen inventory kiosk
|
||||||
1. Deploy Grocy via Compose.
|
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`
|
or a real Grocy instance. See the itemized lists in `pantry-vision/README.md`
|
||||||
and `hosts/kitchen-display/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"
|
### Testing checklist before calling any phase "done"
|
||||||
- Does the reactive path (presence → light on) work with the LLM host powered off? (It must.)
|
- 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.)
|
- 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.)
|
- 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.)
|
- 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.)
|
- If the vision model's response is unparseable or the call fails outright, does `/identify` ever return a broken/blank result instead of a flagged, low-confidence placeholder proposal? (It must not.)
|
||||||
|
- Does `identity` ever accept a raw/unresolved Bluetooth MAC (as opposed to an entity_id matching `TRUSTED_ENTITY_PREFIXES`) as a registration candidate? (It must not — that allowlist is the entire anti-spoofing boundary.)
|
||||||
|
- Does `POST /register` ever commit a registration when zero, more than one, or an already-claimed-by-someone-else candidate was found? (It must not — only the single-unambiguous-candidate case commits within one call; every other case requires a human to disambiguate.)
|
||||||
|
- Does `/presence` ever report a device-less person (no identifiers, no manual override set) as `home: false`? (It must report `null`/unknown — defaulting to "away" would be actively wrong the moment they're actually home, not just imprecise.)
|
||||||
|
- Can the LLM reach the door panel through any path other than HA service call → MQTT → `door-panel-agent`, for *which screen is showing*? (It must not — registration and presence/weather/groceries reads are separate, intentionally-published paths through `identity`/`pantry-vision` themselves, not a violation of this rule.)
|
||||||
|
- Does the door panel's voice registration path ever bypass HA's Assist pipeline (i.e. the kiosk device talking to `identity` on its own initiative from a wake word, with no HA intent script in between)? (It must not — voice is HA Assist → a custom intent script → `identity`'s API, same "HA mediates" shape as every other voice/tool-call path in this project.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Open decisions (Phases 11–17)
|
## 4. Open decisions (Phases 6, 11–18)
|
||||||
|
|
||||||
These need a decision before their respective implementation steps can be built — everything above is written to accommodate any answer, but nothing should be built against an unresolved item.
|
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`.
|
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).
|
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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@
|
||||||
# backend, optional, off by default — needs pantry-vision/ from this repo
|
# backend, optional, off by default — needs pantry-vision/ from this repo
|
||||||
# checked out on this host, see PANTRY_VISION_SRC below and
|
# checked out on this host, see PANTRY_VISION_SRC below and
|
||||||
# pantry-vision/README.md)
|
# 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
|
# 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_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)
|
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
|
# 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."
|
echo " build context for the pantry-vision image — then re-run."
|
||||||
fi
|
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
|
if [[ "$ENABLE_WHATSAPP_INGEST" == "true" ]]; then
|
||||||
echo "WARNING: WhatsApp ingestion is enabled."
|
echo "WARNING: WhatsApp ingestion is enabled."
|
||||||
echo " There is no officially sanctioned way to read WhatsApp programmatically."
|
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)."
|
echo " PANTRY_VISION_TOKEN and GROCY_API_KEY (Grocy's own UI: Settings -> Manage API keys)."
|
||||||
fi
|
fi
|
||||||
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
|
# 4. Mosquitto config
|
||||||
|
|
@ -699,6 +733,52 @@ if [[ "$ENABLE_PANTRY_VISION" == "true" ]]; then
|
||||||
"
|
"
|
||||||
fi
|
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
|
if [[ "$ENABLE_DIGEST_ENGINE" == "true" && "$ENABLE_WHATSAPP_INGEST" == "true" ]]; then
|
||||||
# Long-lived, unlike digest-engine: holds the logged-in WhatsApp Web session
|
# Long-lived, unlike digest-engine: holds the logged-in WhatsApp Web session
|
||||||
# open and appends to /data/messages.jsonl, which digest-engine drains each
|
# open and appends to /data/messages.jsonl, which digest-engine drains each
|
||||||
|
|
@ -795,7 +875,7 @@ ${FRIGATE_DEVICES}
|
||||||
- PUID=1000
|
- PUID=1000
|
||||||
- PGID=1000
|
- PGID=1000
|
||||||
- TZ=${TIMEZONE}
|
- 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
|
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 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>"
|
echo " Pantry display : http://${HOST_IP}:${PANTRY_WEB_PORT}/index.html?api=http://${HOST_IP}:${PANTRY_VISION_PORT}&token=<PANTRY_VISION_TOKEN>"
|
||||||
fi
|
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
|
if [[ "$ENABLE_GALLERY_SMB" == "true" ]]; then
|
||||||
echo " Gallery SMB : \\\\${HOST_IP}\\gallery (user: ${GALLERY_SMB_USERNAME})"
|
echo " Gallery SMB : \\\\${HOST_IP}\\gallery (user: ${GALLERY_SMB_USERNAME})"
|
||||||
fi
|
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 " (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."
|
echo " confirmed pulled or even correct for your setup. See pantry-vision/README.md."
|
||||||
fi
|
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
|
||||||
echo "Updating later: cd $BASE_DIR && docker compose pull && docker compose up -d"
|
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)"
|
echo "Backing up manually: sudo $BASE_DIR/backup.sh (requires ENABLE_BACKUPS=true was run once)"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""door-panel-agent — Home Assistant MQTT control surface for the door/wardrobe panel."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -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"])
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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@"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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."
|
||||||
|
|
@ -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
|
that's [`../../pantry-vision/`](../../pantry-vision/README.md), a container-host
|
||||||
service this device's kiosk browser calls directly. This host is deliberately thin:
|
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
|
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
|
ever controls *which screen is showing* (Scan / Inventory / Recipes, plus
|
||||||
same "the agent controls the surface, a separate write API owns the content" split
|
Registration — see below) over MQTT — the same "the agent controls the surface, a
|
||||||
`hosts/thin-client`'s admin canvas already established.
|
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
|
**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
|
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` |
|
| Autologin | greetd, straight into `/usr/local/bin/kiosk-session` |
|
||||||
| Touch input | Native Wayland `wl_touch`, same assumption as `../touch-panel/` |
|
| 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 |
|
| 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 |
|
| Content | `pantry-vision`'s frontend (`../../pantry-vision/frontend/`) by default; `identity`'s registration page on demand |
|
||||||
| Remote control (HA/LLM) | `kitchen-display-agent` — **Show scan / Show inventory / Show recipes** buttons only |
|
| 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/` |
|
| Remote control (human) | SSH only — no wayvnc, same scope decision as `../touch-panel/` |
|
||||||
|
|
||||||
## Hardware
|
## 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_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_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 |
|
| `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 |
|
| `KIOSK_USERNAME` / `IMAGE_HOSTNAME` / `KITCHEN_DISPLAY_NAME` | Per-device identity |
|
||||||
| `SSH_AUTHORIZED_KEY` | Optional — password auth is disabled and there is no wayvnc |
|
| `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
|
## Home Assistant entities
|
||||||
|
|
||||||
`kitchen-display-agent` publishes three buttons on connect: **Show scan**, **Show
|
`kitchen-display-agent` publishes four buttons on connect: **Show scan**, **Show
|
||||||
inventory**, **Show recipes** — each kills and relaunches the kiosk Chromium window
|
inventory**, **Show recipes**, **Show registration** — the first three kill and
|
||||||
at `PANTRY_WEB_URL/index.html?...#<tab>`. That's the entire MQTT surface; there is no
|
relaunch the kiosk Chromium window at `PANTRY_WEB_URL/index.html?...#<tab>`; the
|
||||||
media_player, no workspace select (one workspace), no capture/audio/remote-desktop
|
fourth relaunches it at `IDENTITY_WEB_URL/register.html?...` instead (a different
|
||||||
entities the way the thin client has. Reading or writing the actual inventory (from
|
backend, `identity`, behind the same "Show X" shape — see
|
||||||
voice, e.g. "what's about to expire?" or "add three eggs") is a **separate** path —
|
`kitchen_display_agent/main.py`'s `on_show()`). That's the entire MQTT surface; there
|
||||||
Home Assistant/the LLM calling `pantry-vision`'s own published API directly, not
|
is no media_player, no workspace select (one workspace), no capture/audio/
|
||||||
through this agent, exactly mirroring the split `hosts/thin-client`'s admin canvas
|
remote-desktop entities the way the thin client has. Reading or writing the actual
|
||||||
already established between "which screen is showing" (MQTT, this agent) and "what's
|
inventory, or registering a person, is a **separate** path — Home Assistant/the LLM
|
||||||
actually on it" (a dedicated write API).
|
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
|
### 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
|
only, which is enough for a bare `http://host:port` but not for a URL with a path
|
||||||
or query string of its own.
|
or query string of its own.
|
||||||
8. Idle-blank timeout (20 minutes, `configs/sway/config`) and touch-resume — untested.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import threading
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
from .mqtt_discovery import Discovery
|
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")
|
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_WEB_URL",
|
||||||
"PANTRY_VISION_URL",
|
"PANTRY_VISION_URL",
|
||||||
"PANTRY_VISION_TOKEN",
|
"PANTRY_VISION_TOKEN",
|
||||||
|
"IDENTITY_WEB_URL",
|
||||||
|
"IDENTITY_URL",
|
||||||
|
"IDENTITY_TOKEN",
|
||||||
"KIOSK_USERNAME",
|
"KIOSK_USERNAME",
|
||||||
"KITCHEN_DISPLAY_NAME",
|
"KITCHEN_DISPLAY_NAME",
|
||||||
)
|
)
|
||||||
|
|
@ -80,6 +83,12 @@ def main() -> int:
|
||||||
discovery = Discovery(client, node_id, friendly_name)
|
discovery = Discovery(client, node_id, friendly_name)
|
||||||
|
|
||||||
def on_show(fragment: str) -> None:
|
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)
|
launch_pantry_kiosk(fragment)
|
||||||
|
|
||||||
def on_connect(_client, _userdata, _flags, rc):
|
def on_connect(_client, _userdata, _flags, rc):
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,10 @@ class Discovery:
|
||||||
("show_scan", "Show scan", "scan", "mdi:camera"),
|
("show_scan", "Show scan", "scan", "mdi:camera"),
|
||||||
("show_inventory", "Show inventory", "inventory", "mdi:fridge-outline"),
|
("show_inventory", "Show inventory", "inventory", "mdi:fridge-outline"),
|
||||||
("show_recipes", "Show recipes", "recipes", "mdi:chef-hat"),
|
("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(
|
self._publish_config(
|
||||||
"button",
|
"button",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"""Session-environment resolution and process launching.
|
"""Session-environment resolution and process launching.
|
||||||
|
|
||||||
A trimmed version of hosts/thin-client's/hosts/touch-panel's sway_control.py: this
|
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
|
device has only one Sway workspace and two possible kiosk destinations — pantry-vision
|
||||||
there is no workspace-switching or window-focus surface to wrap — just enough to run
|
(the everyday screen) and identity's registration page (on demand) — so there is no
|
||||||
`/usr/local/bin/pantry-kiosk [fragment]` with the graphical session's environment.
|
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
|
from __future__ import annotations
|
||||||
|
|
@ -31,8 +32,7 @@ def session_env() -> dict[str, str]:
|
||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
def launch_pantry_kiosk(fragment: str) -> None:
|
def _launch(command: list[str]) -> None:
|
||||||
command = ["/usr/local/bin/pantry-kiosk", fragment] if fragment else ["/usr/local/bin/pantry-kiosk"]
|
|
||||||
log.info("launching %s", " ".join(command))
|
log.info("launching %s", " ".join(command))
|
||||||
try:
|
try:
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
|
|
@ -44,4 +44,12 @@ def launch_pantry_kiosk(fragment: str) -> None:
|
||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
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"])
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -8,6 +8,11 @@
|
||||||
# the page underneath (pantry-vision/frontend/) is cheap to reload. Camera capture is
|
# 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
|
# 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.
|
# 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
|
set -eu
|
||||||
|
|
||||||
PROFILE_DIR="${HOME:-/home/$(id -un)}/.config/kitchen-display-chromium"
|
PROFILE_DIR="${HOME:-/home/$(id -un)}/.config/kitchen-display-chromium"
|
||||||
|
|
@ -34,9 +39,9 @@ fi
|
||||||
|
|
||||||
mkdir -p "$PROFILE_DIR"
|
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
|
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
|
sleep 0.25
|
||||||
i=$((i + 1))
|
i=$((i + 1))
|
||||||
done
|
done
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Kitchen-display package list (live-build .list.chroot format: one package per
|
# 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 ---
|
# --- Wayland compositor / session ---
|
||||||
sway
|
sway
|
||||||
|
|
@ -8,9 +8,9 @@ swaybg
|
||||||
greetd
|
greetd
|
||||||
foot
|
foot
|
||||||
|
|
||||||
# --- Browser (the only app on this device — a Chromium kiosk window pointed at
|
# --- Browser (usually one of two kiosk destinations — pantry-vision or identity's
|
||||||
# pantry-web's frontend, which uses getUserMedia for camera capture directly in
|
# registration page — a Chromium kiosk window, using getUserMedia for camera
|
||||||
# the browser rather than a native capture app) ---
|
# capture directly in the browser rather than a native capture app) ---
|
||||||
chromium
|
chromium
|
||||||
|
|
||||||
# --- Agent runtime ---
|
# --- Agent runtime ---
|
||||||
|
|
@ -19,6 +19,20 @@ python3-venv
|
||||||
python3-paho-mqtt
|
python3-paho-mqtt
|
||||||
procps
|
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 ---
|
# --- Networking ---
|
||||||
network-manager
|
network-manager
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,12 @@
|
||||||
# - greetd autologin straight into a kiosk Sway session (no greeter UI)
|
# - greetd autologin straight into a kiosk Sway session (no greeter UI)
|
||||||
# - ONE Chromium kiosk window pointed at pantry-web's frontend (Scan / Inventory /
|
# - ONE Chromium kiosk window pointed at pantry-web's frontend (Scan / Inventory /
|
||||||
# Recipes is in-page tab navigation, not separate Sway workspaces)
|
# Recipes is in-page tab navigation, not separate Sway workspaces)
|
||||||
# - kitchen-display-agent (Python, systemd) — HA MQTT "Show scan/inventory/recipes"
|
# - kitchen-display-agent (Python, systemd) — HA MQTT "Show scan/inventory/recipes/
|
||||||
# buttons only; the actual camera capture and Grocy read/write path is
|
# registration" buttons only; the actual camera capture, Grocy read/write, and
|
||||||
# pantry-vision's own published API, called directly by the browser
|
# 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
|
# 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
|
# 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.
|
# credential pair that spans two machines in this project.
|
||||||
PANTRY_VISION_TOKEN="" # <-- EDIT: must match pantry-vision's own token
|
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=""
|
SSH_AUTHORIZED_KEY=""
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -102,6 +118,17 @@ if [[ -z "$PANTRY_VISION_TOKEN" ]]; then
|
||||||
echo " pantry-vision/pantry-vision.env on the container host."
|
echo " pantry-vision/pantry-vision.env on the container host."
|
||||||
fi
|
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
|
||||||
echo "=== Smart Home Kitchen-Display ISO Builder ==="
|
echo "=== Smart Home Kitchen-Display ISO Builder ==="
|
||||||
echo "Debian release : $DEBIAN_RELEASE"
|
echo "Debian release : $DEBIAN_RELEASE"
|
||||||
|
|
@ -110,6 +137,9 @@ echo "Image hostname : $IMAGE_HOSTNAME"
|
||||||
echo "MQTT broker : ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}"
|
echo "MQTT broker : ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}"
|
||||||
echo "pantry-web : $PANTRY_WEB_URL"
|
echo "pantry-web : $PANTRY_WEB_URL"
|
||||||
echo "pantry-vision : $PANTRY_VISION_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 "Keyboard layout : $KEYBOARD_LAYOUT"
|
||||||
echo
|
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}/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/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"
|
mkdir -p "$INCLUDES/etc/default"
|
||||||
cat > "$INCLUDES/etc/default/keyboard" <<EOF
|
cat > "$INCLUDES/etc/default/keyboard" <<EOF
|
||||||
|
|
@ -178,6 +209,14 @@ MQTT_PASSWORD=${MQTT_PASSWORD}
|
||||||
PANTRY_WEB_URL=${PANTRY_WEB_URL}
|
PANTRY_WEB_URL=${PANTRY_WEB_URL}
|
||||||
PANTRY_VISION_URL=${PANTRY_VISION_URL}
|
PANTRY_VISION_URL=${PANTRY_VISION_URL}
|
||||||
PANTRY_VISION_TOKEN=${PANTRY_VISION_TOKEN}
|
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
|
EOF
|
||||||
chmod 0644 "$INCLUDES/etc/kitchen-display-agent/config.env"
|
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 " Camera : getUserMedia in Chromium (--use-fake-ui-for-media-stream"
|
||||||
echo " auto-accepts the permission prompt), not a native capture app"
|
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 " 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 " Keyboard layout : ${KEYBOARD_LAYOUT} (console + Sway)"
|
||||||
echo " Maintenance shell : Super+Shift+Ctrl+M opens a floating terminal locally"
|
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 " 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 " 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 " 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 " 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
|
||||||
echo "Then pull the power on the container host and re-check: the kiosk must still"
|
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"
|
echo "boot (Scan/Inventory/Recipes will show connection errors, which is the expected"
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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);
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
paho-mqtt>=1.6
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -10,9 +10,12 @@ Grocy's recipes, on request.
|
||||||
|
|
||||||
- **`pantry-vision`** (this directory) — a small always-on Python HTTP service.
|
- **`pantry-vision`** (this directory) — a small always-on Python HTTP service.
|
||||||
`POST /identify` (a photo → a proposal via an Ollama vision model), `POST /confirm`
|
`POST /identify` (a photo → a proposal via an Ollama vision model), `POST /confirm`
|
||||||
(a human-reviewed proposal → written into Grocy stock), `GET /inventory` and
|
(a human-reviewed proposal → written into Grocy stock), `GET /inventory`,
|
||||||
`GET /recipes` (proxy Grocy, reshaped for the frontend). All four endpoints are
|
`GET /recipes`, and `GET /shopping-list` (all three proxy Grocy, reshaped for a
|
||||||
bearer-token gated.
|
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
|
- **`frontend/`** — the static single-page app the kitchen display's kiosk browser
|
||||||
loads: Scan / Inventory / Recipes, vanilla JS, no build step, no framework — same
|
loads: Scan / Inventory / Recipes, vanilla JS, no build step, no framework — same
|
||||||
"vendored, dependency-free" choice as the digest/admin canvas SDKs. Served
|
"vendored, dependency-free" choice as the digest/admin canvas SDKs. Served
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._handle_inventory()
|
self._handle_inventory()
|
||||||
elif path == "/recipes":
|
elif path == "/recipes":
|
||||||
self._handle_recipes()
|
self._handle_recipes()
|
||||||
|
elif path == "/shopping-list":
|
||||||
|
self._handle_shopping_list()
|
||||||
else:
|
else:
|
||||||
self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"})
|
self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"})
|
||||||
|
|
||||||
|
|
@ -362,6 +364,38 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
self._respond(HTTPStatus.OK, {"recipes": results})
|
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:
|
def main() -> int:
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue