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>
digest-per-person-and-agendas
Amir Alexander Abdelbaki 2026-07-31 13:22:25 +02:00
parent 564c4a801d
commit ea82ee70ad
29 changed files with 2371 additions and 350 deletions

25
.gitignore vendored
View File

@ -89,3 +89,28 @@ hosts/audio-endpoint/rpi-image-gen/rpi-image-gen/
__pycache__/ __pycache__/
*.pyc *.pyc
# ---------------------------------------------------------------------------
# Build system (tools/) — see tools/README.md
#
# CoreSystemConfig.json is the FILLED-IN config: Wi-Fi PSK, every service token,
# MQTT and HA credentials. Only the .template is tracked. Never commit a real one.
CoreSystemConfig.json
CoreSystemConfig.*.json
!CoreSystemConfig.json.template
# Built images. These have every secret above burnt into them by design, so they are
# credential-bearing artifacts, not just large ones.
build-output/
*.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

View File

@ -0,0 +1,189 @@
{
"_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": "output_dir is where finished ISOs land. It is gitignored — see .gitignore.",
"output_dir": "build-output"
}
}

View File

@ -17,8 +17,7 @@ hosts/
container-host/ Docker Compose stack: HA, Mosquitto, Zigbee2MQTT, container-host/ Docker Compose stack: HA, Mosquitto, Zigbee2MQTT,
Frigate, Grocy, Node-RED, monitoring, etc. Frigate, Grocy, Node-RED, monitoring, etc.
configs/ Per-service config files (mosquitto.conf, etc.) configs/ Per-service config files (mosquitto.conf, etc.)
scripts/ Host setup / bootstrap scripts llm-host/ Ollama + GPU host (separate physical machine)
llm-host/ Ollama + GPU host setup (separate physical machine)
thin-client/ Sway kiosk/media-station ISO (live-build) + thinclient-agent thin-client/ Sway kiosk/media-station ISO (live-build) + thinclient-agent
audio-endpoint/ Headless Spotify Connect appliance for rooms with no thin audio-endpoint/ Headless Spotify Connect appliance for rooms with no thin
client — arm64 (rpi-image-gen) + amd64 (live-build) images client — arm64 (rpi-image-gen) + amd64 (live-build) images
@ -40,6 +39,11 @@ firmware/
esp32-s3-touch-lcd-1.85c/ ESPHome voice satellite + status display (round LCD, esp32-s3-touch-lcd-1.85c/ ESPHome voice satellite + status display (round LCD,
media/cover-art priority over an idle weather/time/ media/cover-art priority over an idle weather/time/
date cycle, voice-state visualizer) date cycle, voice-state visualizer)
tools/ All build + setup scripts, driven by one
CoreSystemConfig.json: twinned container-host/LLM-host
ISO pair, every kiosk ISO, and a validator that refuses
a build on port collisions, placeholder tokens, or a
kiosk pointed at a disabled service
identity/ Person <-> BLE-identifier registry: multi-phone support, identity/ Person <-> BLE-identifier registry: multi-phone support,
anti-spoofing (allowlisted IRK-resolved/fixed-tag anti-spoofing (allowlisted IRK-resolved/fixed-tag
entities only, never a raw MAC), voice/touch entities only, never a raw MAC), voice/touch
@ -98,12 +102,34 @@ chores/ Presence/calendar-driven household chore nudging +
- [ ] `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` - [ ] `chores` (Phase 20) — presence/calendar-driven household chore nudging: "I don't care who does it, as long as it gets done" — prefers whoever's been assigned a chore in `identity`'s admin panel but falls through to whoever's actually home rather than waiting (`CHORE_ASSIGNMENT_STRICT` flips that), redirects to someone else if a chore goes neglected, keeps a passive fairness tally that never feeds back into who gets nudged, and camera-checks trash bins/dishes/litter via Frigate + an Ollama vision model. **Litter remains the exception to everything** — it ignores both chore-exemption and assignment, because cleaning up what you left out was never a task anyone could be assigned. Built and wired into `setup-container-host.sh` (`ENABLE_CHORES`, off by default, every-2-hours systemd timer), **no Tapo camera hardware chosen and nothing run against real hardware**, see `chores/README.md`
- [ ] Music Assistant (optional, additive multi-room audio) — wired into `setup-container-host.sh` (`ENABLE_MUSIC_ASSISTANT`, off by default), **its default port is an unverified guess that collides with `PANTRY_VISION_PORT`** if both are enabled together, see `docs/project-plan.md` open decision #31 - [ ] Music Assistant (optional, additive multi-room audio) — wired into `setup-container-host.sh` (`ENABLE_MUSIC_ASSISTANT`, off by default), **its default port is an unverified guess that collides with `PANTRY_VISION_PORT`** if both are enabled together, see `docs/project-plan.md` open decision #31
- [ ] `docs/network-integration.md` (OPNsense VLAN segmentation, 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 - [ ] `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 `build-output/` are gitignored. **No ISO has ever been built with this** (`lb build` needs live-build, root and a long fetch) — what is tested is config validation/derivation and every generated artifact, with `lb` stubbed. See `tools/README.md`
## Quick start ## Quick start
Everything is built from **one** config file — `CoreSystemConfig.json` — so an address
or token can only ever be wrong in one place:
```bash ```bash
cd hosts/container-host/scripts cp CoreSystemConfig.json.template CoreSystemConfig.json
sudo ./setup-container-host.sh $EDITOR CoreSystemConfig.json # subnet, two host octets, tokens, your kiosks
tools/validate-config.py # catches typos in seconds, not after a 40-min build
sudo -E tools/build-all.sh # every ISO, all agreeing with each other
```
The container host and LLM host come out as a **twinned pair**: you give each a last
octet, and the container host's `OLLAMA_HOST` is *derived* from the LLM host's — change
one and the other follows on the next build, with nothing to keep in sync by hand. Every
kiosk's service URLs derive from the container host's address the same way. See
[`tools/README.md`](tools/README.md).
Built ISOs contain every secret in the config, by design (nothing to configure
post-install) — which makes each one a credential. `.gitignore` covers both the
filled-in config and `build-output/`.
To set up a host by hand instead of from an ISO:
```bash
sudo -E tools/setup-container-host.sh
``` ```
Edit the variables at the top of the script first (timezone, Zigbee USB device Edit the variables at the top of the script first (timezone, Zigbee USB device

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. Set `DIGEST_FORCE_EVENING=true` for a one-off run to test it at any hour.
Keep `DIGEST_SCHEDULE` in step with the variable of the same name in Keep `DIGEST_SCHEDULE` in step with the variable of the same name in
`hosts/container-host/scripts/setup-container-host.sh`, which is what sets the `tools/setup-container-host.sh`, which is what sets the
timer. timer.
## Merging an unviewed digest into the next one ## Merging an unviewed digest into the next one

View File

@ -273,7 +273,7 @@ real hardware" callouts for everything downstream of this.)*
3. Build plain HA automations: presence (RuView) on → light on at neutral default; off (with delay) → light off. **Validate this works with the LLM host powered off — this is your safety-net baseline.** 3. Build plain HA automations: presence (RuView) on → light on at neutral default; off (with delay) → light off. **Validate this works with the LLM host powered off — this is your safety-net baseline.**
### Phase 3 — LLM host + conversation agent ### Phase 3 — LLM host + conversation agent
1. Stand up the GPU host (or CPU fallback) with Ollama, pull Qwen2.5-14B-Instruct (or 7B/3B for CPU). **Scripted**: `hosts/llm-host/scripts/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. 1. Stand up the GPU host (or CPU fallback) with Ollama, pull Qwen2.5-14B-Instruct (or 7B/3B for CPU). **Scripted**: `tools/setup-llm-host.sh` auto-detects the tier (`nvidia-smi` must both exist *and* succeed), installs Docker + the NVIDIA Container Toolkit, and runs Ollama as a pinned container rather than `curl | sh` into a root shell. It deliberately does **not** install the GPU driver — the most hardware/kernel-specific step on that box, and silently picking a version is how you get a machine that doesn't boot.
2. Add the Ollama integration in HA, point it at the LLM host over LAN. 2. Add the Ollama integration in HA, point it at the LLM host over LAN.
3. Set up Wyoming faster-whisper + Piper, configure an Assist pipeline. 3. Set up Wyoming faster-whisper + Piper, configure an Assist pipeline.
4. Test open-ended conversation and basic tool-calling before wiring into presence logic. 4. Test open-ended conversation and basic tool-calling before wiring into presence logic.
@ -465,7 +465,7 @@ recurrence/TLS traps. Nextcloud itself is pre-existing; nothing in this repo dep
4. Dedicated Frigate accelerator (Hailo-8L) only if you expand beyond 12 cameras. 4. Dedicated Frigate accelerator (Hailo-8L) only if you expand beyond 12 cameras.
### Phase 11 — Sway thin-client ISO ### Phase 11 — Sway thin-client ISO
1. Scaffold a `live-build` tree at `hosts/thin-client/live-build/` (Debian 12, matching container-host's OS). `config/package-lists/thin-client.list.chroot` pulls `sway`, `greetd`, `wayvnc`, `xwayland`, `firefox-esr`, `mpv`, `mpv-mpris`, `spotifyd` (or `librespot`), `flatpak` (Steam Link), `wyoming-satellite` + `openwakeword` deps, plus `pipewire`/`wireplumber`. `hosts/thin-client/scripts/build-thin-client-iso.sh` drives `lb config && lb build`. 1. Scaffold a `live-build` tree at `hosts/thin-client/live-build/` (Debian 12, matching container-host's OS). `config/package-lists/thin-client.list.chroot` pulls `sway`, `greetd`, `wayvnc`, `xwayland`, `firefox-esr`, `mpv`, `mpv-mpris`, `spotifyd` (or `librespot`), `flatpak` (Steam Link), `wyoming-satellite` + `openwakeword` deps, plus `pipewire`/`wireplumber`. `tools/build-thin-client-iso.sh` drives `lb config && lb build`.
2. Autologin straight into a kiosk Sway session via **greetd** (`initial_session` block runs `sway` directly, no greeter UI) — not the older getty+`.bash_profile` hack. 2. Autologin straight into a kiosk Sway session via **greetd** (`initial_session` block runs `sway` directly, no greeter UI) — not the older getty+`.bash_profile` hack.
3. Remote control: **wayvnc** for interactive screen view/control. **Sway/wlroots has no maintained RDP path** (wlroots dropped its RDP backend; xrdp is X11-only) — wayvnc is the deliberate, confirmed replacement for "RDP" in this project, not a stopgap. 3. Remote control: **wayvnc** for interactive screen view/control. **Sway/wlroots has no maintained RDP path** (wlroots dropped its RDP backend; xrdp is X11-only) — wayvnc is the deliberate, confirmed replacement for "RDP" in this project, not a stopgap.
4. Build `thinclient-agent` (Python, `hosts/thin-client/agent/`) as a systemd service baked into the image: 4. Build `thinclient-agent` (Python, `hosts/thin-client/agent/`) as a systemd service baked into the image:
@ -888,7 +888,7 @@ These need a decision before their respective implementation steps can be built
28. **Which UniFi/CalDAV/Matter/1-Wire/Proxmox/Steam/Discord/HP-iLO/GTFS HA integrations actually get installed is unresolved** (new) — all nine are catalogued in §2's "HA integrations catalog" as available options with their purpose/notes, but none has been installed, configured, or verified against real hardware/accounts; several (Matter, 1-Wire, Proxmox, HP iLO) also depend on hardware/infrastructure decisions this plan hasn't made yet (whether anything in the household actually uses those platforms at all). 28. **Which UniFi/CalDAV/Matter/1-Wire/Proxmox/Steam/Discord/HP-iLO/GTFS HA integrations actually get installed is unresolved** (new) — all nine are catalogued in §2's "HA integrations catalog" as available options with their purpose/notes, but none has been installed, configured, or verified against real hardware/accounts; several (Matter, 1-Wire, Proxmox, HP iLO) also depend on hardware/infrastructure decisions this plan hasn't made yet (whether anything in the household actually uses those platforms at all).
29. **Music Assistant has not been installed or configured** (new) — catalogued in §2 as an optional, additive HA add-on; whether it's worth adding on top of the existing per-room spotifyd/librespot/Spotify-client setup (which keeps working standalone regardless) is a real usage-pattern question, not answerable until the existing per-room setups (Phase 11.6/15/16) are actually running. 29. **Music Assistant has not been installed or configured** (new) — catalogued in §2 as an optional, additive HA add-on; whether it's worth adding on top of the existing per-room spotifyd/librespot/Spotify-client setup (which keeps working standalone regardless) is a real usage-pattern question, not answerable until the existing per-room setups (Phase 11.6/15/16) are actually running.
30. **The self-check/hardware-monitoring integrations catalog (§2) is a menu, not a deployment plan** (new) — System Monitor, SNMP, NUT, Glances, and Uptime Kuma are all listed with their purpose, but which ones are actually worth installing depends on hardware decisions not yet made (is there a UPS? managed switches? which of this repo's published services matter enough to alert on?). 30. **The self-check/hardware-monitoring integrations catalog (§2) is a menu, not a deployment plan** (new) — System Monitor, SNMP, NUT, Glances, and Uptime Kuma are all listed with their purpose, but which ones are actually worth installing depends on hardware decisions not yet made (is there a UPS? managed switches? which of this repo's published services matter enough to alert on?).
31. **Music Assistant's default port is a guess, and it collides with `PANTRY_VISION_PORT` in this exact stack** (new) — assumed 8095 from Music Assistant's own docs, not confirmed against a running instance; `PANTRY_VISION_PORT` is also 8095. Because Music Assistant runs with `network_mode: host` (needed for player-discovery mDNS), Docker Compose's own port-collision checking doesn't catch this the way a normal `ports:` mapping would — `setup-container-host.sh` warns if both `ENABLE_MUSIC_ASSISTANT` and `ENABLE_PANTRY_VISION` are set, but resolving the actual clash (changing Music Assistant's configured listen port) is a manual step, not automated. 31. ~~Music Assistant's default port is a guess, and it collides with `PANTRY_VISION_PORT` in this exact stack~~ — **mechanically resolved** by `tools/`: both ports are now declared in `CoreSystemConfig.json`, and `validate-config.py` **fails the build** on any duplicate, so the clash cannot reach a deployment. The template assigns Music Assistant 8101. **Still genuinely open**: its real default (8095) is still an unverified guess, and because it runs `network_mode: host`, moving it requires configuring Music Assistant itself — the config file can declare the port but cannot make the service bind to it.
32. ~~RuView's semantic-state MQTT entities have no opt-out or visibility restriction beyond this network's normal trust boundary~~**household decision made**: real automations are now built on this data (sleep → dim lights, possible-distress → whole-household alert, concurrent elevated heart rate → colored lighting, bathroom occupancy → an external door indicator — see `firmware/ruview/README.md` §5 and `firmware/ruview/automations.yaml.example`). **Still genuinely open**: there is no technical opt-out for a specific person/room and no access restriction on these MQTT topics beyond this network's normal trust boundary — worth revisiting if anyone not on board with being sensed this way ever stays over. Every automation's `entity_id` is also still an unconfirmed placeholder (see #33), and rule 3 (concurrent two-person heart rate) rests on an unconfirmed assumption that a single RuView node can report two people's heart rates at once — multi-target vital-sign separation from WiFi CSI is a genuinely hard, unconfirmed capability, not something to trust until checked against real entities. 32. ~~RuView's semantic-state MQTT entities have no opt-out or visibility restriction beyond this network's normal trust boundary~~**household decision made**: real automations are now built on this data (sleep → dim lights, possible-distress → whole-household alert, concurrent elevated heart rate → colored lighting, bathroom occupancy → an external door indicator — see `firmware/ruview/README.md` §5 and `firmware/ruview/automations.yaml.example`). **Still genuinely open**: there is no technical opt-out for a specific person/room and no access restriction on these MQTT topics beyond this network's normal trust boundary — worth revisiting if anyone not on board with being sensed this way ever stays over. Every automation's `entity_id` is also still an unconfirmed placeholder (see #33), and rule 3 (concurrent two-person heart rate) rests on an unconfirmed assumption that a single RuView node can report two people's heart rates at once — multi-target vital-sign separation from WiFi CSI is a genuinely hard, unconfirmed capability, not something to trust until checked against real entities.
33. **RuView's build/flash commands and `provision.py`'s exact flags beyond `--port`/`--ssid`/`--password`/`--mqtt` are transcribed from its README, not independently run** (new, Phase 2) — see `firmware/ruview/README.md`'s own "Manual verification still outstanding," same category of risk as every other "written from documentation, not a live instance" open decision in this list (#19, #21). 33. **RuView's build/flash commands and `provision.py`'s exact flags beyond `--port`/`--ssid`/`--password`/`--mqtt` are transcribed from its README, not independently run** (new, Phase 2) — see `firmware/ruview/README.md`'s own "Manual verification still outstanding," same category of risk as every other "written from documentation, not a live instance" open decision in this list (#19, #21).
34. **`identity`'s `DEPARTURE_GRACE_SECONDS` default (15 min) is an untuned guess at how much a real Private BLE Device setup flaps** (new, Phase 6b) — too low and one evening at home is recorded as several separate "visits," too high and a quick trip out never registers. The entire usefulness of the visit history and the co-presence view rests on this number, and nobody has watched a real BLE presence entity over a day to pick it. First thing to check once `GET /visits` has real data in it. 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.

View File

@ -66,7 +66,7 @@ device already on the network).
## Multiple rooms ## Multiple rooms
This is a per-room device, exactly like the thin client's per-image This is a per-room device, exactly like the thin client's per-image
`THINCLIENT_NAME`/`DIGEST_WEB_URL` (`hosts/thin-client/scripts/build-thin-client-iso.sh`). `THINCLIENT_NAME`/`DIGEST_WEB_URL` (`tools/build-thin-client-iso.sh`).
Two separate things both have to be set correctly, per physical unit, for "media Two separate things both have to be set correctly, per physical unit, for "media
status always on the specific room the device is in" to actually hold: status always on the specific room the device is in" to actually hold:

View File

@ -25,7 +25,7 @@ unit/wrapper pair. Nothing else is shared — see each variant's own section bel
- **rpi-image-gen is Raspberry-Pi-specific.** It doesn't target amd64. Rather than - **rpi-image-gen is Raspberry-Pi-specific.** It doesn't target amd64. Rather than
reach for a third, unfamiliar toolchain for the x86 side, the amd64 image reuses reach for a third, unfamiliar toolchain for the x86 side, the amd64 image reuses
**the exact tool this repo already has and has already proven for a bootable **the exact tool this repo already has and has already proven for a bootable
image**: live-build, the same one `hosts/thin-client/scripts/build-thin-client-iso.sh` image**: live-build, the same one `tools/build-thin-client-iso.sh`
drives. Lower risk than learning a new toolchain for one variant. drives. Lower risk than learning a new toolchain for one variant.
## Spotify Connect install: apt-first, documented fallback ## Spotify Connect install: apt-first, documented fallback
@ -115,7 +115,7 @@ cd hosts/audio-endpoint/live-build-amd64/scripts
sudo ./build-audio-endpoint-iso-amd64.sh sudo ./build-audio-endpoint-iso-amd64.sh
``` ```
Same shape as `hosts/thin-client/scripts/build-thin-client-iso.sh`: installs Same shape as `tools/build-thin-client-iso.sh`: installs
`live-build` if missing, regenerates `config/includes.chroot/` from the shared `live-build` if missing, regenerates `config/includes.chroot/` from the shared
`../configs/` (git-ignored, generated — never hand-edit it), then runs `../configs/` (git-ignored, generated — never hand-edit it), then runs
`lb config && lb build`. Output is a hybrid ISO, written to a USB stick and `lb config && lb build`. Output is a hybrid ISO, written to a USB stick and

View File

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

View File

@ -47,10 +47,10 @@ angle can be adjusted independently of the screen. See §1.15 of
## Before you build ## Before you build
Deploy `pantry-vision`/`pantry-web` first (`ENABLE_PANTRY_VISION` in Deploy `pantry-vision`/`pantry-web` first (`ENABLE_PANTRY_VISION` in
`hosts/container-host/scripts/setup-container-host.sh`) — this image builds and boots `tools/setup-container-host.sh`) — this image builds and boots
fine without it, but every tab will show a connection error until it exists. Then edit fine without it, but every tab will show a connection error until it exists. Then edit
the `# CONFIGURATION` block at the top of the `# CONFIGURATION` block at the top of
[`scripts/build-kitchen-display-iso.sh`](scripts/build-kitchen-display-iso.sh): [`tools/build-kitchen-display-iso.sh`](../../tools/build-kitchen-display-iso.sh):
| Variable | What to put in it | | Variable | What to put in it |
|---|---| |---|---|
@ -66,7 +66,7 @@ the `# CONFIGURATION` block at the top of
## Build ## Build
```sh ```sh
sudo ./scripts/build-kitchen-display-iso.sh sudo -E tools/build-kitchen-display-iso.sh
``` ```
Same directory-split convention as every other host in this project: `configs/` and Same directory-split convention as every other host in this project: `configs/` and

View File

@ -105,8 +105,7 @@ the project plan's open decision #38.)
## Run it ## Run it
```sh ```sh
cd hosts/llm-host/scripts sudo -E tools/setup-llm-host.sh
sudo ./setup-llm-host.sh
``` ```
Edit the variables at the top first — `BASE_DIR` above all, since models are large Edit the variables at the top first — `BASE_DIR` above all, since models are large

View File

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

View File

@ -34,7 +34,7 @@ What ends up on the image:
> handles either way a real touchscreen might show up to Linux. > handles either way a real touchscreen might show up to Linux.
Edit the `# CONFIGURATION` block at the top of Edit the `# CONFIGURATION` block at the top of
[`scripts/build-touch-panel-iso.sh`](scripts/build-touch-panel-iso.sh): [`tools/build-touch-panel-iso.sh`](../../tools/build-touch-panel-iso.sh):
| Variable | What to put in it | | Variable | What to put in it |
|---|---| |---|---|
@ -47,7 +47,7 @@ Edit the `# CONFIGURATION` block at the top of
## Build ## Build
```sh ```sh
sudo ./scripts/build-touch-panel-iso.sh sudo -E tools/build-touch-panel-iso.sh
``` ```
Same directory-split convention as the thin client: `configs/` and `agent/` are the Same directory-split convention as the thin client: `configs/` and `agent/` are the

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. requires the bearer token, as the actual boundary instead of network placement.
The same token has to be baked into the kitchen display's own build config The same token has to be baked into the kitchen display's own build config
(`hosts/kitchen-display/scripts/build-kitchen-display-iso.sh`), not just Home (`tools/build-kitchen-display-iso.sh`), not just Home
Assistant's — see that host's README. Assistant's — see that host's README.
## `/identify` never writes anything by itself ## `/identify` never writes anything by itself
@ -106,7 +106,7 @@ above, and adjust `server.py` if the shapes differ.
## Deploy ## Deploy
Wired into `hosts/container-host/scripts/setup-container-host.sh` behind Wired into `tools/setup-container-host.sh` behind
`ENABLE_PANTRY_VISION` (off by default) — see that script's `# CONFIGURATION` block `ENABLE_PANTRY_VISION` (off by default) — see that script's `# CONFIGURATION` block
and its own README. It builds two containers: `pantry-vision` (this API) and and its own README. It builds two containers: `pantry-vision` (this API) and
`pantry-web` (nginx, serves `frontend/` read-only). `pantry-web` (nginx, serves `frontend/` read-only).

152
tools/README.md Normal file
View File

@ -0,0 +1,152 @@
# 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.
## 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 `build-output/`, 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.

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

@ -0,0 +1,188 @@
#!/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
for row in "${AUDIO_ROWS[@]:-}"; do
[[ -n "$row" ]] || continue
IFS=$'\t' read -r aarch ahost _ <<< "$row"
# Two genuinely different toolchains, not one builder with a flag: live-build for
# the amd64 mini-PC, rpi-image-gen for the arm64 Pi.
case "$aarch" in
amd64) builder="${SCRIPT_DIR}/build-audio-endpoint-iso-amd64.sh" ;;
arm64) builder="${SCRIPT_DIR}/build-audio-endpoint-image-arm64.sh" ;;
*) core_warn "Unknown audio endpoint arch '${aarch}' — skipping ${ahost}"
FAILED+=("audio/${ahost} (bad arch)"); continue ;;
esac
run_build "audio endpoint (${aarch}) — ${ahost}" "$builder" "$ahost"
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

