diff --git a/.gitignore b/.gitignore index 6624f5f..69aaed4 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,28 @@ hosts/audio-endpoint/rpi-image-gen/rpi-image-gen/ __pycache__/ *.pyc + +# --------------------------------------------------------------------------- +# Build system (tools/) — see tools/README.md +# +# CoreSystemConfig.json is the FILLED-IN config: Wi-Fi PSK, every service token, +# MQTT and HA credentials. Only the .template is tracked. Never commit a real one. +CoreSystemConfig.json +CoreSystemConfig.*.json +!CoreSystemConfig.json.template + +# Built images. These have every secret above burnt into them by design, so they are +# credential-bearing artifacts, not just large ones. +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 diff --git a/CoreSystemConfig.json.template b/CoreSystemConfig.json.template new file mode 100644 index 0000000..e4165d9 --- /dev/null +++ b/CoreSystemConfig.json.template @@ -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" + } +} diff --git a/README.md b/README.md index 36c5a52..b948c21 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,7 @@ hosts/ container-host/ Docker Compose stack: HA, Mosquitto, Zigbee2MQTT, Frigate, Grocy, Node-RED, monitoring, etc. configs/ Per-service config files (mosquitto.conf, etc.) - scripts/ Host setup / bootstrap scripts - llm-host/ Ollama + GPU host setup (separate physical machine) + llm-host/ Ollama + GPU host (separate physical machine) thin-client/ Sway kiosk/media-station ISO (live-build) + thinclient-agent audio-endpoint/ Headless Spotify Connect appliance for rooms with no thin client — arm64 (rpi-image-gen) + amd64 (live-build) images @@ -40,6 +39,11 @@ firmware/ esp32-s3-touch-lcd-1.85c/ ESPHome voice satellite + status display (round LCD, media/cover-art priority over an idle weather/time/ date cycle, voice-state visualizer) +tools/ All build + setup scripts, driven by one + CoreSystemConfig.json: twinned container-host/LLM-host + ISO pair, every kiosk ISO, and a validator that refuses + a build on port collisions, placeholder tokens, or a + kiosk pointed at a disabled service identity/ Person <-> BLE-identifier registry: multi-phone support, anti-spoofing (allowlisted IRK-resolved/fixed-tag entities only, never a raw MAC), voice/touch @@ -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` - [ ] 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 +- [ ] `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 +Everything is built from **one** config file — `CoreSystemConfig.json` — so an address +or token can only ever be wrong in one place: + ```bash -cd hosts/container-host/scripts -sudo ./setup-container-host.sh +cp CoreSystemConfig.json.template CoreSystemConfig.json +$EDITOR CoreSystemConfig.json # subnet, two host octets, tokens, your kiosks +tools/validate-config.py # catches typos in seconds, not after a 40-min build +sudo -E tools/build-all.sh # every ISO, all agreeing with each other +``` + +The container host and LLM host come out as a **twinned pair**: you give each a last +octet, and the container host's `OLLAMA_HOST` is *derived* from the LLM host's — change +one and the other follows on the next build, with nothing to keep in sync by hand. Every +kiosk's service URLs derive from the container host's address the same way. See +[`tools/README.md`](tools/README.md). + +Built ISOs contain every secret in the config, by design (nothing to configure +post-install) — which makes each one a credential. `.gitignore` covers both the +filled-in config and `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 diff --git a/digest-engine/README.md b/digest-engine/README.md index a7a7e2e..1765e5c 100644 --- a/digest-engine/README.md +++ b/digest-engine/README.md @@ -310,7 +310,7 @@ an exact match would drop the feature on precisely the days the digest is late. Set `DIGEST_FORCE_EVENING=true` for a one-off run to test it at any hour. Keep `DIGEST_SCHEDULE` in step with the variable of the same name in -`hosts/container-host/scripts/setup-container-host.sh`, which is what sets the +`tools/setup-container-host.sh`, which is what sets the timer. ## Merging an unviewed digest into the next one diff --git a/docs/project-plan.md b/docs/project-plan.md index d93827a..8f568d2 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -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.** ### 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. 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. @@ -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 1–2 cameras. ### Phase 11 — Sway thin-client ISO -1. Scaffold a `live-build` tree at `hosts/thin-client/live-build/` (Debian 12, matching container-host's OS). `config/package-lists/thin-client.list.chroot` pulls `sway`, `greetd`, `wayvnc`, `xwayland`, `firefox-esr`, `mpv`, `mpv-mpris`, `spotifyd` (or `librespot`), `flatpak` (Steam Link), `wyoming-satellite` + `openwakeword` deps, plus `pipewire`/`wireplumber`. `hosts/thin-client/scripts/build-thin-client-iso.sh` drives `lb config && lb build`. +1. Scaffold a `live-build` tree at `hosts/thin-client/live-build/` (Debian 12, matching container-host's OS). `config/package-lists/thin-client.list.chroot` pulls `sway`, `greetd`, `wayvnc`, `xwayland`, `firefox-esr`, `mpv`, `mpv-mpris`, `spotifyd` (or `librespot`), `flatpak` (Steam Link), `wyoming-satellite` + `openwakeword` deps, plus `pipewire`/`wireplumber`. `tools/build-thin-client-iso.sh` drives `lb config && lb build`. 2. Autologin straight into a kiosk Sway session via **greetd** (`initial_session` block runs `sway` directly, no greeter UI) — not the older getty+`.bash_profile` hack. 3. Remote control: **wayvnc** for interactive screen view/control. **Sway/wlroots has no maintained RDP path** (wlroots dropped its RDP backend; xrdp is X11-only) — wayvnc is the deliberate, confirmed replacement for "RDP" in this project, not a stopgap. 4. Build `thinclient-agent` (Python, `hosts/thin-client/agent/`) as a systemd service baked into the image: @@ -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). 29. **Music Assistant has not been installed or configured** (new) — catalogued in §2 as an optional, additive HA add-on; whether it's worth adding on top of the existing per-room spotifyd/librespot/Spotify-client setup (which keeps working standalone regardless) is a real usage-pattern question, not answerable until the existing per-room setups (Phase 11.6/15/16) are actually running. 30. **The self-check/hardware-monitoring integrations catalog (§2) is a menu, not a deployment plan** (new) — System Monitor, SNMP, NUT, Glances, and Uptime Kuma are all listed with their purpose, but which ones are actually worth installing depends on hardware decisions not yet made (is there a UPS? managed switches? which of this repo's published services matter enough to alert on?). -31. **Music Assistant's default port is a guess, and it collides with `PANTRY_VISION_PORT` in this exact stack** (new) — assumed 8095 from Music Assistant's own docs, not confirmed against a running instance; `PANTRY_VISION_PORT` is also 8095. Because Music Assistant runs with `network_mode: host` (needed for player-discovery mDNS), Docker Compose's own port-collision checking doesn't catch this the way a normal `ports:` mapping would — `setup-container-host.sh` warns if both `ENABLE_MUSIC_ASSISTANT` and `ENABLE_PANTRY_VISION` are set, but resolving the actual clash (changing Music Assistant's configured listen port) is a manual step, not automated. +31. ~~Music Assistant's default port is a guess, and it collides with `PANTRY_VISION_PORT` in this exact stack~~ — **mechanically resolved** by `tools/`: both ports are now declared in `CoreSystemConfig.json`, and `validate-config.py` **fails the build** on any duplicate, so the clash cannot reach a deployment. The template assigns Music Assistant 8101. **Still genuinely open**: its real default (8095) is still an unverified guess, and because it runs `network_mode: host`, moving it requires configuring Music Assistant itself — the config file can declare the port but cannot make the service bind to it. 32. ~~RuView's semantic-state MQTT entities have no opt-out or visibility restriction beyond this network's normal trust boundary~~ — **household decision made**: real automations are now built on this data (sleep → dim lights, possible-distress → whole-household alert, concurrent elevated heart rate → colored lighting, bathroom occupancy → an external door indicator — see `firmware/ruview/README.md` §5 and `firmware/ruview/automations.yaml.example`). **Still genuinely open**: there is no technical opt-out for a specific person/room and no access restriction on these MQTT topics beyond this network's normal trust boundary — worth revisiting if anyone not on board with being sensed this way ever stays over. Every automation's `entity_id` is also still an unconfirmed placeholder (see #33), and rule 3 (concurrent two-person heart rate) rests on an unconfirmed assumption that a single RuView node can report two people's heart rates at once — multi-target vital-sign separation from WiFi CSI is a genuinely hard, unconfirmed capability, not something to trust until checked against real entities. 33. **RuView's build/flash commands and `provision.py`'s exact flags beyond `--port`/`--ssid`/`--password`/`--mqtt` are transcribed from its README, not independently run** (new, Phase 2) — see `firmware/ruview/README.md`'s own "Manual verification still outstanding," same category of risk as every other "written from documentation, not a live instance" open decision in this list (#19, #21). 34. **`identity`'s `DEPARTURE_GRACE_SECONDS` default (15 min) is an untuned guess at how much a real Private BLE Device setup flaps** (new, Phase 6b) — too low and one evening at home is recorded as several separate "visits," too high and a quick trip out never registers. The entire usefulness of the visit history and the co-presence view rests on this number, and nobody has watched a real BLE presence entity over a day to pick it. First thing to check once `GET /visits` has real data in it. diff --git a/firmware/esp32-s3-touch-lcd-1.85c/README.md b/firmware/esp32-s3-touch-lcd-1.85c/README.md index ed9aa68..9e6bb0f 100644 --- a/firmware/esp32-s3-touch-lcd-1.85c/README.md +++ b/firmware/esp32-s3-touch-lcd-1.85c/README.md @@ -66,7 +66,7 @@ device already on the network). ## Multiple rooms This is a per-room device, exactly like the thin client's per-image -`THINCLIENT_NAME`/`DIGEST_WEB_URL` (`hosts/thin-client/scripts/build-thin-client-iso.sh`). +`THINCLIENT_NAME`/`DIGEST_WEB_URL` (`tools/build-thin-client-iso.sh`). Two separate things both have to be set correctly, per physical unit, for "media status always on the specific room the device is in" to actually hold: diff --git a/hosts/audio-endpoint/README.md b/hosts/audio-endpoint/README.md index 909eaf3..dcb607f 100644 --- a/hosts/audio-endpoint/README.md +++ b/hosts/audio-endpoint/README.md @@ -25,7 +25,7 @@ unit/wrapper pair. Nothing else is shared — see each variant's own section bel - **rpi-image-gen is Raspberry-Pi-specific.** It doesn't target amd64. Rather than reach for a third, unfamiliar toolchain for the x86 side, the amd64 image reuses **the exact tool this repo already has and has already proven for a bootable - image**: live-build, the same one `hosts/thin-client/scripts/build-thin-client-iso.sh` + image**: live-build, the same one `tools/build-thin-client-iso.sh` drives. Lower risk than learning a new toolchain for one variant. ## Spotify Connect install: apt-first, documented fallback @@ -115,7 +115,7 @@ cd hosts/audio-endpoint/live-build-amd64/scripts sudo ./build-audio-endpoint-iso-amd64.sh ``` -Same shape as `hosts/thin-client/scripts/build-thin-client-iso.sh`: installs +Same shape as `tools/build-thin-client-iso.sh`: installs `live-build` if missing, regenerates `config/includes.chroot/` from the shared `../configs/` (git-ignored, generated — never hand-edit it), then runs `lb config && lb build`. Output is a hybrid ISO, written to a USB stick and diff --git a/hosts/door-panel/README.md b/hosts/door-panel/README.md index 77f3564..22673e0 100644 --- a/hosts/door-panel/README.md +++ b/hosts/door-panel/README.md @@ -42,11 +42,11 @@ non-specific) hardware line item. ## Before you build Deploy `identity`/`identity-web` first (`ENABLE_IDENTITY` in -`hosts/container-host/scripts/setup-container-host.sh`) — this image builds and boots +`tools/setup-container-host.sh`) — this image builds and boots fine without it, but the dashboard will show connection errors until it exists. `pantry-vision` is optional (only "Running low" needs it). Then edit the `# CONFIGURATION` block at the top of -[`scripts/build-door-panel-iso.sh`](scripts/build-door-panel-iso.sh): +[`tools/build-door-panel-iso.sh`](../../tools/build-door-panel-iso.sh): | Variable | What to put in it | |---|---| @@ -60,7 +60,7 @@ fine without it, but the dashboard will show connection errors until it exists. ## Build ```sh -sudo ./scripts/build-door-panel-iso.sh +sudo -E tools/build-door-panel-iso.sh ``` Same directory-split convention as every other host: `configs/` and `agent/` are diff --git a/hosts/kitchen-display/README.md b/hosts/kitchen-display/README.md index e575858..13fe94d 100644 --- a/hosts/kitchen-display/README.md +++ b/hosts/kitchen-display/README.md @@ -47,10 +47,10 @@ angle can be adjusted independently of the screen. See §1.15 of ## Before you build Deploy `pantry-vision`/`pantry-web` first (`ENABLE_PANTRY_VISION` in -`hosts/container-host/scripts/setup-container-host.sh`) — this image builds and boots +`tools/setup-container-host.sh`) — this image builds and boots fine without it, but every tab will show a connection error until it exists. Then edit the `# CONFIGURATION` block at the top of -[`scripts/build-kitchen-display-iso.sh`](scripts/build-kitchen-display-iso.sh): +[`tools/build-kitchen-display-iso.sh`](../../tools/build-kitchen-display-iso.sh): | Variable | What to put in it | |---|---| @@ -66,7 +66,7 @@ the `# CONFIGURATION` block at the top of ## Build ```sh -sudo ./scripts/build-kitchen-display-iso.sh +sudo -E tools/build-kitchen-display-iso.sh ``` Same directory-split convention as every other host in this project: `configs/` and diff --git a/hosts/llm-host/README.md b/hosts/llm-host/README.md index dfafc88..26a2c54 100644 --- a/hosts/llm-host/README.md +++ b/hosts/llm-host/README.md @@ -105,8 +105,7 @@ the project plan's open decision #38.) ## Run it ```sh -cd hosts/llm-host/scripts -sudo ./setup-llm-host.sh +sudo -E tools/setup-llm-host.sh ``` Edit the variables at the top first — `BASE_DIR` above all, since models are large diff --git a/hosts/thin-client/README.md b/hosts/thin-client/README.md index b0193d7..aa7c68f 100644 --- a/hosts/thin-client/README.md +++ b/hosts/thin-client/README.md @@ -35,7 +35,7 @@ What ends up on the image: > not turn it on until that list exists. Then edit the `# CONFIGURATION` block at the top of -[`scripts/build-thin-client-iso.sh`](scripts/build-thin-client-iso.sh): +[`tools/build-thin-client-iso.sh`](../../tools/build-thin-client-iso.sh): | Variable | What to put in it | |---|---| @@ -53,7 +53,7 @@ Then edit the `# CONFIGURATION` block at the top of ## Build ```sh -sudo ./scripts/build-thin-client-iso.sh +sudo -E tools/build-thin-client-iso.sh ``` It installs `live-build` if missing, regenerates diff --git a/hosts/touch-panel/README.md b/hosts/touch-panel/README.md index e9709f6..964bcf2 100644 --- a/hosts/touch-panel/README.md +++ b/hosts/touch-panel/README.md @@ -34,7 +34,7 @@ What ends up on the image: > handles either way a real touchscreen might show up to Linux. Edit the `# CONFIGURATION` block at the top of -[`scripts/build-touch-panel-iso.sh`](scripts/build-touch-panel-iso.sh): +[`tools/build-touch-panel-iso.sh`](../../tools/build-touch-panel-iso.sh): | Variable | What to put in it | |---|---| @@ -47,7 +47,7 @@ Edit the `# CONFIGURATION` block at the top of ## Build ```sh -sudo ./scripts/build-touch-panel-iso.sh +sudo -E tools/build-touch-panel-iso.sh ``` Same directory-split convention as the thin client: `configs/` and `agent/` are the diff --git a/pantry-vision/README.md b/pantry-vision/README.md index 1ef33dd..3f899d6 100644 --- a/pantry-vision/README.md +++ b/pantry-vision/README.md @@ -35,7 +35,7 @@ service directly (there is no HA-mediation step between "hold item up to camera" requires the bearer token, as the actual boundary instead of network placement. The same token has to be baked into the kitchen display's own build config -(`hosts/kitchen-display/scripts/build-kitchen-display-iso.sh`), not just Home +(`tools/build-kitchen-display-iso.sh`), not just Home Assistant's — see that host's README. ## `/identify` never writes anything by itself @@ -106,7 +106,7 @@ above, and adjust `server.py` if the shapes differ. ## Deploy -Wired into `hosts/container-host/scripts/setup-container-host.sh` behind +Wired into `tools/setup-container-host.sh` behind `ENABLE_PANTRY_VISION` (off by default) — see that script's `# CONFIGURATION` block and its own README. It builds two containers: `pantry-vision` (this API) and `pantry-web` (nginx, serves `frontend/` read-only). diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..f75faf7 --- /dev/null +++ b/tools/README.md @@ -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--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. diff --git a/tools/build-all.sh b/tools/build-all.sh new file mode 100755 index 0000000..56b3a04 --- /dev/null +++ b/tools/build-all.sh @@ -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 </dev/null | tail -n +2 | awk '{printf " %-52s %s\n", $9, $5}' || true +cat < # a specific one # --------------------------------------------------------------------------- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LIVE_BUILD_AMD64_DIR="$(dirname "$SCRIPT_DIR")" -AUDIO_ENDPOINT_DIR="$(dirname "$LIVE_BUILD_AMD64_DIR")" +# shellcheck source=lib/coreconfig.sh +source "${SCRIPT_DIR}/lib/coreconfig.sh" + +core_select_audio_endpoint amd64 "${1:-}" + +DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE" +IMAGE_HOSTNAME="$CORE_AUDIO_HOSTNAME" # also the Spotify Connect device name + +# --------------------------------------------------------------------------- +# Paths — this script now lives in tools/, so the host directory it drives is +# addressed from the repo root rather than relative to the script. +# --------------------------------------------------------------------------- +AUDIO_ENDPOINT_DIR="${CORE_REPO_ROOT}/hosts/audio-endpoint" SHARED_CONFIGS_DIR="$AUDIO_ENDPOINT_DIR/configs" -LIVE_BUILD_DIR="$LIVE_BUILD_AMD64_DIR" +LIVE_BUILD_DIR="${AUDIO_ENDPOINT_DIR}/live-build-amd64" INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot" PACKAGE_LIST="${LIVE_BUILD_DIR}/config/package-lists/audio-endpoint.list.chroot" diff --git a/tools/build-container-host-iso.sh b/tools/build-container-host-iso.sh new file mode 100755 index 0000000..16b13d5 --- /dev/null +++ b/tools/build-container-host-iso.sh @@ -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" < "$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 < "$PAYLOAD/chores/chores.env" + chmod 600 "$PAYLOAD/chores/chores.env" +fi + +if [[ "$CORE_ENABLE_PANTRY_VISION" == "true" ]]; then + { gen_header + cat < "$PAYLOAD/pantry-vision/pantry-vision.env" + chmod 600 "$PAYLOAD/pantry-vision/pantry-vision.env" +fi + +if [[ "$CORE_ENABLE_TRANSIT" == "true" ]]; then + { gen_header + cat < "$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" < "$INCLUDES/etc/hostname" +cat > "$INCLUDES/etc/hosts" < "$INCLUDES/etc/default/keyboard" < "$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" </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." diff --git a/tools/build-core-pair.sh b/tools/build-core-pair.sh new file mode 100755 index 0000000..f09438f --- /dev/null +++ b/tools/build-core-pair.sh @@ -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 < 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 < 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 diff --git a/hosts/door-panel/scripts/build-door-panel-iso.sh b/tools/build-door-panel-iso.sh similarity index 80% rename from hosts/door-panel/scripts/build-door-panel-iso.sh rename to tools/build-door-panel-iso.sh index fe05cf5..9ae390d 100755 --- a/hosts/door-panel/scripts/build-door-panel-iso.sh +++ b/tools/build-door-panel-iso.sh @@ -21,58 +21,58 @@ # hosts/kitchen-display's twin: same one-workspace-two-kiosk-destinations shape, # different default content and a mic that's actually expected to be used. # -# Run as: sudo ./build-door-panel-iso.sh +# Run as: sudo -E tools/build-door-panel-iso.sh [hostname] # -# EDIT THE VARIABLES BELOW BEFORE RUNNING. +# Configuration comes from CoreSystemConfig.json — see tools/README.md. set -euo pipefail # --------------------------------------------------------------------------- -# CONFIGURATION — edit these before running -# --------------------------------------------------------------------------- -DEBIAN_RELEASE="bookworm" -KIOSK_USERNAME="kiosk" -IMAGE_HOSTNAME="door-panel" -DOOR_PANEL_NAME="Door panel" - -KEYBOARD_LAYOUT="de" - -ENABLE_INSTALLER="false" - -# --- Where the door panel talks to ------------------------------------------- -MQTT_BROKER_HOST="192.168.1.10" # <-- EDIT: container-host IP running Mosquitto -MQTT_BROKER_PORT="1883" -MQTT_USERNAME="" -MQTT_PASSWORD="" - -# identity's dashboard/registration pages and API — ENABLE_IDENTITY in -# setup-container-host.sh. Placeholders until that's deployed; the image builds and -# boots fine without it, the kiosk window just shows a connection error. -IDENTITY_WEB_URL="http://192.168.1.10:8098" # <-- EDIT once identity-web is deployed -IDENTITY_URL="http://192.168.1.10:8097" # <-- EDIT once identity is deployed -# Must match identity/identity.env's own token — no way for this repo to push it -# between the two hosts for you. -IDENTITY_TOKEN="" # <-- EDIT - -# pantry-vision — only needed for the dashboard's "Running low" section; the rest of -# the dashboard (weather, who's home) works without it. Same placeholder handling. -PANTRY_VISION_URL="http://192.168.1.10:8095" # <-- EDIT once pantry-vision is deployed -PANTRY_VISION_TOKEN="" # <-- EDIT: must match pantry-vision's own token - -# --- Voice registration ("register me as ") — this device's whole point, so -# --- true is the expected real-deployment value, unlike hosts/kitchen-display's -# --- identical flag — but still requires a real mic on this specific unit. -ENABLE_VOICE_SATELLITE="true" -VOICE_SATELLITE_NAME="Door panel" -VOICE_WAKE_WORD="ok_nabu" - -SSH_AUTHORIZED_KEY="" - -# --------------------------------------------------------------------------- -# Paths +# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file. +# +# There is nothing to edit here any more. Every value below is read from the one +# config at the repo root, so an address or token can only be wrong in a single +# place. Change it there and rebuild; see tools/README.md. +# +# sudo -E tools/build-door-panel-iso.sh # the only door-panel in the config +# sudo -E tools/build-door-panel-iso.sh # a specific one, if several are defined +# +# The build refuses to start if the config is invalid (validate-config.py runs first), +# so a typo costs seconds rather than a 40-minute build and a reboot. # --------------------------------------------------------------------------- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DOOR_PANEL_DIR="$(dirname "$SCRIPT_DIR")" +# shellcheck source=lib/coreconfig.sh +source "${SCRIPT_DIR}/lib/coreconfig.sh" + +core_select_kiosk "door-panel" "${1:-}" + +# Mapped onto this script's existing variable names, so everything below is unchanged +# from when these were hand-edited constants. +DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE" +KIOSK_USERNAME="$CORE_KIOSK_USERNAME" +IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME" +KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT" +ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER" +MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST" +MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT" +MQTT_USERNAME="$CORE_MQTT_USERNAME" +MQTT_PASSWORD="$CORE_MQTT_PASSWORD" +SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY" +ENABLE_VOICE_SATELLITE="$CORE_KIOSK_VOICE_SATELLITE" +VOICE_SATELLITE_NAME="$CORE_KIOSK_FRIENDLY_NAME" +VOICE_WAKE_WORD="$CORE_KIOSK_WAKE_WORD" +DOOR_PANEL_NAME="$CORE_KIOSK_FRIENDLY_NAME" +IDENTITY_WEB_URL="$CORE_IDENTITY_WEB_URL" +IDENTITY_URL="$CORE_IDENTITY_URL" +IDENTITY_TOKEN="$CORE_IDENTITY_TOKEN" +PANTRY_VISION_URL="$CORE_PANTRY_VISION_URL" +PANTRY_VISION_TOKEN="$CORE_PANTRY_VISION_TOKEN" + +# --------------------------------------------------------------------------- +# Paths — this script now lives in tools/, so the host directory it drives is +# addressed from the repo root rather than relative to the script. +# --------------------------------------------------------------------------- +DOOR_PANEL_DIR="${CORE_REPO_ROOT}/hosts/door-panel" CONFIGS_DIR="${DOOR_PANEL_DIR}/configs" AGENT_DIR="${DOOR_PANEL_DIR}/agent" LIVE_BUILD_DIR="${DOOR_PANEL_DIR}/live-build" @@ -106,20 +106,8 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then exit 1 fi -if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then - echo "Warning: MQTT_BROKER_HOST is still the placeholder IP — edit it before building." -fi -if [[ -z "$IDENTITY_TOKEN" ]]; then - echo "Warning: IDENTITY_TOKEN is empty. The dashboard will load but every call to" - echo " identity will fail (401) until this matches the token in" - echo " identity/identity.env on the container host." -fi -if [[ -z "$PANTRY_VISION_TOKEN" ]]; then - echo "Warning: PANTRY_VISION_TOKEN is empty. 'Running low' will show as unconfigured" - echo " until this matches the token in pantry-vision/pantry-vision.env." -fi if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then echo "Note: ENABLE_VOICE_SATELLITE=true — this image expects a real microphone on" @@ -192,8 +180,8 @@ fi # --------------------------------------------------------------------------- echo "--- Writing /etc/door-panel-agent/config.env into includes.chroot ---" cat > "$INCLUDES/etc/door-panel-agent/config.env" <") — OFF BY DEFAULT until a real mic -# --- is attached to this specific unit. Same per-image opt-in shape as the thin -# --- client's ENABLE_VOICE_SATELLITE (project-plan Phase 11.8). -ENABLE_VOICE_SATELLITE="false" -VOICE_SATELLITE_NAME="Kitchen display" -VOICE_WAKE_WORD="ok_nabu" - -SSH_AUTHORIZED_KEY="" - -# --------------------------------------------------------------------------- -# Paths +# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file. +# +# There is nothing to edit here any more. Every value below is read from the one +# config at the repo root, so an address or token can only be wrong in a single +# place. Change it there and rebuild; see tools/README.md. +# +# sudo -E tools/build-kitchen-display-iso.sh # the only kitchen-display in the config +# sudo -E tools/build-kitchen-display-iso.sh # a specific one, if several are defined +# +# The build refuses to start if the config is invalid (validate-config.py runs first), +# so a typo costs seconds rather than a 40-minute build and a reboot. # --------------------------------------------------------------------------- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -KITCHEN_DISPLAY_DIR="$(dirname "$SCRIPT_DIR")" +# shellcheck source=lib/coreconfig.sh +source "${SCRIPT_DIR}/lib/coreconfig.sh" + +core_select_kiosk "kitchen-display" "${1:-}" + +# Mapped onto this script's existing variable names, so everything below is unchanged +# from when these were hand-edited constants. +DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE" +KIOSK_USERNAME="$CORE_KIOSK_USERNAME" +IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME" +KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT" +ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER" +MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST" +MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT" +MQTT_USERNAME="$CORE_MQTT_USERNAME" +MQTT_PASSWORD="$CORE_MQTT_PASSWORD" +SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY" +ENABLE_VOICE_SATELLITE="$CORE_KIOSK_VOICE_SATELLITE" +VOICE_SATELLITE_NAME="$CORE_KIOSK_FRIENDLY_NAME" +VOICE_WAKE_WORD="$CORE_KIOSK_WAKE_WORD" +KITCHEN_DISPLAY_NAME="$CORE_KIOSK_FRIENDLY_NAME" +PANTRY_WEB_URL="$CORE_PANTRY_WEB_URL" +PANTRY_VISION_URL="$CORE_PANTRY_VISION_URL" +PANTRY_VISION_TOKEN="$CORE_PANTRY_VISION_TOKEN" +IDENTITY_WEB_URL="$CORE_IDENTITY_WEB_URL" +IDENTITY_URL="$CORE_IDENTITY_URL" +IDENTITY_TOKEN="$CORE_IDENTITY_TOKEN" + +# --------------------------------------------------------------------------- +# Paths — this script now lives in tools/, so the host directory it drives is +# addressed from the repo root rather than relative to the script. +# --------------------------------------------------------------------------- +KITCHEN_DISPLAY_DIR="${CORE_REPO_ROOT}/hosts/kitchen-display" CONFIGS_DIR="${KITCHEN_DISPLAY_DIR}/configs" AGENT_DIR="${KITCHEN_DISPLAY_DIR}/agent" LIVE_BUILD_DIR="${KITCHEN_DISPLAY_DIR}/live-build" @@ -108,21 +106,8 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then exit 1 fi -if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then - echo "Warning: MQTT_BROKER_HOST is still the placeholder IP — edit it before building." -fi -if [[ -z "$PANTRY_VISION_TOKEN" ]]; then - echo "Warning: PANTRY_VISION_TOKEN is empty. The kiosk window will load but every" - echo " call to pantry-vision will fail (401) until this matches the token in" - echo " pantry-vision/pantry-vision.env on the container host." -fi -if [[ -z "$IDENTITY_TOKEN" ]]; then - echo "Warning: IDENTITY_TOKEN is empty. 'Show registration' will load but every" - echo " call to identity will fail (401) until this matches the token in" - echo " identity/identity.env on the container host." -fi if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then echo "Note: ENABLE_VOICE_SATELLITE=true — this image expects a real microphone on" @@ -196,8 +181,8 @@ fi # --------------------------------------------------------------------------- echo "--- Writing /etc/kitchen-display-agent/config.env into includes.chroot ---" cat > "$INCLUDES/etc/kitchen-display-agent/config.env" </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" < "$INCLUDES/etc/network/interfaces.d/smarthome" < "$INCLUDES/etc/hostname" +cat > "$INCLUDES/etc/hosts" < "$INCLUDES/etc/default/keyboard" < "$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" </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}" diff --git a/hosts/thin-client/scripts/build-thin-client-iso.sh b/tools/build-thin-client-iso.sh similarity index 78% rename from hosts/thin-client/scripts/build-thin-client-iso.sh rename to tools/build-thin-client-iso.sh index ae917b0..7e396ad 100755 --- a/hosts/thin-client/scripts/build-thin-client-iso.sh +++ b/tools/build-thin-client-iso.sh @@ -18,84 +18,59 @@ # generated tree that actually gets baked into the image). Never hand-edit anything # under includes.chroot — it is wiped and regenerated on every run. # -# Run as: sudo ./build-thin-client-iso.sh +# Run as: sudo -E tools/build-thin-client-iso.sh [hostname] # -# EDIT THE VARIABLES BELOW BEFORE RUNNING. +# Configuration comes from CoreSystemConfig.json — see tools/README.md. set -euo pipefail # --------------------------------------------------------------------------- -# CONFIGURATION — edit these before running -# --------------------------------------------------------------------------- -DEBIAN_RELEASE="bookworm" # Matches the container host's OS -KIOSK_USERNAME="kiosk" # The autologin account the whole image is built around -IMAGE_HOSTNAME="thin-client" # Hostname baked into the image -THINCLIENT_NAME="Living room thin client" # Friendly name shown on the HA device - -# Console + Sway keyboard layout. This IS the "installer choice" in this image's -# architecture: there is no interactive keymap prompt in the normal live-boot path (see -# ENABLE_INSTALLER below for the one case where a real prompt exists), so a per-image -# build variable is what stands in for it — build one ISO per keyboard layout you need. -KEYBOARD_LAYOUT="de" # xkb layout name (`localectl list-x11-keymap-layouts`) - -ENABLE_STEAM_LINK="true" # Install the Steam Link flatpak from Flathub -ENABLE_INSTALLER="false" # "true" adds a debian-installer to the ISO (install to disk) - -# --- Voice satellite — OFF BY DEFAULT, AND MEANT TO STAY THAT WAY ----------- -# Per docs/project-plan.md Phase 11.8, only the specific rooms that have a microphone -# run wyoming-satellite. This is therefore a PER-IMAGE decision, not a universal one: -# build one ISO with this "false" for the silent rooms, and a second ISO with it "true" -# for the mic-enabled ones. The exact mic-enabled room list is still an open decision -# (project-plan §4 #6) — do not flip this on until it has been chosen. -ENABLE_VOICE_SATELLITE="false" -VOICE_SATELLITE_NAME="Living room" # Shown in HA's Wyoming/Assist device list -VOICE_WAKE_WORD="ok_nabu" # openWakeWord model name - -# --- Camera gesture control — OFF BY DEFAULT, AND MEANT TO STAY THAT WAY ------- -# Open hand moves the pointer, fist clicks. Same per-image, per-room logic as the mic -# above: only build this into the image of a room that is actually getting a webcam. -# This flag only decides whether MediaPipe and its ~400 MB dependency tree are INSTALLED. -# Whether the camera is ever OPENED is a second, separate gate — the "enabled" flag in -# configs/gesture-control/gesture-config.json, which is false by default even here, so a -# gesture-capable image still ships with the camera off. See the privacy section in -# hosts/thin-client/README.md. -ENABLE_GESTURE_CONTROL="false" - -# --- Where the thin client talks to ---------------------------------------- -# The container host from Phase 1 (Mosquitto + Home Assistant). Fill in its LAN IP. -MQTT_BROKER_HOST="192.168.1.10" # <-- EDIT: container-host IP running Mosquitto -MQTT_BROKER_PORT="1883" -MQTT_USERNAME="" # Leave empty while Mosquitto runs allow_anonymous -MQTT_PASSWORD="" # Never commit a real value here — see README -HA_URL="http://192.168.1.10:8123" # <-- EDIT: Home Assistant URL - -# digest-web is the static-file service that Phase 12's digest-engine renders into. -# It is built by a separate workstream; until it is deployed this is just a placeholder -# and the kiosk Firefox workspace will show a connection error (harmless — the session -# must still come up with the container host powered off, per Phase 11.10). -DIGEST_WEB_URL="http://192.168.1.10:8081" # <-- EDIT once digest-web is deployed - -# admin-web (Phase 13) — the sys-admin-llm's on-demand display surface. Same -# placeholder handling as DIGEST_WEB_URL above: harmless until deployed, the admin -# workspace just won't have anything to open yet (and unlike the digest workspace it -# is never auto-launched at session start anyway — see configs/sway/config). -ADMIN_WEB_URL="http://192.168.1.10:8094" # <-- EDIT once admin-web is deployed - -# The container host's gallery-smb share (ENABLE_GALLERY_SMB in -# setup-container-host.sh), used by the idle-timeout slideshow. Just the host — -# idle-gallery.sh always mounts the fixed "gallery" share name. -GALLERY_SMB_HOST="192.168.1.10" # <-- EDIT: container-host IP running gallery-smb - -# Optional: an SSH public key to bake into the kiosk account for out-of-band admin. -# The image ships with password auth disabled, so without this the only admin path is -# the local console or wayvnc. -SSH_AUTHORIZED_KEY="" - -# --------------------------------------------------------------------------- -# Paths +# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file. +# +# There is nothing to edit here any more. Every value below is read from the one +# config at the repo root, so an address or token can only be wrong in a single +# place. Change it there and rebuild; see tools/README.md. +# +# sudo -E tools/build-thin-client-iso.sh # the only thin-client in the config +# sudo -E tools/build-thin-client-iso.sh # a specific one, if several are defined +# +# The build refuses to start if the config is invalid (validate-config.py runs first), +# so a typo costs seconds rather than a 40-minute build and a reboot. # --------------------------------------------------------------------------- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -THIN_CLIENT_DIR="$(dirname "$SCRIPT_DIR")" +# shellcheck source=lib/coreconfig.sh +source "${SCRIPT_DIR}/lib/coreconfig.sh" + +core_select_kiosk "thin-client" "${1:-}" + +# Mapped onto this script's existing variable names, so everything below is unchanged +# from when these were hand-edited constants. +DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE" +KIOSK_USERNAME="$CORE_KIOSK_USERNAME" +IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME" +KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT" +ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER" +MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST" +MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT" +MQTT_USERNAME="$CORE_MQTT_USERNAME" +MQTT_PASSWORD="$CORE_MQTT_PASSWORD" +SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY" +ENABLE_VOICE_SATELLITE="$CORE_KIOSK_VOICE_SATELLITE" +VOICE_SATELLITE_NAME="$CORE_KIOSK_FRIENDLY_NAME" +VOICE_WAKE_WORD="$CORE_KIOSK_WAKE_WORD" +THINCLIENT_NAME="$CORE_KIOSK_FRIENDLY_NAME" +HA_URL="$CORE_HA_URL" +DIGEST_WEB_URL="$CORE_DIGEST_WEB_URL" +ADMIN_WEB_URL="$CORE_ADMIN_WEB_URL" +GALLERY_SMB_HOST="$CORE_GALLERY_SMB_HOST" +ENABLE_STEAM_LINK="$CORE_KIOSK_ENABLE_STEAM_LINK" +ENABLE_GESTURE_CONTROL="$CORE_KIOSK_ENABLE_GESTURE_CONTROL" + +# --------------------------------------------------------------------------- +# Paths — this script now lives in tools/, so the host directory it drives is +# addressed from the repo root rather than relative to the script. +# --------------------------------------------------------------------------- +THIN_CLIENT_DIR="${CORE_REPO_ROOT}/hosts/thin-client" CONFIGS_DIR="${THIN_CLIENT_DIR}/configs" AGENT_DIR="${THIN_CLIENT_DIR}/agent" LIVE_BUILD_DIR="${THIN_CLIENT_DIR}/live-build" @@ -134,23 +109,8 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then exit 1 fi -if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then - echo "Warning: MQTT_BROKER_HOST is still the placeholder IP." - echo " Edit it at the top of this script to your container host's real LAN address," - echo " or the thin client won't show up as Home Assistant entities." -fi -if [[ "$DIGEST_WEB_URL" == "http://192.168.1.10:8081" ]]; then - echo "Warning: DIGEST_WEB_URL is still the placeholder." - echo " Fill it in once Phase 12's digest-web service is deployed. The image builds" - echo " and boots fine without it — the digest workspace just won't load anything." -fi -if [[ "$ADMIN_WEB_URL" == "http://192.168.1.10:8094" ]]; then - echo "Warning: ADMIN_WEB_URL is still the placeholder." - echo " Fill it in once Phase 13's admin-web service is deployed. The image builds" - echo " and boots fine without it — 'Show admin canvas' just won't load anything." -fi if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then echo "Note: ENABLE_VOICE_SATELLITE=true — this image is for a MIC-ENABLED room" @@ -298,8 +258,8 @@ fi # --------------------------------------------------------------------------- echo "--- Writing /etc/thinclient-agent/config.env into includes.chroot ---" cat > "$INCLUDES/etc/thinclient-agent/config.env" < # a specific one, if several are defined +# +# The build refuses to start if the config is invalid (validate-config.py runs first), +# so a typo costs seconds rather than a 40-minute build and a reboot. # --------------------------------------------------------------------------- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -TOUCH_PANEL_DIR="$(dirname "$SCRIPT_DIR")" +# shellcheck source=lib/coreconfig.sh +source "${SCRIPT_DIR}/lib/coreconfig.sh" + +core_select_kiosk "touch-panel" "${1:-}" + +# Mapped onto this script's existing variable names, so everything below is unchanged +# from when these were hand-edited constants. +DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE" +KIOSK_USERNAME="$CORE_KIOSK_USERNAME" +IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME" +KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT" +ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER" +MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST" +MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT" +MQTT_USERNAME="$CORE_MQTT_USERNAME" +MQTT_PASSWORD="$CORE_MQTT_PASSWORD" +SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY" +ENABLE_VOICE_SATELLITE="$CORE_KIOSK_VOICE_SATELLITE" +VOICE_SATELLITE_NAME="$CORE_KIOSK_FRIENDLY_NAME" +VOICE_WAKE_WORD="$CORE_KIOSK_WAKE_WORD" +TOUCHPANEL_NAME="$CORE_KIOSK_FRIENDLY_NAME" +HA_URL="$CORE_HA_URL" + +# --------------------------------------------------------------------------- +# Paths — this script now lives in tools/, so the host directory it drives is +# addressed from the repo root rather than relative to the script. +# --------------------------------------------------------------------------- +TOUCH_PANEL_DIR="${CORE_REPO_ROOT}/hosts/touch-panel" CONFIGS_DIR="${TOUCH_PANEL_DIR}/configs" AGENT_DIR="${TOUCH_PANEL_DIR}/agent" LIVE_BUILD_DIR="${TOUCH_PANEL_DIR}/live-build" @@ -96,17 +109,7 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then exit 1 fi -if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then - echo "Warning: MQTT_BROKER_HOST is still the placeholder IP." - echo " Edit it at the top of this script to your container host's real LAN address," - echo " or the touch panel won't show up as Home Assistant entities." -fi -if [[ "$HA_URL" == "http://192.168.1.10:8123" ]]; then - echo "Warning: HA_URL is still the placeholder." - echo " The image builds and boots fine, but the Home workspace will show a" - echo " connection error until this points at a real Home Assistant." -fi echo echo "=== Smart Home Touch-Panel ISO Builder ===" @@ -199,8 +202,8 @@ fi # --------------------------------------------------------------------------- echo "--- Writing /etc/touchpanel-agent/config.env into includes.chroot ---" cat > "$INCLUDES/etc/touchpanel-agent/config.env" <:`, computed here, never typed anywhere. + +Usage: + config-export.py # core values only + config-export.py --kiosk # + that kiosk's values + config-export.py --audio-endpoint # + 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 [--kiosk ]", 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)) diff --git a/tools/lib/coreconfig.sh b/tools/lib/coreconfig.sh new file mode 100644 index 0000000..913a6a0 --- /dev/null +++ b/tools/lib/coreconfig.sh @@ -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" < BLE-identifier registry (Phase 6) — off by default until # --- IDENTITY_TOKEN, HA_TOKEN, and TRUSTED_ENTITY_PREFIXES are provisioned. See # --- identity/README.md. Same "published, unlike admin-canvas" reasoning as # --- ENABLE_PANTRY_VISION above — hosts/kitchen-display's and hosts/door-panel's # --- kiosk browsers call this directly. -ENABLE_IDENTITY="false" +ENABLE_IDENTITY="${ENABLE_IDENTITY:-false}" # Where this repo's identity/ directory lives on THIS host (build context). -IDENTITY_SRC="/opt/smart-home/src/identity" -IDENTITY_PORT="8097" # LAN-facing — kiosk browsers call this directly -IDENTITY_WEB_PORT="8098" # LAN-facing read-only static serving (register.html/dashboard.html) +IDENTITY_SRC="${IDENTITY_SRC:-/opt/smart-home/src/identity}" +IDENTITY_PORT="${IDENTITY_PORT:-8097}" # LAN-facing — kiosk browsers call this directly +IDENTITY_WEB_PORT="${IDENTITY_WEB_PORT:-8098}" # LAN-facing read-only static serving (register.html/dashboard.html) # --- Trash collection date sync (Phase 19) — off by default until WASTE_ICS_URL and # --- CALDAV_TARGET_CALENDAR are provisioned. See trash-calendar/README.md. A oneshot, # --- like digest-engine, not a listener — no port, nothing to publish. -ENABLE_TRASH_CALENDAR="false" +ENABLE_TRASH_CALENDAR="${ENABLE_TRASH_CALENDAR:-false}" # Where this repo's trash-calendar/ directory lives on THIS host (build context). -TRASH_CALENDAR_SRC="/opt/smart-home/src/trash-calendar" +TRASH_CALENDAR_SRC="${TRASH_CALENDAR_SRC:-/opt/smart-home/src/trash-calendar}" # --- Public transit "when's the next bus" voice lookup (Phase 19) — off by default # --- until TRANSIT_TOKEN and GTFS_FEED_URL are provisioned. See transit/README.md. # --- Published like pantry-vision/identity — homeassistant's network_mode: host # --- can't resolve container DNS names, so its rest_command needs a real port. -ENABLE_TRANSIT="false" +ENABLE_TRANSIT="${ENABLE_TRANSIT:-false}" # Where this repo's transit/ directory lives on THIS host (build context). -TRANSIT_SRC="/opt/smart-home/src/transit" -TRANSIT_PORT="8099" # reachable by HA's rest_command (voice lookups) +TRANSIT_SRC="${TRANSIT_SRC:-/opt/smart-home/src/transit}" +TRANSIT_PORT="${TRANSIT_PORT:-8099}" # reachable by HA's rest_command (voice lookups) # --- On-demand route planning (Phase 19) — a SEPARATE opt-in from ENABLE_TRANSIT # --- above on purpose: a real OpenTripPlanner graph (OSM + GTFS) is a meaningfully @@ -160,22 +166,22 @@ TRANSIT_PORT="8099" # reachable by HA's rest_command (voice looku # --- "Austria-wide" is a moderate commitment, "global" is a real infrastructure # --- decision, not a flag. This script does NOT build the OTP graph for you — that's # --- a manual, one-time (per OSM/GTFS update) step; see OpenTripPlanner's own docs. -ENABLE_TRIP_PLANNING="false" -OTP_GRAPHS_DIR="/opt/smart-home/otp-graphs" # you populate this by hand, see above +ENABLE_TRIP_PLANNING="${ENABLE_TRIP_PLANNING:-false}" +OTP_GRAPHS_DIR="${OTP_GRAPHS_DIR:-/opt/smart-home/otp-graphs}" # you populate this by hand, see above # Host-side published port for OTP's own web/GraphQL API. NOT 8080 — zigbee2mqtt's # frontend (always-on, below) already publishes 8080:8080; OTP's own container- # internal port stays 8080 regardless (transit.env.example's OTP_URL correctly # reaches it via container DNS as http://otp:8080), only the host-side mapping # needed to move to avoid the two colliding on the same host. -OTP_PORT="8100" +OTP_PORT="${OTP_PORT:-8100}" # --- Household chore distribution + reminders + camera verification (Phase 20) — # --- off by default. Works with just ENABLE_IDENTITY on (assignment) and ntfy # --- (reminders); the camera-check and trash-day-eve steps each individually # --- no-op until their own env vars are set — see chores/README.md. -ENABLE_CHORES="false" +ENABLE_CHORES="${ENABLE_CHORES:-false}" # Where this repo's chores/ directory lives on THIS host (build context). -CHORES_SRC="/opt/smart-home/src/chores" +CHORES_SRC="${CHORES_SRC:-/opt/smart-home/src/chores}" # --- Music Assistant (optional, additive — docs/project-plan.md §2) --------- # Unifies Spotify Connect + other sources behind one HA-native multi-room player. @@ -195,7 +201,7 @@ CHORES_SRC="/opt/smart-home/src/chores" # Check Music Assistant's own docs/config for how to change its listen port # BEFORE flipping this on if ENABLE_PANTRY_VISION is also true — the sanity check # below only warns, it does not change either port for you. -ENABLE_MUSIC_ASSISTANT="false" +ENABLE_MUSIC_ASSISTANT="${ENABLE_MUSIC_ASSISTANT:-false}" # --------------------------------------------------------------------------- # Sanity checks @@ -1468,7 +1474,7 @@ fi if [[ "$ENABLE_PANTRY_VISION" == "true" ]]; then echo " 15. Fill in $BASE_DIR/pantry-vision/pantry-vision.env before the kitchen display can" echo " identify anything: PANTRY_VISION_TOKEN (also goes into" - echo " hosts/kitchen-display/scripts/build-kitchen-display-iso.sh — both sides need the" + echo " tools/build-kitchen-display-iso.sh — both sides need the" echo " SAME value) and GROCY_API_KEY (Grocy's own UI: Settings -> Manage API keys, at" echo " http://${HOST_IP}:9283). Also pull a vision-capable Ollama model on the LLM host" echo " (e.g. 'ollama pull llava') — OLLAMA_VISION_MODEL defaults to one that is NOT" diff --git a/hosts/llm-host/scripts/setup-llm-host.sh b/tools/setup-llm-host.sh similarity index 92% rename from hosts/llm-host/scripts/setup-llm-host.sh rename to tools/setup-llm-host.sh index 4c15d9f..28c825b 100755 --- a/hosts/llm-host/scripts/setup-llm-host.sh +++ b/tools/setup-llm-host.sh @@ -31,21 +31,26 @@ 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) # — 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 # (CPU). Tags are Ollama library names — `ollama list` on a real host to confirm what # you actually ended up with, since library tags do get renamed upstream. -GPU_TEXT_MODEL="qwen2.5:14b-instruct" -CPU_TEXT_MODEL="qwen2.5:7b-instruct" +GPU_TEXT_MODEL="${GPU_TEXT_MODEL:-qwen2.5:14b-instruct}" +CPU_TEXT_MODEL="${CPU_TEXT_MODEL:-qwen2.5:7b-instruct}" # The vision model, for pantry-vision (grocery items) and chores (bin/dishes/litter). # NOT A CONSIDERED CHOICE — `llava` is the default those services already ship with, @@ -53,9 +58,9 @@ CPU_TEXT_MODEL="qwen2.5:7b-instruct" # unbenchmarked. Treat this as "something to measure", not "the answer": if grocery # recognition is too slow or too wrong to be usable, this is the first knob to turn # (qwen2.5vl and moondream are the obvious alternatives to try). -VISION_MODEL="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. # --- 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 # default is 5m, which means a household that talks to Assist a few times an hour # pays the model-load cost almost every time. 30m keeps it warm through normal use. -OLLAMA_KEEP_ALIVE="30m" +OLLAMA_KEEP_ALIVE="${OLLAMA_KEEP_ALIVE:-30m}" # MAX_LOADED_MODELS — how many distinct models may be resident at once. **1 is # deliberate on a single consumer GPU**: a 14B text model and a vision model do not # fit together in 8-12GB, and letting Ollama try produces VRAM thrash or an OOM # mid-request rather than an honest swap. 1 means "swap predictably, pay the reload # cost when the vision model is actually needed." Raise it only if you have the VRAM # to hold both and have checked that you do. -OLLAMA_MAX_LOADED_MODELS="1" +OLLAMA_MAX_LOADED_MODELS="${OLLAMA_MAX_LOADED_MODELS:-1}" # NUM_PARALLEL — concurrent requests served per loaded model. 1 keeps latency # predictable for whoever is speaking to Assist; higher trades that for throughput # nothing in this project currently needs. -OLLAMA_NUM_PARALLEL="1" +OLLAMA_NUM_PARALLEL="${OLLAMA_NUM_PARALLEL:-1}" # --------------------------------------------------------------------------- # 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 # 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 @@ -185,7 +190,7 @@ write_compose() { mkdir -p "$BASE_DIR/models" cat > "$BASE_DIR/docker-compose.yml" < 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))