Compare commits

..

9 Commits

Author SHA1 Message Date
Amir Alexander Abdelbaki 2ae5cb3449 Add a floorplan editor to the admin panel
Closes the oldest deferred item in the plan (open decision #22). /presence has
reported a best-effort `room` since Phase 6, but there was nothing to plot it
on: no floor plan, no room list, no coordinate format. The reason it stayed
deferred was sound — nothing here knows the shape of this flat, and inventing a
coordinate format against a guess would have been the wrong kind of work. So the
plan is DRAWN, by hand, in a new Floorplan tab.

Levels hold rooms; rooms are polygons (real rooms aren't rectangles) with an
optional background image to trace over. Click corners to draw, drag handles to
adjust. Each room maps to the HA area string /presence already reported, and the
editor offers a pick-list of the areas HA is actually reporting rather than
asking anyone to retype an area_id from Developer Tools — same anti-typo
reasoning as CoreSystemConfig.json. Tick "Show who's home" and occupied rooms
light up.

Coordinates are normalised 0-1 rather than pixels: the plan has to render on a
laptop now and possibly a wall panel later, and pixels would be right on exactly
one of them. The tradeoff is documented — replacing a background with one of a
different aspect ratio distorts existing rooms.

Two choices about failure worth calling out. A room with no HA area is legal and
labelled as such on the plan, because drawing the flat and wiring up presence are
separate jobs. And anyone who can't be placed — home with no room resolved, or
reporting an area no room claims — is listed under the plan, with unclaimed areas
named so you know what's left to draw. A floor plan that quietly lost people
would be worse than no floor plan.

The whole feature is still gated on AREA_ATTRIBUTE being right, which remains an
unconfirmed guess at what Bermuda publishes; every room can be drawn correctly
and still never light up. The editor makes that diagnosable rather than
mysterious, but can't fix it. Added to identity's verification list.

38 backend checks: level and room CRUD, polygon validation (too few points, out
of range, non-numeric, over the vertex cap), duplicate-area refusal, image
round-trip, the presence join including both unplaceable cases, area suggestions
degrading when HA is down, and cascade on level delete. The editor UI itself has
not been opened in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:45:45 +02:00
Amir Alexander Abdelbaki 2a87996cbb Pre-name the arm64 audio images too, so no device is named after the build
arm64 was the last image in the household whose identity was typed in by hand
after building — Raspberry Pi Imager's OS Customisation dialog set the hostname
per unit at flash time. That's the exact step CoreSystemConfig.json exists to
remove, and it 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 in the picker.

The builder now substitutes the endpoint's hostname from the audio_endpoints
array into a generated copy of config/audio-endpoint.yaml and builds one image
per room, published like every other image as
iso-out/smarthome-<hostname>-<pairid>.img. The substitution is targeted rather
than a YAML round-trip: that template is mostly comments explaining an
unverified schema, and a load/dump would strip every one of them. It fails loudly
if it doesn't match exactly once, rather than shipping an image named after the
wrong room.

The cost is honest and documented: one full rpi-image-gen run per arm64 room,
qemu-emulated on an x86 builder, instead of one for all of them.
build.arm64_prebake: false restores the single generic image for households with
several arm64 rooms and a slow builder. Wi-Fi and SSH keys are still set in
Imager on arm64 either way — only the hostname moved into the build.

Also fixes the image-publishing search, which used `find -newer $BUILD_CONFIG`
and silently found nothing when the build finished within the same filesystem
timestamp granularity as the config write. Newest .img wins instead.

Resolves the per-room-identity asymmetry in project-plan Phase 15.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:36:41 +02:00
Amir Alexander Abdelbaki 919ce94d52 Output images to iso-out/, and note why arm64 audio is one image for all rooms
Renames the image output directory from build-output/ to iso-out/ (config
default, .gitignore, docs).

Also documents the asymmetry that showed up when building a household with
several endpoints: kiosks and amd64 audio endpoints get one image per array
entry, because their hostname is baked in at build time; the arm64 audio image
is generic and built once no matter how many arm64 rooms are listed, because Pi
Imager sets hostname and Wi-Fi per unit at flash time. That is project-plan
Phase 15.5's existing decision, now reflected in what build-all actually does
rather than only in prose.

Verified with a stubbed full run of a six-endpoint household: one build-all
invocation produced nine distinctly-named images with no overwrites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:28:08 +02:00
Amir Alexander Abdelbaki bbeabde97e Give every config array entry its own image, and build arm64 audio once
Follow-up to the tools/ build system: the kiosk and amd64 audio-endpoint
builders left their ISO in the live-build tree under live-build's fixed
filename, and never moved it to build-output/. Configure two thin clients and
build-all would build both into the same path — the second silently overwriting
the first, leaving one ISO carrying the second room's hostname and no sign the
first was ever produced.

core_publish_image() now moves each finished image to
build-output/smarthome-<hostname>-<pairid>.iso, so one array entry produces one
artifact. Verified: two thin clients now yield two distinct ISOs, and the
per-type builders still refuse with the real list when the hostname is omitted
or wrong.

The arm64 audio endpoint is the deliberate exception and is now handled as one:
Pi Imager sets hostname/Wi-Fi per unit at flash time, so a single generic .img
serves every room and build-all builds it once regardless of how many arm64
entries are listed. amd64 has no equivalent for a generic x86 ISO, so it bakes
the hostname in and does need one per room (project-plan Phase 15.5).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:25:38 +02:00
Amir Alexander Abdelbaki ea82ee70ad Add tools/ and CoreSystemConfig.json — one source of truth for every build
Installation was six scripts each carrying its own copy of the container host's
IP, three that had to agree on IDENTITY_TOKEN, and every service URL typed by
hand with a port in it. Any one could be wrong, and the symptom was always the
same and always late: an image that boots fine and then can't reach something,
found after a 40-minute build and a reboot.

Two properties fix that class of bug:

- Nothing is written twice. No script in tools/ contains an IP, port or token.
- Anything derivable is derived. You give the subnet prefix once and one last
  octet per host; every address and service URL is computed from those.

THE TWINNED PAIR. container_host.ip_last_octet 12 and llm_host 13 mean the
container host's OLLAMA_HOST *is* http://<prefix>.13:11434 — computed in the
same build, not typed into two files and kept in sync. Move the LLM host to .21
and the container host's Ollama URL follows; change the subnet and both halves
move along with every kiosk's URLs. Neither image can be built pointing at an
address the other isn't using. Both carry the same SMARTHOME_PAIR_ID (a hash of
the config's meaning, not its bytes) so two USB sticks can be checked against
each other later.

validate-config.py runs before every build and refuses to start on an error, so
a mistake costs seconds not an hour. It catches duplicate ports (including the
music_assistant/pantry_vision 8095 clash that Compose can't see because MA runs
network_mode:host — open decision #31), both hosts on one address, duplicate
hostnames across kiosks and audio endpoints, placeholder tokens (checked before
the length check, so padding "changeme" to 32 chars doesn't pass), a private key
pasted where the public one goes, and a kiosk pointed at a disabled service.

build-all.sh is the normal entry point — the images are a set that has to agree
with itself, so building one is the exception. It builds the core pair, every
kiosk, and every audio endpoint including both architectures (amd64 live-build
ISO and arm64 rpi-image-gen img are different toolchains, not one image).

The two new host ISOs install unattended with everything burnt in, including
service env files generated from derived values — which permanently removes the
class of bug that had chores.env shipping IDENTITY_URL=http://127.0.0.1:8097.
setup-container-host.sh and setup-llm-host.sh now read every config value as
${VAR:-default} so the images configure them without editing.

That also makes every ISO a credential: Wi-Fi PSK, tokens, MQTT and HA
credentials are readable by anyone holding the stick. .gitignore covers the
filled-in CoreSystemConfig.json and build-output/.

Tested: 43 config validation/derivation checks and 44 builder checks against the
real code paths with only `lb` stubbed — every generated env file, preseed,
network config, first-boot unit and build stamp is verified, including that a
port collision refuses the build before writing anything. No ISO has been built;
`lb build` needs live-build, root and a long fetch. tools/README.md says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:22:25 +02:00
Amir Alexander Abdelbaki 564c4a801d Add hosts/llm-host (Ollama) and CalDAV integration notes
Two unchecked items from the README status list that were buildable in-repo
rather than blocked on hardware.

hosts/llm-host/ — Phase 3's LLM machine:
- Auto-detects gpu vs cpu tier (nvidia-smi must both exist AND succeed; a
  leftover driver package on a machine whose card was pulled satisfies only
  the first and would fail later at container start).
- Runs Ollama as a pinned container rather than curl|sh into a root shell,
  matching how everything else here is deployed. Deliberately does NOT install
  the GPU driver — the most hardware/kernel-specific step on that box.
- Sets OLLAMA_HOST=0.0.0.0 inside the container. Ollama binds loopback by
  default, which in Docker means the published port forwards to nothing and
  every caller sees a connection refused indistinguishable from "the host is
  off" — and since every consumer here is built to tolerate exactly that, it
  degrades silently. Same class of bug as chores' 127.0.0.1 env values.
- Takes a position on Ollama contention (open decision #4's resource half):
  MAX_LOADED_MODELS=1 so a 14B text model and a vision model swap predictably
  instead of thrashing VRAM or OOM-ing mid-request, NUM_PARALLEL=1 for
  predictable Assist latency, KEEP_ALIVE=30m so a household that talks to
  Assist a few times an hour isn't paying model-load cost every time.
- Documents that Ollama has NO authentication and its API can delete models,
  not just generate — added to network-integration.md's port table, since the
  network is the entire boundary.

docs/caldav-integration.md — Phase 8's notes:
- The four independent clients and their directions (digest-engine read-only,
  chores' busy-check read-only, trash-calendar create-only under a UID-prefix
  ownership invariant, HA's own bridge).
- Why they share one Nextcloud app password, and the two costs: rotation
  touches three env files plus HA and fails quietly, and the read-only
  invariant is a CODE property, not a permission boundary — an app password
  can't be scoped read-only or per-calendar, so the server would not catch a
  regression that started writing.
- The two traps worth knowing before debugging them: unexpanded recurrence
  reporting a meeting on the day it was created, and CALDAV_VERIFY_TLS=false.

Neither has been run — no Debian machine, no GPU, no live Nextcloud. The script
is syntax-checked and its generated compose validated as YAML for both tiers;
that is the whole of the testing, and both READMEs say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:50:46 +02:00
Amir Alexander Abdelbaki 15e6715469 Document the LAN-only ntfy decision and the WireGuard split tunnel
Records the networking decision behind Phase 6b's arrival notifications so it
doesn't get re-derived later.

ntfy stays LAN-only: 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. No DMZ, no port forward, no certificates, no inter-VLAN rule.

Both alternatives were weighed and rejected, with reasons recorded:
- DMZ + port forward buys only "notifications without the VPN connected," at the
  cost of a public listener, TLS upkeep, and mandatory ntfy auth — an exposed
  topic leaks "the house is empty right now".
- DMZ + NAT reflection does avoid a new inter-VLAN rule (the smart-home VLAN's
  existing outbound-WAN allow already covers traffic to your own public IP), but
  makes a purely internal 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 was the safe direction anyway.

New §2.1 covers the split tunnel itself, including the trap that motivated it:
AllowedIPs must be the actual VLAN prefix, never 192.168.0.0/16, which collides
with typical café/hotel LANs and breaks the phone's connectivity there. Plus
always-on VPN yes / lockdown no, and no DNS= so queries aren't pulled home.

The household uses no Apple devices, which removes the only forcing function for
exposure (ntfy's iOS app needs APNs and therefore an upstream relay). Open
decision #37 closes as a household decision; the Apple case is kept as a
footnote in case that ever changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:42:52 +02:00
Amir Alexander Abdelbaki 0caa21fc42 Add opt-in arrival push notifications; fix chores' unreachable container URLs
Rides the same arrival transition the visit log is built from, so "arrived" has
exactly one definition in this service rather than two that could disagree.

- notify_on_arrival (default off) is the "if enabled" half: push me when someone
  gets home. announce_arrivals (default ON) is a separate per-person opt-OUT of
  being announced, for anyone who doesn't want their comings and goings
  broadcast. The asymmetry is deliberate — if both defaulted off, ticking
  "notify me" would look broken until everyone else opted in too.
- Per-person notify_topic falling back to NTFY_DEFAULT_TOPIC. Topics are
  deduplicated, or a five-person household sharing one topic would get five
  identical pushes per arrival. The arriver is never told about themselves;
  subscribers who are away still are, since that's most of the point.
- The first sample after startup notifies nobody and establishes a baseline
  instead — otherwise a restart after a gap would fire "X just got home" for
  everyone already on the sofa. Costs one missed notification in that window.
- Face-recognition arrivals say "was just recognised at home", not "just got
  home" — the signals aren't equally reliable and the reader should know which.
- Pushes are sent outside _db_lock and isolated from each other, so a hanging
  ntfy can't stall request handling and one bad push can't swallow the rest.
- POST /people/<id>/test-notification, because the alternative way to find a
  typo'd topic is to wait for someone to walk in and notice nothing happened.

identity itself never touches the WAN — it POSTs to the ntfy already in this
stack. Whether the push reaches a phone that's AWAY is a network question, and
docs/network-integration.md's existing answer (WireGuard in, never a
port-forward) applies unchanged; iOS can't do this locally at all, since ntfy's
iOS app needs APNs and therefore an upstream relay. Documented as a table.

Also fixes two pre-existing bugs found while wiring this up: chores' template
pointed IDENTITY_URL and NTFY_URL at 127.0.0.1, which inside that container is
the container itself. chores would have reached neither identity nor ntfy, and
done nothing at all — silently, since both paths fail soft by design. Deployed
chores.env files still carry the old values and need editing by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 09:37:10 +02:00
Amir Alexander Abdelbaki 5c05b67a5a Add identity admin panel: nicknames, visit history, device rights, chore assignment
Extends Phase 6's identity rather than adding a service — all of this is facts
about who someone is, already identity's job.

- admin.html/admin.js: manage people/guests, edit every field, prune, history,
  access log. Deliberately not kiosk-shaped and not linked from any wall panel —
  a panel anyone can walk up to shouldn't carry a "prune these people" button.
- Nicknames are an input alias only. /resolve accepts them; every payload also
  carries speak_name (always the real name) and chores/ uses it for reminders.
  A nickname colliding with anyone's name or nickname is refused; a spoken string
  matching two people refuses with ambiguous_name rather than picking one.
- Visit history sampled from identity's own /presence. home:null writes nothing,
  ever — an HA outage must not record a departure that didn't happen. Departures
  need sustained absence (BLE flaps) and are stamped at the last time actually
  seen. "With whom" is an interval-overlap query, not a second table.
- Pruning splits selecting from deleting: the filter fills the checkboxes, the
  delete goes by explicit id, and the filter is never re-run at delete time.
- Device rights are an answer, never an action — identity stores grants and
  answers GET /device-access; HA asks and HA acts. Deny is the default and the
  only fallback. Grants can expire; every check is logged, denials included.
- Chore assignment prefers an assignee who's home but doesn't let an absent one
  block the chore (CHORE_ASSIGNMENT_STRICT flips that). Litter still ignores
  both exemption and assignment.
- Admin-added identifiers still enforce TRUSTED_ENTITY_PREFIXES; only the
  "in range right now" check is relaxed, never the allowlist.

Resolves open decision #26; adds #34-37 for the new unknowns (DEPARTURE_GRACE_
SECONDS is untuned, the panel has never been opened in a browser).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 09:05:14 +02:00
45 changed files with 7215 additions and 539 deletions

27
.gitignore vendored
View File

@ -89,3 +89,30 @@ hosts/audio-endpoint/rpi-image-gen/rpi-image-gen/
__pycache__/
*.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

View File

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

View File

@ -17,8 +17,7 @@ hosts/
container-host/ Docker Compose stack: HA, Mosquitto, Zigbee2MQTT,
Frigate, Grocy, Node-RED, monitoring, etc.
configs/ Per-service config files (mosquitto.conf, etc.)
scripts/ Host setup / bootstrap scripts
llm-host/ Ollama + GPU host setup (separate physical machine)
llm-host/ Ollama + GPU host (separate physical machine)
thin-client/ Sway kiosk/media-station ISO (live-build) + thinclient-agent
audio-endpoint/ Headless Spotify Connect appliance for rooms with no thin
client — arm64 (rpi-image-gen) + amd64 (live-build) images
@ -40,12 +39,19 @@ firmware/
esp32-s3-touch-lcd-1.85c/ ESPHome voice satellite + status display (round LCD,
media/cover-art priority over an idle weather/time/
date cycle, voice-state visualizer)
tools/ All build + setup scripts, driven by one
CoreSystemConfig.json: twinned container-host/LLM-host
ISO pair, every kiosk ISO, and a validator that refuses
a build on port collisions, placeholder tokens, or a
kiosk pointed at a disabled service
identity/ Person <-> BLE-identifier registry: multi-phone support,
anti-spoofing (allowlisted IRK-resolved/fixed-tag
entities only, never a raw MAC), voice/touch
registration, presence resolution (write API +
frontend/ static serving, consumed by kitchen-display
and door-panel)
registration, presence resolution, visit history
("who was home when, with whom"), nicknames, per-device
rights, and an admin panel (write API + frontend/
static serving, consumed by kitchen-display and
door-panel)
digest-engine/ Quarter-daily LLM digest: mail/message/news/financial
ingestion, LLM synthesis, digest-canvas SDK rendering
admin-canvas/ On-demand sys-admin-llm display surface for the thin
@ -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` §56 and `docs/project-plan.md` open decisions #3233 before relying on any of it
- [ ] Frigate peephole camera config (real RTSP details)
- [ ] Grocy kiosk (Pi + touchscreen) setup
- [ ] LLM host (Ollama) setup script
- [ ] CalDAV / Nextcloud calendar integration notes
- [ ] LLM host (Ollama) setup script`hosts/llm-host/` written: auto-detects a GPU vs CPU-only tier, runs Ollama as a pinned container (no `curl | sh` into root), pulls the Phase 3 text model + a vision model for `pantry-vision`/`chores`, and takes a reasoned position on Assist-vs-digest GPU contention (`MAX_LOADED_MODELS=1` — swap predictably rather than OOM mid-request). **Never run — no Debian machine, no GPU, no Ollama server**; the script is syntax-checked and its generated compose validated as YAML for both tiers, and that's the whole of the testing. The vision-model pick is still unmade (open decision #18), and the contention defaults are reasoned, not measured (#4) — see `hosts/llm-host/README.md`
- [x] CalDAV / Nextcloud calendar integration notes — `docs/caldav-integration.md`: the four independent clients (digest-engine read-only, chores' busy-check read-only, trash-calendar create-only, HA's own bridge), why they share one Nextcloud **app password** and what that costs (rotation touches three env files; **the read-only invariant is a code property, not a permission boundary** — an app password can't be scoped read-only or to one calendar), `trash-calendar`'s UID-prefix ownership invariant, and the two traps that cost real debugging time (unexpanded recurrence reporting a meeting on the day it was created; `CALDAV_VERIFY_TLS=false`). Nextcloud itself is pre-existing — nothing here deploys it
- [x] Sway thin-client ISO (live-build) + thinclient-agent — built, not yet boot-tested on real hardware; RDP replaced by wayvnc (resolved), remaining open items (mic-enabled rooms, exact hardware target, wayvnc password provisioning) in `docs/project-plan.md` §4
- [ ] Thin-client follow-ups in progress: fullscreen-aware now-playing widget (cover art + controls), minimal Firefox chrome + uBlock Origin/SponsorBlock, persistent audio-output selection, outbound RDP/VNC client (`rdp-vnc.json`), HA mobile-app browser remote control (text input + mouse buttons), capture-card ("receiver box") video source selection on a new `5:capture` workspace, idle-gallery weather/clock overlay (clock always, weather via a new `smarthome/weather/current` MQTT topic an HA automation has to publish) — built, not yet tried against real capture-card hardware or a real weather automation, see `hosts/thin-client/README.md`
- [x] Quarter-daily digest engine (mail/Signal/Telegram/Discord/WhatsApp, news, financial ingestion; LLM synthesis; digest-canvas SDK) — built and wired into `setup-container-host.sh` (`ENABLE_DIGEST_ENGINE`, off by default), not yet run against real credentials; household/calendar ingest (CalDAV/Grocy) still needs a real data source wired in, see `docs/project-plan.md` §4
@ -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`
- [ ] 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` 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"
- [ ] `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
- [ ] `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
Everything is built from **one** config file — `CoreSystemConfig.json` — so an address
or token can only ever be wrong in one place:
```bash
cd hosts/container-host/scripts
sudo ./setup-container-host.sh
cp CoreSystemConfig.json.template CoreSystemConfig.json
$EDITOR CoreSystemConfig.json # subnet, two host octets, tokens, your kiosks
tools/validate-config.py # catches typos in seconds, not after a 40-min build
sudo -E tools/build-all.sh # every ISO, all agreeing with each other
```
The container host and LLM host come out as a **twinned pair**: you give each a last
octet, and the container host's `OLLAMA_HOST` is *derived* from the LLM host's — change
one and the other follows on the next build, with nothing to keep in sync by hand. Every
kiosk's service URLs derive from the container host's address the same way. See
[`tools/README.md`](tools/README.md).
Built ISOs contain every secret in the config, by design (nothing to configure
post-install) — which makes each one a credential. `.gitignore` covers both the
filled-in config and `iso-out/`.
To set up a host by hand instead of from an ISO:
```bash
sudo -E tools/setup-container-host.sh
```
Edit the variables at the top of the script first (timezone, Zigbee USB device

View File

@ -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
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
nudges whoever `identity` reports home right now (minus anyone `chore_exempt`,
see below). If the chore is still open `NEGLECT_THRESHOLD_HOURS` after the last
nudges whoever `identity` reports home right now — preferring anyone **assigned**
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
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
@ -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
`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
Set via `identity`'s `POST /people/<id>/chore-settings` (see identity/README.md,
no frontend for it yet — call the endpoint directly). A `chore_exempt` person is
Set in the same place (`identity`'s admin panel, or `POST /people/<id>/chore-settings`
directly). A `chore_exempt` person is
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`
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
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.
- **No frontend for setting `chore_exempt`/`chore_reminder_style`** — set via a
direct `POST /people/<id>/chore-settings` call to `identity` until one exists.
- **No confirmation that an assignee actually did it** — assignment changes who gets
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

View File

@ -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
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
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()`.
@ -135,6 +153,18 @@ _ATTRIBUTE_TO_RECENT_VIEWER = {"litter"}
# — so they're kept as separate names rather than reusing one for both purposes.
_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:
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
def _presence() -> list[dict]:
"""Each dict: {"name", "home", "room", "face_seen_recently", "chore_exempt",
"chore_reminder_style"} the raw shape identity's own /presence returns. Empty
list (never raises past this point) if identity is unreachable nudging just
waits for the next run.
"""Each dict: {"name", "speak_name", "nickname", "home", "room",
"face_seen_recently", "chore_exempt", "chore_reminder_style", "chore_assignments"}
the raw shape identity's own /presence returns. Empty list (never raises past
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:
return []
@ -365,6 +400,34 @@ def _household_currently_busy() -> bool:
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:
"""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
@ -408,6 +471,24 @@ def nudge_open_chores(conn) -> None:
)
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
# 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
@ -420,7 +501,12 @@ def nudge_open_chores(conn) -> None:
different = [p for p in eligible if p["name"] != last_nudged]
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(
"UPDATE chores SET assigned_to = ?, assigned_at = ?, reminder_count = reminder_count + 1, "
"last_reminder_at = ? WHERE id = ?",

View File

@ -8,9 +8,17 @@
# ---------------------------------------------------------------------------
# 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=
# ---------------------------------------------------------------------------
@ -73,9 +81,29 @@ OLLAMA_TEXT_MODEL=
# redirecting the nudge to someone else who's around ("the next person that walks
# 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
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
LOG_LEVEL=INFO

View File

@ -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.
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.
## Merging an unviewed digest into the next one

159
docs/caldav-integration.md Normal file
View File

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

View File

@ -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
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
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 |
| 1880 | Node-RED | Yes | Node-RED's own login (if enabled) |
| 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 |
| 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 |
@ -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
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
This entire document was written against `setup-container-host.sh`'s current

View File

@ -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 storage | **Grocy** (already running, Phase 1/7) | The single source of truth for stock/best-before-dates/recipes; `pantry-vision` is a client of Grocy's own REST API, not a replacement for it |
| Kitchen-display static serving | **pantry-web** (nginx:alpine) | Serves `pantry-vision/frontend/`'s Scan/Inventory/Recipes single-page app read-only to the kitchen display — same role `digest-web`/`admin-web` play for their own hosts |
| Identity registry backend | **identity** (custom Python, stdlib `http.server` + `sqlite3`) | Person <-> BLE-identifier registry: registration (voice or touchscreen), presence resolution, a weather proxy. **Published**, unlike admin-canvas — `hosts/kitchen-display/`'s and `hosts/door-panel/`'s kiosk browsers call it directly, bearer-token gated |
| Identity static serving | **identity-web** (nginx:alpine) | Serves `identity/frontend/`'s `register.html`/`dashboard.html` read-only — same role as `pantry-web`/`digest-web`/`admin-web` |
| 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`/`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 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 |
@ -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.**
### 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.
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.
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
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
`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
1. Deploy Grocy via Compose.
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.
### 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.
2. Confirm read + write (`calendar.create_event`) both work.
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.
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
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 12 cameras.
### 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.
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:
@ -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`.
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.
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.
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`.
@ -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.
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.
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 812GB, 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.
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.
@ -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.
20. **`identity`'s `TRUSTED_ENTITY_PREFIXES` default is a guess, and it's the single highest-risk unknown in Phase 6** (new) — the whole anti-spoofing design rests on this allowlist actually matching real Private BLE Device / fixed-tag entity IDs; until it's checked against Developer Tools -> States on a real HA instance, registration will most likely just report "no candidate" for everything. Same open dependency as §1.5's original Bermuda/Private BLE Device setup, which itself has never been built (see the top-level README status checklist).
21. **Identity's HA-side voice wiring (custom sentence + intent script + `rest_command`) is written from HA's documented shape, not tested** (new, Phase 6) — `identity/README.md` has the worked example; nothing under this repo builds or verifies it, same convention as admin-canvas's/digest-engine's own HA-side integration points.
22. **The floor-plan UI itself doesn't exist** (new, Phase 6) — `identity`'s `/presence` reports a best-effort `room` per person as groundwork, but there is no floor-plan image, room↔coordinate mapping, or rendering anywhere in this repo, and `AREA_ATTRIBUTE`'s default is an unconfirmed guess at what Bermuda actually publishes. Needs a real floor plan and room list before there's anything to design a coordinate format against — deliberately deferred rather than built against a guess.
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 01 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.
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`.
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.
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.
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.
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.

View File

@ -66,7 +66,7 @@ device already on the network).
## Multiple rooms
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
status always on the specific room the device is in" to actually hold:

View File

@ -11,7 +11,7 @@ paths genuinely differ (see below) — not one image with a flag.
| Audio hardware | HiFiBerry Amp2 HAT (GPIO, 60W, drives 48Ω 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) |
| 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` +
`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
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
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.
## Spotify Connect install: apt-first, documented fallback
@ -89,14 +89,28 @@ a `.img` under that clone's `work/` directory.
### Per-room identity
**Build one generic image, not one per room.** Raspberry Pi Imager's own **OS
Customisation** dialog (gear icon, or Ctrl+Shift+X) works on *any* `.img`,
including local custom ones — hostname, Wi-Fi SSID/password, and an SSH key, all
set per-flash without touching the image itself. Use it on this image, once per
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 unit's Spotify Connect device name in the picker — no separate config file,
no rebuild per room.
**The hostname is baked in, one image per room** — same as every other image in
this repo. `tools/build-audio-endpoint-image-arm64.sh` substitutes the endpoint's
hostname from `CoreSystemConfig.json`'s `audio_endpoints` array into a generated
copy of `config/audio-endpoint.yaml`, and `build-all.sh` builds one per entry.
That's a change from the original design, which built one generic `.img` and left
identity to Raspberry Pi Imager's **OS Customisation** dialog at flash time. Imager
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
@ -115,7 +129,7 @@ cd hosts/audio-endpoint/live-build-amd64/scripts
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
`../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

View File

@ -53,10 +53,17 @@ hooks:
chroot:
- ../scripts/install-spotify-connect.sh
# Left at the tool's own default — Raspberry Pi Imager's OS Customisation
# dialog overrides this per physical unit after the image is built (see
# hosts/audio-endpoint/README.md's "Per-room identity" section). Do not
# hardcode a room name here; spotify-connect-start reads $(hostname) at
# service-start time, whatever it ends up being set to per unit.
# REWRITTEN PER ROOM AT BUILD TIME. tools/build-audio-endpoint-image-arm64.sh
# substitutes the endpoint's hostname from CoreSystemConfig.json into the `set:`
# line below and writes a generated-<hostname>.yaml alongside this file; this
# value is only the fallback for a hand-run build.
#
# 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:
set: audio-endpoint

View File

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

View File

@ -42,11 +42,11 @@ non-specific) hardware line item.
## Before you build
Deploy `identity`/`identity-web` first (`ENABLE_IDENTITY` in
`hosts/container-host/scripts/setup-container-host.sh`) — this image builds and boots
`tools/setup-container-host.sh`) — this image builds and boots
fine without it, but the dashboard will show connection errors until it exists.
`pantry-vision` is optional (only "Running low" needs it). Then edit the
`# CONFIGURATION` block at the top of
[`scripts/build-door-panel-iso.sh`](scripts/build-door-panel-iso.sh):
[`tools/build-door-panel-iso.sh`](../../tools/build-door-panel-iso.sh):
| Variable | What to put in it |
|---|---|
@ -60,7 +60,7 @@ fine without it, but the dashboard will show connection errors until it exists.
## Build
```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

View File

@ -47,10 +47,10 @@ angle can be adjusted independently of the screen. See §1.15 of
## Before you build
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
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 |
|---|---|
@ -66,7 +66,7 @@ the `# CONFIGURATION` block at the top of
## Build
```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

145
hosts/llm-host/README.md Normal file
View File

@ -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 812GB; 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 58GB) 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.

View File

@ -35,7 +35,7 @@ What ends up on the image:
> not turn it on until that list exists.
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 |
|---|---|
@ -53,7 +53,7 @@ Then edit the `# CONFIGURATION` block at the top of
## Build
```sh
sudo ./scripts/build-thin-client-iso.sh
sudo -E tools/build-thin-client-iso.sh
```
It installs `live-build` if missing, regenerates

View File

@ -34,7 +34,7 @@ What ends up on the image:
> handles either way a real touchscreen might show up to Linux.
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 |
|---|---|
@ -47,7 +47,7 @@ Edit the `# CONFIGURATION` block at the top of
## Build
```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

View File

@ -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
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 01, 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
`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
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/`
Two per-person fields, set via `POST /people/<id>/chore-settings`. **No frontend
for this exists yet** — neither `register.html` nor `dashboard.html` expose a way
to set them — call the endpoint directly (an HA script/automation, or `curl`) until
one is built. Read by `chores/` off `GET /presence`:
Three per-person things live here, not in `chores/`. All of them are editable in the
admin panel (which is what open decision #26 was waiting for), and `chores/` reads
all of them off the same `GET /presence` call it already made.
**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
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,
un-styled template with no LLM call at all.
Both fields live on `people` (not a separate table) because they're household-
standing facts about a person, same category as their name or photo — `identity` is
already this project's source of truth for who someone is, so this is where "how do
I relate to this specific household member" facts belong, not duplicated into
`chores/`'s own database.
Both of those live as columns on `people` (not a separate table) because they're
household-standing facts about a person, same category as their name or photo —
`identity` is already this project's source of truth for who someone is, so this is
where "how do I relate to this specific household member" facts belong, not duplicated
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
@ -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` | `{"name", "device_id", "photo_id"?, "entity_id"?, "no_device"?}` -> registers, or returns a reason it couldn't (see above) |
| `POST /register/guest` | `{"device_id", "photo_id"?}` -> registers "Guest N", no name needed |
| `GET /people` | admin/audit list of every registered person + their identifiers + `has_photo` |
| `GET /people` | 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 |
| `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>` | 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 01 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 /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 |
**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
`/presence` degrades to `null` silently if it's wrong, so this could easily go
unnoticed until someone builds the actual floor-plan UI and finds it empty.
8. The blob+`createObjectURL()` profile-picture fetch (both frontends) has not been
checked for a memory leak from never calling `URL.revokeObjectURL()` on the old
8. The blob+`createObjectURL()` profile-picture fetch (all three frontends) has not
been checked for a memory leak from never calling `URL.revokeObjectURL()` on the old
blob URL when `/people`/`/presence` refreshes and re-fetches the same photo —
likely fine at household scale and dashboard.js's 60s poll cadence, not measured
over a multi-day uptime.
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.

View File

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

963
identity/frontend/admin.js Normal file
View File

@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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();

View File

@ -194,3 +194,426 @@ section.block h2 {
background: rgba(110, 168, 254, 0.14);
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;
}

View File

@ -55,11 +55,67 @@ MQTT_PASSWORD=
FRIGATE_EVENTS_TOPIC=frigate/events
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
# ---------------------------------------------------------------------------
IDENTITY_PORT=8097
IDENTITY_DB_PATH=/data/identity.db
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
LOG_LEVEL=INFO

File diff suppressed because it is too large Load Diff

View File

@ -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.
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.
## `/identify` never writes anything by itself
@ -106,7 +106,7 @@ above, and adjust `server.py` if the shapes differ.
## 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
and its own README. It builds two containers: `pantry-vision` (this API) and
`pantry-web` (nginx, serves `frontend/` read-only).

175
tools/README.md Normal file
View File

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

210
tools/build-all.sh Executable file
View File

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

View File

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

View File

@ -1,7 +1,7 @@
#!/usr/bin/env bash
#
# 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
# 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
@ -12,24 +12,34 @@
# 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
# THINCLIENT_NAME/IMAGE_HOSTNAME. Re-run this script once per room, changing
# IMAGE_HOSTNAME each time.
set -euo pipefail
# ---------------------------------------------------------------------------
# CONFIGURATION — edit before running, then re-run per room
# ---------------------------------------------------------------------------
DEBIAN_RELEASE="bookworm" # Matches the container/thin-client hosts' OS
IMAGE_HOSTNAME="audio-endpoint-livingroom" # <-- EDIT per room; also the Spotify
# Connect device name (see README)
# ---------------------------------------------------------------------------
# Paths
# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
#
# Rooms are entries in the config's "audio_endpoints" list, so adding a second one is
# adding a list entry rather than editing and re-running this script with a different
# hostname — which is how per-room images drifted apart before.
#
# sudo -E tools/build-audio-endpoint-iso-amd64.sh # the only amd64 endpoint
# sudo -E tools/build-audio-endpoint-iso-amd64.sh <hostname> # a specific one
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIVE_BUILD_AMD64_DIR="$(dirname "$SCRIPT_DIR")"
AUDIO_ENDPOINT_DIR="$(dirname "$LIVE_BUILD_AMD64_DIR")"
# shellcheck source=lib/coreconfig.sh
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"
LIVE_BUILD_DIR="$LIVE_BUILD_AMD64_DIR"
LIVE_BUILD_DIR="${AUDIO_ENDPOINT_DIR}/live-build-amd64"
INCLUDES="${LIVE_BUILD_DIR}/config/includes.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="${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 "=== Done ==="

381
tools/build-container-host-iso.sh Executable file
View File

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

109
tools/build-core-pair.sh Executable file
View File

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

View File

@ -21,58 +21,58 @@
# hosts/kitchen-display's twin: same one-workspace-two-kiosk-destinations shape,
# different default content and a mic that's actually expected to be used.
#
# Run as: sudo ./build-door-panel-iso.sh
# 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
# ---------------------------------------------------------------------------
# CONFIGURATION — edit these before running
# ---------------------------------------------------------------------------
DEBIAN_RELEASE="bookworm"
KIOSK_USERNAME="kiosk"
IMAGE_HOSTNAME="door-panel"
DOOR_PANEL_NAME="Door panel"
KEYBOARD_LAYOUT="de"
ENABLE_INSTALLER="false"
# --- Where the door panel talks to -------------------------------------------
MQTT_BROKER_HOST="192.168.1.10" # <-- EDIT: container-host IP running Mosquitto
MQTT_BROKER_PORT="1883"
MQTT_USERNAME=""
MQTT_PASSWORD=""
# identity's dashboard/registration pages and API — ENABLE_IDENTITY in
# setup-container-host.sh. Placeholders until that's deployed; the image builds and
# boots fine without it, the kiosk window just shows a connection error.
IDENTITY_WEB_URL="http://192.168.1.10:8098" # <-- EDIT once identity-web is deployed
IDENTITY_URL="http://192.168.1.10:8097" # <-- EDIT once identity is deployed
# Must match identity/identity.env's own token — no way for this repo to push it
# between the two hosts for you.
IDENTITY_TOKEN="" # <-- EDIT
# pantry-vision — only needed for the dashboard's "Running low" section; the rest of
# the dashboard (weather, who's home) works without it. Same placeholder handling.
PANTRY_VISION_URL="http://192.168.1.10:8095" # <-- EDIT once pantry-vision is deployed
PANTRY_VISION_TOKEN="" # <-- EDIT: must match pantry-vision's own token
# --- Voice registration ("register me as <name>") — this device's whole point, so
# --- true is the expected real-deployment value, unlike hosts/kitchen-display's
# --- identical flag — but still requires a real mic on this specific unit.
ENABLE_VOICE_SATELLITE="true"
VOICE_SATELLITE_NAME="Door panel"
VOICE_WAKE_WORD="ok_nabu"
SSH_AUTHORIZED_KEY=""
# ---------------------------------------------------------------------------
# Paths
# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
#
# There is nothing to edit here any more. Every value below is read from the one
# config at the repo root, so an address or token can only be wrong in a single
# place. Change it there and rebuild; see tools/README.md.
#
# sudo -E tools/build-door-panel-iso.sh # the only door-panel in the config
# sudo -E tools/build-door-panel-iso.sh <hostname> # a specific one, if several are defined
#
# 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.
# ---------------------------------------------------------------------------
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"
AGENT_DIR="${DOOR_PANEL_DIR}/agent"
LIVE_BUILD_DIR="${DOOR_PANEL_DIR}/live-build"
@ -106,20 +106,8 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then
exit 1
fi
if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then
echo "Warning: MQTT_BROKER_HOST is still the placeholder IP — edit it before building."
fi
if [[ -z "$IDENTITY_TOKEN" ]]; then
echo "Warning: IDENTITY_TOKEN is empty. The dashboard will load but every call to"
echo " identity will fail (401) until this matches the token in"
echo " identity/identity.env on the container host."
fi
if [[ -z "$PANTRY_VISION_TOKEN" ]]; then
echo "Warning: PANTRY_VISION_TOKEN is empty. 'Running low' will show as unconfigured"
echo " until this matches the token in pantry-vision/pantry-vision.env."
fi
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
echo "Note: ENABLE_VOICE_SATELLITE=true — this image expects a real microphone on"
@ -192,8 +180,8 @@ fi
# ---------------------------------------------------------------------------
echo "--- Writing /etc/door-panel-agent/config.env into includes.chroot ---"
cat > "$INCLUDES/etc/door-panel-agent/config.env" <<EOF
# Generated by hosts/door-panel/scripts/build-door-panel-iso.sh — do not hand-edit
# here; edit the CONFIGURATION block in that script and rebuild.
# Generated by tools/build-door-panel-iso.sh — do not hand-edit
# here; change CoreSystemConfig.json at the repo root and rebuild.
KIOSK_USERNAME=${KIOSK_USERNAME}
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="${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 "=== Done ==="

View File

@ -20,61 +20,59 @@
# its live-build tree — same relationship hosts/touch-panel and hosts/audio-endpoint
# 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
# ---------------------------------------------------------------------------
# CONFIGURATION — edit these before running
# ---------------------------------------------------------------------------
DEBIAN_RELEASE="bookworm"
KIOSK_USERNAME="kiosk"
IMAGE_HOSTNAME="kitchen-display"
KITCHEN_DISPLAY_NAME="Kitchen fridge display"
KEYBOARD_LAYOUT="de"
ENABLE_INSTALLER="false"
# --- 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
# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
#
# There is nothing to edit here any more. Every value below is read from the one
# config at the repo root, so an address or token can only be wrong in a single
# place. Change it there and rebuild; see tools/README.md.
#
# sudo -E tools/build-kitchen-display-iso.sh # the only kitchen-display in the config
# sudo -E tools/build-kitchen-display-iso.sh <hostname> # a specific one, if several are defined
#
# 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.
# ---------------------------------------------------------------------------
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"
AGENT_DIR="${KITCHEN_DISPLAY_DIR}/agent"
LIVE_BUILD_DIR="${KITCHEN_DISPLAY_DIR}/live-build"
@ -108,21 +106,8 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then
exit 1
fi
if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then
echo "Warning: MQTT_BROKER_HOST is still the placeholder IP — edit it before building."
fi
if [[ -z "$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
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 ---"
cat > "$INCLUDES/etc/kitchen-display-agent/config.env" <<EOF
# Generated by hosts/kitchen-display/scripts/build-kitchen-display-iso.sh — do not
# hand-edit here; edit the CONFIGURATION block in that script and rebuild.
# Generated by tools/build-kitchen-display-iso.sh — do not
# hand-edit here; change CoreSystemConfig.json and rebuild.
KIOSK_USERNAME=${KIOSK_USERNAME}
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="${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 "=== Done ==="

239
tools/build-llm-host-iso.sh Executable file
View File

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

View File

@ -18,84 +18,59 @@
# generated tree that actually gets baked into the image). Never hand-edit anything
# 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
# ---------------------------------------------------------------------------
# CONFIGURATION — edit these before running
# ---------------------------------------------------------------------------
DEBIAN_RELEASE="bookworm" # Matches the container host's OS
KIOSK_USERNAME="kiosk" # The autologin account the whole image is built around
IMAGE_HOSTNAME="thin-client" # Hostname baked into the image
THINCLIENT_NAME="Living room thin client" # Friendly name shown on the HA device
# Console + Sway keyboard layout. This IS the "installer choice" in this image's
# 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
# build variable is what stands in for it — build one ISO per keyboard layout you need.
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
# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
#
# There is nothing to edit here any more. Every value below is read from the one
# config at the repo root, so an address or token can only be wrong in a single
# place. Change it there and rebuild; see tools/README.md.
#
# sudo -E tools/build-thin-client-iso.sh # the only thin-client in the config
# sudo -E tools/build-thin-client-iso.sh <hostname> # a specific one, if several are defined
#
# 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.
# ---------------------------------------------------------------------------
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"
AGENT_DIR="${THIN_CLIENT_DIR}/agent"
LIVE_BUILD_DIR="${THIN_CLIENT_DIR}/live-build"
@ -134,23 +109,8 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then
exit 1
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
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 ---"
cat > "$INCLUDES/etc/thinclient-agent/config.env" <<EOF
# Generated by hosts/thin-client/scripts/build-thin-client-iso.sh — do not hand-edit
# here; edit the CONFIGURATION block in that script and rebuild.
# Generated by tools/build-thin-client-iso.sh — do not hand-edit
# here; change CoreSystemConfig.json at the repo root and rebuild.
KIOSK_USERNAME=${KIOSK_USERNAME}
THINCLIENT_NAME=${THINCLIENT_NAME}
@ -368,6 +328,9 @@ lb build
ISO_PATH="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name '*.iso' -print -quit)"
ISO_PATH="${ISO_PATH:-${LIVE_BUILD_DIR}/live-image-amd64.hybrid.iso}"
# 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 "=== Done ==="
@ -452,7 +415,7 @@ fi
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 " 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
echo "Then pull the power on the container host and re-check: the kiosk session must"

View File

@ -23,41 +23,54 @@
# generated tree that actually gets baked into the image). Never hand-edit anything
# 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
# ---------------------------------------------------------------------------
# CONFIGURATION — edit these before running
# ---------------------------------------------------------------------------
DEBIAN_RELEASE="bookworm" # Matches the container host's OS
KIOSK_USERNAME="kiosk" # The autologin account the whole image is built around
IMAGE_HOSTNAME="touch-panel" # Hostname baked into the image
TOUCHPANEL_NAME="Kitchen touch panel" # Friendly name shown on the HA device
KEYBOARD_LAYOUT="de" # xkb layout name (`localectl list-x11-keymap-layouts`)
ENABLE_INSTALLER="false" # "true" adds a debian-installer to the ISO (install to disk)
# --- 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
# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
#
# There is nothing to edit here any more. Every value below is read from the one
# config at the repo root, so an address or token can only be wrong in a single
# place. Change it there and rebuild; see tools/README.md.
#
# sudo -E tools/build-touch-panel-iso.sh # the only touch-panel in the config
# sudo -E tools/build-touch-panel-iso.sh <hostname> # a specific one, if several are defined
#
# 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.
# ---------------------------------------------------------------------------
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"
AGENT_DIR="${TOUCH_PANEL_DIR}/agent"
LIVE_BUILD_DIR="${TOUCH_PANEL_DIR}/live-build"
@ -96,17 +109,7 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then
exit 1
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 "=== Smart Home Touch-Panel ISO Builder ==="
@ -199,8 +202,8 @@ fi
# ---------------------------------------------------------------------------
echo "--- Writing /etc/touchpanel-agent/config.env into includes.chroot ---"
cat > "$INCLUDES/etc/touchpanel-agent/config.env" <<EOF
# Generated by hosts/touch-panel/scripts/build-touch-panel-iso.sh — do not hand-edit
# here; edit the CONFIGURATION block in that script and rebuild.
# Generated by tools/build-touch-panel-iso.sh — do not hand-edit
# here; change CoreSystemConfig.json at the repo root and rebuild.
KIOSK_USERNAME=${KIOSK_USERNAME}
TOUCHPANEL_NAME=${TOUCHPANEL_NAME}
@ -252,6 +255,9 @@ lb build
ISO_PATH="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name '*.iso' -print -quit)"
ISO_PATH="${ISO_PATH:-${LIVE_BUILD_DIR}/live-image-amd64.hybrid.iso}"
# 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 "=== Done ==="

168
tools/config-export.py Executable file
View File

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

216
tools/lib/coreconfig.sh Normal file
View File

@ -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 $*"
}

View File

@ -55,19 +55,25 @@
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
TIMEZONE="Europe/Vienna" # Adjust to your timezone
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
BASE_DIR="${BASE_DIR:-/opt/smart-home}" # Where all container config/data will live
TIMEZONE="${TIMEZONE:-Europe/Vienna}" # Adjust to your timezone
ENABLE_MEALIE="${ENABLE_MEALIE:-false}" # Set to "true" to also deploy Mealie
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 ---
ENABLE_NODERED="true"
ENABLE_NETDATA="true"
ENABLE_HOMEPAGE="true"
ENABLE_NTFY="true"
ENABLE_PORTAINER="true"
ENABLE_NODERED="${ENABLE_NODERED:-true}"
ENABLE_NETDATA="${ENABLE_NETDATA:-true}"
ENABLE_HOMEPAGE="${ENABLE_HOMEPAGE:-true}"
ENABLE_NTFY="${ENABLE_NTFY:-true}"
ENABLE_PORTAINER="${ENABLE_PORTAINER:-true}"
# --- 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
@ -75,43 +81,43 @@ ENABLE_PORTAINER="true"
# auto-generated: the identical value has to be typed into
# /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.
ENABLE_GALLERY_SMB="false"
GALLERY_SMB_USERNAME="gallery"
GALLERY_SMB_PASSWORD="" # <-- SET THIS before flipping the toggle above
ENABLE_GALLERY_SMB="${ENABLE_GALLERY_SMB:-false}"
GALLERY_SMB_USERNAME="${GALLERY_SMB_USERNAME:-gallery}"
GALLERY_SMB_PASSWORD="${GALLERY_SMB_PASSWORD:-}" # <-- SET THIS before flipping the toggle above
# --- 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
# external/USB drive mount, or a NAS mount), or a remote target restic
# supports (s3:..., sftp:..., b2:..., rest:...). See https://restic.net
ENABLE_BACKUPS="false"
RESTIC_REPOSITORY="/mnt/backup/smart-home-restic"
BACKUP_SCHEDULE="03:30" # systemd OnCalendar time, daily at this local time
ENABLE_BACKUPS="${ENABLE_BACKUPS:-false}"
RESTIC_REPOSITORY="${RESTIC_REPOSITORY:-/mnt/backup/smart-home-restic}"
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) ---
# 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.
# 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
# --- 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
# platforms. Read digest-engine/README.md before setting this to "true" — real
# 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).
DIGEST_ENGINE_SRC="/opt/smart-home/src/digest-engine"
DIGEST_WEB_PORT="8091" # LAN-facing read-only static serving
DIGEST_SCHEDULE="00,06,12,18" # systemd OnCalendar hours, 4x/day
DIGEST_ENGINE_SRC="${DIGEST_ENGINE_SRC:-/opt/smart-home/src/digest-engine}"
DIGEST_WEB_PORT="${DIGEST_WEB_PORT:-8091}" # LAN-facing read-only static serving
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
# --- 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).
ADMIN_CANVAS_SRC="/opt/smart-home/src/admin-canvas"
ADMIN_CANVAS_PORT="8092" # internal only — no `ports:` mapping, HA-reachable only
ADMIN_WEB_PORT="8094" # LAN-facing read-only static serving
ADMIN_CANVAS_SRC="${ADMIN_CANVAS_SRC:-/opt/smart-home/src/admin-canvas}"
ADMIN_CANVAS_PORT="${ADMIN_CANVAS_PORT:-8092}" # internal only — no `ports:` mapping, HA-reachable only
ADMIN_WEB_PORT="${ADMIN_WEB_PORT:-8094}" # LAN-facing read-only static serving
# --- 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.
@ -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
# bearer token is the actual boundary here, not network placement — see
# 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).
PANTRY_VISION_SRC="/opt/smart-home/src/pantry-vision"
PANTRY_VISION_PORT="8095" # LAN-facing — the kitchen display's kiosk browser calls this directly
PANTRY_WEB_PORT="8096" # LAN-facing read-only static serving (the kiosk's frontend)
PANTRY_VISION_SRC="${PANTRY_VISION_SRC:-/opt/smart-home/src/pantry-vision}"
PANTRY_VISION_PORT="${PANTRY_VISION_PORT:-8095}" # LAN-facing — the kitchen display's kiosk browser calls this directly
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
# --- IDENTITY_TOKEN, HA_TOKEN, and TRUSTED_ENTITY_PREFIXES are provisioned. See
# --- identity/README.md. Same "published, unlike admin-canvas" reasoning as
# --- ENABLE_PANTRY_VISION above — hosts/kitchen-display's and hosts/door-panel's
# --- kiosk browsers call this directly.
ENABLE_IDENTITY="false"
ENABLE_IDENTITY="${ENABLE_IDENTITY:-false}"
# Where this repo's identity/ directory lives on THIS host (build context).
IDENTITY_SRC="/opt/smart-home/src/identity"
IDENTITY_PORT="8097" # LAN-facing — kiosk browsers call this directly
IDENTITY_WEB_PORT="8098" # LAN-facing read-only static serving (register.html/dashboard.html)
IDENTITY_SRC="${IDENTITY_SRC:-/opt/smart-home/src/identity}"
IDENTITY_PORT="${IDENTITY_PORT:-8097}" # LAN-facing — kiosk browsers call this directly
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
# --- CALDAV_TARGET_CALENDAR are provisioned. See trash-calendar/README.md. A oneshot,
# --- 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).
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
# --- until TRANSIT_TOKEN and GTFS_FEED_URL are provisioned. See transit/README.md.
# --- Published like pantry-vision/identity — homeassistant's network_mode: host
# --- 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).
TRANSIT_SRC="/opt/smart-home/src/transit"
TRANSIT_PORT="8099" # reachable by HA's rest_command (voice lookups)
TRANSIT_SRC="${TRANSIT_SRC:-/opt/smart-home/src/transit}"
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
# --- 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
# --- 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.
ENABLE_TRIP_PLANNING="false"
OTP_GRAPHS_DIR="/opt/smart-home/otp-graphs" # you populate this by hand, see above
ENABLE_TRIP_PLANNING="${ENABLE_TRIP_PLANNING:-false}"
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
# frontend (always-on, below) already publishes 8080:8080; OTP's own container-
# 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
# 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) —
# --- off by default. Works with just ENABLE_IDENTITY on (assignment) and ntfy
# --- (reminders); the camera-check and trash-day-eve steps each individually
# --- 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).
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) ---------
# 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
# 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.
ENABLE_MUSIC_ASSISTANT="false"
ENABLE_MUSIC_ASSISTANT="${ENABLE_MUSIC_ASSISTANT:-false}"
# ---------------------------------------------------------------------------
# Sanity checks
@ -1414,6 +1420,9 @@ if [[ "$ENABLE_IDENTITY" == "true" ]]; then
echo " Identity API : http://${HOST_IP}:${IDENTITY_PORT} (bearer-token gated)"
echo " Register page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/register.html?api=http://${HOST_IP}:${IDENTITY_PORT}&token=<IDENTITY_TOKEN>&device=<name>"
echo " Dashboard page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/dashboard.html?identity_api=http://${HOST_IP}:${IDENTITY_PORT}&identity_token=<IDENTITY_TOKEN>"
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
if [[ "$ENABLE_GALLERY_SMB" == "true" ]]; then
echo " Gallery SMB : \\\\${HOST_IP}\\gallery (user: ${GALLERY_SMB_USERNAME})"
@ -1465,7 +1474,7 @@ fi
if [[ "$ENABLE_PANTRY_VISION" == "true" ]]; then
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 " 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 " 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"

326
tools/setup-llm-host.sh Executable file
View File

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

403
tools/validate-config.py Executable file
View File

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