@ -22,8 +22,15 @@ RPI_IMAGE_GEN_REPO="https://github.com/raspberrypi/rpi-image-gen.git"
# Paths # Paths
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RPI_IMAGE_GEN_DIR="$(dirname "$SCRIPT_DIR")" # shellcheck source=lib/coreconfig.sh
AUDIO_ENDPOINT_DIR="$(dirname "$RPI_IMAGE_GEN_DIR")" 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" SHARED_CONFIGS_DIR="$AUDIO_ENDPOINT_DIR/configs"
BUILD_CONFIG="$RPI_IMAGE_GEN_DIR/config/audio-endpoint.yaml" BUILD_CONFIG="$RPI_IMAGE_GEN_DIR/config/audio-endpoint.yaml"

View File

@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# Builds the amd64 headless audio-endpoint ISO (mini PC + USB DAC/amp) with # Builds the amd64 headless audio-endpoint ISO (mini PC + USB DAC/amp) with
# live-build — the same tool hosts/thin-client/scripts/build-thin-client-iso.sh # live-build — the same tool tools/build-thin-client-iso.sh
# drives, reused here rather than a new toolchain, per # drives, reused here rather than a new toolchain, per
# hosts/audio-endpoint/README.md's reasoning. Unlike that image, this one has # hosts/audio-endpoint/README.md's reasoning. Unlike that image, this one has
# no graphical/kiosk stack at all: it boots straight to multi-user.target with # no graphical/kiosk stack at all: it boots straight to multi-user.target with
@ -12,24 +12,34 @@
# post-build customisation tool for a generic x86 ISO, so IMAGE_HOSTNAME below # post-build customisation tool for a generic x86 ISO, so IMAGE_HOSTNAME below
# is baked in at build time — same convention as the thin client's own # is baked in at build time — same convention as the thin client's own
# THINCLIENT_NAME/IMAGE_HOSTNAME. Re-run this script once per room, changing # THINCLIENT_NAME/IMAGE_HOSTNAME. Re-run this script once per room, changing
# IMAGE_HOSTNAME each time.
set -euo pipefail set -euo pipefail
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CONFIGURATION — edit before running, then re-run per room # CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
# --------------------------------------------------------------------------- #
DEBIAN_RELEASE="bookworm" # Matches the container/thin-client hosts' OS # Rooms are entries in the config's "audio_endpoints" list, so adding a second one is
IMAGE_HOSTNAME="audio-endpoint-livingroom" # <-- EDIT per room; also the Spotify # adding a list entry rather than editing and re-running this script with a different
# Connect device name (see README) # hostname — which is how per-room images drifted apart before.
#
# --------------------------------------------------------------------------- # sudo -E tools/build-audio-endpoint-iso-amd64.sh # the only amd64 endpoint
# Paths # sudo -E tools/build-audio-endpoint-iso-amd64.sh <hostname> # a specific one
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIVE_BUILD_AMD64_DIR="$(dirname "$SCRIPT_DIR")" # shellcheck source=lib/coreconfig.sh
AUDIO_ENDPOINT_DIR="$(dirname "$LIVE_BUILD_AMD64_DIR")" source "${SCRIPT_DIR}/lib/coreconfig.sh"
core_select_audio_endpoint amd64 "${1:-}"
DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE"
IMAGE_HOSTNAME="$CORE_AUDIO_HOSTNAME" # also the Spotify Connect device name
# ---------------------------------------------------------------------------
# Paths — this script now lives in tools/, so the host directory it drives is
# addressed from the repo root rather than relative to the script.
# ---------------------------------------------------------------------------
AUDIO_ENDPOINT_DIR="${CORE_REPO_ROOT}/hosts/audio-endpoint"
SHARED_CONFIGS_DIR="$AUDIO_ENDPOINT_DIR/configs" SHARED_CONFIGS_DIR="$AUDIO_ENDPOINT_DIR/configs"
LIVE_BUILD_DIR="$LIVE_BUILD_AMD64_DIR" LIVE_BUILD_DIR="${AUDIO_ENDPOINT_DIR}/live-build-amd64"
INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot" INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot"
PACKAGE_LIST="${LIVE_BUILD_DIR}/config/package-lists/audio-endpoint.list.chroot" PACKAGE_LIST="${LIVE_BUILD_DIR}/config/package-lists/audio-endpoint.list.chroot"

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

