Compare commits
9 Commits
2565cf99d0
...
2ae5cb3449
| Author | SHA1 | Date |
|---|---|---|
|
|
2ae5cb3449 | |
|
|
2a87996cbb | |
|
|
919ce94d52 | |
|
|
bbeabde97e | |
|
|
ea82ee70ad | |
|
|
564c4a801d | |
|
|
15e6715469 | |
|
|
0caa21fc42 | |
|
|
5c05b67a5a |
|
|
@ -89,3 +89,30 @@ hosts/audio-endpoint/rpi-image-gen/rpi-image-gen/
|
||||||
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Build system (tools/) — see tools/README.md
|
||||||
|
#
|
||||||
|
# CoreSystemConfig.json is the FILLED-IN config: Wi-Fi PSK, every service token,
|
||||||
|
# MQTT and HA credentials. Only the .template is tracked. Never commit a real one.
|
||||||
|
CoreSystemConfig.json
|
||||||
|
CoreSystemConfig.*.json
|
||||||
|
!CoreSystemConfig.json.template
|
||||||
|
|
||||||
|
# Built images. These have every secret above burnt into them by design, so they are
|
||||||
|
# credential-bearing artifacts, not just large ones.
|
||||||
|
iso-out/
|
||||||
|
*.iso
|
||||||
|
*.img
|
||||||
|
*.img.xz
|
||||||
|
|
||||||
|
# live-build working trees, regenerated from the config on every build.
|
||||||
|
hosts/*/live-build/config/includes.chroot/
|
||||||
|
hosts/*/live-build/config/includes.installer/
|
||||||
|
hosts/*/live-build/.build/
|
||||||
|
hosts/*/live-build/chroot/
|
||||||
|
hosts/*/live-build/cache/
|
||||||
|
hosts/*/live-build/binary/
|
||||||
|
hosts/*/live-build/*.iso
|
||||||
|
config/generated-*.yaml
|
||||||
|
hosts/audio-endpoint/rpi-image-gen/config/generated-*.yaml
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
{
|
||||||
|
"_README": [
|
||||||
|
"SmartestHome — the single source of truth for every build script in tools/.",
|
||||||
|
"",
|
||||||
|
"Copy this to CoreSystemConfig.json (that filename is gitignored), fill it in, then",
|
||||||
|
"run any builder in tools/. Nothing in tools/ has its own copy of an IP, a port, a",
|
||||||
|
"token or a URL — every build script reads this file, so a value can only ever be",
|
||||||
|
"wrong in one place instead of six.",
|
||||||
|
"",
|
||||||
|
"ANYTHING DERIVABLE IS DERIVED, NEVER TYPED. You give the subnet prefix once and one",
|
||||||
|
"last octet per host; the builders compute every address and every service URL from",
|
||||||
|
"those. That is what makes the container-host and llm-host ISOs a TWINNED pair: the",
|
||||||
|
"container host learns the LLM host's address because it was computed from the same",
|
||||||
|
"two numbers, not because someone typed it into two files and kept them in sync.",
|
||||||
|
"",
|
||||||
|
"THIS FILE WILL CONTAIN SECRETS once filled in (Wi-Fi PSK, service tokens, MQTT and",
|
||||||
|
"HA credentials) and those secrets get burnt into the ISOs the builders produce.",
|
||||||
|
"Treat both this file and the built ISOs as credentials: .gitignore covers them, but",
|
||||||
|
"an ISO on a USB stick in a drawer is still every token in this household.",
|
||||||
|
"",
|
||||||
|
"Run 'tools/validate-config.py' at any time to check it. Every builder runs it first",
|
||||||
|
"and refuses to build on an error."
|
||||||
|
],
|
||||||
|
|
||||||
|
"household": {
|
||||||
|
"_comment": "Baked into every image. timezone must be a real zoneinfo name; keyboard_layout an xkb layout ('localectl list-x11-keymap-layouts').",
|
||||||
|
"timezone": "Europe/Vienna",
|
||||||
|
"locale": "en_US.UTF-8",
|
||||||
|
"keyboard_layout": "de",
|
||||||
|
"debian_release": "bookworm"
|
||||||
|
},
|
||||||
|
|
||||||
|
"network": {
|
||||||
|
"_comment": "subnet_prefix is the first three octets of the smart-home VLAN, no trailing dot. Every host below places itself in it by last octet, which is also how the validator guarantees they can actually reach each other.",
|
||||||
|
"subnet_prefix": "192.168.30",
|
||||||
|
"netmask": "255.255.255.0",
|
||||||
|
"gateway_last_octet": 1,
|
||||||
|
"dns_servers": ["192.168.30.1"],
|
||||||
|
"wifi": {
|
||||||
|
"_comment": "Used by kiosk images that have no wired connection. Leave ssid empty if every device is wired; the validator will then not complain about an empty psk.",
|
||||||
|
"ssid": "",
|
||||||
|
"psk": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"container_host": {
|
||||||
|
"_comment": "The Phase 1 machine: Home Assistant, Mosquitto, Zigbee2MQTT, Frigate, Grocy and this repo's own services. Everything else in the household points at this address.",
|
||||||
|
"hostname": "smarthome-core",
|
||||||
|
"ip_last_octet": 12,
|
||||||
|
"install_disk": "/dev/sda",
|
||||||
|
"admin_username": "amir",
|
||||||
|
"enable": {
|
||||||
|
"_comment": "Mirrors setup-container-host.sh's ENABLE_* flags. Turning one off here also stops the builders from demanding the tokens it would have needed.",
|
||||||
|
"identity": true,
|
||||||
|
"pantry_vision": true,
|
||||||
|
"digest_engine": false,
|
||||||
|
"admin_canvas": false,
|
||||||
|
"trash_calendar": false,
|
||||||
|
"transit": false,
|
||||||
|
"trip_planning": false,
|
||||||
|
"chores": true,
|
||||||
|
"ntfy": true,
|
||||||
|
"node_red": true,
|
||||||
|
"netdata": true,
|
||||||
|
"homepage": true,
|
||||||
|
"portainer": true,
|
||||||
|
"mealie": false,
|
||||||
|
"gallery_smb": false,
|
||||||
|
"music_assistant": false,
|
||||||
|
"backups": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"llm_host": {
|
||||||
|
"_comment": "The Phase 3 Ollama machine. Its address is what the container host's OLLAMA_HOST is derived from — change ip_last_octet here and every consumer follows automatically.",
|
||||||
|
"hostname": "smarthome-llm",
|
||||||
|
"ip_last_octet": 13,
|
||||||
|
"install_disk": "/dev/sda",
|
||||||
|
"admin_username": "amir",
|
||||||
|
"tier": "auto",
|
||||||
|
"text_model_gpu": "qwen2.5:14b-instruct",
|
||||||
|
"text_model_cpu": "qwen2.5:7b-instruct",
|
||||||
|
"vision_model": "llava",
|
||||||
|
"pull_vision_model": true,
|
||||||
|
"keep_alive": "30m",
|
||||||
|
"max_loaded_models": 1,
|
||||||
|
"num_parallel": 1
|
||||||
|
},
|
||||||
|
|
||||||
|
"ports": {
|
||||||
|
"_comment": "The whole published-port inventory, matching docs/network-integration.md §4. The validator FAILS on any duplicate here, which is the mechanism that keeps a collision from reaching a deployment. NOTE music_assistant: its own default is 8095, which collides with pantry_vision — and because it runs network_mode:host, Compose's own port-conflict check never fires (project-plan open decision #31). 8101 is set here to resolve that, but you must also configure Music Assistant itself to listen on it; this file cannot make it move.",
|
||||||
|
"home_assistant": 8123,
|
||||||
|
"mqtt": 1883,
|
||||||
|
"zigbee2mqtt": 8080,
|
||||||
|
"node_red": 1880,
|
||||||
|
"homepage": 3000,
|
||||||
|
"ntfy": 8090,
|
||||||
|
"portainer": 9000,
|
||||||
|
"gallery_smb": 445,
|
||||||
|
"mealie": 9925,
|
||||||
|
"frigate": 5000,
|
||||||
|
"grocy": 9283,
|
||||||
|
"digest_web": 8091,
|
||||||
|
"admin_web": 8094,
|
||||||
|
"pantry_vision": 8095,
|
||||||
|
"pantry_web": 8096,
|
||||||
|
"identity": 8097,
|
||||||
|
"identity_web": 8098,
|
||||||
|
"transit": 8099,
|
||||||
|
"otp": 8100,
|
||||||
|
"music_assistant": 8101,
|
||||||
|
"ollama": 11434
|
||||||
|
},
|
||||||
|
|
||||||
|
"secrets": {
|
||||||
|
"_comment": "Generate the tokens with: openssl rand -hex 32. Each is required only if the service that uses it is enabled above; the validator says which. ha_token is a Long-Lived Access Token from HA's own UI (profile -> Security) and cannot be generated ahead of time — leave it empty for the first build and re-run once HA is up.",
|
||||||
|
"identity_token": "",
|
||||||
|
"pantry_vision_token": "",
|
||||||
|
"transit_token": "",
|
||||||
|
"mqtt_username": "",
|
||||||
|
"mqtt_password": "",
|
||||||
|
"ha_token": "",
|
||||||
|
"ssh_authorized_key": "",
|
||||||
|
"kiosk_password": "",
|
||||||
|
"admin_password_hash": ""
|
||||||
|
},
|
||||||
|
|
||||||
|
"voice": {
|
||||||
|
"_comment": "Defaults for any kiosk with voice_satellite enabled; a kiosk may override wake_word individually.",
|
||||||
|
"wake_word": "ok_nabu"
|
||||||
|
},
|
||||||
|
|
||||||
|
"kiosks": [
|
||||||
|
{
|
||||||
|
"_comment": "type must be one of: thin-client, touch-panel, door-panel, kitchen-display. hostname must be unique and a valid DNS label — it is what the HA device shows up as.",
|
||||||
|
"type": "door-panel",
|
||||||
|
"hostname": "door-panel",
|
||||||
|
"friendly_name": "Door panel",
|
||||||
|
"kiosk_username": "kiosk",
|
||||||
|
"voice_satellite": true,
|
||||||
|
"enable_installer": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "kitchen-display",
|
||||||
|
"hostname": "kitchen-display",
|
||||||
|
"friendly_name": "Kitchen fridge display",
|
||||||
|
"kiosk_username": "kiosk",
|
||||||
|
"voice_satellite": false,
|
||||||
|
"enable_installer": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "thin-client",
|
||||||
|
"hostname": "thin-client-living",
|
||||||
|
"friendly_name": "Living room thin client",
|
||||||
|
"kiosk_username": "kiosk",
|
||||||
|
"voice_satellite": false,
|
||||||
|
"enable_installer": false,
|
||||||
|
"enable_steam_link": true,
|
||||||
|
"enable_gesture_control": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "touch-panel",
|
||||||
|
"hostname": "touch-panel-kitchen",
|
||||||
|
"friendly_name": "Kitchen touch panel",
|
||||||
|
"kiosk_username": "kiosk",
|
||||||
|
"voice_satellite": false,
|
||||||
|
"enable_installer": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"audio_endpoints": [
|
||||||
|
{
|
||||||
|
"_comment": "Headless Spotify Connect appliances for rooms with no thin client. arch picks the toolchain — and they are genuinely different toolchains producing different artifacts, not one image for both: 'amd64' is a mini PC + USB DAC built with live-build (an .iso), 'arm64' is a Raspberry Pi + HiFiBerry Amp2 built with rpi-image-gen (an .img). build-all.sh builds every entry here, so listing both architectures gets you both. hostname doubles as the Spotify Connect device name and must be unique across kiosks too — they're all devices on one network.",
|
||||||
|
"hostname": "audio-endpoint-livingroom",
|
||||||
|
"friendly_name": "Living room",
|
||||||
|
"arch": "amd64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hostname": "audio-endpoint-kitchen",
|
||||||
|
"friendly_name": "Kitchen",
|
||||||
|
"arch": "arm64"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"build": {
|
||||||
|
"_comment": "Where finished images land, relative to the repo root. Gitignored — see .gitignore — because these carry every secret in this file. arm64_prebake bakes each arm64 audio endpoint's hostname into its own image, so every device in the household is named by the build rather than typed into Raspberry Pi Imager afterwards; it costs one full rpi-image-gen run per room. Set it false to build a single generic .img and name each unit at flash time instead.",
|
||||||
|
"output_dir": "iso-out",
|
||||||
|
"arm64_prebake": true
|
||||||
|
}
|
||||||
|
}
|
||||||
53
README.md
53
README.md
|
|
@ -17,8 +17,7 @@ hosts/
|
||||||
container-host/ Docker Compose stack: HA, Mosquitto, Zigbee2MQTT,
|
container-host/ Docker Compose stack: HA, Mosquitto, Zigbee2MQTT,
|
||||||
Frigate, Grocy, Node-RED, monitoring, etc.
|
Frigate, Grocy, Node-RED, monitoring, etc.
|
||||||
configs/ Per-service config files (mosquitto.conf, etc.)
|
configs/ Per-service config files (mosquitto.conf, etc.)
|
||||||
scripts/ Host setup / bootstrap scripts
|
llm-host/ Ollama + GPU host (separate physical machine)
|
||||||
llm-host/ Ollama + GPU host setup (separate physical machine)
|
|
||||||
thin-client/ Sway kiosk/media-station ISO (live-build) + thinclient-agent
|
thin-client/ Sway kiosk/media-station ISO (live-build) + thinclient-agent
|
||||||
audio-endpoint/ Headless Spotify Connect appliance for rooms with no thin
|
audio-endpoint/ Headless Spotify Connect appliance for rooms with no thin
|
||||||
client — arm64 (rpi-image-gen) + amd64 (live-build) images
|
client — arm64 (rpi-image-gen) + amd64 (live-build) images
|
||||||
|
|
@ -40,12 +39,19 @@ firmware/
|
||||||
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)
|
||||||
|
tools/ All build + setup scripts, driven by one
|
||||||
|
CoreSystemConfig.json: twinned container-host/LLM-host
|
||||||
|
ISO pair, every kiosk ISO, and a validator that refuses
|
||||||
|
a build on port collisions, placeholder tokens, or a
|
||||||
|
kiosk pointed at a disabled service
|
||||||
identity/ Person <-> BLE-identifier registry: multi-phone support,
|
identity/ Person <-> BLE-identifier registry: multi-phone support,
|
||||||
anti-spoofing (allowlisted IRK-resolved/fixed-tag
|
anti-spoofing (allowlisted IRK-resolved/fixed-tag
|
||||||
entities only, never a raw MAC), voice/touch
|
entities only, never a raw MAC), voice/touch
|
||||||
registration, presence resolution (write API +
|
registration, presence resolution, visit history
|
||||||
frontend/ static serving, consumed by kitchen-display
|
("who was home when, with whom"), nicknames, per-device
|
||||||
and door-panel)
|
rights, and an admin panel (write API + frontend/
|
||||||
|
static serving, consumed by kitchen-display and
|
||||||
|
door-panel)
|
||||||
digest-engine/ Quarter-daily LLM digest: mail/message/news/financial
|
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
|
||||||
|
|
@ -79,8 +85,8 @@ chores/ Presence/calendar-driven household chore nudging +
|
||||||
- [ ] RuView node configs — `firmware/ruview/` documents the real upstream project ([github.com/ruvnet/ruview](https://github.com/ruvnet/ruview), integrated not forked) + a per-room provisioning wrapper + `automations.yaml.example` (sleep → dim lights, possible-distress → whole-household alert, concurrent elevated heart rate → colored lighting, bathroom occupancy → an external door indicator). **Every automation's entity_id is an unconfirmed placeholder**, and the concurrent-two-person-heart-rate rule rests on an unconfirmed assumption about RuView's multi-target vital-sign capability — see `firmware/ruview/README.md` §5–6 and `docs/project-plan.md` open decisions #32–33 before relying on any of it
|
- [ ] RuView node configs — `firmware/ruview/` documents the real upstream project ([github.com/ruvnet/ruview](https://github.com/ruvnet/ruview), integrated not forked) + a per-room provisioning wrapper + `automations.yaml.example` (sleep → dim lights, possible-distress → whole-household alert, concurrent elevated heart rate → colored lighting, bathroom occupancy → an external door indicator). **Every automation's entity_id is an unconfirmed placeholder**, and the concurrent-two-person-heart-rate rule rests on an unconfirmed assumption about RuView's multi-target vital-sign capability — see `firmware/ruview/README.md` §5–6 and `docs/project-plan.md` open decisions #32–33 before relying on any of it
|
||||||
- [ ] Frigate peephole camera config (real RTSP details)
|
- [ ] Frigate peephole camera config (real RTSP details)
|
||||||
- [ ] Grocy kiosk (Pi + touchscreen) setup
|
- [ ] Grocy kiosk (Pi + touchscreen) setup
|
||||||
- [ ] LLM host (Ollama) setup script
|
- [ ] LLM host (Ollama) setup script — `hosts/llm-host/` written: auto-detects a GPU vs CPU-only tier, runs Ollama as a pinned container (no `curl | sh` into root), pulls the Phase 3 text model + a vision model for `pantry-vision`/`chores`, and takes a reasoned position on Assist-vs-digest GPU contention (`MAX_LOADED_MODELS=1` — swap predictably rather than OOM mid-request). **Never run — no Debian machine, no GPU, no Ollama server**; the script is syntax-checked and its generated compose validated as YAML for both tiers, and that's the whole of the testing. The vision-model pick is still unmade (open decision #18), and the contention defaults are reasoned, not measured (#4) — see `hosts/llm-host/README.md`
|
||||||
- [ ] CalDAV / Nextcloud calendar integration notes
|
- [x] CalDAV / Nextcloud calendar integration notes — `docs/caldav-integration.md`: the four independent clients (digest-engine read-only, chores' busy-check read-only, trash-calendar create-only, HA's own bridge), why they share one Nextcloud **app password** and what that costs (rotation touches three env files; **the read-only invariant is a code property, not a permission boundary** — an app password can't be scoped read-only or to one calendar), `trash-calendar`'s UID-prefix ownership invariant, and the two traps that cost real debugging time (unexpanded recurrence reporting a meeting on the day it was created; `CALDAV_VERIFY_TLS=false`). Nextcloud itself is pre-existing — nothing here deploys it
|
||||||
- [x] Sway thin-client ISO (live-build) + thinclient-agent — built, not yet boot-tested on real hardware; RDP replaced by wayvnc (resolved), remaining open items (mic-enabled rooms, exact hardware target, wayvnc password provisioning) in `docs/project-plan.md` §4
|
- [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
|
||||||
|
|
@ -90,17 +96,40 @@ chores/ Presence/calendar-driven household chore nudging +
|
||||||
- [ ] 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, 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`
|
- [ ] 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`
|
- [ ] `identity` + door panel (`identity/`, `hosts/door-panel/`) — the person <-> BLE-identifier registry: "register me as `<name>`" by voice or touchscreen, multi-phone support (multiple identifiers per person), anti-spoofing (only allowlisted IRK-resolved/fixed-tag entities are ever accepted as candidates, never a raw MAC), device-less people (a "no device" flag plus a hand-operated Home/Away toggle — the concrete case: a grandmother without a smartphone), and an anonymous "Guest" path. Backs `hosts/door-panel/`'s weather+clothing/who's-home/groceries-running-low dashboard and `hosts/kitchen-display/`'s "Show registration" screen — built and wired into `setup-container-host.sh` (`ENABLE_IDENTITY`, off by default), **nothing run against a real HA instance, real Private BLE Device entities, or a real voice pipeline** — `TRUSTED_ENTITY_PREFIXES` above all needs checking against Developer Tools -> States, see `identity/README.md` and `hosts/door-panel/README.md`
|
||||||
- [ ] `identity` also corroborates presence from Frigate face recognition (Phase 20, Tapo pan/tilt cameras) — an OR-ed-in second signal only, **never** a registration signal; and gained two per-person chore-system settings (`chore_exempt`, `chore_reminder_style`, set via `POST /people/<id>/chore-settings`, no frontend for it yet) consumed by `chores/`, see `identity/README.md`
|
- [ ] `identity` also corroborates presence from Frigate face recognition (Phase 20, Tapo pan/tilt cameras) — an OR-ed-in second signal only, **never** a registration signal; and owns the per-person chore-system settings (`chore_exempt`, `chore_reminder_style`, plus chore assignment) consumed by `chores/`, see `identity/README.md`
|
||||||
|
- [ ] `identity`'s admin panel (`identity/frontend/admin.html`, Phase 6b) — managing people/guests: edit every field, **nicknames** (an input alias only — `/resolve` accepts them, but the assistant always speaks the real `speak_name`), **visit history** sampled from `/presence` plus a derived "who was home with whom" view, **"select all that last visited before `<date>`"** pruning (the filter selects, a human confirms the exact list, the filter is never re-run at delete time), **per-device rights** for self-entry via a smart lock (`identity` only ever *answers* `GET /device-access` — HA asks and HA acts, deny is the default), chore assignment, a **floorplan editor** (draw levels and rooms as polygons, map each to an HA area, and watch occupied rooms light up — resolves the long-deferred open decision #22), and **opt-in arrival push notifications** ("tell me when someone gets home", via the self-hosted ntfy this stack already runs — `identity` itself never touches the WAN; ntfy stays LAN-only and remote delivery rides a WireGuard split tunnel — see `docs/network-integration.md` §2.2 for why a DMZ/port-forward was weighed and rejected). Deliberately **not** a kiosk page and not linked from any wall panel. Covered by API-level tests; **never opened in a real browser**, and `DEPARTURE_GRACE_SECONDS` is an untuned guess — see `identity/README.md`
|
||||||
- [ ] `trash-calendar` + `transit` (Phase 19, Kennelbach AT trash pickup + Vorarlberg public transit) — built and wired into `setup-container-host.sh` (`ENABLE_TRASH_CALENDAR`/`ENABLE_TRANSIT`/`ENABLE_TRIP_PLANNING`, all off by default), **nothing run against a live ICS feed, a live GTFS feed, or a real OpenTripPlanner instance** — trip planning also needs a manually-built OTP graph this repo does not build for you, see `trash-calendar/README.md` and `transit/README.md`'s "Route planning scope"
|
- [ ] `trash-calendar` + `transit` (Phase 19, Kennelbach AT trash pickup + Vorarlberg public transit) — built and wired into `setup-container-host.sh` (`ENABLE_TRASH_CALENDAR`/`ENABLE_TRANSIT`/`ENABLE_TRIP_PLANNING`, all off by default), **nothing run against a live ICS feed, a live GTFS feed, or a real OpenTripPlanner instance** — trip planning also needs a manually-built OTP graph this repo does not build for you, see `trash-calendar/README.md` and `transit/README.md`'s "Route planning scope"
|
||||||
- [ ] `chores` (Phase 20) — presence/calendar-driven household chore nudging: "I don't care who does it, as long as it gets done" — nudges whoever's home, redirects to someone else if a chore goes neglected, keeps a passive fairness tally that never feeds back into who gets nudged, and camera-checks trash bins/dishes/litter via Frigate + an Ollama vision model. Built and wired into `setup-container-host.sh` (`ENABLE_CHORES`, off by default, every-2-hours systemd timer), **no Tapo camera hardware chosen and nothing run against real hardware**, see `chores/README.md`
|
- [ ] `chores` (Phase 20) — presence/calendar-driven household chore nudging: "I don't care who does it, as long as it gets done" — prefers whoever's been assigned a chore in `identity`'s admin panel but falls through to whoever's actually home rather than waiting (`CHORE_ASSIGNMENT_STRICT` flips that), redirects to someone else if a chore goes neglected, keeps a passive fairness tally that never feeds back into who gets nudged, and camera-checks trash bins/dishes/litter via Frigate + an Ollama vision model. **Litter remains the exception to everything** — it ignores both chore-exemption and assignment, because cleaning up what you left out was never a task anyone could be assigned. Built and wired into `setup-container-host.sh` (`ENABLE_CHORES`, off by default, every-2-hours systemd timer), **no Tapo camera hardware chosen and nothing run against real hardware**, see `chores/README.md`
|
||||||
- [ ] Music Assistant (optional, additive multi-room audio) — wired into `setup-container-host.sh` (`ENABLE_MUSIC_ASSISTANT`, off by default), **its default port is an unverified guess that collides with `PANTRY_VISION_PORT`** if both are enabled together, see `docs/project-plan.md` open decision #31
|
- [ ] Music Assistant (optional, additive multi-room audio) — wired into `setup-container-host.sh` (`ENABLE_MUSIC_ASSISTANT`, off by default), **its default port is an unverified guess that collides with `PANTRY_VISION_PORT`** if both are enabled together, see `docs/project-plan.md` open decision #31
|
||||||
- [ ] `docs/network-integration.md` (OPNsense VLAN segmentation + why nothing here should be port-forwarded to the WAN) — written, not run against a real OPNsense instance
|
- [ ] `docs/network-integration.md` (OPNsense VLAN segmentation, the WireGuard split tunnel that carries arrival notifications, and why nothing here — ntfy included — gets port-forwarded to the WAN) — written, not run against a real OPNsense instance
|
||||||
|
- [ ] `tools/` + `CoreSystemConfig.json` — every build and setup script in one place, reading one config. The container host and LLM host build as a **twinned pair**: you set two last octets and the container host's `OLLAMA_HOST` is *derived* from the LLM host's, so the two ISOs cannot be built disagreeing about where the other one is; every kiosk's service URLs derive from the container host's address the same way. `build-all.sh` builds the set, `validate-config.py` refuses a build on duplicate ports (the `music_assistant`/`pantry_vision` 8095 clash, open decision #31), placeholder or padded tokens, duplicate hostnames, or a kiosk pointed at a disabled service. All secrets are burnt into the images so installs are unattended — **which makes every ISO a credential**; the filled-in config and `iso-out/` are gitignored. **No ISO has ever been built with this** (`lb build` needs live-build, root and a long fetch) — what is tested is config validation/derivation and every generated artifact, with `lb` stubbed. See `tools/README.md`
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
Everything is built from **one** config file — `CoreSystemConfig.json` — so an address
|
||||||
|
or token can only ever be wrong in one place:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd hosts/container-host/scripts
|
cp CoreSystemConfig.json.template CoreSystemConfig.json
|
||||||
sudo ./setup-container-host.sh
|
$EDITOR CoreSystemConfig.json # subnet, two host octets, tokens, your kiosks
|
||||||
|
tools/validate-config.py # catches typos in seconds, not after a 40-min build
|
||||||
|
sudo -E tools/build-all.sh # every ISO, all agreeing with each other
|
||||||
|
```
|
||||||
|
|
||||||
|
The container host and LLM host come out as a **twinned pair**: you give each a last
|
||||||
|
octet, and the container host's `OLLAMA_HOST` is *derived* from the LLM host's — change
|
||||||
|
one and the other follows on the next build, with nothing to keep in sync by hand. Every
|
||||||
|
kiosk's service URLs derive from the container host's address the same way. See
|
||||||
|
[`tools/README.md`](tools/README.md).
|
||||||
|
|
||||||
|
Built ISOs contain every secret in the config, by design (nothing to configure
|
||||||
|
post-install) — which makes each one a credential. `.gitignore` covers both the
|
||||||
|
filled-in config and `iso-out/`.
|
||||||
|
|
||||||
|
To set up a host by hand instead of from an ISO:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -E tools/setup-container-host.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit the variables at the top of the script first (timezone, Zigbee USB device
|
Edit the variables at the top of the script first (timezone, Zigbee USB device
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ module docstring for the full reasoning on each:
|
||||||
FULL/PARTIAL/EMPTY", "is this counter DIRTY/CLEAN", "is there litter left out
|
FULL/PARTIAL/EMPTY", "is this counter DIRTY/CLEAN", "is there litter left out
|
||||||
here, YES/NO"), opens a chore on "needs attention," auto-closes one on "clear."
|
here, YES/NO"), opens a chore on "needs attention," auto-closes one on "clear."
|
||||||
3. **Nudging** — ASAP, not on a fixed schedule: the first run after a chore opens
|
3. **Nudging** — ASAP, not on a fixed schedule: the first run after a chore opens
|
||||||
nudges whoever `identity` reports home right now (minus anyone `chore_exempt`,
|
nudges whoever `identity` reports home right now — preferring anyone **assigned**
|
||||||
see below). If the chore is still open `NEGLECT_THRESHOLD_HOURS` after the last
|
that chore type, minus anyone `chore_exempt`, both below. If the chore is still open `NEGLECT_THRESHOLD_HOURS` after the last
|
||||||
nudge (and the household calendar isn't showing a busy window), the nudge goes
|
nudge (and the household calendar isn't showing a busy window), the nudge goes
|
||||||
to **someone different from who was last asked** — "the next person that walks
|
to **someone different from who was last asked** — "the next person that walks
|
||||||
by" — rather than re-nagging the same person. `litter` chores are special-cased
|
by" — rather than re-nagging the same person. `litter` chores are special-cased
|
||||||
|
|
@ -34,10 +34,32 @@ module docstring for the full reasoning on each:
|
||||||
below. Each nudge's wording is a plain template unless the target has a
|
below. Each nudge's wording is a plain template unless the target has a
|
||||||
`chore_reminder_style` set, see below.
|
`chore_reminder_style` set, see below.
|
||||||
|
|
||||||
|
## Assignment is a preference, not a lock
|
||||||
|
|
||||||
|
Set in `identity`'s admin panel (`admin.html`, People → a person → Chores), stored as
|
||||||
|
that person's `chore_assignments` and read off the same `/presence` call as everything
|
||||||
|
else here.
|
||||||
|
|
||||||
|
When someone assigned to a chore type **is home**, they get nudged instead of whoever
|
||||||
|
happens to be nearest — that's the point of assigning it. When **no assignee is home**,
|
||||||
|
the nudge falls through to the ordinary whoever's-here rotation rather than waiting,
|
||||||
|
because the stated principle is still *"I don't care who does it, as long as it gets
|
||||||
|
done."* An absent assignee doesn't get to be the reason the bin stays full.
|
||||||
|
|
||||||
|
Set **`CHORE_ASSIGNMENT_STRICT=true`** if your household wants the opposite: the chore
|
||||||
|
waits for its assignee and nobody else is asked. Both readings of "assign chores" are
|
||||||
|
legitimate and this file can't pick for you, so it's one env var rather than a
|
||||||
|
hard-coded opinion. Strict mode still never stalls a chore that was assigned to
|
||||||
|
*nobody* — that falls through regardless.
|
||||||
|
|
||||||
|
**Litter can't be assigned** (`_ASSIGNMENTS_DONT_APPLY` in `check.py`), for the same
|
||||||
|
reason it ignores exemptions: it goes to whoever left the mess, and cleaning up after
|
||||||
|
yourself was never a task anyone could be handed.
|
||||||
|
|
||||||
## Chore-exempt people — everyone except litter
|
## Chore-exempt people — everyone except litter
|
||||||
|
|
||||||
Set via `identity`'s `POST /people/<id>/chore-settings` (see identity/README.md,
|
Set in the same place (`identity`'s admin panel, or `POST /people/<id>/chore-settings`
|
||||||
no frontend for it yet — call the endpoint directly). A `chore_exempt` person is
|
directly). A `chore_exempt` person is
|
||||||
dropped from the nudge rotation entirely — the "cousin visits often but doesn't owe
|
dropped from the nudge rotation entirely — the "cousin visits often but doesn't owe
|
||||||
me chores" case. **Litter is the deliberate exception** (`_EXEMPTIONS_DONT_APPLY`
|
me chores" case. **Litter is the deliberate exception** (`_EXEMPTIONS_DONT_APPLY`
|
||||||
in `check.py`): an exempt person still gets told to put trash they left out into
|
in `check.py`): an exempt person still gets told to put trash they left out into
|
||||||
|
|
@ -114,8 +136,10 @@ ingestion.
|
||||||
- **No re-check of `_compose_message()`'s LLM output** — whatever the model
|
- **No re-check of `_compose_message()`'s LLM output** — whatever the model
|
||||||
returns (if anything) is sent as-is, no validation that it's actually on-topic,
|
returns (if anything) is sent as-is, no validation that it's actually on-topic,
|
||||||
on-tone, or even non-empty garbage beyond the plain empty-string fallback check.
|
on-tone, or even non-empty garbage beyond the plain empty-string fallback check.
|
||||||
- **No frontend for setting `chore_exempt`/`chore_reminder_style`** — set via a
|
- **No confirmation that an assignee actually did it** — assignment changes who gets
|
||||||
direct `POST /people/<id>/chore-settings` call to `identity` until one exists.
|
asked, not who gets credited. Attribution when a chore closes is still the same
|
||||||
|
best-effort "whoever was nudged most recently" heuristic, which an assignment doesn't
|
||||||
|
make any more truthful. See "The tally is passive" above.
|
||||||
|
|
||||||
## Configure
|
## Configure
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,24 @@ exception** (`_EXEMPTIONS_DONT_APPLY` below): everyone, exempt or not, still get
|
||||||
told to put trash they left out into the bin — that isn't "doing a chore," it's
|
told to put trash they left out into the bin — that isn't "doing a chore," it's
|
||||||
cleaning up after yourself.
|
cleaning up after yourself.
|
||||||
|
|
||||||
|
## Assignment is a preference, not a lock
|
||||||
|
|
||||||
|
`identity`'s per-person `chore_assignments` (set in its admin panel, read off the same
|
||||||
|
`/presence` call as everything else here) says who *owes* a given chore type. When
|
||||||
|
someone assigned to a chore is home, they get nudged instead of whoever happens to be
|
||||||
|
nearest — that's the whole point of assigning it.
|
||||||
|
|
||||||
|
**But an assignee who isn't home doesn't block the chore.** The stated principle above
|
||||||
|
is "I don't care who does it, as long as it gets done," so if nobody assigned is
|
||||||
|
around, the nudge falls through to the ordinary whoever's-here rotation rather than
|
||||||
|
waiting. Set `CHORE_ASSIGNMENT_STRICT=true` if you'd rather it wait for the assignee —
|
||||||
|
that's the honest opposite reading of the same feature, and which one a household
|
||||||
|
wants isn't something this file can decide for it.
|
||||||
|
|
||||||
|
**`litter` ignores assignment entirely** (`_ASSIGNMENTS_DONT_APPLY`), for the same
|
||||||
|
reason it ignores exemptions: it goes to whoever left the mess, and "cleaning up after
|
||||||
|
yourself" was never a task anyone could be assigned in the first place.
|
||||||
|
|
||||||
`identity`'s `chore_reminder_style` free-text field (same endpoint) is passed to an
|
`identity`'s `chore_reminder_style` free-text field (same endpoint) is passed to an
|
||||||
LLM that **phrases** the ntfy message in that person's preferred tone ("be
|
LLM that **phrases** the ntfy message in that person's preferred tone ("be
|
||||||
assertive," "be gentle, give me a few minutes of grace") — see `_compose_message()`.
|
assertive," "be gentle, give me a few minutes of grace") — see `_compose_message()`.
|
||||||
|
|
@ -135,6 +153,18 @@ _ATTRIBUTE_TO_RECENT_VIEWER = {"litter"}
|
||||||
# — so they're kept as separate names rather than reusing one for both purposes.
|
# — so they're kept as separate names rather than reusing one for both purposes.
|
||||||
_EXEMPTIONS_DONT_APPLY = {"litter"}
|
_EXEMPTIONS_DONT_APPLY = {"litter"}
|
||||||
|
|
||||||
|
# Chore types that can't be assigned to anyone — see the module docstring's
|
||||||
|
# "Assignment is a preference, not a lock". Third set with the same one member as the
|
||||||
|
# two above, and kept separate for the third distinct reason: attribution, then
|
||||||
|
# eligibility, now assignability. If they ever diverge (a chore that's assignable but
|
||||||
|
# exempt-proof, say) collapsing them now would be the thing that made that painful.
|
||||||
|
_ASSIGNMENTS_DONT_APPLY = {"litter"}
|
||||||
|
|
||||||
|
# Whether an assigned person who ISN'T home blocks the chore from falling through to
|
||||||
|
# whoever is. Default false — "as long as it gets done" is the house rule; true makes
|
||||||
|
# assignment binding instead. See the module docstring.
|
||||||
|
CHORE_ASSIGNMENT_STRICT = os.environ.get("CHORE_ASSIGNMENT_STRICT", "false").strip().lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
def _now() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
@ -317,10 +347,15 @@ def check_cameras(conn) -> None:
|
||||||
|
|
||||||
# --- 3. Nudging — presence/calendar-driven, "whoever's around," see module docstring
|
# --- 3. Nudging — presence/calendar-driven, "whoever's around," see module docstring
|
||||||
def _presence() -> list[dict]:
|
def _presence() -> list[dict]:
|
||||||
"""Each dict: {"name", "home", "room", "face_seen_recently", "chore_exempt",
|
"""Each dict: {"name", "speak_name", "nickname", "home", "room",
|
||||||
"chore_reminder_style"} — the raw shape identity's own /presence returns. Empty
|
"face_seen_recently", "chore_exempt", "chore_reminder_style", "chore_assignments"}
|
||||||
list (never raises past this point) if identity is unreachable — nudging just
|
— the raw shape identity's own /presence returns. Empty list (never raises past
|
||||||
waits for the next run.
|
this point) if identity is unreachable — nudging just waits for the next run.
|
||||||
|
|
||||||
|
Everything this module needs about a person arrives in this one call, including
|
||||||
|
who's assigned what; there's no second lookup per person. (`_anyone_assigned()`
|
||||||
|
does make one extra call, but only in strict mode and only about a chore type, not
|
||||||
|
a person.)
|
||||||
"""
|
"""
|
||||||
if not IDENTITY_URL or not IDENTITY_TOKEN:
|
if not IDENTITY_URL or not IDENTITY_TOKEN:
|
||||||
return []
|
return []
|
||||||
|
|
@ -365,6 +400,34 @@ def _household_currently_busy() -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _anyone_assigned(home: list[dict], chore_type: str) -> bool:
|
||||||
|
"""Is this chore type assigned to ANYONE — including people who aren't home?
|
||||||
|
|
||||||
|
Only consulted in CHORE_ASSIGNMENT_STRICT mode, and only once the cheaper check
|
||||||
|
(is an assignee home?) has already come back empty, which is why it's allowed to
|
||||||
|
cost an extra request. The distinction it draws matters: strict mode should wait
|
||||||
|
for an absent assignee, but must not wait forever on a chore nobody was ever
|
||||||
|
assigned — that would silently stop unassigned chores from being nudged at all.
|
||||||
|
|
||||||
|
Fails OPEN (returns False, i.e. "nobody's assigned, go ahead and nudge whoever's
|
||||||
|
around") if identity can't be reached — same rule as the calendar busy-check: a
|
||||||
|
broken lookup must never be the reason chores stop getting done.
|
||||||
|
"""
|
||||||
|
if any(chore_type in (p.get("chore_assignments") or []) for p in home):
|
||||||
|
return True
|
||||||
|
if not IDENTITY_URL or not IDENTITY_TOKEN:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(f"{IDENTITY_URL}/chore-assignments")
|
||||||
|
req.add_header("Authorization", f"Bearer {IDENTITY_TOKEN}")
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
return bool((data.get("assignments") or {}).get(chore_type))
|
||||||
|
except Exception:
|
||||||
|
LOG.warning("chores: could not reach identity for /chore-assignments", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _likely_culprit(candidates: list[dict]) -> dict | None:
|
def _likely_culprit(candidates: list[dict]) -> dict | None:
|
||||||
"""For litter-type chores: prefer whoever was MOST RECENTLY seen by camera face
|
"""For litter-type chores: prefer whoever was MOST RECENTLY seen by camera face
|
||||||
recognition — a best-effort "who was just here" guess, not a certainty. Falls
|
recognition — a best-effort "who was just here" guess, not a certainty. Falls
|
||||||
|
|
@ -408,6 +471,24 @@ def nudge_open_chores(conn) -> None:
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Whoever's actually been assigned this chore type in identity's admin panel,
|
||||||
|
# and is home right now — see the module docstring's "Assignment is a
|
||||||
|
# preference, not a lock" for why an empty list here doesn't stop the nudge.
|
||||||
|
assigned = (
|
||||||
|
[]
|
||||||
|
if chore["type"] in _ASSIGNMENTS_DONT_APPLY
|
||||||
|
else [p for p in eligible if chore["type"] in (p.get("chore_assignments") or [])]
|
||||||
|
)
|
||||||
|
if assigned:
|
||||||
|
eligible = assigned
|
||||||
|
elif CHORE_ASSIGNMENT_STRICT and _anyone_assigned(home, chore["type"]):
|
||||||
|
LOG.info(
|
||||||
|
"chores: %r chore #%d is assigned, but no assignee is home and "
|
||||||
|
"CHORE_ASSIGNMENT_STRICT is on — waiting rather than redirecting",
|
||||||
|
chore["type"], chore["id"],
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# "The next person that walks by": prefer someone home right now who ISN'T
|
# "The next person that walks by": prefer someone home right now who ISN'T
|
||||||
# who we last nudged (a real redirect, not the same person nagged again) —
|
# who we last nudged (a real redirect, not the same person nagged again) —
|
||||||
# falls back to re-nudging the same person if they're genuinely the only
|
# falls back to re-nudging the same person if they're genuinely the only
|
||||||
|
|
@ -420,7 +501,12 @@ def nudge_open_chores(conn) -> None:
|
||||||
different = [p for p in eligible if p["name"] != last_nudged]
|
different = [p for p in eligible if p["name"] != last_nudged]
|
||||||
target = (different or eligible)[0]
|
target = (different or eligible)[0]
|
||||||
|
|
||||||
name = target["name"]
|
# identity's speak_name is ALWAYS the person's real name, never a nickname
|
||||||
|
# they've been given — see identity/server.py's module docstring. Reminders go
|
||||||
|
# out as text and get read aloud by whatever's showing them, so this is one of
|
||||||
|
# the consumers that rule exists for. Falls back to `name` for an identity
|
||||||
|
# older than the nickname feature.
|
||||||
|
name = target.get("speak_name") or target["name"]
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE chores SET assigned_to = ?, assigned_at = ?, reminder_count = reminder_count + 1, "
|
"UPDATE chores SET assigned_to = ?, assigned_at = ?, reminder_count = reminder_count + 1, "
|
||||||
"last_reminder_at = ? WHERE id = ?",
|
"last_reminder_at = ? WHERE id = ?",
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,17 @@
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# identity — required for nudging (who's home, who's near which room, who was
|
# identity — required for nudging (who's home, who's near which room, who was
|
||||||
# recently seen by a camera). Same token identity itself uses.
|
# recently seen by a camera, and who's assigned which chore). Same token identity
|
||||||
|
# itself uses.
|
||||||
|
#
|
||||||
|
# NOTE: this was `http://127.0.0.1:8097` and could never have worked — inside the
|
||||||
|
# chores container that address is the chores container itself. Since _presence()
|
||||||
|
# fails soft (logs, returns an empty list, waits for the next run), the symptom would
|
||||||
|
# have been nudging that simply never happened, with no error anywhere obvious.
|
||||||
|
# identity is an ordinary service on the same compose bridge network, so its
|
||||||
|
# container name resolves via Docker's DNS.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
IDENTITY_URL=http://127.0.0.1:8097
|
IDENTITY_URL=http://identity:8097
|
||||||
IDENTITY_TOKEN=
|
IDENTITY_TOKEN=
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -73,9 +81,29 @@ OLLAMA_TEXT_MODEL=
|
||||||
# redirecting the nudge to someone else who's around ("the next person that walks
|
# redirecting the nudge to someone else who's around ("the next person that walks
|
||||||
# by") instead of re-nagging whoever was last nudged.
|
# by") instead of re-nagging whoever was last nudged.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
NTFY_URL=http://127.0.0.1:8090
|
# NOTE: this was `http://127.0.0.1:8090` and could never have worked — inside the
|
||||||
|
# chores container, 127.0.0.1 is the chores container itself, not the host, so every
|
||||||
|
# nudge would have failed to send (silently: _notify() logs and moves on). ntfy is an
|
||||||
|
# ordinary service on the same compose bridge network, so its container name resolves
|
||||||
|
# via Docker's DNS, on its INTERNAL port 80 rather than the 8090 published to the LAN.
|
||||||
|
NTFY_URL=http://ntfy
|
||||||
NTFY_TOPIC=chores
|
NTFY_TOPIC=chores
|
||||||
NEGLECT_THRESHOLD_HOURS=4
|
NEGLECT_THRESHOLD_HOURS=4
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CHORE_ASSIGNMENT_STRICT: what an assignment (set in identity's admin panel)
|
||||||
|
# actually means when the assigned person isn't home.
|
||||||
|
#
|
||||||
|
# false (default) — nudge whoever IS home instead. The house rule stays "I don't
|
||||||
|
# care who does it, as long as it gets done"; an absent assignee
|
||||||
|
# doesn't get to be the reason the bin stays full.
|
||||||
|
# true — wait for the assignee; nobody else is asked.
|
||||||
|
#
|
||||||
|
# Both are legitimate readings of "let me assign chores" — pick the one your
|
||||||
|
# household means. Either way, a chore assigned to nobody still falls through to the
|
||||||
|
# ordinary rotation, and litter ignores assignment entirely (see README.md).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
CHORE_ASSIGNMENT_STRICT=false
|
||||||
|
|
||||||
CHORES_DB_PATH=/data/chores.db
|
CHORES_DB_PATH=/data/chores.db
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
|
||||||
|
|
@ -310,7 +310,7 @@ an exact match would drop the feature on precisely the days the digest is late.
|
||||||
|
|
||||||
Set `DIGEST_FORCE_EVENING=true` for a one-off run to test it at any hour.
|
Set `DIGEST_FORCE_EVENING=true` for a one-off run to test it at any hour.
|
||||||
Keep `DIGEST_SCHEDULE` in step with the variable of the same name in
|
Keep `DIGEST_SCHEDULE` in step with the variable of the same name in
|
||||||
`hosts/container-host/scripts/setup-container-host.sh`, which is what sets the
|
`tools/setup-container-host.sh`, which is what sets the
|
||||||
timer.
|
timer.
|
||||||
|
|
||||||
## Merging an unviewed digest into the next one
|
## Merging an unviewed digest into the next one
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
# CalDAV / Nextcloud calendar integration
|
||||||
|
|
||||||
|
Notes for [Phase 8](project-plan.md) and for the three services in this repo that
|
||||||
|
talk to the household calendar directly.
|
||||||
|
|
||||||
|
**This repo does not deploy Nextcloud.** It's listed in the project plan's hardware
|
||||||
|
table as an *already running* household service (`§ "Nextcloud instance | Calendar
|
||||||
|
backend (CalDAV sync target)"`), and `setup-container-host.sh` never touches it.
|
||||||
|
Everything below assumes you already have one and are pointing this stack at it.
|
||||||
|
|
||||||
|
Like `network-integration.md`, this is **guidance, not automation** — nothing under
|
||||||
|
this repo configures Nextcloud or Home Assistant for you.
|
||||||
|
|
||||||
|
## Who talks to the calendar, and in which direction
|
||||||
|
|
||||||
|
Four independent clients, and the direction of each matters more than the count:
|
||||||
|
|
||||||
|
| Client | Direction | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| `digest-engine/ingest/caldav.py` | **read-only** | Pulls events in the digest window for the household section of the quarter-daily digest |
|
||||||
|
| `chores/check.py` (`_household_currently_busy()`) | **read-only** | Asks "is there a currently-active event whose summary matches a quiet keyword?" before nudging anyone |
|
||||||
|
| `trash-calendar/sync.py` | **write** (create only) | Writes bin-collection dates from the municipal ICS feed onto the household calendar |
|
||||||
|
| Home Assistant's CalDAV integration | read/write | The general-purpose bridge — `calendar.*` entities, `calendar.create_event` |
|
||||||
|
|
||||||
|
These are **four separate connections to the same server**, not one shared client. That
|
||||||
|
is deliberate: `chores` runs as a oneshot timer job with no HTTP surface, `digest-engine`
|
||||||
|
runs on its own schedule, and neither should have to be up for the other to work.
|
||||||
|
|
||||||
|
## One app password, not four
|
||||||
|
|
||||||
|
All three of this repo's services deliberately read the **same** environment variable
|
||||||
|
names — `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`, `CALDAV_VERIFY_TLS` — and
|
||||||
|
expect the **same single Nextcloud app password** pasted into each of their env files.
|
||||||
|
|
||||||
|
Create it at: **Nextcloud → Settings → Security → Devices & sessions → Create new app
|
||||||
|
password.**
|
||||||
|
|
||||||
|
**Not the account password.** An app password is revocable on its own, scoped to this
|
||||||
|
one integration, and keeps the account password out of files on the container host.
|
||||||
|
It's also *mandatory* once two-factor authentication is on: the DAV endpoints have no
|
||||||
|
way to prompt for a second factor, so a 2FA account simply cannot authenticate to
|
||||||
|
CalDAV with its normal password.
|
||||||
|
|
||||||
|
### What that choice actually costs
|
||||||
|
|
||||||
|
Sharing one credential is the right call at household scale — four credentials to
|
||||||
|
create, rotate and revoke, for four clients that are all equally trusted, is
|
||||||
|
bookkeeping without a security gain. But be clear about the two consequences:
|
||||||
|
|
||||||
|
1. **Rotating it means editing three files.** `digest-engine.env`, `chores.env`, and
|
||||||
|
`trash-calendar.env` on the container host, plus re-entering it in Home Assistant's
|
||||||
|
CalDAV integration. Nothing propagates it for you, and a service left with the old
|
||||||
|
value fails *quietly* — every one of these clients is built to degrade rather than
|
||||||
|
crash, so a stale credential looks like "the calendar had nothing to say."
|
||||||
|
2. **The read-only invariant is a code property, not a permission boundary.** A
|
||||||
|
Nextcloud app password cannot be scoped to read-only, nor to a single calendar. The
|
||||||
|
credential `digest-engine` holds is fully capable of deleting every event you own —
|
||||||
|
what stops it is that `ingest/caldav.py` only ever issues reads, an invariant
|
||||||
|
asserted in its own docstring and in the project plan's Phase 12 rule. If that
|
||||||
|
module ever grew a write, no permission on the server side would catch it. Treat
|
||||||
|
changes to those files accordingly.
|
||||||
|
|
||||||
|
## What each service needs beyond the shared four
|
||||||
|
|
||||||
|
| Variable | Used by | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `CALDAV_CALENDARS` | digest-engine | Which calendars to read (blank = all discovered) |
|
||||||
|
| `CALDAV_LOOKAHEAD_HOURS` | digest-engine | How far forward to look; the window is deliberately asymmetric (back over the digest window, forward over this) because a calendar is mostly useful forwards |
|
||||||
|
| `CALDAV_MAX_EVENTS` | digest-engine | Cap on events fed into the LLM context |
|
||||||
|
| `CALDAV_QUIET_KEYWORDS` | chores | Summary substrings that mean "don't nudge right now" — default `busy,meeting,call,movie,sleep` |
|
||||||
|
| `CALDAV_TARGET_CALENDAR` | trash-calendar | The **one** calendar it writes into, by display name |
|
||||||
|
|
||||||
|
`CALDAV_TARGET_CALENDAR` being singular is intentional: the read paths can happily
|
||||||
|
span several calendars, but a writer that had to *guess* which of several calendars a
|
||||||
|
bin-collection event belongs in would eventually guess wrong, in someone else's
|
||||||
|
calendar.
|
||||||
|
|
||||||
|
## The write path's ownership invariant
|
||||||
|
|
||||||
|
`trash-calendar` is the only thing here that writes, and it constrains itself to
|
||||||
|
events it created:
|
||||||
|
|
||||||
|
- Every event it creates gets a UID prefixed `smartesthome-trash-`, derived
|
||||||
|
deterministically from the source feed's own content.
|
||||||
|
- It only ever *creates* under that prefix, and only ever checks for existence before
|
||||||
|
creating.
|
||||||
|
- It never reads, modifies, or deletes anything else in the target calendar.
|
||||||
|
- A re-run with nothing new in the feed touches nothing at all.
|
||||||
|
|
||||||
|
So the blast radius of a bug there is "duplicate or missing bin-day events", not
|
||||||
|
"someone's appointments are gone". If you add another writer later, copy this shape.
|
||||||
|
|
||||||
|
## URL form
|
||||||
|
|
||||||
|
Point `CALDAV_URL` at Nextcloud's **DAV root**:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://cloud.example.com/remote.php/dav
|
||||||
|
```
|
||||||
|
|
||||||
|
The `caldav` library discovers the principal and its calendars from there. Nextcloud
|
||||||
|
also documents the per-user form
|
||||||
|
(`https://<host>/remote.php/dav/principals/users/<username>/`); either works.
|
||||||
|
|
||||||
|
## Two traps worth knowing before you debug them
|
||||||
|
|
||||||
|
**Recurring events without expansion.** A weekly recurring event fetched without
|
||||||
|
`expand=True` comes back *once*, as its original master VEVENT carrying an RRULE — so
|
||||||
|
a naive client reports the meeting on the day it was first created, possibly years
|
||||||
|
ago, and nothing looks obviously broken. `digest-engine` passes `expand=True` and
|
||||||
|
retries for servers that reject it outright. Any new client needs the same.
|
||||||
|
|
||||||
|
**`CALDAV_VERIFY_TLS=false` is for a self-signed internal cert, and nothing else.**
|
||||||
|
It disables certificate verification entirely, which on a LAN-only Nextcloud behind
|
||||||
|
your own CA is a reasonable trade, and on anything reachable beyond the LAN is a
|
||||||
|
straightforward man-in-the-middle hole. If your Nextcloud has a real certificate —
|
||||||
|
and it should, Let's Encrypt is free — leave this `true`.
|
||||||
|
|
||||||
|
## Home Assistant's own integration
|
||||||
|
|
||||||
|
Separate from this repo's three clients, and set up in HA's UI rather than by anything
|
||||||
|
here: **Settings → Devices & Services → Add Integration → CalDAV**, same URL and app
|
||||||
|
password.
|
||||||
|
|
||||||
|
Phase 8's own guidance still stands: **create recurring events directly in Nextcloud,
|
||||||
|
not through HA**, and gate delete/move actions behind a confirmation step. HA's CalDAV
|
||||||
|
bridge is good at reading and at creating simple one-off events; recurrence rules are
|
||||||
|
where the impedance mismatch between the integration and the server shows up.
|
||||||
|
|
||||||
|
## Failure behaviour
|
||||||
|
|
||||||
|
Every client here fails soft, which is correct and also means an outage is quiet:
|
||||||
|
|
||||||
|
| Client | If Nextcloud is unreachable |
|
||||||
|
|---|---|
|
||||||
|
| digest-engine | Household section renders without calendar content |
|
||||||
|
| chores | `_household_currently_busy()` returns "not busy" — **fails open on purpose**, so a broken calendar check can never be the reason chores stop being nudged |
|
||||||
|
| trash-calendar | The sync run is skipped; tomorrow's timer tries again |
|
||||||
|
|
||||||
|
There is no alerting on any of this. A Nextcloud that's been down for a week looks
|
||||||
|
identical to a week with no calendar events — the same "silence isn't a signal" gap
|
||||||
|
noted in the project plan's open decision #38.
|
||||||
|
|
||||||
|
## Still unverified
|
||||||
|
|
||||||
|
1. **None of this has been run against a real Nextcloud instance** from this repo.
|
||||||
|
`digest-engine/ingest/caldav.py` is written against the `caldav` library's
|
||||||
|
documented API (sourced and dated in its own docstring); `chores`' busy-check and
|
||||||
|
`trash-calendar`'s write path have never been executed against a live server at all.
|
||||||
|
2. **`CALDAV_TARGET_CALENDAR` matching is by display name**, which is whatever the
|
||||||
|
calendar is called in Nextcloud's UI — not a URL or an ID. Renaming the calendar
|
||||||
|
silently breaks the trash sync.
|
||||||
|
3. **Nobody has checked what happens when the app password is revoked** mid-operation
|
||||||
|
— expected to surface as an auth error each client swallows into its normal
|
||||||
|
degrade path, i.e. silently, but that's reasoning rather than observation.
|
||||||
|
4. **The quiet-keyword list is a guess at how this household actually labels events**
|
||||||
|
(`busy,meeting,call,movie,sleep`). It matches on substrings of the summary, so an
|
||||||
|
event called "Call with the bank" pauses every chore nudge in the house for its
|
||||||
|
duration — which may or may not be what you want.
|
||||||
|
|
@ -48,6 +48,56 @@ side per new service this repo adds later.
|
||||||
If you don't need remote access at all, skip this section entirely — every service
|
If you don't need remote access at all, skip this section entirely — every service
|
||||||
in this stack works purely on the LAN with zero WAN configuration.
|
in this stack works purely on the LAN with zero WAN configuration.
|
||||||
|
|
||||||
|
### 2.1 Split tunnel — route the smart-home VLAN, nothing else
|
||||||
|
|
||||||
|
`AllowedIPs` on the **client** profile is both the route table and WireGuard's crypto
|
||||||
|
ACL, so it alone decides what enters the tunnel. Route the smart-home VLAN and leave
|
||||||
|
everything else on the phone's own connection:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Peer]
|
||||||
|
AllowedIPs = 192.168.30.0/24 # the smart-home VLAN, adjust to your real subnet
|
||||||
|
# NOT 0.0.0.0/0 (sends all your traffic home for no reason)
|
||||||
|
# NOT 192.168.0.0/16 — see the collision warning below
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never route the whole `192.168.0.0/16`.** Practically every café, hotel and hotspot
|
||||||
|
LAN lives on `192.168.0.0/24` or `192.168.1.0/24`; routing the entire /16 makes the
|
||||||
|
phone send *that* network's own gateway down the tunnel, and you lose connectivity on
|
||||||
|
that Wi-Fi altogether. Route only your actual VLAN prefix — and if your home LAN is
|
||||||
|
itself on `192.168.1.0/24`, put the smart-home VLAN somewhere unlikely
|
||||||
|
(`192.168.73.0/24`, say) so the clash can't arise in the first place.
|
||||||
|
|
||||||
|
Two client-side settings that are easy to get wrong together:
|
||||||
|
|
||||||
|
- **Android "always-on VPN": on. "Block connections without VPN" (lockdown): OFF.**
|
||||||
|
Lockdown drops everything not traversing the tunnel, which with a split tunnel kills
|
||||||
|
all non-`192.168.x` traffic on the device.
|
||||||
|
- **Leave `DNS =` unset.** Reach services by LAN IP (`http://192.168.30.x:8090` for
|
||||||
|
ntfy). No internal DNS needed, and your DNS queries don't get pulled home while
|
||||||
|
you're out.
|
||||||
|
|
||||||
|
This is just §3's rule 4 (route WireGuard peers into the smart-home VLAN, not Trusted
|
||||||
|
LAN) expressed on the client side.
|
||||||
|
|
||||||
|
## 2.2 Decided: ntfy stays LAN-only, no DMZ, no port forward
|
||||||
|
|
||||||
|
`identity`'s arrival notifications (Phase 6b — "tell me when someone gets home") were
|
||||||
|
the first thing in this project with a real argument for WAN exposure, so the
|
||||||
|
alternatives were weighed properly and **rejected**:
|
||||||
|
|
||||||
|
| Option | Verdict |
|
||||||
|
|---|---|
|
||||||
|
| **ntfy stays in the compose stack, reached over WireGuard** | **Chosen.** `identity → ntfy` is a container-to-container call that never reaches OPNsense at all — no firewall rule, no DMZ, no certificates. ntfy's Android app does instant delivery straight to a LAN server, so it works at home with nothing configured and away over the split tunnel above |
|
||||||
|
| ntfy in a DMZ, port-forwarded | Rejected. Buys only "notifications without the VPN connected," at the cost of a public listener, TLS/reverse-proxy upkeep, and mandatory ntfy auth — an exposed topic leaks *"the house is empty right now"*, which is materially worse than the usual leak of a self-hosted service |
|
||||||
|
| ntfy in a DMZ, reached from the smart-home VLAN by **NAT reflection** (hairpin) instead of an inter-VLAN rule | Rejected. It does avoid a new rule — the smart-home VLAN's existing blanket outbound-WAN allow already covers traffic to your own public IP — but it makes a purely internal notification path depend on the **ISP and external DNS being up**, so two devices metres apart stop talking during a WAN outage. CGNAT breaks it outright, and the rule it avoids (`smart-home → DMZ:443`, one host, one port, outbound) was the *safe* direction anyway; `DMZ → smart-home` stays denied either way |
|
||||||
|
|
||||||
|
**The household uses no Apple devices**, which removes the one genuine forcing
|
||||||
|
function for exposure: ntfy's iOS app can only be woken by Apple's APNs, so a
|
||||||
|
self-hosted server would need `upstream-base-url` relaying through ntfy.sh — WAN
|
||||||
|
egress through a third party, in an otherwise fully-local stack. Android needs none of
|
||||||
|
that. Revisit this table only if an Apple device ever joins the household.
|
||||||
|
|
||||||
## 3. VLAN segmentation — not a DMZ, a blast-radius boundary
|
## 3. VLAN segmentation — not a DMZ, a blast-radius boundary
|
||||||
|
|
||||||
A traditional DMZ exists to host something the WAN needs to reach. Nothing here
|
A traditional DMZ exists to host something the WAN needs to reach. Nothing here
|
||||||
|
|
@ -110,7 +160,7 @@ column shows which are opt-in vs. always-on with the base stack.
|
||||||
| 8080 | zigbee2mqtt frontend | Yes | none by default — VERIFY you've set `frontend.auth` if this VLAN isn't fully trusted |
|
| 8080 | zigbee2mqtt frontend | Yes | none by default — VERIFY you've set `frontend.auth` if this VLAN isn't fully trusted |
|
||||||
| 1880 | Node-RED | Yes | Node-RED's own login (if enabled) |
|
| 1880 | Node-RED | Yes | Node-RED's own login (if enabled) |
|
||||||
| 3000 | Homepage dashboard | `ENABLE_HOMEPAGE` (default on) | none |
|
| 3000 | Homepage dashboard | `ENABLE_HOMEPAGE` (default on) | none |
|
||||||
| 8090 | ntfy | `ENABLE_NTFY` (default on) | ntfy's own auth (if configured) — off by default, treat the topic name as the only barrier until you set one |
|
| 8090 | ntfy | `ENABLE_NTFY` (default on) | ntfy's own auth (if configured) — off by default, treat the topic name as the only barrier until you set one. Carries `identity`'s arrival notifications ("X just got home"), so an exposed topic leaks when the house is empty — this is the reason §2.2 keeps it LAN-only. Reach it from a phone at `http://<container-host>:8090`, at home or over the split tunnel |
|
||||||
| 9000 | Portainer | `ENABLE_PORTAINER` (default on) | Portainer's own login |
|
| 9000 | Portainer | `ENABLE_PORTAINER` (default on) | Portainer's own login |
|
||||||
| 445 | Gallery SMB share | `ENABLE_GALLERY_SMB` (default off) | SMB username/password you set (`GALLERY_SMB_USERNAME`/`PASSWORD`) |
|
| 445 | Gallery SMB share | `ENABLE_GALLERY_SMB` (default off) | SMB username/password you set (`GALLERY_SMB_USERNAME`/`PASSWORD`) |
|
||||||
| 9925 | Mealie | `ENABLE_MEALIE` (default off) | Mealie's own login |
|
| 9925 | Mealie | `ENABLE_MEALIE` (default off) | Mealie's own login |
|
||||||
|
|
@ -130,6 +180,17 @@ column shows which are opt-in vs. always-on with the base stack.
|
||||||
`ports:` mapping in the generated compose file at all, reachable only from other
|
`ports:` mapping in the generated compose file at all, reachable only from other
|
||||||
containers on the compose network (i.e. Home Assistant), by design.
|
containers on the compose network (i.e. Home Assistant), by design.
|
||||||
|
|
||||||
|
### On the LLM host (a separate machine — `hosts/llm-host/`)
|
||||||
|
|
||||||
|
| Port | Service | Auth |
|
||||||
|
|---|---|---|
|
||||||
|
| 11434 | Ollama | **None whatsoever.** Not a token, not a password — and the API is not read-only: it can pull and **delete** models as well as generate. Anyone who can reach this port can do all of it |
|
||||||
|
|
||||||
|
That row is the strongest case in this table for §3's VLAN segmentation and §1's no-
|
||||||
|
port-forward rule. It is also why the LLM host belongs on the smart-home VLAN rather
|
||||||
|
than the trusted LAN: it accepts unauthenticated instructions from anything that can
|
||||||
|
reach it, which is exactly the property you want blast-radius boundaries around.
|
||||||
|
|
||||||
## 5. What's still unverified here
|
## 5. What's still unverified here
|
||||||
|
|
||||||
This entire document was written against `setup-container-host.sh`'s current
|
This entire document was written against `setup-container-host.sh`'s current
|
||||||
|
|
|
||||||
|
|
@ -216,8 +216,8 @@ real hardware" callouts for everything downstream of this.)*
|
||||||
| 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 registry backend | **identity** (custom Python, stdlib `http.server` + `sqlite3`) | Person <-> BLE-identifier registry: registration (voice or touchscreen), presence resolution, visit history, per-device rights, 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` |
|
| Identity static serving | **identity-web** (nginx:alpine) | Serves `identity/frontend/`'s `register.html`/`dashboard.html`/`admin.html` read-only — same role as `pantry-web`/`digest-web`/`admin-web`. The two kiosk pages are what the wall panels load; `admin.html` is deliberately not linked from either (see Phase 6b) |
|
||||||
| 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 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 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 |
|
| 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 |
|
||||||
|
|
@ -273,10 +273,12 @@ real hardware" callouts for everything downstream of this.)*
|
||||||
3. Build plain HA automations: presence (RuView) on → light on at neutral default; off (with delay) → light off. **Validate this works with the LLM host powered off — this is your safety-net baseline.**
|
3. Build plain HA automations: presence (RuView) on → light on at neutral default; off (with delay) → light off. **Validate this works with the LLM host powered off — this is your safety-net baseline.**
|
||||||
|
|
||||||
### Phase 3 — LLM host + conversation agent
|
### Phase 3 — LLM host + conversation agent
|
||||||
1. Stand up the GPU host (or CPU fallback) with Ollama, pull Qwen2.5-14B-Instruct (or 7B/3B for CPU).
|
1. Stand up the GPU host (or CPU fallback) with Ollama, pull Qwen2.5-14B-Instruct (or 7B/3B for CPU). **Scripted**: `tools/setup-llm-host.sh` auto-detects the tier (`nvidia-smi` must both exist *and* succeed), installs Docker + the NVIDIA Container Toolkit, and runs Ollama as a pinned container rather than `curl | sh` into a root shell. It deliberately does **not** install the GPU driver — the most hardware/kernel-specific step on that box, and silently picking a version is how you get a machine that doesn't boot.
|
||||||
2. Add the Ollama integration in HA, point it at the LLM host over LAN.
|
2. Add the Ollama integration in HA, point it at the LLM host over LAN.
|
||||||
3. Set up Wyoming faster-whisper + Piper, configure an Assist pipeline.
|
3. Set up Wyoming faster-whisper + Piper, configure an Assist pipeline.
|
||||||
4. Test open-ended conversation and basic tool-calling before wiring into presence logic.
|
4. Test open-ended conversation and basic tool-calling before wiring into presence logic.
|
||||||
|
5. **Then power the LLM host off and confirm the house still works** — presence → light, the door panel, chore nudges (plain template instead of LLM-phrased), digest skipping rather than erroring. This is the testing-checklist guardrail below, and it's the entire reason this is a separate machine: it has to be able to be off. Anything that *breaks* rather than degrading is a bug in the consumer, not in the LLM host.
|
||||||
|
6. **Ollama has no authentication of any kind**, and its API can pull and delete models, not only generate — so the network is the whole boundary. Smart-home VLAN, never port-forwarded; it's in `docs/network-integration.md`'s port table for that reason.
|
||||||
|
|
||||||
### Phase 4 — AI-managed brightness/color
|
### Phase 4 — AI-managed brightness/color
|
||||||
1. Script triggered after the presence-automation light-on event, calling `ai_task.generate_data` with room/time/occupancy context, requesting structured JSON (`brightness`, `rgb_color`).
|
1. Script triggered after the presence-automation light-on event, calling `ai_task.generate_data` with room/time/occupancy context, requesting structured JSON (`brightness`, `rgb_color`).
|
||||||
|
|
@ -366,6 +368,71 @@ shipped API at all.
|
||||||
entities, or a real voice pipeline — see the itemized list in
|
entities, or a real voice pipeline — see the itemized list in
|
||||||
`identity/README.md`, `TRUSTED_ENTITY_PREFIXES`' defaults above all.
|
`identity/README.md`, `TRUSTED_ENTITY_PREFIXES`' defaults above all.
|
||||||
|
|
||||||
|
### Phase 6b — Household admin panel (people, history, rights)
|
||||||
|
|
||||||
|
Extends Phase 6's `identity` rather than adding a service: all of this is facts about
|
||||||
|
who someone is, which this project already made `identity`'s job.
|
||||||
|
|
||||||
|
1. **`identity/frontend/admin.html` — the one page here that isn't kiosk-shaped.**
|
||||||
|
`register.html`/`dashboard.html` are wall panels read from across a room; the admin
|
||||||
|
page is dense, has destructive actions, and is meant for a phone or laptop. A wall
|
||||||
|
panel anyone can walk up to must not carry a "prune these six people" button, which
|
||||||
|
is why it's a separate URL and nothing in `hosts/door-panel/` links to it.
|
||||||
|
2. **Nicknames are an input alias, never an output one.** A person can have a
|
||||||
|
`nickname` that `/resolve` accepts ("is Bibi home?"), but every payload also carries
|
||||||
|
`speak_name`, always the real name, and voice/TTS consumers read that. The asymmetry
|
||||||
|
*is* the feature: a nickname is something people grant each other, not something a
|
||||||
|
machine should presume back. A nickname colliding with anyone else's name or
|
||||||
|
nickname is refused; a spoken string that somehow matches two people refuses with
|
||||||
|
`ambiguous_name` rather than picking, extending Phase 6's own never-auto-commit rule
|
||||||
|
to a second kind of ambiguity.
|
||||||
|
3. **Visit history is sampled by `identity` itself**, not pushed at it — a poller
|
||||||
|
writes arrival/departure rows off its own `/presence`. `home: null` (unknown) writes
|
||||||
|
**nothing**, ever: an HA outage must not record a departure that didn't happen. A
|
||||||
|
departure needs `DEPARTURE_GRACE_SECONDS` of sustained absence (BLE flaps) and is
|
||||||
|
recorded as of the last moment the person was actually seen.
|
||||||
|
4. **"Who was home with whom" is a query, not a table** — overlapping visit intervals,
|
||||||
|
computed on read. No second copy of the same truth to drift; O(visits²) in the
|
||||||
|
window, fine for a household, stated plainly rather than hidden.
|
||||||
|
5. **Pruning splits selecting from deleting.** `GET /prune/candidates` fills the
|
||||||
|
checkboxes; `POST /people/prune` deletes the explicit ids that came back. The filter
|
||||||
|
is never re-run at delete time, so nobody who walks in the door mid-review gets
|
||||||
|
swept up by a filter that quietly re-evaluated.
|
||||||
|
6. **Per-device rights are an answer, never an action** (the "let my cousin unlock the
|
||||||
|
front door herself" case). `identity` stores grants and answers
|
||||||
|
`GET /device-access`; **HA asks and HA acts**, same "HA mediates, nothing auto-acts"
|
||||||
|
rule as every other control path in this plan. Deny is the default and the only
|
||||||
|
fallback — the one place in this service that fails closed rather than degrading,
|
||||||
|
because a lock has no useful "unknown". Grants can expire; every check is logged,
|
||||||
|
allowed and denied alike.
|
||||||
|
7. **Chore assignment is a preference, not a lock** — an assignee who's home gets
|
||||||
|
nudged first, an assignee who's away doesn't block the chore ("I don't care who does
|
||||||
|
it, as long as it gets done"), and `CHORE_ASSIGNMENT_STRICT` flips that for
|
||||||
|
households that meant the stronger thing. **Litter still can't be assigned**, for the
|
||||||
|
same reason it ignores exemptions.
|
||||||
|
8. Admin-added identifiers **stay behind the anti-spoofing boundary** —
|
||||||
|
`TRUSTED_ENTITY_PREFIXES` is still enforced. What the admin path relaxes is only
|
||||||
|
"must be in range right now" (so a fixed BLE tag can be provisioned before it's near
|
||||||
|
the door), never the allowlist itself. An admin panel is not a reason to hand out an
|
||||||
|
exception to the one security property this service has.
|
||||||
|
9. **Arrival notifications ride the same transition as the visit log** — opt-in per
|
||||||
|
person (`notify_on_arrival`), with a separate per-person opt-*out* of being
|
||||||
|
announced (`announce_arrivals`, default on) for anyone who doesn't want their
|
||||||
|
comings and goings broadcast, the same concern open decision #32 raises about
|
||||||
|
RuView. Delivered via the self-hosted ntfy this stack already runs for `chores` —
|
||||||
|
**`identity` itself never touches the WAN**. The first sample after startup
|
||||||
|
notifies nobody, establishing a baseline instead of firing a burst of false
|
||||||
|
arrivals for everyone already home.
|
||||||
|
10. **Whether the push reaches a phone that's away from home is a network decision,
|
||||||
|
not a feature of this service** — `docs/network-integration.md`'s existing answer
|
||||||
|
(WireGuard in, never a port-forward) applies unchanged. Android + WireGuard keeps
|
||||||
|
this fully local; **iOS can't**, since ntfy's iOS app needs APNs and therefore an
|
||||||
|
upstream relay through a third party. That's an Apple constraint, but it makes
|
||||||
|
"everyone gets arrival notifications" a household-platform question worth deciding
|
||||||
|
consciously — see `identity/README.md`'s table.
|
||||||
|
11. Covered by API-level tests, not browser ones — see `identity/README.md`'s
|
||||||
|
verification list, `DEPARTURE_GRACE_SECONDS`' untuned default above all.
|
||||||
|
|
||||||
### Phase 7 — Kitchen inventory kiosk
|
### Phase 7 — Kitchen inventory kiosk
|
||||||
1. Deploy Grocy via Compose.
|
1. Deploy Grocy via Compose.
|
||||||
2. Pi + touchscreen running Chromium kiosk mode pointed at Grocy's PWA.
|
2. Pi + touchscreen running Chromium kiosk mode pointed at Grocy's PWA.
|
||||||
|
|
@ -373,10 +440,17 @@ shipped API at all.
|
||||||
4. Use Grocy's own add/consume UI — no extra hardware/logic needed for the "in vs. out" problem.
|
4. Use Grocy's own add/consume UI — no extra hardware/logic needed for the "in vs. out" problem.
|
||||||
|
|
||||||
### Phase 8 — Nextcloud calendar sync
|
### Phase 8 — Nextcloud calendar sync
|
||||||
|
|
||||||
|
Written up in full in **`docs/caldav-integration.md`** — the four independent clients
|
||||||
|
and their directions, the shared-app-password decision and what it costs, and the
|
||||||
|
recurrence/TLS traps. Nextcloud itself is pre-existing; nothing in this repo deploys it.
|
||||||
|
|
||||||
1. Add HA's CalDAV integration, point at Nextcloud's CalDAV URL.
|
1. Add HA's CalDAV integration, point at Nextcloud's CalDAV URL.
|
||||||
2. Confirm read + write (`calendar.create_event`) both work.
|
2. Confirm read + write (`calendar.create_event`) both work.
|
||||||
3. Expose the calendar entity to the LLM's tool list.
|
3. Expose the calendar entity to the LLM's tool list.
|
||||||
4. Gate delete/move actions behind a confirmation step; create recurring events directly in Nextcloud, not via HA.
|
4. Gate delete/move actions behind a confirmation step; create recurring events directly in Nextcloud, not via HA.
|
||||||
|
5. **One Nextcloud app password, shared by all four clients** (HA plus this repo's three), never the account password — revocable on its own, and mandatory anyway once 2FA is on, since DAV endpoints can't prompt for a second factor. Note the two consequences: rotating it means editing three env files *and* HA, and a stale credential fails **quietly** because every client here degrades rather than crashes.
|
||||||
|
6. **The read-only invariant on `digest-engine`/`chores` is a code property, not a permission boundary** — a Nextcloud app password cannot be scoped read-only or to a single calendar, so the credential those services hold could delete every event you own. What prevents it is that those modules only ever issue reads. Review changes to them with that in mind; the server will not catch a regression.
|
||||||
|
|
||||||
### Phase 9 — Observability, dashboard, backup
|
### Phase 9 — Observability, dashboard, backup
|
||||||
1. Deploy Netdata (or your team's eventual choice from the Netdata/Checkmk evaluation) for container/host monitoring.
|
1. Deploy Netdata (or your team's eventual choice from the Netdata/Checkmk evaluation) for container/host monitoring.
|
||||||
|
|
@ -391,7 +465,7 @@ shipped API at all.
|
||||||
4. Dedicated Frigate accelerator (Hailo-8L) only if you expand beyond 1–2 cameras.
|
4. Dedicated Frigate accelerator (Hailo-8L) only if you expand beyond 1–2 cameras.
|
||||||
|
|
||||||
### Phase 11 — Sway thin-client ISO
|
### Phase 11 — Sway thin-client ISO
|
||||||
1. Scaffold a `live-build` tree at `hosts/thin-client/live-build/` (Debian 12, matching container-host's OS). `config/package-lists/thin-client.list.chroot` pulls `sway`, `greetd`, `wayvnc`, `xwayland`, `firefox-esr`, `mpv`, `mpv-mpris`, `spotifyd` (or `librespot`), `flatpak` (Steam Link), `wyoming-satellite` + `openwakeword` deps, plus `pipewire`/`wireplumber`. `hosts/thin-client/scripts/build-thin-client-iso.sh` drives `lb config && lb build`.
|
1. Scaffold a `live-build` tree at `hosts/thin-client/live-build/` (Debian 12, matching container-host's OS). `config/package-lists/thin-client.list.chroot` pulls `sway`, `greetd`, `wayvnc`, `xwayland`, `firefox-esr`, `mpv`, `mpv-mpris`, `spotifyd` (or `librespot`), `flatpak` (Steam Link), `wyoming-satellite` + `openwakeword` deps, plus `pipewire`/`wireplumber`. `tools/build-thin-client-iso.sh` drives `lb config && lb build`.
|
||||||
2. Autologin straight into a kiosk Sway session via **greetd** (`initial_session` block runs `sway` directly, no greeter UI) — not the older getty+`.bash_profile` hack.
|
2. Autologin straight into a kiosk Sway session via **greetd** (`initial_session` block runs `sway` directly, no greeter UI) — not the older getty+`.bash_profile` hack.
|
||||||
3. Remote control: **wayvnc** for interactive screen view/control. **Sway/wlroots has no maintained RDP path** (wlroots dropped its RDP backend; xrdp is X11-only) — wayvnc is the deliberate, confirmed replacement for "RDP" in this project, not a stopgap.
|
3. Remote control: **wayvnc** for interactive screen view/control. **Sway/wlroots has no maintained RDP path** (wlroots dropped its RDP backend; xrdp is X11-only) — wayvnc is the deliberate, confirmed replacement for "RDP" in this project, not a stopgap.
|
||||||
4. Build `thinclient-agent` (Python, `hosts/thin-client/agent/`) as a systemd service baked into the image:
|
4. Build `thinclient-agent` (Python, `hosts/thin-client/agent/`) as a systemd service baked into the image:
|
||||||
|
|
@ -460,7 +534,7 @@ New hardware: §1.13. Two separate build pipelines, arm64 and amd64 — see belo
|
||||||
2. **Two build pipelines, not one image with a flag**: the HiFiBerry Amp2 (arm64's assumed audio hardware) is a Raspberry Pi GPIO HAT with no amd64 equivalent — an x86 mini PC instead uses a USB DAC/amp (e.g. Fosi Audio V3, USB Audio Class, no driver needed). arm64 uses **rpi-image-gen** (the Raspberry Pi Foundation's current officially-recommended tool for custom images, superseding pi-gen for this use case) to produce a real flashable `.img`. amd64 reuses **the thin client's own live-build toolchain** rather than a third, unfamiliar one — a new, much smaller live-build tree with the entire graphical/kiosk stack stripped out, headless boot straight to `multi-user.target`.
|
2. **Two build pipelines, not one image with a flag**: the HiFiBerry Amp2 (arm64's assumed audio hardware) is a Raspberry Pi GPIO HAT with no amd64 equivalent — an x86 mini PC instead uses a USB DAC/amp (e.g. Fosi Audio V3, USB Audio Class, no driver needed). arm64 uses **rpi-image-gen** (the Raspberry Pi Foundation's current officially-recommended tool for custom images, superseding pi-gen for this use case) to produce a real flashable `.img`. amd64 reuses **the thin client's own live-build toolchain** rather than a third, unfamiliar one — a new, much smaller live-build tree with the entire graphical/kiosk stack stripped out, headless boot straight to `multi-user.target`.
|
||||||
3. **Spotify Connect install is apt-first with a documented fallback, reused identically on both variants** from `hosts/thin-client/live-build/config/hooks/normal/0500-spotify-connect.hook.chroot`'s own already-established logic (neither `spotifyd` nor `librespot` is in Debian bookworm main) — not two independent judgment calls about the same package.
|
3. **Spotify Connect install is apt-first with a documented fallback, reused identically on both variants** from `hosts/thin-client/live-build/config/hooks/normal/0500-spotify-connect.hook.chroot`'s own already-established logic (neither `spotifyd` nor `librespot` is in Debian bookworm main) — not two independent judgment calls about the same package.
|
||||||
4. **Audio output is direct ALSA, not PipeWire**, on both variants — deliberately different from the thin client, since this is a single-purpose headless appliance with no multi-app mixing need and no desktop session to run a per-user PipeWire daemon in the first place.
|
4. **Audio output is direct ALSA, not PipeWire**, on both variants — deliberately different from the thin client, since this is a single-purpose headless appliance with no multi-app mixing need and no desktop session to run a per-user PipeWire daemon in the first place.
|
||||||
5. **Per-room identity differs by variant, each using whichever mechanism its own platform actually supports well**: arm64 builds one generic image and sets hostname/Wi-Fi per physical unit via Raspberry Pi Imager's OS Customisation dialog (works on any `.img`, no rebuild needed); amd64 has no equivalent tool for a generic x86 ISO, so it bakes `IMAGE_HOSTNAME` in at build time instead, one build per room, matching the thin client's own `THINCLIENT_NAME` convention. Both converge on the same mechanism at the software layer: `spotify-connect-start` reads `$(hostname)` fresh at every service start, regardless of which point in the pipeline set it.
|
5. ~~Per-room identity differs by variant~~ — **converged** (`tools/`): **both** variants now bake the hostname in at build time from `CoreSystemConfig.json`'s `audio_endpoints` array, one image per room, so no device in the household gets its identity typed in after the build. arm64 was the last exception (a generic `.img` named in Raspberry Pi Imager's OS Customisation dialog at flash time), and it was worth converging because it was the only remaining hand-entry point *and* it failed silently when mistyped — `spotify-connect-start` reads `$(hostname)`, so a typo yields a Spotify Connect device under the wrong room name rather than an error. **The tradeoff is explicit**: one full `rpi-image-gen` run per arm64 room (qemu-emulated on an x86 builder) instead of one for all; `build.arm64_prebake: false` restores the single-generic-image behaviour. Wi-Fi and SSH keys are still set in Imager on arm64 — only the hostname moved into the build. Both converge on the same mechanism at the software layer: `spotify-connect-start` reads `$(hostname)` fresh at every service start, regardless of which point in the pipeline set it.
|
||||||
6. **rpi-image-gen's exact config/layer YAML schema is the single highest-risk unverified part of this phase** — written from the tool's documented structure, not hands-on validated (no ARM build environment available). See `hosts/audio-endpoint/README.md`'s prominent callout before a real build.
|
6. **rpi-image-gen's exact config/layer YAML schema is the single highest-risk unverified part of this phase** — written from the tool's documented structure, not hands-on validated (no ARM build environment available). See `hosts/audio-endpoint/README.md`'s prominent callout before a real build.
|
||||||
7. Neither image gives Home Assistant any control or visibility over these endpoints — no MQTT, no HA entities. Deliberate scope decision matching the "per-room independent" choice, not an oversight; revisit as a separate addition if remote control/monitoring from HA is wanted later.
|
7. Neither image gives Home Assistant any control or visibility over these endpoints — no MQTT, no HA entities. Deliberate scope decision matching the "per-room independent" choice, not an oversight; revisit as a separate addition if remote control/monitoring from HA is wanted later.
|
||||||
8. Nothing built or flashed on real hardware — no Raspberry Pi, no HiFiBerry Amp2, no x86 test box available. See the itemized unverified list in `hosts/audio-endpoint/README.md`.
|
8. Nothing built or flashed on real hardware — no Raspberry Pi, no HiFiBerry Amp2, no x86 test box available. See the itemized unverified list in `hosts/audio-endpoint/README.md`.
|
||||||
|
|
@ -787,7 +861,7 @@ These need a decision before their respective implementation steps can be built
|
||||||
1. ~~RDP vs. VNC vs. desktop-environment swap~~ — **resolved**: wayvnc (VNC) replaces RDP for this project; Sway is kept.
|
1. ~~RDP vs. VNC vs. desktop-environment swap~~ — **resolved**: wayvnc (VNC) replaces RDP for this project; Sway is kept.
|
||||||
2. ~~WhatsApp ingestion approach~~ — **resolved**: `whatsapp-bridge` (headful Chromium + `whatsapp-web.js` in an Xvfb virtual display), not Baileys. Still opt-in (`ENABLE_WHATSAPP_INGEST`); still recommend a secondary/non-critical number, since automating a personal account carries some risk even via the real web client.
|
2. ~~WhatsApp ingestion approach~~ — **resolved**: `whatsapp-bridge` (headful Chromium + `whatsapp-web.js` in an Xvfb virtual display), not Baileys. Still opt-in (`ENABLE_WHATSAPP_INGEST`); still recommend a secondary/non-critical number, since automating a personal account carries some risk even via the real web client.
|
||||||
3. **Mainstream news source list** — `digest-engine/feeds/curated-feeds.opml` ships with `marxist.com/feed/rss` (real) plus BBC World/Al Jazeera/Guardian World/DW as clearly-marked placeholders (Reuters/AP were skipped — both have restricted their public RSS and guessing a live URL seemed worse than an honest placeholder). Still needs the user's actual sign-off/edit.
|
3. **Mainstream news source list** — `digest-engine/feeds/curated-feeds.opml` ships with `marxist.com/feed/rss` (real) plus BBC World/Al Jazeera/Guardian World/DW as clearly-marked placeholders (Reuters/AP were skipped — both have restricted their public RSS and guessing a live URL seemed worse than an honest placeholder). Still needs the user's actual sign-off/edit.
|
||||||
4. **Ollama contention** — not yet resolved; `DIGEST_SCHEDULE` defaults to `00,06,12,18` (every 6h) in `setup-container-host.sh`, unadjusted for Assist-traffic overlap. Revisit once real usage patterns are known.
|
4. **Ollama contention** — **half resolved** (Phase 3, `hosts/llm-host/`). The *resource* half now has a defensible default: `OLLAMA_MAX_LOADED_MODELS=1` (a 14B text model and a vision model don't co-fit in 8–12GB, so force a predictable swap rather than VRAM thrash or an OOM mid-request), `OLLAMA_NUM_PARALLEL=1` (predictable latency for whoever is speaking to Assist over throughput nothing here needs), and `OLLAMA_KEEP_ALIVE=30m` (Ollama's 5m default makes a household that talks to Assist a few times an hour pay the model-load cost nearly every time). **Still open**: these are reasoned, not measured — nothing has run against a real GPU under concurrent load — and the *scheduling* half is untouched, `DIGEST_SCHEDULE` still defaults to `00,06,12,18` with no adjustment for real Assist-traffic overlap, which needs actual usage data to settle.
|
||||||
5. **Credential storage** — implemented as a git-ignored `.env` seeded from `digest-engine.env.example` (matches the restic-password precedent); `age`/`sops` was not built, considered adequate for now.
|
5. **Credential storage** — implemented as a git-ignored `.env` seeded from `digest-engine.env.example` (matches the restic-password precedent); `age`/`sops` was not built, considered adequate for now.
|
||||||
6. **Exact mic-enabled room list** — still needed; `ENABLE_VOICE_SATELLITE` in `build-thin-client-iso.sh` defaults to `false` per-image until rooms are chosen.
|
6. **Exact mic-enabled room list** — still needed; `ENABLE_VOICE_SATELLITE` in `build-thin-client-iso.sh` defaults to `false` per-image until rooms are chosen.
|
||||||
7. **Exact thin-client hardware target** — still needed; nothing in the built image assumes specific hardware, but Steam Link/Xwayland decode performance can't be validated without it.
|
7. **Exact thin-client hardware target** — still needed; nothing in the built image assumes specific hardware, but Steam Link/Xwayland decode performance can't be validated without it.
|
||||||
|
|
@ -805,15 +879,21 @@ These need a decision before their respective implementation steps can be built
|
||||||
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).
|
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.
|
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.
|
22. ~~The floor-plan UI itself doesn't exist~~ — **built** (Phase 6b): `identity`'s admin panel has a Floorplan tab where levels and rooms are **drawn by hand** (polygons, optional background image, normalised 0–1 coordinates so the plan renders at any size), each room mapped to the HA area string `/presence` already reported. Occupied rooms light up live. Nothing is auto-placed — nothing here knows the shape of this flat, and drawing it is what removed the need to invent a coordinate format against a guess. **Still genuinely open**: `AREA_ATTRIBUTE`'s default (`area_id`) is still an unconfirmed guess at what Bermuda publishes, so until that's verified every room will simply never light up — the editor now surfaces this directly by showing which areas HA is actually reporting, and listing people who are home but unplaceable. The editor has also never been opened in a real browser.
|
||||||
23. **No Tapo camera model or count has been chosen, and Frigate's PTZ move-to-preset API shape is assumed** (new, Phase 20) — §1.17 lists a placeholder model/price only; whether a specific Tapo model even exposes RTSP without go2rtc as a bridge is unverified, and `chores/check.py`'s `_frigate_snapshot()`'s `POST /api/<camera>/ptz/move/<preset>` is assumed from Frigate's general PTZ feature set, not a real deployment. This is the single highest-risk unknown in Phase 20, same class of risk as open decision #18's vision-model pick for Phase 17.
|
23. **No Tapo camera model or count has been chosen, and Frigate's PTZ move-to-preset API shape is assumed** (new, Phase 20) — §1.17 lists a placeholder model/price only; whether a specific Tapo model even exposes RTSP without go2rtc as a bridge is unverified, and `chores/check.py`'s `_frigate_snapshot()`'s `POST /api/<camera>/ptz/move/<preset>` is assumed from Frigate's general PTZ feature set, not a real deployment. This is the single highest-risk unknown in Phase 20, same class of risk as open decision #18's vision-model pick for Phase 17.
|
||||||
24. **Vision-model accuracy for bin-fullness/dishes/litter checks is completely unmeasured** (new, Phase 20) — same caveat as open decision #18, applied to a different prompt; a wrong FULL/DIRTY/YES answer just means a chore opens or stays open incorrectly, never a hard failure, but nobody has checked how often that actually happens.
|
24. **Vision-model accuracy for bin-fullness/dishes/litter checks is completely unmeasured** (new, Phase 20) — same caveat as open decision #18, applied to a different prompt; a wrong FULL/DIRTY/YES answer just means a chore opens or stays open incorrectly, never a hard failure, but nobody has checked how often that actually happens.
|
||||||
25. **`chores`' household calendar busy-check is household-wide, not per-person** (new, Phase 20) — `_household_currently_busy()` can't tell that only one person is in a flagged-busy calendar event and nudge someone else who's free; everyone's nudges pause together. A real per-person availability model would need per-person calendars, which this project doesn't have. Documented as a known limitation, not a bug, in `chores/README.md`.
|
25. **`chores`' household calendar busy-check is household-wide, not per-person** (new, Phase 20) — `_household_currently_busy()` can't tell that only one person is in a flagged-busy calendar event and nudge someone else who's free; everyone's nudges pause together. A real per-person availability model would need per-person calendars, which this project doesn't have. Documented as a known limitation, not a bug, in `chores/README.md`.
|
||||||
26. **No frontend exists yet for setting `chore_exempt`/`chore_reminder_style`** (new, Phase 20) — set via a direct `POST /people/<id>/chore-settings` call to `identity` (HA script/automation, or `curl`) until a UI is built into `register.html`/`dashboard.html`.
|
26. ~~No frontend exists yet for setting `chore_exempt`/`chore_reminder_style`~~ — **resolved** (Phase 6b): `identity/frontend/admin.html` edits both, plus chore assignment, on the People tab. **Still open in a smaller way**: `admin.js`'s `CHORE_TYPES` list is kept in sync with `chores/check.py`'s `_CHORE_PROMPTS` **by hand**, because `chores` is a oneshot timer job with no HTTP surface to query for it — adding a fourth chore type means editing both files, and nothing catches it if you don't.
|
||||||
27. **No way to manually mark a chore done exists yet** (new, Phase 20) — the only way a `chores` chore currently closes is a camera re-check finding it clear, or direct SQLite surgery; a real deployment probably wants an HA button or voice phrase for "mark the trash as done," deliberately left out of this pass rather than guessed at.
|
27. **No way to manually mark a chore done exists yet** (new, Phase 20) — the only way a `chores` chore currently closes is a camera re-check finding it clear, or direct SQLite surgery; a real deployment probably wants an HA button or voice phrase for "mark the trash as done," deliberately left out of this pass rather than guessed at.
|
||||||
28. **Which UniFi/CalDAV/Matter/1-Wire/Proxmox/Steam/Discord/HP-iLO/GTFS HA integrations actually get installed is unresolved** (new) — all nine are catalogued in §2's "HA integrations catalog" as available options with their purpose/notes, but none has been installed, configured, or verified against real hardware/accounts; several (Matter, 1-Wire, Proxmox, HP iLO) also depend on hardware/infrastructure decisions this plan hasn't made yet (whether anything in the household actually uses those platforms at all).
|
28. **Which UniFi/CalDAV/Matter/1-Wire/Proxmox/Steam/Discord/HP-iLO/GTFS HA integrations actually get installed is unresolved** (new) — all nine are catalogued in §2's "HA integrations catalog" as available options with their purpose/notes, but none has been installed, configured, or verified against real hardware/accounts; several (Matter, 1-Wire, Proxmox, HP iLO) also depend on hardware/infrastructure decisions this plan hasn't made yet (whether anything in the household actually uses those platforms at all).
|
||||||
29. **Music Assistant has not been installed or configured** (new) — catalogued in §2 as an optional, additive HA add-on; whether it's worth adding on top of the existing per-room spotifyd/librespot/Spotify-client setup (which keeps working standalone regardless) is a real usage-pattern question, not answerable until the existing per-room setups (Phase 11.6/15/16) are actually running.
|
29. **Music Assistant has not been installed or configured** (new) — catalogued in §2 as an optional, additive HA add-on; whether it's worth adding on top of the existing per-room spotifyd/librespot/Spotify-client setup (which keeps working standalone regardless) is a real usage-pattern question, not answerable until the existing per-room setups (Phase 11.6/15/16) are actually running.
|
||||||
30. **The self-check/hardware-monitoring integrations catalog (§2) is a menu, not a deployment plan** (new) — System Monitor, SNMP, NUT, Glances, and Uptime Kuma are all listed with their purpose, but which ones are actually worth installing depends on hardware decisions not yet made (is there a UPS? managed switches? which of this repo's published services matter enough to alert on?).
|
30. **The self-check/hardware-monitoring integrations catalog (§2) is a menu, not a deployment plan** (new) — System Monitor, SNMP, NUT, Glances, and Uptime Kuma are all listed with their purpose, but which ones are actually worth installing depends on hardware decisions not yet made (is there a UPS? managed switches? which of this repo's published services matter enough to alert on?).
|
||||||
31. **Music Assistant's default port is a guess, and it collides with `PANTRY_VISION_PORT` in this exact stack** (new) — assumed 8095 from Music Assistant's own docs, not confirmed against a running instance; `PANTRY_VISION_PORT` is also 8095. Because Music Assistant runs with `network_mode: host` (needed for player-discovery mDNS), Docker Compose's own port-collision checking doesn't catch this the way a normal `ports:` mapping would — `setup-container-host.sh` warns if both `ENABLE_MUSIC_ASSISTANT` and `ENABLE_PANTRY_VISION` are set, but resolving the actual clash (changing Music Assistant's configured listen port) is a manual step, not automated.
|
31. ~~Music Assistant's default port is a guess, and it collides with `PANTRY_VISION_PORT` in this exact stack~~ — **mechanically resolved** by `tools/`: both ports are now declared in `CoreSystemConfig.json`, and `validate-config.py` **fails the build** on any duplicate, so the clash cannot reach a deployment. The template assigns Music Assistant 8101. **Still genuinely open**: its real default (8095) is still an unverified guess, and because it runs `network_mode: host`, moving it requires configuring Music Assistant itself — the config file can declare the port but cannot make the service bind to it.
|
||||||
32. ~~RuView's semantic-state MQTT entities have no opt-out or visibility restriction beyond this network's normal trust boundary~~ — **household decision made**: real automations are now built on this data (sleep → dim lights, possible-distress → whole-household alert, concurrent elevated heart rate → colored lighting, bathroom occupancy → an external door indicator — see `firmware/ruview/README.md` §5 and `firmware/ruview/automations.yaml.example`). **Still genuinely open**: there is no technical opt-out for a specific person/room and no access restriction on these MQTT topics beyond this network's normal trust boundary — worth revisiting if anyone not on board with being sensed this way ever stays over. Every automation's `entity_id` is also still an unconfirmed placeholder (see #33), and rule 3 (concurrent two-person heart rate) rests on an unconfirmed assumption that a single RuView node can report two people's heart rates at once — multi-target vital-sign separation from WiFi CSI is a genuinely hard, unconfirmed capability, not something to trust until checked against real entities.
|
32. ~~RuView's semantic-state MQTT entities have no opt-out or visibility restriction beyond this network's normal trust boundary~~ — **household decision made**: real automations are now built on this data (sleep → dim lights, possible-distress → whole-household alert, concurrent elevated heart rate → colored lighting, bathroom occupancy → an external door indicator — see `firmware/ruview/README.md` §5 and `firmware/ruview/automations.yaml.example`). **Still genuinely open**: there is no technical opt-out for a specific person/room and no access restriction on these MQTT topics beyond this network's normal trust boundary — worth revisiting if anyone not on board with being sensed this way ever stays over. Every automation's `entity_id` is also still an unconfirmed placeholder (see #33), and rule 3 (concurrent two-person heart rate) rests on an unconfirmed assumption that a single RuView node can report two people's heart rates at once — multi-target vital-sign separation from WiFi CSI is a genuinely hard, unconfirmed capability, not something to trust until checked against real entities.
|
||||||
33. **RuView's build/flash commands and `provision.py`'s exact flags beyond `--port`/`--ssid`/`--password`/`--mqtt` are transcribed from its README, not independently run** (new, Phase 2) — see `firmware/ruview/README.md`'s own "Manual verification still outstanding," same category of risk as every other "written from documentation, not a live instance" open decision in this list (#19, #21).
|
33. **RuView's build/flash commands and `provision.py`'s exact flags beyond `--port`/`--ssid`/`--password`/`--mqtt` are transcribed from its README, not independently run** (new, Phase 2) — see `firmware/ruview/README.md`'s own "Manual verification still outstanding," same category of risk as every other "written from documentation, not a live instance" open decision in this list (#19, #21).
|
||||||
|
34. **`identity`'s `DEPARTURE_GRACE_SECONDS` default (15 min) is an untuned guess at how much a real Private BLE Device setup flaps** (new, Phase 6b) — too low and one evening at home is recorded as several separate "visits," too high and a quick trip out never registers. The entire usefulness of the visit history and the co-presence view rests on this number, and nobody has watched a real BLE presence entity over a day to pick it. First thing to check once `GET /visits` has real data in it.
|
||||||
|
35. **The device-rights HA wiring is a worked example, not a tested one, and this is the one place where getting it wrong opens a door** (new, Phase 6b) — `identity` only ever *answers* `GET /device-access`; an HA automation that calls `lock.unlock` outside the `condition: template` guard in `identity/README.md`'s example would unlock regardless of that answer, and nothing on this side can detect or prevent that. Same "documented shape, not verified" caveat as #21, with a materially worse failure mode.
|
||||||
|
36. **The admin panel has never been opened in a real browser** (new, Phase 6b) — every endpoint it calls is covered by API-level tests, but the page itself (the `<dialog>`-based person editor, the prune checkbox flow) has only been checked for syntax. `showModal()` needs a reasonably current browser; the door panel's Chromium qualifies, something older might not.
|
||||||
|
37. ~~iOS cannot receive `identity`'s arrival notifications without WAN egress through a third party~~ — **household decision made** (Phase 6b): **no Apple devices are used here**, which removes the only forcing function for exposing ntfy at all. ntfy therefore **stays LAN-only** — no DMZ, no port forward, no NAT-reflection hairpin; `identity → ntfy` is a container-to-container call that never reaches OPNsense, and remote delivery rides a **WireGuard split tunnel** routing only the smart-home VLAN. The full comparison (including why the hairpin variant, which avoids an inter-VLAN rule, was rejected for making an internal path depend on the ISP) is recorded in `docs/network-integration.md` §2.2 so it doesn't get re-litigated. **Still genuinely open**: nothing has been delivered to a real phone yet, and the split tunnel's `AllowedIPs` is the fiddly part — routing all of `192.168.0.0/16` would collide with typical café/hotel LANs and break the phone's connectivity there. Revisit §2.2 only if an Apple device ever joins the household.
|
||||||
|
38. **Both of `chores`' shipped container URLs pointed at `127.0.0.1` and could never have worked** (found while wiring Phase 6b's arrival notifications) — inside the `chores` container that address is the chores container itself, not the host. `NTFY_URL=http://127.0.0.1:8090` meant every nudge failed to send, and `IDENTITY_URL=http://127.0.0.1:8097` meant `_presence()` never reached identity at all, so **`chores` would have done nothing whatsoever** — and silently, since both failure paths log and continue by design (a deliberate never-block-on-a-dependency choice that here hid a total outage). Corrected in the template to `http://ntfy` and `http://identity:8097` (compose-bridge DNS; ntfy on its internal port 80, not the 8090 published to the LAN). **An already-deployed `/opt/smart-home/chores/chores.env` still has the old values** — templates are copied once at setup and never re-synced, so existing installs need this edited by hand. The broader lesson worth acting on: several services fail soft on an unreachable dependency, which means a misconfigured address produces silence rather than an error — nothing in this repo currently distinguishes "nothing to do" from "never reached the thing that would have told me".
|
||||||
|
39. **Nothing enforces that voice/TTS consumers actually read `speak_name` rather than `nickname`** (new, Phase 6b) — the field exists, is documented, and `chores/` uses it, but a future HA intent script that reaches for the friendlier-looking `nickname` would break the "the assistant uses real names" rule silently. Worth checking whenever a new consumer of `/presence` or `/resolve` is written.
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ device already on the network).
|
||||||
## Multiple rooms
|
## Multiple rooms
|
||||||
|
|
||||||
This is a per-room device, exactly like the thin client's per-image
|
This is a per-room device, exactly like the thin client's per-image
|
||||||
`THINCLIENT_NAME`/`DIGEST_WEB_URL` (`hosts/thin-client/scripts/build-thin-client-iso.sh`).
|
`THINCLIENT_NAME`/`DIGEST_WEB_URL` (`tools/build-thin-client-iso.sh`).
|
||||||
Two separate things both have to be set correctly, per physical unit, for "media
|
Two separate things both have to be set correctly, per physical unit, for "media
|
||||||
status always on the specific room the device is in" to actually hold:
|
status always on the specific room the device is in" to actually hold:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ paths genuinely differ (see below) — not one image with a flag.
|
||||||
| Audio hardware | HiFiBerry Amp2 HAT (GPIO, 60W, drives 4–8Ω passive speakers directly) | A USB DAC/amp, e.g. Fosi Audio V3 (USB Audio Class, drives passive speakers directly) |
|
| Audio hardware | HiFiBerry Amp2 HAT (GPIO, 60W, drives 4–8Ω passive speakers directly) | A USB DAC/amp, e.g. Fosi Audio V3 (USB Audio Class, drives passive speakers directly) |
|
||||||
| Build tool | [rpi-image-gen](https://github.com/raspberrypi/rpi-image-gen) | live-build (same tool `hosts/thin-client/` uses) |
|
| Build tool | [rpi-image-gen](https://github.com/raspberrypi/rpi-image-gen) | live-build (same tool `hosts/thin-client/` uses) |
|
||||||
| Output | A bootable `.img` | A bootable live ISO |
|
| Output | A bootable `.img` | A bootable live ISO |
|
||||||
| Per-room identity | Set per physical unit, after building, via Raspberry Pi Imager | Baked in at build time — one build per room |
|
| Per-room identity | Baked in at build time from `CoreSystemConfig.json` — one build per room (set `build.arm64_prebake: false` to get one generic image named in Pi Imager instead) | Baked in at build time — one build per room |
|
||||||
|
|
||||||
Both variants share one thing: `configs/spotify-connect.service` +
|
Both variants share one thing: `configs/spotify-connect.service` +
|
||||||
`configs/spotify-connect-start`, an architecture-independent systemd
|
`configs/spotify-connect-start`, an architecture-independent systemd
|
||||||
|
|
@ -25,7 +25,7 @@ unit/wrapper pair. Nothing else is shared — see each variant's own section bel
|
||||||
- **rpi-image-gen is Raspberry-Pi-specific.** It doesn't target amd64. Rather than
|
- **rpi-image-gen is Raspberry-Pi-specific.** It doesn't target amd64. Rather than
|
||||||
reach for a third, unfamiliar toolchain for the x86 side, the amd64 image reuses
|
reach for a third, unfamiliar toolchain for the x86 side, the amd64 image reuses
|
||||||
**the exact tool this repo already has and has already proven for a bootable
|
**the exact tool this repo already has and has already proven for a bootable
|
||||||
image**: live-build, the same one `hosts/thin-client/scripts/build-thin-client-iso.sh`
|
image**: live-build, the same one `tools/build-thin-client-iso.sh`
|
||||||
drives. Lower risk than learning a new toolchain for one variant.
|
drives. Lower risk than learning a new toolchain for one variant.
|
||||||
|
|
||||||
## Spotify Connect install: apt-first, documented fallback
|
## Spotify Connect install: apt-first, documented fallback
|
||||||
|
|
@ -89,14 +89,28 @@ a `.img` under that clone's `work/` directory.
|
||||||
|
|
||||||
### Per-room identity
|
### Per-room identity
|
||||||
|
|
||||||
**Build one generic image, not one per room.** Raspberry Pi Imager's own **OS
|
**The hostname is baked in, one image per room** — same as every other image in
|
||||||
Customisation** dialog (gear icon, or Ctrl+Shift+X) works on *any* `.img`,
|
this repo. `tools/build-audio-endpoint-image-arm64.sh` substitutes the endpoint's
|
||||||
including local custom ones — hostname, Wi-Fi SSID/password, and an SSH key, all
|
hostname from `CoreSystemConfig.json`'s `audio_endpoints` array into a generated
|
||||||
set per-flash without touching the image itself. Use it on this image, once per
|
copy of `config/audio-endpoint.yaml`, and `build-all.sh` builds one per entry.
|
||||||
physical unit, before writing the SD card. `spotify-connect-start` reads
|
|
||||||
`$(hostname)` at every service start, so whatever hostname you set in Imager *is*
|
That's a change from the original design, which built one generic `.img` and left
|
||||||
that unit's Spotify Connect device name in the picker — no separate config file,
|
identity to Raspberry Pi Imager's **OS Customisation** dialog at flash time. Imager
|
||||||
no rebuild per room.
|
still works and is still the right tool for Wi-Fi and SSH keys (neither is baked
|
||||||
|
in) — but hostname was worth moving into the build for two reasons:
|
||||||
|
|
||||||
|
- It was the **only** identity in the household still typed in by hand after a
|
||||||
|
build, which is the exact step `CoreSystemConfig.json` exists to remove.
|
||||||
|
- It failed *quietly* when mistyped. `spotify-connect-start` reads `$(hostname)`
|
||||||
|
at every service start, so a typo doesn't error — it just puts a Spotify Connect
|
||||||
|
device with the wrong room name in the picker, and you find out when you cast to
|
||||||
|
the wrong room.
|
||||||
|
|
||||||
|
**The cost is real**: one full `rpi-image-gen` run per room instead of one for all
|
||||||
|
of them, and on an x86 build host each of those is a qemu-emulated arm64 bootstrap.
|
||||||
|
If you have several arm64 rooms and a slow builder, set `"arm64_prebake": false` in
|
||||||
|
the config's `build` section — that restores the single generic image, and
|
||||||
|
`build-all.sh` will build it once no matter how many arm64 rooms are listed.
|
||||||
|
|
||||||
### Hardware setup
|
### Hardware setup
|
||||||
|
|
||||||
|
|
@ -115,7 +129,7 @@ cd hosts/audio-endpoint/live-build-amd64/scripts
|
||||||
sudo ./build-audio-endpoint-iso-amd64.sh
|
sudo ./build-audio-endpoint-iso-amd64.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Same shape as `hosts/thin-client/scripts/build-thin-client-iso.sh`: installs
|
Same shape as `tools/build-thin-client-iso.sh`: installs
|
||||||
`live-build` if missing, regenerates `config/includes.chroot/` from the shared
|
`live-build` if missing, regenerates `config/includes.chroot/` from the shared
|
||||||
`../configs/` (git-ignored, generated — never hand-edit it), then runs
|
`../configs/` (git-ignored, generated — never hand-edit it), then runs
|
||||||
`lb config && lb build`. Output is a hybrid ISO, written to a USB stick and
|
`lb config && lb build`. Output is a hybrid ISO, written to a USB stick and
|
||||||
|
|
|
||||||
|
|
@ -53,10 +53,17 @@ hooks:
|
||||||
chroot:
|
chroot:
|
||||||
- ../scripts/install-spotify-connect.sh
|
- ../scripts/install-spotify-connect.sh
|
||||||
|
|
||||||
# Left at the tool's own default — Raspberry Pi Imager's OS Customisation
|
# REWRITTEN PER ROOM AT BUILD TIME. tools/build-audio-endpoint-image-arm64.sh
|
||||||
# dialog overrides this per physical unit after the image is built (see
|
# substitutes the endpoint's hostname from CoreSystemConfig.json into the `set:`
|
||||||
# hosts/audio-endpoint/README.md's "Per-room identity" section). Do not
|
# line below and writes a generated-<hostname>.yaml alongside this file; this
|
||||||
# hardcode a room name here; spotify-connect-start reads $(hostname) at
|
# value is only the fallback for a hand-run build.
|
||||||
# service-start time, whatever it ends up being set to per unit.
|
#
|
||||||
|
# Don't edit it to a room name — that would be a second place a room name lives,
|
||||||
|
# which is the duplication the whole tools/ build system exists to remove. Add
|
||||||
|
# the room to the config's "audio_endpoints" array instead.
|
||||||
|
#
|
||||||
|
# The hostname matters beyond identification: spotify-connect-start reads
|
||||||
|
# $(hostname) at service-start time, so this is also the room's Spotify Connect
|
||||||
|
# device name.
|
||||||
hostname:
|
hostname:
|
||||||
set: audio-endpoint
|
set: audio-endpoint
|
||||||
|
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Builds the arm64 headless audio-endpoint image (Raspberry Pi + HiFiBerry
|
|
||||||
# Amp2) with rpi-image-gen. Read hosts/audio-endpoint/README.md before running
|
|
||||||
# this — in particular the callout that rpi-image-gen's exact config/layer
|
|
||||||
# schema (config/audio-endpoint.yaml) was not hands-on validated while writing
|
|
||||||
# this script; a real build may need that file adjusted first.
|
|
||||||
#
|
|
||||||
# Output: a real bootable .img. Each physical unit then gets its own
|
|
||||||
# hostname/Wi-Fi via Raspberry Pi Imager's OS Customisation dialog when
|
|
||||||
# flashing — see the README's "Per-room identity" section. This script builds
|
|
||||||
# ONE generic image, not one per room.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# CONFIGURATION — edit if needed
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
RPI_IMAGE_GEN_SRC="${RPI_IMAGE_GEN_SRC:-/opt/smart-home/src/rpi-image-gen}"
|
|
||||||
RPI_IMAGE_GEN_REPO="https://github.com/raspberrypi/rpi-image-gen.git"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Paths
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
RPI_IMAGE_GEN_DIR="$(dirname "$SCRIPT_DIR")"
|
|
||||||
AUDIO_ENDPOINT_DIR="$(dirname "$RPI_IMAGE_GEN_DIR")"
|
|
||||||
SHARED_CONFIGS_DIR="$AUDIO_ENDPOINT_DIR/configs"
|
|
||||||
BUILD_CONFIG="$RPI_IMAGE_GEN_DIR/config/audio-endpoint.yaml"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Sanity checks
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
for f in spotify-connect.service spotify-connect-start; do
|
|
||||||
if [ ! -f "$SHARED_CONFIGS_DIR/$f" ]; then
|
|
||||||
echo "Missing $SHARED_CONFIGS_DIR/$f — is this script running from a full checkout?" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ ! -f "$BUILD_CONFIG" ]; then
|
|
||||||
echo "Missing $BUILD_CONFIG" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v git >/dev/null 2>&1; then
|
|
||||||
echo "git is required to fetch rpi-image-gen. Install it first: sudo apt-get install git" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. Fetch rpi-image-gen if not already present
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if [ ! -d "$RPI_IMAGE_GEN_SRC" ]; then
|
|
||||||
echo "--- Cloning rpi-image-gen into $RPI_IMAGE_GEN_SRC ---"
|
|
||||||
git clone --depth 1 "$RPI_IMAGE_GEN_REPO" "$RPI_IMAGE_GEN_SRC"
|
|
||||||
else
|
|
||||||
echo "--- rpi-image-gen already present at $RPI_IMAGE_GEN_SRC, skipping clone ---"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2. Build
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
echo "--- Running rpi-image-gen build ---"
|
|
||||||
echo " Config: $BUILD_CONFIG"
|
|
||||||
cd "$RPI_IMAGE_GEN_SRC"
|
|
||||||
|
|
||||||
# UNVERIFIED: assumes a ./rpi-image-gen entry point at the repo root, matching
|
|
||||||
# common conventions for this class of tool (e.g. pi-gen's own build.sh). If
|
|
||||||
# the real tool exposes a different entry point (a pip-installed console
|
|
||||||
# script, a Python module, etc.), fix this one line — everything else in this
|
|
||||||
# script and config/audio-endpoint.yaml is unaffected either way.
|
|
||||||
./rpi-image-gen build -c "$BUILD_CONFIG"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "=== Done (if rpi-image-gen's invocation above matched the real tool) ==="
|
|
||||||
echo "Image should be under $RPI_IMAGE_GEN_SRC/work/ — check rpi-image-gen's own"
|
|
||||||
echo "build output above for the exact path; this wrapper does not parse it."
|
|
||||||
echo
|
|
||||||
echo "Next steps:"
|
|
||||||
echo " 1. Flash with Raspberry Pi Imager (GUI) or:"
|
|
||||||
echo " rpi-imager --cli <path-to-image>.img /dev/<sd-card-device>"
|
|
||||||
echo " 2. BEFORE writing, use Imager's OS Customisation (gear icon / Ctrl+Shift+X)"
|
|
||||||
echo " to set THIS unit's hostname (= its Spotify Connect device name — see"
|
|
||||||
echo " the README's 'Per-room identity' section), Wi-Fi SSID/password, and"
|
|
||||||
echo " an SSH key if you want remote access. This is per physical unit, not"
|
|
||||||
echo " per build — the same .img is reused for every room."
|
|
||||||
echo " 3. Attach the HiFiBerry Amp2 HAT and passive speakers before first boot."
|
|
||||||
echo " 4. First boot checklist (see README.md for the full unverified list):"
|
|
||||||
echo " systemctl status spotify-connect"
|
|
||||||
echo " # confirm the room's hostname shows up as a Spotify Connect device"
|
|
||||||
echo " aplay -l # confirm the HiFiBerry is card 0 (onboard audio disabled)"
|
|
||||||
|
|
@ -42,11 +42,11 @@ non-specific) hardware line item.
|
||||||
## Before you build
|
## Before you build
|
||||||
|
|
||||||
Deploy `identity`/`identity-web` first (`ENABLE_IDENTITY` in
|
Deploy `identity`/`identity-web` first (`ENABLE_IDENTITY` in
|
||||||
`hosts/container-host/scripts/setup-container-host.sh`) — this image builds and boots
|
`tools/setup-container-host.sh`) — this image builds and boots
|
||||||
fine without it, but the dashboard will show connection errors until it exists.
|
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
|
`pantry-vision` is optional (only "Running low" needs it). Then edit the
|
||||||
`# CONFIGURATION` block at the top of
|
`# CONFIGURATION` block at the top of
|
||||||
[`scripts/build-door-panel-iso.sh`](scripts/build-door-panel-iso.sh):
|
[`tools/build-door-panel-iso.sh`](../../tools/build-door-panel-iso.sh):
|
||||||
|
|
||||||
| Variable | What to put in it |
|
| Variable | What to put in it |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -60,7 +60,7 @@ fine without it, but the dashboard will show connection errors until it exists.
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo ./scripts/build-door-panel-iso.sh
|
sudo -E tools/build-door-panel-iso.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Same directory-split convention as every other host: `configs/` and `agent/` are
|
Same directory-split convention as every other host: `configs/` and `agent/` are
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,10 @@ angle can be adjusted independently of the screen. See §1.15 of
|
||||||
## Before you build
|
## Before you build
|
||||||
|
|
||||||
Deploy `pantry-vision`/`pantry-web` first (`ENABLE_PANTRY_VISION` in
|
Deploy `pantry-vision`/`pantry-web` first (`ENABLE_PANTRY_VISION` in
|
||||||
`hosts/container-host/scripts/setup-container-host.sh`) — this image builds and boots
|
`tools/setup-container-host.sh`) — this image builds and boots
|
||||||
fine without it, but every tab will show a connection error until it exists. Then edit
|
fine without it, but every tab will show a connection error until it exists. Then edit
|
||||||
the `# CONFIGURATION` block at the top of
|
the `# CONFIGURATION` block at the top of
|
||||||
[`scripts/build-kitchen-display-iso.sh`](scripts/build-kitchen-display-iso.sh):
|
[`tools/build-kitchen-display-iso.sh`](../../tools/build-kitchen-display-iso.sh):
|
||||||
|
|
||||||
| Variable | What to put in it |
|
| Variable | What to put in it |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -66,7 +66,7 @@ the `# CONFIGURATION` block at the top of
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo ./scripts/build-kitchen-display-iso.sh
|
sudo -E tools/build-kitchen-display-iso.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Same directory-split convention as every other host in this project: `configs/` and
|
Same directory-split convention as every other host in this project: `configs/` and
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
# llm-host
|
||||||
|
|
||||||
|
The Ollama machine, from [Phase 3 of the project plan](../../docs/project-plan.md).
|
||||||
|
A **separate physical host** from the container host, on purpose — see "Why a separate
|
||||||
|
machine" below.
|
||||||
|
|
||||||
|
Everything in this project that wants inference calls this one server:
|
||||||
|
|
||||||
|
| Caller | What it asks for | If this host is off |
|
||||||
|
|---|---|---|
|
||||||
|
| Home Assistant (Assist / AI Task) | Conversation, tool calls, Phase 4's brightness/colour JSON | Assist's LLM agent is unavailable; **presence → light still works**, on plain automations |
|
||||||
|
| `digest-engine` | Quarter-daily synthesis + the counter-run verification pass | The run is skipped; the timer doesn't error |
|
||||||
|
| `pantry-vision` | Grocery-item recognition from one photo | The scan flow reports it can't identify; Grocy is untouched |
|
||||||
|
| `chores` | Bin/dishes/litter vision checks, and reminder phrasing | Camera checks skip; nudges use the plain template |
|
||||||
|
|
||||||
|
## The guardrail this host is built around
|
||||||
|
|
||||||
|
**Every consumer must degrade to "unavailable", never to "broken", when this machine
|
||||||
|
is off.** The project plan's testing checklist states it directly — *"Does the reactive
|
||||||
|
path (presence → light on) work with the LLM host powered off? (It must.)"*
|
||||||
|
|
||||||
|
That's the whole reason this is a separate box rather than more containers on the
|
||||||
|
Phase 1 host: it *can* be off — for power, for noise, because you pulled the GPU — and
|
||||||
|
the house still works. The setup script's closing output walks you through actually
|
||||||
|
testing that, and it's worth doing once for real rather than assuming.
|
||||||
|
|
||||||
|
If something *breaks* rather than degrading when this host is down, that's a bug in
|
||||||
|
the consumer, not here.
|
||||||
|
|
||||||
|
## Two tiers
|
||||||
|
|
||||||
|
Auto-detected from whether `nvidia-smi` both exists **and** succeeds (a leftover driver
|
||||||
|
package on a machine whose card was pulled satisfies the first but not the second).
|
||||||
|
Override with `TIER` at the top of the script.
|
||||||
|
|
||||||
|
| Tier | Model | Reality |
|
||||||
|
|---|---|---|
|
||||||
|
| `gpu` | `qwen2.5:14b-instruct` | What Phase 3 specifies |
|
||||||
|
| `cpu` | `qwen2.5:7b-instruct` | Single-digit tokens/sec. Enough to validate the entire pipeline end to end before buying a card — `docs/components.md`'s deliberate "skip GPU" fallback, not a failure mode |
|
||||||
|
|
||||||
|
Plus a vision model (`llava` by default) for `pantry-vision` and `chores`. Set
|
||||||
|
`PULL_VISION_MODEL=false` to skip it and save several GB if you're not running those
|
||||||
|
camera paths yet.
|
||||||
|
|
||||||
|
**The vision model choice is not a considered one.** `llava` is simply the default
|
||||||
|
those two services already ship with, and open decision #18 flags the pick as unmade
|
||||||
|
and completely unbenchmarked. If grocery recognition turns out too slow or too
|
||||||
|
inaccurate to be usable, this is the first knob to turn — `qwen2.5vl` and `moondream`
|
||||||
|
are the obvious alternatives to measure against.
|
||||||
|
|
||||||
|
## Contention: interactive vs. batch on one GPU
|
||||||
|
|
||||||
|
The real scheduling problem here (project plan open decision #4), and the script's
|
||||||
|
defaults take a position on it:
|
||||||
|
|
||||||
|
- **Assist is interactive** — a person is standing in the room waiting.
|
||||||
|
- **`digest-engine` is batch** — every 6h, nobody watching.
|
||||||
|
- **The vision callers are occasional but want a *different model resident*.**
|
||||||
|
|
||||||
|
| Setting | Default | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `OLLAMA_KEEP_ALIVE` | `30m` | Ollama's own default of 5m means a household that talks to Assist a few times an hour pays the model-load cost nearly every time. 30m keeps it warm through normal use |
|
||||||
|
| `OLLAMA_MAX_LOADED_MODELS` | `1` | **Deliberate.** A 14B text model and a vision model don't co-fit in 8–12GB; letting Ollama try produces VRAM thrash or an OOM mid-request instead of an honest swap. 1 means "swap predictably, pay the reload when vision is actually needed." Raise it only if you have the VRAM *and have checked* |
|
||||||
|
| `OLLAMA_NUM_PARALLEL` | `1` | Predictable latency for whoever is speaking, over throughput nothing here needs |
|
||||||
|
|
||||||
|
This is a **reasoned default, not a measured one** — none of it has been run against a
|
||||||
|
real GPU under real concurrent load. The remaining half of open decision #4 (whether
|
||||||
|
`DIGEST_SCHEDULE`'s `00,06,12,18` overlaps real Assist usage) needs actual usage data
|
||||||
|
to settle; the settings above at least make the failure mode a predictable swap rather
|
||||||
|
than an OOM.
|
||||||
|
|
||||||
|
## Security: Ollama has no authentication
|
||||||
|
|
||||||
|
None. Not a token, not a password. And its API is not read-only — it can **pull and
|
||||||
|
delete models**, not just generate. Anyone who can reach `:11434` can do all of that.
|
||||||
|
|
||||||
|
The network is therefore the entire boundary: keep this host on the smart-home VLAN
|
||||||
|
and never port-forward it, exactly as `docs/network-integration.md` §1 says for
|
||||||
|
everything else. It's now in that document's port table for the same reason.
|
||||||
|
|
||||||
|
## Why a container, not the native installer
|
||||||
|
|
||||||
|
Ollama's official install is `curl -fsSL https://ollama.com/install.sh | sh`, which
|
||||||
|
pipes a fetched script straight into a root shell. The container path gives a pinned
|
||||||
|
image, an uninstall that's `docker rm`, and no arbitrary remote code executed as root
|
||||||
|
— the same reasoning behind every other component in this project running in Docker.
|
||||||
|
|
||||||
|
The native install is a perfectly legitimate alternative, and on some GPU setups it's
|
||||||
|
less fuss than the NVIDIA Container Toolkit. If you go that way, the one thing you
|
||||||
|
**must** still do is set `OLLAMA_HOST=0.0.0.0:11434` in the systemd unit — see below.
|
||||||
|
|
||||||
|
## The one configuration mistake that looks like a dead host
|
||||||
|
|
||||||
|
Ollama binds `127.0.0.1` by default. In a container, that means the published port
|
||||||
|
forwards to a socket nothing is listening on, and **every caller gets a connection
|
||||||
|
refused that is indistinguishable from "the LLM host is powered off"** — which, given
|
||||||
|
that every consumer here is built to tolerate exactly that, degrades silently and
|
||||||
|
looks like nothing is wrong.
|
||||||
|
|
||||||
|
The compose file sets `OLLAMA_HOST=0.0.0.0:11434` for this reason. Check it first if
|
||||||
|
inference is mysteriously "unavailable" everywhere at once. (This is the same class of
|
||||||
|
bug as `chores`' env template pointing at `127.0.0.1` for a sibling container — see
|
||||||
|
the project plan's open decision #38.)
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo -E tools/setup-llm-host.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit the variables at the top first — `BASE_DIR` above all, since models are large
|
||||||
|
(a 14B Q4 model is ~9GB, a vision model another 5–8GB) and it defaults to
|
||||||
|
`/opt/llm-host`.
|
||||||
|
|
||||||
|
For the GPU tier, **the NVIDIA driver must already work** (`nvidia-smi` prints your
|
||||||
|
card). The script installs the Container Toolkit that lets Docker see the GPU, but
|
||||||
|
deliberately does not install the driver: that's the most hardware- and
|
||||||
|
kernel-specific step on this machine, and silently choosing a driver version for
|
||||||
|
someone is a good way to produce a box that doesn't boot.
|
||||||
|
|
||||||
|
Afterwards the script prints exactly what to paste into HA and into each service's env
|
||||||
|
file on the container host.
|
||||||
|
|
||||||
|
## Manual verification still outstanding
|
||||||
|
|
||||||
|
1. **None of this has been run.** No Debian machine, no GPU, no Ollama server — the
|
||||||
|
script is syntax-checked and its generated compose file is validated as YAML for
|
||||||
|
both tiers, and that is the entire extent of the testing. Same honesty rule as
|
||||||
|
every other unbuilt host in this repo.
|
||||||
|
2. **The model tags are library names that upstream does rename.** `qwen2.5:14b-instruct`
|
||||||
|
and `llava` are written from Ollama's library as documented, not confirmed pullable
|
||||||
|
today. A failed pull is deliberately non-fatal — the server stays up and you fix the
|
||||||
|
tag by hand — but check <https://ollama.com/library> if one fails.
|
||||||
|
3. **The NVIDIA Container Toolkit repo/apt steps are from NVIDIA's documented install**,
|
||||||
|
not run on a real machine. `nvidia-ctk runtime configure --runtime=docker` followed
|
||||||
|
by a Docker restart is the documented shape; verify against NVIDIA's current docs
|
||||||
|
before trusting it on hardware you care about.
|
||||||
|
4. **The contention defaults are unmeasured** — see the table above.
|
||||||
|
5. **Whether a 14B model at Q4 actually fits your card** is not checked anywhere. On
|
||||||
|
8GB it will be tight-to-impossible; on 12GB+ it's comfortable. If it OOMs, drop to
|
||||||
|
the CPU tier's 7B tag on the GPU, which is the cheap first thing to try.
|
||||||
|
6. **No Wake-on-LAN.** If you want the digest timer to wake this host rather than skip
|
||||||
|
its run, that's a BIOS + `ethtool -s <iface> wol g` + a `wakeonlan` call from the
|
||||||
|
container host's timer — deliberately not scripted here, since it depends on
|
||||||
|
hardware that hasn't been chosen.
|
||||||
|
|
@ -35,7 +35,7 @@ What ends up on the image:
|
||||||
> not turn it on until that list exists.
|
> not turn it on until that list exists.
|
||||||
|
|
||||||
Then edit the `# CONFIGURATION` block at the top of
|
Then edit the `# CONFIGURATION` block at the top of
|
||||||
[`scripts/build-thin-client-iso.sh`](scripts/build-thin-client-iso.sh):
|
[`tools/build-thin-client-iso.sh`](../../tools/build-thin-client-iso.sh):
|
||||||
|
|
||||||
| Variable | What to put in it |
|
| Variable | What to put in it |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -53,7 +53,7 @@ Then edit the `# CONFIGURATION` block at the top of
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo ./scripts/build-thin-client-iso.sh
|
sudo -E tools/build-thin-client-iso.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
It installs `live-build` if missing, regenerates
|
It installs `live-build` if missing, regenerates
|
||||||
|
|
|
||||||
0
hosts/thin-client/live-build/config/hooks/normal/0800-eww-widget.hook.chroot
Normal file → Executable file
0
hosts/thin-client/live-build/config/hooks/normal/0800-eww-widget.hook.chroot
Normal file → Executable file
0
hosts/thin-client/live-build/config/hooks/normal/0900-firefox.hook.chroot
Normal file → Executable file
0
hosts/thin-client/live-build/config/hooks/normal/0900-firefox.hook.chroot
Normal file → Executable file
0
hosts/thin-client/live-build/config/hooks/normal/1000-ydotool.hook.chroot
Normal file → Executable file
0
hosts/thin-client/live-build/config/hooks/normal/1000-ydotool.hook.chroot
Normal file → Executable file
|
|
@ -34,7 +34,7 @@ What ends up on the image:
|
||||||
> handles either way a real touchscreen might show up to Linux.
|
> handles either way a real touchscreen might show up to Linux.
|
||||||
|
|
||||||
Edit the `# CONFIGURATION` block at the top of
|
Edit the `# CONFIGURATION` block at the top of
|
||||||
[`scripts/build-touch-panel-iso.sh`](scripts/build-touch-panel-iso.sh):
|
[`tools/build-touch-panel-iso.sh`](../../tools/build-touch-panel-iso.sh):
|
||||||
|
|
||||||
| Variable | What to put in it |
|
| Variable | What to put in it |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -47,7 +47,7 @@ Edit the `# CONFIGURATION` block at the top of
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo ./scripts/build-touch-panel-iso.sh
|
sudo -E tools/build-touch-panel-iso.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Same directory-split convention as the thin client: `configs/` and `agent/` are the
|
Same directory-split convention as the thin client: `configs/` and `agent/` are the
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,42 @@ at least once, `/presence` reports `home: null` ("unknown") for that person —
|
||||||
`false`**, since defaulting a device-less person to "away" would be actively wrong
|
`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.
|
the moment they're actually sitting in the next room, not just uninformative.
|
||||||
|
|
||||||
## Floor-plan groundwork (not the floor plan itself)
|
## The floor plan
|
||||||
|
|
||||||
|
**Draw it in the admin panel's Floorplan tab.** Add a level, optionally upload a
|
||||||
|
background (a scan, a screenshot of an architect's PDF, a photo of a sketch), then
|
||||||
|
click corners to trace each room and drag the handles to adjust. Rooms are polygons,
|
||||||
|
not rectangles, because real rooms aren't rectangles.
|
||||||
|
|
||||||
|
The piece that makes it *live* is the **Home Assistant area** field on each room. That
|
||||||
|
string is matched against whatever `/presence` reports as a person's `room` — i.e.
|
||||||
|
whatever `AREA_ATTRIBUTE` holds on their trusted entity. The editor offers a pick-list
|
||||||
|
of the areas HA is actually reporting right now (`GET /floorplan/areas`) rather than
|
||||||
|
asking you to retype an area_id from Developer Tools, for the same anti-typo reason
|
||||||
|
`tools/CoreSystemConfig.json` exists. Tick **Show who's home** and occupied rooms light
|
||||||
|
up.
|
||||||
|
|
||||||
|
Three deliberate choices worth knowing:
|
||||||
|
|
||||||
|
- **Coordinates are normalised 0–1, not pixels.** The plan has to render on a laptop
|
||||||
|
now and possibly a wall panel later, and pixel coordinates would be right on exactly
|
||||||
|
one of them. The tradeoff: replacing a background image with one of a *different
|
||||||
|
aspect ratio* distorts existing rooms. Same-ratio replacements are fine.
|
||||||
|
- **Nothing is ever placed automatically.** No auto-detection of rooms, no inference
|
||||||
|
from BLE distances. Nothing in this project knows the shape of this flat, and the
|
||||||
|
alternative to drawing it was inventing a coordinate format against a guess — which
|
||||||
|
is exactly why this stayed deferred rather than half-built (open decision #22).
|
||||||
|
- **A person who can't be placed is shown, not dropped.** Home but no room resolved
|
||||||
|
(the normal case without room-level BLE), or reporting an area no room claims — both
|
||||||
|
are listed under the plan, and unclaimed areas are named so you know what's left to
|
||||||
|
draw. A floor plan that quietly loses people would be worse than no floor plan.
|
||||||
|
|
||||||
|
A room with no HA area is legal and labelled "no HA area" on the plan: drawing the flat
|
||||||
|
and wiring up presence are separate jobs, and you should be able to finish the first
|
||||||
|
without the second. Two rooms may not claim the same area — both would light up for one
|
||||||
|
person, which looks like a presence bug rather than a mapping mistake.
|
||||||
|
|
||||||
|
## Floor-plan groundwork (the `/presence` half)
|
||||||
|
|
||||||
`/presence` also reports a best-effort `room` per person (`server.py`'s
|
`/presence` also reports a best-effort `room` per person (`server.py`'s
|
||||||
`AREA_ATTRIBUTE`, default `area_id`) — read from whichever HA area/room attribute
|
`AREA_ATTRIBUTE`, default `area_id`) — read from whichever HA area/room attribute
|
||||||
|
|
@ -113,12 +148,229 @@ real Bermuda-tracked entity's attributes (Developer Tools -> States) before rely
|
||||||
on `room` being populated at all; it degrades to `null` if missing, never breaks the
|
on `room` being populated at all; it degrades to `null` if missing, never breaks the
|
||||||
response.
|
response.
|
||||||
|
|
||||||
|
## The admin panel
|
||||||
|
|
||||||
|
`frontend/admin.html` — the third page here, and the only one **not** designed for a
|
||||||
|
kiosk. `register.html` and `dashboard.html` are big-touch-target screens running
|
||||||
|
unattended on a wall; this one is dense, has destructive buttons, and is meant for a
|
||||||
|
phone or laptop belonging to someone who sat down intending to administer something.
|
||||||
|
**A wall panel anyone can walk up to should not have a "Prune 6 people" button on
|
||||||
|
it** — that's why the admin page is a separate URL rather than a tab on the door
|
||||||
|
panel, and why nothing in `hosts/door-panel/` or `hosts/kitchen-display/` links to it.
|
||||||
|
|
||||||
|
It's served by the same read-only `identity-web` nginx container as its siblings, and
|
||||||
|
configured the same way:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://<host>:8098/admin.html?api=http://<host>:8097&token=<IDENTITY_TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
Four tabs: **People** (tap anyone to edit every field, their devices, their door
|
||||||
|
rights and their chores), **Prune**, **History**, and **Access log**.
|
||||||
|
|
||||||
|
> The token is in the URL, exactly like the two kiosk pages — that's the existing
|
||||||
|
> pattern here, not a new decision, and it's why this service treats the token as the
|
||||||
|
> real boundary rather than network placement. Bookmark the admin URL somewhere
|
||||||
|
> private; anyone with it has full administrative access to the person registry.
|
||||||
|
|
||||||
|
## Nicknames: people say them, the assistant doesn't
|
||||||
|
|
||||||
|
A person can have a **`nickname`** — what the household actually calls them. It is an
|
||||||
|
**input alias only**:
|
||||||
|
|
||||||
|
- `GET /resolve?q=bibi` finds Linus. So does `q=Linus`. Registering a second phone as
|
||||||
|
"Bibi" attaches it to Linus's existing record rather than creating a duplicate.
|
||||||
|
- **Every payload also carries `speak_name`, which is always the real name.** Voice/TTS
|
||||||
|
consumers must read `speak_name`, never `nickname`. `chores/` already does this for
|
||||||
|
reminder text.
|
||||||
|
|
||||||
|
The asymmetry is the entire point of the field, not an implementation detail: a
|
||||||
|
nickname is something people grant each other, and a machine reading it back is a
|
||||||
|
different thing from a friend saying it. Assign one in the admin panel, and the
|
||||||
|
assistant keeps calling them by their name.
|
||||||
|
|
||||||
|
A nickname may not collide with anyone else's name **or** nickname — the edit is
|
||||||
|
refused with the conflicting person named. If a spoken string somehow matches two
|
||||||
|
people anyway, registration refuses with `reason: "ambiguous_name"` rather than
|
||||||
|
picking one, the same never-auto-commit-on-ambiguity rule as the BLE candidate case
|
||||||
|
above.
|
||||||
|
|
||||||
|
## Visit history — who was home when, and with whom
|
||||||
|
|
||||||
|
`identity` samples its **own** `/presence` every `PRESENCE_POLL_SECONDS` and writes
|
||||||
|
arrival/departure rows. Nothing pushes events at it. Three deliberate consequences:
|
||||||
|
|
||||||
|
- **The history is honest about its resolution.** You know when someone was *observed*
|
||||||
|
home, to within one poll interval.
|
||||||
|
- **`home: null` (unknown) never writes anything.** Not a visit, and — more
|
||||||
|
importantly — never a departure. An HA outage or a device-less person nobody has
|
||||||
|
toggled must not put a fake "left the house" into the record; an inferred absence
|
||||||
|
written down as an observed one is a lie the log can never un-tell. An
|
||||||
|
`ha_unreachable` sample is skipped in full.
|
||||||
|
- **BLE flapping doesn't shred the log.** A person has to read as away for
|
||||||
|
`DEPARTURE_GRACE_SECONDS` before their visit closes, and the departure is recorded as
|
||||||
|
of the last moment they were actually *seen*, not when that window ran out.
|
||||||
|
|
||||||
|
A visit that never gets a definite "not home" (the device-less, hand-toggled case) is
|
||||||
|
eventually closed by `VISIT_MAX_OPEN_HOURS` with `close_reason: "timed_out"` rather
|
||||||
|
than `"departed"` — the two are never conflated, and the admin panel labels the
|
||||||
|
difference ("departure never observed").
|
||||||
|
|
||||||
|
**"With whom" is a query, not a table.** `GET /co-presence` overlaps visit intervals on
|
||||||
|
read. There's no second copy of the same truth to drift out of sync, and a visit
|
||||||
|
corrected later automatically corrects the co-presence answer. The tradeoff is stated
|
||||||
|
plainly: it's O(visits²) within the window, which is fine for a household and would not
|
||||||
|
be for a venue.
|
||||||
|
|
||||||
|
## Arrival notifications — "tell me when someone gets home"
|
||||||
|
|
||||||
|
Opt-in per person, in the admin panel. Fires on **the same arrival transition the
|
||||||
|
visit log is built from** — a trusted identifier coming into range and HA registering
|
||||||
|
it — so there is exactly one definition of "arrived" in this service rather than two
|
||||||
|
that could disagree.
|
||||||
|
|
||||||
|
Three per-person settings:
|
||||||
|
|
||||||
|
- **`notify_on_arrival`** (default **off**) — send *this* person a push when someone
|
||||||
|
else gets home. The "if enabled" half.
|
||||||
|
- **`announce_arrivals`** (default **on**) — whether *this* person's own arrivals may
|
||||||
|
be announced. Untick it for anyone who doesn't want their comings and goings
|
||||||
|
broadcast to the household — the same concern the project plan's open decision #32
|
||||||
|
raises about RuView. It defaults **on** deliberately: if both flags defaulted off,
|
||||||
|
ticking "notify me" would appear broken until every other person also opted in.
|
||||||
|
- **`notify_topic`** — this person's own ntfy topic; blank falls back to
|
||||||
|
`NTFY_DEFAULT_TOPIC`, so a household that never sets these still works.
|
||||||
|
|
||||||
|
Rules that fall out of it:
|
||||||
|
|
||||||
|
- **The arriving person is never notified about themselves.**
|
||||||
|
- **Topics are deduplicated.** With no per-person topics, everyone shares
|
||||||
|
`NTFY_DEFAULT_TOPIC` — without dedup a five-person household would get five
|
||||||
|
identical pushes for one person walking in.
|
||||||
|
- **Subscribers who are away still get notified.** "Did the kid get home?" is most of
|
||||||
|
the reason to want this.
|
||||||
|
- **The first sample after startup notifies nobody.** It establishes a baseline
|
||||||
|
instead. Otherwise a restart following a gap long enough for visits to have closed
|
||||||
|
would fire "X just got home" for everyone who's been on the sofa for hours. The cost
|
||||||
|
is one genuinely missed notification if somebody walks in during that first pass —
|
||||||
|
a fair trade against crying wolf on every container restart, and the visit is
|
||||||
|
recorded correctly either way.
|
||||||
|
- **A camera sighting says so.** Face-recognition arrivals read "was just recognised
|
||||||
|
at home", not "just got home" — the two signals aren't equally reliable and the
|
||||||
|
reader deserves to know which one fired.
|
||||||
|
- **A failed push never costs you history.** Visits are committed before any network
|
||||||
|
call; pushes are best-effort and isolated from each other.
|
||||||
|
|
||||||
|
`POST /people/<id>/test-notification` pushes a test message to that person's topic —
|
||||||
|
because the alternative way to discover a typo'd topic is to wait for somebody to walk
|
||||||
|
through the door and then notice nothing happened.
|
||||||
|
|
||||||
|
### Getting the push while you're actually away
|
||||||
|
|
||||||
|
**`identity` never touches the WAN.** It POSTs to the self-hosted ntfy this stack
|
||||||
|
already runs for `chores` (`setup-container-host.sh`'s `ENABLE_NTFY`) — one container
|
||||||
|
to another on the compose network, never even reaching the firewall.
|
||||||
|
|
||||||
|
Getting the message onto a phone is a **network** question, and it's settled in
|
||||||
|
`docs/network-integration.md` §2.2: **ntfy stays LAN-only.** At home, ntfy's Android
|
||||||
|
app holds a connection straight to it ("instant delivery" — no Google services, no
|
||||||
|
WAN). Away, a **WireGuard split tunnel** routing just the smart-home VLAN (§2.1)
|
||||||
|
reaches it exactly as if you were sitting at home. No DMZ, no port forward, no
|
||||||
|
certificates, no firewall rule.
|
||||||
|
|
||||||
|
That doc records why exposing ntfy in a DMZ — with or without NAT reflection — was
|
||||||
|
weighed and rejected, so the reasoning doesn't have to be re-derived later.
|
||||||
|
|
||||||
|
> Apple footnote, for completeness only: this household uses no Apple devices. If one
|
||||||
|
> ever joins, note that ntfy's iOS app can only be woken via Apple's APNs, so a
|
||||||
|
> self-hosted server would need `upstream-base-url` relaying through ntfy.sh — real WAN
|
||||||
|
> egress through a third party, even on your own Wi-Fi. That would reopen §2.2's
|
||||||
|
> decision. Android needs none of it.
|
||||||
|
|
||||||
|
## Pruning: the filter selects, the human deletes
|
||||||
|
|
||||||
|
"Select all that have last visited before `<date>`" is two endpoints on purpose:
|
||||||
|
|
||||||
|
1. `GET /prune/candidates?last_visit_before=…` — a **read**. Fills in the checkboxes.
|
||||||
|
2. `POST /people/prune` with `{"person_ids": [...]}` — deletes exactly the ids that
|
||||||
|
came back and stayed ticked.
|
||||||
|
|
||||||
|
The filter is **never re-run at delete time**. Someone who walks in the door between
|
||||||
|
"Select all" and "Delete selected" can't be swept up by a filter that quietly
|
||||||
|
re-evaluated — the list you approved is the list that gets deleted. That's worth one
|
||||||
|
extra round trip for an irreversible operation on people's records.
|
||||||
|
|
||||||
|
Someone with no recorded visits falls back to their `created_at` (flagged
|
||||||
|
`last_visit_is_estimated`), so a person registered once and never seen again — the most
|
||||||
|
prunable record there is — is findable rather than invisible to the filter.
|
||||||
|
|
||||||
|
## Per-device rights — an answer, never an action
|
||||||
|
|
||||||
|
`device_grants` records that a person may operate a specific HA entity: the "let my
|
||||||
|
cousin unlock the front door herself" case. `GET /device-access` answers yes/no with a
|
||||||
|
reason.
|
||||||
|
|
||||||
|
**This service never touches a device.** It has no path to one. Home Assistant asks,
|
||||||
|
Home Assistant acts — the same "HA mediates, nothing auto-acts" rule as every other
|
||||||
|
control path in this project. The flow is: BLE/face resolves who's at the door → HA
|
||||||
|
calls `GET /device-access?person_id=…&entity_id=lock.front_door` → HA calls
|
||||||
|
`lock.unlock` if and only if the answer was `allowed: true`.
|
||||||
|
|
||||||
|
**Deny is the default and the only fallback.** No grant, unknown person, expired
|
||||||
|
grant — all `allowed: false`. This is the one place here that fails *closed* rather
|
||||||
|
than degrading gracefully: everything else in this service would rather report
|
||||||
|
"unknown" than guess, but a lock has no useful "unknown", and the safe half of
|
||||||
|
"open/don't open" is "don't".
|
||||||
|
|
||||||
|
Grants can carry an `expires_at` (a weekend key for a visiting cousin), checked at
|
||||||
|
answer time rather than by a sweep, so a lapsed grant stops working the instant it
|
||||||
|
lapses. **Every check is logged** to `device_access_events`, allowed and denied
|
||||||
|
alike — for a door lock the denied ones are the interesting ones — and the admin
|
||||||
|
panel's Access log tab shows them.
|
||||||
|
|
||||||
|
A worked HA example, unverified against a running instance like every other HA-side
|
||||||
|
snippet in this repo:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# configuration.yaml (excerpt)
|
||||||
|
rest_command:
|
||||||
|
identity_may_operate:
|
||||||
|
url: "http://<container-host>:8097/device-access?person_id={{ person_id }}&entity_id={{ entity_id }}&via=door-panel"
|
||||||
|
method: GET
|
||||||
|
headers:
|
||||||
|
Authorization: "Bearer !secret identity_token"
|
||||||
|
|
||||||
|
script:
|
||||||
|
cousin_self_entry:
|
||||||
|
sequence:
|
||||||
|
- service: rest_command.identity_may_operate
|
||||||
|
data:
|
||||||
|
person_id: "{{ person_id }}"
|
||||||
|
entity_id: lock.front_door
|
||||||
|
response_variable: verdict
|
||||||
|
# The lock is only ever touched inside this guard.
|
||||||
|
- condition: template
|
||||||
|
value_template: "{{ verdict.content.allowed }}"
|
||||||
|
- service: lock.unlock
|
||||||
|
target:
|
||||||
|
entity_id: lock.front_door
|
||||||
|
```
|
||||||
|
|
||||||
## Chore-system settings — owned here, used by `chores/`
|
## Chore-system settings — owned here, used by `chores/`
|
||||||
|
|
||||||
Two per-person fields, set via `POST /people/<id>/chore-settings`. **No frontend
|
Three per-person things live here, not in `chores/`. All of them are editable in the
|
||||||
for this exists yet** — neither `register.html` nor `dashboard.html` expose a way
|
admin panel (which is what open decision #26 was waiting for), and `chores/` reads
|
||||||
to set them — call the endpoint directly (an HA script/automation, or `curl`) until
|
all of them off the same `GET /presence` call it already made.
|
||||||
one is built. Read by `chores/` off `GET /presence`:
|
|
||||||
|
**Assignment** (`POST /people/<id>/chore-assignments`) says who owes which chore type.
|
||||||
|
It's a strong **preference, not a lock**: an assignee who's home gets nudged instead of
|
||||||
|
whoever's nearest, but an assignee who's *away* doesn't block the chore — the nudge
|
||||||
|
falls through to whoever is around, because the house rule is still "I don't care who
|
||||||
|
does it, as long as it gets done." `CHORE_ASSIGNMENT_STRICT=true` in `chores.env`
|
||||||
|
flips that to waiting for the assignee instead. **Litter can't be assigned to anyone**,
|
||||||
|
for the same reason it ignores exemptions — see below.
|
||||||
|
|
||||||
|
The other two fields, set via `POST /people/<id>/chore-settings`:
|
||||||
|
|
||||||
- **`chore_exempt`** — a household member who's tracked for presence/identity like
|
- **`chore_exempt`** — a household member who's tracked for presence/identity like
|
||||||
anyone else but never nudged about chores in general (the "cousin visits often
|
anyone else but never nudged about chores in general (the "cousin visits often
|
||||||
|
|
@ -134,11 +386,12 @@ one is built. Read by `chores/` off `GET /presence`:
|
||||||
assignment decision (see `chores/README.md`). Empty/unset falls back to a plain,
|
assignment decision (see `chores/README.md`). Empty/unset falls back to a plain,
|
||||||
un-styled template with no LLM call at all.
|
un-styled template with no LLM call at all.
|
||||||
|
|
||||||
Both fields live on `people` (not a separate table) because they're household-
|
Both of those live as columns on `people` (not a separate table) because they're
|
||||||
standing facts about a person, same category as their name or photo — `identity` is
|
household-standing facts about a person, same category as their name or photo —
|
||||||
already this project's source of truth for who someone is, so this is where "how do
|
`identity` is already this project's source of truth for who someone is, so this is
|
||||||
I relate to this specific household member" facts belong, not duplicated into
|
where "how do I relate to this specific household member" facts belong, not duplicated
|
||||||
`chores/`'s own database.
|
into `chores/`'s own database. Assignments get their own table only because they're
|
||||||
|
many-per-person, not because they belong anywhere else.
|
||||||
|
|
||||||
## Camera face recognition — a second presence signal, never a registration one
|
## Camera face recognition — a second presence signal, never a registration one
|
||||||
|
|
||||||
|
|
@ -241,13 +494,35 @@ not network placement.
|
||||||
| `POST /register/photo` | raw image bytes -> `{"photo_id": "..."}` — an audit artifact, and also becomes the person's profile picture (see below) |
|
| `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` | `{"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 |
|
| `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` | admin/audit list of every person: identifiers, device grants, chore assignments, `nickname`/`speak_name`, `last_visit_at`, `visit_count`, `currently_home_since` |
|
||||||
|
| `POST /people/<id>` | edit any editable field — `{"name"?, "nickname"?, "note"?, "chore_exempt"?, "chore_reminder_style"?, "notify_on_arrival"?, "announce_arrivals"?, "notify_topic"?, "clear_photo"?}`. Omitted keys are left alone |
|
||||||
|
| `POST /people/<id>/test-notification` | push a test message to this person's ntfy topic, to prove it works |
|
||||||
| `GET /people/<id>/photo` | the person's profile picture (raw JPEG) — their most recent registration photo |
|
| `GET /people/<id>/photo` | the person's profile picture (raw JPEG) — their most recent registration photo |
|
||||||
|
| `POST /people/<id>/identifiers` | `{"entity_id"}` — attach an identifier by hand (a fixed BLE tag not in range yet). Still enforces `TRUSTED_ENTITY_PREFIXES` |
|
||||||
| `DELETE /people/<id>/identifiers/<id>` | revoke a mistaken or compromised identifier |
|
| `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 |
|
| `DELETE /people/<id>` | remove a person entirely (their identifiers, grants and visits go with them) |
|
||||||
|
| `GET /resolve?q=<spoken>` | spoken name **or nickname** -> the canonical person, with `speak_name` (see below) |
|
||||||
|
| `GET /prune/candidates?last_visit_before=<date>` | everyone whose last visit predates that date — a **read**, it selects and never deletes |
|
||||||
|
| `POST /people/prune` | `{"person_ids": [...]}` — bulk delete by explicit id, never by filter (see below) |
|
||||||
|
| `GET /people/<id>/visits`, `GET /visits` | visit history; both take `?since=&limit=` |
|
||||||
|
| `GET /co-presence?person_id=&since=` | who was home at the same time as whom, derived from overlapping visits |
|
||||||
|
| `GET /device-access?person_id=&entity_id=&permission=&via=` | **may this person operate this device?** -> `{"allowed", "reason", ...}` |
|
||||||
|
| `POST /people/<id>/device-grants` | `{"entity_id", "permission"?, "expires_at"?, "note"?}` — grant a right |
|
||||||
|
| `DELETE /people/<id>/device-grants/<id>` | revoke one |
|
||||||
|
| `GET /device-access/events?limit=` | the audit log of every access check, allowed and denied |
|
||||||
|
| `GET`/`POST /people/<id>/chore-assignments` | read/replace this person's assigned chore types (`{"chore_types": [...]}`) |
|
||||||
|
| `GET /chore-assignments` | the same facts keyed by chore type — the shape `chores/` reads |
|
||||||
|
| `GET /floorplan` | every level and its drawn rooms (polygons in normalised 0–1 coordinates) |
|
||||||
|
| `POST /floorplan/levels` | create or rename a level — `{"id"?, "name", "sort_order"?}` |
|
||||||
|
| `DELETE /floorplan/levels/<id>` | remove a level and its rooms |
|
||||||
|
| `GET`/`POST /floorplan/levels/<id>/image` | the level's background image (raw bytes) |
|
||||||
|
| `POST /floorplan/rooms` | create or update a room — `{"id"?, "level_id", "name", "ha_area_id"?, "points", "color"?}` |
|
||||||
|
| `DELETE /floorplan/rooms/<id>` | remove a room |
|
||||||
|
| `GET /floorplan/presence` | the plan **plus who is in each room right now**, with `unplaced`/`unmapped_areas` |
|
||||||
|
| `GET /floorplan/areas` | area values HA is currently reporting — the editor's pick list |
|
||||||
| `POST /presence/manual` | `{"person_id", "home"}` — hand-operated Home/Away for anyone with no identifiers |
|
| `POST /presence/manual` | `{"person_id", "home"}` — hand-operated Home/Away for anyone with no identifiers |
|
||||||
| `POST /people/<id>/chore-settings` | `{"chore_exempt"?, "chore_reminder_style"?}` — see below; either field omitted/`null` leaves it unchanged |
|
| `POST /people/<id>/chore-settings` | `{"chore_exempt"?, "chore_reminder_style"?}` — see below; either field omitted/`null` leaves it unchanged |
|
||||||
| `GET /presence` | `{"people": [{"id", "name", "home", "room", "has_device", "has_photo", "chore_exempt", "chore_reminder_style"}], "generated_at"}` — `home` is `true`/`false`/`null` (unknown), `room` is best-effort floor-plan groundwork (see below) |
|
| `GET /presence` | `{"people": [{"id", "name", "nickname", "speak_name", "home", "room", "has_device", "has_photo", "chore_exempt", "chore_reminder_style", "chore_assignments"}], "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 |
|
| `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
|
**Every person gets a profile picture, automatically** — whichever registration photo
|
||||||
|
|
@ -283,8 +558,44 @@ no way to send an `Authorization` header.
|
||||||
attaches to a trusted entity's state — unconfirmed, and the whole `room` field in
|
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
|
`/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.
|
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
|
8. The blob+`createObjectURL()` profile-picture fetch (all three frontends) has not
|
||||||
checked for a memory leak from never calling `URL.revokeObjectURL()` on the old
|
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 —
|
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
|
likely fine at household scale and dashboard.js's 60s poll cadence, not measured
|
||||||
over a multi-day uptime.
|
over a multi-day uptime.
|
||||||
|
9. **`DEPARTURE_GRACE_SECONDS`' default (15 min) is a guess at how much a real Private
|
||||||
|
BLE Device setup actually flaps** — too low and one evening at home becomes several
|
||||||
|
"visits"; too high and a quick trip out doesn't register at all. Nobody has watched
|
||||||
|
a real BLE presence entity over a day to tune it. The visit log's usefulness rests
|
||||||
|
almost entirely on this number, and it's the first thing to check once there's real
|
||||||
|
data in `GET /visits`.
|
||||||
|
10. **The floor-plan editor's usefulness is gated entirely on `AREA_ATTRIBUTE` being
|
||||||
|
right** — every room can be drawn and mapped correctly and still never light up, if
|
||||||
|
the attribute `/presence` reads isn't what Bermuda actually publishes. The editor
|
||||||
|
makes this diagnosable rather than mysterious (it lists the areas HA is really
|
||||||
|
reporting, and names anyone home who couldn't be placed), but it can't fix it. This
|
||||||
|
is the same unconfirmed guess as verification item 7, now with a UI depending on it.
|
||||||
|
11. **The admin panel has been exercised against the API, not in a browser** — every
|
||||||
|
endpoint it calls is covered by the route tests, but the page itself (the `<dialog>`
|
||||||
|
editor, the prune checkboxes) has not been opened in a real browser on this
|
||||||
|
machine. `<dialog>`'s `showModal()` needs a reasonably current browser; the door
|
||||||
|
panel's Chromium is fine, an ancient one wouldn't be.
|
||||||
|
12. **Nothing enforces that voice/TTS consumers actually read `speak_name`** — the
|
||||||
|
field is there and documented, and `chores/` uses it, but a future HA intent script
|
||||||
|
that reaches for `nickname` instead would be wrong in a way this repo can't catch.
|
||||||
|
Worth a look whenever a new consumer of `/presence` or `/resolve` gets written.
|
||||||
|
13. **Arrival pushes have never been delivered to a real phone from here** — the
|
||||||
|
notification logic is covered by tests (subscription, opt-out, dedup, the
|
||||||
|
startup-baseline guard), but nothing has been sent through a real ntfy server to a
|
||||||
|
real device. Two things to check: that the ntfy **Android** app's instant delivery
|
||||||
|
against a self-hosted LAN server behaves as its docs describe, and that a phone on
|
||||||
|
the **WireGuard split tunnel** (`docs/network-integration.md` §2.1) actually reaches
|
||||||
|
`NTFY_URL` from outside. The split tunnel is the more likely of the two to need
|
||||||
|
fiddling — get `AllowedIPs` wrong and it fails either silently or by breaking the
|
||||||
|
phone's connectivity on café Wi-Fi.
|
||||||
|
14. **The device-rights HA wiring above is a worked example, not a tested one** — same
|
||||||
|
caveat as the voice-registration YAML. The consequence of getting it wrong is worse
|
||||||
|
here than anywhere else in this repo: an automation that calls `lock.unlock`
|
||||||
|
*outside* the `condition: template` guard would open the door regardless of what
|
||||||
|
this service answered. `identity` cannot enforce that from its side — it only ever
|
||||||
|
answers the question.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,286 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Household admin</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body class="admin">
|
||||||
|
<!--
|
||||||
|
identity's admin panel — managing people, guests, rights and history, as opposed to
|
||||||
|
register.html (the door-panel kiosk's own self-service flow) and dashboard.html (the
|
||||||
|
ambient at-a-glance screen).
|
||||||
|
|
||||||
|
THIS IS THE ONE PAGE HERE NOT DESIGNED FOR A KIOSK. The other two are big-touch-
|
||||||
|
target, glanceable, and running unattended on a wall panel; this one is dense, has
|
||||||
|
destructive buttons on it, and is meant for a phone or laptop where you sat down
|
||||||
|
intending to administer something. That's why it opts into `body.admin`'s tighter
|
||||||
|
scale rather than inheriting the 64px-button kiosk defaults — a wall panel anyone
|
||||||
|
can walk up to should not have a "Prune 6 people" button on it.
|
||||||
|
|
||||||
|
Config comes from the URL exactly like the sibling pages: ?api=&token=.
|
||||||
|
-->
|
||||||
|
<div id="tabs">
|
||||||
|
<button type="button" class="tab active" data-panel="people">👥<span>People</span></button>
|
||||||
|
<button type="button" class="tab" data-panel="prune">🧹<span>Prune</span></button>
|
||||||
|
<button type="button" class="tab" data-panel="history">🕘<span>History</span></button>
|
||||||
|
<button type="button" class="tab" data-panel="floorplan">🗺️<span>Floorplan</span></button>
|
||||||
|
<button type="button" class="tab" data-panel="access">🔑<span>Access log</span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- ---------------------------------------------------------------- People -->
|
||||||
|
<section class="panel active" id="panel-people">
|
||||||
|
<section class="block">
|
||||||
|
<h2>People</h2>
|
||||||
|
<p class="hint">Tap a person to edit every field, their devices, door rights and chores.</p>
|
||||||
|
<div id="people-list" class="card-list"><p class="hint">Loading…</p></div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ----------------------------------------------------------------- Prune -->
|
||||||
|
<section class="panel" id="panel-prune">
|
||||||
|
<section class="block">
|
||||||
|
<h2>Prune old records</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Select everyone whose last visit was before a date — for clearing out
|
||||||
|
one-off guests. Nothing is deleted until you confirm the exact list below.
|
||||||
|
</p>
|
||||||
|
<div class="row">
|
||||||
|
<label class="field">
|
||||||
|
Last visited before
|
||||||
|
<input id="prune-date" type="date">
|
||||||
|
</label>
|
||||||
|
<button type="button" id="prune-select" class="btn">Select all</button>
|
||||||
|
</div>
|
||||||
|
<p id="prune-summary" class="hint"></p>
|
||||||
|
<div id="prune-list" class="card-list"></div>
|
||||||
|
<div class="row" style="margin-top:12px">
|
||||||
|
<button type="button" id="prune-delete" class="btn danger" disabled>Delete selected</button>
|
||||||
|
<span id="prune-status" class="hint"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- --------------------------------------------------------------- History -->
|
||||||
|
<section class="panel" id="panel-history">
|
||||||
|
<section class="block">
|
||||||
|
<h2>Who was home, when</h2>
|
||||||
|
<div class="row">
|
||||||
|
<label class="field">
|
||||||
|
Person
|
||||||
|
<select id="history-person"><option value="">Everyone</option></select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
Since
|
||||||
|
<input id="history-since" type="date">
|
||||||
|
</label>
|
||||||
|
<button type="button" id="history-load" class="btn">Load</button>
|
||||||
|
</div>
|
||||||
|
<div id="visit-list" class="card-list"><p class="hint">Pick a range and load.</p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>…and with whom</h2>
|
||||||
|
<p class="hint">Overlapping visits, worked out from the history above.</p>
|
||||||
|
<div id="copresence-list" class="card-list"></div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ------------------------------------------------------------- Floorplan -->
|
||||||
|
<!--
|
||||||
|
The half that was missing from Phase 6 (project-plan open decision #22): /presence
|
||||||
|
has reported a best-effort `room` all along, but there was nothing to plot it on.
|
||||||
|
Rooms are DRAWN HERE BY HAND — nothing in this project knows the shape of this
|
||||||
|
flat, and the alternative to drawing it was inventing a coordinate format against
|
||||||
|
a guess, which is why this stayed deferred rather than half-built.
|
||||||
|
-->
|
||||||
|
<section class="panel" id="panel-floorplan">
|
||||||
|
<section class="block">
|
||||||
|
<div class="row">
|
||||||
|
<label class="field">
|
||||||
|
Level
|
||||||
|
<select id="fp-level"></select>
|
||||||
|
</label>
|
||||||
|
<button type="button" id="fp-add-level" class="btn">Add level</button>
|
||||||
|
<button type="button" id="fp-rename-level" class="btn">Rename</button>
|
||||||
|
<button type="button" id="fp-delete-level" class="btn danger">Delete level</button>
|
||||||
|
<label class="btn" style="cursor:pointer">
|
||||||
|
Background image
|
||||||
|
<input id="fp-image" type="file" accept="image/*" hidden>
|
||||||
|
</label>
|
||||||
|
<span class="grow"></span>
|
||||||
|
<label class="check">
|
||||||
|
<input id="fp-live" type="checkbox" checked>
|
||||||
|
Show who's home
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="fp-hint">
|
||||||
|
Click on the plan to drop points, then <b>Finish</b> (or double-click) to close
|
||||||
|
the room. Drag a point to adjust it. Click a finished room to edit it.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block fp-layout">
|
||||||
|
<div class="fp-canvas-wrap">
|
||||||
|
<svg id="fp-canvas" viewBox="0 0 1000 700" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<image id="fp-bg" x="0" y="0" width="1000" height="700" preserveAspectRatio="none"></image>
|
||||||
|
<g id="fp-rooms"></g>
|
||||||
|
<g id="fp-draft"></g>
|
||||||
|
<g id="fp-handles"></g>
|
||||||
|
</svg>
|
||||||
|
<div class="row" style="margin-top:8px">
|
||||||
|
<button type="button" id="fp-draw" class="btn primary">Draw a room</button>
|
||||||
|
<button type="button" id="fp-finish" class="btn" disabled>Finish</button>
|
||||||
|
<button type="button" id="fp-cancel" class="btn" disabled>Cancel</button>
|
||||||
|
<span id="fp-status" class="hint"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="fp-side">
|
||||||
|
<h3 id="fp-editor-title">Rooms</h3>
|
||||||
|
<div id="fp-room-list" class="card-list"></div>
|
||||||
|
|
||||||
|
<fieldset class="field-group" id="fp-room-editor" hidden>
|
||||||
|
<legend>Selected room</legend>
|
||||||
|
<label class="field">Name
|
||||||
|
<input id="fp-room-name" type="text" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
Home Assistant area
|
||||||
|
<input id="fp-room-area" type="text" list="fp-areas" placeholder="e.g. kitchen" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<datalist id="fp-areas"></datalist>
|
||||||
|
<p class="hint" id="fp-area-hint"></p>
|
||||||
|
<label class="field">Colour
|
||||||
|
<input id="fp-room-color" type="color" value="#6ea8fe">
|
||||||
|
</label>
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" id="fp-room-save" class="btn primary">Save room</button>
|
||||||
|
<button type="button" id="fp-room-delete" class="btn danger">Delete</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div id="fp-unplaced" class="hint" style="margin-top:14px"></div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ------------------------------------------------------------ Access log -->
|
||||||
|
<section class="panel" id="panel-access">
|
||||||
|
<section class="block">
|
||||||
|
<h2>Device access checks</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Every time something asked whether a person may operate a device — allowed and
|
||||||
|
denied alike. identity only ever answers these; Home Assistant is what actually
|
||||||
|
moves a lock.
|
||||||
|
</p>
|
||||||
|
<div id="access-list" class="card-list"><p class="hint">Loading…</p></div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- The person editor. One dialog reused for whoever's selected, rather than an
|
||||||
|
always-rendered form per person — the list can get long and only one is ever
|
||||||
|
being edited. -->
|
||||||
|
<dialog id="editor">
|
||||||
|
<form method="dialog" id="editor-form">
|
||||||
|
<header class="editor-head">
|
||||||
|
<span class="avatar" id="edit-avatar">👤</span>
|
||||||
|
<h3 id="edit-title">Edit</h3>
|
||||||
|
<button type="button" class="btn ghost" id="edit-close">Close</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="editor-body">
|
||||||
|
<label class="field">Name <span class="hint">— what the assistant says out loud</span>
|
||||||
|
<input id="edit-name" type="text" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<label class="field">Nickname <span class="hint">— what people call them; the assistant never uses it</span>
|
||||||
|
<input id="edit-nickname" type="text" autocomplete="off" placeholder="none">
|
||||||
|
</label>
|
||||||
|
<label class="field">Note
|
||||||
|
<input id="edit-note" type="text" autocomplete="off" placeholder="e.g. cousin, visits at Christmas">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset class="field-group">
|
||||||
|
<legend>Chores</legend>
|
||||||
|
<label class="check">
|
||||||
|
<input id="edit-chore-exempt" type="checkbox">
|
||||||
|
Exempt from chores
|
||||||
|
<span class="hint">— litter is always the exception; everyone cleans up what they left out</span>
|
||||||
|
</label>
|
||||||
|
<label class="field">Reminder style
|
||||||
|
<input id="edit-reminder-style" type="text" placeholder="e.g. be gentle, give me a few minutes">
|
||||||
|
</label>
|
||||||
|
<div class="field">
|
||||||
|
Assigned chores
|
||||||
|
<div id="edit-chore-types" class="chip-row"></div>
|
||||||
|
<p class="hint">
|
||||||
|
A strong preference, not a lock: if the assignee isn't home, whoever is
|
||||||
|
gets nudged instead. Litter ignores assignment entirely.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="field-group">
|
||||||
|
<legend>Arrival notifications</legend>
|
||||||
|
<label class="check">
|
||||||
|
<input id="edit-notify-on-arrival" type="checkbox">
|
||||||
|
Push me when someone gets home
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input id="edit-announce-arrivals" type="checkbox">
|
||||||
|
Announce <em>my</em> arrivals to others
|
||||||
|
<span class="hint">— untick to keep your comings and goings private</span>
|
||||||
|
</label>
|
||||||
|
<div class="row">
|
||||||
|
<label class="field grow">
|
||||||
|
ntfy topic
|
||||||
|
<input id="edit-notify-topic" type="text" placeholder="(shared household topic)">
|
||||||
|
</label>
|
||||||
|
<button type="button" id="edit-test-notify" class="btn">Send test</button>
|
||||||
|
</div>
|
||||||
|
<p id="notify-hint" class="hint"></p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="field-group">
|
||||||
|
<legend>Devices used for presence</legend>
|
||||||
|
<div id="edit-identifiers" class="card-list"></div>
|
||||||
|
<div class="row">
|
||||||
|
<input id="edit-new-identifier" type="text" placeholder="device_tracker.pble_…" class="grow">
|
||||||
|
<button type="button" id="edit-add-identifier" class="btn">Add</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
Must match TRUSTED_ENTITY_PREFIXES — only IRK-resolved or fixed-tag entities
|
||||||
|
can identify a person. For provisioning a tag that isn't in range yet.
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="field-group">
|
||||||
|
<legend>Device rights</legend>
|
||||||
|
<div id="edit-grants" class="card-list"></div>
|
||||||
|
<div class="row">
|
||||||
|
<input id="grant-entity" type="text" placeholder="lock.front_door" class="grow">
|
||||||
|
<input id="grant-permission" type="text" placeholder="operate" style="max-width:110px">
|
||||||
|
<input id="grant-expires" type="date" title="Optional expiry">
|
||||||
|
<button type="button" id="grant-add" class="btn">Grant</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
Lets this person operate that entity — e.g. unlocking the front door for
|
||||||
|
themselves. An expiry date makes it a temporary key.
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="row editor-footer">
|
||||||
|
<button type="button" id="edit-save" class="btn primary">Save changes</button>
|
||||||
|
<button type="button" id="edit-delete" class="btn danger">Delete person</button>
|
||||||
|
<span id="edit-status" class="hint"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,963 @@
|
||||||
|
// identity's admin panel logic. Vanilla JS, no framework, no build step — same
|
||||||
|
// "vendored, dependency-free" choice as every other frontend in this project. See
|
||||||
|
// admin.html's top comment for why this page is the one that isn't kiosk-shaped.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const API = (params.get("api") || "").replace(/\/$/, "");
|
||||||
|
const TOKEN = params.get("token") || "";
|
||||||
|
|
||||||
|
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("admin: missing ?api=/&token= query params");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chore types the checkboxes offer. Kept in sync BY HAND with chores/check.py's
|
||||||
|
// _CHORE_PROMPTS — there is no endpoint that lists them, because chores/ is a
|
||||||
|
// oneshot timer job with no HTTP surface at all, and inventing one just so this
|
||||||
|
// dropdown could be generated would be a lot of moving parts for a list that changes
|
||||||
|
// about once a year. "litter" is deliberately absent: it can't be assigned to anyone
|
||||||
|
// (see chores/README.md), so offering it here would be offering a lie.
|
||||||
|
const CHORE_TYPES = ["trash", "bin_full", "dishes"];
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
// 409 carries a real, human-readable refusal ("that nickname collides with…"),
|
||||||
|
// so it's a result to display rather than an error to throw — same convention
|
||||||
|
// as register.js.
|
||||||
|
if (!res.ok && res.status !== 409) throw new Error(body.error || body.message || `${res.status} ${res.statusText}`);
|
||||||
|
return body;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function postJson(path, body) {
|
||||||
|
return api(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dates are stored as UTC ISO strings; a household admin reading "was Amir home
|
||||||
|
// Tuesday evening" wants them in their own timezone, which is what toLocaleString
|
||||||
|
// gives without this page needing to know what that timezone is.
|
||||||
|
function fmt(iso) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return isNaN(d) ? iso : d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return isNaN(d) ? iso : d.toLocaleDateString(undefined, { dateStyle: "medium" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function duration(fromIso, toIso) {
|
||||||
|
const from = new Date(fromIso);
|
||||||
|
const to = toIso ? new Date(toIso) : new Date();
|
||||||
|
const mins = Math.round((to - from) / 60000);
|
||||||
|
if (isNaN(mins) || mins < 0) return "";
|
||||||
|
if (mins < 60) return `${mins}m`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
return hours < 24 ? `${hours}h ${mins % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same blob-fetch approach as register.js/dashboard.js — every identity endpoint
|
||||||
|
// requires an Authorization header, and a plain <img src="..."> has no way to send
|
||||||
|
// one. See identity/README.md.
|
||||||
|
function loadAvatar(container, 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);
|
||||||
|
container.replaceChildren(img);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tabs ------------------------------------------------------------------------
|
||||||
|
document.querySelectorAll(".tab").forEach((tab) => {
|
||||||
|
tab.addEventListener("click", () => {
|
||||||
|
document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t === tab));
|
||||||
|
document.querySelectorAll(".panel").forEach((p) => {
|
||||||
|
p.classList.toggle("active", p.id === `panel-${tab.dataset.panel}`);
|
||||||
|
});
|
||||||
|
if (tab.dataset.panel === "access") loadAccessLog();
|
||||||
|
if (tab.dataset.panel === "floorplan") {
|
||||||
|
// Loaded on open rather than at startup: it costs an HA round trip for the area
|
||||||
|
// suggestions, and most visits to this page never touch the floorplan.
|
||||||
|
loadFloorplan().then(loadFloorplanPresence);
|
||||||
|
loadAreaSuggestions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- People list -------------------------------------------------------------------
|
||||||
|
let people = [];
|
||||||
|
|
||||||
|
function personSubtitle(p) {
|
||||||
|
const bits = [];
|
||||||
|
if (p.nickname) bits.push(`“${p.nickname}”`);
|
||||||
|
if (p.currently_home_since) bits.push(`home since ${fmt(p.currently_home_since)}`);
|
||||||
|
else bits.push(`last seen ${fmtDate(p.last_visit_at)}${p.last_visit_is_estimated ? " (never recorded)" : ""}`);
|
||||||
|
if (p.identifiers.length) bits.push(`${p.identifiers.length} device${p.identifiers.length === 1 ? "" : "s"}`);
|
||||||
|
else bits.push("no device");
|
||||||
|
if (p.device_grants.length) bits.push(`${p.device_grants.length} right${p.device_grants.length === 1 ? "" : "s"}`);
|
||||||
|
if (p.notify_on_arrival) bits.push(p.notify_deliverable ? "🔔 arrivals" : "🔔 arrivals (undeliverable)");
|
||||||
|
if (!p.announce_arrivals) bits.push("not announced");
|
||||||
|
if (p.chore_exempt) bits.push("chore-exempt");
|
||||||
|
if (p.chore_assignments.length) bits.push(`chores: ${p.chore_assignments.join(", ")}`);
|
||||||
|
return bits.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPeople() {
|
||||||
|
const el = document.getElementById("people-list");
|
||||||
|
return api("/people")
|
||||||
|
.then((data) => {
|
||||||
|
people = data.people || [];
|
||||||
|
if (!people.length) {
|
||||||
|
el.innerHTML = '<p class="hint">Nobody registered yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = people
|
||||||
|
.map(
|
||||||
|
(p) =>
|
||||||
|
`<button type="button" class="card as-button" data-person="${p.id}">
|
||||||
|
<span class="avatar" data-avatar="${p.id}">👤</span>
|
||||||
|
<span class="card-body">
|
||||||
|
<span class="card-name">${escapeHtml(p.name)}${p.currently_home_since ? ' <span class="dot-home" title="home now"></span>' : ""}</span>
|
||||||
|
<span class="card-meta">${escapeHtml(personSubtitle(p))}</span>
|
||||||
|
</span>
|
||||||
|
</button>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
el.querySelectorAll("[data-person]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => openEditor(Number(btn.dataset.person)));
|
||||||
|
});
|
||||||
|
people.forEach((p) => {
|
||||||
|
if (p.has_photo) {
|
||||||
|
const avatar = el.querySelector(`[data-avatar="${p.id}"]`);
|
||||||
|
if (avatar) loadAvatar(avatar, p.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
populatePersonSelect();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
el.innerHTML = `<p class="error">Could not load people: ${escapeHtml(err.message)}</p>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Person editor ------------------------------------------------------------------
|
||||||
|
const editor = document.getElementById("editor");
|
||||||
|
const editStatus = document.getElementById("edit-status");
|
||||||
|
let editing = null;
|
||||||
|
|
||||||
|
function setStatus(text, isError) {
|
||||||
|
editStatus.textContent = text;
|
||||||
|
editStatus.className = isError ? "error" : "hint";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditor(personId) {
|
||||||
|
editing = people.find((p) => p.id === personId);
|
||||||
|
if (!editing) return;
|
||||||
|
|
||||||
|
document.getElementById("edit-title").textContent = editing.name;
|
||||||
|
document.getElementById("edit-name").value = editing.name;
|
||||||
|
document.getElementById("edit-nickname").value = editing.nickname || "";
|
||||||
|
document.getElementById("edit-note").value = editing.note || "";
|
||||||
|
document.getElementById("edit-chore-exempt").checked = editing.chore_exempt;
|
||||||
|
document.getElementById("edit-reminder-style").value = editing.chore_reminder_style || "";
|
||||||
|
document.getElementById("edit-notify-on-arrival").checked = editing.notify_on_arrival;
|
||||||
|
document.getElementById("edit-announce-arrivals").checked = editing.announce_arrivals;
|
||||||
|
document.getElementById("edit-notify-topic").value = editing.notify_topic || "";
|
||||||
|
// Says out loud when a ticked box still can't deliver, rather than leaving someone
|
||||||
|
// to cross-reference a checkbox against an env file to work out why nothing arrives.
|
||||||
|
document.getElementById("notify-hint").innerHTML = editing.notify_deliverable
|
||||||
|
? "Pushes go out via the household ntfy server."
|
||||||
|
: '<span class="error">No ntfy topic reachable — set NTFY_URL and NTFY_DEFAULT_TOPIC on the server, or a topic here.</span>';
|
||||||
|
|
||||||
|
const avatar = document.getElementById("edit-avatar");
|
||||||
|
avatar.replaceChildren(document.createTextNode("👤"));
|
||||||
|
if (editing.has_photo) loadAvatar(avatar, editing.id);
|
||||||
|
|
||||||
|
document.getElementById("edit-chore-types").innerHTML = CHORE_TYPES.map(
|
||||||
|
(type) =>
|
||||||
|
`<label class="chip"><input type="checkbox" data-chore="${type}"${
|
||||||
|
editing.chore_assignments.includes(type) ? " checked" : ""
|
||||||
|
}> ${escapeHtml(type)}</label>`
|
||||||
|
).join("");
|
||||||
|
|
||||||
|
renderIdentifiers();
|
||||||
|
renderGrants();
|
||||||
|
setStatus("");
|
||||||
|
editor.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIdentifiers() {
|
||||||
|
const el = document.getElementById("edit-identifiers");
|
||||||
|
if (!editing.identifiers.length) {
|
||||||
|
el.innerHTML = '<p class="hint">No devices — presence has to be set by hand.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = editing.identifiers
|
||||||
|
.map(
|
||||||
|
(i) =>
|
||||||
|
`<div class="card compact">
|
||||||
|
<span class="card-body">
|
||||||
|
<span class="card-name mono">${escapeHtml(i.ha_entity_id)}</span>
|
||||||
|
<span class="card-meta">added ${escapeHtml(fmtDate(i.registered_at))} via ${escapeHtml(i.registered_via_device || "?")}</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="btn danger small" data-drop-identifier="${i.id}">Revoke</button>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
el.querySelectorAll("[data-drop-identifier]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
api(`/people/${editing.id}/identifiers/${btn.dataset.dropIdentifier}`, { method: "DELETE" })
|
||||||
|
.then(() => refreshEditing("Device revoked."))
|
||||||
|
.catch((err) => setStatus(err.message, true));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrants() {
|
||||||
|
const el = document.getElementById("edit-grants");
|
||||||
|
if (!editing.device_grants.length) {
|
||||||
|
el.innerHTML = '<p class="hint">No device rights.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
el.innerHTML = editing.device_grants
|
||||||
|
.map((g) => {
|
||||||
|
const expired = g.expires_at && g.expires_at <= now;
|
||||||
|
return `<div class="card compact${expired ? " expired" : ""}">
|
||||||
|
<span class="card-body">
|
||||||
|
<span class="card-name mono">${escapeHtml(g.ha_entity_id)}</span>
|
||||||
|
<span class="card-meta">${escapeHtml(g.permission)}${
|
||||||
|
g.expires_at ? ` · ${expired ? "expired" : "until"} ${escapeHtml(fmtDate(g.expires_at))}` : " · no expiry"
|
||||||
|
}${g.note ? ` · ${escapeHtml(g.note)}` : ""}</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="btn danger small" data-drop-grant="${g.id}">Revoke</button>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
el.querySelectorAll("[data-drop-grant]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
api(`/people/${editing.id}/device-grants/${btn.dataset.dropGrant}`, { method: "DELETE" })
|
||||||
|
.then(() => refreshEditing("Right revoked."))
|
||||||
|
.catch((err) => setStatus(err.message, true));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-reads /people and re-points the open dialog at the fresh record, so a sub-action
|
||||||
|
// (revoking a device, adding a grant) doesn't leave the dialog showing stale data or
|
||||||
|
// force the admin to close and reopen it.
|
||||||
|
function refreshEditing(message) {
|
||||||
|
const id = editing.id;
|
||||||
|
return loadPeople().then(() => {
|
||||||
|
editing = people.find((p) => p.id === id);
|
||||||
|
if (!editing) {
|
||||||
|
editor.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderIdentifiers();
|
||||||
|
renderGrants();
|
||||||
|
if (message) setStatus(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("edit-close").addEventListener("click", () => editor.close());
|
||||||
|
|
||||||
|
document.getElementById("edit-save").addEventListener("click", () => {
|
||||||
|
const chores = Array.from(document.querySelectorAll("[data-chore]:checked")).map((c) => c.dataset.chore);
|
||||||
|
setStatus("Saving…");
|
||||||
|
// Two calls because they're two different resources, not one form: the person's own
|
||||||
|
// fields, and their chore assignments. Sequenced rather than parallel so a rejected
|
||||||
|
// rename (a nickname collision, say) surfaces its message instead of racing the
|
||||||
|
// assignment call's own status text.
|
||||||
|
postJson(`/people/${editing.id}`, {
|
||||||
|
name: document.getElementById("edit-name").value.trim(),
|
||||||
|
nickname: document.getElementById("edit-nickname").value.trim(),
|
||||||
|
note: document.getElementById("edit-note").value.trim(),
|
||||||
|
chore_exempt: document.getElementById("edit-chore-exempt").checked,
|
||||||
|
chore_reminder_style: document.getElementById("edit-reminder-style").value.trim(),
|
||||||
|
notify_on_arrival: document.getElementById("edit-notify-on-arrival").checked,
|
||||||
|
announce_arrivals: document.getElementById("edit-announce-arrivals").checked,
|
||||||
|
notify_topic: document.getElementById("edit-notify-topic").value.trim(),
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
if (!result.ok) throw new Error(result.message || "Could not save.");
|
||||||
|
return postJson(`/people/${editing.id}/chore-assignments`, { chore_types: chores });
|
||||||
|
})
|
||||||
|
.then(() => refreshEditing("Saved."))
|
||||||
|
.then(() => {
|
||||||
|
document.getElementById("edit-title").textContent = editing ? editing.name : "";
|
||||||
|
})
|
||||||
|
.catch((err) => setStatus(err.message, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deliberately sends to whatever is SAVED, not what's typed in the box — a test that
|
||||||
|
// silently used unsaved input would prove a topic works and then leave a different one
|
||||||
|
// stored. Save first, then test.
|
||||||
|
document.getElementById("edit-test-notify").addEventListener("click", () => {
|
||||||
|
setStatus("Sending test…");
|
||||||
|
postJson(`/people/${editing.id}/test-notification`, {})
|
||||||
|
.then((result) => setStatus(result.message, !result.ok))
|
||||||
|
.catch((err) => setStatus(err.message, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("edit-add-identifier").addEventListener("click", () => {
|
||||||
|
const input = document.getElementById("edit-new-identifier");
|
||||||
|
const entityId = input.value.trim();
|
||||||
|
if (!entityId) return;
|
||||||
|
postJson(`/people/${editing.id}/identifiers`, { entity_id: entityId })
|
||||||
|
.then((result) => {
|
||||||
|
if (!result.ok) throw new Error(result.message);
|
||||||
|
input.value = "";
|
||||||
|
return refreshEditing("Device added.");
|
||||||
|
})
|
||||||
|
.catch((err) => setStatus(err.message, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("grant-add").addEventListener("click", () => {
|
||||||
|
const entity = document.getElementById("grant-entity");
|
||||||
|
const permission = document.getElementById("grant-permission");
|
||||||
|
const expires = document.getElementById("grant-expires");
|
||||||
|
if (!entity.value.trim()) return;
|
||||||
|
postJson(`/people/${editing.id}/device-grants`, {
|
||||||
|
entity_id: entity.value.trim(),
|
||||||
|
permission: permission.value.trim() || "operate",
|
||||||
|
// A date input gives "2026-08-01"; the grant expires at the START of that day, so
|
||||||
|
// "until 1 Aug" means the last usable day is 31 Jul. Sent explicitly as UTC
|
||||||
|
// midnight rather than left for the server to interpret a bare date.
|
||||||
|
expires_at: expires.value ? `${expires.value}T00:00:00Z` : null,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
if (!result.ok) throw new Error(result.message);
|
||||||
|
entity.value = "";
|
||||||
|
permission.value = "";
|
||||||
|
expires.value = "";
|
||||||
|
return refreshEditing("Right granted.");
|
||||||
|
})
|
||||||
|
.catch((err) => setStatus(err.message, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("edit-delete").addEventListener("click", () => {
|
||||||
|
if (!confirm(`Delete ${editing.name} completely? Their devices, rights and visit history go too.`)) return;
|
||||||
|
api(`/people/${editing.id}`, { method: "DELETE" })
|
||||||
|
.then(() => {
|
||||||
|
editor.close();
|
||||||
|
loadPeople();
|
||||||
|
})
|
||||||
|
.catch((err) => setStatus(err.message, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Prune ---------------------------------------------------------------------------
|
||||||
|
// The two-step shape here is deliberate and matches the backend: /prune/candidates
|
||||||
|
// SELECTS, POST /people/prune DELETES the exact ids that came back and stayed ticked.
|
||||||
|
// The filter never gets re-run at delete time, so someone who walks in the door
|
||||||
|
// between "Select all" and "Delete selected" can't be swept up by a filter that
|
||||||
|
// silently re-evaluated. See prune_people()'s docstring in server.py.
|
||||||
|
const pruneList = document.getElementById("prune-list");
|
||||||
|
const pruneDelete = document.getElementById("prune-delete");
|
||||||
|
const pruneStatus = document.getElementById("prune-status");
|
||||||
|
|
||||||
|
function selectedPruneIds() {
|
||||||
|
return Array.from(pruneList.querySelectorAll("input[type=checkbox]:checked")).map((c) => Number(c.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPruneButton() {
|
||||||
|
const n = selectedPruneIds().length;
|
||||||
|
pruneDelete.disabled = n === 0;
|
||||||
|
pruneDelete.textContent = n ? `Delete ${n} selected` : "Delete selected";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("prune-select").addEventListener("click", () => {
|
||||||
|
const date = document.getElementById("prune-date").value;
|
||||||
|
if (!date) {
|
||||||
|
document.getElementById("prune-summary").textContent = "Pick a date first.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pruneStatus.textContent = "";
|
||||||
|
api(`/prune/candidates?last_visit_before=${encodeURIComponent(date)}`)
|
||||||
|
.then((data) => {
|
||||||
|
const summary = document.getElementById("prune-summary");
|
||||||
|
if (!data.count) {
|
||||||
|
summary.textContent = `Nobody has a last visit before ${fmtDate(date)}.`;
|
||||||
|
pruneList.innerHTML = "";
|
||||||
|
syncPruneButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
summary.textContent = `${data.count} match${data.count === 1 ? "" : "es"} — untick anyone you want to keep.`;
|
||||||
|
pruneList.innerHTML = data.candidates
|
||||||
|
.map(
|
||||||
|
(p) =>
|
||||||
|
`<label class="card compact">
|
||||||
|
<input type="checkbox" value="${p.id}" checked>
|
||||||
|
<span class="card-body">
|
||||||
|
<span class="card-name">${escapeHtml(p.name)}${p.nickname ? ` “${escapeHtml(p.nickname)}”` : ""}</span>
|
||||||
|
<span class="card-meta">last visit ${escapeHtml(fmtDate(p.last_visit_at))}${
|
||||||
|
p.last_visit_is_estimated ? " (never actually recorded — registered then)" : ""
|
||||||
|
} · ${p.visit_count} visit${p.visit_count === 1 ? "" : "s"}</span>
|
||||||
|
</span>
|
||||||
|
</label>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
pruneList.querySelectorAll("input").forEach((c) => c.addEventListener("change", syncPruneButton));
|
||||||
|
syncPruneButton();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
document.getElementById("prune-summary").innerHTML = `<span class="error">${escapeHtml(err.message)}</span>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
pruneDelete.addEventListener("click", () => {
|
||||||
|
const ids = selectedPruneIds();
|
||||||
|
if (!ids.length) return;
|
||||||
|
const names = ids.map((id) => (people.find((p) => p.id === id) || {}).name || id);
|
||||||
|
if (!confirm(`Delete ${ids.length} record(s)?\n\n${names.join("\n")}\n\nThis cannot be undone.`)) return;
|
||||||
|
pruneStatus.textContent = "Deleting…";
|
||||||
|
postJson("/people/prune", { person_ids: ids })
|
||||||
|
.then((result) => {
|
||||||
|
pruneStatus.textContent = `Deleted ${result.deleted.length} record(s).`;
|
||||||
|
pruneList.innerHTML = "";
|
||||||
|
document.getElementById("prune-summary").textContent = "";
|
||||||
|
syncPruneButton();
|
||||||
|
loadPeople();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
pruneStatus.innerHTML = `<span class="error">${escapeHtml(err.message)}</span>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- History --------------------------------------------------------------------------
|
||||||
|
function populatePersonSelect() {
|
||||||
|
const select = document.getElementById("history-person");
|
||||||
|
const current = select.value;
|
||||||
|
select.innerHTML =
|
||||||
|
'<option value="">Everyone</option>' +
|
||||||
|
people.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join("");
|
||||||
|
select.value = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadHistory() {
|
||||||
|
const personId = document.getElementById("history-person").value;
|
||||||
|
const sinceDate = document.getElementById("history-since").value;
|
||||||
|
const since = sinceDate ? `${sinceDate}T00:00:00Z` : "";
|
||||||
|
const query = since ? `?since=${encodeURIComponent(since)}` : "";
|
||||||
|
const visitPath = personId ? `/people/${personId}/visits${query}` : `/visits${query}`;
|
||||||
|
|
||||||
|
const el = document.getElementById("visit-list");
|
||||||
|
el.innerHTML = '<p class="hint">Loading…</p>';
|
||||||
|
api(visitPath)
|
||||||
|
.then((data) => {
|
||||||
|
const visits = data.visits || [];
|
||||||
|
if (!visits.length) {
|
||||||
|
el.innerHTML = '<p class="hint">No visits recorded in that range.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = visits
|
||||||
|
.map(
|
||||||
|
(v) =>
|
||||||
|
`<div class="card compact">
|
||||||
|
<span class="card-body">
|
||||||
|
<span class="card-name">${escapeHtml(v.name)}</span>
|
||||||
|
<span class="card-meta">${escapeHtml(fmt(v.arrived_at))} → ${
|
||||||
|
v.ongoing ? "still home" : escapeHtml(fmt(v.departed_at))
|
||||||
|
} · ${escapeHtml(duration(v.arrived_at, v.departed_at))} · via ${escapeHtml(v.source)}${
|
||||||
|
v.close_reason === "timed_out" ? " · departure never observed" : ""
|
||||||
|
}</span>
|
||||||
|
</span>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
el.innerHTML = `<p class="error">Could not load visits: ${escapeHtml(err.message)}</p>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const copEl = document.getElementById("copresence-list");
|
||||||
|
const copQuery = new URLSearchParams();
|
||||||
|
if (personId) copQuery.set("person_id", personId);
|
||||||
|
if (since) copQuery.set("since", since);
|
||||||
|
api(`/co-presence?${copQuery.toString()}`)
|
||||||
|
.then((data) => {
|
||||||
|
const overlaps = data.overlaps || [];
|
||||||
|
if (!overlaps.length) {
|
||||||
|
copEl.innerHTML = '<p class="hint">Nobody overlapped in that range.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copEl.innerHTML = overlaps
|
||||||
|
.map(
|
||||||
|
(o) =>
|
||||||
|
`<div class="card compact">
|
||||||
|
<span class="card-body">
|
||||||
|
<span class="card-name">${escapeHtml(o.people.map((p) => p.name).join(" + "))}</span>
|
||||||
|
<span class="card-meta">${escapeHtml(fmt(o.from))} → ${
|
||||||
|
o.ongoing ? "now" : escapeHtml(fmt(o.until))
|
||||||
|
} · ${escapeHtml(duration(o.from, o.ongoing ? null : o.until))}</span>
|
||||||
|
</span>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
copEl.innerHTML = `<p class="error">Could not load co-presence: ${escapeHtml(err.message)}</p>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("history-load").addEventListener("click", loadHistory);
|
||||||
|
|
||||||
|
// --- Access log ------------------------------------------------------------------------
|
||||||
|
function loadAccessLog() {
|
||||||
|
const el = document.getElementById("access-list");
|
||||||
|
api("/device-access/events?limit=200")
|
||||||
|
.then((data) => {
|
||||||
|
const events = data.events || [];
|
||||||
|
if (!events.length) {
|
||||||
|
el.innerHTML = '<p class="hint">Nothing has asked yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = events
|
||||||
|
.map(
|
||||||
|
(e) =>
|
||||||
|
`<div class="card compact ${e.allowed ? "allowed" : "denied"}">
|
||||||
|
<span class="card-body">
|
||||||
|
<span class="card-name">${escapeHtml(e.name || `person ${e.person_id}`)} → <span class="mono">${escapeHtml(e.ha_entity_id)}</span></span>
|
||||||
|
<span class="card-meta">${escapeHtml(fmt(e.created_at))} · ${escapeHtml(e.permission)} · ${escapeHtml(e.reason)}${
|
||||||
|
e.requested_via ? ` · via ${escapeHtml(e.requested_via)}` : ""
|
||||||
|
}</span>
|
||||||
|
</span>
|
||||||
|
<span class="verdict">${e.allowed ? "allowed" : "denied"}</span>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
el.innerHTML = `<p class="error">Could not load access log: ${escapeHtml(err.message)}</p>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Floorplan editor ------------------------------------------------------------------
|
||||||
|
// Rooms are polygons in NORMALISED 0..1 coordinates (see the schema comment in
|
||||||
|
// server.py): the plan has to render on a laptop now and possibly a wall panel later,
|
||||||
|
// and pixel coordinates would be correct on exactly one of them. Everything below
|
||||||
|
// converts to the SVG's fixed 1000x700 viewBox only at draw time.
|
||||||
|
const FP = { W: 1000, H: 700 };
|
||||||
|
let fpLevels = [];
|
||||||
|
let fpLevelId = null;
|
||||||
|
let fpSelected = null; // the room being edited (a COPY — see selectRoom)
|
||||||
|
let fpDraft = null; // points of a room currently being drawn
|
||||||
|
let fpLive = null; // latest /floorplan/presence payload
|
||||||
|
|
||||||
|
const svg = document.getElementById("fp-canvas");
|
||||||
|
const fpStatus = document.getElementById("fp-status");
|
||||||
|
|
||||||
|
function fpSetStatus(text, isError) {
|
||||||
|
fpStatus.textContent = text || "";
|
||||||
|
fpStatus.className = isError ? "error" : "hint";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNorm(evt) {
|
||||||
|
// Uses the SVG's own coordinate space rather than clientX/clientY arithmetic, so the
|
||||||
|
// mapping stays correct however the element is scaled, scrolled, or letterboxed by
|
||||||
|
// preserveAspectRatio.
|
||||||
|
const pt = svg.createSVGPoint();
|
||||||
|
pt.x = evt.clientX;
|
||||||
|
pt.y = evt.clientY;
|
||||||
|
const local = pt.matrixTransform(svg.getScreenCTM().inverse());
|
||||||
|
return [Math.min(1, Math.max(0, local.x / FP.W)), Math.min(1, Math.max(0, local.y / FP.H))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgEl(tag, attrs) {
|
||||||
|
const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
|
||||||
|
Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointsAttr(points) {
|
||||||
|
return points.map(([x, y]) => `${x * FP.W},${y * FP.H}`).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentLevel() {
|
||||||
|
return fpLevels.find((l) => l.id === fpLevelId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFloorplan() {
|
||||||
|
const level = currentLevel();
|
||||||
|
const roomsG = document.getElementById("fp-rooms");
|
||||||
|
const handlesG = document.getElementById("fp-handles");
|
||||||
|
const draftG = document.getElementById("fp-draft");
|
||||||
|
roomsG.replaceChildren();
|
||||||
|
handlesG.replaceChildren();
|
||||||
|
draftG.replaceChildren();
|
||||||
|
|
||||||
|
const bg = document.getElementById("fp-bg");
|
||||||
|
if (level && level.has_image) {
|
||||||
|
// Blob-fetched like every other image here — the endpoint is token-gated and an
|
||||||
|
// <image href> has no way to send an Authorization header.
|
||||||
|
fetch(`${API}/floorplan/levels/${level.id}/image`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
||||||
|
.then((res) => (res.ok ? res.blob() : Promise.reject()))
|
||||||
|
.then((blob) => bg.setAttribute("href", URL.createObjectURL(blob)))
|
||||||
|
.catch(() => bg.removeAttribute("href"));
|
||||||
|
} else {
|
||||||
|
bg.removeAttribute("href");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!level) return;
|
||||||
|
|
||||||
|
const liveRooms = {};
|
||||||
|
if (fpLive) {
|
||||||
|
(fpLive.levels || []).forEach((l) => (l.rooms || []).forEach((r) => (liveRooms[r.id] = r)));
|
||||||
|
}
|
||||||
|
const showLive = document.getElementById("fp-live").checked;
|
||||||
|
|
||||||
|
level.rooms.forEach((room) => {
|
||||||
|
const occupants = ((liveRooms[room.id] || {}).occupants) || [];
|
||||||
|
const occupied = showLive && occupants.length > 0;
|
||||||
|
const selected = fpSelected && fpSelected.id === room.id;
|
||||||
|
roomsG.appendChild(
|
||||||
|
svgEl("polygon", {
|
||||||
|
points: pointsAttr(room.points),
|
||||||
|
class: `fp-room${occupied ? " occupied" : ""}${selected ? " selected" : ""}`,
|
||||||
|
fill: room.color || "#6ea8fe",
|
||||||
|
"data-room": room.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Label at the average of the vertices. A true centroid would still sit outside an
|
||||||
|
// L-shaped room, so the extra maths buys nothing a human wouldn't just drag anyway.
|
||||||
|
const cx = (room.points.reduce((s, p) => s + p[0], 0) / room.points.length) * FP.W;
|
||||||
|
const cy = (room.points.reduce((s, p) => s + p[1], 0) / room.points.length) * FP.H;
|
||||||
|
const label = svgEl("text", { x: cx, y: cy, class: "fp-label", "text-anchor": "middle" });
|
||||||
|
label.textContent = room.name;
|
||||||
|
roomsG.appendChild(label);
|
||||||
|
|
||||||
|
if (occupied) {
|
||||||
|
const who = svgEl("text", { x: cx, y: cy + 22, class: "fp-occupants", "text-anchor": "middle" });
|
||||||
|
who.textContent = occupants.map((o) => o.name).join(", ");
|
||||||
|
roomsG.appendChild(who);
|
||||||
|
}
|
||||||
|
if (!room.ha_area_id) {
|
||||||
|
// A drawn room with no HA area can never light up, and looking at a plan where
|
||||||
|
// one room never reacts is a confusing way to discover that.
|
||||||
|
const warn = svgEl("text", { x: cx, y: cy + 40, class: "fp-warn", "text-anchor": "middle" });
|
||||||
|
warn.textContent = "no HA area";
|
||||||
|
roomsG.appendChild(warn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
roomsG.querySelectorAll("[data-room]").forEach((poly) => {
|
||||||
|
poly.addEventListener("click", (e) => {
|
||||||
|
if (fpDraft) return; // don't hijack clicks meant for the polygon being drawn
|
||||||
|
e.stopPropagation();
|
||||||
|
selectRoom(Number(poly.dataset.room));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vertex handles for the selected room — dragging one is how a wall gets nudged.
|
||||||
|
if (fpSelected) {
|
||||||
|
fpSelected.points.forEach((point, index) => {
|
||||||
|
const handle = svgEl("circle", {
|
||||||
|
cx: point[0] * FP.W,
|
||||||
|
cy: point[1] * FP.H,
|
||||||
|
r: 8,
|
||||||
|
class: "fp-handle",
|
||||||
|
});
|
||||||
|
handle.addEventListener("pointerdown", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handle.setPointerCapture(e.pointerId);
|
||||||
|
const move = (ev) => {
|
||||||
|
fpSelected.points[index] = toNorm(ev);
|
||||||
|
renderFloorplan();
|
||||||
|
};
|
||||||
|
handle.addEventListener("pointermove", move);
|
||||||
|
handle.addEventListener(
|
||||||
|
"pointerup",
|
||||||
|
() => {
|
||||||
|
handle.removeEventListener("pointermove", move);
|
||||||
|
fpSetStatus("Moved a corner — Save room to keep it.");
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
handlesG.appendChild(handle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fpDraft && fpDraft.length) {
|
||||||
|
draftG.appendChild(svgEl("polyline", { points: pointsAttr(fpDraft), class: "fp-draft-line" }));
|
||||||
|
fpDraft.forEach((p) =>
|
||||||
|
draftG.appendChild(svgEl("circle", { cx: p[0] * FP.W, cy: p[1] * FP.H, r: 6, class: "fp-draft-point" }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoomList() {
|
||||||
|
const el = document.getElementById("fp-room-list");
|
||||||
|
const level = currentLevel();
|
||||||
|
if (!level || !level.rooms.length) {
|
||||||
|
el.innerHTML = '<p class="hint">No rooms on this level yet — draw one.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = level.rooms
|
||||||
|
.map(
|
||||||
|
(r) =>
|
||||||
|
`<button type="button" class="card compact as-button" data-pick="${r.id}">
|
||||||
|
<span class="fp-swatch" style="background:${escapeHtml(r.color || "#6ea8fe")}"></span>
|
||||||
|
<span class="card-body">
|
||||||
|
<span class="card-name">${escapeHtml(r.name)}</span>
|
||||||
|
<span class="card-meta">${r.ha_area_id ? escapeHtml(r.ha_area_id) : "not mapped to an HA area"}</span>
|
||||||
|
</span>
|
||||||
|
</button>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
el.querySelectorAll("[data-pick]").forEach((b) =>
|
||||||
|
b.addEventListener("click", () => selectRoom(Number(b.dataset.pick)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRoom(roomId) {
|
||||||
|
const level = currentLevel();
|
||||||
|
const room = level && level.rooms.find((r) => r.id === roomId);
|
||||||
|
if (!room) return;
|
||||||
|
// Edited on a deep copy: dragging handles mutates points as you go, and abandoning
|
||||||
|
// an edit has to leave the stored room untouched.
|
||||||
|
fpSelected = JSON.parse(JSON.stringify(room));
|
||||||
|
document.getElementById("fp-room-editor").hidden = false;
|
||||||
|
document.getElementById("fp-room-name").value = room.name;
|
||||||
|
document.getElementById("fp-room-area").value = room.ha_area_id || "";
|
||||||
|
document.getElementById("fp-room-color").value = room.color || "#6ea8fe";
|
||||||
|
document.getElementById("fp-editor-title").textContent = room.name;
|
||||||
|
fpSetStatus("");
|
||||||
|
renderFloorplan();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
fpSelected = null;
|
||||||
|
document.getElementById("fp-room-editor").hidden = true;
|
||||||
|
document.getElementById("fp-editor-title").textContent = "Rooms";
|
||||||
|
renderFloorplan();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFloorplan(keepSelection) {
|
||||||
|
return api("/floorplan")
|
||||||
|
.then((data) => {
|
||||||
|
fpLevels = data.levels || [];
|
||||||
|
if (!fpLevels.some((l) => l.id === fpLevelId)) fpLevelId = fpLevels.length ? fpLevels[0].id : null;
|
||||||
|
const select = document.getElementById("fp-level");
|
||||||
|
select.innerHTML = fpLevels.map((l) => `<option value="${l.id}">${escapeHtml(l.name)}</option>`).join("");
|
||||||
|
if (fpLevelId) select.value = fpLevelId;
|
||||||
|
if (!keepSelection) clearSelection();
|
||||||
|
renderRoomList();
|
||||||
|
renderFloorplan();
|
||||||
|
})
|
||||||
|
.catch((err) => fpSetStatus(err.message, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFloorplanPresence() {
|
||||||
|
if (!document.getElementById("fp-live").checked) {
|
||||||
|
fpLive = null;
|
||||||
|
document.getElementById("fp-unplaced").textContent = "";
|
||||||
|
renderFloorplan();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return api("/floorplan/presence")
|
||||||
|
.then((data) => {
|
||||||
|
fpLive = data;
|
||||||
|
const bits = [];
|
||||||
|
if ((data.unplaced || []).length) {
|
||||||
|
bits.push(
|
||||||
|
"Home but not on the plan: " +
|
||||||
|
data.unplaced
|
||||||
|
.map((p) => escapeHtml(p.name) + (p.reported_area ? ` (area “${escapeHtml(p.reported_area)}”)` : ""))
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((data.unmapped_areas || []).length) {
|
||||||
|
bits.push(
|
||||||
|
"Areas HA is reporting that no room claims: <b>" +
|
||||||
|
data.unmapped_areas.map(escapeHtml).join(", ") +
|
||||||
|
"</b> — draw them, or map an existing room to them."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
document.getElementById("fp-unplaced").innerHTML = bits.join("<br>");
|
||||||
|
renderFloorplan();
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAreaSuggestions() {
|
||||||
|
api("/floorplan/areas")
|
||||||
|
.then((data) => {
|
||||||
|
document.getElementById("fp-areas").innerHTML = (data.areas || [])
|
||||||
|
.map((a) => `<option value="${escapeHtml(a)}">`)
|
||||||
|
.join("");
|
||||||
|
const hint = document.getElementById("fp-area-hint");
|
||||||
|
if (data.error === "ha_unreachable") {
|
||||||
|
hint.innerHTML = "<span class=\"error\">Home Assistant unreachable — type the area id by hand.</span>";
|
||||||
|
} else if (!(data.areas || []).length) {
|
||||||
|
hint.textContent =
|
||||||
|
`No areas reported yet on the '${data.attribute || "area_id"}' attribute. That's the field ` +
|
||||||
|
"AREA_ATTRIBUTE reads, and it's an unconfirmed guess until Bermuda is actually running — " +
|
||||||
|
"see identity/README.md.";
|
||||||
|
} else {
|
||||||
|
hint.textContent = `Areas HA is reporting right now on '${data.attribute}'.`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- drawing -----------------------------------------------------------------------
|
||||||
|
document.getElementById("fp-draw").addEventListener("click", () => {
|
||||||
|
if (!fpLevelId) return fpSetStatus("Add a level first.", true);
|
||||||
|
clearSelection();
|
||||||
|
fpDraft = [];
|
||||||
|
document.getElementById("fp-finish").disabled = false;
|
||||||
|
document.getElementById("fp-cancel").disabled = false;
|
||||||
|
fpSetStatus("Click to place corners. Finish (or double-click) to close the room.");
|
||||||
|
renderFloorplan();
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.addEventListener("click", (evt) => {
|
||||||
|
if (!fpDraft) return;
|
||||||
|
fpDraft.push(toNorm(evt));
|
||||||
|
fpSetStatus(`${fpDraft.length} corner${fpDraft.length === 1 ? "" : "s"} — 3 needed to finish.`);
|
||||||
|
renderFloorplan();
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.addEventListener("dblclick", () => {
|
||||||
|
if (fpDraft && fpDraft.length >= 3) finishDraft();
|
||||||
|
});
|
||||||
|
|
||||||
|
function cancelDraft() {
|
||||||
|
fpDraft = null;
|
||||||
|
document.getElementById("fp-finish").disabled = true;
|
||||||
|
document.getElementById("fp-cancel").disabled = true;
|
||||||
|
fpSetStatus("");
|
||||||
|
renderFloorplan();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishDraft() {
|
||||||
|
if (!fpDraft || fpDraft.length < 3) return fpSetStatus("A room needs at least 3 corners.", true);
|
||||||
|
const name = prompt("Room name?");
|
||||||
|
if (!name) return;
|
||||||
|
postJson("/floorplan/rooms", { level_id: fpLevelId, name, points: fpDraft, color: "#6ea8fe" })
|
||||||
|
.then((result) => {
|
||||||
|
if (!result.ok) throw new Error(result.message);
|
||||||
|
cancelDraft();
|
||||||
|
return loadFloorplan().then(() => selectRoom(result.room_id));
|
||||||
|
})
|
||||||
|
.catch((err) => fpSetStatus(err.message, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("fp-finish").addEventListener("click", finishDraft);
|
||||||
|
document.getElementById("fp-cancel").addEventListener("click", cancelDraft);
|
||||||
|
|
||||||
|
// --- room editor -------------------------------------------------------------------
|
||||||
|
document.getElementById("fp-room-save").addEventListener("click", () => {
|
||||||
|
if (!fpSelected) return;
|
||||||
|
postJson("/floorplan/rooms", {
|
||||||
|
id: fpSelected.id,
|
||||||
|
level_id: fpLevelId,
|
||||||
|
name: document.getElementById("fp-room-name").value.trim(),
|
||||||
|
ha_area_id: document.getElementById("fp-room-area").value.trim(),
|
||||||
|
color: document.getElementById("fp-room-color").value,
|
||||||
|
points: fpSelected.points,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
if (!result.ok) throw new Error(result.message);
|
||||||
|
fpSetStatus("Saved.");
|
||||||
|
return loadFloorplan(true).then(loadFloorplanPresence);
|
||||||
|
})
|
||||||
|
.catch((err) => fpSetStatus(err.message, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("fp-room-delete").addEventListener("click", () => {
|
||||||
|
if (!fpSelected || !confirm(`Delete the room “${fpSelected.name}”?`)) return;
|
||||||
|
api(`/floorplan/rooms/${fpSelected.id}`, { method: "DELETE" })
|
||||||
|
.then(() => {
|
||||||
|
clearSelection();
|
||||||
|
return loadFloorplan();
|
||||||
|
})
|
||||||
|
.catch((err) => fpSetStatus(err.message, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- levels ------------------------------------------------------------------------
|
||||||
|
document.getElementById("fp-level").addEventListener("change", (e) => {
|
||||||
|
fpLevelId = Number(e.target.value);
|
||||||
|
clearSelection();
|
||||||
|
renderRoomList();
|
||||||
|
loadFloorplanPresence();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("fp-add-level").addEventListener("click", () => {
|
||||||
|
const name = prompt("Level name? (e.g. Ground floor)");
|
||||||
|
if (!name) return;
|
||||||
|
postJson("/floorplan/levels", { name, sort_order: fpLevels.length })
|
||||||
|
.then((result) => {
|
||||||
|
if (!result.ok) throw new Error(result.message);
|
||||||
|
fpLevelId = result.level_id;
|
||||||
|
return loadFloorplan();
|
||||||
|
})
|
||||||
|
.catch((err) => fpSetStatus(err.message, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("fp-rename-level").addEventListener("click", () => {
|
||||||
|
const level = currentLevel();
|
||||||
|
if (!level) return;
|
||||||
|
const name = prompt("Level name?", level.name);
|
||||||
|
if (!name) return;
|
||||||
|
postJson("/floorplan/levels", { id: level.id, name, sort_order: level.sort_order })
|
||||||
|
.then(() => loadFloorplan(true))
|
||||||
|
.catch((err) => fpSetStatus(err.message, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("fp-delete-level").addEventListener("click", () => {
|
||||||
|
const level = currentLevel();
|
||||||
|
if (!level) return;
|
||||||
|
if (!confirm(`Delete “${level.name}” and its ${level.rooms.length} room(s)?`)) return;
|
||||||
|
api(`/floorplan/levels/${level.id}`, { method: "DELETE" })
|
||||||
|
.then(() => {
|
||||||
|
fpLevelId = null;
|
||||||
|
return loadFloorplan();
|
||||||
|
})
|
||||||
|
.catch((err) => fpSetStatus(err.message, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("fp-image").addEventListener("change", (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file || !fpLevelId) return;
|
||||||
|
fpSetStatus("Uploading background…");
|
||||||
|
// Raw bytes, same shape as the registration-photo endpoint.
|
||||||
|
fetch(`${API}/floorplan/levels/${fpLevelId}/image`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/octet-stream" },
|
||||||
|
body: file,
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((result) => {
|
||||||
|
if (!result.ok) throw new Error(result.message || "Upload failed");
|
||||||
|
fpSetStatus("Background saved.");
|
||||||
|
e.target.value = "";
|
||||||
|
return loadFloorplan(true);
|
||||||
|
})
|
||||||
|
.catch((err) => fpSetStatus(err.message, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("fp-live").addEventListener("change", loadFloorplanPresence);
|
||||||
|
|
||||||
|
loadPeople();
|
||||||
|
|
@ -194,3 +194,426 @@ section.block h2 {
|
||||||
background: rgba(110, 168, 254, 0.14);
|
background: rgba(110, 168, 254, 0.14);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Admin panel (admin.html) ---------------------------------------------------
|
||||||
|
* Everything under `body.admin` only. The rules above are tuned for a wall-mounted
|
||||||
|
* kiosk read from across a room — 64px buttons, 84px tabs, no scrolling; the admin
|
||||||
|
* page is read at arm's length on a phone or laptop by someone who came to do
|
||||||
|
* administration, so it opts into a denser scale rather than fighting those defaults.
|
||||||
|
* Sharing the file (rather than a second stylesheet) keeps the colour palette,
|
||||||
|
* card/avatar shapes and status colours identical across all three pages, which is
|
||||||
|
* the part that should NOT diverge. */
|
||||||
|
|
||||||
|
body.admin {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.admin #tabs {
|
||||||
|
height: 60px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.admin .tab {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.admin main {
|
||||||
|
height: auto;
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.admin .card {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.as-button {
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.as-button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Long entity_ids and free-text notes must not stretch a card past the viewport —
|
||||||
|
* they wrap instead, since truncating them would hide the exact thing (which lock?
|
||||||
|
* which entity?) the row exists to show. */
|
||||||
|
.card-body .card-meta,
|
||||||
|
.card-body .card-name {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.compact {
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-home {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #7cf0a0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #c8c8d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.grow,
|
||||||
|
input.grow {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select,
|
||||||
|
.row input,
|
||||||
|
.row select {
|
||||||
|
min-height: 40px;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
background: #1a1a20;
|
||||||
|
color: #e8e8ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group legend {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #9a9aa6;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check input {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A deliberately smaller button than .big-btn: this page has destructive actions on
|
||||||
|
* it, and they should not be the easiest thing on screen to hit by accident. */
|
||||||
|
.btn {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
background: rgba(255, 255, 255, 0.10);
|
||||||
|
color: #e8e8ec;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: #6ea8fe;
|
||||||
|
border-color: #6ea8fe;
|
||||||
|
color: #101014;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger {
|
||||||
|
color: #ff8080;
|
||||||
|
border-color: rgba(255, 128, 128, 0.4);
|
||||||
|
background: rgba(255, 128, 128, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger:hover {
|
||||||
|
background: rgba(255, 128, 128, 0.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
color: #9a9aa6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.small {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.expired {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.allowed {
|
||||||
|
border-left: 3px solid #7cf0a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.denied {
|
||||||
|
border-left: 3px solid #ff8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #9a9aa6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.allowed .verdict {
|
||||||
|
color: #7cf0a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.denied .verdict {
|
||||||
|
color: #ff8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Floorplan editor --- */
|
||||||
|
|
||||||
|
.fp-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-canvas-wrap {
|
||||||
|
flex: 1 1 480px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-side {
|
||||||
|
flex: 0 1 280px;
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-side h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: #9a9aa6;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fp-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1000 / 700;
|
||||||
|
background: #14141a;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
/* Without this a click-drag on the plan selects the surrounding page text, which
|
||||||
|
* makes drawing a room feel broken. */
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fp-bg {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-room {
|
||||||
|
fill-opacity: 0.22;
|
||||||
|
stroke: rgba(255, 255, 255, 0.5);
|
||||||
|
stroke-width: 2;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: fill-opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-room:hover {
|
||||||
|
fill-opacity: 0.38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-room.selected {
|
||||||
|
stroke: #6ea8fe;
|
||||||
|
stroke-width: 3;
|
||||||
|
fill-opacity: 0.42;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Occupied rooms are the whole point of the live view, so they read at a glance from
|
||||||
|
* across a room rather than needing to be hunted for. */
|
||||||
|
.fp-room.occupied {
|
||||||
|
fill-opacity: 0.55;
|
||||||
|
stroke: #7cf0a0;
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-label {
|
||||||
|
fill: #e8e8ec;
|
||||||
|
font-size: 18px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-occupants {
|
||||||
|
fill: #7cf0a0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-warn {
|
||||||
|
fill: #ffc850;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-handle {
|
||||||
|
fill: #6ea8fe;
|
||||||
|
stroke: #101014;
|
||||||
|
stroke-width: 2;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-draft-line {
|
||||||
|
fill: rgba(110, 168, 254, 0.14);
|
||||||
|
stroke: #6ea8fe;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-draft-point {
|
||||||
|
fill: #6ea8fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-swatch {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Person editor dialog --- */
|
||||||
|
#editor {
|
||||||
|
width: min(680px, 94vw);
|
||||||
|
max-height: 92vh;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #17171d;
|
||||||
|
color: #e8e8ec;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor::backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.62);
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 92vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-head h3 {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-footer {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
padding-top: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,11 +55,67 @@ MQTT_PASSWORD=
|
||||||
FRIGATE_EVENTS_TOPIC=frigate/events
|
FRIGATE_EVENTS_TOPIC=frigate/events
|
||||||
FACE_PRESENCE_WINDOW_SECONDS=600
|
FACE_PRESENCE_WINDOW_SECONDS=600
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Visit history — "who was home when, and with whom". identity samples its own
|
||||||
|
# /presence on a timer and writes arrival/departure rows; nothing pushes events at
|
||||||
|
# it. See server.py's "VISITS ARE SAMPLED, NOT REPORTED".
|
||||||
|
#
|
||||||
|
# PRESENCE_POLL_SECONDS how often to sample. Each sample is a full HA /api/states
|
||||||
|
# fetch, so this is deliberately coarse — it's a household
|
||||||
|
# log, not a security audit trail.
|
||||||
|
# DEPARTURE_GRACE_SECONDS how long someone must read as "not home" before their
|
||||||
|
# visit is closed. BLE presence flaps (a phone in a pocket
|
||||||
|
# in the far room drops out and comes back); without this,
|
||||||
|
# one evening at home becomes forty "visits". The recorded
|
||||||
|
# departure time is the last moment they were actually seen,
|
||||||
|
# not when this window expired.
|
||||||
|
# VISIT_MAX_OPEN_HOURS safety net for a visit that never gets a definite "not
|
||||||
|
# home" to close it (someone device-less marked home by hand
|
||||||
|
# and never marked away). Closed as 'timed_out', never as
|
||||||
|
# 'departed' — the log never claims to have observed a
|
||||||
|
# departure it didn't.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
PRESENCE_POLL_SECONDS=60
|
||||||
|
DEPARTURE_GRACE_SECONDS=900
|
||||||
|
VISIT_MAX_OPEN_HOURS=72
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Arrival push notifications — "tell me when someone gets home." Fires on the same
|
||||||
|
# arrival transition the visit log above is built from (a trusted identifier coming
|
||||||
|
# into range and HA registering it), so there's exactly one definition of "arrived."
|
||||||
|
#
|
||||||
|
# Per-person opt-in: tick "Push me when someone gets home" in the admin panel. A
|
||||||
|
# person can also untick "announce my arrivals" to keep their own comings and goings
|
||||||
|
# out of everyone else's notifications.
|
||||||
|
#
|
||||||
|
# NTFY_URL — the same self-hosted ntfy this stack already runs for `chores`
|
||||||
|
# (setup-container-host.sh's ENABLE_NTFY). This is a call from one container to
|
||||||
|
# another; identity itself never talks to the WAN.
|
||||||
|
#
|
||||||
|
# Unlike HA_URL above, a container NAME works here: ntfy is an ordinary service on
|
||||||
|
# the compose bridge network, so Docker's own DNS resolves "ntfy" — and the port is
|
||||||
|
# its INTERNAL 80, not the 8090 published to the LAN. (homeassistant is the odd one
|
||||||
|
# out because it runs network_mode: host, which is why HA_URL needs a real IP.)
|
||||||
|
# NTFY_DEFAULT_TOPIC — fallback for anyone without their own topic set in the admin
|
||||||
|
# panel. Leave both blank to turn the whole feature off; it then silently no-ops.
|
||||||
|
#
|
||||||
|
# WHETHER YOUR PHONE ACTUALLY RINGS WHILE YOU'RE OUT IS A NETWORK QUESTION, NOT AN
|
||||||
|
# identity ONE — and "did they get home while I was out?" is most of the point of
|
||||||
|
# this feature. docs/network-integration.md's answer applies unchanged: WireGuard
|
||||||
|
# back into your own network, never a port-forward. See identity/README.md's
|
||||||
|
# "Getting the push while you're actually away".
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
NTFY_URL=http://ntfy
|
||||||
|
NTFY_DEFAULT_TOPIC=household
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Run behaviour
|
# Run behaviour
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
IDENTITY_PORT=8097
|
IDENTITY_PORT=8097
|
||||||
IDENTITY_DB_PATH=/data/identity.db
|
IDENTITY_DB_PATH=/data/identity.db
|
||||||
IDENTITY_PHOTO_DIR=/data/photos
|
IDENTITY_PHOTO_DIR=/data/photos
|
||||||
|
# Background images for the admin panel's floorplan editor. Same volume as the
|
||||||
|
# registration photos — cover it with whatever backs up /data.
|
||||||
|
IDENTITY_FLOORPLAN_DIR=/data/floorplans
|
||||||
IDENTITY_MAX_IMAGE_MB=15
|
IDENTITY_MAX_IMAGE_MB=15
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
|
||||||
1628
identity/server.py
1628
identity/server.py
File diff suppressed because it is too large
Load Diff
|
|
@ -35,7 +35,7 @@ service directly (there is no HA-mediation step between "hold item up to camera"
|
||||||
requires the bearer token, as the actual boundary instead of network placement.
|
requires the bearer token, as the actual boundary instead of network placement.
|
||||||
|
|
||||||
The same token has to be baked into the kitchen display's own build config
|
The same token has to be baked into the kitchen display's own build config
|
||||||
(`hosts/kitchen-display/scripts/build-kitchen-display-iso.sh`), not just Home
|
(`tools/build-kitchen-display-iso.sh`), not just Home
|
||||||
Assistant's — see that host's README.
|
Assistant's — see that host's README.
|
||||||
|
|
||||||
## `/identify` never writes anything by itself
|
## `/identify` never writes anything by itself
|
||||||
|
|
@ -106,7 +106,7 @@ above, and adjust `server.py` if the shapes differ.
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
Wired into `hosts/container-host/scripts/setup-container-host.sh` behind
|
Wired into `tools/setup-container-host.sh` behind
|
||||||
`ENABLE_PANTRY_VISION` (off by default) — see that script's `# CONFIGURATION` block
|
`ENABLE_PANTRY_VISION` (off by default) — see that script's `# CONFIGURATION` block
|
||||||
and its own README. It builds two containers: `pantry-vision` (this API) and
|
and its own README. It builds two containers: `pantry-vision` (this API) and
|
||||||
`pantry-web` (nginx, serves `frontend/` read-only).
|
`pantry-web` (nginx, serves `frontend/` read-only).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
# tools — the build system
|
||||||
|
|
||||||
|
Every build and setup script for this project, driven by **one** config file at the
|
||||||
|
repo root.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp CoreSystemConfig.json.template CoreSystemConfig.json
|
||||||
|
$EDITOR CoreSystemConfig.json
|
||||||
|
tools/validate-config.py # check it before you commit to a long build
|
||||||
|
sudo -E tools/build-all.sh # build everything
|
||||||
|
```
|
||||||
|
|
||||||
|
That's the whole workflow.
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
Before it, the same facts lived in six places. The container host's IP was written into
|
||||||
|
four ISO builders; `IDENTITY_TOKEN` had to match by hand across three; every service URL
|
||||||
|
was a hand-typed string with a port in it. Any one of them could be wrong, and the
|
||||||
|
symptom was always the same and always late: an image that boots fine and then can't
|
||||||
|
reach something, discovered after a 40-minute build and a reboot.
|
||||||
|
|
||||||
|
Two changes fix that class of bug:
|
||||||
|
|
||||||
|
1. **Nothing is written twice.** No script in this directory contains an IP address, a
|
||||||
|
port or a token. They read `CoreSystemConfig.json`.
|
||||||
|
2. **Anything derivable is derived.** You give the subnet prefix once and one last
|
||||||
|
octet per host. Every address and every service URL is computed from those.
|
||||||
|
|
||||||
|
## The twinned pair
|
||||||
|
|
||||||
|
The container host and the LLM host are built as a matched set, and the twinning is
|
||||||
|
mechanical rather than a matter of remembering:
|
||||||
|
|
||||||
|
```
|
||||||
|
network.subnet_prefix 192.168.30
|
||||||
|
container_host.ip_last_octet 12 -> 192.168.30.12
|
||||||
|
llm_host.ip_last_octet 13 -> 192.168.30.13
|
||||||
|
|
||||||
|
...so the container host's OLLAMA_HOST is http://192.168.30.13:11434
|
||||||
|
because it was COMPUTED from the LLM host's octet in the same build,
|
||||||
|
not because someone typed the same address into two files.
|
||||||
|
```
|
||||||
|
|
||||||
|
Move the LLM host to `.21` and rebuild: the container host's Ollama URL follows on its
|
||||||
|
own. Change the subnet prefix and *both* halves move, along with every kiosk's service
|
||||||
|
URLs. **Neither image can be built pointing at an address the other one isn't using.**
|
||||||
|
|
||||||
|
Both halves are stamped with the same `SMARTHOME_PAIR_ID` in `/etc/smarthome-build`, so
|
||||||
|
two USB sticks found in a drawer months later can be checked against each other. The ID
|
||||||
|
is a hash of the config's *meaning*, not its bytes — reformatting the JSON doesn't
|
||||||
|
change it, moving a host does.
|
||||||
|
|
||||||
|
## The validator
|
||||||
|
|
||||||
|
`validate-config.py` runs before every build and refuses to start on an error, so a
|
||||||
|
mistake costs seconds instead of an hour. It's stdlib-only on purpose: it has to be
|
||||||
|
able to run on a fresh checkout before anything is installed.
|
||||||
|
|
||||||
|
What it catches, beyond missing and malformed values:
|
||||||
|
|
||||||
|
| Check | Why it matters |
|
||||||
|
|---|---|
|
||||||
|
| **Duplicate ports** | Two services on one port means a container that silently fails to bind, or whichever won last boot answering. Miserable to diagnose from the symptom — and it's how `music_assistant` (default 8095) collides with `pantry_vision`, which Compose's own check never catches because Music Assistant runs `network_mode: host` |
|
||||||
|
| Both core hosts on one address | The twinning's single assumption |
|
||||||
|
| A host colliding with the gateway | — |
|
||||||
|
| Duplicate kiosk hostnames | They identify devices on the network and in HA |
|
||||||
|
| Placeholder or low-entropy tokens | Caught *before* the length check, so padding `changeme` out to 32 characters doesn't sneak past |
|
||||||
|
| A private key pasted where the public key goes | — |
|
||||||
|
| A kiosk that needs a disabled service | A door panel built against `enable.identity: false` builds fine and fails at runtime |
|
||||||
|
| Wi-Fi SSID without a PSK, MQTT user without a password | — |
|
||||||
|
| `192.168.0.x` / `192.168.1.x` subnets (warning) | Collides with typical café and hotel LANs, which breaks a WireGuard split tunnel routing that range — see `docs/network-integration.md` §2.1 |
|
||||||
|
|
||||||
|
Warnings print but don't block. Errors block and nothing is written.
|
||||||
|
|
||||||
|
## What's here
|
||||||
|
|
||||||
|
| Script | Builds |
|
||||||
|
|---|---|
|
||||||
|
| **`build-all.sh`** | **Everything. The normal entry point** — `--core`, `--kiosks`, `--dry-run` |
|
||||||
|
| `build-core-pair.sh` | The twinned container host + LLM host |
|
||||||
|
| `build-container-host-iso.sh` | Just the container host |
|
||||||
|
| `build-llm-host-iso.sh` | Just the LLM host |
|
||||||
|
| `build-door-panel-iso.sh` etc. | One kiosk; takes a hostname when several of a type are configured |
|
||||||
|
| `build-audio-endpoint-iso-amd64.sh` | An amd64 audio endpoint (mini PC, live-build → `.iso`) |
|
||||||
|
| `build-audio-endpoint-image-arm64.sh` | An arm64 audio endpoint (Pi + HiFiBerry, rpi-image-gen → `.img`) |
|
||||||
|
| `setup-container-host.sh` | The container-host setup itself, run by its ISO's first-boot unit (or by hand) |
|
||||||
|
| `setup-llm-host.sh` | Same, for the LLM host |
|
||||||
|
| `validate-config.py` | Check the config |
|
||||||
|
| `config-export.py` | Config → shell variables, deriving URLs. Where the twinning happens |
|
||||||
|
| `lib/coreconfig.sh` | The loader every builder sources |
|
||||||
|
|
||||||
|
Building one image is supported but unusual: the images are a set that has to agree
|
||||||
|
with itself, which is why `build-all.sh` is the default and a failure in one image
|
||||||
|
doesn't abandon the rest.
|
||||||
|
|
||||||
|
### One entry, one image
|
||||||
|
|
||||||
|
Kiosks and audio endpoints come from **arrays** in the config, and every entry gets its
|
||||||
|
own artifact in `iso-out/`, named `smarthome-<hostname>-<pairid>.iso`. That naming
|
||||||
|
is load-bearing rather than cosmetic: live-build always writes the same filename into
|
||||||
|
the same per-host tree, so two thin clients would otherwise have the second silently
|
||||||
|
overwrite the first — leaving one ISO carrying the second room's hostname and nothing
|
||||||
|
to indicate the first was lost.
|
||||||
|
|
||||||
|
Per-type builders take a hostname when several of that type exist, and refuse with the
|
||||||
|
list of real ones if you omit it or get it wrong.
|
||||||
|
|
||||||
|
This holds for **both** audio-endpoint architectures: the arm64 image used to be
|
||||||
|
generic (named per unit in Raspberry Pi Imager at flash time) and is now pre-named
|
||||||
|
like everything else, because it was the last identity in the household typed in after
|
||||||
|
a build — and one that failed *silently* when mistyped, since `spotify-connect-start`
|
||||||
|
reads `$(hostname)` and a typo just yields a Spotify device under the wrong room name.
|
||||||
|
|
||||||
|
The cost is one full `rpi-image-gen` run per arm64 room, qemu-emulated on an x86
|
||||||
|
builder. Set `"arm64_prebake": false` in the config's `build` section to go back to a
|
||||||
|
single generic `.img`, built once regardless of how many arm64 rooms are listed. Wi-Fi
|
||||||
|
and SSH keys are still set in Imager for arm64 either way — only the hostname moved.
|
||||||
|
|
||||||
|
## The ISOs contain secrets
|
||||||
|
|
||||||
|
This is deliberate — burning everything in is what makes installation unattended, with
|
||||||
|
no env files to edit on a freshly-booted host. It also means **every ISO is a
|
||||||
|
credential**: Wi-Fi PSK, service tokens, MQTT and HA credentials, all readable by
|
||||||
|
anyone holding the stick.
|
||||||
|
|
||||||
|
`.gitignore` covers `CoreSystemConfig.json` and `iso-out/`, so neither can be
|
||||||
|
committed by accident. Wiping old USB sticks is on you.
|
||||||
|
|
||||||
|
## Two things can't be burnt in
|
||||||
|
|
||||||
|
Neither exists at build time, so both need a human afterwards:
|
||||||
|
|
||||||
|
1. **`HA_TOKEN`** — a Long-Lived Access Token from Home Assistant's own UI, which
|
||||||
|
doesn't exist until HA has been started and an account created. Put it in the config
|
||||||
|
and rebuild, or edit `identity.env` on the container host.
|
||||||
|
2. **`TRUSTED_ENTITY_PREFIXES`** — the real entity_id prefixes your Private BLE Device
|
||||||
|
setup produces (Developer Tools → States). The shipped default is a guess and it's
|
||||||
|
the highest-risk unknown in Phase 6.
|
||||||
|
|
||||||
|
## Adding a service or a kiosk
|
||||||
|
|
||||||
|
- **A new port**: add it to `ports` and reference it in `config-export.py`'s derived
|
||||||
|
URLs. The duplicate check covers it from then on.
|
||||||
|
- **Another audio endpoint**: add an entry to `audio_endpoints` with its `arch`.
|
||||||
|
`build-all.sh` builds every entry, so listing both an `amd64` and an `arm64` one
|
||||||
|
gets you both — they're separate toolchains producing different artifacts, not one
|
||||||
|
image that runs on both.
|
||||||
|
- **Another kiosk of an existing type**: add an entry to `kiosks` with its own
|
||||||
|
hostname. `build-all.sh` picks it up; the per-type builder takes the hostname as an
|
||||||
|
argument.
|
||||||
|
- **A new kiosk type**: add it to `KIOSK_TYPES` in `validate-config.py` and add a
|
||||||
|
`build-<type>-iso.sh`. `build-all.sh` finds it by naming convention.
|
||||||
|
|
||||||
|
## Manual verification still outstanding
|
||||||
|
|
||||||
|
1. **No ISO has ever been built with this.** `lb build` needs live-build, root, and a
|
||||||
|
long network fetch; none of that has been run. What *has* been tested is everything
|
||||||
|
up to that point: config validation and URL derivation (43 checks), and the builders'
|
||||||
|
generated artifacts — env files, preseed, network config, `/etc/hosts`, first-boot
|
||||||
|
units, build stamps — produced by the real code paths with only `lb` stubbed (44
|
||||||
|
checks). The `lb config`/`lb build` invocations themselves are unverified.
|
||||||
|
2. **The preseed files are written from Debian's documented shape, not tested.** An
|
||||||
|
unattended install that gets a preseed key wrong typically stops at an interactive
|
||||||
|
prompt rather than failing loudly, so budget for a monitor on the first install.
|
||||||
|
3. **`partman-auto/disk` erases the configured disk without confirmation.** That is
|
||||||
|
what unattended means, and it's why `install_disk` is worth double-checking against
|
||||||
|
the actual machine you boot it on.
|
||||||
|
4. **Static addressing assumes `eth0`.** Debian's predictable interface naming may well
|
||||||
|
call it `enp3s0` on your hardware, in which case
|
||||||
|
`/etc/network/interfaces.d/smarthome` needs the real name.
|
||||||
|
5. **The kiosk builders' migration is untested end-to-end.** Their config blocks now
|
||||||
|
read from `CoreSystemConfig.json`, but the body of each script is unchanged from when
|
||||||
|
it worked with hand-edited constants — so the risk is confined to the mapping, not
|
||||||
|
to image contents.
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Build EVERY image this household needs, from one CoreSystemConfig.json.
|
||||||
|
#
|
||||||
|
# This is the normal way to use tools/. The individual builders still exist and still
|
||||||
|
# work, but rebuilding one image on its own is the unusual case: the images are a set
|
||||||
|
# that has to agree with itself, and the whole reason a kiosk knows the container
|
||||||
|
# host's address is that both were generated in the same pass from the same file.
|
||||||
|
# Building the set is therefore the default, and building one is the exception.
|
||||||
|
#
|
||||||
|
# sudo -E tools/build-all.sh # the core pair + every configured kiosk
|
||||||
|
# sudo -E tools/build-all.sh --core # just the twinned pair
|
||||||
|
# sudo -E tools/build-all.sh --kiosks # just the kiosks
|
||||||
|
# sudo -E tools/build-all.sh --dry-run # validate + list what would be built
|
||||||
|
#
|
||||||
|
# Every image is stamped with the same pair ID, so a drawer full of USB sticks can be
|
||||||
|
# checked against each other later: same ID means they were built from the same
|
||||||
|
# config and agree on every address and token.
|
||||||
|
#
|
||||||
|
# WHAT COMES OUT CONTAINS SECRETS. Wi-Fi PSK, service tokens, MQTT and HA credentials
|
||||||
|
# are burnt into these images — that is the point (nothing to configure post-install),
|
||||||
|
# and it makes every ISO a credential. .gitignore keeps them out of the repo; wiping
|
||||||
|
# old USB sticks is on you.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=lib/coreconfig.sh
|
||||||
|
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||||
|
|
||||||
|
MODE="all"
|
||||||
|
DRY_RUN="false"
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--core) MODE="core" ;;
|
||||||
|
--kiosks) MODE="kiosks" ;;
|
||||||
|
--dry-run) DRY_RUN="true" ;;
|
||||||
|
-h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
|
*) core_die "Unknown argument '$arg'. Try --help." ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
core_load
|
||||||
|
[[ "$DRY_RUN" == "true" ]] || core_require_root "$@"
|
||||||
|
|
||||||
|
PAIR_ID="$(core_pair_id)"
|
||||||
|
|
||||||
|
# Which kiosks are configured, and which builder each needs.
|
||||||
|
mapfile -t KIOSK_ROWS < <(python3 - "$CORE_CONFIG_PATH" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
for k in json.load(open(sys.argv[1])).get("kiosks", []):
|
||||||
|
print(f"{k['type']}\t{k['hostname']}\t{k.get('friendly_name','')}")
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audio endpoints, with the architecture each one needs. Both architectures are built
|
||||||
|
# when both are configured — they're two different toolchains producing two different
|
||||||
|
# artifacts (an amd64 ISO and an arm64 .img), not one image that runs on both.
|
||||||
|
mapfile -t AUDIO_ROWS < <(python3 - "$CORE_CONFIG_PATH" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
for a in json.load(open(sys.argv[1])).get("audio_endpoints", []):
|
||||||
|
print(f"{a['arch']}\t{a['hostname']}\t{a.get('friendly_name','')}")
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
============================================================================
|
||||||
|
SmartestHome — build all
|
||||||
|
============================================================================
|
||||||
|
Config : ${CORE_CONFIG_PATH}
|
||||||
|
Pair ID : ${PAIR_ID}
|
||||||
|
Subnet : ${CORE_SUBNET_PREFIX}.0/24
|
||||||
|
|
||||||
|
Core pair (twinned — each knows the other's address by derivation):
|
||||||
|
container host ${CORE_CONTAINER_HOST_IP} ${CORE_CONTAINER_HOST_NAME}
|
||||||
|
LLM host ${CORE_LLM_HOST_IP} ${CORE_LLM_HOST_NAME}
|
||||||
|
the link ${CORE_OLLAMA_HOST}
|
||||||
|
|
||||||
|
Kiosks + audio endpoints (all pointed at ${CORE_CONTAINER_HOST_IP} by derivation):
|
||||||
|
EOF
|
||||||
|
if [[ ${#KIOSK_ROWS[@]} -eq 0 && ${#AUDIO_ROWS[@]} -eq 0 ]]; then
|
||||||
|
echo " (none configured)"
|
||||||
|
fi
|
||||||
|
for row in "${KIOSK_ROWS[@]:-}"; do
|
||||||
|
[[ -n "$row" ]] || continue
|
||||||
|
IFS=$'\t' read -r ktype khost kname <<< "$row"
|
||||||
|
printf ' %-18s %-24s %s\n' "$ktype" "$khost" "$kname"
|
||||||
|
done
|
||||||
|
for row in "${AUDIO_ROWS[@]:-}"; do
|
||||||
|
[[ -n "$row" ]] || continue
|
||||||
|
IFS=$'\t' read -r aarch ahost aname <<< "$row"
|
||||||
|
printf ' %-18s %-24s %s\n' "audio/${aarch}" "$ahost" "$aname"
|
||||||
|
done
|
||||||
|
echo "
|
||||||
|
Building : ${MODE}$([[ "$DRY_RUN" == "true" ]] && echo " (DRY RUN — nothing will be built)")
|
||||||
|
============================================================================"
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" == "true" ]]; then
|
||||||
|
echo "
|
||||||
|
Config is valid and the above is what would be built. Re-run without --dry-run.
|
||||||
|
"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUILT=()
|
||||||
|
FAILED=()
|
||||||
|
|
||||||
|
run_build() {
|
||||||
|
local label="$1"; shift
|
||||||
|
core_log "Building ${label}"
|
||||||
|
# One image failing must not abandon the rest: an ISO build is long, and losing an
|
||||||
|
# hour of successful builds because the last one hit a mirror timeout would be a
|
||||||
|
# poor trade. Failures are collected and reported together at the end.
|
||||||
|
if "$@"; then
|
||||||
|
BUILT+=("$label")
|
||||||
|
else
|
||||||
|
core_warn "${label} FAILED — continuing with the rest"
|
||||||
|
FAILED+=("$label")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$MODE" == "all" || "$MODE" == "core" ]]; then
|
||||||
|
run_build "container host" "${SCRIPT_DIR}/build-container-host-iso.sh"
|
||||||
|
run_build "LLM host" "${SCRIPT_DIR}/build-llm-host-iso.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$MODE" == "all" || "$MODE" == "kiosks" ]]; then
|
||||||
|
for row in "${KIOSK_ROWS[@]}"; do
|
||||||
|
IFS=$'\t' read -r ktype khost _ <<< "$row"
|
||||||
|
builder="${SCRIPT_DIR}/build-${ktype}-iso.sh"
|
||||||
|
if [[ ! -x "$builder" ]]; then
|
||||||
|
core_warn "No builder for kiosk type '${ktype}' (${builder}) — skipping ${khost}"
|
||||||
|
FAILED+=("${ktype}/${khost} (no builder)")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
run_build "${ktype} — ${khost}" "$builder" "$khost"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Both architectures now produce ONE PRE-NAMED IMAGE PER ROOM, so every device in
|
||||||
|
# the household is identified by the build rather than by something typed in
|
||||||
|
# afterwards. arm64 used to be the exception (one generic .img, hostname set in
|
||||||
|
# Raspberry Pi Imager at flash time); baking it in costs a full rpi-image-gen run per
|
||||||
|
# room, which is the tradeoff `build.arm64_prebake: false` exists to undo.
|
||||||
|
arm64_generic_done="false"
|
||||||
|
for row in "${AUDIO_ROWS[@]:-}"; do
|
||||||
|
[[ -n "$row" ]] || continue
|
||||||
|
IFS=$'\t' read -r aarch ahost _ <<< "$row"
|
||||||
|
case "$aarch" in
|
||||||
|
amd64)
|
||||||
|
run_build "audio endpoint (amd64) — ${ahost}" \
|
||||||
|
"${SCRIPT_DIR}/build-audio-endpoint-iso-amd64.sh" "$ahost"
|
||||||
|
;;
|
||||||
|
arm64)
|
||||||
|
if [[ "$CORE_ARM64_PREBAKE" != "true" ]]; then
|
||||||
|
# Generic mode: one image for every arm64 room, named in Pi Imager.
|
||||||
|
if [[ "$arm64_generic_done" == "true" ]]; then
|
||||||
|
core_log "arm64 generic image already built — ${ahost} flashes the same .img (name it in Pi Imager)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
run_build "audio endpoint (arm64, generic image)" \
|
||||||
|
"${SCRIPT_DIR}/build-audio-endpoint-image-arm64.sh" "$ahost"
|
||||||
|
arm64_generic_done="true"
|
||||||
|
else
|
||||||
|
run_build "audio endpoint (arm64) — ${ahost}" \
|
||||||
|
"${SCRIPT_DIR}/build-audio-endpoint-image-arm64.sh" "$ahost"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
core_warn "Unknown audio endpoint arch '${aarch}' — skipping ${ahost}"
|
||||||
|
FAILED+=("audio/${ahost} (bad arch)")
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUTPUT_DIR="${CORE_REPO_ROOT}/${CORE_BUILD_OUTPUT_DIR}"
|
||||||
|
|
||||||
|
echo "
|
||||||
|
============================================================================
|
||||||
|
Build all — done (pair ${PAIR_ID})
|
||||||
|
============================================================================"
|
||||||
|
if [[ ${#BUILT[@]} -gt 0 ]]; then
|
||||||
|
echo " Built:"
|
||||||
|
for b in "${BUILT[@]}"; do echo " ✓ $b"; done
|
||||||
|
fi
|
||||||
|
if [[ ${#FAILED[@]} -gt 0 ]]; then
|
||||||
|
echo " FAILED:"
|
||||||
|
for f in "${FAILED[@]}"; do echo " ✗ $f"; done
|
||||||
|
fi
|
||||||
|
echo "
|
||||||
|
Images in: ${OUTPUT_DIR}
|
||||||
|
"
|
||||||
|
ls -lh "$OUTPUT_DIR" 2>/dev/null | tail -n +2 | awk '{printf " %-52s %s\n", $9, $5}' || true
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Install the container host FIRST — the kiosks and the LLM host are all
|
||||||
|
clients of it, and every one of them was built expecting it at
|
||||||
|
${CORE_CONTAINER_HOST_IP}.
|
||||||
|
|
||||||
|
Two things still need a human, because neither exists at build time:
|
||||||
|
1. HA_TOKEN — a Long-Lived Access Token from Home Assistant's own UI, which
|
||||||
|
can't be created until HA is running. Put it in CoreSystemConfig.json and
|
||||||
|
rebuild, or edit identity.env on the container host.
|
||||||
|
2. TRUSTED_ENTITY_PREFIXES — the real entity_id prefixes your Private BLE
|
||||||
|
Device setup produces. The default is a guess and it is the highest-risk
|
||||||
|
unknown in Phase 6.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
[[ ${#FAILED[@]} -eq 0 ]]
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Builds the arm64 headless audio-endpoint image (Raspberry Pi + HiFiBerry
|
||||||
|
# Amp2) with rpi-image-gen. Read hosts/audio-endpoint/README.md before running
|
||||||
|
# this — in particular the callout that rpi-image-gen's exact config/layer
|
||||||
|
# schema (config/audio-endpoint.yaml) was not hands-on validated while writing
|
||||||
|
# this script; a real build may need that file adjusted first.
|
||||||
|
#
|
||||||
|
# Output: a real bootable .img. Each physical unit then gets its own
|
||||||
|
# hostname/Wi-Fi via Raspberry Pi Imager's OS Customisation dialog when
|
||||||
|
# flashing — see the README's "Per-room identity" section. This script builds
|
||||||
|
# ONE generic image, not one per room.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CONFIGURATION — edit if needed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
RPI_IMAGE_GEN_SRC="${RPI_IMAGE_GEN_SRC:-/opt/smart-home/src/rpi-image-gen}"
|
||||||
|
RPI_IMAGE_GEN_REPO="https://github.com/raspberrypi/rpi-image-gen.git"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=lib/coreconfig.sh
|
||||||
|
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||||
|
|
||||||
|
core_select_audio_endpoint arm64 "${1:-}"
|
||||||
|
|
||||||
|
# This script now lives in tools/, so the host directory it drives is addressed from
|
||||||
|
# the repo root rather than relative to the script.
|
||||||
|
AUDIO_ENDPOINT_DIR="${CORE_REPO_ROOT}/hosts/audio-endpoint"
|
||||||
|
RPI_IMAGE_GEN_DIR="${AUDIO_ENDPOINT_DIR}/rpi-image-gen"
|
||||||
|
SHARED_CONFIGS_DIR="$AUDIO_ENDPOINT_DIR/configs"
|
||||||
|
BUILD_CONFIG_TEMPLATE="$RPI_IMAGE_GEN_DIR/config/audio-endpoint.yaml"
|
||||||
|
# Per-endpoint build config, generated below. Kept out of the tracked config/ dir so a
|
||||||
|
# build never leaves a room-specific file lying where the template belongs.
|
||||||
|
if [[ "${CORE_ARM64_PREBAKE:-true}" == "true" ]]; then
|
||||||
|
BUILD_CONFIG="$RPI_IMAGE_GEN_DIR/config/generated-${CORE_AUDIO_HOSTNAME}.yaml"
|
||||||
|
IMAGE_INSTANCE="$CORE_AUDIO_HOSTNAME"
|
||||||
|
else
|
||||||
|
# Generic mode: build the template unchanged, name each unit in Pi Imager.
|
||||||
|
BUILD_CONFIG="$BUILD_CONFIG_TEMPLATE"
|
||||||
|
IMAGE_INSTANCE="generic"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sanity checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
for f in spotify-connect.service spotify-connect-start; do
|
||||||
|
if [ ! -f "$SHARED_CONFIGS_DIR/$f" ]; then
|
||||||
|
echo "Missing $SHARED_CONFIGS_DIR/$f — is this script running from a full checkout?" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ! -f "$BUILD_CONFIG_TEMPLATE" ]; then
|
||||||
|
echo "Missing $BUILD_CONFIG_TEMPLATE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
|
echo "git is required to fetch rpi-image-gen. Install it first: sudo apt-get install git" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 0. Generate this room's build config — PRE-NAMED, not generic.
|
||||||
|
#
|
||||||
|
# The arm64 image used to be built once and handed to Raspberry Pi Imager, whose OS
|
||||||
|
# Customisation dialog set hostname/Wi-Fi per physical unit at flash time. That worked,
|
||||||
|
# but it made this the ONE image in the household where identity is typed in by hand
|
||||||
|
# after the build — exactly the step CoreSystemConfig.json exists to remove. It also
|
||||||
|
# failed quietly when mistyped: spotify-connect-start reads $(hostname) at service
|
||||||
|
# start, so a typo doesn't error, it just puts a Spotify Connect device with the wrong
|
||||||
|
# room name on the network.
|
||||||
|
#
|
||||||
|
# So the hostname is baked in here instead, per endpoint, same as every other image.
|
||||||
|
# The cost is honest and worth stating: one full rpi-image-gen run PER ROOM rather than
|
||||||
|
# one for all of them, and on an x86 build host that means qemu-emulated arm64
|
||||||
|
# bootstrapping each time. Set "arm64_prebake": false in the config's build section to
|
||||||
|
# go back to a single generic image and do it in Imager.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [[ "${CORE_ARM64_PREBAKE:-true}" == "true" ]]; then
|
||||||
|
core_log "Generating build config for ${CORE_AUDIO_HOSTNAME}"
|
||||||
|
python3 - "$BUILD_CONFIG_TEMPLATE" "$BUILD_CONFIG" "$CORE_AUDIO_HOSTNAME" <<'PYEOF'
|
||||||
|
import re, sys
|
||||||
|
template, dest, hostname = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
text = open(template).read()
|
||||||
|
|
||||||
|
# Only the hostname value is rewritten. Deliberately a targeted substitution rather
|
||||||
|
# than a YAML load/dump: this file is full of explanatory comments about an unverified
|
||||||
|
# schema, and round-tripping it through a YAML library would silently strip every one
|
||||||
|
# of them. It also avoids a PyYAML dependency in the build path.
|
||||||
|
new, n = re.subn(r"(?m)^(hostname:\n(?:\s+#.*\n)*\s+set:\s*).*$",
|
||||||
|
lambda m: m.group(1) + hostname, text)
|
||||||
|
if n != 1:
|
||||||
|
sys.exit(f"error: expected exactly one 'hostname:/set:' block in {template}, found {n}. "
|
||||||
|
"The template's shape changed — fix this substitution rather than shipping "
|
||||||
|
"an image named after the wrong room.")
|
||||||
|
|
||||||
|
header = (f"# GENERATED by tools/build-audio-endpoint-image-arm64.sh for '{hostname}'.\n"
|
||||||
|
f"# Do not edit — edit config/audio-endpoint.yaml (the template) or\n"
|
||||||
|
f"# CoreSystemConfig.json, and rebuild.\n")
|
||||||
|
open(dest, "w").write(header + new)
|
||||||
|
PYEOF
|
||||||
|
else
|
||||||
|
core_log "arm64_prebake is false — building the generic image (name each unit in Pi Imager)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Fetch rpi-image-gen if not already present
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [ ! -d "$RPI_IMAGE_GEN_SRC" ]; then
|
||||||
|
echo "--- Cloning rpi-image-gen into $RPI_IMAGE_GEN_SRC ---"
|
||||||
|
git clone --depth 1 "$RPI_IMAGE_GEN_REPO" "$RPI_IMAGE_GEN_SRC"
|
||||||
|
else
|
||||||
|
echo "--- rpi-image-gen already present at $RPI_IMAGE_GEN_SRC, skipping clone ---"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Build
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo "--- Running rpi-image-gen build ---"
|
||||||
|
echo " Config: $BUILD_CONFIG"
|
||||||
|
cd "$RPI_IMAGE_GEN_SRC"
|
||||||
|
|
||||||
|
# UNVERIFIED: assumes a ./rpi-image-gen entry point at the repo root, matching
|
||||||
|
# common conventions for this class of tool (e.g. pi-gen's own build.sh). If
|
||||||
|
# the real tool exposes a different entry point (a pip-installed console
|
||||||
|
# script, a Python module, etc.), fix this one line — everything else in this
|
||||||
|
# script and config/audio-endpoint.yaml is unaffected either way.
|
||||||
|
./rpi-image-gen build -c "$BUILD_CONFIG"
|
||||||
|
|
||||||
|
# Publish alongside every other image. The search is best-effort because
|
||||||
|
# rpi-image-gen's output path is part of the unverified surface — if it finds nothing,
|
||||||
|
# core_publish_image says so and the image is still wherever the tool put it.
|
||||||
|
# Newest .img wins, rather than `-newer $BUILD_CONFIG`: a build that finishes in the
|
||||||
|
# same filesystem timestamp granularity as the config write is not strictly "newer",
|
||||||
|
# and that comparison silently found nothing rather than failing visibly.
|
||||||
|
IMG="$(find "$RPI_IMAGE_GEN_SRC/work" -name '*.img' -printf '%T@ %p\n' 2>/dev/null \
|
||||||
|
| sort -rn | head -1 | cut -d' ' -f2-)"
|
||||||
|
if [[ -n "$IMG" ]]; then
|
||||||
|
IMG="$(core_publish_image "$IMG" "audio-endpoint" "$IMAGE_INSTANCE")"
|
||||||
|
else
|
||||||
|
core_warn "No .img found under $RPI_IMAGE_GEN_SRC/work — check rpi-image-gen's own output
|
||||||
|
above for where it wrote the image; this wrapper could not locate it."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Done (if rpi-image-gen's invocation above matched the real tool) ==="
|
||||||
|
echo
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. Flash with Raspberry Pi Imager (GUI) or:"
|
||||||
|
echo " rpi-imager --cli <path-to-image>.img /dev/<sd-card-device>"
|
||||||
|
echo " 2. The hostname is ALREADY SET to '${CORE_AUDIO_HOSTNAME}' in this image — it is"
|
||||||
|
echo " built for one room, not generic, so there is nothing to type into Imager's"
|
||||||
|
echo " OS Customisation dialog for it. That hostname is also the Spotify Connect"
|
||||||
|
echo " device name (spotify-connect-start reads \$(hostname) at service start)."
|
||||||
|
echo " STILL DO set Wi-Fi and an SSH key in Imager if this unit needs them —"
|
||||||
|
echo " those are not baked in, see README.md's 'Per-room identity' section."
|
||||||
|
echo " 3. Attach the HiFiBerry Amp2 HAT and passive speakers before first boot."
|
||||||
|
echo " 4. First boot checklist (see README.md for the full unverified list):"
|
||||||
|
echo " systemctl status spotify-connect"
|
||||||
|
echo " # confirm the room's hostname shows up as a Spotify Connect device"
|
||||||
|
echo " aplay -l # confirm the HiFiBerry is card 0 (onboard audio disabled)"
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# Builds the amd64 headless audio-endpoint ISO (mini PC + USB DAC/amp) with
|
# Builds the amd64 headless audio-endpoint ISO (mini PC + USB DAC/amp) with
|
||||||
# live-build — the same tool hosts/thin-client/scripts/build-thin-client-iso.sh
|
# live-build — the same tool tools/build-thin-client-iso.sh
|
||||||
# drives, reused here rather than a new toolchain, per
|
# drives, reused here rather than a new toolchain, per
|
||||||
# hosts/audio-endpoint/README.md's reasoning. Unlike that image, this one has
|
# hosts/audio-endpoint/README.md's reasoning. Unlike that image, this one has
|
||||||
# no graphical/kiosk stack at all: it boots straight to multi-user.target with
|
# no graphical/kiosk stack at all: it boots straight to multi-user.target with
|
||||||
|
|
@ -12,24 +12,34 @@
|
||||||
# post-build customisation tool for a generic x86 ISO, so IMAGE_HOSTNAME below
|
# post-build customisation tool for a generic x86 ISO, so IMAGE_HOSTNAME below
|
||||||
# is baked in at build time — same convention as the thin client's own
|
# is baked in at build time — same convention as the thin client's own
|
||||||
# THINCLIENT_NAME/IMAGE_HOSTNAME. Re-run this script once per room, changing
|
# THINCLIENT_NAME/IMAGE_HOSTNAME. Re-run this script once per room, changing
|
||||||
# IMAGE_HOSTNAME each time.
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CONFIGURATION — edit before running, then re-run per room
|
# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
|
||||||
# ---------------------------------------------------------------------------
|
#
|
||||||
DEBIAN_RELEASE="bookworm" # Matches the container/thin-client hosts' OS
|
# Rooms are entries in the config's "audio_endpoints" list, so adding a second one is
|
||||||
IMAGE_HOSTNAME="audio-endpoint-livingroom" # <-- EDIT per room; also the Spotify
|
# adding a list entry rather than editing and re-running this script with a different
|
||||||
# Connect device name (see README)
|
# hostname — which is how per-room images drifted apart before.
|
||||||
|
#
|
||||||
# ---------------------------------------------------------------------------
|
# sudo -E tools/build-audio-endpoint-iso-amd64.sh # the only amd64 endpoint
|
||||||
# Paths
|
# sudo -E tools/build-audio-endpoint-iso-amd64.sh <hostname> # a specific one
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
LIVE_BUILD_AMD64_DIR="$(dirname "$SCRIPT_DIR")"
|
# shellcheck source=lib/coreconfig.sh
|
||||||
AUDIO_ENDPOINT_DIR="$(dirname "$LIVE_BUILD_AMD64_DIR")"
|
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||||
|
|
||||||
|
core_select_audio_endpoint amd64 "${1:-}"
|
||||||
|
|
||||||
|
DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE"
|
||||||
|
IMAGE_HOSTNAME="$CORE_AUDIO_HOSTNAME" # also the Spotify Connect device name
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Paths — this script now lives in tools/, so the host directory it drives is
|
||||||
|
# addressed from the repo root rather than relative to the script.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
AUDIO_ENDPOINT_DIR="${CORE_REPO_ROOT}/hosts/audio-endpoint"
|
||||||
SHARED_CONFIGS_DIR="$AUDIO_ENDPOINT_DIR/configs"
|
SHARED_CONFIGS_DIR="$AUDIO_ENDPOINT_DIR/configs"
|
||||||
LIVE_BUILD_DIR="$LIVE_BUILD_AMD64_DIR"
|
LIVE_BUILD_DIR="${AUDIO_ENDPOINT_DIR}/live-build-amd64"
|
||||||
INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot"
|
INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot"
|
||||||
PACKAGE_LIST="${LIVE_BUILD_DIR}/config/package-lists/audio-endpoint.list.chroot"
|
PACKAGE_LIST="${LIVE_BUILD_DIR}/config/package-lists/audio-endpoint.list.chroot"
|
||||||
|
|
||||||
|
|
@ -116,6 +126,10 @@ lb build
|
||||||
|
|
||||||
ISO_PATH="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name '*.iso' -print -quit)"
|
ISO_PATH="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name '*.iso' -print -quit)"
|
||||||
ISO_PATH="${ISO_PATH:-${LIVE_BUILD_DIR}/live-image-amd64.hybrid.iso}"
|
ISO_PATH="${ISO_PATH:-${LIVE_BUILD_DIR}/live-image-amd64.hybrid.iso}"
|
||||||
|
# Per-room name: unlike the arm64 image below, amd64 bakes the hostname in at build
|
||||||
|
# time (there is no x86 equivalent of Pi Imager's OS Customisation), so each room is a
|
||||||
|
# genuinely different ISO and they must not share a filename.
|
||||||
|
ISO_PATH="$(core_publish_image "$ISO_PATH" "audio-endpoint" "$IMAGE_HOSTNAME")"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Done ==="
|
echo "=== Done ==="
|
||||||
|
|
@ -0,0 +1,381 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Container host ISO — the Phase 1 machine (Home Assistant, Mosquitto, Zigbee2MQTT,
|
||||||
|
# Frigate, Grocy, and this repo's own services), as an unattended-install ISO with
|
||||||
|
# everything burnt in.
|
||||||
|
#
|
||||||
|
# Normally invoked via tools/build-core-pair.sh, which builds this and its LLM-host
|
||||||
|
# twin from the same config. Runnable on its own when only this half changed.
|
||||||
|
#
|
||||||
|
# WHAT'S BURNT IN: static network config, hostname, admin user + SSH key, this repo's
|
||||||
|
# source, and — the part that matters — **every service env file, generated from
|
||||||
|
# CoreSystemConfig.json**. Those env files were previously copied from .env.example
|
||||||
|
# templates and hand-edited on the host, which is exactly how `chores.env` ended up
|
||||||
|
# shipping `IDENTITY_URL=http://127.0.0.1:8097` (project-plan open decision #38): an
|
||||||
|
# address that could never work from inside a container, in a file nobody re-read
|
||||||
|
# after copying it. Generating them from derived values removes that whole class of
|
||||||
|
# mistake permanently — no hand-editing, no stale template, no address typed twice.
|
||||||
|
#
|
||||||
|
# The machine boots, installs unattended, and on first boot runs
|
||||||
|
# setup-container-host.sh with the env files already in place.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=lib/coreconfig.sh
|
||||||
|
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||||
|
|
||||||
|
core_load
|
||||||
|
core_require_root
|
||||||
|
|
||||||
|
HOST_DIR="${CORE_REPO_ROOT}/hosts/container-host"
|
||||||
|
LIVE_BUILD_DIR="${HOST_DIR}/live-build"
|
||||||
|
INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot"
|
||||||
|
PAYLOAD="${INCLUDES}/opt/smart-home"
|
||||||
|
OUTPUT_DIR="${CORE_REPO_ROOT}/${CORE_BUILD_OUTPUT_DIR}"
|
||||||
|
|
||||||
|
command -v lb >/dev/null 2>&1 || core_die "live-build is not installed (apt install live-build)"
|
||||||
|
|
||||||
|
core_log "Preparing ${LIVE_BUILD_DIR}"
|
||||||
|
rm -rf "$INCLUDES"
|
||||||
|
mkdir -p \
|
||||||
|
"$INCLUDES/etc/systemd/system" \
|
||||||
|
"$INCLUDES/etc/default" \
|
||||||
|
"$INCLUDES/etc/network/interfaces.d" \
|
||||||
|
"$LIVE_BUILD_DIR/config/package-lists" \
|
||||||
|
"$PAYLOAD/src" \
|
||||||
|
"$OUTPUT_DIR"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. This repo's source, for the services that build from it.
|
||||||
|
# setup-container-host.sh expects each service's directory to exist on the host
|
||||||
|
# (its *_SRC variables); shipping them inside the image is what makes the install
|
||||||
|
# unattended instead of "now go git clone something".
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
core_log "Copying service sources into the image"
|
||||||
|
for svc in identity pantry-vision chores digest-engine admin-canvas trash-calendar transit; do
|
||||||
|
if [[ -d "${CORE_REPO_ROOT}/${svc}" ]]; then
|
||||||
|
cp -r "${CORE_REPO_ROOT}/${svc}" "$PAYLOAD/src/"
|
||||||
|
# __pycache__ from a developer machine is architecture- and version-specific
|
||||||
|
# noise that must never ship in an image.
|
||||||
|
find "$PAYLOAD/src/${svc}" -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
mkdir -p "$PAYLOAD/scripts"
|
||||||
|
cp "${CORE_TOOLS_DIR}/setup-container-host.sh" "$PAYLOAD/scripts/"
|
||||||
|
chmod +x "$PAYLOAD/scripts/setup-container-host.sh"
|
||||||
|
|
||||||
|
# setup-container-host.sh reads every config value as ${VAR:-default}, so this file —
|
||||||
|
# sourced by the first-boot unit — configures it without the script being edited. The
|
||||||
|
# ENABLE_* flags and ports come from CoreSystemConfig.json, which is what keeps the
|
||||||
|
# ports the services actually bind to identical to the ports the kiosk images were
|
||||||
|
# built to call.
|
||||||
|
core_log "Generating the container host's setup overrides"
|
||||||
|
cat > "$PAYLOAD/setup.env" <<EOF
|
||||||
|
# GENERATED at image build time by tools/build-container-host-iso.sh from
|
||||||
|
# CoreSystemConfig.json. Pair ID: $(core_pair_id)
|
||||||
|
BASE_DIR=/opt/smart-home
|
||||||
|
TIMEZONE=${CORE_TIMEZONE}
|
||||||
|
ENABLE_IDENTITY=${CORE_ENABLE_IDENTITY}
|
||||||
|
ENABLE_PANTRY_VISION=${CORE_ENABLE_PANTRY_VISION}
|
||||||
|
ENABLE_DIGEST_ENGINE=${CORE_ENABLE_DIGEST_ENGINE}
|
||||||
|
ENABLE_ADMIN_CANVAS=${CORE_ENABLE_ADMIN_CANVAS}
|
||||||
|
ENABLE_TRASH_CALENDAR=${CORE_ENABLE_TRASH_CALENDAR}
|
||||||
|
ENABLE_TRANSIT=${CORE_ENABLE_TRANSIT}
|
||||||
|
ENABLE_TRIP_PLANNING=${CORE_ENABLE_TRIP_PLANNING}
|
||||||
|
ENABLE_CHORES=${CORE_ENABLE_CHORES}
|
||||||
|
ENABLE_NTFY=${CORE_ENABLE_NTFY}
|
||||||
|
ENABLE_NODERED=${CORE_ENABLE_NODE_RED}
|
||||||
|
ENABLE_NETDATA=${CORE_ENABLE_NETDATA}
|
||||||
|
ENABLE_HOMEPAGE=${CORE_ENABLE_HOMEPAGE}
|
||||||
|
ENABLE_PORTAINER=${CORE_ENABLE_PORTAINER}
|
||||||
|
ENABLE_MEALIE=${CORE_ENABLE_MEALIE}
|
||||||
|
ENABLE_GALLERY_SMB=${CORE_ENABLE_GALLERY_SMB}
|
||||||
|
ENABLE_MUSIC_ASSISTANT=${CORE_ENABLE_MUSIC_ASSISTANT}
|
||||||
|
ENABLE_BACKUPS=${CORE_ENABLE_BACKUPS}
|
||||||
|
IDENTITY_PORT=${CORE_PORT_IDENTITY}
|
||||||
|
IDENTITY_WEB_PORT=${CORE_PORT_IDENTITY_WEB}
|
||||||
|
PANTRY_VISION_PORT=${CORE_PORT_PANTRY_VISION}
|
||||||
|
PANTRY_WEB_PORT=${CORE_PORT_PANTRY_WEB}
|
||||||
|
DIGEST_WEB_PORT=${CORE_PORT_DIGEST_WEB}
|
||||||
|
ADMIN_WEB_PORT=${CORE_PORT_ADMIN_WEB}
|
||||||
|
TRANSIT_PORT=${CORE_PORT_TRANSIT}
|
||||||
|
OTP_PORT=${CORE_PORT_OTP}
|
||||||
|
EOF
|
||||||
|
chmod 600 "$PAYLOAD/setup.env"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Generated env files. EVERY URL BELOW IS DERIVED — see tools/config-export.py.
|
||||||
|
# No address, port or token is written literally in this script.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
core_log "Generating service env files from CoreSystemConfig.json"
|
||||||
|
mkdir -p "$PAYLOAD"/{identity,chores,pantry-vision,digest-engine,transit,trash-calendar}
|
||||||
|
|
||||||
|
gen_header() {
|
||||||
|
cat <<EOF
|
||||||
|
# GENERATED at image build time by tools/build-container-host-iso.sh from
|
||||||
|
# CoreSystemConfig.json. Editing this file by hand works, but the next image build
|
||||||
|
# overwrites it — change CoreSystemConfig.json and rebuild instead.
|
||||||
|
# Pair ID: $(core_pair_id)
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$CORE_ENABLE_IDENTITY" == "true" ]]; then
|
||||||
|
{ gen_header
|
||||||
|
cat <<EOF
|
||||||
|
IDENTITY_TOKEN=${CORE_IDENTITY_TOKEN}
|
||||||
|
HA_URL=${CORE_HA_URL}
|
||||||
|
HA_TOKEN=${CORE_HA_TOKEN}
|
||||||
|
TRUSTED_ENTITY_PREFIXES=device_tracker.pble_,device_tracker.bletag_
|
||||||
|
MQTT_BROKER_HOST=mosquitto
|
||||||
|
MQTT_BROKER_PORT=${CORE_PORT_MQTT}
|
||||||
|
MQTT_USERNAME=${CORE_MQTT_USERNAME}
|
||||||
|
MQTT_PASSWORD=${CORE_MQTT_PASSWORD}
|
||||||
|
FRIGATE_EVENTS_TOPIC=frigate/events
|
||||||
|
FACE_PRESENCE_WINDOW_SECONDS=600
|
||||||
|
PRESENCE_POLL_SECONDS=60
|
||||||
|
DEPARTURE_GRACE_SECONDS=900
|
||||||
|
VISIT_MAX_OPEN_HOURS=72
|
||||||
|
NTFY_URL=http://ntfy
|
||||||
|
NTFY_DEFAULT_TOPIC=household
|
||||||
|
IDENTITY_PORT=${CORE_PORT_IDENTITY}
|
||||||
|
IDENTITY_DB_PATH=/data/identity.db
|
||||||
|
IDENTITY_PHOTO_DIR=/data/photos
|
||||||
|
IDENTITY_FLOORPLAN_DIR=/data/floorplans
|
||||||
|
IDENTITY_MAX_IMAGE_MB=15
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
EOF
|
||||||
|
} > "$PAYLOAD/identity/identity.env"
|
||||||
|
chmod 600 "$PAYLOAD/identity/identity.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$CORE_ENABLE_CHORES" == "true" ]]; then
|
||||||
|
# Container-name DNS, not 127.0.0.1 — see this script's header comment.
|
||||||
|
{ gen_header
|
||||||
|
cat <<EOF
|
||||||
|
IDENTITY_URL=http://identity:${CORE_PORT_IDENTITY}
|
||||||
|
IDENTITY_TOKEN=${CORE_IDENTITY_TOKEN}
|
||||||
|
WASTE_ICS_URL=
|
||||||
|
FRIGATE_URL=http://frigate:${CORE_PORT_FRIGATE}
|
||||||
|
OLLAMA_HOST=${CORE_OLLAMA_HOST}
|
||||||
|
OLLAMA_VISION_MODEL=${CORE_LLM_VISION_MODEL}
|
||||||
|
OLLAMA_TEXT_MODEL=
|
||||||
|
CAMERA_WATCHPOINTS=
|
||||||
|
CALDAV_URL=
|
||||||
|
CALDAV_USERNAME=
|
||||||
|
CALDAV_PASSWORD=
|
||||||
|
CALDAV_VERIFY_TLS=true
|
||||||
|
CALDAV_QUIET_KEYWORDS=busy,meeting,call,movie,sleep
|
||||||
|
NTFY_URL=http://ntfy
|
||||||
|
NTFY_TOPIC=chores
|
||||||
|
NEGLECT_THRESHOLD_HOURS=4
|
||||||
|
CHORE_ASSIGNMENT_STRICT=false
|
||||||
|
CHORES_DB_PATH=/data/chores.db
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
EOF
|
||||||
|
} > "$PAYLOAD/chores/chores.env"
|
||||||
|
chmod 600 "$PAYLOAD/chores/chores.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$CORE_ENABLE_PANTRY_VISION" == "true" ]]; then
|
||||||
|
{ gen_header
|
||||||
|
cat <<EOF
|
||||||
|
PANTRY_VISION_TOKEN=${CORE_PANTRY_VISION_TOKEN}
|
||||||
|
PANTRY_VISION_PORT=${CORE_PORT_PANTRY_VISION}
|
||||||
|
GROCY_URL=http://grocy
|
||||||
|
GROCY_API_KEY=
|
||||||
|
OLLAMA_HOST=${CORE_OLLAMA_HOST}
|
||||||
|
OLLAMA_VISION_MODEL=${CORE_LLM_VISION_MODEL}
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
EOF
|
||||||
|
} > "$PAYLOAD/pantry-vision/pantry-vision.env"
|
||||||
|
chmod 600 "$PAYLOAD/pantry-vision/pantry-vision.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$CORE_ENABLE_TRANSIT" == "true" ]]; then
|
||||||
|
{ gen_header
|
||||||
|
cat <<EOF
|
||||||
|
TRANSIT_TOKEN=${CORE_TRANSIT_TOKEN}
|
||||||
|
TRANSIT_PORT=${CORE_PORT_TRANSIT}
|
||||||
|
OTP_URL=${CORE_OTP_URL}
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
EOF
|
||||||
|
} > "$PAYLOAD/transit/transit.env"
|
||||||
|
chmod 600 "$PAYLOAD/transit/transit.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Static networking. The whole point of a fixed address here is that every kiosk
|
||||||
|
# image was built with this exact IP compiled into its URLs — DHCP would break
|
||||||
|
# every one of them the first time the lease moved.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
cat > "$INCLUDES/etc/network/interfaces.d/smarthome" <<EOF
|
||||||
|
# GENERATED from CoreSystemConfig.json. This address is not arbitrary: every kiosk
|
||||||
|
# image built from the same config has it baked into its service URLs, so changing it
|
||||||
|
# here alone would silently orphan them. Change it in CoreSystemConfig.json and
|
||||||
|
# rebuild everything.
|
||||||
|
auto lo
|
||||||
|
iface lo inet loopback
|
||||||
|
|
||||||
|
allow-hotplug eth0
|
||||||
|
iface eth0 inet static
|
||||||
|
address ${CORE_CONTAINER_HOST_IP}
|
||||||
|
netmask ${CORE_NETMASK}
|
||||||
|
gateway ${CORE_GATEWAY}
|
||||||
|
dns-nameservers ${CORE_DNS_SERVERS}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "${CORE_CONTAINER_HOST_NAME}" > "$INCLUDES/etc/hostname"
|
||||||
|
cat > "$INCLUDES/etc/hosts" <<EOF
|
||||||
|
127.0.0.1 localhost
|
||||||
|
127.0.1.1 ${CORE_CONTAINER_HOST_NAME}
|
||||||
|
${CORE_CONTAINER_HOST_IP} ${CORE_CONTAINER_HOST_NAME}
|
||||||
|
# The twin. Present so this host can reach the LLM host by name as well as address,
|
||||||
|
# and so anyone reading /etc/hosts can see what this machine is paired with.
|
||||||
|
${CORE_LLM_HOST_IP} ${CORE_LLM_HOST_NAME}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$INCLUDES/etc/default/keyboard" <<EOF
|
||||||
|
XKBMODEL="pc105"
|
||||||
|
XKBLAYOUT="${CORE_KEYBOARD_LAYOUT}"
|
||||||
|
XKBVARIANT=""
|
||||||
|
XKBOPTIONS=""
|
||||||
|
BACKSPACE="guess"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
core_write_build_stamp "$INCLUDES/etc/smarthome-build" "container-host"
|
||||||
|
|
||||||
|
# SSH key, if one was configured.
|
||||||
|
if [[ -n "$CORE_SSH_AUTHORIZED_KEY" ]]; then
|
||||||
|
mkdir -p "$INCLUDES/home/${CORE_CONTAINER_HOST_USER}/.ssh"
|
||||||
|
echo "$CORE_SSH_AUTHORIZED_KEY" > "$INCLUDES/home/${CORE_CONTAINER_HOST_USER}/.ssh/authorized_keys"
|
||||||
|
chmod 700 "$INCLUDES/home/${CORE_CONTAINER_HOST_USER}/.ssh"
|
||||||
|
chmod 600 "$INCLUDES/home/${CORE_CONTAINER_HOST_USER}/.ssh/authorized_keys"
|
||||||
|
else
|
||||||
|
core_warn "No ssh_authorized_key in the config — this headless host will have no SSH access."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. First-boot unit. Runs ONCE, then disables itself: setup-container-host.sh is
|
||||||
|
# idempotent, but a first-boot job that re-runs on every reboot would fight
|
||||||
|
# whatever you changed by hand afterwards.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
cat > "$INCLUDES/etc/systemd/system/smarthome-firstboot.service" <<'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=SmartestHome first-boot setup (container host)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
ConditionPathExists=!/opt/smart-home/.firstboot-done
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
EnvironmentFile=/opt/smart-home/setup.env
|
||||||
|
# Needs the network: it pulls container images. Deliberately not Restart=on-failure —
|
||||||
|
# a half-finished run should be looked at, not retried in a loop that buries the
|
||||||
|
# original error in the journal.
|
||||||
|
ExecStart=/opt/smart-home/scripts/setup-container-host.sh
|
||||||
|
ExecStartPost=/usr/bin/touch /opt/smart-home/.firstboot-done
|
||||||
|
ExecStartPost=/bin/systemctl disable smarthome-firstboot.service
|
||||||
|
StandardOutput=journal+console
|
||||||
|
StandardError=journal+console
|
||||||
|
TimeoutStartSec=3600
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
mkdir -p "$INCLUDES/etc/systemd/system/multi-user.target.wants"
|
||||||
|
ln -sf /etc/systemd/system/smarthome-firstboot.service \
|
||||||
|
"$INCLUDES/etc/systemd/system/multi-user.target.wants/smarthome-firstboot.service"
|
||||||
|
|
||||||
|
cat > "$LIVE_BUILD_DIR/config/package-lists/container-host.list.chroot" <<'EOF'
|
||||||
|
ca-certificates
|
||||||
|
curl
|
||||||
|
gnupg
|
||||||
|
openssh-server
|
||||||
|
sudo
|
||||||
|
python3
|
||||||
|
git
|
||||||
|
rsync
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Preseed for the unattended install.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mkdir -p "$LIVE_BUILD_DIR/config/includes.installer"
|
||||||
|
cat > "$LIVE_BUILD_DIR/config/includes.installer/preseed.cfg" <<EOF
|
||||||
|
# GENERATED from CoreSystemConfig.json by tools/build-container-host-iso.sh
|
||||||
|
d-i debian-installer/locale string ${CORE_LOCALE}
|
||||||
|
d-i keyboard-configuration/xkb-keymap select ${CORE_KEYBOARD_LAYOUT}
|
||||||
|
d-i time/zone string ${CORE_TIMEZONE}
|
||||||
|
d-i clock-setup/utc boolean true
|
||||||
|
|
||||||
|
d-i netcfg/choose_interface select auto
|
||||||
|
d-i netcfg/disable_autoconfig boolean true
|
||||||
|
d-i netcfg/get_ipaddress string ${CORE_CONTAINER_HOST_IP}
|
||||||
|
d-i netcfg/get_netmask string ${CORE_NETMASK}
|
||||||
|
d-i netcfg/get_gateway string ${CORE_GATEWAY}
|
||||||
|
d-i netcfg/get_nameservers string ${CORE_DNS_SERVERS}
|
||||||
|
d-i netcfg/confirm_static boolean true
|
||||||
|
d-i netcfg/get_hostname string ${CORE_CONTAINER_HOST_NAME}
|
||||||
|
d-i netcfg/get_domain string local
|
||||||
|
|
||||||
|
d-i passwd/root-login boolean false
|
||||||
|
d-i passwd/user-fullname string ${CORE_CONTAINER_HOST_USER}
|
||||||
|
d-i passwd/username string ${CORE_CONTAINER_HOST_USER}
|
||||||
|
$(if [[ -n "$CORE_ADMIN_PASSWORD_HASH" ]]; then
|
||||||
|
echo "d-i passwd/user-password-crypted password ${CORE_ADMIN_PASSWORD_HASH}"
|
||||||
|
else
|
||||||
|
echo "# No admin_password_hash set — the installer will prompt for a password."
|
||||||
|
echo "# Generate one with: mkpasswd -m sha-512"
|
||||||
|
fi)
|
||||||
|
d-i user-setup/allow-password-weak boolean false
|
||||||
|
d-i user-setup/encrypt-home boolean false
|
||||||
|
|
||||||
|
# WHOLE-DISK, AUTOMATIC, NO CONFIRMATION. This erases ${CORE_CONTAINER_HOST_DISK}
|
||||||
|
# without asking. That is the point of an unattended installer, and it is also why
|
||||||
|
# you should be certain which disk that is on the machine you're booting this on.
|
||||||
|
d-i partman-auto/disk string ${CORE_CONTAINER_HOST_DISK}
|
||||||
|
d-i partman-auto/method string regular
|
||||||
|
d-i partman-auto/choose_recipe select atomic
|
||||||
|
d-i partman-partitioning/confirm_write_new_label boolean true
|
||||||
|
d-i partman/choose_partition select finish
|
||||||
|
d-i partman/confirm boolean true
|
||||||
|
d-i partman/confirm_nooverwrite boolean true
|
||||||
|
|
||||||
|
d-i pkgsel/include string openssh-server sudo curl ca-certificates python3 git
|
||||||
|
tasksel tasksel/first multiselect standard, ssh-server
|
||||||
|
popularity-contest popularity-contest/participate boolean false
|
||||||
|
|
||||||
|
d-i grub-installer/only_debian boolean true
|
||||||
|
d-i grub-installer/bootdev string ${CORE_CONTAINER_HOST_DISK}
|
||||||
|
d-i finish-install/reboot_in_progress note
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. Build
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
cd "$LIVE_BUILD_DIR"
|
||||||
|
core_log "Running lb config"
|
||||||
|
lb clean --purge >/dev/null 2>&1 || true
|
||||||
|
lb config \
|
||||||
|
--distribution "$CORE_DEBIAN_RELEASE" \
|
||||||
|
--architecture amd64 \
|
||||||
|
--binary-images iso-hybrid \
|
||||||
|
--debian-installer netinst \
|
||||||
|
--debian-installer-gui false \
|
||||||
|
--archive-areas "main contrib non-free non-free-firmware" \
|
||||||
|
--iso-application "SmartestHome container host" \
|
||||||
|
--iso-volume "smarthome-core-$(core_pair_id)"
|
||||||
|
|
||||||
|
core_log "Running lb build (long, needs network)"
|
||||||
|
lb build
|
||||||
|
|
||||||
|
ISO="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name 'live-image-amd64.hybrid.iso' -print -quit)"
|
||||||
|
[[ -n "$ISO" ]] || core_die "lb build finished but no ISO was produced — check the log above."
|
||||||
|
|
||||||
|
DEST="${OUTPUT_DIR}/smarthome-container-host-$(core_pair_id).iso"
|
||||||
|
mv "$ISO" "$DEST"
|
||||||
|
core_log "Container host ISO: ${DEST}"
|
||||||
|
core_warn "This ISO contains every secret from CoreSystemConfig.json. Treat it as a credential."
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Build the TWINNED core pair: the container host (Home Assistant + everything in
|
||||||
|
# hosts/container-host/) and the LLM host (Ollama, hosts/llm-host/), as two ISOs that
|
||||||
|
# already know about each other.
|
||||||
|
#
|
||||||
|
# WHAT "TWINNED" MEANS HERE, concretely: CoreSystemConfig.json states the subnet prefix
|
||||||
|
# once and one last octet per host. Everything else is computed. The container host's
|
||||||
|
# OLLAMA_HOST is the LLM host's address because both were derived from those numbers in
|
||||||
|
# the same build — not because someone typed the same IP into two files and remembered
|
||||||
|
# to keep them in sync. Move the LLM host from .13 to .21 and rebuild, and the
|
||||||
|
# container host's Ollama URL follows on its own. Neither ISO can be built pointing at
|
||||||
|
# an address the other one isn't using.
|
||||||
|
#
|
||||||
|
# Both images get a matching SMARTHOME_PAIR_ID in /etc/smarthome-build, so two ISOs on
|
||||||
|
# two USB sticks can be checked against each other months later.
|
||||||
|
#
|
||||||
|
# Everything each host needs is burnt in: static network config, hostname, admin user,
|
||||||
|
# SSH key, every service token, the generated env files, and this repo itself. The
|
||||||
|
# machines come up configured, with no post-install editing of env files by hand.
|
||||||
|
#
|
||||||
|
# sudo -E tools/build-core-pair.sh # both
|
||||||
|
# sudo -E tools/build-core-pair.sh container # just the container host
|
||||||
|
# sudo -E tools/build-core-pair.sh llm # just the LLM host
|
||||||
|
#
|
||||||
|
# READ THIS BEFORE YOU BUILD: the resulting ISOs contain every secret in
|
||||||
|
# CoreSystemConfig.json — Wi-Fi PSK, service tokens, MQTT and HA credentials, your
|
||||||
|
# SSH public key. They are credential-bearing artifacts. .gitignore keeps them out of
|
||||||
|
# the repo, but an ISO on a USB stick in a drawer is still every token in this
|
||||||
|
# household. Treat them accordingly, and wipe sticks you stop using.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=lib/coreconfig.sh
|
||||||
|
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||||
|
|
||||||
|
TARGET="${1:-both}"
|
||||||
|
case "$TARGET" in
|
||||||
|
both|container|llm) ;;
|
||||||
|
*) core_die "Usage: $0 [both|container|llm]" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
core_load
|
||||||
|
core_require_root "$TARGET"
|
||||||
|
|
||||||
|
PAIR_ID="$(core_pair_id)"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
============================================================================
|
||||||
|
SmartestHome — twinned core pair
|
||||||
|
============================================================================
|
||||||
|
Pair ID : ${PAIR_ID}
|
||||||
|
Subnet : ${CORE_SUBNET_PREFIX}.0/24 (gateway ${CORE_GATEWAY})
|
||||||
|
|
||||||
|
Container host : ${CORE_CONTAINER_HOST_NAME} ${CORE_CONTAINER_HOST_IP}
|
||||||
|
LLM host : ${CORE_LLM_HOST_NAME} ${CORE_LLM_HOST_IP}
|
||||||
|
|
||||||
|
Derived cross-references (nothing below was typed by hand):
|
||||||
|
container host -> Ollama : ${CORE_OLLAMA_HOST}
|
||||||
|
kiosks -> Home Assistant : ${CORE_HA_URL}
|
||||||
|
kiosks -> identity : ${CORE_IDENTITY_URL}
|
||||||
|
kiosks -> MQTT : ${CORE_MQTT_BROKER_HOST}:${CORE_MQTT_BROKER_PORT}
|
||||||
|
|
||||||
|
Building : ${TARGET}
|
||||||
|
============================================================================
|
||||||
|
EOF
|
||||||
|
|
||||||
|
OUTPUT_DIR="${CORE_REPO_ROOT}/${CORE_BUILD_OUTPUT_DIR}"
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
if [[ "$TARGET" == "both" || "$TARGET" == "container" ]]; then
|
||||||
|
core_log "Building the container host ISO"
|
||||||
|
"${SCRIPT_DIR}/build-container-host-iso.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$TARGET" == "both" || "$TARGET" == "llm" ]]; then
|
||||||
|
core_log "Building the LLM host ISO"
|
||||||
|
"${SCRIPT_DIR}/build-llm-host-iso.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
============================================================================
|
||||||
|
Done — pair ${PAIR_ID}
|
||||||
|
============================================================================
|
||||||
|
ISOs in: ${OUTPUT_DIR}
|
||||||
|
|
||||||
|
Install order matters slightly: bring up the CONTAINER host first, since the
|
||||||
|
LLM host is only ever a server and doesn't need to reach it, whereas the
|
||||||
|
container host's services will start looking for Ollama immediately (and
|
||||||
|
degrade quietly until it answers — which is by design, see
|
||||||
|
hosts/llm-host/README.md).
|
||||||
|
|
||||||
|
Two things still need a human afterwards, because neither can be known at
|
||||||
|
build time:
|
||||||
|
1. HA_TOKEN — a Long-Lived Access Token from Home Assistant's own UI, which
|
||||||
|
doesn't exist until HA has been started and an account created. Put it in
|
||||||
|
CoreSystemConfig.json and re-run this builder, or edit
|
||||||
|
/opt/smart-home/identity/identity.env on the container host directly.
|
||||||
|
2. TRUSTED_ENTITY_PREFIXES — the real entity_id prefixes your Private BLE
|
||||||
|
Device setup produces (Developer Tools -> States). The shipped default is
|
||||||
|
a guess, and it's the single highest-risk unknown in Phase 6.
|
||||||
|
|
||||||
|
Then do the thing the split exists for: power the LLM host OFF and confirm the
|
||||||
|
house still works. See hosts/llm-host/README.md.
|
||||||
|
============================================================================
|
||||||
|
EOF
|
||||||
|
|
@ -21,58 +21,58 @@
|
||||||
# hosts/kitchen-display's twin: same one-workspace-two-kiosk-destinations shape,
|
# 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.
|
# different default content and a mic that's actually expected to be used.
|
||||||
#
|
#
|
||||||
# Run as: sudo ./build-door-panel-iso.sh
|
# Run as: sudo -E tools/build-door-panel-iso.sh [hostname]
|
||||||
#
|
#
|
||||||
# EDIT THE VARIABLES BELOW BEFORE RUNNING.
|
# Configuration comes from CoreSystemConfig.json — see tools/README.md.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CONFIGURATION — edit these before running
|
# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
|
||||||
# ---------------------------------------------------------------------------
|
#
|
||||||
DEBIAN_RELEASE="bookworm"
|
# There is nothing to edit here any more. Every value below is read from the one
|
||||||
KIOSK_USERNAME="kiosk"
|
# config at the repo root, so an address or token can only be wrong in a single
|
||||||
IMAGE_HOSTNAME="door-panel"
|
# place. Change it there and rebuild; see tools/README.md.
|
||||||
DOOR_PANEL_NAME="Door panel"
|
#
|
||||||
|
# sudo -E tools/build-door-panel-iso.sh # the only door-panel in the config
|
||||||
KEYBOARD_LAYOUT="de"
|
# sudo -E tools/build-door-panel-iso.sh <hostname> # a specific one, if several are defined
|
||||||
|
#
|
||||||
ENABLE_INSTALLER="false"
|
# The build refuses to start if the config is invalid (validate-config.py runs first),
|
||||||
|
# so a typo costs seconds rather than a 40-minute build and a reboot.
|
||||||
# --- 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)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
DOOR_PANEL_DIR="$(dirname "$SCRIPT_DIR")"
|
# shellcheck source=lib/coreconfig.sh
|
||||||
|
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||||
|
|
||||||
|
core_select_kiosk "door-panel" "${1:-}"
|
||||||
|
|
||||||
|
# Mapped onto this script's existing variable names, so everything below is unchanged
|
||||||
|
# from when these were hand-edited constants.
|
||||||
|
DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE"
|
||||||
|
KIOSK_USERNAME="$CORE_KIOSK_USERNAME"
|
||||||
|
IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME"
|
||||||
|
KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT"
|
||||||
|
ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER"
|
||||||
|
MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST"
|
||||||
|
MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT"
|
||||||
|
MQTT_USERNAME="$CORE_MQTT_USERNAME"
|
||||||
|
MQTT_PASSWORD="$CORE_MQTT_PASSWORD"
|
||||||
|
SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY"
|
||||||
|
ENABLE_VOICE_SATELLITE="$CORE_KIOSK_VOICE_SATELLITE"
|
||||||
|
VOICE_SATELLITE_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
||||||
|
VOICE_WAKE_WORD="$CORE_KIOSK_WAKE_WORD"
|
||||||
|
DOOR_PANEL_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
||||||
|
IDENTITY_WEB_URL="$CORE_IDENTITY_WEB_URL"
|
||||||
|
IDENTITY_URL="$CORE_IDENTITY_URL"
|
||||||
|
IDENTITY_TOKEN="$CORE_IDENTITY_TOKEN"
|
||||||
|
PANTRY_VISION_URL="$CORE_PANTRY_VISION_URL"
|
||||||
|
PANTRY_VISION_TOKEN="$CORE_PANTRY_VISION_TOKEN"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Paths — this script now lives in tools/, so the host directory it drives is
|
||||||
|
# addressed from the repo root rather than relative to the script.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DOOR_PANEL_DIR="${CORE_REPO_ROOT}/hosts/door-panel"
|
||||||
CONFIGS_DIR="${DOOR_PANEL_DIR}/configs"
|
CONFIGS_DIR="${DOOR_PANEL_DIR}/configs"
|
||||||
AGENT_DIR="${DOOR_PANEL_DIR}/agent"
|
AGENT_DIR="${DOOR_PANEL_DIR}/agent"
|
||||||
LIVE_BUILD_DIR="${DOOR_PANEL_DIR}/live-build"
|
LIVE_BUILD_DIR="${DOOR_PANEL_DIR}/live-build"
|
||||||
|
|
@ -106,20 +106,8 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
|
||||||
echo "Note: ENABLE_VOICE_SATELLITE=true — this image expects a real microphone on"
|
echo "Note: ENABLE_VOICE_SATELLITE=true — this image expects a real microphone on"
|
||||||
|
|
@ -192,8 +180,8 @@ fi
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo "--- Writing /etc/door-panel-agent/config.env into includes.chroot ---"
|
echo "--- Writing /etc/door-panel-agent/config.env into includes.chroot ---"
|
||||||
cat > "$INCLUDES/etc/door-panel-agent/config.env" <<EOF
|
cat > "$INCLUDES/etc/door-panel-agent/config.env" <<EOF
|
||||||
# Generated by hosts/door-panel/scripts/build-door-panel-iso.sh — do not hand-edit
|
# Generated by tools/build-door-panel-iso.sh — do not hand-edit
|
||||||
# here; edit the CONFIGURATION block in that script and rebuild.
|
# here; change CoreSystemConfig.json at the repo root and rebuild.
|
||||||
KIOSK_USERNAME=${KIOSK_USERNAME}
|
KIOSK_USERNAME=${KIOSK_USERNAME}
|
||||||
DOOR_PANEL_NAME=${DOOR_PANEL_NAME}
|
DOOR_PANEL_NAME=${DOOR_PANEL_NAME}
|
||||||
|
|
||||||
|
|
@ -254,6 +242,9 @@ lb build
|
||||||
|
|
||||||
ISO_PATH="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name '*.iso' -print -quit)"
|
ISO_PATH="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name '*.iso' -print -quit)"
|
||||||
ISO_PATH="${ISO_PATH:-${LIVE_BUILD_DIR}/live-image-amd64.hybrid.iso}"
|
ISO_PATH="${ISO_PATH:-${LIVE_BUILD_DIR}/live-image-amd64.hybrid.iso}"
|
||||||
|
# Published under this kiosk's own hostname — live-build reuses one filename per host
|
||||||
|
# tree, so two of the same kiosk type would otherwise overwrite each other.
|
||||||
|
ISO_PATH="$(core_publish_image "$ISO_PATH" "door-panel" "$IMAGE_HOSTNAME")"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Done ==="
|
echo "=== Done ==="
|
||||||
|
|
@ -20,61 +20,59 @@
|
||||||
# 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
|
||||||
# already have to the thin client's.
|
# already have to the thin client's.
|
||||||
#
|
#
|
||||||
# Run as: sudo ./build-kitchen-display-iso.sh
|
# Run as: sudo -E tools/build-kitchen-display-iso.sh [hostname]
|
||||||
#
|
#
|
||||||
# EDIT THE VARIABLES BELOW BEFORE RUNNING.
|
# Configuration comes from CoreSystemConfig.json — see tools/README.md.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CONFIGURATION — edit these before running
|
# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
|
||||||
# ---------------------------------------------------------------------------
|
#
|
||||||
DEBIAN_RELEASE="bookworm"
|
# There is nothing to edit here any more. Every value below is read from the one
|
||||||
KIOSK_USERNAME="kiosk"
|
# config at the repo root, so an address or token can only be wrong in a single
|
||||||
IMAGE_HOSTNAME="kitchen-display"
|
# place. Change it there and rebuild; see tools/README.md.
|
||||||
KITCHEN_DISPLAY_NAME="Kitchen fridge display"
|
#
|
||||||
|
# sudo -E tools/build-kitchen-display-iso.sh # the only kitchen-display in the config
|
||||||
KEYBOARD_LAYOUT="de"
|
# sudo -E tools/build-kitchen-display-iso.sh <hostname> # a specific one, if several are defined
|
||||||
|
#
|
||||||
ENABLE_INSTALLER="false"
|
# The build refuses to start if the config is invalid (validate-config.py runs first),
|
||||||
|
# so a typo costs seconds rather than a 40-minute build and a reboot.
|
||||||
# --- Where the kitchen display talks to -------------------------------------
|
|
||||||
MQTT_BROKER_HOST="192.168.1.10" # <-- EDIT: container-host IP running Mosquitto
|
|
||||||
MQTT_BROKER_PORT="1883"
|
|
||||||
MQTT_USERNAME=""
|
|
||||||
MQTT_PASSWORD=""
|
|
||||||
|
|
||||||
# pantry-web (nginx, serves pantry-vision/frontend/) and pantry-vision (the API the
|
|
||||||
# frontend calls directly from the browser) — both from ENABLE_PANTRY_VISION 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.
|
|
||||||
PANTRY_WEB_URL="http://192.168.1.10:8096" # <-- EDIT once pantry-web is deployed
|
|
||||||
PANTRY_VISION_URL="http://192.168.1.10:8095" # <-- EDIT once pantry-vision is deployed
|
|
||||||
# Same value as PANTRY_VISION_TOKEN in pantry-vision/pantry-vision.env — there is no
|
|
||||||
# way for this repo to push it between the two hosts for you, same as every other
|
|
||||||
# credential pair that spans two machines in this project.
|
|
||||||
PANTRY_VISION_TOKEN="" # <-- EDIT: must match pantry-vision's own token
|
|
||||||
|
|
||||||
# identity's registration page (Phase 6) — ENABLE_IDENTITY in setup-container-host.sh.
|
|
||||||
# Same placeholder handling as the pantry-vision block above.
|
|
||||||
IDENTITY_WEB_URL="http://192.168.1.10:8098" # <-- EDIT once identity-web is deployed
|
|
||||||
IDENTITY_URL="http://192.168.1.10:8097" # <-- EDIT once identity is deployed
|
|
||||||
IDENTITY_TOKEN="" # <-- EDIT: must match identity's own token
|
|
||||||
|
|
||||||
# --- Voice registration ("register me as <name>") — OFF BY DEFAULT until a real mic
|
|
||||||
# --- is attached to this specific unit. Same per-image opt-in shape as the thin
|
|
||||||
# --- client's ENABLE_VOICE_SATELLITE (project-plan Phase 11.8).
|
|
||||||
ENABLE_VOICE_SATELLITE="false"
|
|
||||||
VOICE_SATELLITE_NAME="Kitchen display"
|
|
||||||
VOICE_WAKE_WORD="ok_nabu"
|
|
||||||
|
|
||||||
SSH_AUTHORIZED_KEY=""
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Paths
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
KITCHEN_DISPLAY_DIR="$(dirname "$SCRIPT_DIR")"
|
# shellcheck source=lib/coreconfig.sh
|
||||||
|
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||||
|
|
||||||
|
core_select_kiosk "kitchen-display" "${1:-}"
|
||||||
|
|
||||||
|
# Mapped onto this script's existing variable names, so everything below is unchanged
|
||||||
|
# from when these were hand-edited constants.
|
||||||
|
DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE"
|
||||||
|
KIOSK_USERNAME="$CORE_KIOSK_USERNAME"
|
||||||
|
IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME"
|
||||||
|
KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT"
|
||||||
|
ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER"
|
||||||
|
MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST"
|
||||||
|
MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT"
|
||||||
|
MQTT_USERNAME="$CORE_MQTT_USERNAME"
|
||||||
|
MQTT_PASSWORD="$CORE_MQTT_PASSWORD"
|
||||||
|
SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY"
|
||||||
|
ENABLE_VOICE_SATELLITE="$CORE_KIOSK_VOICE_SATELLITE"
|
||||||
|
VOICE_SATELLITE_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
||||||
|
VOICE_WAKE_WORD="$CORE_KIOSK_WAKE_WORD"
|
||||||
|
KITCHEN_DISPLAY_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
||||||
|
PANTRY_WEB_URL="$CORE_PANTRY_WEB_URL"
|
||||||
|
PANTRY_VISION_URL="$CORE_PANTRY_VISION_URL"
|
||||||
|
PANTRY_VISION_TOKEN="$CORE_PANTRY_VISION_TOKEN"
|
||||||
|
IDENTITY_WEB_URL="$CORE_IDENTITY_WEB_URL"
|
||||||
|
IDENTITY_URL="$CORE_IDENTITY_URL"
|
||||||
|
IDENTITY_TOKEN="$CORE_IDENTITY_TOKEN"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Paths — this script now lives in tools/, so the host directory it drives is
|
||||||
|
# addressed from the repo root rather than relative to the script.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
KITCHEN_DISPLAY_DIR="${CORE_REPO_ROOT}/hosts/kitchen-display"
|
||||||
CONFIGS_DIR="${KITCHEN_DISPLAY_DIR}/configs"
|
CONFIGS_DIR="${KITCHEN_DISPLAY_DIR}/configs"
|
||||||
AGENT_DIR="${KITCHEN_DISPLAY_DIR}/agent"
|
AGENT_DIR="${KITCHEN_DISPLAY_DIR}/agent"
|
||||||
LIVE_BUILD_DIR="${KITCHEN_DISPLAY_DIR}/live-build"
|
LIVE_BUILD_DIR="${KITCHEN_DISPLAY_DIR}/live-build"
|
||||||
|
|
@ -108,21 +106,8 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 "$PANTRY_VISION_TOKEN" ]]; then
|
|
||||||
echo "Warning: PANTRY_VISION_TOKEN is empty. The kiosk window will load but every"
|
|
||||||
echo " call to pantry-vision will fail (401) until this matches the token in"
|
|
||||||
echo " pantry-vision/pantry-vision.env on the container host."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "$IDENTITY_TOKEN" ]]; then
|
|
||||||
echo "Warning: IDENTITY_TOKEN is empty. 'Show registration' will load but every"
|
|
||||||
echo " call to identity will fail (401) until this matches the token in"
|
|
||||||
echo " identity/identity.env on the container host."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
|
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
|
||||||
echo "Note: ENABLE_VOICE_SATELLITE=true — this image expects a real microphone on"
|
echo "Note: ENABLE_VOICE_SATELLITE=true — this image expects a real microphone on"
|
||||||
|
|
@ -196,8 +181,8 @@ fi
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo "--- Writing /etc/kitchen-display-agent/config.env into includes.chroot ---"
|
echo "--- Writing /etc/kitchen-display-agent/config.env into includes.chroot ---"
|
||||||
cat > "$INCLUDES/etc/kitchen-display-agent/config.env" <<EOF
|
cat > "$INCLUDES/etc/kitchen-display-agent/config.env" <<EOF
|
||||||
# Generated by hosts/kitchen-display/scripts/build-kitchen-display-iso.sh — do not
|
# Generated by tools/build-kitchen-display-iso.sh — do not
|
||||||
# hand-edit here; edit the CONFIGURATION block in that script and rebuild.
|
# hand-edit here; change CoreSystemConfig.json and rebuild.
|
||||||
KIOSK_USERNAME=${KIOSK_USERNAME}
|
KIOSK_USERNAME=${KIOSK_USERNAME}
|
||||||
KITCHEN_DISPLAY_NAME=${KITCHEN_DISPLAY_NAME}
|
KITCHEN_DISPLAY_NAME=${KITCHEN_DISPLAY_NAME}
|
||||||
|
|
||||||
|
|
@ -259,6 +244,9 @@ lb build
|
||||||
|
|
||||||
ISO_PATH="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name '*.iso' -print -quit)"
|
ISO_PATH="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name '*.iso' -print -quit)"
|
||||||
ISO_PATH="${ISO_PATH:-${LIVE_BUILD_DIR}/live-image-amd64.hybrid.iso}"
|
ISO_PATH="${ISO_PATH:-${LIVE_BUILD_DIR}/live-image-amd64.hybrid.iso}"
|
||||||
|
# Published under this kiosk's own hostname — live-build reuses one filename per host
|
||||||
|
# tree, so two of the same kiosk type would otherwise overwrite each other.
|
||||||
|
ISO_PATH="$(core_publish_image "$ISO_PATH" "kitchen-display" "$IMAGE_HOSTNAME")"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Done ==="
|
echo "=== Done ==="
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# LLM host ISO — the Phase 3 Ollama machine (hosts/llm-host/), as an unattended-install
|
||||||
|
# ISO with everything burnt in.
|
||||||
|
#
|
||||||
|
# The twin of build-container-host-iso.sh. Both read the same CoreSystemConfig.json, so
|
||||||
|
# this image's address is the one the container host was built to call, by construction
|
||||||
|
# rather than by agreement.
|
||||||
|
#
|
||||||
|
# This half is deliberately the simpler one, and that asymmetry is the design: the LLM
|
||||||
|
# host is a *server*. It doesn't need to know the container host's address, hold any
|
||||||
|
# service token, or reach anything at boot beyond a model registry. Nothing here is
|
||||||
|
# load-bearing for the house — see hosts/llm-host/README.md's guardrail. Keeping this
|
||||||
|
# image dumb is what lets you power it off, reinstall it, or swap the GPU without any
|
||||||
|
# of that touching the smart home.
|
||||||
|
#
|
||||||
|
# Normally invoked via tools/build-core-pair.sh.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=lib/coreconfig.sh
|
||||||
|
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||||
|
|
||||||
|
core_load
|
||||||
|
core_require_root
|
||||||
|
|
||||||
|
HOST_DIR="${CORE_REPO_ROOT}/hosts/llm-host"
|
||||||
|
LIVE_BUILD_DIR="${HOST_DIR}/live-build"
|
||||||
|
INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot"
|
||||||
|
PAYLOAD="${INCLUDES}/opt/llm-host"
|
||||||
|
OUTPUT_DIR="${CORE_REPO_ROOT}/${CORE_BUILD_OUTPUT_DIR}"
|
||||||
|
|
||||||
|
command -v lb >/dev/null 2>&1 || core_die "live-build is not installed (apt install live-build)"
|
||||||
|
|
||||||
|
core_log "Preparing ${LIVE_BUILD_DIR}"
|
||||||
|
rm -rf "$INCLUDES"
|
||||||
|
mkdir -p \
|
||||||
|
"$INCLUDES/etc/systemd/system" \
|
||||||
|
"$INCLUDES/etc/default" \
|
||||||
|
"$INCLUDES/etc/network/interfaces.d" \
|
||||||
|
"$LIVE_BUILD_DIR/config/package-lists" \
|
||||||
|
"$LIVE_BUILD_DIR/config/includes.installer" \
|
||||||
|
"$PAYLOAD" \
|
||||||
|
"$OUTPUT_DIR"
|
||||||
|
|
||||||
|
mkdir -p "$PAYLOAD/scripts"
|
||||||
|
cp "${CORE_TOOLS_DIR}/setup-llm-host.sh" "$PAYLOAD/scripts/"
|
||||||
|
chmod +x "$PAYLOAD/scripts/setup-llm-host.sh"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Bake the config into setup-llm-host.sh's own variables, so the first-boot run
|
||||||
|
# needs no arguments and no editing. Values come from CoreSystemConfig.json; the
|
||||||
|
# script keeps its defaults for anything not managed centrally.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
core_log "Generating the LLM host's setup overrides"
|
||||||
|
cat > "$PAYLOAD/llm-host.env" <<EOF
|
||||||
|
# GENERATED at image build time by tools/build-llm-host-iso.sh from
|
||||||
|
# CoreSystemConfig.json. Sourced by the first-boot unit to override
|
||||||
|
# setup-llm-host.sh's defaults.
|
||||||
|
# Pair ID: $(core_pair_id)
|
||||||
|
TIER=${CORE_LLM_TIER}
|
||||||
|
OLLAMA_PORT=${CORE_PORT_OLLAMA}
|
||||||
|
GPU_TEXT_MODEL=${CORE_LLM_TEXT_MODEL_GPU}
|
||||||
|
CPU_TEXT_MODEL=${CORE_LLM_TEXT_MODEL_CPU}
|
||||||
|
VISION_MODEL=${CORE_LLM_VISION_MODEL}
|
||||||
|
PULL_VISION_MODEL=${CORE_LLM_PULL_VISION_MODEL}
|
||||||
|
OLLAMA_KEEP_ALIVE=${CORE_LLM_KEEP_ALIVE}
|
||||||
|
OLLAMA_MAX_LOADED_MODELS=${CORE_LLM_MAX_LOADED_MODELS}
|
||||||
|
OLLAMA_NUM_PARALLEL=${CORE_LLM_NUM_PARALLEL}
|
||||||
|
EOF
|
||||||
|
chmod 600 "$PAYLOAD/llm-host.env"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Static networking — this address is what the container host's OLLAMA_HOST was
|
||||||
|
# built to point at, so it cannot be left to DHCP.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
cat > "$INCLUDES/etc/network/interfaces.d/smarthome" <<EOF
|
||||||
|
# GENERATED from CoreSystemConfig.json. The container host image built alongside this
|
||||||
|
# one has ${CORE_OLLAMA_HOST} compiled into its service env files — if this address
|
||||||
|
# moves on its own, that host quietly loses inference and (by design) degrades rather
|
||||||
|
# than erroring, so the breakage is easy to miss. Change it in CoreSystemConfig.json
|
||||||
|
# and rebuild both halves.
|
||||||
|
auto lo
|
||||||
|
iface lo inet loopback
|
||||||
|
|
||||||
|
allow-hotplug eth0
|
||||||
|
iface eth0 inet static
|
||||||
|
address ${CORE_LLM_HOST_IP}
|
||||||
|
netmask ${CORE_NETMASK}
|
||||||
|
gateway ${CORE_GATEWAY}
|
||||||
|
dns-nameservers ${CORE_DNS_SERVERS}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "${CORE_LLM_HOST_NAME}" > "$INCLUDES/etc/hostname"
|
||||||
|
cat > "$INCLUDES/etc/hosts" <<EOF
|
||||||
|
127.0.0.1 localhost
|
||||||
|
127.0.1.1 ${CORE_LLM_HOST_NAME}
|
||||||
|
${CORE_LLM_HOST_IP} ${CORE_LLM_HOST_NAME}
|
||||||
|
# The twin — recorded for diagnosis, not used. This host never initiates anything
|
||||||
|
# toward the container host; it only answers.
|
||||||
|
${CORE_CONTAINER_HOST_IP} ${CORE_CONTAINER_HOST_NAME}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$INCLUDES/etc/default/keyboard" <<EOF
|
||||||
|
XKBMODEL="pc105"
|
||||||
|
XKBLAYOUT="${CORE_KEYBOARD_LAYOUT}"
|
||||||
|
XKBVARIANT=""
|
||||||
|
XKBOPTIONS=""
|
||||||
|
BACKSPACE="guess"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
core_write_build_stamp "$INCLUDES/etc/smarthome-build" "llm-host"
|
||||||
|
|
||||||
|
if [[ -n "$CORE_SSH_AUTHORIZED_KEY" ]]; then
|
||||||
|
mkdir -p "$INCLUDES/home/${CORE_LLM_HOST_USER}/.ssh"
|
||||||
|
echo "$CORE_SSH_AUTHORIZED_KEY" > "$INCLUDES/home/${CORE_LLM_HOST_USER}/.ssh/authorized_keys"
|
||||||
|
chmod 700 "$INCLUDES/home/${CORE_LLM_HOST_USER}/.ssh"
|
||||||
|
chmod 600 "$INCLUDES/home/${CORE_LLM_HOST_USER}/.ssh/authorized_keys"
|
||||||
|
else
|
||||||
|
core_warn "No ssh_authorized_key in the config — this headless host will have no SSH access."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. First-boot unit. Long timeout on purpose: this pulls several GB of model weights
|
||||||
|
# on a first run, and a model download on a slow link genuinely can outlast a
|
||||||
|
# conservative systemd timeout.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
cat > "$INCLUDES/etc/systemd/system/smarthome-llm-firstboot.service" <<'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=SmartestHome first-boot setup (LLM host)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
ConditionPathExists=!/opt/llm-host/.firstboot-done
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
EnvironmentFile=/opt/llm-host/llm-host.env
|
||||||
|
ExecStart=/opt/llm-host/scripts/setup-llm-host.sh
|
||||||
|
ExecStartPost=/usr/bin/touch /opt/llm-host/.firstboot-done
|
||||||
|
ExecStartPost=/bin/systemctl disable smarthome-llm-firstboot.service
|
||||||
|
StandardOutput=journal+console
|
||||||
|
StandardError=journal+console
|
||||||
|
# Model pulls are multi-GB; 4h is generous rather than optimistic.
|
||||||
|
TimeoutStartSec=14400
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
mkdir -p "$INCLUDES/etc/systemd/system/multi-user.target.wants"
|
||||||
|
ln -sf /etc/systemd/system/smarthome-llm-firstboot.service \
|
||||||
|
"$INCLUDES/etc/systemd/system/multi-user.target.wants/smarthome-llm-firstboot.service"
|
||||||
|
|
||||||
|
# firmware-misc-nonfree/nvidia-driver are NOT preinstalled here. Driver choice is the
|
||||||
|
# most hardware-specific decision on this machine and picking one blind is how you
|
||||||
|
# produce a box that doesn't boot — setup-llm-host.sh checks for a working nvidia-smi
|
||||||
|
# and tells you what to install if it's missing. See hosts/llm-host/README.md.
|
||||||
|
cat > "$LIVE_BUILD_DIR/config/package-lists/llm-host.list.chroot" <<'EOF'
|
||||||
|
ca-certificates
|
||||||
|
curl
|
||||||
|
gnupg
|
||||||
|
openssh-server
|
||||||
|
sudo
|
||||||
|
python3
|
||||||
|
pciutils
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$LIVE_BUILD_DIR/config/includes.installer/preseed.cfg" <<EOF
|
||||||
|
# GENERATED from CoreSystemConfig.json by tools/build-llm-host-iso.sh
|
||||||
|
d-i debian-installer/locale string ${CORE_LOCALE}
|
||||||
|
d-i keyboard-configuration/xkb-keymap select ${CORE_KEYBOARD_LAYOUT}
|
||||||
|
d-i time/zone string ${CORE_TIMEZONE}
|
||||||
|
d-i clock-setup/utc boolean true
|
||||||
|
|
||||||
|
d-i netcfg/choose_interface select auto
|
||||||
|
d-i netcfg/disable_autoconfig boolean true
|
||||||
|
d-i netcfg/get_ipaddress string ${CORE_LLM_HOST_IP}
|
||||||
|
d-i netcfg/get_netmask string ${CORE_NETMASK}
|
||||||
|
d-i netcfg/get_gateway string ${CORE_GATEWAY}
|
||||||
|
d-i netcfg/get_nameservers string ${CORE_DNS_SERVERS}
|
||||||
|
d-i netcfg/confirm_static boolean true
|
||||||
|
d-i netcfg/get_hostname string ${CORE_LLM_HOST_NAME}
|
||||||
|
d-i netcfg/get_domain string local
|
||||||
|
|
||||||
|
d-i passwd/root-login boolean false
|
||||||
|
d-i passwd/user-fullname string ${CORE_LLM_HOST_USER}
|
||||||
|
d-i passwd/username string ${CORE_LLM_HOST_USER}
|
||||||
|
$(if [[ -n "$CORE_ADMIN_PASSWORD_HASH" ]]; then
|
||||||
|
echo "d-i passwd/user-password-crypted password ${CORE_ADMIN_PASSWORD_HASH}"
|
||||||
|
else
|
||||||
|
echo "# No admin_password_hash set — the installer will prompt for a password."
|
||||||
|
echo "# Generate one with: mkpasswd -m sha-512"
|
||||||
|
fi)
|
||||||
|
d-i user-setup/allow-password-weak boolean false
|
||||||
|
d-i user-setup/encrypt-home boolean false
|
||||||
|
|
||||||
|
# WHOLE-DISK, AUTOMATIC, NO CONFIRMATION — erases ${CORE_LLM_HOST_DISK} without asking.
|
||||||
|
# Model storage wants room: a 14B Q4 model is ~9GB and a vision model another 5-8GB.
|
||||||
|
d-i partman-auto/disk string ${CORE_LLM_HOST_DISK}
|
||||||
|
d-i partman-auto/method string regular
|
||||||
|
d-i partman-auto/choose_recipe select atomic
|
||||||
|
d-i partman-partitioning/confirm_write_new_label boolean true
|
||||||
|
d-i partman/choose_partition select finish
|
||||||
|
d-i partman/confirm boolean true
|
||||||
|
d-i partman/confirm_nooverwrite boolean true
|
||||||
|
|
||||||
|
d-i pkgsel/include string openssh-server sudo curl ca-certificates python3 pciutils
|
||||||
|
tasksel tasksel/first multiselect standard, ssh-server
|
||||||
|
popularity-contest popularity-contest/participate boolean false
|
||||||
|
|
||||||
|
d-i grub-installer/only_debian boolean true
|
||||||
|
d-i grub-installer/bootdev string ${CORE_LLM_HOST_DISK}
|
||||||
|
d-i finish-install/reboot_in_progress note
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cd "$LIVE_BUILD_DIR"
|
||||||
|
core_log "Running lb config"
|
||||||
|
lb clean --purge >/dev/null 2>&1 || true
|
||||||
|
lb config \
|
||||||
|
--distribution "$CORE_DEBIAN_RELEASE" \
|
||||||
|
--architecture amd64 \
|
||||||
|
--binary-images iso-hybrid \
|
||||||
|
--debian-installer netinst \
|
||||||
|
--debian-installer-gui false \
|
||||||
|
--archive-areas "main contrib non-free non-free-firmware" \
|
||||||
|
--iso-application "SmartestHome LLM host" \
|
||||||
|
--iso-volume "smarthome-llm-$(core_pair_id)"
|
||||||
|
|
||||||
|
core_log "Running lb build (long, needs network)"
|
||||||
|
lb build
|
||||||
|
|
||||||
|
ISO="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name 'live-image-amd64.hybrid.iso' -print -quit)"
|
||||||
|
[[ -n "$ISO" ]] || core_die "lb build finished but no ISO was produced — check the log above."
|
||||||
|
|
||||||
|
DEST="${OUTPUT_DIR}/smarthome-llm-host-$(core_pair_id).iso"
|
||||||
|
mv "$ISO" "$DEST"
|
||||||
|
core_log "LLM host ISO: ${DEST}"
|
||||||
|
|
@ -18,84 +18,59 @@
|
||||||
# generated tree that actually gets baked into the image). Never hand-edit anything
|
# generated tree that actually gets baked into the image). Never hand-edit anything
|
||||||
# under includes.chroot — it is wiped and regenerated on every run.
|
# under includes.chroot — it is wiped and regenerated on every run.
|
||||||
#
|
#
|
||||||
# Run as: sudo ./build-thin-client-iso.sh
|
# Run as: sudo -E tools/build-thin-client-iso.sh [hostname]
|
||||||
#
|
#
|
||||||
# EDIT THE VARIABLES BELOW BEFORE RUNNING.
|
# Configuration comes from CoreSystemConfig.json — see tools/README.md.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CONFIGURATION — edit these before running
|
# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
|
||||||
# ---------------------------------------------------------------------------
|
#
|
||||||
DEBIAN_RELEASE="bookworm" # Matches the container host's OS
|
# There is nothing to edit here any more. Every value below is read from the one
|
||||||
KIOSK_USERNAME="kiosk" # The autologin account the whole image is built around
|
# config at the repo root, so an address or token can only be wrong in a single
|
||||||
IMAGE_HOSTNAME="thin-client" # Hostname baked into the image
|
# place. Change it there and rebuild; see tools/README.md.
|
||||||
THINCLIENT_NAME="Living room thin client" # Friendly name shown on the HA device
|
#
|
||||||
|
# sudo -E tools/build-thin-client-iso.sh # the only thin-client in the config
|
||||||
# Console + Sway keyboard layout. This IS the "installer choice" in this image's
|
# sudo -E tools/build-thin-client-iso.sh <hostname> # a specific one, if several are defined
|
||||||
# architecture: there is no interactive keymap prompt in the normal live-boot path (see
|
#
|
||||||
# ENABLE_INSTALLER below for the one case where a real prompt exists), so a per-image
|
# The build refuses to start if the config is invalid (validate-config.py runs first),
|
||||||
# build variable is what stands in for it — build one ISO per keyboard layout you need.
|
# so a typo costs seconds rather than a 40-minute build and a reboot.
|
||||||
KEYBOARD_LAYOUT="de" # xkb layout name (`localectl list-x11-keymap-layouts`)
|
|
||||||
|
|
||||||
ENABLE_STEAM_LINK="true" # Install the Steam Link flatpak from Flathub
|
|
||||||
ENABLE_INSTALLER="false" # "true" adds a debian-installer to the ISO (install to disk)
|
|
||||||
|
|
||||||
# --- Voice satellite — OFF BY DEFAULT, AND MEANT TO STAY THAT WAY -----------
|
|
||||||
# Per docs/project-plan.md Phase 11.8, only the specific rooms that have a microphone
|
|
||||||
# run wyoming-satellite. This is therefore a PER-IMAGE decision, not a universal one:
|
|
||||||
# build one ISO with this "false" for the silent rooms, and a second ISO with it "true"
|
|
||||||
# for the mic-enabled ones. The exact mic-enabled room list is still an open decision
|
|
||||||
# (project-plan §4 #6) — do not flip this on until it has been chosen.
|
|
||||||
ENABLE_VOICE_SATELLITE="false"
|
|
||||||
VOICE_SATELLITE_NAME="Living room" # Shown in HA's Wyoming/Assist device list
|
|
||||||
VOICE_WAKE_WORD="ok_nabu" # openWakeWord model name
|
|
||||||
|
|
||||||
# --- Camera gesture control — OFF BY DEFAULT, AND MEANT TO STAY THAT WAY -------
|
|
||||||
# Open hand moves the pointer, fist clicks. Same per-image, per-room logic as the mic
|
|
||||||
# above: only build this into the image of a room that is actually getting a webcam.
|
|
||||||
# This flag only decides whether MediaPipe and its ~400 MB dependency tree are INSTALLED.
|
|
||||||
# Whether the camera is ever OPENED is a second, separate gate — the "enabled" flag in
|
|
||||||
# configs/gesture-control/gesture-config.json, which is false by default even here, so a
|
|
||||||
# gesture-capable image still ships with the camera off. See the privacy section in
|
|
||||||
# hosts/thin-client/README.md.
|
|
||||||
ENABLE_GESTURE_CONTROL="false"
|
|
||||||
|
|
||||||
# --- Where the thin client talks to ----------------------------------------
|
|
||||||
# The container host from Phase 1 (Mosquitto + Home Assistant). Fill in its LAN IP.
|
|
||||||
MQTT_BROKER_HOST="192.168.1.10" # <-- EDIT: container-host IP running Mosquitto
|
|
||||||
MQTT_BROKER_PORT="1883"
|
|
||||||
MQTT_USERNAME="" # Leave empty while Mosquitto runs allow_anonymous
|
|
||||||
MQTT_PASSWORD="" # Never commit a real value here — see README
|
|
||||||
HA_URL="http://192.168.1.10:8123" # <-- EDIT: Home Assistant URL
|
|
||||||
|
|
||||||
# digest-web is the static-file service that Phase 12's digest-engine renders into.
|
|
||||||
# It is built by a separate workstream; until it is deployed this is just a placeholder
|
|
||||||
# and the kiosk Firefox workspace will show a connection error (harmless — the session
|
|
||||||
# must still come up with the container host powered off, per Phase 11.10).
|
|
||||||
DIGEST_WEB_URL="http://192.168.1.10:8081" # <-- EDIT once digest-web is deployed
|
|
||||||
|
|
||||||
# admin-web (Phase 13) — the sys-admin-llm's on-demand display surface. Same
|
|
||||||
# placeholder handling as DIGEST_WEB_URL above: harmless until deployed, the admin
|
|
||||||
# workspace just won't have anything to open yet (and unlike the digest workspace it
|
|
||||||
# is never auto-launched at session start anyway — see configs/sway/config).
|
|
||||||
ADMIN_WEB_URL="http://192.168.1.10:8094" # <-- EDIT once admin-web is deployed
|
|
||||||
|
|
||||||
# The container host's gallery-smb share (ENABLE_GALLERY_SMB in
|
|
||||||
# setup-container-host.sh), used by the idle-timeout slideshow. Just the host —
|
|
||||||
# idle-gallery.sh always mounts the fixed "gallery" share name.
|
|
||||||
GALLERY_SMB_HOST="192.168.1.10" # <-- EDIT: container-host IP running gallery-smb
|
|
||||||
|
|
||||||
# Optional: an SSH public key to bake into the kiosk account for out-of-band admin.
|
|
||||||
# The image ships with password auth disabled, so without this the only admin path is
|
|
||||||
# the local console or wayvnc.
|
|
||||||
SSH_AUTHORIZED_KEY=""
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Paths
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
THIN_CLIENT_DIR="$(dirname "$SCRIPT_DIR")"
|
# shellcheck source=lib/coreconfig.sh
|
||||||
|
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||||
|
|
||||||
|
core_select_kiosk "thin-client" "${1:-}"
|
||||||
|
|
||||||
|
# Mapped onto this script's existing variable names, so everything below is unchanged
|
||||||
|
# from when these were hand-edited constants.
|
||||||
|
DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE"
|
||||||
|
KIOSK_USERNAME="$CORE_KIOSK_USERNAME"
|
||||||
|
IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME"
|
||||||
|
KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT"
|
||||||
|
ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER"
|
||||||
|
MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST"
|
||||||
|
MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT"
|
||||||
|
MQTT_USERNAME="$CORE_MQTT_USERNAME"
|
||||||
|
MQTT_PASSWORD="$CORE_MQTT_PASSWORD"
|
||||||
|
SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY"
|
||||||
|
ENABLE_VOICE_SATELLITE="$CORE_KIOSK_VOICE_SATELLITE"
|
||||||
|
VOICE_SATELLITE_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
||||||
|
VOICE_WAKE_WORD="$CORE_KIOSK_WAKE_WORD"
|
||||||
|
THINCLIENT_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
||||||
|
HA_URL="$CORE_HA_URL"
|
||||||
|
DIGEST_WEB_URL="$CORE_DIGEST_WEB_URL"
|
||||||
|
ADMIN_WEB_URL="$CORE_ADMIN_WEB_URL"
|
||||||
|
GALLERY_SMB_HOST="$CORE_GALLERY_SMB_HOST"
|
||||||
|
ENABLE_STEAM_LINK="$CORE_KIOSK_ENABLE_STEAM_LINK"
|
||||||
|
ENABLE_GESTURE_CONTROL="$CORE_KIOSK_ENABLE_GESTURE_CONTROL"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Paths — this script now lives in tools/, so the host directory it drives is
|
||||||
|
# addressed from the repo root rather than relative to the script.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
THIN_CLIENT_DIR="${CORE_REPO_ROOT}/hosts/thin-client"
|
||||||
CONFIGS_DIR="${THIN_CLIENT_DIR}/configs"
|
CONFIGS_DIR="${THIN_CLIENT_DIR}/configs"
|
||||||
AGENT_DIR="${THIN_CLIENT_DIR}/agent"
|
AGENT_DIR="${THIN_CLIENT_DIR}/agent"
|
||||||
LIVE_BUILD_DIR="${THIN_CLIENT_DIR}/live-build"
|
LIVE_BUILD_DIR="${THIN_CLIENT_DIR}/live-build"
|
||||||
|
|
@ -134,23 +109,8 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then
|
|
||||||
echo "Warning: MQTT_BROKER_HOST is still the placeholder IP."
|
|
||||||
echo " Edit it at the top of this script to your container host's real LAN address,"
|
|
||||||
echo " or the thin client won't show up as Home Assistant entities."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$DIGEST_WEB_URL" == "http://192.168.1.10:8081" ]]; then
|
|
||||||
echo "Warning: DIGEST_WEB_URL is still the placeholder."
|
|
||||||
echo " Fill it in once Phase 12's digest-web service is deployed. The image builds"
|
|
||||||
echo " and boots fine without it — the digest workspace just won't load anything."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$ADMIN_WEB_URL" == "http://192.168.1.10:8094" ]]; then
|
|
||||||
echo "Warning: ADMIN_WEB_URL is still the placeholder."
|
|
||||||
echo " Fill it in once Phase 13's admin-web service is deployed. The image builds"
|
|
||||||
echo " and boots fine without it — 'Show admin canvas' just won't load anything."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
|
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
|
||||||
echo "Note: ENABLE_VOICE_SATELLITE=true — this image is for a MIC-ENABLED room"
|
echo "Note: ENABLE_VOICE_SATELLITE=true — this image is for a MIC-ENABLED room"
|
||||||
|
|
@ -298,8 +258,8 @@ fi
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo "--- Writing /etc/thinclient-agent/config.env into includes.chroot ---"
|
echo "--- Writing /etc/thinclient-agent/config.env into includes.chroot ---"
|
||||||
cat > "$INCLUDES/etc/thinclient-agent/config.env" <<EOF
|
cat > "$INCLUDES/etc/thinclient-agent/config.env" <<EOF
|
||||||
# Generated by hosts/thin-client/scripts/build-thin-client-iso.sh — do not hand-edit
|
# Generated by tools/build-thin-client-iso.sh — do not hand-edit
|
||||||
# here; edit the CONFIGURATION block in that script and rebuild.
|
# here; change CoreSystemConfig.json at the repo root and rebuild.
|
||||||
KIOSK_USERNAME=${KIOSK_USERNAME}
|
KIOSK_USERNAME=${KIOSK_USERNAME}
|
||||||
THINCLIENT_NAME=${THINCLIENT_NAME}
|
THINCLIENT_NAME=${THINCLIENT_NAME}
|
||||||
|
|
||||||
|
|
@ -368,6 +328,9 @@ lb build
|
||||||
|
|
||||||
ISO_PATH="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name '*.iso' -print -quit)"
|
ISO_PATH="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name '*.iso' -print -quit)"
|
||||||
ISO_PATH="${ISO_PATH:-${LIVE_BUILD_DIR}/live-image-amd64.hybrid.iso}"
|
ISO_PATH="${ISO_PATH:-${LIVE_BUILD_DIR}/live-image-amd64.hybrid.iso}"
|
||||||
|
# Published under this kiosk's own hostname — live-build reuses one filename per host
|
||||||
|
# tree, so two of the same kiosk type would otherwise overwrite each other.
|
||||||
|
ISO_PATH="$(core_publish_image "$ISO_PATH" "thin-client" "$IMAGE_HOSTNAME")"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Done ==="
|
echo "=== Done ==="
|
||||||
|
|
@ -452,7 +415,7 @@ fi
|
||||||
echo " 9. Idle photo slideshow: create /etc/thinclient-agent/gallery-credentials on the"
|
echo " 9. Idle photo slideshow: create /etc/thinclient-agent/gallery-credentials on the"
|
||||||
echo " booted machine (chmod 600) from the .example next to it, matching whatever"
|
echo " booted machine (chmod 600) from the .example next to it, matching whatever"
|
||||||
echo " GALLERY_SMB_USERNAME/GALLERY_SMB_PASSWORD you set in"
|
echo " GALLERY_SMB_USERNAME/GALLERY_SMB_PASSWORD you set in"
|
||||||
echo " hosts/container-host/scripts/setup-container-host.sh. Until that file exists,"
|
echo " tools/setup-container-host.sh. Until that file exists,"
|
||||||
echo " idle timeout just blanks the panel — the old, pre-slideshow behaviour."
|
echo " idle timeout just blanks the panel — the old, pre-slideshow behaviour."
|
||||||
echo
|
echo
|
||||||
echo "Then pull the power on the container host and re-check: the kiosk session must"
|
echo "Then pull the power on the container host and re-check: the kiosk session must"
|
||||||
|
|
@ -23,41 +23,54 @@
|
||||||
# generated tree that actually gets baked into the image). Never hand-edit anything
|
# generated tree that actually gets baked into the image). Never hand-edit anything
|
||||||
# under includes.chroot — it is wiped and regenerated on every run.
|
# under includes.chroot — it is wiped and regenerated on every run.
|
||||||
#
|
#
|
||||||
# Run as: sudo ./build-touch-panel-iso.sh
|
# Run as: sudo -E tools/build-touch-panel-iso.sh [hostname]
|
||||||
#
|
#
|
||||||
# EDIT THE VARIABLES BELOW BEFORE RUNNING.
|
# Configuration comes from CoreSystemConfig.json — see tools/README.md.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CONFIGURATION — edit these before running
|
# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
|
||||||
# ---------------------------------------------------------------------------
|
#
|
||||||
DEBIAN_RELEASE="bookworm" # Matches the container host's OS
|
# There is nothing to edit here any more. Every value below is read from the one
|
||||||
KIOSK_USERNAME="kiosk" # The autologin account the whole image is built around
|
# config at the repo root, so an address or token can only be wrong in a single
|
||||||
IMAGE_HOSTNAME="touch-panel" # Hostname baked into the image
|
# place. Change it there and rebuild; see tools/README.md.
|
||||||
TOUCHPANEL_NAME="Kitchen touch panel" # Friendly name shown on the HA device
|
#
|
||||||
|
# sudo -E tools/build-touch-panel-iso.sh # the only touch-panel in the config
|
||||||
KEYBOARD_LAYOUT="de" # xkb layout name (`localectl list-x11-keymap-layouts`)
|
# sudo -E tools/build-touch-panel-iso.sh <hostname> # a specific one, if several are defined
|
||||||
|
#
|
||||||
ENABLE_INSTALLER="false" # "true" adds a debian-installer to the ISO (install to disk)
|
# The build refuses to start if the config is invalid (validate-config.py runs first),
|
||||||
|
# so a typo costs seconds rather than a 40-minute build and a reboot.
|
||||||
# --- Where the touch panel talks to -----------------------------------------
|
|
||||||
MQTT_BROKER_HOST="192.168.1.10" # <-- EDIT: container-host IP running Mosquitto
|
|
||||||
MQTT_BROKER_PORT="1883"
|
|
||||||
MQTT_USERNAME="" # Leave empty while Mosquitto runs allow_anonymous
|
|
||||||
MQTT_PASSWORD="" # Never commit a real value here
|
|
||||||
HA_URL="http://192.168.1.10:8123" # <-- EDIT: Home Assistant URL — the "Home" workspace
|
|
||||||
|
|
||||||
# Optional: an SSH public key to bake into the kiosk account for out-of-band admin.
|
|
||||||
# The image ships with password auth disabled and no wayvnc (see README's scope note),
|
|
||||||
# so without this the only admin path is the local console.
|
|
||||||
SSH_AUTHORIZED_KEY=""
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Paths
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
TOUCH_PANEL_DIR="$(dirname "$SCRIPT_DIR")"
|
# shellcheck source=lib/coreconfig.sh
|
||||||
|
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||||
|
|
||||||
|
core_select_kiosk "touch-panel" "${1:-}"
|
||||||
|
|
||||||
|
# Mapped onto this script's existing variable names, so everything below is unchanged
|
||||||
|
# from when these were hand-edited constants.
|
||||||
|
DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE"
|
||||||
|
KIOSK_USERNAME="$CORE_KIOSK_USERNAME"
|
||||||
|
IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME"
|
||||||
|
KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT"
|
||||||
|
ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER"
|
||||||
|
MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST"
|
||||||
|
MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT"
|
||||||
|
MQTT_USERNAME="$CORE_MQTT_USERNAME"
|
||||||
|
MQTT_PASSWORD="$CORE_MQTT_PASSWORD"
|
||||||
|
SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY"
|
||||||
|
ENABLE_VOICE_SATELLITE="$CORE_KIOSK_VOICE_SATELLITE"
|
||||||
|
VOICE_SATELLITE_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
||||||
|
VOICE_WAKE_WORD="$CORE_KIOSK_WAKE_WORD"
|
||||||
|
TOUCHPANEL_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
||||||
|
HA_URL="$CORE_HA_URL"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Paths — this script now lives in tools/, so the host directory it drives is
|
||||||
|
# addressed from the repo root rather than relative to the script.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
TOUCH_PANEL_DIR="${CORE_REPO_ROOT}/hosts/touch-panel"
|
||||||
CONFIGS_DIR="${TOUCH_PANEL_DIR}/configs"
|
CONFIGS_DIR="${TOUCH_PANEL_DIR}/configs"
|
||||||
AGENT_DIR="${TOUCH_PANEL_DIR}/agent"
|
AGENT_DIR="${TOUCH_PANEL_DIR}/agent"
|
||||||
LIVE_BUILD_DIR="${TOUCH_PANEL_DIR}/live-build"
|
LIVE_BUILD_DIR="${TOUCH_PANEL_DIR}/live-build"
|
||||||
|
|
@ -96,17 +109,7 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then
|
|
||||||
echo "Warning: MQTT_BROKER_HOST is still the placeholder IP."
|
|
||||||
echo " Edit it at the top of this script to your container host's real LAN address,"
|
|
||||||
echo " or the touch panel won't show up as Home Assistant entities."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$HA_URL" == "http://192.168.1.10:8123" ]]; then
|
|
||||||
echo "Warning: HA_URL is still the placeholder."
|
|
||||||
echo " The image builds and boots fine, but the Home workspace will show a"
|
|
||||||
echo " connection error until this points at a real Home Assistant."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Smart Home Touch-Panel ISO Builder ==="
|
echo "=== Smart Home Touch-Panel ISO Builder ==="
|
||||||
|
|
@ -199,8 +202,8 @@ fi
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo "--- Writing /etc/touchpanel-agent/config.env into includes.chroot ---"
|
echo "--- Writing /etc/touchpanel-agent/config.env into includes.chroot ---"
|
||||||
cat > "$INCLUDES/etc/touchpanel-agent/config.env" <<EOF
|
cat > "$INCLUDES/etc/touchpanel-agent/config.env" <<EOF
|
||||||
# Generated by hosts/touch-panel/scripts/build-touch-panel-iso.sh — do not hand-edit
|
# Generated by tools/build-touch-panel-iso.sh — do not hand-edit
|
||||||
# here; edit the CONFIGURATION block in that script and rebuild.
|
# here; change CoreSystemConfig.json at the repo root and rebuild.
|
||||||
KIOSK_USERNAME=${KIOSK_USERNAME}
|
KIOSK_USERNAME=${KIOSK_USERNAME}
|
||||||
TOUCHPANEL_NAME=${TOUCHPANEL_NAME}
|
TOUCHPANEL_NAME=${TOUCHPANEL_NAME}
|
||||||
|
|
||||||
|
|
@ -252,6 +255,9 @@ lb build
|
||||||
|
|
||||||
ISO_PATH="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name '*.iso' -print -quit)"
|
ISO_PATH="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name '*.iso' -print -quit)"
|
||||||
ISO_PATH="${ISO_PATH:-${LIVE_BUILD_DIR}/live-image-amd64.hybrid.iso}"
|
ISO_PATH="${ISO_PATH:-${LIVE_BUILD_DIR}/live-image-amd64.hybrid.iso}"
|
||||||
|
# Published under this kiosk's own hostname — live-build reuses one filename per host
|
||||||
|
# tree, so two of the same kiosk type would otherwise overwrite each other.
|
||||||
|
ISO_PATH="$(core_publish_image "$ISO_PATH" "touch-panel" "$IMAGE_HOSTNAME")"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Done ==="
|
echo "=== Done ==="
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Turn CoreSystemConfig.json into shell variable assignments, deriving everything
|
||||||
|
derivable along the way. Consumed by tools/lib/coreconfig.sh via `eval`.
|
||||||
|
|
||||||
|
THIS IS WHERE THE TWINNING ACTUALLY HAPPENS. The config states two numbers — the
|
||||||
|
container host's last octet and the LLM host's — and this script computes every
|
||||||
|
address and every service URL from them. So the container host's OLLAMA_HOST is the
|
||||||
|
LLM host's address *by construction*: change `llm_host.ip_last_octet` from 13 to 21 and
|
||||||
|
the container-host ISO's Ollama URL follows on the next build, with nothing to keep in
|
||||||
|
sync by hand and nothing that can drift.
|
||||||
|
|
||||||
|
The same applies to every kiosk: a door panel's IDENTITY_URL is
|
||||||
|
`http://<container_host_ip>:<ports.identity>`, computed here, never typed anywhere.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
config-export.py <config.json> # core values only
|
||||||
|
config-export.py <config.json> --kiosk <hostname> # + that kiosk's values
|
||||||
|
config-export.py <config.json> --audio-endpoint <hostname> # + that endpoint's values
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def emit(name: str, value) -> None:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
value = "true" if value else "false"
|
||||||
|
print(f"{name}={shlex.quote(str(value))}")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if len(argv) < 2:
|
||||||
|
print("usage: config-export.py <config.json> [--kiosk <hostname>]", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
cfg = json.loads(Path(argv[1]).read_text())
|
||||||
|
kiosk_hostname = None
|
||||||
|
if "--kiosk" in argv:
|
||||||
|
kiosk_hostname = argv[argv.index("--kiosk") + 1]
|
||||||
|
audio_hostname = None
|
||||||
|
if "--audio-endpoint" in argv:
|
||||||
|
audio_hostname = argv[argv.index("--audio-endpoint") + 1]
|
||||||
|
|
||||||
|
prefix = cfg["network"]["subnet_prefix"]
|
||||||
|
ports = {k: v for k, v in cfg["ports"].items() if not k.startswith("_")}
|
||||||
|
|
||||||
|
container_ip = f"{prefix}.{cfg['container_host']['ip_last_octet']}"
|
||||||
|
llm_ip = f"{prefix}.{cfg['llm_host']['ip_last_octet']}"
|
||||||
|
gateway = f"{prefix}.{cfg['network']['gateway_last_octet']}"
|
||||||
|
|
||||||
|
# --- Household / image basics ---
|
||||||
|
emit("CORE_TIMEZONE", cfg["household"]["timezone"])
|
||||||
|
emit("CORE_LOCALE", cfg["household"]["locale"])
|
||||||
|
emit("CORE_KEYBOARD_LAYOUT", cfg["household"]["keyboard_layout"])
|
||||||
|
emit("CORE_DEBIAN_RELEASE", cfg["household"]["debian_release"])
|
||||||
|
|
||||||
|
# --- Network ---
|
||||||
|
emit("CORE_SUBNET_PREFIX", prefix)
|
||||||
|
emit("CORE_NETMASK", cfg["network"]["netmask"])
|
||||||
|
emit("CORE_GATEWAY", gateway)
|
||||||
|
emit("CORE_DNS_SERVERS", " ".join(cfg["network"]["dns_servers"]))
|
||||||
|
emit("CORE_WIFI_SSID", cfg["network"]["wifi"].get("ssid", ""))
|
||||||
|
emit("CORE_WIFI_PSK", cfg["network"]["wifi"].get("psk", ""))
|
||||||
|
|
||||||
|
# --- The two core hosts, each aware of the other. This is the twinning. ---
|
||||||
|
emit("CORE_CONTAINER_HOST_IP", container_ip)
|
||||||
|
emit("CORE_CONTAINER_HOST_NAME", cfg["container_host"]["hostname"])
|
||||||
|
emit("CORE_CONTAINER_HOST_DISK", cfg["container_host"]["install_disk"])
|
||||||
|
emit("CORE_CONTAINER_HOST_USER", cfg["container_host"]["admin_username"])
|
||||||
|
emit("CORE_LLM_HOST_IP", llm_ip)
|
||||||
|
emit("CORE_LLM_HOST_NAME", cfg["llm_host"]["hostname"])
|
||||||
|
emit("CORE_LLM_HOST_DISK", cfg["llm_host"]["install_disk"])
|
||||||
|
emit("CORE_LLM_HOST_USER", cfg["llm_host"]["admin_username"])
|
||||||
|
|
||||||
|
# --- LLM host settings ---
|
||||||
|
llm = cfg["llm_host"]
|
||||||
|
emit("CORE_LLM_TIER", llm["tier"])
|
||||||
|
emit("CORE_LLM_TEXT_MODEL_GPU", llm["text_model_gpu"])
|
||||||
|
emit("CORE_LLM_TEXT_MODEL_CPU", llm["text_model_cpu"])
|
||||||
|
emit("CORE_LLM_VISION_MODEL", llm["vision_model"])
|
||||||
|
emit("CORE_LLM_PULL_VISION_MODEL", llm.get("pull_vision_model", True))
|
||||||
|
emit("CORE_LLM_KEEP_ALIVE", llm["keep_alive"])
|
||||||
|
emit("CORE_LLM_MAX_LOADED_MODELS", llm["max_loaded_models"])
|
||||||
|
emit("CORE_LLM_NUM_PARALLEL", llm["num_parallel"])
|
||||||
|
|
||||||
|
# --- Ports, individually and as derived URLs ---
|
||||||
|
for name, port in sorted(ports.items()):
|
||||||
|
emit(f"CORE_PORT_{name.upper()}", port)
|
||||||
|
|
||||||
|
# DERIVED URLS — the whole reason this file exists. Nothing below is ever written
|
||||||
|
# by hand in a build script or an env file; every one is computed from an address
|
||||||
|
# and a port that each appear exactly once in CoreSystemConfig.json.
|
||||||
|
emit("CORE_HA_URL", f"http://{container_ip}:{ports['home_assistant']}")
|
||||||
|
emit("CORE_MQTT_BROKER_HOST", container_ip)
|
||||||
|
emit("CORE_MQTT_BROKER_PORT", ports["mqtt"])
|
||||||
|
emit("CORE_IDENTITY_URL", f"http://{container_ip}:{ports['identity']}")
|
||||||
|
emit("CORE_IDENTITY_WEB_URL", f"http://{container_ip}:{ports['identity_web']}")
|
||||||
|
emit("CORE_PANTRY_VISION_URL", f"http://{container_ip}:{ports['pantry_vision']}")
|
||||||
|
emit("CORE_PANTRY_WEB_URL", f"http://{container_ip}:{ports['pantry_web']}")
|
||||||
|
emit("CORE_DIGEST_WEB_URL", f"http://{container_ip}:{ports['digest_web']}")
|
||||||
|
emit("CORE_ADMIN_WEB_URL", f"http://{container_ip}:{ports['admin_web']}")
|
||||||
|
emit("CORE_TRANSIT_URL", f"http://{container_ip}:{ports['transit']}")
|
||||||
|
emit("CORE_OTP_URL", f"http://{container_ip}:{ports['otp']}")
|
||||||
|
emit("CORE_NTFY_URL", f"http://{container_ip}:{ports['ntfy']}")
|
||||||
|
emit("CORE_GALLERY_SMB_HOST", container_ip)
|
||||||
|
emit("CORE_FRIGATE_URL", f"http://{container_ip}:{ports['frigate']}")
|
||||||
|
emit("CORE_GROCY_URL", f"http://{container_ip}:{ports['grocy']}")
|
||||||
|
# The one that points the OTHER way — the container host's services reaching the
|
||||||
|
# LLM host. Derived from llm_host.ip_last_octet, so the pair can never disagree.
|
||||||
|
emit("CORE_OLLAMA_HOST", f"http://{llm_ip}:{ports['ollama']}")
|
||||||
|
|
||||||
|
# --- Secrets ---
|
||||||
|
secrets = cfg.get("secrets", {})
|
||||||
|
for key in ("identity_token", "pantry_vision_token", "transit_token", "mqtt_username",
|
||||||
|
"mqtt_password", "ha_token", "ssh_authorized_key", "kiosk_password",
|
||||||
|
"admin_password_hash"):
|
||||||
|
emit(f"CORE_{key.upper()}", secrets.get(key, ""))
|
||||||
|
|
||||||
|
# --- Enable flags ---
|
||||||
|
for flag, value in (cfg.get("container_host", {}).get("enable", {}) or {}).items():
|
||||||
|
if not flag.startswith("_"):
|
||||||
|
emit(f"CORE_ENABLE_{flag.upper()}", value)
|
||||||
|
|
||||||
|
emit("CORE_VOICE_WAKE_WORD", cfg.get("voice", {}).get("wake_word", "ok_nabu"))
|
||||||
|
emit("CORE_BUILD_OUTPUT_DIR", cfg.get("build", {}).get("output_dir", "iso-out"))
|
||||||
|
emit("CORE_ARM64_PREBAKE", cfg.get("build", {}).get("arm64_prebake", True))
|
||||||
|
|
||||||
|
# --- The selected kiosk, if one was asked for ---
|
||||||
|
if kiosk_hostname:
|
||||||
|
matches = [k for k in cfg.get("kiosks", []) if k.get("hostname") == kiosk_hostname]
|
||||||
|
if not matches:
|
||||||
|
available = ", ".join(k.get("hostname", "?") for k in cfg.get("kiosks", []))
|
||||||
|
print(f"echo 'error: no kiosk with hostname {kiosk_hostname!r} in the config "
|
||||||
|
f"(have: {available})' >&2; return 1 2>/dev/null || exit 1")
|
||||||
|
return 1
|
||||||
|
kiosk = matches[0]
|
||||||
|
emit("CORE_KIOSK_TYPE", kiosk["type"])
|
||||||
|
emit("CORE_KIOSK_HOSTNAME", kiosk["hostname"])
|
||||||
|
emit("CORE_KIOSK_FRIENDLY_NAME", kiosk["friendly_name"])
|
||||||
|
emit("CORE_KIOSK_USERNAME", kiosk["kiosk_username"])
|
||||||
|
emit("CORE_KIOSK_VOICE_SATELLITE", kiosk.get("voice_satellite", False))
|
||||||
|
emit("CORE_KIOSK_ENABLE_INSTALLER", kiosk.get("enable_installer", False))
|
||||||
|
emit("CORE_KIOSK_ENABLE_STEAM_LINK", kiosk.get("enable_steam_link", False))
|
||||||
|
emit("CORE_KIOSK_ENABLE_GESTURE_CONTROL", kiosk.get("enable_gesture_control", False))
|
||||||
|
# A per-kiosk wake word overrides the household default.
|
||||||
|
emit("CORE_KIOSK_WAKE_WORD", kiosk.get("wake_word", cfg.get("voice", {}).get("wake_word", "ok_nabu")))
|
||||||
|
|
||||||
|
if audio_hostname:
|
||||||
|
matches = [a for a in cfg.get("audio_endpoints", []) if a.get("hostname") == audio_hostname]
|
||||||
|
if not matches:
|
||||||
|
available = ", ".join(a.get("hostname", "?") for a in cfg.get("audio_endpoints", []))
|
||||||
|
print(f"echo 'error: no audio endpoint with hostname {audio_hostname!r} in the config "
|
||||||
|
f"(have: {available})' >&2; return 1 2>/dev/null || exit 1")
|
||||||
|
return 1
|
||||||
|
endpoint = matches[0]
|
||||||
|
emit("CORE_AUDIO_HOSTNAME", endpoint["hostname"])
|
||||||
|
emit("CORE_AUDIO_FRIENDLY_NAME", endpoint["friendly_name"])
|
||||||
|
emit("CORE_AUDIO_ARCH", endpoint["arch"])
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv))
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
# shellcheck shell=bash
|
||||||
|
#
|
||||||
|
# Shared config loader for every build script in tools/. Source it, call
|
||||||
|
# `core_load`, and every CORE_* variable is in scope.
|
||||||
|
#
|
||||||
|
# source "$(dirname "${BASH_SOURCE[0]}")/lib/coreconfig.sh"
|
||||||
|
# core_load # core values only
|
||||||
|
# core_load --kiosk door-panel # + that kiosk's own values
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS: before it, every builder carried its own copy of the container
|
||||||
|
# host's IP, the MQTT port, the identity token and half a dozen URLs. Six scripts, six
|
||||||
|
# chances to typo one of them, and the symptom was always the same — a kiosk that
|
||||||
|
# boots fine and then can't reach something, discovered after a 40-minute ISO build.
|
||||||
|
# Now no build script contains an address at all; they all read one file, and
|
||||||
|
# validate-config.py has already refused the build if that file is wrong.
|
||||||
|
#
|
||||||
|
# Nothing here is exported to child processes on purpose: build scripts substitute
|
||||||
|
# these into generated files explicitly, so it's always visible in the script which
|
||||||
|
# value went where.
|
||||||
|
|
||||||
|
CORE_REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
CORE_CONFIG_PATH="${CORE_CONFIG_PATH:-${CORE_REPO_ROOT}/CoreSystemConfig.json}"
|
||||||
|
CORE_TOOLS_DIR="${CORE_REPO_ROOT}/tools"
|
||||||
|
|
||||||
|
core_die() { echo -e "\033[1;31m[error]\033[0m $*" >&2; exit 1; }
|
||||||
|
core_log() { echo -e "\n\033[1;34m==>\033[0m $*"; }
|
||||||
|
core_warn() { echo -e "\033[1;33m[warn]\033[0m $*" >&2; }
|
||||||
|
|
||||||
|
core_load() {
|
||||||
|
if [[ ! -f "$CORE_CONFIG_PATH" ]]; then
|
||||||
|
core_die "No CoreSystemConfig.json found at $CORE_CONFIG_PATH
|
||||||
|
|
||||||
|
Create one from the template and fill it in:
|
||||||
|
cp ${CORE_REPO_ROOT}/CoreSystemConfig.json.template ${CORE_REPO_ROOT}/CoreSystemConfig.json
|
||||||
|
\$EDITOR ${CORE_REPO_ROOT}/CoreSystemConfig.json
|
||||||
|
|
||||||
|
Then check it with:
|
||||||
|
${CORE_TOOLS_DIR}/validate-config.py
|
||||||
|
|
||||||
|
(Set CORE_CONFIG_PATH to build from a different file — useful for a second
|
||||||
|
household or a test config.)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v python3 >/dev/null 2>&1 || core_die "python3 is required to read CoreSystemConfig.json"
|
||||||
|
|
||||||
|
# VALIDATE BEFORE ANYTHING ELSE. An ISO build is long and mostly unattended; the
|
||||||
|
# entire value of the validator evaporates if it runs after 40 minutes of debootstrap
|
||||||
|
# rather than before it. Warnings print but don't stop the build.
|
||||||
|
core_log "Validating $(basename "$CORE_CONFIG_PATH")"
|
||||||
|
if ! python3 "${CORE_TOOLS_DIR}/validate-config.py" "$CORE_CONFIG_PATH"; then
|
||||||
|
core_die "CoreSystemConfig.json has errors (above) — fix them before building.
|
||||||
|
Nothing was built and nothing was written."
|
||||||
|
fi
|
||||||
|
|
||||||
|
local exported
|
||||||
|
if ! exported="$(python3 "${CORE_TOOLS_DIR}/config-export.py" "$CORE_CONFIG_PATH" "$@")"; then
|
||||||
|
# config-export.py emits a shell `echo ... >&2; exit 1` on an unknown kiosk, so
|
||||||
|
# evaluating its output is what surfaces that message.
|
||||||
|
eval "$exported"
|
||||||
|
core_die "Could not read $CORE_CONFIG_PATH"
|
||||||
|
fi
|
||||||
|
# Every value is shlex.quote'd on the Python side, so a password containing quotes,
|
||||||
|
# spaces or semicolons survives this intact rather than becoming shell syntax.
|
||||||
|
eval "$exported"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load the one kiosk this builder is for.
|
||||||
|
# core_select_kiosk door-panel # the only door-panel in the config
|
||||||
|
# core_select_kiosk thin-client living # a specific one, when there are several
|
||||||
|
#
|
||||||
|
# Taking the hostname as an argument (rather than a per-script constant) is what lets
|
||||||
|
# one builder produce several images: two thin clients in two rooms are two entries in
|
||||||
|
# CoreSystemConfig.json, not two copies of a script with one line changed — which is
|
||||||
|
# how the addresses drifted apart in the first place.
|
||||||
|
core_select_kiosk() {
|
||||||
|
local want_type="$1" want_host="${2:-}"
|
||||||
|
local matches
|
||||||
|
matches="$(python3 - "$CORE_CONFIG_PATH" "$want_type" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
cfg = json.load(open(sys.argv[1]))
|
||||||
|
print("\n".join(k["hostname"] for k in cfg.get("kiosks", []) if k.get("type") == sys.argv[2]))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -z "$matches" ]]; then
|
||||||
|
core_die "No kiosk of type '${want_type}' in $(basename "$CORE_CONFIG_PATH").
|
||||||
|
Add one to the \"kiosks\" list and re-run."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$want_host" ]]; then
|
||||||
|
if [[ "$(wc -l <<< "$matches")" -gt 1 ]]; then
|
||||||
|
core_die "Several '${want_type}' kiosks are configured — say which one:
|
||||||
|
$(sed 's/^/ /' <<< "$matches")
|
||||||
|
|
||||||
|
e.g. $0 $(head -1 <<< "$matches")"
|
||||||
|
fi
|
||||||
|
want_host="$matches"
|
||||||
|
elif ! grep -qx "$want_host" <<< "$matches"; then
|
||||||
|
core_die "'${want_host}' is not a configured ${want_type}. Available:
|
||||||
|
$(sed 's/^/ /' <<< "$matches")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
core_load --kiosk "$want_host"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Same idea as core_select_kiosk, for the headless audio endpoints — selected by
|
||||||
|
# architecture, since those are two genuinely different builders (live-build for the
|
||||||
|
# amd64 mini-PC, rpi-image-gen for the arm64 Pi) rather than one with a flag.
|
||||||
|
core_select_audio_endpoint() {
|
||||||
|
local want_arch="$1" want_host="${2:-}"
|
||||||
|
local matches
|
||||||
|
matches="$(python3 - "$CORE_CONFIG_PATH" "$want_arch" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
cfg = json.load(open(sys.argv[1]))
|
||||||
|
print("\n".join(a["hostname"] for a in cfg.get("audio_endpoints", []) if a.get("arch") == sys.argv[2]))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -z "$matches" ]]; then
|
||||||
|
core_die "No ${want_arch} audio endpoint in $(basename "$CORE_CONFIG_PATH").
|
||||||
|
Add one to the \"audio_endpoints\" list and re-run."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$want_host" ]]; then
|
||||||
|
if [[ "$(wc -l <<< "$matches")" -gt 1 ]]; then
|
||||||
|
core_die "Several ${want_arch} audio endpoints are configured — say which one:
|
||||||
|
$(sed 's/^/ /' <<< "$matches")"
|
||||||
|
fi
|
||||||
|
want_host="$matches"
|
||||||
|
elif ! grep -qx "$want_host" <<< "$matches"; then
|
||||||
|
core_die "'${want_host}' is not a configured ${want_arch} audio endpoint. Available:
|
||||||
|
$(sed 's/^/ /' <<< "$matches")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
core_load --audio-endpoint "$want_host"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deterministic identifier for a matched set of images, printed at build time and
|
||||||
|
# written into every image as /etc/smarthome-build. Two ISOs built from the same
|
||||||
|
# config carry the same PAIR_ID; if you ever end up holding a container-host ISO and
|
||||||
|
# an llm-host ISO and can't remember whether they agree on addresses and tokens,
|
||||||
|
# compare this and you know.
|
||||||
|
core_pair_id() {
|
||||||
|
local hash
|
||||||
|
hash="$(python3 - "$CORE_CONFIG_PATH" <<'PY'
|
||||||
|
import hashlib, json, sys
|
||||||
|
# Hash the SEMANTIC content, not the bytes: reformatting the JSON or reordering keys
|
||||||
|
# must not change the pair ID, because it didn't change what gets built.
|
||||||
|
cfg = json.loads(open(sys.argv[1]).read())
|
||||||
|
def strip(node):
|
||||||
|
if isinstance(node, dict):
|
||||||
|
return {k: strip(v) for k, v in sorted(node.items()) if not k.startswith("_")}
|
||||||
|
if isinstance(node, list):
|
||||||
|
return [strip(v) for v in node]
|
||||||
|
return node
|
||||||
|
print(hashlib.sha256(json.dumps(strip(cfg), sort_keys=True).encode()).hexdigest()[:12])
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
echo "$hash"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Written into every image so a booted machine can say what it was built from.
|
||||||
|
core_write_build_stamp() {
|
||||||
|
local dest="$1" role="$2"
|
||||||
|
mkdir -p "$(dirname "$dest")"
|
||||||
|
cat > "$dest" <<EOF
|
||||||
|
# Generated by tools/ at image build time — see tools/README.md
|
||||||
|
SMARTHOME_ROLE=${role}
|
||||||
|
SMARTHOME_PAIR_ID=$(core_pair_id)
|
||||||
|
SMARTHOME_BUILT_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
SMARTHOME_CONTAINER_HOST=${CORE_CONTAINER_HOST_IP}
|
||||||
|
SMARTHOME_LLM_HOST=${CORE_LLM_HOST_IP}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Move a finished image out of its live-build tree into iso-out/, named for the
|
||||||
|
# thing it actually is.
|
||||||
|
#
|
||||||
|
# THIS IS LOAD-BEARING FOR MULTI-INSTANCE BUILDS, not cosmetic. live-build always
|
||||||
|
# writes the same filename (live-image-amd64.hybrid.iso) into the same per-host tree,
|
||||||
|
# so building two thin clients — a living-room one and a bedroom one — would have the
|
||||||
|
# second silently overwrite the first, leaving one ISO carrying the second room's
|
||||||
|
# hostname and no indication the first was ever lost. Publishing under
|
||||||
|
# <role>-<hostname>-<pairid> makes each config entry produce its own artifact.
|
||||||
|
core_publish_image() {
|
||||||
|
local src="$1" role="$2" instance="${3:-}"
|
||||||
|
local out_dir="${CORE_REPO_ROOT}/${CORE_BUILD_OUTPUT_DIR}"
|
||||||
|
mkdir -p "$out_dir"
|
||||||
|
|
||||||
|
# Hostnames usually already carry the role ("thin-client-bedroom"), so appending the
|
||||||
|
# role verbatim gives "thin-client-thin-client-bedroom". Use the hostname alone when
|
||||||
|
# it already starts with the role.
|
||||||
|
local name="smarthome-${role}"
|
||||||
|
if [[ -n "$instance" ]]; then
|
||||||
|
if [[ "$instance" == "$role"* ]]; then
|
||||||
|
name="smarthome-${instance}"
|
||||||
|
else
|
||||||
|
name="smarthome-${role}-${instance}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
local dest="${out_dir}/${name}-$(core_pair_id).${src##*.}"
|
||||||
|
|
||||||
|
if [[ ! -f "$src" ]]; then
|
||||||
|
core_warn "Expected an image at ${src} but found none — nothing published."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
mv "$src" "$dest"
|
||||||
|
core_log "Image: ${dest}"
|
||||||
|
echo "$dest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Guard for the ISO builders: `lb build` needs root, and finding that out after the
|
||||||
|
# config phase has already written files is worse than finding out now.
|
||||||
|
core_require_root() {
|
||||||
|
[[ $EUID -eq 0 ]] || core_die "This needs root (lb build does). Re-run with: sudo -E $0 $*"
|
||||||
|
}
|
||||||
|
|
@ -55,19 +55,25 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CONFIGURATION — edit these before running
|
# CONFIGURATION
|
||||||
|
#
|
||||||
|
# Every value here is `${VAR:-default}`, so anything already in the environment wins.
|
||||||
|
# That is how the ISO built by tools/build-container-host-iso.sh configures this
|
||||||
|
# script without editing it: the first-boot unit sources a generated env file derived
|
||||||
|
# from CoreSystemConfig.json, and these defaults apply only to a hand-run install.
|
||||||
|
# Edit them directly ONLY if you are running this script standalone.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
BASE_DIR="/opt/smart-home" # Where all container config/data will live
|
BASE_DIR="${BASE_DIR:-/opt/smart-home}" # Where all container config/data will live
|
||||||
TIMEZONE="Europe/Vienna" # Adjust to your timezone
|
TIMEZONE="${TIMEZONE:-Europe/Vienna}" # Adjust to your timezone
|
||||||
ENABLE_MEALIE="false" # Set to "true" to also deploy Mealie
|
ENABLE_MEALIE="${ENABLE_MEALIE:-false}" # Set to "true" to also deploy Mealie
|
||||||
ENABLE_INTEL_HWACCEL="false" # Set to "true" if this host has an Intel iGPU for Frigate
|
ENABLE_INTEL_HWACCEL="${ENABLE_INTEL_HWACCEL:-false}" # Set to "true" if this host has an Intel iGPU for Frigate
|
||||||
|
|
||||||
# --- Phase 1/9 add-ons — on by default, set to "false" to skip any of them ---
|
# --- Phase 1/9 add-ons — on by default, set to "false" to skip any of them ---
|
||||||
ENABLE_NODERED="true"
|
ENABLE_NODERED="${ENABLE_NODERED:-true}"
|
||||||
ENABLE_NETDATA="true"
|
ENABLE_NETDATA="${ENABLE_NETDATA:-true}"
|
||||||
ENABLE_HOMEPAGE="true"
|
ENABLE_HOMEPAGE="${ENABLE_HOMEPAGE:-true}"
|
||||||
ENABLE_NTFY="true"
|
ENABLE_NTFY="${ENABLE_NTFY:-true}"
|
||||||
ENABLE_PORTAINER="true"
|
ENABLE_PORTAINER="${ENABLE_PORTAINER:-true}"
|
||||||
|
|
||||||
# --- Gallery SMB share — off by default until a password is chosen ----------
|
# --- Gallery SMB share — off by default until a password is chosen ----------
|
||||||
# Serves $BASE_DIR/gallery read-only over SMB so idle thin clients cycle through photos
|
# Serves $BASE_DIR/gallery read-only over SMB so idle thin clients cycle through photos
|
||||||
|
|
@ -75,43 +81,43 @@ ENABLE_PORTAINER="true"
|
||||||
# auto-generated: the identical value has to be typed into
|
# auto-generated: the identical value has to be typed into
|
||||||
# /etc/thinclient-agent/gallery-credentials on every thin client, so a secret only this
|
# /etc/thinclient-agent/gallery-credentials on every thin client, so a secret only this
|
||||||
# script ever saw would be a secret the other end cannot have. Pick one yourself.
|
# script ever saw would be a secret the other end cannot have. Pick one yourself.
|
||||||
ENABLE_GALLERY_SMB="false"
|
ENABLE_GALLERY_SMB="${ENABLE_GALLERY_SMB:-false}"
|
||||||
GALLERY_SMB_USERNAME="gallery"
|
GALLERY_SMB_USERNAME="${GALLERY_SMB_USERNAME:-gallery}"
|
||||||
GALLERY_SMB_PASSWORD="" # <-- SET THIS before flipping the toggle above
|
GALLERY_SMB_PASSWORD="${GALLERY_SMB_PASSWORD:-}" # <-- SET THIS before flipping the toggle above
|
||||||
|
|
||||||
# --- Scheduled backups (restic) — off by default until you pick a target ---
|
# --- Scheduled backups (restic) — off by default until you pick a target ---
|
||||||
# Set ENABLE_BACKUPS=true and RESTIC_REPOSITORY to a local path (e.g. an
|
# Set ENABLE_BACKUPS=true and RESTIC_REPOSITORY to a local path (e.g. an
|
||||||
# external/USB drive mount, or a NAS mount), or a remote target restic
|
# external/USB drive mount, or a NAS mount), or a remote target restic
|
||||||
# supports (s3:..., sftp:..., b2:..., rest:...). See https://restic.net
|
# supports (s3:..., sftp:..., b2:..., rest:...). See https://restic.net
|
||||||
ENABLE_BACKUPS="false"
|
ENABLE_BACKUPS="${ENABLE_BACKUPS:-false}"
|
||||||
RESTIC_REPOSITORY="/mnt/backup/smart-home-restic"
|
RESTIC_REPOSITORY="${RESTIC_REPOSITORY:-/mnt/backup/smart-home-restic}"
|
||||||
BACKUP_SCHEDULE="03:30" # systemd OnCalendar time, daily at this local time
|
BACKUP_SCHEDULE="${BACKUP_SCHEDULE:-03:30}" # systemd OnCalendar time, daily at this local time
|
||||||
|
|
||||||
# --- Zigbee adapter: USB (CC2652P/CH340C, e.g. Haozee/Sonoff Dongle-P style) ---
|
# --- Zigbee adapter: USB (CC2652P/CH340C, e.g. Haozee/Sonoff Dongle-P style) ---
|
||||||
# Run `ls -l /dev/serial/by-id/` AFTER plugging the adapter in, and paste the
|
# Run `ls -l /dev/serial/by-id/` AFTER plugging the adapter in, and paste the
|
||||||
# full path it shows here. This is more stable across reboots than /dev/ttyUSB0.
|
# full path it shows here. This is more stable across reboots than /dev/ttyUSB0.
|
||||||
# Example: /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0
|
# Example: /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0
|
||||||
ZIGBEE_USB_DEVICE="/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0"
|
ZIGBEE_USB_DEVICE="${ZIGBEE_USB_DEVICE:-/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0}"
|
||||||
|
|
||||||
# --- Quarter-daily LLM digest (Phase 12) — off by default until credentials
|
# --- Quarter-daily LLM digest (Phase 12) — off by default until credentials
|
||||||
# --- are provisioned. See digest-engine/README.md.
|
# --- are provisioned. See digest-engine/README.md.
|
||||||
ENABLE_DIGEST_ENGINE="false"
|
ENABLE_DIGEST_ENGINE="${ENABLE_DIGEST_ENGINE:-false}"
|
||||||
# WhatsApp ingestion is separately gated and highest-risk of the four message
|
# WhatsApp ingestion is separately gated and highest-risk of the four message
|
||||||
# platforms. Read digest-engine/README.md before setting this to "true" — real
|
# platforms. Read digest-engine/README.md before setting this to "true" — real
|
||||||
# ban risk even with the headful-Chromium mitigation; use a secondary number.
|
# ban risk even with the headful-Chromium mitigation; use a secondary number.
|
||||||
ENABLE_WHATSAPP_INGEST="false"
|
ENABLE_WHATSAPP_INGEST="${ENABLE_WHATSAPP_INGEST:-false}"
|
||||||
# Where this repo's digest-engine/ directory lives on THIS host (build context).
|
# Where this repo's digest-engine/ directory lives on THIS host (build context).
|
||||||
DIGEST_ENGINE_SRC="/opt/smart-home/src/digest-engine"
|
DIGEST_ENGINE_SRC="${DIGEST_ENGINE_SRC:-/opt/smart-home/src/digest-engine}"
|
||||||
DIGEST_WEB_PORT="8091" # LAN-facing read-only static serving
|
DIGEST_WEB_PORT="${DIGEST_WEB_PORT:-8091}" # LAN-facing read-only static serving
|
||||||
DIGEST_SCHEDULE="00,06,12,18" # systemd OnCalendar hours, 4x/day
|
DIGEST_SCHEDULE="${DIGEST_SCHEDULE:-00,06,12,18}" # systemd OnCalendar hours, 4x/day
|
||||||
|
|
||||||
# --- On-demand sys-admin-llm display surface (Phase 13) — off by default until
|
# --- On-demand sys-admin-llm display surface (Phase 13) — off by default until
|
||||||
# --- ADMIN_CANVAS_TOKEN is provisioned. See admin-canvas/README.md.
|
# --- ADMIN_CANVAS_TOKEN is provisioned. See admin-canvas/README.md.
|
||||||
ENABLE_ADMIN_CANVAS="false"
|
ENABLE_ADMIN_CANVAS="${ENABLE_ADMIN_CANVAS:-false}"
|
||||||
# Where this repo's admin-canvas/ directory lives on THIS host (build context).
|
# Where this repo's admin-canvas/ directory lives on THIS host (build context).
|
||||||
ADMIN_CANVAS_SRC="/opt/smart-home/src/admin-canvas"
|
ADMIN_CANVAS_SRC="${ADMIN_CANVAS_SRC:-/opt/smart-home/src/admin-canvas}"
|
||||||
ADMIN_CANVAS_PORT="8092" # internal only — no `ports:` mapping, HA-reachable only
|
ADMIN_CANVAS_PORT="${ADMIN_CANVAS_PORT:-8092}" # internal only — no `ports:` mapping, HA-reachable only
|
||||||
ADMIN_WEB_PORT="8094" # LAN-facing read-only static serving
|
ADMIN_WEB_PORT="${ADMIN_WEB_PORT:-8094}" # LAN-facing read-only static serving
|
||||||
|
|
||||||
# --- Kitchen-display camera cataloguing backend (Phase 17) — off by default until
|
# --- Kitchen-display camera cataloguing backend (Phase 17) — off by default until
|
||||||
# --- PANTRY_VISION_TOKEN and GROCY_API_KEY are provisioned. See pantry-vision/README.md.
|
# --- PANTRY_VISION_TOKEN and GROCY_API_KEY are provisioned. See pantry-vision/README.md.
|
||||||
|
|
@ -120,38 +126,38 @@ ADMIN_WEB_PORT="8094" # LAN-facing read-only static serving
|
||||||
# deliberately unlike ADMIN_CANVAS_PORT above, it DOES get a `ports:` mapping. The
|
# deliberately unlike ADMIN_CANVAS_PORT above, it DOES get a `ports:` mapping. The
|
||||||
# bearer token is the actual boundary here, not network placement — see
|
# bearer token is the actual boundary here, not network placement — see
|
||||||
# pantry-vision/README.md's "A real network listener, unlike admin-canvas" section.
|
# pantry-vision/README.md's "A real network listener, unlike admin-canvas" section.
|
||||||
ENABLE_PANTRY_VISION="false"
|
ENABLE_PANTRY_VISION="${ENABLE_PANTRY_VISION:-false}"
|
||||||
# Where this repo's pantry-vision/ directory lives on THIS host (build context).
|
# Where this repo's pantry-vision/ directory lives on THIS host (build context).
|
||||||
PANTRY_VISION_SRC="/opt/smart-home/src/pantry-vision"
|
PANTRY_VISION_SRC="${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="${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="${PANTRY_WEB_PORT:-8096}" # LAN-facing read-only static serving (the kiosk's frontend)
|
||||||
|
|
||||||
# --- Person <-> BLE-identifier registry (Phase 6) — off by default until
|
# --- Person <-> BLE-identifier registry (Phase 6) — off by default until
|
||||||
# --- IDENTITY_TOKEN, HA_TOKEN, and TRUSTED_ENTITY_PREFIXES are provisioned. See
|
# --- IDENTITY_TOKEN, HA_TOKEN, and TRUSTED_ENTITY_PREFIXES are provisioned. See
|
||||||
# --- identity/README.md. Same "published, unlike admin-canvas" reasoning as
|
# --- identity/README.md. Same "published, unlike admin-canvas" reasoning as
|
||||||
# --- ENABLE_PANTRY_VISION above — hosts/kitchen-display's and hosts/door-panel's
|
# --- ENABLE_PANTRY_VISION above — hosts/kitchen-display's and hosts/door-panel's
|
||||||
# --- kiosk browsers call this directly.
|
# --- kiosk browsers call this directly.
|
||||||
ENABLE_IDENTITY="false"
|
ENABLE_IDENTITY="${ENABLE_IDENTITY:-false}"
|
||||||
# Where this repo's identity/ directory lives on THIS host (build context).
|
# Where this repo's identity/ directory lives on THIS host (build context).
|
||||||
IDENTITY_SRC="/opt/smart-home/src/identity"
|
IDENTITY_SRC="${IDENTITY_SRC:-/opt/smart-home/src/identity}"
|
||||||
IDENTITY_PORT="8097" # LAN-facing — kiosk browsers call this directly
|
IDENTITY_PORT="${IDENTITY_PORT:-8097}" # LAN-facing — kiosk browsers call this directly
|
||||||
IDENTITY_WEB_PORT="8098" # LAN-facing read-only static serving (register.html/dashboard.html)
|
IDENTITY_WEB_PORT="${IDENTITY_WEB_PORT:-8098}" # LAN-facing read-only static serving (register.html/dashboard.html)
|
||||||
|
|
||||||
# --- Trash collection date sync (Phase 19) — off by default until WASTE_ICS_URL and
|
# --- Trash collection date sync (Phase 19) — off by default until WASTE_ICS_URL and
|
||||||
# --- CALDAV_TARGET_CALENDAR are provisioned. See trash-calendar/README.md. A oneshot,
|
# --- CALDAV_TARGET_CALENDAR are provisioned. See trash-calendar/README.md. A oneshot,
|
||||||
# --- like digest-engine, not a listener — no port, nothing to publish.
|
# --- like digest-engine, not a listener — no port, nothing to publish.
|
||||||
ENABLE_TRASH_CALENDAR="false"
|
ENABLE_TRASH_CALENDAR="${ENABLE_TRASH_CALENDAR:-false}"
|
||||||
# Where this repo's trash-calendar/ directory lives on THIS host (build context).
|
# Where this repo's trash-calendar/ directory lives on THIS host (build context).
|
||||||
TRASH_CALENDAR_SRC="/opt/smart-home/src/trash-calendar"
|
TRASH_CALENDAR_SRC="${TRASH_CALENDAR_SRC:-/opt/smart-home/src/trash-calendar}"
|
||||||
|
|
||||||
# --- Public transit "when's the next bus" voice lookup (Phase 19) — off by default
|
# --- Public transit "when's the next bus" voice lookup (Phase 19) — off by default
|
||||||
# --- until TRANSIT_TOKEN and GTFS_FEED_URL are provisioned. See transit/README.md.
|
# --- until TRANSIT_TOKEN and GTFS_FEED_URL are provisioned. See transit/README.md.
|
||||||
# --- Published like pantry-vision/identity — homeassistant's network_mode: host
|
# --- Published like pantry-vision/identity — homeassistant's network_mode: host
|
||||||
# --- can't resolve container DNS names, so its rest_command needs a real port.
|
# --- can't resolve container DNS names, so its rest_command needs a real port.
|
||||||
ENABLE_TRANSIT="false"
|
ENABLE_TRANSIT="${ENABLE_TRANSIT:-false}"
|
||||||
# Where this repo's transit/ directory lives on THIS host (build context).
|
# Where this repo's transit/ directory lives on THIS host (build context).
|
||||||
TRANSIT_SRC="/opt/smart-home/src/transit"
|
TRANSIT_SRC="${TRANSIT_SRC:-/opt/smart-home/src/transit}"
|
||||||
TRANSIT_PORT="8099" # reachable by HA's rest_command (voice lookups)
|
TRANSIT_PORT="${TRANSIT_PORT:-8099}" # reachable by HA's rest_command (voice lookups)
|
||||||
|
|
||||||
# --- On-demand route planning (Phase 19) — a SEPARATE opt-in from ENABLE_TRANSIT
|
# --- On-demand route planning (Phase 19) — a SEPARATE opt-in from ENABLE_TRANSIT
|
||||||
# --- above on purpose: a real OpenTripPlanner graph (OSM + GTFS) is a meaningfully
|
# --- above on purpose: a real OpenTripPlanner graph (OSM + GTFS) is a meaningfully
|
||||||
|
|
@ -160,22 +166,22 @@ TRANSIT_PORT="8099" # reachable by HA's rest_command (voice looku
|
||||||
# --- "Austria-wide" is a moderate commitment, "global" is a real infrastructure
|
# --- "Austria-wide" is a moderate commitment, "global" is a real infrastructure
|
||||||
# --- decision, not a flag. This script does NOT build the OTP graph for you — that's
|
# --- decision, not a flag. This script does NOT build the OTP graph for you — that's
|
||||||
# --- a manual, one-time (per OSM/GTFS update) step; see OpenTripPlanner's own docs.
|
# --- a manual, one-time (per OSM/GTFS update) step; see OpenTripPlanner's own docs.
|
||||||
ENABLE_TRIP_PLANNING="false"
|
ENABLE_TRIP_PLANNING="${ENABLE_TRIP_PLANNING:-false}"
|
||||||
OTP_GRAPHS_DIR="/opt/smart-home/otp-graphs" # you populate this by hand, see above
|
OTP_GRAPHS_DIR="${OTP_GRAPHS_DIR:-/opt/smart-home/otp-graphs}" # you populate this by hand, see above
|
||||||
# Host-side published port for OTP's own web/GraphQL API. NOT 8080 — zigbee2mqtt's
|
# Host-side published port for OTP's own web/GraphQL API. NOT 8080 — zigbee2mqtt's
|
||||||
# frontend (always-on, below) already publishes 8080:8080; OTP's own container-
|
# frontend (always-on, below) already publishes 8080:8080; OTP's own container-
|
||||||
# internal port stays 8080 regardless (transit.env.example's OTP_URL correctly
|
# internal port stays 8080 regardless (transit.env.example's OTP_URL correctly
|
||||||
# reaches it via container DNS as http://otp:8080), only the host-side mapping
|
# reaches it via container DNS as http://otp:8080), only the host-side mapping
|
||||||
# needed to move to avoid the two colliding on the same host.
|
# needed to move to avoid the two colliding on the same host.
|
||||||
OTP_PORT="8100"
|
OTP_PORT="${OTP_PORT:-8100}"
|
||||||
|
|
||||||
# --- Household chore distribution + reminders + camera verification (Phase 20) —
|
# --- Household chore distribution + reminders + camera verification (Phase 20) —
|
||||||
# --- off by default. Works with just ENABLE_IDENTITY on (assignment) and ntfy
|
# --- off by default. Works with just ENABLE_IDENTITY on (assignment) and ntfy
|
||||||
# --- (reminders); the camera-check and trash-day-eve steps each individually
|
# --- (reminders); the camera-check and trash-day-eve steps each individually
|
||||||
# --- no-op until their own env vars are set — see chores/README.md.
|
# --- no-op until their own env vars are set — see chores/README.md.
|
||||||
ENABLE_CHORES="false"
|
ENABLE_CHORES="${ENABLE_CHORES:-false}"
|
||||||
# Where this repo's chores/ directory lives on THIS host (build context).
|
# Where this repo's chores/ directory lives on THIS host (build context).
|
||||||
CHORES_SRC="/opt/smart-home/src/chores"
|
CHORES_SRC="${CHORES_SRC:-/opt/smart-home/src/chores}"
|
||||||
|
|
||||||
# --- Music Assistant (optional, additive — docs/project-plan.md §2) ---------
|
# --- Music Assistant (optional, additive — docs/project-plan.md §2) ---------
|
||||||
# Unifies Spotify Connect + other sources behind one HA-native multi-room player.
|
# Unifies Spotify Connect + other sources behind one HA-native multi-room player.
|
||||||
|
|
@ -195,7 +201,7 @@ CHORES_SRC="/opt/smart-home/src/chores"
|
||||||
# Check Music Assistant's own docs/config for how to change its listen port
|
# Check Music Assistant's own docs/config for how to change its listen port
|
||||||
# BEFORE flipping this on if ENABLE_PANTRY_VISION is also true — the sanity check
|
# BEFORE flipping this on if ENABLE_PANTRY_VISION is also true — the sanity check
|
||||||
# below only warns, it does not change either port for you.
|
# below only warns, it does not change either port for you.
|
||||||
ENABLE_MUSIC_ASSISTANT="false"
|
ENABLE_MUSIC_ASSISTANT="${ENABLE_MUSIC_ASSISTANT:-false}"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Sanity checks
|
# Sanity checks
|
||||||
|
|
@ -1414,6 +1420,9 @@ if [[ "$ENABLE_IDENTITY" == "true" ]]; then
|
||||||
echo " Identity API : http://${HOST_IP}:${IDENTITY_PORT} (bearer-token gated)"
|
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 " 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>"
|
echo " Dashboard page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/dashboard.html?identity_api=http://${HOST_IP}:${IDENTITY_PORT}&identity_token=<IDENTITY_TOKEN>"
|
||||||
|
echo " Admin panel : http://${HOST_IP}:${IDENTITY_WEB_PORT}/admin.html?api=http://${HOST_IP}:${IDENTITY_PORT}&token=<IDENTITY_TOKEN>"
|
||||||
|
echo " (people/guests, pruning, visit history, device rights —"
|
||||||
|
echo " NOT a kiosk page; keep this URL off the wall panels)"
|
||||||
fi
|
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})"
|
||||||
|
|
@ -1465,7 +1474,7 @@ fi
|
||||||
if [[ "$ENABLE_PANTRY_VISION" == "true" ]]; then
|
if [[ "$ENABLE_PANTRY_VISION" == "true" ]]; then
|
||||||
echo " 15. Fill in $BASE_DIR/pantry-vision/pantry-vision.env before the kitchen display can"
|
echo " 15. Fill in $BASE_DIR/pantry-vision/pantry-vision.env before the kitchen display can"
|
||||||
echo " identify anything: PANTRY_VISION_TOKEN (also goes into"
|
echo " identify anything: PANTRY_VISION_TOKEN (also goes into"
|
||||||
echo " hosts/kitchen-display/scripts/build-kitchen-display-iso.sh — both sides need the"
|
echo " tools/build-kitchen-display-iso.sh — both sides need the"
|
||||||
echo " SAME value) and GROCY_API_KEY (Grocy's own UI: Settings -> Manage API keys, at"
|
echo " SAME value) and GROCY_API_KEY (Grocy's own UI: Settings -> Manage API keys, at"
|
||||||
echo " http://${HOST_IP}:9283). Also pull a vision-capable Ollama model on the LLM host"
|
echo " http://${HOST_IP}:9283). Also pull a vision-capable Ollama model on the LLM host"
|
||||||
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"
|
||||||
|
|
@ -0,0 +1,326 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# LLM Host Setup — Ollama, docs/project-plan.md Phase 3
|
||||||
|
# Target: Debian 12 (Bookworm), a SEPARATE physical machine from the container host.
|
||||||
|
#
|
||||||
|
# Stands up the one thing this host exists for: an Ollama server on the LAN that
|
||||||
|
# everything else in this project calls for inference —
|
||||||
|
# - Home Assistant's Ollama conversation integration (Assist, AI Task) [Phase 3/4]
|
||||||
|
# - digest-engine's quarter-daily synthesis + counter-run [Phase 12]
|
||||||
|
# - pantry-vision's grocery-item recognition (vision model) [Phase 17]
|
||||||
|
# - chores' bin/dishes/litter checks + reminder phrasing [Phase 20]
|
||||||
|
#
|
||||||
|
# THE GUARDRAIL THIS HOST IS BUILT AROUND: **everything above must degrade to
|
||||||
|
# "unavailable", never to "broken", when this machine is off.** docs/project-plan.md's
|
||||||
|
# testing checklist states it directly — "Does the reactive path (presence → light on)
|
||||||
|
# work with the LLM host powered off? (It must.)" — and every consumer above already
|
||||||
|
# falls back to a deterministic template, a plain lookup table, or simply skipping the
|
||||||
|
# run. Nothing here should ever become load-bearing for the reactive smart-home
|
||||||
|
# baseline. That is why this is a separate host at all: so it CAN be off.
|
||||||
|
#
|
||||||
|
# Two hardware tiers, auto-detected (override with TIER below):
|
||||||
|
# gpu — NVIDIA card present. Runs the 14B-class model per Phase 3.
|
||||||
|
# cpu — no GPU. Runs a 7B/3B model. Slower (single-digit tok/s), enough to validate
|
||||||
|
# the whole pipeline before spending money on a card, per docs/components.md's
|
||||||
|
# "Fallback: skip GPU" line.
|
||||||
|
#
|
||||||
|
# Run as: sudo ./setup-llm-host.sh
|
||||||
|
#
|
||||||
|
# EDIT THE VARIABLES BELOW BEFORE RUNNING.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CONFIGURATION
|
||||||
|
#
|
||||||
|
# Every value here is `${VAR:-default}`, so anything already in the environment wins —
|
||||||
|
# which is how the ISO from tools/build-llm-host-iso.sh configures this script without
|
||||||
|
# editing it (its first-boot unit sources /opt/llm-host/llm-host.env, generated from
|
||||||
|
# CoreSystemConfig.json). Edit these directly ONLY for a standalone run.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
BASE_DIR="${BASE_DIR:-/opt/llm-host}" # Config + model storage. Models are BIG (a 14B
|
||||||
|
# Q4 model is ~9GB, a vision model another 5-8GB)
|
||||||
|
# — make sure this lives on a disk with room.
|
||||||
|
|
||||||
|
TIER="${TIER:-auto}" # auto | gpu | cpu
|
||||||
|
|
||||||
|
OLLAMA_PORT="${OLLAMA_PORT:-11434}" # Ollama's own default.
|
||||||
|
|
||||||
|
# Models to pull, per tier. Phase 3 specifies Qwen2.5-14B-Instruct (GPU) or 7B/3B
|
||||||
|
# (CPU). Tags are Ollama library names — `ollama list` on a real host to confirm what
|
||||||
|
# you actually ended up with, since library tags do get renamed upstream.
|
||||||
|
GPU_TEXT_MODEL="${GPU_TEXT_MODEL:-qwen2.5:14b-instruct}"
|
||||||
|
CPU_TEXT_MODEL="${CPU_TEXT_MODEL:-qwen2.5:7b-instruct}"
|
||||||
|
|
||||||
|
# The vision model, for pantry-vision (grocery items) and chores (bin/dishes/litter).
|
||||||
|
# NOT A CONSIDERED CHOICE — `llava` is the default those services already ship with,
|
||||||
|
# and docs/project-plan.md open decision #18 flags the pick as unmade and completely
|
||||||
|
# unbenchmarked. Treat this as "something to measure", not "the answer": if grocery
|
||||||
|
# recognition is too slow or too wrong to be usable, this is the first knob to turn
|
||||||
|
# (qwen2.5vl and moondream are the obvious alternatives to try).
|
||||||
|
VISION_MODEL="${VISION_MODEL:-llava}"
|
||||||
|
|
||||||
|
PULL_VISION_MODEL="${PULL_VISION_MODEL:-true}" # false to skip — saves several GB if you're not
|
||||||
|
# running pantry-vision/chores camera checks yet.
|
||||||
|
|
||||||
|
# --- Contention between interactive and batch callers ------------------------------
|
||||||
|
# The real scheduling problem on one GPU (docs/project-plan.md open decision #4):
|
||||||
|
# Assist is INTERACTIVE (a person is standing there waiting), while digest-engine is
|
||||||
|
# BATCH (every 6h, nobody watching) and the vision callers are occasional but want a
|
||||||
|
# DIFFERENT model resident. Defaults below optimise for the interactive case, because
|
||||||
|
# that's the one where latency is felt:
|
||||||
|
#
|
||||||
|
# KEEP_ALIVE — how long a model stays resident after its last request. Ollama's own
|
||||||
|
# default is 5m, which means a household that talks to Assist a few times an hour
|
||||||
|
# pays the model-load cost almost every time. 30m keeps it warm through normal use.
|
||||||
|
OLLAMA_KEEP_ALIVE="${OLLAMA_KEEP_ALIVE:-30m}"
|
||||||
|
# MAX_LOADED_MODELS — how many distinct models may be resident at once. **1 is
|
||||||
|
# deliberate on a single consumer GPU**: a 14B text model and a vision model do not
|
||||||
|
# fit together in 8-12GB, and letting Ollama try produces VRAM thrash or an OOM
|
||||||
|
# mid-request rather than an honest swap. 1 means "swap predictably, pay the reload
|
||||||
|
# cost when the vision model is actually needed." Raise it only if you have the VRAM
|
||||||
|
# to hold both and have checked that you do.
|
||||||
|
OLLAMA_MAX_LOADED_MODELS="${OLLAMA_MAX_LOADED_MODELS:-1}"
|
||||||
|
# NUM_PARALLEL — concurrent requests served per loaded model. 1 keeps latency
|
||||||
|
# predictable for whoever is speaking to Assist; higher trades that for throughput
|
||||||
|
# nothing in this project currently needs.
|
||||||
|
OLLAMA_NUM_PARALLEL="${OLLAMA_NUM_PARALLEL:-1}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# End of configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
log() { echo -e "\n\033[1;34m==>\033[0m $*"; }
|
||||||
|
warn() { echo -e "\033[1;33m[warn]\033[0m $*" >&2; }
|
||||||
|
die() { echo -e "\033[1;31m[error]\033[0m $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[[ $EUID -eq 0 ]] || die "Run this with sudo."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tier detection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
detect_tier() {
|
||||||
|
if [[ "$TIER" != "auto" ]]; then
|
||||||
|
echo "$TIER"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
# nvidia-smi existing AND succeeding are different things — a leftover driver
|
||||||
|
# package on a machine whose card has been pulled would satisfy `command -v` alone
|
||||||
|
# and send us down the GPU path to fail later at container start.
|
||||||
|
if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi >/dev/null 2>&1; then
|
||||||
|
echo "gpu"
|
||||||
|
else
|
||||||
|
echo "cpu"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Docker — same install path as tools/setup-container-host.sh.
|
||||||
|
# Ollama is run as a container rather than natively installed for the same reason
|
||||||
|
# everything else in this project is: no `curl | sh` into a root shell, a pinned
|
||||||
|
# image, and an uninstall that's `docker rm`. The native installer is a legitimate
|
||||||
|
# alternative (see README.md); it is not the default here.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
install_docker() {
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
log "Docker already installed — skipping"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
log "Installing Docker"
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y ca-certificates curl gnupg
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/debian/gpg \
|
||||||
|
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||||
|
https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||||
|
> /etc/apt/sources.list.d/docker.list
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# NVIDIA Container Toolkit — only what lets Docker see the GPU. It does NOT install
|
||||||
|
# the driver: a working `nvidia-smi` is a prerequisite this script checks for rather
|
||||||
|
# than tries to satisfy, because driver installation is the single most
|
||||||
|
# hardware/kernel-specific step on this host and silently picking a driver version
|
||||||
|
# for someone is a good way to produce an unbootable machine.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
install_nvidia_toolkit() {
|
||||||
|
if ! nvidia-smi >/dev/null 2>&1; then
|
||||||
|
die "TIER=gpu but nvidia-smi doesn't work. Install the NVIDIA driver first
|
||||||
|
(Debian: enable non-free-firmware, then 'apt install nvidia-driver firmware-misc-nonfree',
|
||||||
|
reboot, confirm 'nvidia-smi' prints your card), or set TIER=cpu to run without a GPU."
|
||||||
|
fi
|
||||||
|
if command -v nvidia-ctk >/dev/null 2>&1; then
|
||||||
|
log "NVIDIA Container Toolkit already installed — skipping"
|
||||||
|
else
|
||||||
|
log "Installing NVIDIA Container Toolkit"
|
||||||
|
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
|
||||||
|
| gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
|
||||||
|
curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
|
||||||
|
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
|
||||||
|
> /etc/apt/sources.list.d/nvidia-container-toolkit.list
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y nvidia-container-toolkit
|
||||||
|
fi
|
||||||
|
nvidia-ctk runtime configure --runtime=docker
|
||||||
|
systemctl restart docker
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Compose file
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
write_compose() {
|
||||||
|
local tier="$1"
|
||||||
|
local gpu_block=""
|
||||||
|
|
||||||
|
if [[ "$tier" == "gpu" ]]; then
|
||||||
|
gpu_block="
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: all
|
||||||
|
capabilities: [gpu]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$BASE_DIR/models"
|
||||||
|
|
||||||
|
cat > "$BASE_DIR/docker-compose.yml" <<EOF
|
||||||
|
# Generated by tools/setup-llm-host.sh — re-running the script
|
||||||
|
# regenerates this file. Tier: ${tier}
|
||||||
|
services:
|
||||||
|
ollama:
|
||||||
|
image: ollama/ollama:latest
|
||||||
|
container_name: ollama
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
# Published on ALL interfaces so the container host, HA, and the kiosks can
|
||||||
|
# reach it over the LAN — this host exists to serve them. See README.md's
|
||||||
|
# security note: Ollama has NO authentication, so the VLAN is the boundary.
|
||||||
|
- "${OLLAMA_PORT}:11434"
|
||||||
|
volumes:
|
||||||
|
- ${BASE_DIR}/models:/root/.ollama
|
||||||
|
environment:
|
||||||
|
# Inside the container Ollama must listen on 0.0.0.0, not its default
|
||||||
|
# 127.0.0.1 — otherwise the published port above forwards to a socket nothing
|
||||||
|
# is listening on, and every caller gets a connection refused that looks
|
||||||
|
# exactly like "the host is down".
|
||||||
|
- OLLAMA_HOST=0.0.0.0:11434
|
||||||
|
- OLLAMA_KEEP_ALIVE=${OLLAMA_KEEP_ALIVE}
|
||||||
|
- OLLAMA_MAX_LOADED_MODELS=${OLLAMA_MAX_LOADED_MODELS}
|
||||||
|
- OLLAMA_NUM_PARALLEL=${OLLAMA_NUM_PARALLEL}
|
||||||
|
- OLLAMA_MODELS=/root/.ollama/models${gpu_block}
|
||||||
|
EOF
|
||||||
|
log "Wrote $BASE_DIR/docker-compose.yml"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Model pulls
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
pull_model() {
|
||||||
|
local model="$1"
|
||||||
|
log "Pulling $model (this can take a long while — several GB)"
|
||||||
|
# Pulls run INSIDE the already-running container so they land on the mounted
|
||||||
|
# models volume and are visible to the server without a restart.
|
||||||
|
if ! docker exec ollama ollama pull "$model"; then
|
||||||
|
warn "Could not pull '$model'. The server is still up — pull it by hand later with:
|
||||||
|
docker exec ollama ollama pull $model
|
||||||
|
If the tag was renamed upstream, check https://ollama.com/library for the current one."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_ollama() {
|
||||||
|
log "Waiting for Ollama to answer on :${OLLAMA_PORT}"
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if curl -fsS "http://127.0.0.1:${OLLAMA_PORT}/api/tags" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
die "Ollama didn't come up within 2 minutes. Check: docker logs ollama"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
TIER_RESOLVED="$(detect_tier)"
|
||||||
|
log "Tier: ${TIER_RESOLVED}$([[ "$TIER" == "auto" ]] && echo " (auto-detected)")"
|
||||||
|
|
||||||
|
if [[ "$TIER_RESOLVED" == "cpu" ]]; then
|
||||||
|
warn "Running CPU-only. Expect single-digit tokens/sec — usable for validating the
|
||||||
|
whole pipeline end to end, slow enough to be annoying for live Assist. This is
|
||||||
|
docs/components.md's deliberate 'skip the GPU for now' fallback, not a failure."
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_docker
|
||||||
|
[[ "$TIER_RESOLVED" == "gpu" ]] && install_nvidia_toolkit
|
||||||
|
|
||||||
|
write_compose "$TIER_RESOLVED"
|
||||||
|
|
||||||
|
log "Starting Ollama"
|
||||||
|
docker compose -f "$BASE_DIR/docker-compose.yml" up -d
|
||||||
|
wait_for_ollama
|
||||||
|
|
||||||
|
if [[ "$TIER_RESOLVED" == "gpu" ]]; then
|
||||||
|
TEXT_MODEL="$GPU_TEXT_MODEL"
|
||||||
|
else
|
||||||
|
TEXT_MODEL="$CPU_TEXT_MODEL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A failed pull is deliberately not fatal — the server is up and useful, and the
|
||||||
|
# likeliest cause is a renamed upstream tag, which a human fixes in seconds and a
|
||||||
|
# script cannot guess at.
|
||||||
|
pull_model "$TEXT_MODEL" || true
|
||||||
|
if [[ "$PULL_VISION_MODEL" == "true" ]]; then
|
||||||
|
pull_model "$VISION_MODEL" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
HOST_IP="$(hostname -I | awk '{print $1}')"
|
||||||
|
|
||||||
|
log "Installed models:"
|
||||||
|
docker exec ollama ollama list || warn "Could not list models"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
============================================================================
|
||||||
|
LLM host is up: http://${HOST_IP}:${OLLAMA_PORT}
|
||||||
|
============================================================================
|
||||||
|
|
||||||
|
Wire it into the rest of the stack — everything below wants that URL:
|
||||||
|
|
||||||
|
1. Home Assistant (Phase 3): Settings -> Devices & Services -> Add Integration
|
||||||
|
-> Ollama, URL http://${HOST_IP}:${OLLAMA_PORT}, model ${TEXT_MODEL}.
|
||||||
|
Then Settings -> Voice assistants -> your Assist pipeline -> Conversation agent.
|
||||||
|
|
||||||
|
2. On the CONTAINER host, in each service's env file:
|
||||||
|
digest-engine.env : OLLAMA_HOST=http://${HOST_IP}:${OLLAMA_PORT}
|
||||||
|
OLLAMA_MODEL=${TEXT_MODEL}
|
||||||
|
pantry-vision.env : OLLAMA_HOST=http://${HOST_IP}:${OLLAMA_PORT}
|
||||||
|
OLLAMA_VISION_MODEL=${VISION_MODEL}
|
||||||
|
chores.env : OLLAMA_HOST=http://${HOST_IP}:${OLLAMA_PORT}
|
||||||
|
OLLAMA_VISION_MODEL=${VISION_MODEL}
|
||||||
|
OLLAMA_TEXT_MODEL=${TEXT_MODEL} (optional, reminder phrasing)
|
||||||
|
|
||||||
|
NOTE the full scheme+port form: those services build URLs by string
|
||||||
|
concatenation, so a bare IP will not work.
|
||||||
|
|
||||||
|
3. THEN DO THE THING THIS HOST EXISTS TO SURVIVE — power it off and confirm the
|
||||||
|
reactive baseline still works:
|
||||||
|
- presence -> light on/off still fires (Phase 2 automations, no LLM in the loop)
|
||||||
|
- the door panel still shows weather/who's-home
|
||||||
|
- chores still nudges (plain template instead of LLM-phrased wording)
|
||||||
|
- digest-engine skips its run rather than erroring the timer
|
||||||
|
docs/project-plan.md's testing checklist calls this out explicitly. If anything
|
||||||
|
above BREAKS rather than degrading, that's a bug in the consumer, not here.
|
||||||
|
|
||||||
|
4. SECURITY: Ollama has no authentication of any kind, and its API can pull and
|
||||||
|
DELETE models, not just generate. Anyone who can reach :${OLLAMA_PORT} can do all
|
||||||
|
of that. Keep this host on the smart-home VLAN, never port-forwarded — see
|
||||||
|
docs/network-integration.md.
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
@ -0,0 +1,403 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate CoreSystemConfig.json — the gate every builder in tools/ runs first.
|
||||||
|
|
||||||
|
THE POINT OF THIS FILE: before CoreSystemConfig.json existed, an IP lived in six build
|
||||||
|
scripts and a token in three, so "the door panel can't reach identity" could mean a
|
||||||
|
typo in any one of them, discovered only after a 40-minute ISO build and a reboot.
|
||||||
|
Centralising the values removes the duplication; this script removes the rest — it
|
||||||
|
fails the build at second zero for anything it can prove wrong on paper.
|
||||||
|
|
||||||
|
Deliberately stdlib-only and dependency-free (no jsonschema): the whole point is that
|
||||||
|
a fresh checkout can validate a config before anything is installed, on a machine
|
||||||
|
where `pip install` may not even be available yet.
|
||||||
|
|
||||||
|
Exit codes: 0 = valid (warnings may still print) 1 = errors found 2 = unusable file
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
|
||||||
|
KIOSK_TYPES = {"thin-client", "touch-panel", "door-panel", "kitchen-display"}
|
||||||
|
TIERS = {"auto", "gpu", "cpu"}
|
||||||
|
ARCHITECTURES = {"amd64", "arm64"}
|
||||||
|
|
||||||
|
# Tokens the validator insists on, and the enable-flag that makes each one required.
|
||||||
|
# A token for a disabled service is not an error — you shouldn't have to invent a
|
||||||
|
# transit token to build a door panel.
|
||||||
|
TOKEN_REQUIREMENTS = [
|
||||||
|
("identity_token", "identity", "identity, the door panel and the kitchen display"),
|
||||||
|
("pantry_vision_token", "pantry_vision", "pantry-vision and the kitchen display"),
|
||||||
|
("transit_token", "transit", "transit"),
|
||||||
|
]
|
||||||
|
|
||||||
|
MIN_TOKEN_LEN = 32
|
||||||
|
|
||||||
|
# Substrings that mean "nobody actually generated this". Matched anywhere in the value,
|
||||||
|
# case-insensitively, so padding a placeholder out to 32 characters doesn't sneak past.
|
||||||
|
PLACEHOLDER_MARKERS = (
|
||||||
|
"changeme", "change-me", "your-", "yourtoken", "placeholder", "example",
|
||||||
|
"xxxxx", "secret", "password", "todo", "fixme", "insert",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_placeholder(value: str) -> bool:
|
||||||
|
lowered = value.lower()
|
||||||
|
if any(marker in lowered for marker in PLACEHOLDER_MARKERS):
|
||||||
|
return True
|
||||||
|
# A "token" made of one repeated character (aaaa..., 0000...) is padding, not entropy.
|
||||||
|
return len(set(value)) <= 2 and len(value) > 4
|
||||||
|
|
||||||
|
|
||||||
|
class Report:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.errors: list[str] = []
|
||||||
|
self.warnings: list[str] = []
|
||||||
|
|
||||||
|
def error(self, where: str, msg: str) -> None:
|
||||||
|
self.errors.append(f"{where}: {msg}")
|
||||||
|
|
||||||
|
def warn(self, where: str, msg: str) -> None:
|
||||||
|
self.warnings.append(f"{where}: {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get(cfg: dict, path: str, default: Any = None) -> Any:
|
||||||
|
node = cfg
|
||||||
|
for part in path.split("."):
|
||||||
|
if not isinstance(node, dict) or part not in node:
|
||||||
|
return default
|
||||||
|
node = node[part]
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def _require(cfg: dict, path: str, kind, rep: Report, where: str | None = None) -> Any:
|
||||||
|
"""Fetch a required key, reporting a precise path rather than a KeyError."""
|
||||||
|
value = _get(cfg, path, None)
|
||||||
|
label = where or path
|
||||||
|
if value is None:
|
||||||
|
rep.error(label, "missing (required)")
|
||||||
|
return None
|
||||||
|
if kind is not None and not isinstance(value, kind):
|
||||||
|
rep.error(label, f"must be {getattr(kind, '__name__', kind)}, got {type(value).__name__}")
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_household(cfg: dict, rep: Report) -> None:
|
||||||
|
tz = _require(cfg, "household.timezone", str, rep)
|
||||||
|
if tz:
|
||||||
|
# Checked against the zoneinfo database actually present, not a regex — a
|
||||||
|
# plausible-looking but wrong timezone ("Europe/Wien") silently gives every
|
||||||
|
# image the wrong clock, and every timestamped log with it.
|
||||||
|
if not Path(f"/usr/share/zoneinfo/{tz}").exists():
|
||||||
|
rep.warn("household.timezone",
|
||||||
|
f"'{tz}' isn't in this machine's /usr/share/zoneinfo — verify it exists on the target")
|
||||||
|
_require(cfg, "household.locale", str, rep)
|
||||||
|
layout = _require(cfg, "household.keyboard_layout", str, rep)
|
||||||
|
if layout and not re.match(r"^[a-z]{2,6}$", layout):
|
||||||
|
rep.error("household.keyboard_layout", f"'{layout}' doesn't look like an xkb layout (e.g. 'de', 'us')")
|
||||||
|
_require(cfg, "household.debian_release", str, rep)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_network(cfg: dict, rep: Report) -> None:
|
||||||
|
prefix = _require(cfg, "network.subnet_prefix", str, rep)
|
||||||
|
if not prefix:
|
||||||
|
return
|
||||||
|
if prefix.endswith("."):
|
||||||
|
rep.error("network.subnet_prefix", f"'{prefix}' must not end with a dot")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(f"{prefix}.0/24", strict=True)
|
||||||
|
except ValueError as exc:
|
||||||
|
rep.error("network.subnet_prefix", f"'{prefix}' is not a valid /24 prefix ({exc})")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not network.is_private:
|
||||||
|
rep.warn("network.subnet_prefix",
|
||||||
|
f"{network} is not a private range — this stack is designed to be LAN-only "
|
||||||
|
"(docs/network-integration.md §1)")
|
||||||
|
# 192.168.0.x and 192.168.1.x are what nearly every café, hotel and consumer router
|
||||||
|
# uses, so a WireGuard split tunnel routing this VLAN would collide with them —
|
||||||
|
# docs/network-integration.md §2.1 spells this out.
|
||||||
|
if prefix in ("192.168.0", "192.168.1"):
|
||||||
|
rep.warn("network.subnet_prefix",
|
||||||
|
f"'{prefix}' collides with typical café/hotel LANs; a WireGuard split tunnel "
|
||||||
|
"routing it will break connectivity on those networks (network-integration.md §2.1)")
|
||||||
|
|
||||||
|
gw = _get(cfg, "network.gateway_last_octet")
|
||||||
|
if not isinstance(gw, int) or not 1 <= gw <= 254:
|
||||||
|
rep.error("network.gateway_last_octet", f"must be an int 1-254, got {gw!r}")
|
||||||
|
|
||||||
|
dns = _get(cfg, "network.dns_servers")
|
||||||
|
if not isinstance(dns, list) or not dns:
|
||||||
|
rep.error("network.dns_servers", "must be a non-empty list of IP addresses")
|
||||||
|
else:
|
||||||
|
for entry in dns:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(entry)
|
||||||
|
except ValueError:
|
||||||
|
rep.error("network.dns_servers", f"'{entry}' is not a valid IP address")
|
||||||
|
|
||||||
|
ssid = _get(cfg, "network.wifi.ssid", "")
|
||||||
|
psk = _get(cfg, "network.wifi.psk", "")
|
||||||
|
if ssid and not psk:
|
||||||
|
rep.error("network.wifi.psk", "an SSID is set but the PSK is empty")
|
||||||
|
if psk and not ssid:
|
||||||
|
rep.error("network.wifi.ssid", "a PSK is set but the SSID is empty")
|
||||||
|
if psk and len(psk) < 8:
|
||||||
|
rep.error("network.wifi.psk", "WPA2 pre-shared keys are at least 8 characters")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_hosts(cfg: dict, rep: Report) -> None:
|
||||||
|
"""The twinning check. The container host and the LLM host derive each other's
|
||||||
|
addresses from these two numbers, so the one thing that must hold is that they are
|
||||||
|
two distinct, valid, same-subnet addresses — get that right and no builder can
|
||||||
|
produce a mismatched pair.
|
||||||
|
"""
|
||||||
|
octets: dict[str, int] = {}
|
||||||
|
for host in ("container_host", "llm_host"):
|
||||||
|
hostname = _require(cfg, f"{host}.hostname", str, rep)
|
||||||
|
if hostname and not HOSTNAME_RE.match(hostname):
|
||||||
|
rep.error(f"{host}.hostname",
|
||||||
|
f"'{hostname}' is not a valid DNS label (lowercase letters, digits, hyphens; "
|
||||||
|
"no leading/trailing hyphen)")
|
||||||
|
octet = _get(cfg, f"{host}.ip_last_octet")
|
||||||
|
if not isinstance(octet, int) or not 1 <= octet <= 254:
|
||||||
|
rep.error(f"{host}.ip_last_octet", f"must be an int 1-254, got {octet!r}")
|
||||||
|
else:
|
||||||
|
octets[host] = octet
|
||||||
|
disk = _require(cfg, f"{host}.install_disk", str, rep)
|
||||||
|
if disk and not disk.startswith("/dev/"):
|
||||||
|
rep.error(f"{host}.install_disk", f"'{disk}' should be a device path like /dev/sda or /dev/nvme0n1")
|
||||||
|
_require(cfg, f"{host}.admin_username", str, rep)
|
||||||
|
|
||||||
|
if len(octets) == 2:
|
||||||
|
if octets["container_host"] == octets["llm_host"]:
|
||||||
|
rep.error("llm_host.ip_last_octet",
|
||||||
|
f"the container host and LLM host both want .{octets['llm_host']} — "
|
||||||
|
"they are two separate machines and need two addresses")
|
||||||
|
gw = _get(cfg, "network.gateway_last_octet")
|
||||||
|
for host, octet in octets.items():
|
||||||
|
if octet == gw:
|
||||||
|
rep.error(f"{host}.ip_last_octet", f".{octet} is the gateway address")
|
||||||
|
|
||||||
|
tier = _get(cfg, "llm_host.tier")
|
||||||
|
if tier not in TIERS:
|
||||||
|
rep.error("llm_host.tier", f"must be one of {sorted(TIERS)}, got {tier!r}")
|
||||||
|
for key in ("text_model_gpu", "text_model_cpu", "vision_model"):
|
||||||
|
_require(cfg, f"llm_host.{key}", str, rep)
|
||||||
|
for key in ("max_loaded_models", "num_parallel"):
|
||||||
|
value = _get(cfg, f"llm_host.{key}")
|
||||||
|
if not isinstance(value, int) or value < 1:
|
||||||
|
rep.error(f"llm_host.{key}", f"must be a positive int, got {value!r}")
|
||||||
|
keep_alive = _get(cfg, "llm_host.keep_alive")
|
||||||
|
if not isinstance(keep_alive, str) or not re.match(r"^\d+[smh]$|^-1$", str(keep_alive)):
|
||||||
|
rep.error("llm_host.keep_alive", f"must look like '30m', '2h', '600s' or '-1', got {keep_alive!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_ports(cfg: dict, rep: Report) -> None:
|
||||||
|
"""Port uniqueness is the single highest-value check here: a duplicate produces a
|
||||||
|
container that silently fails to bind, or two services fighting over one port with
|
||||||
|
whichever won last boot answering. Both are miserable to diagnose from the symptom.
|
||||||
|
"""
|
||||||
|
ports = _get(cfg, "ports")
|
||||||
|
if not isinstance(ports, dict):
|
||||||
|
rep.error("ports", "missing or not an object")
|
||||||
|
return
|
||||||
|
|
||||||
|
seen: dict[int, list[str]] = {}
|
||||||
|
for name, value in ports.items():
|
||||||
|
if name.startswith("_"):
|
||||||
|
continue
|
||||||
|
if not isinstance(value, int) or not 1 <= value <= 65535:
|
||||||
|
rep.error(f"ports.{name}", f"must be an int 1-65535, got {value!r}")
|
||||||
|
continue
|
||||||
|
if value < 1024:
|
||||||
|
rep.warn(f"ports.{name}", f"{value} is a privileged port (<1024)")
|
||||||
|
seen.setdefault(value, []).append(name)
|
||||||
|
|
||||||
|
for value, names in sorted(seen.items()):
|
||||||
|
if len(names) > 1:
|
||||||
|
rep.error("ports", f"port {value} is claimed by {len(names)} services: {', '.join(sorted(names))}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_secrets(cfg: dict, rep: Report) -> None:
|
||||||
|
enable = _get(cfg, "container_host.enable", {}) or {}
|
||||||
|
for token_name, flag, used_by in TOKEN_REQUIREMENTS:
|
||||||
|
value = _get(cfg, f"secrets.{token_name}", "") or ""
|
||||||
|
if not enable.get(flag):
|
||||||
|
if value:
|
||||||
|
rep.warn(f"secrets.{token_name}",
|
||||||
|
f"set, but container_host.enable.{flag} is false — it won't be used")
|
||||||
|
continue
|
||||||
|
if not value:
|
||||||
|
rep.error(f"secrets.{token_name}",
|
||||||
|
f"required because container_host.enable.{flag} is true (used by {used_by}). "
|
||||||
|
"Generate one with: openssl rand -hex 32")
|
||||||
|
# Placeholder BEFORE length: a long placeholder ("changeme-changeme-changeme-...")
|
||||||
|
# would otherwise sail past the length check, and a short one would be reported
|
||||||
|
# as merely too short — which invites someone to pad it rather than generate one.
|
||||||
|
elif _looks_like_placeholder(value):
|
||||||
|
rep.error(f"secrets.{token_name}",
|
||||||
|
f"'{value[:24]}' is a placeholder, not a generated secret. "
|
||||||
|
"Generate one with: openssl rand -hex 32")
|
||||||
|
elif len(value) < MIN_TOKEN_LEN:
|
||||||
|
rep.error(f"secrets.{token_name}",
|
||||||
|
f"only {len(value)} characters; use at least {MIN_TOKEN_LEN} "
|
||||||
|
"(openssl rand -hex 32)")
|
||||||
|
|
||||||
|
mqtt_user = _get(cfg, "secrets.mqtt_username", "") or ""
|
||||||
|
mqtt_pass = _get(cfg, "secrets.mqtt_password", "") or ""
|
||||||
|
if mqtt_user and not mqtt_pass:
|
||||||
|
rep.error("secrets.mqtt_password", "an MQTT username is set but the password is empty")
|
||||||
|
if not mqtt_user:
|
||||||
|
rep.warn("secrets.mqtt_username",
|
||||||
|
"empty — every kiosk will connect to Mosquitto anonymously. Fine while "
|
||||||
|
"allow_anonymous is on; revisit before that changes")
|
||||||
|
|
||||||
|
if not (_get(cfg, "secrets.ha_token", "") or ""):
|
||||||
|
rep.warn("secrets.ha_token",
|
||||||
|
"empty — identity's /register and /presence can't reach Home Assistant until "
|
||||||
|
"this is a real Long-Lived Access Token. It cannot be generated ahead of time; "
|
||||||
|
"fill it in and re-run the builder once HA is up")
|
||||||
|
|
||||||
|
key = _get(cfg, "secrets.ssh_authorized_key", "") or ""
|
||||||
|
if key and not re.match(r"^(ssh-(rsa|ed25519|dss)|ecdsa-sha2-\S+) \S+", key):
|
||||||
|
rep.error("secrets.ssh_authorized_key",
|
||||||
|
"doesn't look like an OpenSSH public key (should start 'ssh-ed25519 AAAA...')")
|
||||||
|
if key and "PRIVATE KEY" in key:
|
||||||
|
rep.error("secrets.ssh_authorized_key", "this is a PRIVATE key — put the .pub here instead")
|
||||||
|
if not key:
|
||||||
|
rep.warn("secrets.ssh_authorized_key",
|
||||||
|
"empty — the built images will have no way in over SSH. Fine for a kiosk you "
|
||||||
|
"only ever touch physically, painful for a headless host")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_kiosks(cfg: dict, rep: Report) -> None:
|
||||||
|
kiosks = _get(cfg, "kiosks")
|
||||||
|
if kiosks is None:
|
||||||
|
rep.warn("kiosks", "no kiosks defined — only the core pair will be buildable")
|
||||||
|
return
|
||||||
|
if not isinstance(kiosks, list):
|
||||||
|
rep.error("kiosks", "must be a list")
|
||||||
|
return
|
||||||
|
|
||||||
|
hostnames: dict[str, int] = {}
|
||||||
|
for index, kiosk in enumerate(kiosks):
|
||||||
|
where = f"kiosks[{index}]"
|
||||||
|
if not isinstance(kiosk, dict):
|
||||||
|
rep.error(where, "must be an object")
|
||||||
|
continue
|
||||||
|
ktype = kiosk.get("type")
|
||||||
|
if ktype not in KIOSK_TYPES:
|
||||||
|
rep.error(f"{where}.type", f"must be one of {sorted(KIOSK_TYPES)}, got {ktype!r}")
|
||||||
|
hostname = kiosk.get("hostname")
|
||||||
|
if not isinstance(hostname, str) or not HOSTNAME_RE.match(hostname or ""):
|
||||||
|
rep.error(f"{where}.hostname", f"{hostname!r} is not a valid DNS label")
|
||||||
|
else:
|
||||||
|
if hostname in hostnames:
|
||||||
|
rep.error(f"{where}.hostname",
|
||||||
|
f"'{hostname}' is already used by kiosks[{hostnames[hostname]}] — "
|
||||||
|
"hostnames identify these devices on the network and in HA, so they must be unique")
|
||||||
|
hostnames[hostname] = index
|
||||||
|
if not kiosk.get("friendly_name"):
|
||||||
|
rep.error(f"{where}.friendly_name", "missing — this is the name shown on the HA device")
|
||||||
|
if not kiosk.get("kiosk_username"):
|
||||||
|
rep.error(f"{where}.kiosk_username", "missing")
|
||||||
|
|
||||||
|
# Audio endpoints share the hostname namespace with kiosks: they are all devices on
|
||||||
|
# one network, and two of anything answering to the same name is the same problem
|
||||||
|
# regardless of what kind of device they are.
|
||||||
|
for index, endpoint in enumerate(_get(cfg, "audio_endpoints", []) or []):
|
||||||
|
where = f"audio_endpoints[{index}]"
|
||||||
|
if not isinstance(endpoint, dict):
|
||||||
|
rep.error(where, "must be an object")
|
||||||
|
continue
|
||||||
|
hostname = endpoint.get("hostname")
|
||||||
|
if not isinstance(hostname, str) or not HOSTNAME_RE.match(hostname or ""):
|
||||||
|
rep.error(f"{where}.hostname", f"{hostname!r} is not a valid DNS label")
|
||||||
|
elif hostname in hostnames:
|
||||||
|
rep.error(f"{where}.hostname",
|
||||||
|
f"'{hostname}' is already used by kiosks[{hostnames[hostname]}] — "
|
||||||
|
"kiosks and audio endpoints share one hostname namespace")
|
||||||
|
else:
|
||||||
|
hostnames[hostname] = index
|
||||||
|
if endpoint.get("arch") not in ARCHITECTURES:
|
||||||
|
rep.error(f"{where}.arch",
|
||||||
|
f"must be one of {sorted(ARCHITECTURES)}, got {endpoint.get('arch')!r}")
|
||||||
|
if not endpoint.get("friendly_name"):
|
||||||
|
rep.error(f"{where}.friendly_name", "missing")
|
||||||
|
|
||||||
|
# Cross-check: a kiosk that talks to a disabled service will build fine and then
|
||||||
|
# fail at runtime with a connection error, which is exactly the class of "works on
|
||||||
|
# paper" mistake this file exists to catch.
|
||||||
|
enable = _get(cfg, "container_host.enable", {}) or {}
|
||||||
|
needs = {
|
||||||
|
"door-panel": [("identity", "identity"), ("pantry_vision", "pantry-vision")],
|
||||||
|
"kitchen-display": [("identity", "identity"), ("pantry_vision", "pantry-vision")],
|
||||||
|
}
|
||||||
|
for index, kiosk in enumerate(kiosks):
|
||||||
|
if not isinstance(kiosk, dict):
|
||||||
|
continue
|
||||||
|
for flag, label in needs.get(kiosk.get("type", ""), []):
|
||||||
|
if not enable.get(flag):
|
||||||
|
rep.error(f"kiosks[{index}]",
|
||||||
|
f"a {kiosk.get('type')} needs {label}, but container_host.enable.{flag} is false")
|
||||||
|
|
||||||
|
|
||||||
|
def validate(cfg: dict) -> Report:
|
||||||
|
rep = Report()
|
||||||
|
validate_network(cfg, rep)
|
||||||
|
validate_household(cfg, rep)
|
||||||
|
validate_hosts(cfg, rep)
|
||||||
|
validate_ports(cfg, rep)
|
||||||
|
validate_secrets(cfg, rep)
|
||||||
|
validate_kiosks(cfg, rep)
|
||||||
|
return rep
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
path = Path(argv[1]) if len(argv) > 1 else Path(__file__).resolve().parent.parent / "CoreSystemConfig.json"
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
template = path.parent / "CoreSystemConfig.json.template"
|
||||||
|
print(f"error: {path} not found.", file=sys.stderr)
|
||||||
|
if template.exists():
|
||||||
|
print(f" Copy the template and fill it in:\n"
|
||||||
|
f" cp {template.name} {path.name}\n"
|
||||||
|
f" $EDITOR {path.name}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg = json.loads(path.read_text())
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
# Line and column, because a trailing comma in a 200-line JSON file is otherwise
|
||||||
|
# a genuinely annoying thing to find.
|
||||||
|
print(f"error: {path} is not valid JSON — line {exc.lineno}, column {exc.colno}: {exc.msg}",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
rep = validate(cfg)
|
||||||
|
|
||||||
|
for warning in rep.warnings:
|
||||||
|
print(f" warn {warning}")
|
||||||
|
for error in rep.errors:
|
||||||
|
print(f" ERROR {error}", file=sys.stderr)
|
||||||
|
|
||||||
|
if rep.errors:
|
||||||
|
print(f"\n{len(rep.errors)} error(s), {len(rep.warnings)} warning(s) — {path.name} is not usable yet.",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"\n{path.name} is valid ({len(rep.warnings)} warning(s)).")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv))
|
||||||
Loading…
Reference in New Issue