@ -0,0 +1,380 @@
#!/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_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, # hosts/kitchen-display's twin: same one-workspace-two-kiosk-destinations shape,
# different default content and a mic that's actually expected to be used. # different default content and a mic that's actually expected to be used.
# #
# Run as: sudo ./build-door-panel-iso.sh # Run as: sudo -E tools/build-door-panel-iso.sh [hostname]
# #
# EDIT THE VARIABLES BELOW BEFORE RUNNING. # Configuration comes from CoreSystemConfig.json — see tools/README.md.
set -euo pipefail set -euo pipefail
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CONFIGURATION — edit these before running # CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
# --------------------------------------------------------------------------- #
DEBIAN_RELEASE="bookworm" # There is nothing to edit here any more. Every value below is read from the one
KIOSK_USERNAME="kiosk" # config at the repo root, so an address or token can only be wrong in a single
IMAGE_HOSTNAME="door-panel" # place. Change it there and rebuild; see tools/README.md.
DOOR_PANEL_NAME="Door panel" #
# sudo -E tools/build-door-panel-iso.sh # the only door-panel in the config
KEYBOARD_LAYOUT="de" # sudo -E tools/build-door-panel-iso.sh <hostname> # a specific one, if several are defined
#
ENABLE_INSTALLER="false" # The build refuses to start if the config is invalid (validate-config.py runs first),
# so a typo costs seconds rather than a 40-minute build and a reboot.
# --- Where the door panel talks to -------------------------------------------
MQTT_BROKER_HOST="192.168.1.10" # <-- EDIT: container-host IP running Mosquitto
MQTT_BROKER_PORT="1883"
MQTT_USERNAME=""
MQTT_PASSWORD=""
# identity's dashboard/registration pages and API — ENABLE_IDENTITY in
# setup-container-host.sh. Placeholders until that's deployed; the image builds and
# boots fine without it, the kiosk window just shows a connection error.
IDENTITY_WEB_URL="http://192.168.1.10:8098" # <-- EDIT once identity-web is deployed
IDENTITY_URL="http://192.168.1.10:8097" # <-- EDIT once identity is deployed
# Must match identity/identity.env's own token — no way for this repo to push it
# between the two hosts for you.
IDENTITY_TOKEN="" # <-- EDIT
# pantry-vision — only needed for the dashboard's "Running low" section; the rest of
# the dashboard (weather, who's home) works without it. Same placeholder handling.
PANTRY_VISION_URL="http://192.168.1.10:8095" # <-- EDIT once pantry-vision is deployed
PANTRY_VISION_TOKEN="" # <-- EDIT: must match pantry-vision's own token
# --- Voice registration ("register me as <name>") — this device's whole point, so
# --- true is the expected real-deployment value, unlike hosts/kitchen-display's
# --- identical flag — but still requires a real mic on this specific unit.
ENABLE_VOICE_SATELLITE="true"
VOICE_SATELLITE_NAME="Door panel"
VOICE_WAKE_WORD="ok_nabu"
SSH_AUTHORIZED_KEY=""
# ---------------------------------------------------------------------------
# Paths
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOOR_PANEL_DIR="$(dirname "$SCRIPT_DIR")" # shellcheck source=lib/coreconfig.sh
source "${SCRIPT_DIR}/lib/coreconfig.sh"
core_select_kiosk "door-panel" "${1:-}"
# Mapped onto this script's existing variable names, so everything below is unchanged
# from when these were hand-edited constants.
DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE"
KIOSK_USERNAME="$CORE_KIOSK_USERNAME"
IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME"
KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT"
ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER"
MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST"
MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT"
MQTT_USERNAME="$CORE_MQTT_USERNAME"
MQTT_PASSWORD="$CORE_MQTT_PASSWORD"
SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY"
ENABLE_VOICE_SATELLITE="$CORE_KIOSK_VOICE_SATELLITE"
VOICE_SATELLITE_NAME="$CORE_KIOSK_FRIENDLY_NAME"
VOICE_WAKE_WORD="$CORE_KIOSK_WAKE_WORD"
DOOR_PANEL_NAME="$CORE_KIOSK_FRIENDLY_NAME"
IDENTITY_WEB_URL="$CORE_IDENTITY_WEB_URL"
IDENTITY_URL="$CORE_IDENTITY_URL"
IDENTITY_TOKEN="$CORE_IDENTITY_TOKEN"
PANTRY_VISION_URL="$CORE_PANTRY_VISION_URL"
PANTRY_VISION_TOKEN="$CORE_PANTRY_VISION_TOKEN"
# ---------------------------------------------------------------------------
# Paths — this script now lives in tools/, so the host directory it drives is
# addressed from the repo root rather than relative to the script.
# ---------------------------------------------------------------------------
DOOR_PANEL_DIR="${CORE_REPO_ROOT}/hosts/door-panel"
CONFIGS_DIR="${DOOR_PANEL_DIR}/configs" CONFIGS_DIR="${DOOR_PANEL_DIR}/configs"
AGENT_DIR="${DOOR_PANEL_DIR}/agent" AGENT_DIR="${DOOR_PANEL_DIR}/agent"
LIVE_BUILD_DIR="${DOOR_PANEL_DIR}/live-build" LIVE_BUILD_DIR="${DOOR_PANEL_DIR}/live-build"
@ -106,20 +106,8 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then
exit 1 exit 1
fi fi
if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then
echo "Warning: MQTT_BROKER_HOST is still the placeholder IP — edit it before building."
fi
if [[ -z "$IDENTITY_TOKEN" ]]; then
echo "Warning: IDENTITY_TOKEN is empty. The dashboard will load but every call to"
echo " identity will fail (401) until this matches the token in"
echo " identity/identity.env on the container host."
fi
if [[ -z "$PANTRY_VISION_TOKEN" ]]; then
echo "Warning: PANTRY_VISION_TOKEN is empty. 'Running low' will show as unconfigured"
echo " until this matches the token in pantry-vision/pantry-vision.env."
fi
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
echo "Note: ENABLE_VOICE_SATELLITE=true — this image expects a real microphone on" echo "Note: ENABLE_VOICE_SATELLITE=true — this image expects a real microphone on"
@ -192,8 +180,8 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo "--- Writing /etc/door-panel-agent/config.env into includes.chroot ---" echo "--- Writing /etc/door-panel-agent/config.env into includes.chroot ---"
cat > "$INCLUDES/etc/door-panel-agent/config.env" <<EOF cat > "$INCLUDES/etc/door-panel-agent/config.env" <<EOF
# Generated by hosts/door-panel/scripts/build-door-panel-iso.sh — do not hand-edit # Generated by tools/build-door-panel-iso.sh — do not hand-edit
# here; edit the CONFIGURATION block in that script and rebuild. # here; change CoreSystemConfig.json at the repo root and rebuild.
KIOSK_USERNAME=${KIOSK_USERNAME} KIOSK_USERNAME=${KIOSK_USERNAME}
DOOR_PANEL_NAME=${DOOR_PANEL_NAME} DOOR_PANEL_NAME=${DOOR_PANEL_NAME}

View File

@ -20,61 +20,59 @@
# its live-build tree — same relationship hosts/touch-panel and hosts/audio-endpoint # its live-build tree — same relationship hosts/touch-panel and hosts/audio-endpoint
# already have to the thin client's. # already have to the thin client's.
# #
# Run as: sudo ./build-kitchen-display-iso.sh # Run as: sudo -E tools/build-kitchen-display-iso.sh [hostname]
# #
# EDIT THE VARIABLES BELOW BEFORE RUNNING. # Configuration comes from CoreSystemConfig.json — see tools/README.md.
set -euo pipefail set -euo pipefail
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CONFIGURATION — edit these before running # CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
# --------------------------------------------------------------------------- #
DEBIAN_RELEASE="bookworm" # There is nothing to edit here any more. Every value below is read from the one
KIOSK_USERNAME="kiosk" # config at the repo root, so an address or token can only be wrong in a single
IMAGE_HOSTNAME="kitchen-display" # place. Change it there and rebuild; see tools/README.md.
KITCHEN_DISPLAY_NAME="Kitchen fridge display" #
# sudo -E tools/build-kitchen-display-iso.sh # the only kitchen-display in the config
KEYBOARD_LAYOUT="de" # sudo -E tools/build-kitchen-display-iso.sh <hostname> # a specific one, if several are defined
#
ENABLE_INSTALLER="false" # The build refuses to start if the config is invalid (validate-config.py runs first),
# so a typo costs seconds rather than a 40-minute build and a reboot.
# --- Where the kitchen display talks to -------------------------------------
MQTT_BROKER_HOST="192.168.1.10" # <-- EDIT: container-host IP running Mosquitto
MQTT_BROKER_PORT="1883"
MQTT_USERNAME=""
MQTT_PASSWORD=""
# pantry-web (nginx, serves pantry-vision/frontend/) and pantry-vision (the API the
# frontend calls directly from the browser) — both from ENABLE_PANTRY_VISION in
# setup-container-host.sh. Placeholders until that's deployed; the image builds and
# boots fine without it, the kiosk window just shows a connection error.
PANTRY_WEB_URL="http://192.168.1.10:8096" # <-- EDIT once pantry-web is deployed
PANTRY_VISION_URL="http://192.168.1.10:8095" # <-- EDIT once pantry-vision is deployed
# Same value as PANTRY_VISION_TOKEN in pantry-vision/pantry-vision.env — there is no
# way for this repo to push it between the two hosts for you, same as every other
# credential pair that spans two machines in this project.
PANTRY_VISION_TOKEN="" # <-- EDIT: must match pantry-vision's own token
# identity's registration page (Phase 6) — ENABLE_IDENTITY in setup-container-host.sh.
# Same placeholder handling as the pantry-vision block above.
IDENTITY_WEB_URL="http://192.168.1.10:8098" # <-- EDIT once identity-web is deployed
IDENTITY_URL="http://192.168.1.10:8097" # <-- EDIT once identity is deployed
IDENTITY_TOKEN="" # <-- EDIT: must match identity's own token
# --- Voice registration ("register me as <name>") — OFF BY DEFAULT until a real mic
# --- is attached to this specific unit. Same per-image opt-in shape as the thin
# --- client's ENABLE_VOICE_SATELLITE (project-plan Phase 11.8).
ENABLE_VOICE_SATELLITE="false"
VOICE_SATELLITE_NAME="Kitchen display"
VOICE_WAKE_WORD="ok_nabu"
SSH_AUTHORIZED_KEY=""
# ---------------------------------------------------------------------------
# Paths
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
KITCHEN_DISPLAY_DIR="$(dirname "$SCRIPT_DIR")" # shellcheck source=lib/coreconfig.sh
source "${SCRIPT_DIR}/lib/coreconfig.sh"
core_select_kiosk "kitchen-display" "${1:-}"
# Mapped onto this script's existing variable names, so everything below is unchanged
# from when these were hand-edited constants.
DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE"
KIOSK_USERNAME="$CORE_KIOSK_USERNAME"
IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME"
KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT"
ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER"
MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST"
MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT"
MQTT_USERNAME="$CORE_MQTT_USERNAME"
MQTT_PASSWORD="$CORE_MQTT_PASSWORD"
SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY"
ENABLE_VOICE_SATELLITE="$CORE_KIOSK_VOICE_SATELLITE"
VOICE_SATELLITE_NAME="$CORE_KIOSK_FRIENDLY_NAME"
VOICE_WAKE_WORD="$CORE_KIOSK_WAKE_WORD"
KITCHEN_DISPLAY_NAME="$CORE_KIOSK_FRIENDLY_NAME"
PANTRY_WEB_URL="$CORE_PANTRY_WEB_URL"
PANTRY_VISION_URL="$CORE_PANTRY_VISION_URL"
PANTRY_VISION_TOKEN="$CORE_PANTRY_VISION_TOKEN"
IDENTITY_WEB_URL="$CORE_IDENTITY_WEB_URL"
IDENTITY_URL="$CORE_IDENTITY_URL"
IDENTITY_TOKEN="$CORE_IDENTITY_TOKEN"
# ---------------------------------------------------------------------------
# Paths — this script now lives in tools/, so the host directory it drives is
# addressed from the repo root rather than relative to the script.
# ---------------------------------------------------------------------------
KITCHEN_DISPLAY_DIR="${CORE_REPO_ROOT}/hosts/kitchen-display"
CONFIGS_DIR="${KITCHEN_DISPLAY_DIR}/configs" CONFIGS_DIR="${KITCHEN_DISPLAY_DIR}/configs"
AGENT_DIR="${KITCHEN_DISPLAY_DIR}/agent" AGENT_DIR="${KITCHEN_DISPLAY_DIR}/agent"
LIVE_BUILD_DIR="${KITCHEN_DISPLAY_DIR}/live-build" LIVE_BUILD_DIR="${KITCHEN_DISPLAY_DIR}/live-build"
@ -108,21 +106,8 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then
exit 1 exit 1
fi fi
if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then
echo "Warning: MQTT_BROKER_HOST is still the placeholder IP — edit it before building."
fi
if [[ -z "$PANTRY_VISION_TOKEN" ]]; then
echo "Warning: PANTRY_VISION_TOKEN is empty. The kiosk window will load but every"
echo " call to pantry-vision will fail (401) until this matches the token in"
echo " pantry-vision/pantry-vision.env on the container host."
fi
if [[ -z "$IDENTITY_TOKEN" ]]; then
echo "Warning: IDENTITY_TOKEN is empty. 'Show registration' will load but every"
echo " call to identity will fail (401) until this matches the token in"
echo " identity/identity.env on the container host."
fi
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
echo "Note: ENABLE_VOICE_SATELLITE=true — this image expects a real microphone on" echo "Note: ENABLE_VOICE_SATELLITE=true — this image expects a real microphone on"
@ -196,8 +181,8 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo "--- Writing /etc/kitchen-display-agent/config.env into includes.chroot ---" echo "--- Writing /etc/kitchen-display-agent/config.env into includes.chroot ---"
cat > "$INCLUDES/etc/kitchen-display-agent/config.env" <<EOF cat > "$INCLUDES/etc/kitchen-display-agent/config.env" <<EOF
# Generated by hosts/kitchen-display/scripts/build-kitchen-display-iso.sh — do not # Generated by tools/build-kitchen-display-iso.sh — do not
# hand-edit here; edit the CONFIGURATION block in that script and rebuild. # hand-edit here; change CoreSystemConfig.json and rebuild.
KIOSK_USERNAME=${KIOSK_USERNAME} KIOSK_USERNAME=${KIOSK_USERNAME}
KITCHEN_DISPLAY_NAME=${KITCHEN_DISPLAY_NAME} KITCHEN_DISPLAY_NAME=${KITCHEN_DISPLAY_NAME}

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 # generated tree that actually gets baked into the image). Never hand-edit anything
# under includes.chroot — it is wiped and regenerated on every run. # under includes.chroot — it is wiped and regenerated on every run.
# #
# Run as: sudo ./build-thin-client-iso.sh # Run as: sudo -E tools/build-thin-client-iso.sh [hostname]
# #
# EDIT THE VARIABLES BELOW BEFORE RUNNING. # Configuration comes from CoreSystemConfig.json — see tools/README.md.
set -euo pipefail set -euo pipefail
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CONFIGURATION — edit these before running # CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
# --------------------------------------------------------------------------- #
DEBIAN_RELEASE="bookworm" # Matches the container host's OS # There is nothing to edit here any more. Every value below is read from the one
KIOSK_USERNAME="kiosk" # The autologin account the whole image is built around # config at the repo root, so an address or token can only be wrong in a single
IMAGE_HOSTNAME="thin-client" # Hostname baked into the image # place. Change it there and rebuild; see tools/README.md.
THINCLIENT_NAME="Living room thin client" # Friendly name shown on the HA device #
# sudo -E tools/build-thin-client-iso.sh # the only thin-client in the config
# Console + Sway keyboard layout. This IS the "installer choice" in this image's # sudo -E tools/build-thin-client-iso.sh <hostname> # a specific one, if several are defined
# architecture: there is no interactive keymap prompt in the normal live-boot path (see #
# ENABLE_INSTALLER below for the one case where a real prompt exists), so a per-image # The build refuses to start if the config is invalid (validate-config.py runs first),
# build variable is what stands in for it — build one ISO per keyboard layout you need. # so a typo costs seconds rather than a 40-minute build and a reboot.
KEYBOARD_LAYOUT="de" # xkb layout name (`localectl list-x11-keymap-layouts`)
ENABLE_STEAM_LINK="true" # Install the Steam Link flatpak from Flathub
ENABLE_INSTALLER="false" # "true" adds a debian-installer to the ISO (install to disk)
# --- Voice satellite — OFF BY DEFAULT, AND MEANT TO STAY THAT WAY -----------
# Per docs/project-plan.md Phase 11.8, only the specific rooms that have a microphone
# run wyoming-satellite. This is therefore a PER-IMAGE decision, not a universal one:
# build one ISO with this "false" for the silent rooms, and a second ISO with it "true"
# for the mic-enabled ones. The exact mic-enabled room list is still an open decision
# (project-plan §4 #6) — do not flip this on until it has been chosen.
ENABLE_VOICE_SATELLITE="false"
VOICE_SATELLITE_NAME="Living room" # Shown in HA's Wyoming/Assist device list
VOICE_WAKE_WORD="ok_nabu" # openWakeWord model name
# --- Camera gesture control — OFF BY DEFAULT, AND MEANT TO STAY THAT WAY -------
# Open hand moves the pointer, fist clicks. Same per-image, per-room logic as the mic
# above: only build this into the image of a room that is actually getting a webcam.
# This flag only decides whether MediaPipe and its ~400 MB dependency tree are INSTALLED.
# Whether the camera is ever OPENED is a second, separate gate — the "enabled" flag in
# configs/gesture-control/gesture-config.json, which is false by default even here, so a
# gesture-capable image still ships with the camera off. See the privacy section in
# hosts/thin-client/README.md.
ENABLE_GESTURE_CONTROL="false"
# --- Where the thin client talks to ----------------------------------------
# The container host from Phase 1 (Mosquitto + Home Assistant). Fill in its LAN IP.
MQTT_BROKER_HOST="192.168.1.10" # <-- EDIT: container-host IP running Mosquitto
MQTT_BROKER_PORT="1883"
MQTT_USERNAME="" # Leave empty while Mosquitto runs allow_anonymous
MQTT_PASSWORD="" # Never commit a real value here — see README
HA_URL="http://192.168.1.10:8123" # <-- EDIT: Home Assistant URL
# digest-web is the static-file service that Phase 12's digest-engine renders into.
# It is built by a separate workstream; until it is deployed this is just a placeholder
# and the kiosk Firefox workspace will show a connection error (harmless — the session
# must still come up with the container host powered off, per Phase 11.10).
DIGEST_WEB_URL="http://192.168.1.10:8081" # <-- EDIT once digest-web is deployed
# admin-web (Phase 13) — the sys-admin-llm's on-demand display surface. Same
# placeholder handling as DIGEST_WEB_URL above: harmless until deployed, the admin
# workspace just won't have anything to open yet (and unlike the digest workspace it
# is never auto-launched at session start anyway — see configs/sway/config).
ADMIN_WEB_URL="http://192.168.1.10:8094" # <-- EDIT once admin-web is deployed
# The container host's gallery-smb share (ENABLE_GALLERY_SMB in
# setup-container-host.sh), used by the idle-timeout slideshow. Just the host —
# idle-gallery.sh always mounts the fixed "gallery" share name.
GALLERY_SMB_HOST="192.168.1.10" # <-- EDIT: container-host IP running gallery-smb
# Optional: an SSH public key to bake into the kiosk account for out-of-band admin.
# The image ships with password auth disabled, so without this the only admin path is
# the local console or wayvnc.
SSH_AUTHORIZED_KEY=""
# ---------------------------------------------------------------------------
# Paths
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
THIN_CLIENT_DIR="$(dirname "$SCRIPT_DIR")" # shellcheck source=lib/coreconfig.sh
source "${SCRIPT_DIR}/lib/coreconfig.sh"
core_select_kiosk "thin-client" "${1:-}"
# Mapped onto this script's existing variable names, so everything below is unchanged
# from when these were hand-edited constants.
DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE"
KIOSK_USERNAME="$CORE_KIOSK_USERNAME"
IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME"
KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT"
ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER"
MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST"
MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT"
MQTT_USERNAME="$CORE_MQTT_USERNAME"
MQTT_PASSWORD="$CORE_MQTT_PASSWORD"
SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY"
ENABLE_VOICE_SATELLITE="$CORE_KIOSK_VOICE_SATELLITE"
VOICE_SATELLITE_NAME="$CORE_KIOSK_FRIENDLY_NAME"
VOICE_WAKE_WORD="$CORE_KIOSK_WAKE_WORD"
THINCLIENT_NAME="$CORE_KIOSK_FRIENDLY_NAME"
HA_URL="$CORE_HA_URL"
DIGEST_WEB_URL="$CORE_DIGEST_WEB_URL"
ADMIN_WEB_URL="$CORE_ADMIN_WEB_URL"
GALLERY_SMB_HOST="$CORE_GALLERY_SMB_HOST"
ENABLE_STEAM_LINK="$CORE_KIOSK_ENABLE_STEAM_LINK"
ENABLE_GESTURE_CONTROL="$CORE_KIOSK_ENABLE_GESTURE_CONTROL"
# ---------------------------------------------------------------------------
# Paths — this script now lives in tools/, so the host directory it drives is
# addressed from the repo root rather than relative to the script.
# ---------------------------------------------------------------------------
THIN_CLIENT_DIR="${CORE_REPO_ROOT}/hosts/thin-client"
CONFIGS_DIR="${THIN_CLIENT_DIR}/configs" CONFIGS_DIR="${THIN_CLIENT_DIR}/configs"
AGENT_DIR="${THIN_CLIENT_DIR}/agent" AGENT_DIR="${THIN_CLIENT_DIR}/agent"
LIVE_BUILD_DIR="${THIN_CLIENT_DIR}/live-build" LIVE_BUILD_DIR="${THIN_CLIENT_DIR}/live-build"
@ -134,23 +109,8 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then
exit 1 exit 1
fi fi
if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then
echo "Warning: MQTT_BROKER_HOST is still the placeholder IP."
echo " Edit it at the top of this script to your container host's real LAN address,"
echo " or the thin client won't show up as Home Assistant entities."
fi
if [[ "$DIGEST_WEB_URL" == "http://192.168.1.10:8081" ]]; then
echo "Warning: DIGEST_WEB_URL is still the placeholder."
echo " Fill it in once Phase 12's digest-web service is deployed. The image builds"
echo " and boots fine without it — the digest workspace just won't load anything."
fi
if [[ "$ADMIN_WEB_URL" == "http://192.168.1.10:8094" ]]; then
echo "Warning: ADMIN_WEB_URL is still the placeholder."
echo " Fill it in once Phase 13's admin-web service is deployed. The image builds"
echo " and boots fine without it — 'Show admin canvas' just won't load anything."
fi
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
echo "Note: ENABLE_VOICE_SATELLITE=true — this image is for a MIC-ENABLED room" echo "Note: ENABLE_VOICE_SATELLITE=true — this image is for a MIC-ENABLED room"
@ -298,8 +258,8 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo "--- Writing /etc/thinclient-agent/config.env into includes.chroot ---" echo "--- Writing /etc/thinclient-agent/config.env into includes.chroot ---"
cat > "$INCLUDES/etc/thinclient-agent/config.env" <<EOF cat > "$INCLUDES/etc/thinclient-agent/config.env" <<EOF
# Generated by hosts/thin-client/scripts/build-thin-client-iso.sh — do not hand-edit # Generated by tools/build-thin-client-iso.sh — do not hand-edit
# here; edit the CONFIGURATION block in that script and rebuild. # here; change CoreSystemConfig.json at the repo root and rebuild.
KIOSK_USERNAME=${KIOSK_USERNAME} KIOSK_USERNAME=${KIOSK_USERNAME}
THINCLIENT_NAME=${THINCLIENT_NAME} THINCLIENT_NAME=${THINCLIENT_NAME}
@ -452,7 +412,7 @@ fi
echo " 9. Idle photo slideshow: create /etc/thinclient-agent/gallery-credentials on the" echo " 9. Idle photo slideshow: create /etc/thinclient-agent/gallery-credentials on the"
echo " booted machine (chmod 600) from the .example next to it, matching whatever" echo " booted machine (chmod 600) from the .example next to it, matching whatever"
echo " GALLERY_SMB_USERNAME/GALLERY_SMB_PASSWORD you set in" echo " GALLERY_SMB_USERNAME/GALLERY_SMB_PASSWORD you set in"
echo " hosts/container-host/scripts/setup-container-host.sh. Until that file exists," echo " tools/setup-container-host.sh. Until that file exists,"
echo " idle timeout just blanks the panel — the old, pre-slideshow behaviour." echo " idle timeout just blanks the panel — the old, pre-slideshow behaviour."
echo echo
echo "Then pull the power on the container host and re-check: the kiosk session must" echo "Then pull the power on the container host and re-check: the kiosk session must"

View File

@ -23,41 +23,54 @@
# generated tree that actually gets baked into the image). Never hand-edit anything # generated tree that actually gets baked into the image). Never hand-edit anything
# under includes.chroot — it is wiped and regenerated on every run. # under includes.chroot — it is wiped and regenerated on every run.
# #
# Run as: sudo ./build-touch-panel-iso.sh # Run as: sudo -E tools/build-touch-panel-iso.sh [hostname]
# #
# EDIT THE VARIABLES BELOW BEFORE RUNNING. # Configuration comes from CoreSystemConfig.json — see tools/README.md.
set -euo pipefail set -euo pipefail
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CONFIGURATION — edit these before running # CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
# --------------------------------------------------------------------------- #
DEBIAN_RELEASE="bookworm" # Matches the container host's OS # There is nothing to edit here any more. Every value below is read from the one
KIOSK_USERNAME="kiosk" # The autologin account the whole image is built around # config at the repo root, so an address or token can only be wrong in a single
IMAGE_HOSTNAME="touch-panel" # Hostname baked into the image # place. Change it there and rebuild; see tools/README.md.
TOUCHPANEL_NAME="Kitchen touch panel" # Friendly name shown on the HA device #
# sudo -E tools/build-touch-panel-iso.sh # the only touch-panel in the config
KEYBOARD_LAYOUT="de" # xkb layout name (`localectl list-x11-keymap-layouts`) # sudo -E tools/build-touch-panel-iso.sh <hostname> # a specific one, if several are defined
#
ENABLE_INSTALLER="false" # "true" adds a debian-installer to the ISO (install to disk) # The build refuses to start if the config is invalid (validate-config.py runs first),
# so a typo costs seconds rather than a 40-minute build and a reboot.
# --- Where the touch panel talks to -----------------------------------------
MQTT_BROKER_HOST="192.168.1.10" # <-- EDIT: container-host IP running Mosquitto
MQTT_BROKER_PORT="1883"
MQTT_USERNAME="" # Leave empty while Mosquitto runs allow_anonymous
MQTT_PASSWORD="" # Never commit a real value here
HA_URL="http://192.168.1.10:8123" # <-- EDIT: Home Assistant URL — the "Home" workspace
# Optional: an SSH public key to bake into the kiosk account for out-of-band admin.
# The image ships with password auth disabled and no wayvnc (see README's scope note),
# so without this the only admin path is the local console.
SSH_AUTHORIZED_KEY=""
# ---------------------------------------------------------------------------
# Paths
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOUCH_PANEL_DIR="$(dirname "$SCRIPT_DIR")" # shellcheck source=lib/coreconfig.sh
source "${SCRIPT_DIR}/lib/coreconfig.sh"
core_select_kiosk "touch-panel" "${1:-}"
# Mapped onto this script's existing variable names, so everything below is unchanged
# from when these were hand-edited constants.
DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE"
KIOSK_USERNAME="$CORE_KIOSK_USERNAME"
IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME"
KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT"
ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER"
MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST"
MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT"
MQTT_USERNAME="$CORE_MQTT_USERNAME"
MQTT_PASSWORD="$CORE_MQTT_PASSWORD"
SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY"
ENABLE_VOICE_SATELLITE="$CORE_KIOSK_VOICE_SATELLITE"
VOICE_SATELLITE_NAME="$CORE_KIOSK_FRIENDLY_NAME"
VOICE_WAKE_WORD="$CORE_KIOSK_WAKE_WORD"
TOUCHPANEL_NAME="$CORE_KIOSK_FRIENDLY_NAME"
HA_URL="$CORE_HA_URL"
# ---------------------------------------------------------------------------
# Paths — this script now lives in tools/, so the host directory it drives is
# addressed from the repo root rather than relative to the script.
# ---------------------------------------------------------------------------
TOUCH_PANEL_DIR="${CORE_REPO_ROOT}/hosts/touch-panel"
CONFIGS_DIR="${TOUCH_PANEL_DIR}/configs" CONFIGS_DIR="${TOUCH_PANEL_DIR}/configs"
AGENT_DIR="${TOUCH_PANEL_DIR}/agent" AGENT_DIR="${TOUCH_PANEL_DIR}/agent"
LIVE_BUILD_DIR="${TOUCH_PANEL_DIR}/live-build" LIVE_BUILD_DIR="${TOUCH_PANEL_DIR}/live-build"
@ -96,17 +109,7 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then
exit 1 exit 1
fi fi
if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then
echo "Warning: MQTT_BROKER_HOST is still the placeholder IP."
echo " Edit it at the top of this script to your container host's real LAN address,"
echo " or the touch panel won't show up as Home Assistant entities."
fi
if [[ "$HA_URL" == "http://192.168.1.10:8123" ]]; then
echo "Warning: HA_URL is still the placeholder."
echo " The image builds and boots fine, but the Home workspace will show a"
echo " connection error until this points at a real Home Assistant."
fi
echo echo
echo "=== Smart Home Touch-Panel ISO Builder ===" echo "=== Smart Home Touch-Panel ISO Builder ==="
@ -199,8 +202,8 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo "--- Writing /etc/touchpanel-agent/config.env into includes.chroot ---" echo "--- Writing /etc/touchpanel-agent/config.env into includes.chroot ---"
cat > "$INCLUDES/etc/touchpanel-agent/config.env" <<EOF cat > "$INCLUDES/etc/touchpanel-agent/config.env" <<EOF
# Generated by hosts/touch-panel/scripts/build-touch-panel-iso.sh — do not hand-edit # Generated by tools/build-touch-panel-iso.sh — do not hand-edit
# here; edit the CONFIGURATION block in that script and rebuild. # here; change CoreSystemConfig.json at the repo root and rebuild.
KIOSK_USERNAME=${KIOSK_USERNAME} KIOSK_USERNAME=${KIOSK_USERNAME}
TOUCHPANEL_NAME=${TOUCHPANEL_NAME} TOUCHPANEL_NAME=${TOUCHPANEL_NAME}

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

@ -0,0 +1,167 @@
#!/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", "build-output"))
# --- 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))

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

@ -0,0 +1,180 @@
# 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
}
# 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 set -euo pipefail
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CONFIGURATION — edit these before running # CONFIGURATION
#
# Every value here is `${VAR:-default}`, so anything already in the environment wins.
# That is how the ISO built by tools/build-container-host-iso.sh configures this
# script without editing it: the first-boot unit sources a generated env file derived
# from CoreSystemConfig.json, and these defaults apply only to a hand-run install.
# Edit them directly ONLY if you are running this script standalone.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
BASE_DIR="/opt/smart-home" # Where all container config/data will live BASE_DIR="${BASE_DIR:-/opt/smart-home}" # Where all container config/data will live
TIMEZONE="Europe/Vienna" # Adjust to your timezone TIMEZONE="${TIMEZONE:-Europe/Vienna}" # Adjust to your timezone
ENABLE_MEALIE="false" # Set to "true" to also deploy Mealie ENABLE_MEALIE="${ENABLE_MEALIE:-false}" # Set to "true" to also deploy Mealie
ENABLE_INTEL_HWACCEL="false" # Set to "true" if this host has an Intel iGPU for Frigate ENABLE_INTEL_HWACCEL="${ENABLE_INTEL_HWACCEL:-false}" # Set to "true" if this host has an Intel iGPU for Frigate
# --- Phase 1/9 add-ons — on by default, set to "false" to skip any of them --- # --- Phase 1/9 add-ons — on by default, set to "false" to skip any of them ---
ENABLE_NODERED="true" ENABLE_NODERED="${ENABLE_NODERED:-true}"
ENABLE_NETDATA="true" ENABLE_NETDATA="${ENABLE_NETDATA:-true}"
ENABLE_HOMEPAGE="true" ENABLE_HOMEPAGE="${ENABLE_HOMEPAGE:-true}"
ENABLE_NTFY="true" ENABLE_NTFY="${ENABLE_NTFY:-true}"
ENABLE_PORTAINER="true" ENABLE_PORTAINER="${ENABLE_PORTAINER:-true}"
# --- Gallery SMB share — off by default until a password is chosen ---------- # --- Gallery SMB share — off by default until a password is chosen ----------
# Serves $BASE_DIR/gallery read-only over SMB so idle thin clients cycle through photos # Serves $BASE_DIR/gallery read-only over SMB so idle thin clients cycle through photos
@ -75,43 +81,43 @@ ENABLE_PORTAINER="true"
# auto-generated: the identical value has to be typed into # auto-generated: the identical value has to be typed into
# /etc/thinclient-agent/gallery-credentials on every thin client, so a secret only this # /etc/thinclient-agent/gallery-credentials on every thin client, so a secret only this
# script ever saw would be a secret the other end cannot have. Pick one yourself. # script ever saw would be a secret the other end cannot have. Pick one yourself.
ENABLE_GALLERY_SMB="false" ENABLE_GALLERY_SMB="${ENABLE_GALLERY_SMB:-false}"
GALLERY_SMB_USERNAME="gallery" GALLERY_SMB_USERNAME="${GALLERY_SMB_USERNAME:-gallery}"
GALLERY_SMB_PASSWORD="" # <-- SET THIS before flipping the toggle above GALLERY_SMB_PASSWORD="${GALLERY_SMB_PASSWORD:-}" # <-- SET THIS before flipping the toggle above
# --- Scheduled backups (restic) — off by default until you pick a target --- # --- Scheduled backups (restic) — off by default until you pick a target ---
# Set ENABLE_BACKUPS=true and RESTIC_REPOSITORY to a local path (e.g. an # Set ENABLE_BACKUPS=true and RESTIC_REPOSITORY to a local path (e.g. an
# external/USB drive mount, or a NAS mount), or a remote target restic # external/USB drive mount, or a NAS mount), or a remote target restic
# supports (s3:..., sftp:..., b2:..., rest:...). See https://restic.net # supports (s3:..., sftp:..., b2:..., rest:...). See https://restic.net
ENABLE_BACKUPS="false" ENABLE_BACKUPS="${ENABLE_BACKUPS:-false}"
RESTIC_REPOSITORY="/mnt/backup/smart-home-restic" RESTIC_REPOSITORY="${RESTIC_REPOSITORY:-/mnt/backup/smart-home-restic}"
BACKUP_SCHEDULE="03:30" # systemd OnCalendar time, daily at this local time BACKUP_SCHEDULE="${BACKUP_SCHEDULE:-03:30}" # systemd OnCalendar time, daily at this local time
# --- Zigbee adapter: USB (CC2652P/CH340C, e.g. Haozee/Sonoff Dongle-P style) --- # --- Zigbee adapter: USB (CC2652P/CH340C, e.g. Haozee/Sonoff Dongle-P style) ---
# Run `ls -l /dev/serial/by-id/` AFTER plugging the adapter in, and paste the # Run `ls -l /dev/serial/by-id/` AFTER plugging the adapter in, and paste the
# full path it shows here. This is more stable across reboots than /dev/ttyUSB0. # full path it shows here. This is more stable across reboots than /dev/ttyUSB0.
# Example: /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0 # Example: /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0
ZIGBEE_USB_DEVICE="/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0" ZIGBEE_USB_DEVICE="${ZIGBEE_USB_DEVICE:-/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0}"
# --- Quarter-daily LLM digest (Phase 12) — off by default until credentials # --- Quarter-daily LLM digest (Phase 12) — off by default until credentials
# --- are provisioned. See digest-engine/README.md. # --- are provisioned. See digest-engine/README.md.
ENABLE_DIGEST_ENGINE="false" ENABLE_DIGEST_ENGINE="${ENABLE_DIGEST_ENGINE:-false}"
# WhatsApp ingestion is separately gated and highest-risk of the four message # WhatsApp ingestion is separately gated and highest-risk of the four message
# platforms. Read digest-engine/README.md before setting this to "true" — real # platforms. Read digest-engine/README.md before setting this to "true" — real
# ban risk even with the headful-Chromium mitigation; use a secondary number. # ban risk even with the headful-Chromium mitigation; use a secondary number.
ENABLE_WHATSAPP_INGEST="false" ENABLE_WHATSAPP_INGEST="${ENABLE_WHATSAPP_INGEST:-false}"
# Where this repo's digest-engine/ directory lives on THIS host (build context). # Where this repo's digest-engine/ directory lives on THIS host (build context).
DIGEST_ENGINE_SRC="/opt/smart-home/src/digest-engine" DIGEST_ENGINE_SRC="${DIGEST_ENGINE_SRC:-/opt/smart-home/src/digest-engine}"
DIGEST_WEB_PORT="8091" # LAN-facing read-only static serving DIGEST_WEB_PORT="${DIGEST_WEB_PORT:-8091}" # LAN-facing read-only static serving
DIGEST_SCHEDULE="00,06,12,18" # systemd OnCalendar hours, 4x/day DIGEST_SCHEDULE="${DIGEST_SCHEDULE:-00,06,12,18}" # systemd OnCalendar hours, 4x/day
# --- On-demand sys-admin-llm display surface (Phase 13) — off by default until # --- On-demand sys-admin-llm display surface (Phase 13) — off by default until
# --- ADMIN_CANVAS_TOKEN is provisioned. See admin-canvas/README.md. # --- ADMIN_CANVAS_TOKEN is provisioned. See admin-canvas/README.md.
ENABLE_ADMIN_CANVAS="false" ENABLE_ADMIN_CANVAS="${ENABLE_ADMIN_CANVAS:-false}"
# Where this repo's admin-canvas/ directory lives on THIS host (build context). # Where this repo's admin-canvas/ directory lives on THIS host (build context).
ADMIN_CANVAS_SRC="/opt/smart-home/src/admin-canvas" ADMIN_CANVAS_SRC="${ADMIN_CANVAS_SRC:-/opt/smart-home/src/admin-canvas}"
ADMIN_CANVAS_PORT="8092" # internal only — no `ports:` mapping, HA-reachable only ADMIN_CANVAS_PORT="${ADMIN_CANVAS_PORT:-8092}" # internal only — no `ports:` mapping, HA-reachable only
ADMIN_WEB_PORT="8094" # LAN-facing read-only static serving ADMIN_WEB_PORT="${ADMIN_WEB_PORT:-8094}" # LAN-facing read-only static serving
# --- Kitchen-display camera cataloguing backend (Phase 17) — off by default until # --- Kitchen-display camera cataloguing backend (Phase 17) — off by default until
# --- PANTRY_VISION_TOKEN and GROCY_API_KEY are provisioned. See pantry-vision/README.md. # --- PANTRY_VISION_TOKEN and GROCY_API_KEY are provisioned. See pantry-vision/README.md.
@ -120,38 +126,38 @@ ADMIN_WEB_PORT="8094" # LAN-facing read-only static serving
# deliberately unlike ADMIN_CANVAS_PORT above, it DOES get a `ports:` mapping. The # deliberately unlike ADMIN_CANVAS_PORT above, it DOES get a `ports:` mapping. The
# bearer token is the actual boundary here, not network placement — see # bearer token is the actual boundary here, not network placement — see
# pantry-vision/README.md's "A real network listener, unlike admin-canvas" section. # pantry-vision/README.md's "A real network listener, unlike admin-canvas" section.
ENABLE_PANTRY_VISION="false" ENABLE_PANTRY_VISION="${ENABLE_PANTRY_VISION:-false}"
# Where this repo's pantry-vision/ directory lives on THIS host (build context). # Where this repo's pantry-vision/ directory lives on THIS host (build context).
PANTRY_VISION_SRC="/opt/smart-home/src/pantry-vision" PANTRY_VISION_SRC="${PANTRY_VISION_SRC:-/opt/smart-home/src/pantry-vision}"
PANTRY_VISION_PORT="8095" # LAN-facing — the kitchen display's kiosk browser calls this directly PANTRY_VISION_PORT="${PANTRY_VISION_PORT:-8095}" # LAN-facing — the kitchen display's kiosk browser calls this directly
PANTRY_WEB_PORT="8096" # LAN-facing read-only static serving (the kiosk's frontend) PANTRY_WEB_PORT="${PANTRY_WEB_PORT:-8096}" # LAN-facing read-only static serving (the kiosk's frontend)
# --- Person <-> BLE-identifier registry (Phase 6) — off by default until # --- Person <-> BLE-identifier registry (Phase 6) — off by default until
# --- IDENTITY_TOKEN, HA_TOKEN, and TRUSTED_ENTITY_PREFIXES are provisioned. See # --- IDENTITY_TOKEN, HA_TOKEN, and TRUSTED_ENTITY_PREFIXES are provisioned. See
# --- identity/README.md. Same "published, unlike admin-canvas" reasoning as # --- identity/README.md. Same "published, unlike admin-canvas" reasoning as
# --- ENABLE_PANTRY_VISION above — hosts/kitchen-display's and hosts/door-panel's # --- ENABLE_PANTRY_VISION above — hosts/kitchen-display's and hosts/door-panel's
# --- kiosk browsers call this directly. # --- kiosk browsers call this directly.
ENABLE_IDENTITY="false" ENABLE_IDENTITY="${ENABLE_IDENTITY:-false}"
# Where this repo's identity/ directory lives on THIS host (build context). # Where this repo's identity/ directory lives on THIS host (build context).
IDENTITY_SRC="/opt/smart-home/src/identity" IDENTITY_SRC="${IDENTITY_SRC:-/opt/smart-home/src/identity}"
IDENTITY_PORT="8097" # LAN-facing — kiosk browsers call this directly IDENTITY_PORT="${IDENTITY_PORT:-8097}" # LAN-facing — kiosk browsers call this directly
IDENTITY_WEB_PORT="8098" # LAN-facing read-only static serving (register.html/dashboard.html) IDENTITY_WEB_PORT="${IDENTITY_WEB_PORT:-8098}" # LAN-facing read-only static serving (register.html/dashboard.html)
# --- Trash collection date sync (Phase 19) — off by default until WASTE_ICS_URL and # --- Trash collection date sync (Phase 19) — off by default until WASTE_ICS_URL and
# --- CALDAV_TARGET_CALENDAR are provisioned. See trash-calendar/README.md. A oneshot, # --- CALDAV_TARGET_CALENDAR are provisioned. See trash-calendar/README.md. A oneshot,
# --- like digest-engine, not a listener — no port, nothing to publish. # --- like digest-engine, not a listener — no port, nothing to publish.
ENABLE_TRASH_CALENDAR="false" ENABLE_TRASH_CALENDAR="${ENABLE_TRASH_CALENDAR:-false}"
# Where this repo's trash-calendar/ directory lives on THIS host (build context). # Where this repo's trash-calendar/ directory lives on THIS host (build context).
TRASH_CALENDAR_SRC="/opt/smart-home/src/trash-calendar" TRASH_CALENDAR_SRC="${TRASH_CALENDAR_SRC:-/opt/smart-home/src/trash-calendar}"
# --- Public transit "when's the next bus" voice lookup (Phase 19) — off by default # --- Public transit "when's the next bus" voice lookup (Phase 19) — off by default
# --- until TRANSIT_TOKEN and GTFS_FEED_URL are provisioned. See transit/README.md. # --- until TRANSIT_TOKEN and GTFS_FEED_URL are provisioned. See transit/README.md.
# --- Published like pantry-vision/identity — homeassistant's network_mode: host # --- Published like pantry-vision/identity — homeassistant's network_mode: host
# --- can't resolve container DNS names, so its rest_command needs a real port. # --- can't resolve container DNS names, so its rest_command needs a real port.
ENABLE_TRANSIT="false" ENABLE_TRANSIT="${ENABLE_TRANSIT:-false}"
# Where this repo's transit/ directory lives on THIS host (build context). # Where this repo's transit/ directory lives on THIS host (build context).
TRANSIT_SRC="/opt/smart-home/src/transit" TRANSIT_SRC="${TRANSIT_SRC:-/opt/smart-home/src/transit}"
TRANSIT_PORT="8099" # reachable by HA's rest_command (voice lookups) TRANSIT_PORT="${TRANSIT_PORT:-8099}" # reachable by HA's rest_command (voice lookups)
# --- On-demand route planning (Phase 19) — a SEPARATE opt-in from ENABLE_TRANSIT # --- On-demand route planning (Phase 19) — a SEPARATE opt-in from ENABLE_TRANSIT
# --- above on purpose: a real OpenTripPlanner graph (OSM + GTFS) is a meaningfully # --- above on purpose: a real OpenTripPlanner graph (OSM + GTFS) is a meaningfully
@ -160,22 +166,22 @@ TRANSIT_PORT="8099" # reachable by HA's rest_command (voice looku
# --- "Austria-wide" is a moderate commitment, "global" is a real infrastructure # --- "Austria-wide" is a moderate commitment, "global" is a real infrastructure
# --- decision, not a flag. This script does NOT build the OTP graph for you — that's # --- decision, not a flag. This script does NOT build the OTP graph for you — that's
# --- a manual, one-time (per OSM/GTFS update) step; see OpenTripPlanner's own docs. # --- a manual, one-time (per OSM/GTFS update) step; see OpenTripPlanner's own docs.
ENABLE_TRIP_PLANNING="false" ENABLE_TRIP_PLANNING="${ENABLE_TRIP_PLANNING:-false}"
OTP_GRAPHS_DIR="/opt/smart-home/otp-graphs" # you populate this by hand, see above OTP_GRAPHS_DIR="${OTP_GRAPHS_DIR:-/opt/smart-home/otp-graphs}" # you populate this by hand, see above
# Host-side published port for OTP's own web/GraphQL API. NOT 8080 — zigbee2mqtt's # Host-side published port for OTP's own web/GraphQL API. NOT 8080 — zigbee2mqtt's
# frontend (always-on, below) already publishes 8080:8080; OTP's own container- # frontend (always-on, below) already publishes 8080:8080; OTP's own container-
# internal port stays 8080 regardless (transit.env.example's OTP_URL correctly # internal port stays 8080 regardless (transit.env.example's OTP_URL correctly
# reaches it via container DNS as http://otp:8080), only the host-side mapping # reaches it via container DNS as http://otp:8080), only the host-side mapping
# needed to move to avoid the two colliding on the same host. # needed to move to avoid the two colliding on the same host.
OTP_PORT="8100" OTP_PORT="${OTP_PORT:-8100}"
# --- Household chore distribution + reminders + camera verification (Phase 20) — # --- Household chore distribution + reminders + camera verification (Phase 20) —
# --- off by default. Works with just ENABLE_IDENTITY on (assignment) and ntfy # --- off by default. Works with just ENABLE_IDENTITY on (assignment) and ntfy
# --- (reminders); the camera-check and trash-day-eve steps each individually # --- (reminders); the camera-check and trash-day-eve steps each individually
# --- no-op until their own env vars are set — see chores/README.md. # --- no-op until their own env vars are set — see chores/README.md.
ENABLE_CHORES="false" ENABLE_CHORES="${ENABLE_CHORES:-false}"
# Where this repo's chores/ directory lives on THIS host (build context). # Where this repo's chores/ directory lives on THIS host (build context).
CHORES_SRC="/opt/smart-home/src/chores" CHORES_SRC="${CHORES_SRC:-/opt/smart-home/src/chores}"
# --- Music Assistant (optional, additive — docs/project-plan.md §2) --------- # --- Music Assistant (optional, additive — docs/project-plan.md §2) ---------
# Unifies Spotify Connect + other sources behind one HA-native multi-room player. # Unifies Spotify Connect + other sources behind one HA-native multi-room player.
@ -195,7 +201,7 @@ CHORES_SRC="/opt/smart-home/src/chores"
# Check Music Assistant's own docs/config for how to change its listen port # Check Music Assistant's own docs/config for how to change its listen port
# BEFORE flipping this on if ENABLE_PANTRY_VISION is also true — the sanity check # BEFORE flipping this on if ENABLE_PANTRY_VISION is also true — the sanity check
# below only warns, it does not change either port for you. # below only warns, it does not change either port for you.
ENABLE_MUSIC_ASSISTANT="false" ENABLE_MUSIC_ASSISTANT="${ENABLE_MUSIC_ASSISTANT:-false}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Sanity checks # Sanity checks
@ -1468,7 +1474,7 @@ fi
if [[ "$ENABLE_PANTRY_VISION" == "true" ]]; then if [[ "$ENABLE_PANTRY_VISION" == "true" ]]; then
echo " 15. Fill in $BASE_DIR/pantry-vision/pantry-vision.env before the kitchen display can" echo " 15. Fill in $BASE_DIR/pantry-vision/pantry-vision.env before the kitchen display can"
echo " identify anything: PANTRY_VISION_TOKEN (also goes into" echo " identify anything: PANTRY_VISION_TOKEN (also goes into"
echo " hosts/kitchen-display/scripts/build-kitchen-display-iso.sh — both sides need the" echo " tools/build-kitchen-display-iso.sh — both sides need the"
echo " SAME value) and GROCY_API_KEY (Grocy's own UI: Settings -> Manage API keys, at" echo " SAME value) and GROCY_API_KEY (Grocy's own UI: Settings -> Manage API keys, at"
echo " http://${HOST_IP}:9283). Also pull a vision-capable Ollama model on the LLM host" echo " http://${HOST_IP}:9283). Also pull a vision-capable Ollama model on the LLM host"
echo " (e.g. 'ollama pull llava') — OLLAMA_VISION_MODEL defaults to one that is NOT" echo " (e.g. 'ollama pull llava') — OLLAMA_VISION_MODEL defaults to one that is NOT"

View File

@ -31,21 +31,26 @@
set -euo pipefail set -euo pipefail
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CONFIGURATION — edit these before running # CONFIGURATION
#
# Every value here is `${VAR:-default}`, so anything already in the environment wins —
# 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="/opt/llm-host" # Config + model storage. Models are BIG (a 14B 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) # Q4 model is ~9GB, a vision model another 5-8GB)
# — make sure this lives on a disk with room. # — make sure this lives on a disk with room.
TIER="auto" # auto | gpu | cpu TIER="${TIER:-auto}" # auto | gpu | cpu
OLLAMA_PORT="11434" # Ollama's own default. 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 # 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 # (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. # you actually ended up with, since library tags do get renamed upstream.
GPU_TEXT_MODEL="qwen2.5:14b-instruct" GPU_TEXT_MODEL="${GPU_TEXT_MODEL:-qwen2.5:14b-instruct}"
CPU_TEXT_MODEL="qwen2.5:7b-instruct" CPU_TEXT_MODEL="${CPU_TEXT_MODEL:-qwen2.5:7b-instruct}"
# The vision model, for pantry-vision (grocery items) and chores (bin/dishes/litter). # 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, # NOT A CONSIDERED CHOICE — `llava` is the default those services already ship with,
@ -53,9 +58,9 @@ CPU_TEXT_MODEL="qwen2.5:7b-instruct"
# unbenchmarked. Treat this as "something to measure", not "the answer": if grocery # 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 # 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). # (qwen2.5vl and moondream are the obvious alternatives to try).
VISION_MODEL="llava" VISION_MODEL="${VISION_MODEL:-llava}"
PULL_VISION_MODEL="true" # false to skip — saves several GB if you're not PULL_VISION_MODEL="${PULL_VISION_MODEL:-true}" # false to skip — saves several GB if you're not
# running pantry-vision/chores camera checks yet. # running pantry-vision/chores camera checks yet.
# --- Contention between interactive and batch callers ------------------------------ # --- Contention between interactive and batch callers ------------------------------
@ -68,18 +73,18 @@ PULL_VISION_MODEL="true" # false to skip — saves several GB if you'
# KEEP_ALIVE — how long a model stays resident after its last request. Ollama's own # 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 # 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. # pays the model-load cost almost every time. 30m keeps it warm through normal use.
OLLAMA_KEEP_ALIVE="30m" OLLAMA_KEEP_ALIVE="${OLLAMA_KEEP_ALIVE:-30m}"
# MAX_LOADED_MODELS — how many distinct models may be resident at once. **1 is # 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 # 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 # 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 # 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 # 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. # to hold both and have checked that you do.
OLLAMA_MAX_LOADED_MODELS="1" OLLAMA_MAX_LOADED_MODELS="${OLLAMA_MAX_LOADED_MODELS:-1}"
# NUM_PARALLEL — concurrent requests served per loaded model. 1 keeps latency # NUM_PARALLEL — concurrent requests served per loaded model. 1 keeps latency
# predictable for whoever is speaking to Assist; higher trades that for throughput # predictable for whoever is speaking to Assist; higher trades that for throughput
# nothing in this project currently needs. # nothing in this project currently needs.
OLLAMA_NUM_PARALLEL="1" OLLAMA_NUM_PARALLEL="${OLLAMA_NUM_PARALLEL:-1}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# End of configuration # End of configuration
@ -110,7 +115,7 @@ detect_tier() {
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Docker — same install path as hosts/container-host/scripts/setup-container-host.sh. # Docker — same install path as tools/setup-container-host.sh.
# Ollama is run as a container rather than natively installed for the same reason # 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 # 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 # image, and an uninstall that's `docker rm`. The native installer is a legitimate
@ -185,7 +190,7 @@ write_compose() {
mkdir -p "$BASE_DIR/models" mkdir -p "$BASE_DIR/models"
cat > "$BASE_DIR/docker-compose.yml" <<EOF cat > "$BASE_DIR/docker-compose.yml" <<EOF
# Generated by hosts/llm-host/scripts/setup-llm-host.sh — re-running the script # Generated by tools/setup-llm-host.sh — re-running the script
# regenerates this file. Tier: ${tier} # regenerates this file. Tier: ${tier}
services: services:
ollama: ollama:

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