Add tools/ and CoreSystemConfig.json — one source of truth for every build
Installation was six scripts each carrying its own copy of the container host's IP, three that had to agree on IDENTITY_TOKEN, and every service URL typed by hand with a port in it. Any one could be wrong, and the symptom was always the same and always late: an image that boots fine and then can't reach something, found after a 40-minute build and a reboot. Two properties fix that class of bug: - Nothing is written twice. No script in tools/ contains an IP, port or token. - Anything derivable is derived. You give the subnet prefix once and one last octet per host; every address and service URL is computed from those. THE TWINNED PAIR. container_host.ip_last_octet 12 and llm_host 13 mean the container host's OLLAMA_HOST *is* http://<prefix>.13:11434 — computed in the same build, not typed into two files and kept in sync. Move the LLM host to .21 and the container host's Ollama URL follows; change the subnet and both halves move along with every kiosk's URLs. Neither image can be built pointing at an address the other isn't using. Both carry the same SMARTHOME_PAIR_ID (a hash of the config's meaning, not its bytes) so two USB sticks can be checked against each other later. validate-config.py runs before every build and refuses to start on an error, so a mistake costs seconds not an hour. It catches duplicate ports (including the music_assistant/pantry_vision 8095 clash that Compose can't see because MA runs network_mode:host — open decision #31), both hosts on one address, duplicate hostnames across kiosks and audio endpoints, placeholder tokens (checked before the length check, so padding "changeme" to 32 chars doesn't pass), a private key pasted where the public one goes, and a kiosk pointed at a disabled service. build-all.sh is the normal entry point — the images are a set that has to agree with itself, so building one is the exception. It builds the core pair, every kiosk, and every audio endpoint including both architectures (amd64 live-build ISO and arm64 rpi-image-gen img are different toolchains, not one image). The two new host ISOs install unattended with everything burnt in, including service env files generated from derived values — which permanently removes the class of bug that had chores.env shipping IDENTITY_URL=http://127.0.0.1:8097. setup-container-host.sh and setup-llm-host.sh now read every config value as ${VAR:-default} so the images configure them without editing. That also makes every ISO a credential: Wi-Fi PSK, tokens, MQTT and HA credentials are readable by anyone holding the stick. .gitignore covers the filled-in CoreSystemConfig.json and build-output/. Tested: 43 config validation/derivation checks and 44 builder checks against the real code paths with only `lb` stubbed — every generated env file, preseed, network config, first-boot unit and build stamp is verified, including that a port collision refuses the build before writing anything. No ISO has been built; `lb build` needs live-build, root and a long fetch. tools/README.md says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>digest-per-person-and-agendas
parent
564c4a801d
commit
ea82ee70ad
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
34
README.md
34
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
# tools — the build system
|
||||
|
||||
Every build and setup script for this project, driven by **one** config file at the
|
||||
repo root.
|
||||
|
||||
```sh
|
||||
cp CoreSystemConfig.json.template CoreSystemConfig.json
|
||||
$EDITOR CoreSystemConfig.json
|
||||
tools/validate-config.py # check it before you commit to a long build
|
||||
sudo -E tools/build-all.sh # build everything
|
||||
```
|
||||
|
||||
That's the whole workflow.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Before it, the same facts lived in six places. The container host's IP was written into
|
||||
four ISO builders; `IDENTITY_TOKEN` had to match by hand across three; every service URL
|
||||
was a hand-typed string with a port in it. Any one of them could be wrong, and the
|
||||
symptom was always the same and always late: an image that boots fine and then can't
|
||||
reach something, discovered after a 40-minute build and a reboot.
|
||||
|
||||
Two changes fix that class of bug:
|
||||
|
||||
1. **Nothing is written twice.** No script in this directory contains an IP address, a
|
||||
port or a token. They read `CoreSystemConfig.json`.
|
||||
2. **Anything derivable is derived.** You give the subnet prefix once and one last
|
||||
octet per host. Every address and every service URL is computed from those.
|
||||
|
||||
## The twinned pair
|
||||
|
||||
The container host and the LLM host are built as a matched set, and the twinning is
|
||||
mechanical rather than a matter of remembering:
|
||||
|
||||
```
|
||||
network.subnet_prefix 192.168.30
|
||||
container_host.ip_last_octet 12 -> 192.168.30.12
|
||||
llm_host.ip_last_octet 13 -> 192.168.30.13
|
||||
|
||||
...so the container host's OLLAMA_HOST is http://192.168.30.13:11434
|
||||
because it was COMPUTED from the LLM host's octet in the same build,
|
||||
not because someone typed the same address into two files.
|
||||
```
|
||||
|
||||
Move the LLM host to `.21` and rebuild: the container host's Ollama URL follows on its
|
||||
own. Change the subnet prefix and *both* halves move, along with every kiosk's service
|
||||
URLs. **Neither image can be built pointing at an address the other one isn't using.**
|
||||
|
||||
Both halves are stamped with the same `SMARTHOME_PAIR_ID` in `/etc/smarthome-build`, so
|
||||
two USB sticks found in a drawer months later can be checked against each other. The ID
|
||||
is a hash of the config's *meaning*, not its bytes — reformatting the JSON doesn't
|
||||
change it, moving a host does.
|
||||
|
||||
## The validator
|
||||
|
||||
`validate-config.py` runs before every build and refuses to start on an error, so a
|
||||
mistake costs seconds instead of an hour. It's stdlib-only on purpose: it has to be
|
||||
able to run on a fresh checkout before anything is installed.
|
||||
|
||||
What it catches, beyond missing and malformed values:
|
||||
|
||||
| Check | Why it matters |
|
||||
|---|---|
|
||||
| **Duplicate ports** | Two services on one port means a container that silently fails to bind, or whichever won last boot answering. Miserable to diagnose from the symptom — and it's how `music_assistant` (default 8095) collides with `pantry_vision`, which Compose's own check never catches because Music Assistant runs `network_mode: host` |
|
||||
| Both core hosts on one address | The twinning's single assumption |
|
||||
| A host colliding with the gateway | — |
|
||||
| Duplicate kiosk hostnames | They identify devices on the network and in HA |
|
||||
| Placeholder or low-entropy tokens | Caught *before* the length check, so padding `changeme` out to 32 characters doesn't sneak past |
|
||||
| A private key pasted where the public key goes | — |
|
||||
| A kiosk that needs a disabled service | A door panel built against `enable.identity: false` builds fine and fails at runtime |
|
||||
| Wi-Fi SSID without a PSK, MQTT user without a password | — |
|
||||
| `192.168.0.x` / `192.168.1.x` subnets (warning) | Collides with typical café and hotel LANs, which breaks a WireGuard split tunnel routing that range — see `docs/network-integration.md` §2.1 |
|
||||
|
||||
Warnings print but don't block. Errors block and nothing is written.
|
||||
|
||||
## What's here
|
||||
|
||||
| Script | Builds |
|
||||
|---|---|
|
||||
| **`build-all.sh`** | **Everything. The normal entry point** — `--core`, `--kiosks`, `--dry-run` |
|
||||
| `build-core-pair.sh` | The twinned container host + LLM host |
|
||||
| `build-container-host-iso.sh` | Just the container host |
|
||||
| `build-llm-host-iso.sh` | Just the LLM host |
|
||||
| `build-door-panel-iso.sh` etc. | One kiosk; takes a hostname when several of a type are configured |
|
||||
| `build-audio-endpoint-iso-amd64.sh` | An amd64 audio endpoint (mini PC, live-build → `.iso`) |
|
||||
| `build-audio-endpoint-image-arm64.sh` | An arm64 audio endpoint (Pi + HiFiBerry, rpi-image-gen → `.img`) |
|
||||
| `setup-container-host.sh` | The container-host setup itself, run by its ISO's first-boot unit (or by hand) |
|
||||
| `setup-llm-host.sh` | Same, for the LLM host |
|
||||
| `validate-config.py` | Check the config |
|
||||
| `config-export.py` | Config → shell variables, deriving URLs. Where the twinning happens |
|
||||
| `lib/coreconfig.sh` | The loader every builder sources |
|
||||
|
||||
Building one image is supported but unusual: the images are a set that has to agree
|
||||
with itself, which is why `build-all.sh` is the default and a failure in one image
|
||||
doesn't abandon the rest.
|
||||
|
||||
## The ISOs contain secrets
|
||||
|
||||
This is deliberate — burning everything in is what makes installation unattended, with
|
||||
no env files to edit on a freshly-booted host. It also means **every ISO is a
|
||||
credential**: Wi-Fi PSK, service tokens, MQTT and HA credentials, all readable by
|
||||
anyone holding the stick.
|
||||
|
||||
`.gitignore` covers `CoreSystemConfig.json` and `build-output/`, so neither can be
|
||||
committed by accident. Wiping old USB sticks is on you.
|
||||
|
||||
## Two things can't be burnt in
|
||||
|
||||
Neither exists at build time, so both need a human afterwards:
|
||||
|
||||
1. **`HA_TOKEN`** — a Long-Lived Access Token from Home Assistant's own UI, which
|
||||
doesn't exist until HA has been started and an account created. Put it in the config
|
||||
and rebuild, or edit `identity.env` on the container host.
|
||||
2. **`TRUSTED_ENTITY_PREFIXES`** — the real entity_id prefixes your Private BLE Device
|
||||
setup produces (Developer Tools → States). The shipped default is a guess and it's
|
||||
the highest-risk unknown in Phase 6.
|
||||
|
||||
## Adding a service or a kiosk
|
||||
|
||||
- **A new port**: add it to `ports` and reference it in `config-export.py`'s derived
|
||||
URLs. The duplicate check covers it from then on.
|
||||
- **Another audio endpoint**: add an entry to `audio_endpoints` with its `arch`.
|
||||
`build-all.sh` builds every entry, so listing both an `amd64` and an `arm64` one
|
||||
gets you both — they're separate toolchains producing different artifacts, not one
|
||||
image that runs on both.
|
||||
- **Another kiosk of an existing type**: add an entry to `kiosks` with its own
|
||||
hostname. `build-all.sh` picks it up; the per-type builder takes the hostname as an
|
||||
argument.
|
||||
- **A new kiosk type**: add it to `KIOSK_TYPES` in `validate-config.py` and add a
|
||||
`build-<type>-iso.sh`. `build-all.sh` finds it by naming convention.
|
||||
|
||||
## Manual verification still outstanding
|
||||
|
||||
1. **No ISO has ever been built with this.** `lb build` needs live-build, root, and a
|
||||
long network fetch; none of that has been run. What *has* been tested is everything
|
||||
up to that point: config validation and URL derivation (43 checks), and the builders'
|
||||
generated artifacts — env files, preseed, network config, `/etc/hosts`, first-boot
|
||||
units, build stamps — produced by the real code paths with only `lb` stubbed (44
|
||||
checks). The `lb config`/`lb build` invocations themselves are unverified.
|
||||
2. **The preseed files are written from Debian's documented shape, not tested.** An
|
||||
unattended install that gets a preseed key wrong typically stops at an interactive
|
||||
prompt rather than failing loudly, so budget for a monitor on the first install.
|
||||
3. **`partman-auto/disk` erases the configured disk without confirmation.** That is
|
||||
what unattended means, and it's why `install_disk` is worth double-checking against
|
||||
the actual machine you boot it on.
|
||||
4. **Static addressing assumes `eth0`.** Debian's predictable interface naming may well
|
||||
call it `enp3s0` on your hardware, in which case
|
||||
`/etc/network/interfaces.d/smarthome` needs the real name.
|
||||
5. **The kiosk builders' migration is untested end-to-end.** Their config blocks now
|
||||
read from `CoreSystemConfig.json`, but the body of each script is unchanged from when
|
||||
it worked with hand-edited constants — so the risk is confined to the mapping, not
|
||||
to image contents.
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build EVERY image this household needs, from one CoreSystemConfig.json.
|
||||
#
|
||||
# This is the normal way to use tools/. The individual builders still exist and still
|
||||
# work, but rebuilding one image on its own is the unusual case: the images are a set
|
||||
# that has to agree with itself, and the whole reason a kiosk knows the container
|
||||
# host's address is that both were generated in the same pass from the same file.
|
||||
# Building the set is therefore the default, and building one is the exception.
|
||||
#
|
||||
# sudo -E tools/build-all.sh # the core pair + every configured kiosk
|
||||
# sudo -E tools/build-all.sh --core # just the twinned pair
|
||||
# sudo -E tools/build-all.sh --kiosks # just the kiosks
|
||||
# sudo -E tools/build-all.sh --dry-run # validate + list what would be built
|
||||
#
|
||||
# Every image is stamped with the same pair ID, so a drawer full of USB sticks can be
|
||||
# checked against each other later: same ID means they were built from the same
|
||||
# config and agree on every address and token.
|
||||
#
|
||||
# WHAT COMES OUT CONTAINS SECRETS. Wi-Fi PSK, service tokens, MQTT and HA credentials
|
||||
# are burnt into these images — that is the point (nothing to configure post-install),
|
||||
# and it makes every ISO a credential. .gitignore keeps them out of the repo; wiping
|
||||
# old USB sticks is on you.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/coreconfig.sh
|
||||
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||
|
||||
MODE="all"
|
||||
DRY_RUN="false"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--core) MODE="core" ;;
|
||||
--kiosks) MODE="kiosks" ;;
|
||||
--dry-run) DRY_RUN="true" ;;
|
||||
-h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) core_die "Unknown argument '$arg'. Try --help." ;;
|
||||
esac
|
||||
done
|
||||
|
||||
core_load
|
||||
[[ "$DRY_RUN" == "true" ]] || core_require_root "$@"
|
||||
|
||||
PAIR_ID="$(core_pair_id)"
|
||||
|
||||
# Which kiosks are configured, and which builder each needs.
|
||||
mapfile -t KIOSK_ROWS < <(python3 - "$CORE_CONFIG_PATH" <<'PY'
|
||||
import json, sys
|
||||
for k in json.load(open(sys.argv[1])).get("kiosks", []):
|
||||
print(f"{k['type']}\t{k['hostname']}\t{k.get('friendly_name','')}")
|
||||
PY
|
||||
)
|
||||
|
||||
# Audio endpoints, with the architecture each one needs. Both architectures are built
|
||||
# when both are configured — they're two different toolchains producing two different
|
||||
# artifacts (an amd64 ISO and an arm64 .img), not one image that runs on both.
|
||||
mapfile -t AUDIO_ROWS < <(python3 - "$CORE_CONFIG_PATH" <<'PY'
|
||||
import json, sys
|
||||
for a in json.load(open(sys.argv[1])).get("audio_endpoints", []):
|
||||
print(f"{a['arch']}\t{a['hostname']}\t{a.get('friendly_name','')}")
|
||||
PY
|
||||
)
|
||||
|
||||
cat <<EOF
|
||||
|
||||
============================================================================
|
||||
SmartestHome — build all
|
||||
============================================================================
|
||||
Config : ${CORE_CONFIG_PATH}
|
||||
Pair ID : ${PAIR_ID}
|
||||
Subnet : ${CORE_SUBNET_PREFIX}.0/24
|
||||
|
||||
Core pair (twinned — each knows the other's address by derivation):
|
||||
container host ${CORE_CONTAINER_HOST_IP} ${CORE_CONTAINER_HOST_NAME}
|
||||
LLM host ${CORE_LLM_HOST_IP} ${CORE_LLM_HOST_NAME}
|
||||
the link ${CORE_OLLAMA_HOST}
|
||||
|
||||
Kiosks + audio endpoints (all pointed at ${CORE_CONTAINER_HOST_IP} by derivation):
|
||||
EOF
|
||||
if [[ ${#KIOSK_ROWS[@]} -eq 0 && ${#AUDIO_ROWS[@]} -eq 0 ]]; then
|
||||
echo " (none configured)"
|
||||
fi
|
||||
for row in "${KIOSK_ROWS[@]:-}"; do
|
||||
[[ -n "$row" ]] || continue
|
||||
IFS=$'\t' read -r ktype khost kname <<< "$row"
|
||||
printf ' %-18s %-24s %s\n' "$ktype" "$khost" "$kname"
|
||||
done
|
||||
for row in "${AUDIO_ROWS[@]:-}"; do
|
||||
[[ -n "$row" ]] || continue
|
||||
IFS=$'\t' read -r aarch ahost aname <<< "$row"
|
||||
printf ' %-18s %-24s %s\n' "audio/${aarch}" "$ahost" "$aname"
|
||||
done
|
||||
echo "
|
||||
Building : ${MODE}$([[ "$DRY_RUN" == "true" ]] && echo " (DRY RUN — nothing will be built)")
|
||||
============================================================================"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo "
|
||||
Config is valid and the above is what would be built. Re-run without --dry-run.
|
||||
"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BUILT=()
|
||||
FAILED=()
|
||||
|
||||
run_build() {
|
||||
local label="$1"; shift
|
||||
core_log "Building ${label}"
|
||||
# One image failing must not abandon the rest: an ISO build is long, and losing an
|
||||
# hour of successful builds because the last one hit a mirror timeout would be a
|
||||
# poor trade. Failures are collected and reported together at the end.
|
||||
if "$@"; then
|
||||
BUILT+=("$label")
|
||||
else
|
||||
core_warn "${label} FAILED — continuing with the rest"
|
||||
FAILED+=("$label")
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "$MODE" == "all" || "$MODE" == "core" ]]; then
|
||||
run_build "container host" "${SCRIPT_DIR}/build-container-host-iso.sh"
|
||||
run_build "LLM host" "${SCRIPT_DIR}/build-llm-host-iso.sh"
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "all" || "$MODE" == "kiosks" ]]; then
|
||||
for row in "${KIOSK_ROWS[@]}"; do
|
||||
IFS=$'\t' read -r ktype khost _ <<< "$row"
|
||||
builder="${SCRIPT_DIR}/build-${ktype}-iso.sh"
|
||||
if [[ ! -x "$builder" ]]; then
|
||||
core_warn "No builder for kiosk type '${ktype}' (${builder}) — skipping ${khost}"
|
||||
FAILED+=("${ktype}/${khost} (no builder)")
|
||||
continue
|
||||
fi
|
||||
run_build "${ktype} — ${khost}" "$builder" "$khost"
|
||||
done
|
||||
|
||||
for row in "${AUDIO_ROWS[@]:-}"; do
|
||||
[[ -n "$row" ]] || continue
|
||||
IFS=$'\t' read -r aarch ahost _ <<< "$row"
|
||||
# Two genuinely different toolchains, not one builder with a flag: live-build for
|
||||
# the amd64 mini-PC, rpi-image-gen for the arm64 Pi.
|
||||
case "$aarch" in
|
||||
amd64) builder="${SCRIPT_DIR}/build-audio-endpoint-iso-amd64.sh" ;;
|
||||
arm64) builder="${SCRIPT_DIR}/build-audio-endpoint-image-arm64.sh" ;;
|
||||
*) core_warn "Unknown audio endpoint arch '${aarch}' — skipping ${ahost}"
|
||||
FAILED+=("audio/${ahost} (bad arch)"); continue ;;
|
||||
esac
|
||||
run_build "audio endpoint (${aarch}) — ${ahost}" "$builder" "$ahost"
|
||||
done
|
||||
fi
|
||||
|
||||
OUTPUT_DIR="${CORE_REPO_ROOT}/${CORE_BUILD_OUTPUT_DIR}"
|
||||
|
||||
echo "
|
||||
============================================================================
|
||||
Build all — done (pair ${PAIR_ID})
|
||||
============================================================================"
|
||||
if [[ ${#BUILT[@]} -gt 0 ]]; then
|
||||
echo " Built:"
|
||||
for b in "${BUILT[@]}"; do echo " ✓ $b"; done
|
||||
fi
|
||||
if [[ ${#FAILED[@]} -gt 0 ]]; then
|
||||
echo " FAILED:"
|
||||
for f in "${FAILED[@]}"; do echo " ✗ $f"; done
|
||||
fi
|
||||
echo "
|
||||
Images in: ${OUTPUT_DIR}
|
||||
"
|
||||
ls -lh "$OUTPUT_DIR" 2>/dev/null | tail -n +2 | awk '{printf " %-52s %s\n", $9, $5}' || true
|
||||
cat <<EOF
|
||||
|
||||
Install the container host FIRST — the kiosks and the LLM host are all
|
||||
clients of it, and every one of them was built expecting it at
|
||||
${CORE_CONTAINER_HOST_IP}.
|
||||
|
||||
Two things still need a human, because neither exists at build time:
|
||||
1. HA_TOKEN — a Long-Lived Access Token from Home Assistant's own UI, which
|
||||
can't be created until HA is running. Put it in CoreSystemConfig.json and
|
||||
rebuild, or edit identity.env on the container host.
|
||||
2. TRUSTED_ENTITY_PREFIXES — the real entity_id prefixes your Private BLE
|
||||
Device setup produces. The default is a guess and it is the highest-risk
|
||||
unknown in Phase 6.
|
||||
EOF
|
||||
|
||||
[[ ${#FAILED[@]} -eq 0 ]]
|
||||
|
|
@ -22,8 +22,15 @@ RPI_IMAGE_GEN_REPO="https://github.com/raspberrypi/rpi-image-gen.git"
|
|||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RPI_IMAGE_GEN_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
AUDIO_ENDPOINT_DIR="$(dirname "$RPI_IMAGE_GEN_DIR")"
|
||||
# shellcheck source=lib/coreconfig.sh
|
||||
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||
|
||||
core_select_audio_endpoint arm64 "${1:-}"
|
||||
|
||||
# This script now lives in tools/, so the host directory it drives is addressed from
|
||||
# the repo root rather than relative to the script.
|
||||
AUDIO_ENDPOINT_DIR="${CORE_REPO_ROOT}/hosts/audio-endpoint"
|
||||
RPI_IMAGE_GEN_DIR="${AUDIO_ENDPOINT_DIR}/rpi-image-gen"
|
||||
SHARED_CONFIGS_DIR="$AUDIO_ENDPOINT_DIR/configs"
|
||||
BUILD_CONFIG="$RPI_IMAGE_GEN_DIR/config/audio-endpoint.yaml"
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Builds the amd64 headless audio-endpoint ISO (mini PC + USB DAC/amp) with
|
||||
# live-build — the same tool hosts/thin-client/scripts/build-thin-client-iso.sh
|
||||
# live-build — the same tool tools/build-thin-client-iso.sh
|
||||
# drives, reused here rather than a new toolchain, per
|
||||
# hosts/audio-endpoint/README.md's reasoning. Unlike that image, this one has
|
||||
# no graphical/kiosk stack at all: it boots straight to multi-user.target with
|
||||
|
|
@ -12,24 +12,34 @@
|
|||
# post-build customisation tool for a generic x86 ISO, so IMAGE_HOSTNAME below
|
||||
# is baked in at build time — same convention as the thin client's own
|
||||
# THINCLIENT_NAME/IMAGE_HOSTNAME. Re-run this script once per room, changing
|
||||
# IMAGE_HOSTNAME each time.
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CONFIGURATION — edit before running, then re-run per room
|
||||
# ---------------------------------------------------------------------------
|
||||
DEBIAN_RELEASE="bookworm" # Matches the container/thin-client hosts' OS
|
||||
IMAGE_HOSTNAME="audio-endpoint-livingroom" # <-- EDIT per room; also the Spotify
|
||||
# Connect device name (see README)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
|
||||
#
|
||||
# Rooms are entries in the config's "audio_endpoints" list, so adding a second one is
|
||||
# adding a list entry rather than editing and re-running this script with a different
|
||||
# hostname — which is how per-room images drifted apart before.
|
||||
#
|
||||
# sudo -E tools/build-audio-endpoint-iso-amd64.sh # the only amd64 endpoint
|
||||
# sudo -E tools/build-audio-endpoint-iso-amd64.sh <hostname> # a specific one
|
||||
# ---------------------------------------------------------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LIVE_BUILD_AMD64_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
AUDIO_ENDPOINT_DIR="$(dirname "$LIVE_BUILD_AMD64_DIR")"
|
||||
# shellcheck source=lib/coreconfig.sh
|
||||
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||
|
||||
core_select_audio_endpoint amd64 "${1:-}"
|
||||
|
||||
DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE"
|
||||
IMAGE_HOSTNAME="$CORE_AUDIO_HOSTNAME" # also the Spotify Connect device name
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths — this script now lives in tools/, so the host directory it drives is
|
||||
# addressed from the repo root rather than relative to the script.
|
||||
# ---------------------------------------------------------------------------
|
||||
AUDIO_ENDPOINT_DIR="${CORE_REPO_ROOT}/hosts/audio-endpoint"
|
||||
SHARED_CONFIGS_DIR="$AUDIO_ENDPOINT_DIR/configs"
|
||||
LIVE_BUILD_DIR="$LIVE_BUILD_AMD64_DIR"
|
||||
LIVE_BUILD_DIR="${AUDIO_ENDPOINT_DIR}/live-build-amd64"
|
||||
INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot"
|
||||
PACKAGE_LIST="${LIVE_BUILD_DIR}/config/package-lists/audio-endpoint.list.chroot"
|
||||
|
||||
|
|
@ -0,0 +1,380 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Container host ISO — the Phase 1 machine (Home Assistant, Mosquitto, Zigbee2MQTT,
|
||||
# Frigate, Grocy, and this repo's own services), as an unattended-install ISO with
|
||||
# everything burnt in.
|
||||
#
|
||||
# Normally invoked via tools/build-core-pair.sh, which builds this and its LLM-host
|
||||
# twin from the same config. Runnable on its own when only this half changed.
|
||||
#
|
||||
# WHAT'S BURNT IN: static network config, hostname, admin user + SSH key, this repo's
|
||||
# source, and — the part that matters — **every service env file, generated from
|
||||
# CoreSystemConfig.json**. Those env files were previously copied from .env.example
|
||||
# templates and hand-edited on the host, which is exactly how `chores.env` ended up
|
||||
# shipping `IDENTITY_URL=http://127.0.0.1:8097` (project-plan open decision #38): an
|
||||
# address that could never work from inside a container, in a file nobody re-read
|
||||
# after copying it. Generating them from derived values removes that whole class of
|
||||
# mistake permanently — no hand-editing, no stale template, no address typed twice.
|
||||
#
|
||||
# The machine boots, installs unattended, and on first boot runs
|
||||
# setup-container-host.sh with the env files already in place.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/coreconfig.sh
|
||||
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||
|
||||
core_load
|
||||
core_require_root
|
||||
|
||||
HOST_DIR="${CORE_REPO_ROOT}/hosts/container-host"
|
||||
LIVE_BUILD_DIR="${HOST_DIR}/live-build"
|
||||
INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot"
|
||||
PAYLOAD="${INCLUDES}/opt/smart-home"
|
||||
OUTPUT_DIR="${CORE_REPO_ROOT}/${CORE_BUILD_OUTPUT_DIR}"
|
||||
|
||||
command -v lb >/dev/null 2>&1 || core_die "live-build is not installed (apt install live-build)"
|
||||
|
||||
core_log "Preparing ${LIVE_BUILD_DIR}"
|
||||
rm -rf "$INCLUDES"
|
||||
mkdir -p \
|
||||
"$INCLUDES/etc/systemd/system" \
|
||||
"$INCLUDES/etc/default" \
|
||||
"$INCLUDES/etc/network/interfaces.d" \
|
||||
"$LIVE_BUILD_DIR/config/package-lists" \
|
||||
"$PAYLOAD/src" \
|
||||
"$OUTPUT_DIR"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. This repo's source, for the services that build from it.
|
||||
# setup-container-host.sh expects each service's directory to exist on the host
|
||||
# (its *_SRC variables); shipping them inside the image is what makes the install
|
||||
# unattended instead of "now go git clone something".
|
||||
# ---------------------------------------------------------------------------
|
||||
core_log "Copying service sources into the image"
|
||||
for svc in identity pantry-vision chores digest-engine admin-canvas trash-calendar transit; do
|
||||
if [[ -d "${CORE_REPO_ROOT}/${svc}" ]]; then
|
||||
cp -r "${CORE_REPO_ROOT}/${svc}" "$PAYLOAD/src/"
|
||||
# __pycache__ from a developer machine is architecture- and version-specific
|
||||
# noise that must never ship in an image.
|
||||
find "$PAYLOAD/src/${svc}" -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
mkdir -p "$PAYLOAD/scripts"
|
||||
cp "${CORE_TOOLS_DIR}/setup-container-host.sh" "$PAYLOAD/scripts/"
|
||||
chmod +x "$PAYLOAD/scripts/setup-container-host.sh"
|
||||
|
||||
# setup-container-host.sh reads every config value as ${VAR:-default}, so this file —
|
||||
# sourced by the first-boot unit — configures it without the script being edited. The
|
||||
# ENABLE_* flags and ports come from CoreSystemConfig.json, which is what keeps the
|
||||
# ports the services actually bind to identical to the ports the kiosk images were
|
||||
# built to call.
|
||||
core_log "Generating the container host's setup overrides"
|
||||
cat > "$PAYLOAD/setup.env" <<EOF
|
||||
# GENERATED at image build time by tools/build-container-host-iso.sh from
|
||||
# CoreSystemConfig.json. Pair ID: $(core_pair_id)
|
||||
BASE_DIR=/opt/smart-home
|
||||
TIMEZONE=${CORE_TIMEZONE}
|
||||
ENABLE_IDENTITY=${CORE_ENABLE_IDENTITY}
|
||||
ENABLE_PANTRY_VISION=${CORE_ENABLE_PANTRY_VISION}
|
||||
ENABLE_DIGEST_ENGINE=${CORE_ENABLE_DIGEST_ENGINE}
|
||||
ENABLE_ADMIN_CANVAS=${CORE_ENABLE_ADMIN_CANVAS}
|
||||
ENABLE_TRASH_CALENDAR=${CORE_ENABLE_TRASH_CALENDAR}
|
||||
ENABLE_TRANSIT=${CORE_ENABLE_TRANSIT}
|
||||
ENABLE_TRIP_PLANNING=${CORE_ENABLE_TRIP_PLANNING}
|
||||
ENABLE_CHORES=${CORE_ENABLE_CHORES}
|
||||
ENABLE_NTFY=${CORE_ENABLE_NTFY}
|
||||
ENABLE_NODERED=${CORE_ENABLE_NODE_RED}
|
||||
ENABLE_NETDATA=${CORE_ENABLE_NETDATA}
|
||||
ENABLE_HOMEPAGE=${CORE_ENABLE_HOMEPAGE}
|
||||
ENABLE_PORTAINER=${CORE_ENABLE_PORTAINER}
|
||||
ENABLE_MEALIE=${CORE_ENABLE_MEALIE}
|
||||
ENABLE_GALLERY_SMB=${CORE_ENABLE_GALLERY_SMB}
|
||||
ENABLE_MUSIC_ASSISTANT=${CORE_ENABLE_MUSIC_ASSISTANT}
|
||||
ENABLE_BACKUPS=${CORE_ENABLE_BACKUPS}
|
||||
IDENTITY_PORT=${CORE_PORT_IDENTITY}
|
||||
IDENTITY_WEB_PORT=${CORE_PORT_IDENTITY_WEB}
|
||||
PANTRY_VISION_PORT=${CORE_PORT_PANTRY_VISION}
|
||||
PANTRY_WEB_PORT=${CORE_PORT_PANTRY_WEB}
|
||||
DIGEST_WEB_PORT=${CORE_PORT_DIGEST_WEB}
|
||||
ADMIN_WEB_PORT=${CORE_PORT_ADMIN_WEB}
|
||||
TRANSIT_PORT=${CORE_PORT_TRANSIT}
|
||||
OTP_PORT=${CORE_PORT_OTP}
|
||||
EOF
|
||||
chmod 600 "$PAYLOAD/setup.env"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Generated env files. EVERY URL BELOW IS DERIVED — see tools/config-export.py.
|
||||
# No address, port or token is written literally in this script.
|
||||
# ---------------------------------------------------------------------------
|
||||
core_log "Generating service env files from CoreSystemConfig.json"
|
||||
mkdir -p "$PAYLOAD"/{identity,chores,pantry-vision,digest-engine,transit,trash-calendar}
|
||||
|
||||
gen_header() {
|
||||
cat <<EOF
|
||||
# GENERATED at image build time by tools/build-container-host-iso.sh from
|
||||
# CoreSystemConfig.json. Editing this file by hand works, but the next image build
|
||||
# overwrites it — change CoreSystemConfig.json and rebuild instead.
|
||||
# Pair ID: $(core_pair_id)
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "$CORE_ENABLE_IDENTITY" == "true" ]]; then
|
||||
{ gen_header
|
||||
cat <<EOF
|
||||
IDENTITY_TOKEN=${CORE_IDENTITY_TOKEN}
|
||||
HA_URL=${CORE_HA_URL}
|
||||
HA_TOKEN=${CORE_HA_TOKEN}
|
||||
TRUSTED_ENTITY_PREFIXES=device_tracker.pble_,device_tracker.bletag_
|
||||
MQTT_BROKER_HOST=mosquitto
|
||||
MQTT_BROKER_PORT=${CORE_PORT_MQTT}
|
||||
MQTT_USERNAME=${CORE_MQTT_USERNAME}
|
||||
MQTT_PASSWORD=${CORE_MQTT_PASSWORD}
|
||||
FRIGATE_EVENTS_TOPIC=frigate/events
|
||||
FACE_PRESENCE_WINDOW_SECONDS=600
|
||||
PRESENCE_POLL_SECONDS=60
|
||||
DEPARTURE_GRACE_SECONDS=900
|
||||
VISIT_MAX_OPEN_HOURS=72
|
||||
NTFY_URL=http://ntfy
|
||||
NTFY_DEFAULT_TOPIC=household
|
||||
IDENTITY_PORT=${CORE_PORT_IDENTITY}
|
||||
IDENTITY_DB_PATH=/data/identity.db
|
||||
IDENTITY_PHOTO_DIR=/data/photos
|
||||
IDENTITY_MAX_IMAGE_MB=15
|
||||
LOG_LEVEL=INFO
|
||||
EOF
|
||||
} > "$PAYLOAD/identity/identity.env"
|
||||
chmod 600 "$PAYLOAD/identity/identity.env"
|
||||
fi
|
||||
|
||||
if [[ "$CORE_ENABLE_CHORES" == "true" ]]; then
|
||||
# Container-name DNS, not 127.0.0.1 — see this script's header comment.
|
||||
{ gen_header
|
||||
cat <<EOF
|
||||
IDENTITY_URL=http://identity:${CORE_PORT_IDENTITY}
|
||||
IDENTITY_TOKEN=${CORE_IDENTITY_TOKEN}
|
||||
WASTE_ICS_URL=
|
||||
FRIGATE_URL=http://frigate:${CORE_PORT_FRIGATE}
|
||||
OLLAMA_HOST=${CORE_OLLAMA_HOST}
|
||||
OLLAMA_VISION_MODEL=${CORE_LLM_VISION_MODEL}
|
||||
OLLAMA_TEXT_MODEL=
|
||||
CAMERA_WATCHPOINTS=
|
||||
CALDAV_URL=
|
||||
CALDAV_USERNAME=
|
||||
CALDAV_PASSWORD=
|
||||
CALDAV_VERIFY_TLS=true
|
||||
CALDAV_QUIET_KEYWORDS=busy,meeting,call,movie,sleep
|
||||
NTFY_URL=http://ntfy
|
||||
NTFY_TOPIC=chores
|
||||
NEGLECT_THRESHOLD_HOURS=4
|
||||
CHORE_ASSIGNMENT_STRICT=false
|
||||
CHORES_DB_PATH=/data/chores.db
|
||||
LOG_LEVEL=INFO
|
||||
EOF
|
||||
} > "$PAYLOAD/chores/chores.env"
|
||||
chmod 600 "$PAYLOAD/chores/chores.env"
|
||||
fi
|
||||
|
||||
if [[ "$CORE_ENABLE_PANTRY_VISION" == "true" ]]; then
|
||||
{ gen_header
|
||||
cat <<EOF
|
||||
PANTRY_VISION_TOKEN=${CORE_PANTRY_VISION_TOKEN}
|
||||
PANTRY_VISION_PORT=${CORE_PORT_PANTRY_VISION}
|
||||
GROCY_URL=http://grocy
|
||||
GROCY_API_KEY=
|
||||
OLLAMA_HOST=${CORE_OLLAMA_HOST}
|
||||
OLLAMA_VISION_MODEL=${CORE_LLM_VISION_MODEL}
|
||||
LOG_LEVEL=INFO
|
||||
EOF
|
||||
} > "$PAYLOAD/pantry-vision/pantry-vision.env"
|
||||
chmod 600 "$PAYLOAD/pantry-vision/pantry-vision.env"
|
||||
fi
|
||||
|
||||
if [[ "$CORE_ENABLE_TRANSIT" == "true" ]]; then
|
||||
{ gen_header
|
||||
cat <<EOF
|
||||
TRANSIT_TOKEN=${CORE_TRANSIT_TOKEN}
|
||||
TRANSIT_PORT=${CORE_PORT_TRANSIT}
|
||||
OTP_URL=${CORE_OTP_URL}
|
||||
LOG_LEVEL=INFO
|
||||
EOF
|
||||
} > "$PAYLOAD/transit/transit.env"
|
||||
chmod 600 "$PAYLOAD/transit/transit.env"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Static networking. The whole point of a fixed address here is that every kiosk
|
||||
# image was built with this exact IP compiled into its URLs — DHCP would break
|
||||
# every one of them the first time the lease moved.
|
||||
# ---------------------------------------------------------------------------
|
||||
cat > "$INCLUDES/etc/network/interfaces.d/smarthome" <<EOF
|
||||
# GENERATED from CoreSystemConfig.json. This address is not arbitrary: every kiosk
|
||||
# image built from the same config has it baked into its service URLs, so changing it
|
||||
# here alone would silently orphan them. Change it in CoreSystemConfig.json and
|
||||
# rebuild everything.
|
||||
auto lo
|
||||
iface lo inet loopback
|
||||
|
||||
allow-hotplug eth0
|
||||
iface eth0 inet static
|
||||
address ${CORE_CONTAINER_HOST_IP}
|
||||
netmask ${CORE_NETMASK}
|
||||
gateway ${CORE_GATEWAY}
|
||||
dns-nameservers ${CORE_DNS_SERVERS}
|
||||
EOF
|
||||
|
||||
echo "${CORE_CONTAINER_HOST_NAME}" > "$INCLUDES/etc/hostname"
|
||||
cat > "$INCLUDES/etc/hosts" <<EOF
|
||||
127.0.0.1 localhost
|
||||
127.0.1.1 ${CORE_CONTAINER_HOST_NAME}
|
||||
${CORE_CONTAINER_HOST_IP} ${CORE_CONTAINER_HOST_NAME}
|
||||
# The twin. Present so this host can reach the LLM host by name as well as address,
|
||||
# and so anyone reading /etc/hosts can see what this machine is paired with.
|
||||
${CORE_LLM_HOST_IP} ${CORE_LLM_HOST_NAME}
|
||||
EOF
|
||||
|
||||
cat > "$INCLUDES/etc/default/keyboard" <<EOF
|
||||
XKBMODEL="pc105"
|
||||
XKBLAYOUT="${CORE_KEYBOARD_LAYOUT}"
|
||||
XKBVARIANT=""
|
||||
XKBOPTIONS=""
|
||||
BACKSPACE="guess"
|
||||
EOF
|
||||
|
||||
core_write_build_stamp "$INCLUDES/etc/smarthome-build" "container-host"
|
||||
|
||||
# SSH key, if one was configured.
|
||||
if [[ -n "$CORE_SSH_AUTHORIZED_KEY" ]]; then
|
||||
mkdir -p "$INCLUDES/home/${CORE_CONTAINER_HOST_USER}/.ssh"
|
||||
echo "$CORE_SSH_AUTHORIZED_KEY" > "$INCLUDES/home/${CORE_CONTAINER_HOST_USER}/.ssh/authorized_keys"
|
||||
chmod 700 "$INCLUDES/home/${CORE_CONTAINER_HOST_USER}/.ssh"
|
||||
chmod 600 "$INCLUDES/home/${CORE_CONTAINER_HOST_USER}/.ssh/authorized_keys"
|
||||
else
|
||||
core_warn "No ssh_authorized_key in the config — this headless host will have no SSH access."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. First-boot unit. Runs ONCE, then disables itself: setup-container-host.sh is
|
||||
# idempotent, but a first-boot job that re-runs on every reboot would fight
|
||||
# whatever you changed by hand afterwards.
|
||||
# ---------------------------------------------------------------------------
|
||||
cat > "$INCLUDES/etc/systemd/system/smarthome-firstboot.service" <<'EOF'
|
||||
[Unit]
|
||||
Description=SmartestHome first-boot setup (container host)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=!/opt/smart-home/.firstboot-done
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
EnvironmentFile=/opt/smart-home/setup.env
|
||||
# Needs the network: it pulls container images. Deliberately not Restart=on-failure —
|
||||
# a half-finished run should be looked at, not retried in a loop that buries the
|
||||
# original error in the journal.
|
||||
ExecStart=/opt/smart-home/scripts/setup-container-host.sh
|
||||
ExecStartPost=/usr/bin/touch /opt/smart-home/.firstboot-done
|
||||
ExecStartPost=/bin/systemctl disable smarthome-firstboot.service
|
||||
StandardOutput=journal+console
|
||||
StandardError=journal+console
|
||||
TimeoutStartSec=3600
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
mkdir -p "$INCLUDES/etc/systemd/system/multi-user.target.wants"
|
||||
ln -sf /etc/systemd/system/smarthome-firstboot.service \
|
||||
"$INCLUDES/etc/systemd/system/multi-user.target.wants/smarthome-firstboot.service"
|
||||
|
||||
cat > "$LIVE_BUILD_DIR/config/package-lists/container-host.list.chroot" <<'EOF'
|
||||
ca-certificates
|
||||
curl
|
||||
gnupg
|
||||
openssh-server
|
||||
sudo
|
||||
python3
|
||||
git
|
||||
rsync
|
||||
EOF
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Preseed for the unattended install.
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p "$LIVE_BUILD_DIR/config/includes.installer"
|
||||
cat > "$LIVE_BUILD_DIR/config/includes.installer/preseed.cfg" <<EOF
|
||||
# GENERATED from CoreSystemConfig.json by tools/build-container-host-iso.sh
|
||||
d-i debian-installer/locale string ${CORE_LOCALE}
|
||||
d-i keyboard-configuration/xkb-keymap select ${CORE_KEYBOARD_LAYOUT}
|
||||
d-i time/zone string ${CORE_TIMEZONE}
|
||||
d-i clock-setup/utc boolean true
|
||||
|
||||
d-i netcfg/choose_interface select auto
|
||||
d-i netcfg/disable_autoconfig boolean true
|
||||
d-i netcfg/get_ipaddress string ${CORE_CONTAINER_HOST_IP}
|
||||
d-i netcfg/get_netmask string ${CORE_NETMASK}
|
||||
d-i netcfg/get_gateway string ${CORE_GATEWAY}
|
||||
d-i netcfg/get_nameservers string ${CORE_DNS_SERVERS}
|
||||
d-i netcfg/confirm_static boolean true
|
||||
d-i netcfg/get_hostname string ${CORE_CONTAINER_HOST_NAME}
|
||||
d-i netcfg/get_domain string local
|
||||
|
||||
d-i passwd/root-login boolean false
|
||||
d-i passwd/user-fullname string ${CORE_CONTAINER_HOST_USER}
|
||||
d-i passwd/username string ${CORE_CONTAINER_HOST_USER}
|
||||
$(if [[ -n "$CORE_ADMIN_PASSWORD_HASH" ]]; then
|
||||
echo "d-i passwd/user-password-crypted password ${CORE_ADMIN_PASSWORD_HASH}"
|
||||
else
|
||||
echo "# No admin_password_hash set — the installer will prompt for a password."
|
||||
echo "# Generate one with: mkpasswd -m sha-512"
|
||||
fi)
|
||||
d-i user-setup/allow-password-weak boolean false
|
||||
d-i user-setup/encrypt-home boolean false
|
||||
|
||||
# WHOLE-DISK, AUTOMATIC, NO CONFIRMATION. This erases ${CORE_CONTAINER_HOST_DISK}
|
||||
# without asking. That is the point of an unattended installer, and it is also why
|
||||
# you should be certain which disk that is on the machine you're booting this on.
|
||||
d-i partman-auto/disk string ${CORE_CONTAINER_HOST_DISK}
|
||||
d-i partman-auto/method string regular
|
||||
d-i partman-auto/choose_recipe select atomic
|
||||
d-i partman-partitioning/confirm_write_new_label boolean true
|
||||
d-i partman/choose_partition select finish
|
||||
d-i partman/confirm boolean true
|
||||
d-i partman/confirm_nooverwrite boolean true
|
||||
|
||||
d-i pkgsel/include string openssh-server sudo curl ca-certificates python3 git
|
||||
tasksel tasksel/first multiselect standard, ssh-server
|
||||
popularity-contest popularity-contest/participate boolean false
|
||||
|
||||
d-i grub-installer/only_debian boolean true
|
||||
d-i grub-installer/bootdev string ${CORE_CONTAINER_HOST_DISK}
|
||||
d-i finish-install/reboot_in_progress note
|
||||
EOF
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Build
|
||||
# ---------------------------------------------------------------------------
|
||||
cd "$LIVE_BUILD_DIR"
|
||||
core_log "Running lb config"
|
||||
lb clean --purge >/dev/null 2>&1 || true
|
||||
lb config \
|
||||
--distribution "$CORE_DEBIAN_RELEASE" \
|
||||
--architecture amd64 \
|
||||
--binary-images iso-hybrid \
|
||||
--debian-installer netinst \
|
||||
--debian-installer-gui false \
|
||||
--archive-areas "main contrib non-free non-free-firmware" \
|
||||
--iso-application "SmartestHome container host" \
|
||||
--iso-volume "smarthome-core-$(core_pair_id)"
|
||||
|
||||
core_log "Running lb build (long, needs network)"
|
||||
lb build
|
||||
|
||||
ISO="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name 'live-image-amd64.hybrid.iso' -print -quit)"
|
||||
[[ -n "$ISO" ]] || core_die "lb build finished but no ISO was produced — check the log above."
|
||||
|
||||
DEST="${OUTPUT_DIR}/smarthome-container-host-$(core_pair_id).iso"
|
||||
mv "$ISO" "$DEST"
|
||||
core_log "Container host ISO: ${DEST}"
|
||||
core_warn "This ISO contains every secret from CoreSystemConfig.json. Treat it as a credential."
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the TWINNED core pair: the container host (Home Assistant + everything in
|
||||
# hosts/container-host/) and the LLM host (Ollama, hosts/llm-host/), as two ISOs that
|
||||
# already know about each other.
|
||||
#
|
||||
# WHAT "TWINNED" MEANS HERE, concretely: CoreSystemConfig.json states the subnet prefix
|
||||
# once and one last octet per host. Everything else is computed. The container host's
|
||||
# OLLAMA_HOST is the LLM host's address because both were derived from those numbers in
|
||||
# the same build — not because someone typed the same IP into two files and remembered
|
||||
# to keep them in sync. Move the LLM host from .13 to .21 and rebuild, and the
|
||||
# container host's Ollama URL follows on its own. Neither ISO can be built pointing at
|
||||
# an address the other one isn't using.
|
||||
#
|
||||
# Both images get a matching SMARTHOME_PAIR_ID in /etc/smarthome-build, so two ISOs on
|
||||
# two USB sticks can be checked against each other months later.
|
||||
#
|
||||
# Everything each host needs is burnt in: static network config, hostname, admin user,
|
||||
# SSH key, every service token, the generated env files, and this repo itself. The
|
||||
# machines come up configured, with no post-install editing of env files by hand.
|
||||
#
|
||||
# sudo -E tools/build-core-pair.sh # both
|
||||
# sudo -E tools/build-core-pair.sh container # just the container host
|
||||
# sudo -E tools/build-core-pair.sh llm # just the LLM host
|
||||
#
|
||||
# READ THIS BEFORE YOU BUILD: the resulting ISOs contain every secret in
|
||||
# CoreSystemConfig.json — Wi-Fi PSK, service tokens, MQTT and HA credentials, your
|
||||
# SSH public key. They are credential-bearing artifacts. .gitignore keeps them out of
|
||||
# the repo, but an ISO on a USB stick in a drawer is still every token in this
|
||||
# household. Treat them accordingly, and wipe sticks you stop using.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/coreconfig.sh
|
||||
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||
|
||||
TARGET="${1:-both}"
|
||||
case "$TARGET" in
|
||||
both|container|llm) ;;
|
||||
*) core_die "Usage: $0 [both|container|llm]" ;;
|
||||
esac
|
||||
|
||||
core_load
|
||||
core_require_root "$TARGET"
|
||||
|
||||
PAIR_ID="$(core_pair_id)"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
============================================================================
|
||||
SmartestHome — twinned core pair
|
||||
============================================================================
|
||||
Pair ID : ${PAIR_ID}
|
||||
Subnet : ${CORE_SUBNET_PREFIX}.0/24 (gateway ${CORE_GATEWAY})
|
||||
|
||||
Container host : ${CORE_CONTAINER_HOST_NAME} ${CORE_CONTAINER_HOST_IP}
|
||||
LLM host : ${CORE_LLM_HOST_NAME} ${CORE_LLM_HOST_IP}
|
||||
|
||||
Derived cross-references (nothing below was typed by hand):
|
||||
container host -> Ollama : ${CORE_OLLAMA_HOST}
|
||||
kiosks -> Home Assistant : ${CORE_HA_URL}
|
||||
kiosks -> identity : ${CORE_IDENTITY_URL}
|
||||
kiosks -> MQTT : ${CORE_MQTT_BROKER_HOST}:${CORE_MQTT_BROKER_PORT}
|
||||
|
||||
Building : ${TARGET}
|
||||
============================================================================
|
||||
EOF
|
||||
|
||||
OUTPUT_DIR="${CORE_REPO_ROOT}/${CORE_BUILD_OUTPUT_DIR}"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
if [[ "$TARGET" == "both" || "$TARGET" == "container" ]]; then
|
||||
core_log "Building the container host ISO"
|
||||
"${SCRIPT_DIR}/build-container-host-iso.sh"
|
||||
fi
|
||||
|
||||
if [[ "$TARGET" == "both" || "$TARGET" == "llm" ]]; then
|
||||
core_log "Building the LLM host ISO"
|
||||
"${SCRIPT_DIR}/build-llm-host-iso.sh"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
============================================================================
|
||||
Done — pair ${PAIR_ID}
|
||||
============================================================================
|
||||
ISOs in: ${OUTPUT_DIR}
|
||||
|
||||
Install order matters slightly: bring up the CONTAINER host first, since the
|
||||
LLM host is only ever a server and doesn't need to reach it, whereas the
|
||||
container host's services will start looking for Ollama immediately (and
|
||||
degrade quietly until it answers — which is by design, see
|
||||
hosts/llm-host/README.md).
|
||||
|
||||
Two things still need a human afterwards, because neither can be known at
|
||||
build time:
|
||||
1. HA_TOKEN — a Long-Lived Access Token from Home Assistant's own UI, which
|
||||
doesn't exist until HA has been started and an account created. Put it in
|
||||
CoreSystemConfig.json and re-run this builder, or edit
|
||||
/opt/smart-home/identity/identity.env on the container host directly.
|
||||
2. TRUSTED_ENTITY_PREFIXES — the real entity_id prefixes your Private BLE
|
||||
Device setup produces (Developer Tools -> States). The shipped default is
|
||||
a guess, and it's the single highest-risk unknown in Phase 6.
|
||||
|
||||
Then do the thing the split exists for: power the LLM host OFF and confirm the
|
||||
house still works. See hosts/llm-host/README.md.
|
||||
============================================================================
|
||||
EOF
|
||||
|
|
@ -21,58 +21,58 @@
|
|||
# hosts/kitchen-display's twin: same one-workspace-two-kiosk-destinations shape,
|
||||
# different default content and a mic that's actually expected to be used.
|
||||
#
|
||||
# Run as: sudo ./build-door-panel-iso.sh
|
||||
# Run as: sudo -E tools/build-door-panel-iso.sh [hostname]
|
||||
#
|
||||
# EDIT THE VARIABLES BELOW BEFORE RUNNING.
|
||||
# Configuration comes from CoreSystemConfig.json — see tools/README.md.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CONFIGURATION — edit these before running
|
||||
# ---------------------------------------------------------------------------
|
||||
DEBIAN_RELEASE="bookworm"
|
||||
KIOSK_USERNAME="kiosk"
|
||||
IMAGE_HOSTNAME="door-panel"
|
||||
DOOR_PANEL_NAME="Door panel"
|
||||
|
||||
KEYBOARD_LAYOUT="de"
|
||||
|
||||
ENABLE_INSTALLER="false"
|
||||
|
||||
# --- Where the door panel talks to -------------------------------------------
|
||||
MQTT_BROKER_HOST="192.168.1.10" # <-- EDIT: container-host IP running Mosquitto
|
||||
MQTT_BROKER_PORT="1883"
|
||||
MQTT_USERNAME=""
|
||||
MQTT_PASSWORD=""
|
||||
|
||||
# identity's dashboard/registration pages and API — ENABLE_IDENTITY in
|
||||
# setup-container-host.sh. Placeholders until that's deployed; the image builds and
|
||||
# boots fine without it, the kiosk window just shows a connection error.
|
||||
IDENTITY_WEB_URL="http://192.168.1.10:8098" # <-- EDIT once identity-web is deployed
|
||||
IDENTITY_URL="http://192.168.1.10:8097" # <-- EDIT once identity is deployed
|
||||
# Must match identity/identity.env's own token — no way for this repo to push it
|
||||
# between the two hosts for you.
|
||||
IDENTITY_TOKEN="" # <-- EDIT
|
||||
|
||||
# pantry-vision — only needed for the dashboard's "Running low" section; the rest of
|
||||
# the dashboard (weather, who's home) works without it. Same placeholder handling.
|
||||
PANTRY_VISION_URL="http://192.168.1.10:8095" # <-- EDIT once pantry-vision is deployed
|
||||
PANTRY_VISION_TOKEN="" # <-- EDIT: must match pantry-vision's own token
|
||||
|
||||
# --- Voice registration ("register me as <name>") — this device's whole point, so
|
||||
# --- true is the expected real-deployment value, unlike hosts/kitchen-display's
|
||||
# --- identical flag — but still requires a real mic on this specific unit.
|
||||
ENABLE_VOICE_SATELLITE="true"
|
||||
VOICE_SATELLITE_NAME="Door panel"
|
||||
VOICE_WAKE_WORD="ok_nabu"
|
||||
|
||||
SSH_AUTHORIZED_KEY=""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
|
||||
#
|
||||
# There is nothing to edit here any more. Every value below is read from the one
|
||||
# config at the repo root, so an address or token can only be wrong in a single
|
||||
# place. Change it there and rebuild; see tools/README.md.
|
||||
#
|
||||
# sudo -E tools/build-door-panel-iso.sh # the only door-panel in the config
|
||||
# sudo -E tools/build-door-panel-iso.sh <hostname> # a specific one, if several are defined
|
||||
#
|
||||
# The build refuses to start if the config is invalid (validate-config.py runs first),
|
||||
# so a typo costs seconds rather than a 40-minute build and a reboot.
|
||||
# ---------------------------------------------------------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DOOR_PANEL_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
# shellcheck source=lib/coreconfig.sh
|
||||
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||
|
||||
core_select_kiosk "door-panel" "${1:-}"
|
||||
|
||||
# Mapped onto this script's existing variable names, so everything below is unchanged
|
||||
# from when these were hand-edited constants.
|
||||
DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE"
|
||||
KIOSK_USERNAME="$CORE_KIOSK_USERNAME"
|
||||
IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME"
|
||||
KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT"
|
||||
ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER"
|
||||
MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST"
|
||||
MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT"
|
||||
MQTT_USERNAME="$CORE_MQTT_USERNAME"
|
||||
MQTT_PASSWORD="$CORE_MQTT_PASSWORD"
|
||||
SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY"
|
||||
ENABLE_VOICE_SATELLITE="$CORE_KIOSK_VOICE_SATELLITE"
|
||||
VOICE_SATELLITE_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
||||
VOICE_WAKE_WORD="$CORE_KIOSK_WAKE_WORD"
|
||||
DOOR_PANEL_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
||||
IDENTITY_WEB_URL="$CORE_IDENTITY_WEB_URL"
|
||||
IDENTITY_URL="$CORE_IDENTITY_URL"
|
||||
IDENTITY_TOKEN="$CORE_IDENTITY_TOKEN"
|
||||
PANTRY_VISION_URL="$CORE_PANTRY_VISION_URL"
|
||||
PANTRY_VISION_TOKEN="$CORE_PANTRY_VISION_TOKEN"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths — this script now lives in tools/, so the host directory it drives is
|
||||
# addressed from the repo root rather than relative to the script.
|
||||
# ---------------------------------------------------------------------------
|
||||
DOOR_PANEL_DIR="${CORE_REPO_ROOT}/hosts/door-panel"
|
||||
CONFIGS_DIR="${DOOR_PANEL_DIR}/configs"
|
||||
AGENT_DIR="${DOOR_PANEL_DIR}/agent"
|
||||
LIVE_BUILD_DIR="${DOOR_PANEL_DIR}/live-build"
|
||||
|
|
@ -106,20 +106,8 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then
|
||||
echo "Warning: MQTT_BROKER_HOST is still the placeholder IP — edit it before building."
|
||||
fi
|
||||
|
||||
if [[ -z "$IDENTITY_TOKEN" ]]; then
|
||||
echo "Warning: IDENTITY_TOKEN is empty. The dashboard will load but every call to"
|
||||
echo " identity will fail (401) until this matches the token in"
|
||||
echo " identity/identity.env on the container host."
|
||||
fi
|
||||
|
||||
if [[ -z "$PANTRY_VISION_TOKEN" ]]; then
|
||||
echo "Warning: PANTRY_VISION_TOKEN is empty. 'Running low' will show as unconfigured"
|
||||
echo " until this matches the token in pantry-vision/pantry-vision.env."
|
||||
fi
|
||||
|
||||
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
|
||||
echo "Note: ENABLE_VOICE_SATELLITE=true — this image expects a real microphone on"
|
||||
|
|
@ -192,8 +180,8 @@ fi
|
|||
# ---------------------------------------------------------------------------
|
||||
echo "--- Writing /etc/door-panel-agent/config.env into includes.chroot ---"
|
||||
cat > "$INCLUDES/etc/door-panel-agent/config.env" <<EOF
|
||||
# Generated by hosts/door-panel/scripts/build-door-panel-iso.sh — do not hand-edit
|
||||
# here; edit the CONFIGURATION block in that script and rebuild.
|
||||
# Generated by tools/build-door-panel-iso.sh — do not hand-edit
|
||||
# here; change CoreSystemConfig.json at the repo root and rebuild.
|
||||
KIOSK_USERNAME=${KIOSK_USERNAME}
|
||||
DOOR_PANEL_NAME=${DOOR_PANEL_NAME}
|
||||
|
||||
|
|
@ -20,61 +20,59 @@
|
|||
# its live-build tree — same relationship hosts/touch-panel and hosts/audio-endpoint
|
||||
# already have to the thin client's.
|
||||
#
|
||||
# Run as: sudo ./build-kitchen-display-iso.sh
|
||||
# Run as: sudo -E tools/build-kitchen-display-iso.sh [hostname]
|
||||
#
|
||||
# EDIT THE VARIABLES BELOW BEFORE RUNNING.
|
||||
# Configuration comes from CoreSystemConfig.json — see tools/README.md.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CONFIGURATION — edit these before running
|
||||
# ---------------------------------------------------------------------------
|
||||
DEBIAN_RELEASE="bookworm"
|
||||
KIOSK_USERNAME="kiosk"
|
||||
IMAGE_HOSTNAME="kitchen-display"
|
||||
KITCHEN_DISPLAY_NAME="Kitchen fridge display"
|
||||
|
||||
KEYBOARD_LAYOUT="de"
|
||||
|
||||
ENABLE_INSTALLER="false"
|
||||
|
||||
# --- Where the kitchen display talks to -------------------------------------
|
||||
MQTT_BROKER_HOST="192.168.1.10" # <-- EDIT: container-host IP running Mosquitto
|
||||
MQTT_BROKER_PORT="1883"
|
||||
MQTT_USERNAME=""
|
||||
MQTT_PASSWORD=""
|
||||
|
||||
# pantry-web (nginx, serves pantry-vision/frontend/) and pantry-vision (the API the
|
||||
# frontend calls directly from the browser) — both from ENABLE_PANTRY_VISION in
|
||||
# setup-container-host.sh. Placeholders until that's deployed; the image builds and
|
||||
# boots fine without it, the kiosk window just shows a connection error.
|
||||
PANTRY_WEB_URL="http://192.168.1.10:8096" # <-- EDIT once pantry-web is deployed
|
||||
PANTRY_VISION_URL="http://192.168.1.10:8095" # <-- EDIT once pantry-vision is deployed
|
||||
# Same value as PANTRY_VISION_TOKEN in pantry-vision/pantry-vision.env — there is no
|
||||
# way for this repo to push it between the two hosts for you, same as every other
|
||||
# credential pair that spans two machines in this project.
|
||||
PANTRY_VISION_TOKEN="" # <-- EDIT: must match pantry-vision's own token
|
||||
|
||||
# identity's registration page (Phase 6) — ENABLE_IDENTITY in setup-container-host.sh.
|
||||
# Same placeholder handling as the pantry-vision block above.
|
||||
IDENTITY_WEB_URL="http://192.168.1.10:8098" # <-- EDIT once identity-web is deployed
|
||||
IDENTITY_URL="http://192.168.1.10:8097" # <-- EDIT once identity is deployed
|
||||
IDENTITY_TOKEN="" # <-- EDIT: must match identity's own token
|
||||
|
||||
# --- Voice registration ("register me as <name>") — OFF BY DEFAULT until a real mic
|
||||
# --- is attached to this specific unit. Same per-image opt-in shape as the thin
|
||||
# --- client's ENABLE_VOICE_SATELLITE (project-plan Phase 11.8).
|
||||
ENABLE_VOICE_SATELLITE="false"
|
||||
VOICE_SATELLITE_NAME="Kitchen display"
|
||||
VOICE_WAKE_WORD="ok_nabu"
|
||||
|
||||
SSH_AUTHORIZED_KEY=""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
|
||||
#
|
||||
# There is nothing to edit here any more. Every value below is read from the one
|
||||
# config at the repo root, so an address or token can only be wrong in a single
|
||||
# place. Change it there and rebuild; see tools/README.md.
|
||||
#
|
||||
# sudo -E tools/build-kitchen-display-iso.sh # the only kitchen-display in the config
|
||||
# sudo -E tools/build-kitchen-display-iso.sh <hostname> # a specific one, if several are defined
|
||||
#
|
||||
# The build refuses to start if the config is invalid (validate-config.py runs first),
|
||||
# so a typo costs seconds rather than a 40-minute build and a reboot.
|
||||
# ---------------------------------------------------------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
KITCHEN_DISPLAY_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
# shellcheck source=lib/coreconfig.sh
|
||||
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||
|
||||
core_select_kiosk "kitchen-display" "${1:-}"
|
||||
|
||||
# Mapped onto this script's existing variable names, so everything below is unchanged
|
||||
# from when these were hand-edited constants.
|
||||
DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE"
|
||||
KIOSK_USERNAME="$CORE_KIOSK_USERNAME"
|
||||
IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME"
|
||||
KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT"
|
||||
ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER"
|
||||
MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST"
|
||||
MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT"
|
||||
MQTT_USERNAME="$CORE_MQTT_USERNAME"
|
||||
MQTT_PASSWORD="$CORE_MQTT_PASSWORD"
|
||||
SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY"
|
||||
ENABLE_VOICE_SATELLITE="$CORE_KIOSK_VOICE_SATELLITE"
|
||||
VOICE_SATELLITE_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
||||
VOICE_WAKE_WORD="$CORE_KIOSK_WAKE_WORD"
|
||||
KITCHEN_DISPLAY_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
||||
PANTRY_WEB_URL="$CORE_PANTRY_WEB_URL"
|
||||
PANTRY_VISION_URL="$CORE_PANTRY_VISION_URL"
|
||||
PANTRY_VISION_TOKEN="$CORE_PANTRY_VISION_TOKEN"
|
||||
IDENTITY_WEB_URL="$CORE_IDENTITY_WEB_URL"
|
||||
IDENTITY_URL="$CORE_IDENTITY_URL"
|
||||
IDENTITY_TOKEN="$CORE_IDENTITY_TOKEN"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths — this script now lives in tools/, so the host directory it drives is
|
||||
# addressed from the repo root rather than relative to the script.
|
||||
# ---------------------------------------------------------------------------
|
||||
KITCHEN_DISPLAY_DIR="${CORE_REPO_ROOT}/hosts/kitchen-display"
|
||||
CONFIGS_DIR="${KITCHEN_DISPLAY_DIR}/configs"
|
||||
AGENT_DIR="${KITCHEN_DISPLAY_DIR}/agent"
|
||||
LIVE_BUILD_DIR="${KITCHEN_DISPLAY_DIR}/live-build"
|
||||
|
|
@ -108,21 +106,8 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then
|
||||
echo "Warning: MQTT_BROKER_HOST is still the placeholder IP — edit it before building."
|
||||
fi
|
||||
|
||||
if [[ -z "$PANTRY_VISION_TOKEN" ]]; then
|
||||
echo "Warning: PANTRY_VISION_TOKEN is empty. The kiosk window will load but every"
|
||||
echo " call to pantry-vision will fail (401) until this matches the token in"
|
||||
echo " pantry-vision/pantry-vision.env on the container host."
|
||||
fi
|
||||
|
||||
if [[ -z "$IDENTITY_TOKEN" ]]; then
|
||||
echo "Warning: IDENTITY_TOKEN is empty. 'Show registration' will load but every"
|
||||
echo " call to identity will fail (401) until this matches the token in"
|
||||
echo " identity/identity.env on the container host."
|
||||
fi
|
||||
|
||||
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
|
||||
echo "Note: ENABLE_VOICE_SATELLITE=true — this image expects a real microphone on"
|
||||
|
|
@ -196,8 +181,8 @@ fi
|
|||
# ---------------------------------------------------------------------------
|
||||
echo "--- Writing /etc/kitchen-display-agent/config.env into includes.chroot ---"
|
||||
cat > "$INCLUDES/etc/kitchen-display-agent/config.env" <<EOF
|
||||
# Generated by hosts/kitchen-display/scripts/build-kitchen-display-iso.sh — do not
|
||||
# hand-edit here; edit the CONFIGURATION block in that script and rebuild.
|
||||
# Generated by tools/build-kitchen-display-iso.sh — do not
|
||||
# hand-edit here; change CoreSystemConfig.json and rebuild.
|
||||
KIOSK_USERNAME=${KIOSK_USERNAME}
|
||||
KITCHEN_DISPLAY_NAME=${KITCHEN_DISPLAY_NAME}
|
||||
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# LLM host ISO — the Phase 3 Ollama machine (hosts/llm-host/), as an unattended-install
|
||||
# ISO with everything burnt in.
|
||||
#
|
||||
# The twin of build-container-host-iso.sh. Both read the same CoreSystemConfig.json, so
|
||||
# this image's address is the one the container host was built to call, by construction
|
||||
# rather than by agreement.
|
||||
#
|
||||
# This half is deliberately the simpler one, and that asymmetry is the design: the LLM
|
||||
# host is a *server*. It doesn't need to know the container host's address, hold any
|
||||
# service token, or reach anything at boot beyond a model registry. Nothing here is
|
||||
# load-bearing for the house — see hosts/llm-host/README.md's guardrail. Keeping this
|
||||
# image dumb is what lets you power it off, reinstall it, or swap the GPU without any
|
||||
# of that touching the smart home.
|
||||
#
|
||||
# Normally invoked via tools/build-core-pair.sh.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/coreconfig.sh
|
||||
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||
|
||||
core_load
|
||||
core_require_root
|
||||
|
||||
HOST_DIR="${CORE_REPO_ROOT}/hosts/llm-host"
|
||||
LIVE_BUILD_DIR="${HOST_DIR}/live-build"
|
||||
INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot"
|
||||
PAYLOAD="${INCLUDES}/opt/llm-host"
|
||||
OUTPUT_DIR="${CORE_REPO_ROOT}/${CORE_BUILD_OUTPUT_DIR}"
|
||||
|
||||
command -v lb >/dev/null 2>&1 || core_die "live-build is not installed (apt install live-build)"
|
||||
|
||||
core_log "Preparing ${LIVE_BUILD_DIR}"
|
||||
rm -rf "$INCLUDES"
|
||||
mkdir -p \
|
||||
"$INCLUDES/etc/systemd/system" \
|
||||
"$INCLUDES/etc/default" \
|
||||
"$INCLUDES/etc/network/interfaces.d" \
|
||||
"$LIVE_BUILD_DIR/config/package-lists" \
|
||||
"$LIVE_BUILD_DIR/config/includes.installer" \
|
||||
"$PAYLOAD" \
|
||||
"$OUTPUT_DIR"
|
||||
|
||||
mkdir -p "$PAYLOAD/scripts"
|
||||
cp "${CORE_TOOLS_DIR}/setup-llm-host.sh" "$PAYLOAD/scripts/"
|
||||
chmod +x "$PAYLOAD/scripts/setup-llm-host.sh"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Bake the config into setup-llm-host.sh's own variables, so the first-boot run
|
||||
# needs no arguments and no editing. Values come from CoreSystemConfig.json; the
|
||||
# script keeps its defaults for anything not managed centrally.
|
||||
# ---------------------------------------------------------------------------
|
||||
core_log "Generating the LLM host's setup overrides"
|
||||
cat > "$PAYLOAD/llm-host.env" <<EOF
|
||||
# GENERATED at image build time by tools/build-llm-host-iso.sh from
|
||||
# CoreSystemConfig.json. Sourced by the first-boot unit to override
|
||||
# setup-llm-host.sh's defaults.
|
||||
# Pair ID: $(core_pair_id)
|
||||
TIER=${CORE_LLM_TIER}
|
||||
OLLAMA_PORT=${CORE_PORT_OLLAMA}
|
||||
GPU_TEXT_MODEL=${CORE_LLM_TEXT_MODEL_GPU}
|
||||
CPU_TEXT_MODEL=${CORE_LLM_TEXT_MODEL_CPU}
|
||||
VISION_MODEL=${CORE_LLM_VISION_MODEL}
|
||||
PULL_VISION_MODEL=${CORE_LLM_PULL_VISION_MODEL}
|
||||
OLLAMA_KEEP_ALIVE=${CORE_LLM_KEEP_ALIVE}
|
||||
OLLAMA_MAX_LOADED_MODELS=${CORE_LLM_MAX_LOADED_MODELS}
|
||||
OLLAMA_NUM_PARALLEL=${CORE_LLM_NUM_PARALLEL}
|
||||
EOF
|
||||
chmod 600 "$PAYLOAD/llm-host.env"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Static networking — this address is what the container host's OLLAMA_HOST was
|
||||
# built to point at, so it cannot be left to DHCP.
|
||||
# ---------------------------------------------------------------------------
|
||||
cat > "$INCLUDES/etc/network/interfaces.d/smarthome" <<EOF
|
||||
# GENERATED from CoreSystemConfig.json. The container host image built alongside this
|
||||
# one has ${CORE_OLLAMA_HOST} compiled into its service env files — if this address
|
||||
# moves on its own, that host quietly loses inference and (by design) degrades rather
|
||||
# than erroring, so the breakage is easy to miss. Change it in CoreSystemConfig.json
|
||||
# and rebuild both halves.
|
||||
auto lo
|
||||
iface lo inet loopback
|
||||
|
||||
allow-hotplug eth0
|
||||
iface eth0 inet static
|
||||
address ${CORE_LLM_HOST_IP}
|
||||
netmask ${CORE_NETMASK}
|
||||
gateway ${CORE_GATEWAY}
|
||||
dns-nameservers ${CORE_DNS_SERVERS}
|
||||
EOF
|
||||
|
||||
echo "${CORE_LLM_HOST_NAME}" > "$INCLUDES/etc/hostname"
|
||||
cat > "$INCLUDES/etc/hosts" <<EOF
|
||||
127.0.0.1 localhost
|
||||
127.0.1.1 ${CORE_LLM_HOST_NAME}
|
||||
${CORE_LLM_HOST_IP} ${CORE_LLM_HOST_NAME}
|
||||
# The twin — recorded for diagnosis, not used. This host never initiates anything
|
||||
# toward the container host; it only answers.
|
||||
${CORE_CONTAINER_HOST_IP} ${CORE_CONTAINER_HOST_NAME}
|
||||
EOF
|
||||
|
||||
cat > "$INCLUDES/etc/default/keyboard" <<EOF
|
||||
XKBMODEL="pc105"
|
||||
XKBLAYOUT="${CORE_KEYBOARD_LAYOUT}"
|
||||
XKBVARIANT=""
|
||||
XKBOPTIONS=""
|
||||
BACKSPACE="guess"
|
||||
EOF
|
||||
|
||||
core_write_build_stamp "$INCLUDES/etc/smarthome-build" "llm-host"
|
||||
|
||||
if [[ -n "$CORE_SSH_AUTHORIZED_KEY" ]]; then
|
||||
mkdir -p "$INCLUDES/home/${CORE_LLM_HOST_USER}/.ssh"
|
||||
echo "$CORE_SSH_AUTHORIZED_KEY" > "$INCLUDES/home/${CORE_LLM_HOST_USER}/.ssh/authorized_keys"
|
||||
chmod 700 "$INCLUDES/home/${CORE_LLM_HOST_USER}/.ssh"
|
||||
chmod 600 "$INCLUDES/home/${CORE_LLM_HOST_USER}/.ssh/authorized_keys"
|
||||
else
|
||||
core_warn "No ssh_authorized_key in the config — this headless host will have no SSH access."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. First-boot unit. Long timeout on purpose: this pulls several GB of model weights
|
||||
# on a first run, and a model download on a slow link genuinely can outlast a
|
||||
# conservative systemd timeout.
|
||||
# ---------------------------------------------------------------------------
|
||||
cat > "$INCLUDES/etc/systemd/system/smarthome-llm-firstboot.service" <<'EOF'
|
||||
[Unit]
|
||||
Description=SmartestHome first-boot setup (LLM host)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=!/opt/llm-host/.firstboot-done
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
EnvironmentFile=/opt/llm-host/llm-host.env
|
||||
ExecStart=/opt/llm-host/scripts/setup-llm-host.sh
|
||||
ExecStartPost=/usr/bin/touch /opt/llm-host/.firstboot-done
|
||||
ExecStartPost=/bin/systemctl disable smarthome-llm-firstboot.service
|
||||
StandardOutput=journal+console
|
||||
StandardError=journal+console
|
||||
# Model pulls are multi-GB; 4h is generous rather than optimistic.
|
||||
TimeoutStartSec=14400
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
mkdir -p "$INCLUDES/etc/systemd/system/multi-user.target.wants"
|
||||
ln -sf /etc/systemd/system/smarthome-llm-firstboot.service \
|
||||
"$INCLUDES/etc/systemd/system/multi-user.target.wants/smarthome-llm-firstboot.service"
|
||||
|
||||
# firmware-misc-nonfree/nvidia-driver are NOT preinstalled here. Driver choice is the
|
||||
# most hardware-specific decision on this machine and picking one blind is how you
|
||||
# produce a box that doesn't boot — setup-llm-host.sh checks for a working nvidia-smi
|
||||
# and tells you what to install if it's missing. See hosts/llm-host/README.md.
|
||||
cat > "$LIVE_BUILD_DIR/config/package-lists/llm-host.list.chroot" <<'EOF'
|
||||
ca-certificates
|
||||
curl
|
||||
gnupg
|
||||
openssh-server
|
||||
sudo
|
||||
python3
|
||||
pciutils
|
||||
EOF
|
||||
|
||||
cat > "$LIVE_BUILD_DIR/config/includes.installer/preseed.cfg" <<EOF
|
||||
# GENERATED from CoreSystemConfig.json by tools/build-llm-host-iso.sh
|
||||
d-i debian-installer/locale string ${CORE_LOCALE}
|
||||
d-i keyboard-configuration/xkb-keymap select ${CORE_KEYBOARD_LAYOUT}
|
||||
d-i time/zone string ${CORE_TIMEZONE}
|
||||
d-i clock-setup/utc boolean true
|
||||
|
||||
d-i netcfg/choose_interface select auto
|
||||
d-i netcfg/disable_autoconfig boolean true
|
||||
d-i netcfg/get_ipaddress string ${CORE_LLM_HOST_IP}
|
||||
d-i netcfg/get_netmask string ${CORE_NETMASK}
|
||||
d-i netcfg/get_gateway string ${CORE_GATEWAY}
|
||||
d-i netcfg/get_nameservers string ${CORE_DNS_SERVERS}
|
||||
d-i netcfg/confirm_static boolean true
|
||||
d-i netcfg/get_hostname string ${CORE_LLM_HOST_NAME}
|
||||
d-i netcfg/get_domain string local
|
||||
|
||||
d-i passwd/root-login boolean false
|
||||
d-i passwd/user-fullname string ${CORE_LLM_HOST_USER}
|
||||
d-i passwd/username string ${CORE_LLM_HOST_USER}
|
||||
$(if [[ -n "$CORE_ADMIN_PASSWORD_HASH" ]]; then
|
||||
echo "d-i passwd/user-password-crypted password ${CORE_ADMIN_PASSWORD_HASH}"
|
||||
else
|
||||
echo "# No admin_password_hash set — the installer will prompt for a password."
|
||||
echo "# Generate one with: mkpasswd -m sha-512"
|
||||
fi)
|
||||
d-i user-setup/allow-password-weak boolean false
|
||||
d-i user-setup/encrypt-home boolean false
|
||||
|
||||
# WHOLE-DISK, AUTOMATIC, NO CONFIRMATION — erases ${CORE_LLM_HOST_DISK} without asking.
|
||||
# Model storage wants room: a 14B Q4 model is ~9GB and a vision model another 5-8GB.
|
||||
d-i partman-auto/disk string ${CORE_LLM_HOST_DISK}
|
||||
d-i partman-auto/method string regular
|
||||
d-i partman-auto/choose_recipe select atomic
|
||||
d-i partman-partitioning/confirm_write_new_label boolean true
|
||||
d-i partman/choose_partition select finish
|
||||
d-i partman/confirm boolean true
|
||||
d-i partman/confirm_nooverwrite boolean true
|
||||
|
||||
d-i pkgsel/include string openssh-server sudo curl ca-certificates python3 pciutils
|
||||
tasksel tasksel/first multiselect standard, ssh-server
|
||||
popularity-contest popularity-contest/participate boolean false
|
||||
|
||||
d-i grub-installer/only_debian boolean true
|
||||
d-i grub-installer/bootdev string ${CORE_LLM_HOST_DISK}
|
||||
d-i finish-install/reboot_in_progress note
|
||||
EOF
|
||||
|
||||
cd "$LIVE_BUILD_DIR"
|
||||
core_log "Running lb config"
|
||||
lb clean --purge >/dev/null 2>&1 || true
|
||||
lb config \
|
||||
--distribution "$CORE_DEBIAN_RELEASE" \
|
||||
--architecture amd64 \
|
||||
--binary-images iso-hybrid \
|
||||
--debian-installer netinst \
|
||||
--debian-installer-gui false \
|
||||
--archive-areas "main contrib non-free non-free-firmware" \
|
||||
--iso-application "SmartestHome LLM host" \
|
||||
--iso-volume "smarthome-llm-$(core_pair_id)"
|
||||
|
||||
core_log "Running lb build (long, needs network)"
|
||||
lb build
|
||||
|
||||
ISO="$(find "$LIVE_BUILD_DIR" -maxdepth 1 -name 'live-image-amd64.hybrid.iso' -print -quit)"
|
||||
[[ -n "$ISO" ]] || core_die "lb build finished but no ISO was produced — check the log above."
|
||||
|
||||
DEST="${OUTPUT_DIR}/smarthome-llm-host-$(core_pair_id).iso"
|
||||
mv "$ISO" "$DEST"
|
||||
core_log "LLM host ISO: ${DEST}"
|
||||
|
|
@ -18,84 +18,59 @@
|
|||
# generated tree that actually gets baked into the image). Never hand-edit anything
|
||||
# under includes.chroot — it is wiped and regenerated on every run.
|
||||
#
|
||||
# Run as: sudo ./build-thin-client-iso.sh
|
||||
# Run as: sudo -E tools/build-thin-client-iso.sh [hostname]
|
||||
#
|
||||
# EDIT THE VARIABLES BELOW BEFORE RUNNING.
|
||||
# Configuration comes from CoreSystemConfig.json — see tools/README.md.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CONFIGURATION — edit these before running
|
||||
# ---------------------------------------------------------------------------
|
||||
DEBIAN_RELEASE="bookworm" # Matches the container host's OS
|
||||
KIOSK_USERNAME="kiosk" # The autologin account the whole image is built around
|
||||
IMAGE_HOSTNAME="thin-client" # Hostname baked into the image
|
||||
THINCLIENT_NAME="Living room thin client" # Friendly name shown on the HA device
|
||||
|
||||
# Console + Sway keyboard layout. This IS the "installer choice" in this image's
|
||||
# architecture: there is no interactive keymap prompt in the normal live-boot path (see
|
||||
# ENABLE_INSTALLER below for the one case where a real prompt exists), so a per-image
|
||||
# build variable is what stands in for it — build one ISO per keyboard layout you need.
|
||||
KEYBOARD_LAYOUT="de" # xkb layout name (`localectl list-x11-keymap-layouts`)
|
||||
|
||||
ENABLE_STEAM_LINK="true" # Install the Steam Link flatpak from Flathub
|
||||
ENABLE_INSTALLER="false" # "true" adds a debian-installer to the ISO (install to disk)
|
||||
|
||||
# --- Voice satellite — OFF BY DEFAULT, AND MEANT TO STAY THAT WAY -----------
|
||||
# Per docs/project-plan.md Phase 11.8, only the specific rooms that have a microphone
|
||||
# run wyoming-satellite. This is therefore a PER-IMAGE decision, not a universal one:
|
||||
# build one ISO with this "false" for the silent rooms, and a second ISO with it "true"
|
||||
# for the mic-enabled ones. The exact mic-enabled room list is still an open decision
|
||||
# (project-plan §4 #6) — do not flip this on until it has been chosen.
|
||||
ENABLE_VOICE_SATELLITE="false"
|
||||
VOICE_SATELLITE_NAME="Living room" # Shown in HA's Wyoming/Assist device list
|
||||
VOICE_WAKE_WORD="ok_nabu" # openWakeWord model name
|
||||
|
||||
# --- Camera gesture control — OFF BY DEFAULT, AND MEANT TO STAY THAT WAY -------
|
||||
# Open hand moves the pointer, fist clicks. Same per-image, per-room logic as the mic
|
||||
# above: only build this into the image of a room that is actually getting a webcam.
|
||||
# This flag only decides whether MediaPipe and its ~400 MB dependency tree are INSTALLED.
|
||||
# Whether the camera is ever OPENED is a second, separate gate — the "enabled" flag in
|
||||
# configs/gesture-control/gesture-config.json, which is false by default even here, so a
|
||||
# gesture-capable image still ships with the camera off. See the privacy section in
|
||||
# hosts/thin-client/README.md.
|
||||
ENABLE_GESTURE_CONTROL="false"
|
||||
|
||||
# --- Where the thin client talks to ----------------------------------------
|
||||
# The container host from Phase 1 (Mosquitto + Home Assistant). Fill in its LAN IP.
|
||||
MQTT_BROKER_HOST="192.168.1.10" # <-- EDIT: container-host IP running Mosquitto
|
||||
MQTT_BROKER_PORT="1883"
|
||||
MQTT_USERNAME="" # Leave empty while Mosquitto runs allow_anonymous
|
||||
MQTT_PASSWORD="" # Never commit a real value here — see README
|
||||
HA_URL="http://192.168.1.10:8123" # <-- EDIT: Home Assistant URL
|
||||
|
||||
# digest-web is the static-file service that Phase 12's digest-engine renders into.
|
||||
# It is built by a separate workstream; until it is deployed this is just a placeholder
|
||||
# and the kiosk Firefox workspace will show a connection error (harmless — the session
|
||||
# must still come up with the container host powered off, per Phase 11.10).
|
||||
DIGEST_WEB_URL="http://192.168.1.10:8081" # <-- EDIT once digest-web is deployed
|
||||
|
||||
# admin-web (Phase 13) — the sys-admin-llm's on-demand display surface. Same
|
||||
# placeholder handling as DIGEST_WEB_URL above: harmless until deployed, the admin
|
||||
# workspace just won't have anything to open yet (and unlike the digest workspace it
|
||||
# is never auto-launched at session start anyway — see configs/sway/config).
|
||||
ADMIN_WEB_URL="http://192.168.1.10:8094" # <-- EDIT once admin-web is deployed
|
||||
|
||||
# The container host's gallery-smb share (ENABLE_GALLERY_SMB in
|
||||
# setup-container-host.sh), used by the idle-timeout slideshow. Just the host —
|
||||
# idle-gallery.sh always mounts the fixed "gallery" share name.
|
||||
GALLERY_SMB_HOST="192.168.1.10" # <-- EDIT: container-host IP running gallery-smb
|
||||
|
||||
# Optional: an SSH public key to bake into the kiosk account for out-of-band admin.
|
||||
# The image ships with password auth disabled, so without this the only admin path is
|
||||
# the local console or wayvnc.
|
||||
SSH_AUTHORIZED_KEY=""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
|
||||
#
|
||||
# There is nothing to edit here any more. Every value below is read from the one
|
||||
# config at the repo root, so an address or token can only be wrong in a single
|
||||
# place. Change it there and rebuild; see tools/README.md.
|
||||
#
|
||||
# sudo -E tools/build-thin-client-iso.sh # the only thin-client in the config
|
||||
# sudo -E tools/build-thin-client-iso.sh <hostname> # a specific one, if several are defined
|
||||
#
|
||||
# The build refuses to start if the config is invalid (validate-config.py runs first),
|
||||
# so a typo costs seconds rather than a 40-minute build and a reboot.
|
||||
# ---------------------------------------------------------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
THIN_CLIENT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
# shellcheck source=lib/coreconfig.sh
|
||||
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||
|
||||
core_select_kiosk "thin-client" "${1:-}"
|
||||
|
||||
# Mapped onto this script's existing variable names, so everything below is unchanged
|
||||
# from when these were hand-edited constants.
|
||||
DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE"
|
||||
KIOSK_USERNAME="$CORE_KIOSK_USERNAME"
|
||||
IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME"
|
||||
KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT"
|
||||
ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER"
|
||||
MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST"
|
||||
MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT"
|
||||
MQTT_USERNAME="$CORE_MQTT_USERNAME"
|
||||
MQTT_PASSWORD="$CORE_MQTT_PASSWORD"
|
||||
SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY"
|
||||
ENABLE_VOICE_SATELLITE="$CORE_KIOSK_VOICE_SATELLITE"
|
||||
VOICE_SATELLITE_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
||||
VOICE_WAKE_WORD="$CORE_KIOSK_WAKE_WORD"
|
||||
THINCLIENT_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
||||
HA_URL="$CORE_HA_URL"
|
||||
DIGEST_WEB_URL="$CORE_DIGEST_WEB_URL"
|
||||
ADMIN_WEB_URL="$CORE_ADMIN_WEB_URL"
|
||||
GALLERY_SMB_HOST="$CORE_GALLERY_SMB_HOST"
|
||||
ENABLE_STEAM_LINK="$CORE_KIOSK_ENABLE_STEAM_LINK"
|
||||
ENABLE_GESTURE_CONTROL="$CORE_KIOSK_ENABLE_GESTURE_CONTROL"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths — this script now lives in tools/, so the host directory it drives is
|
||||
# addressed from the repo root rather than relative to the script.
|
||||
# ---------------------------------------------------------------------------
|
||||
THIN_CLIENT_DIR="${CORE_REPO_ROOT}/hosts/thin-client"
|
||||
CONFIGS_DIR="${THIN_CLIENT_DIR}/configs"
|
||||
AGENT_DIR="${THIN_CLIENT_DIR}/agent"
|
||||
LIVE_BUILD_DIR="${THIN_CLIENT_DIR}/live-build"
|
||||
|
|
@ -134,23 +109,8 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then
|
||||
echo "Warning: MQTT_BROKER_HOST is still the placeholder IP."
|
||||
echo " Edit it at the top of this script to your container host's real LAN address,"
|
||||
echo " or the thin client won't show up as Home Assistant entities."
|
||||
fi
|
||||
|
||||
if [[ "$DIGEST_WEB_URL" == "http://192.168.1.10:8081" ]]; then
|
||||
echo "Warning: DIGEST_WEB_URL is still the placeholder."
|
||||
echo " Fill it in once Phase 12's digest-web service is deployed. The image builds"
|
||||
echo " and boots fine without it — the digest workspace just won't load anything."
|
||||
fi
|
||||
|
||||
if [[ "$ADMIN_WEB_URL" == "http://192.168.1.10:8094" ]]; then
|
||||
echo "Warning: ADMIN_WEB_URL is still the placeholder."
|
||||
echo " Fill it in once Phase 13's admin-web service is deployed. The image builds"
|
||||
echo " and boots fine without it — 'Show admin canvas' just won't load anything."
|
||||
fi
|
||||
|
||||
if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
|
||||
echo "Note: ENABLE_VOICE_SATELLITE=true — this image is for a MIC-ENABLED room"
|
||||
|
|
@ -298,8 +258,8 @@ fi
|
|||
# ---------------------------------------------------------------------------
|
||||
echo "--- Writing /etc/thinclient-agent/config.env into includes.chroot ---"
|
||||
cat > "$INCLUDES/etc/thinclient-agent/config.env" <<EOF
|
||||
# Generated by hosts/thin-client/scripts/build-thin-client-iso.sh — do not hand-edit
|
||||
# here; edit the CONFIGURATION block in that script and rebuild.
|
||||
# Generated by tools/build-thin-client-iso.sh — do not hand-edit
|
||||
# here; change CoreSystemConfig.json at the repo root and rebuild.
|
||||
KIOSK_USERNAME=${KIOSK_USERNAME}
|
||||
THINCLIENT_NAME=${THINCLIENT_NAME}
|
||||
|
||||
|
|
@ -452,7 +412,7 @@ fi
|
|||
echo " 9. Idle photo slideshow: create /etc/thinclient-agent/gallery-credentials on the"
|
||||
echo " booted machine (chmod 600) from the .example next to it, matching whatever"
|
||||
echo " GALLERY_SMB_USERNAME/GALLERY_SMB_PASSWORD you set in"
|
||||
echo " hosts/container-host/scripts/setup-container-host.sh. Until that file exists,"
|
||||
echo " tools/setup-container-host.sh. Until that file exists,"
|
||||
echo " idle timeout just blanks the panel — the old, pre-slideshow behaviour."
|
||||
echo
|
||||
echo "Then pull the power on the container host and re-check: the kiosk session must"
|
||||
|
|
@ -23,41 +23,54 @@
|
|||
# generated tree that actually gets baked into the image). Never hand-edit anything
|
||||
# under includes.chroot — it is wiped and regenerated on every run.
|
||||
#
|
||||
# Run as: sudo ./build-touch-panel-iso.sh
|
||||
# Run as: sudo -E tools/build-touch-panel-iso.sh [hostname]
|
||||
#
|
||||
# EDIT THE VARIABLES BELOW BEFORE RUNNING.
|
||||
# Configuration comes from CoreSystemConfig.json — see tools/README.md.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CONFIGURATION — edit these before running
|
||||
# ---------------------------------------------------------------------------
|
||||
DEBIAN_RELEASE="bookworm" # Matches the container host's OS
|
||||
KIOSK_USERNAME="kiosk" # The autologin account the whole image is built around
|
||||
IMAGE_HOSTNAME="touch-panel" # Hostname baked into the image
|
||||
TOUCHPANEL_NAME="Kitchen touch panel" # Friendly name shown on the HA device
|
||||
|
||||
KEYBOARD_LAYOUT="de" # xkb layout name (`localectl list-x11-keymap-layouts`)
|
||||
|
||||
ENABLE_INSTALLER="false" # "true" adds a debian-installer to the ISO (install to disk)
|
||||
|
||||
# --- Where the touch panel talks to -----------------------------------------
|
||||
MQTT_BROKER_HOST="192.168.1.10" # <-- EDIT: container-host IP running Mosquitto
|
||||
MQTT_BROKER_PORT="1883"
|
||||
MQTT_USERNAME="" # Leave empty while Mosquitto runs allow_anonymous
|
||||
MQTT_PASSWORD="" # Never commit a real value here
|
||||
HA_URL="http://192.168.1.10:8123" # <-- EDIT: Home Assistant URL — the "Home" workspace
|
||||
|
||||
# Optional: an SSH public key to bake into the kiosk account for out-of-band admin.
|
||||
# The image ships with password auth disabled and no wayvnc (see README's scope note),
|
||||
# so without this the only admin path is the local console.
|
||||
SSH_AUTHORIZED_KEY=""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# CONFIGURATION — comes from CoreSystemConfig.json, NOT from this file.
|
||||
#
|
||||
# There is nothing to edit here any more. Every value below is read from the one
|
||||
# config at the repo root, so an address or token can only be wrong in a single
|
||||
# place. Change it there and rebuild; see tools/README.md.
|
||||
#
|
||||
# sudo -E tools/build-touch-panel-iso.sh # the only touch-panel in the config
|
||||
# sudo -E tools/build-touch-panel-iso.sh <hostname> # a specific one, if several are defined
|
||||
#
|
||||
# The build refuses to start if the config is invalid (validate-config.py runs first),
|
||||
# so a typo costs seconds rather than a 40-minute build and a reboot.
|
||||
# ---------------------------------------------------------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TOUCH_PANEL_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
# shellcheck source=lib/coreconfig.sh
|
||||
source "${SCRIPT_DIR}/lib/coreconfig.sh"
|
||||
|
||||
core_select_kiosk "touch-panel" "${1:-}"
|
||||
|
||||
# Mapped onto this script's existing variable names, so everything below is unchanged
|
||||
# from when these were hand-edited constants.
|
||||
DEBIAN_RELEASE="$CORE_DEBIAN_RELEASE"
|
||||
KIOSK_USERNAME="$CORE_KIOSK_USERNAME"
|
||||
IMAGE_HOSTNAME="$CORE_KIOSK_HOSTNAME"
|
||||
KEYBOARD_LAYOUT="$CORE_KEYBOARD_LAYOUT"
|
||||
ENABLE_INSTALLER="$CORE_KIOSK_ENABLE_INSTALLER"
|
||||
MQTT_BROKER_HOST="$CORE_MQTT_BROKER_HOST"
|
||||
MQTT_BROKER_PORT="$CORE_MQTT_BROKER_PORT"
|
||||
MQTT_USERNAME="$CORE_MQTT_USERNAME"
|
||||
MQTT_PASSWORD="$CORE_MQTT_PASSWORD"
|
||||
SSH_AUTHORIZED_KEY="$CORE_SSH_AUTHORIZED_KEY"
|
||||
ENABLE_VOICE_SATELLITE="$CORE_KIOSK_VOICE_SATELLITE"
|
||||
VOICE_SATELLITE_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
||||
VOICE_WAKE_WORD="$CORE_KIOSK_WAKE_WORD"
|
||||
TOUCHPANEL_NAME="$CORE_KIOSK_FRIENDLY_NAME"
|
||||
HA_URL="$CORE_HA_URL"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths — this script now lives in tools/, so the host directory it drives is
|
||||
# addressed from the repo root rather than relative to the script.
|
||||
# ---------------------------------------------------------------------------
|
||||
TOUCH_PANEL_DIR="${CORE_REPO_ROOT}/hosts/touch-panel"
|
||||
CONFIGS_DIR="${TOUCH_PANEL_DIR}/configs"
|
||||
AGENT_DIR="${TOUCH_PANEL_DIR}/agent"
|
||||
LIVE_BUILD_DIR="${TOUCH_PANEL_DIR}/live-build"
|
||||
|
|
@ -96,17 +109,7 @@ if [[ ! -f "$PACKAGE_LIST" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then
|
||||
echo "Warning: MQTT_BROKER_HOST is still the placeholder IP."
|
||||
echo " Edit it at the top of this script to your container host's real LAN address,"
|
||||
echo " or the touch panel won't show up as Home Assistant entities."
|
||||
fi
|
||||
|
||||
if [[ "$HA_URL" == "http://192.168.1.10:8123" ]]; then
|
||||
echo "Warning: HA_URL is still the placeholder."
|
||||
echo " The image builds and boots fine, but the Home workspace will show a"
|
||||
echo " connection error until this points at a real Home Assistant."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Smart Home Touch-Panel ISO Builder ==="
|
||||
|
|
@ -199,8 +202,8 @@ fi
|
|||
# ---------------------------------------------------------------------------
|
||||
echo "--- Writing /etc/touchpanel-agent/config.env into includes.chroot ---"
|
||||
cat > "$INCLUDES/etc/touchpanel-agent/config.env" <<EOF
|
||||
# Generated by hosts/touch-panel/scripts/build-touch-panel-iso.sh — do not hand-edit
|
||||
# here; edit the CONFIGURATION block in that script and rebuild.
|
||||
# Generated by tools/build-touch-panel-iso.sh — do not hand-edit
|
||||
# here; change CoreSystemConfig.json at the repo root and rebuild.
|
||||
KIOSK_USERNAME=${KIOSK_USERNAME}
|
||||
TOUCHPANEL_NAME=${TOUCHPANEL_NAME}
|
||||
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Turn CoreSystemConfig.json into shell variable assignments, deriving everything
|
||||
derivable along the way. Consumed by tools/lib/coreconfig.sh via `eval`.
|
||||
|
||||
THIS IS WHERE THE TWINNING ACTUALLY HAPPENS. The config states two numbers — the
|
||||
container host's last octet and the LLM host's — and this script computes every
|
||||
address and every service URL from them. So the container host's OLLAMA_HOST is the
|
||||
LLM host's address *by construction*: change `llm_host.ip_last_octet` from 13 to 21 and
|
||||
the container-host ISO's Ollama URL follows on the next build, with nothing to keep in
|
||||
sync by hand and nothing that can drift.
|
||||
|
||||
The same applies to every kiosk: a door panel's IDENTITY_URL is
|
||||
`http://<container_host_ip>:<ports.identity>`, computed here, never typed anywhere.
|
||||
|
||||
Usage:
|
||||
config-export.py <config.json> # core values only
|
||||
config-export.py <config.json> --kiosk <hostname> # + that kiosk's values
|
||||
config-export.py <config.json> --audio-endpoint <hostname> # + that endpoint's values
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def emit(name: str, value) -> None:
|
||||
if isinstance(value, bool):
|
||||
value = "true" if value else "false"
|
||||
print(f"{name}={shlex.quote(str(value))}")
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) < 2:
|
||||
print("usage: config-export.py <config.json> [--kiosk <hostname>]", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
cfg = json.loads(Path(argv[1]).read_text())
|
||||
kiosk_hostname = None
|
||||
if "--kiosk" in argv:
|
||||
kiosk_hostname = argv[argv.index("--kiosk") + 1]
|
||||
audio_hostname = None
|
||||
if "--audio-endpoint" in argv:
|
||||
audio_hostname = argv[argv.index("--audio-endpoint") + 1]
|
||||
|
||||
prefix = cfg["network"]["subnet_prefix"]
|
||||
ports = {k: v for k, v in cfg["ports"].items() if not k.startswith("_")}
|
||||
|
||||
container_ip = f"{prefix}.{cfg['container_host']['ip_last_octet']}"
|
||||
llm_ip = f"{prefix}.{cfg['llm_host']['ip_last_octet']}"
|
||||
gateway = f"{prefix}.{cfg['network']['gateway_last_octet']}"
|
||||
|
||||
# --- Household / image basics ---
|
||||
emit("CORE_TIMEZONE", cfg["household"]["timezone"])
|
||||
emit("CORE_LOCALE", cfg["household"]["locale"])
|
||||
emit("CORE_KEYBOARD_LAYOUT", cfg["household"]["keyboard_layout"])
|
||||
emit("CORE_DEBIAN_RELEASE", cfg["household"]["debian_release"])
|
||||
|
||||
# --- Network ---
|
||||
emit("CORE_SUBNET_PREFIX", prefix)
|
||||
emit("CORE_NETMASK", cfg["network"]["netmask"])
|
||||
emit("CORE_GATEWAY", gateway)
|
||||
emit("CORE_DNS_SERVERS", " ".join(cfg["network"]["dns_servers"]))
|
||||
emit("CORE_WIFI_SSID", cfg["network"]["wifi"].get("ssid", ""))
|
||||
emit("CORE_WIFI_PSK", cfg["network"]["wifi"].get("psk", ""))
|
||||
|
||||
# --- The two core hosts, each aware of the other. This is the twinning. ---
|
||||
emit("CORE_CONTAINER_HOST_IP", container_ip)
|
||||
emit("CORE_CONTAINER_HOST_NAME", cfg["container_host"]["hostname"])
|
||||
emit("CORE_CONTAINER_HOST_DISK", cfg["container_host"]["install_disk"])
|
||||
emit("CORE_CONTAINER_HOST_USER", cfg["container_host"]["admin_username"])
|
||||
emit("CORE_LLM_HOST_IP", llm_ip)
|
||||
emit("CORE_LLM_HOST_NAME", cfg["llm_host"]["hostname"])
|
||||
emit("CORE_LLM_HOST_DISK", cfg["llm_host"]["install_disk"])
|
||||
emit("CORE_LLM_HOST_USER", cfg["llm_host"]["admin_username"])
|
||||
|
||||
# --- LLM host settings ---
|
||||
llm = cfg["llm_host"]
|
||||
emit("CORE_LLM_TIER", llm["tier"])
|
||||
emit("CORE_LLM_TEXT_MODEL_GPU", llm["text_model_gpu"])
|
||||
emit("CORE_LLM_TEXT_MODEL_CPU", llm["text_model_cpu"])
|
||||
emit("CORE_LLM_VISION_MODEL", llm["vision_model"])
|
||||
emit("CORE_LLM_PULL_VISION_MODEL", llm.get("pull_vision_model", True))
|
||||
emit("CORE_LLM_KEEP_ALIVE", llm["keep_alive"])
|
||||
emit("CORE_LLM_MAX_LOADED_MODELS", llm["max_loaded_models"])
|
||||
emit("CORE_LLM_NUM_PARALLEL", llm["num_parallel"])
|
||||
|
||||
# --- Ports, individually and as derived URLs ---
|
||||
for name, port in sorted(ports.items()):
|
||||
emit(f"CORE_PORT_{name.upper()}", port)
|
||||
|
||||
# DERIVED URLS — the whole reason this file exists. Nothing below is ever written
|
||||
# by hand in a build script or an env file; every one is computed from an address
|
||||
# and a port that each appear exactly once in CoreSystemConfig.json.
|
||||
emit("CORE_HA_URL", f"http://{container_ip}:{ports['home_assistant']}")
|
||||
emit("CORE_MQTT_BROKER_HOST", container_ip)
|
||||
emit("CORE_MQTT_BROKER_PORT", ports["mqtt"])
|
||||
emit("CORE_IDENTITY_URL", f"http://{container_ip}:{ports['identity']}")
|
||||
emit("CORE_IDENTITY_WEB_URL", f"http://{container_ip}:{ports['identity_web']}")
|
||||
emit("CORE_PANTRY_VISION_URL", f"http://{container_ip}:{ports['pantry_vision']}")
|
||||
emit("CORE_PANTRY_WEB_URL", f"http://{container_ip}:{ports['pantry_web']}")
|
||||
emit("CORE_DIGEST_WEB_URL", f"http://{container_ip}:{ports['digest_web']}")
|
||||
emit("CORE_ADMIN_WEB_URL", f"http://{container_ip}:{ports['admin_web']}")
|
||||
emit("CORE_TRANSIT_URL", f"http://{container_ip}:{ports['transit']}")
|
||||
emit("CORE_OTP_URL", f"http://{container_ip}:{ports['otp']}")
|
||||
emit("CORE_NTFY_URL", f"http://{container_ip}:{ports['ntfy']}")
|
||||
emit("CORE_GALLERY_SMB_HOST", container_ip)
|
||||
emit("CORE_FRIGATE_URL", f"http://{container_ip}:{ports['frigate']}")
|
||||
emit("CORE_GROCY_URL", f"http://{container_ip}:{ports['grocy']}")
|
||||
# The one that points the OTHER way — the container host's services reaching the
|
||||
# LLM host. Derived from llm_host.ip_last_octet, so the pair can never disagree.
|
||||
emit("CORE_OLLAMA_HOST", f"http://{llm_ip}:{ports['ollama']}")
|
||||
|
||||
# --- Secrets ---
|
||||
secrets = cfg.get("secrets", {})
|
||||
for key in ("identity_token", "pantry_vision_token", "transit_token", "mqtt_username",
|
||||
"mqtt_password", "ha_token", "ssh_authorized_key", "kiosk_password",
|
||||
"admin_password_hash"):
|
||||
emit(f"CORE_{key.upper()}", secrets.get(key, ""))
|
||||
|
||||
# --- Enable flags ---
|
||||
for flag, value in (cfg.get("container_host", {}).get("enable", {}) or {}).items():
|
||||
if not flag.startswith("_"):
|
||||
emit(f"CORE_ENABLE_{flag.upper()}", value)
|
||||
|
||||
emit("CORE_VOICE_WAKE_WORD", cfg.get("voice", {}).get("wake_word", "ok_nabu"))
|
||||
emit("CORE_BUILD_OUTPUT_DIR", cfg.get("build", {}).get("output_dir", "build-output"))
|
||||
|
||||
# --- The selected kiosk, if one was asked for ---
|
||||
if kiosk_hostname:
|
||||
matches = [k for k in cfg.get("kiosks", []) if k.get("hostname") == kiosk_hostname]
|
||||
if not matches:
|
||||
available = ", ".join(k.get("hostname", "?") for k in cfg.get("kiosks", []))
|
||||
print(f"echo 'error: no kiosk with hostname {kiosk_hostname!r} in the config "
|
||||
f"(have: {available})' >&2; return 1 2>/dev/null || exit 1")
|
||||
return 1
|
||||
kiosk = matches[0]
|
||||
emit("CORE_KIOSK_TYPE", kiosk["type"])
|
||||
emit("CORE_KIOSK_HOSTNAME", kiosk["hostname"])
|
||||
emit("CORE_KIOSK_FRIENDLY_NAME", kiosk["friendly_name"])
|
||||
emit("CORE_KIOSK_USERNAME", kiosk["kiosk_username"])
|
||||
emit("CORE_KIOSK_VOICE_SATELLITE", kiosk.get("voice_satellite", False))
|
||||
emit("CORE_KIOSK_ENABLE_INSTALLER", kiosk.get("enable_installer", False))
|
||||
emit("CORE_KIOSK_ENABLE_STEAM_LINK", kiosk.get("enable_steam_link", False))
|
||||
emit("CORE_KIOSK_ENABLE_GESTURE_CONTROL", kiosk.get("enable_gesture_control", False))
|
||||
# A per-kiosk wake word overrides the household default.
|
||||
emit("CORE_KIOSK_WAKE_WORD", kiosk.get("wake_word", cfg.get("voice", {}).get("wake_word", "ok_nabu")))
|
||||
|
||||
if audio_hostname:
|
||||
matches = [a for a in cfg.get("audio_endpoints", []) if a.get("hostname") == audio_hostname]
|
||||
if not matches:
|
||||
available = ", ".join(a.get("hostname", "?") for a in cfg.get("audio_endpoints", []))
|
||||
print(f"echo 'error: no audio endpoint with hostname {audio_hostname!r} in the config "
|
||||
f"(have: {available})' >&2; return 1 2>/dev/null || exit 1")
|
||||
return 1
|
||||
endpoint = matches[0]
|
||||
emit("CORE_AUDIO_HOSTNAME", endpoint["hostname"])
|
||||
emit("CORE_AUDIO_FRIENDLY_NAME", endpoint["friendly_name"])
|
||||
emit("CORE_AUDIO_ARCH", endpoint["arch"])
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
# shellcheck shell=bash
|
||||
#
|
||||
# Shared config loader for every build script in tools/. Source it, call
|
||||
# `core_load`, and every CORE_* variable is in scope.
|
||||
#
|
||||
# source "$(dirname "${BASH_SOURCE[0]}")/lib/coreconfig.sh"
|
||||
# core_load # core values only
|
||||
# core_load --kiosk door-panel # + that kiosk's own values
|
||||
#
|
||||
# WHY THIS EXISTS: before it, every builder carried its own copy of the container
|
||||
# host's IP, the MQTT port, the identity token and half a dozen URLs. Six scripts, six
|
||||
# chances to typo one of them, and the symptom was always the same — a kiosk that
|
||||
# boots fine and then can't reach something, discovered after a 40-minute ISO build.
|
||||
# Now no build script contains an address at all; they all read one file, and
|
||||
# validate-config.py has already refused the build if that file is wrong.
|
||||
#
|
||||
# Nothing here is exported to child processes on purpose: build scripts substitute
|
||||
# these into generated files explicitly, so it's always visible in the script which
|
||||
# value went where.
|
||||
|
||||
CORE_REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
CORE_CONFIG_PATH="${CORE_CONFIG_PATH:-${CORE_REPO_ROOT}/CoreSystemConfig.json}"
|
||||
CORE_TOOLS_DIR="${CORE_REPO_ROOT}/tools"
|
||||
|
||||
core_die() { echo -e "\033[1;31m[error]\033[0m $*" >&2; exit 1; }
|
||||
core_log() { echo -e "\n\033[1;34m==>\033[0m $*"; }
|
||||
core_warn() { echo -e "\033[1;33m[warn]\033[0m $*" >&2; }
|
||||
|
||||
core_load() {
|
||||
if [[ ! -f "$CORE_CONFIG_PATH" ]]; then
|
||||
core_die "No CoreSystemConfig.json found at $CORE_CONFIG_PATH
|
||||
|
||||
Create one from the template and fill it in:
|
||||
cp ${CORE_REPO_ROOT}/CoreSystemConfig.json.template ${CORE_REPO_ROOT}/CoreSystemConfig.json
|
||||
\$EDITOR ${CORE_REPO_ROOT}/CoreSystemConfig.json
|
||||
|
||||
Then check it with:
|
||||
${CORE_TOOLS_DIR}/validate-config.py
|
||||
|
||||
(Set CORE_CONFIG_PATH to build from a different file — useful for a second
|
||||
household or a test config.)"
|
||||
fi
|
||||
|
||||
command -v python3 >/dev/null 2>&1 || core_die "python3 is required to read CoreSystemConfig.json"
|
||||
|
||||
# VALIDATE BEFORE ANYTHING ELSE. An ISO build is long and mostly unattended; the
|
||||
# entire value of the validator evaporates if it runs after 40 minutes of debootstrap
|
||||
# rather than before it. Warnings print but don't stop the build.
|
||||
core_log "Validating $(basename "$CORE_CONFIG_PATH")"
|
||||
if ! python3 "${CORE_TOOLS_DIR}/validate-config.py" "$CORE_CONFIG_PATH"; then
|
||||
core_die "CoreSystemConfig.json has errors (above) — fix them before building.
|
||||
Nothing was built and nothing was written."
|
||||
fi
|
||||
|
||||
local exported
|
||||
if ! exported="$(python3 "${CORE_TOOLS_DIR}/config-export.py" "$CORE_CONFIG_PATH" "$@")"; then
|
||||
# config-export.py emits a shell `echo ... >&2; exit 1` on an unknown kiosk, so
|
||||
# evaluating its output is what surfaces that message.
|
||||
eval "$exported"
|
||||
core_die "Could not read $CORE_CONFIG_PATH"
|
||||
fi
|
||||
# Every value is shlex.quote'd on the Python side, so a password containing quotes,
|
||||
# spaces or semicolons survives this intact rather than becoming shell syntax.
|
||||
eval "$exported"
|
||||
}
|
||||
|
||||
# Load the one kiosk this builder is for.
|
||||
# core_select_kiosk door-panel # the only door-panel in the config
|
||||
# core_select_kiosk thin-client living # a specific one, when there are several
|
||||
#
|
||||
# Taking the hostname as an argument (rather than a per-script constant) is what lets
|
||||
# one builder produce several images: two thin clients in two rooms are two entries in
|
||||
# CoreSystemConfig.json, not two copies of a script with one line changed — which is
|
||||
# how the addresses drifted apart in the first place.
|
||||
core_select_kiosk() {
|
||||
local want_type="$1" want_host="${2:-}"
|
||||
local matches
|
||||
matches="$(python3 - "$CORE_CONFIG_PATH" "$want_type" <<'PY'
|
||||
import json, sys
|
||||
cfg = json.load(open(sys.argv[1]))
|
||||
print("\n".join(k["hostname"] for k in cfg.get("kiosks", []) if k.get("type") == sys.argv[2]))
|
||||
PY
|
||||
)"
|
||||
|
||||
if [[ -z "$matches" ]]; then
|
||||
core_die "No kiosk of type '${want_type}' in $(basename "$CORE_CONFIG_PATH").
|
||||
Add one to the \"kiosks\" list and re-run."
|
||||
fi
|
||||
|
||||
if [[ -z "$want_host" ]]; then
|
||||
if [[ "$(wc -l <<< "$matches")" -gt 1 ]]; then
|
||||
core_die "Several '${want_type}' kiosks are configured — say which one:
|
||||
$(sed 's/^/ /' <<< "$matches")
|
||||
|
||||
e.g. $0 $(head -1 <<< "$matches")"
|
||||
fi
|
||||
want_host="$matches"
|
||||
elif ! grep -qx "$want_host" <<< "$matches"; then
|
||||
core_die "'${want_host}' is not a configured ${want_type}. Available:
|
||||
$(sed 's/^/ /' <<< "$matches")"
|
||||
fi
|
||||
|
||||
core_load --kiosk "$want_host"
|
||||
}
|
||||
|
||||
# Same idea as core_select_kiosk, for the headless audio endpoints — selected by
|
||||
# architecture, since those are two genuinely different builders (live-build for the
|
||||
# amd64 mini-PC, rpi-image-gen for the arm64 Pi) rather than one with a flag.
|
||||
core_select_audio_endpoint() {
|
||||
local want_arch="$1" want_host="${2:-}"
|
||||
local matches
|
||||
matches="$(python3 - "$CORE_CONFIG_PATH" "$want_arch" <<'PY'
|
||||
import json, sys
|
||||
cfg = json.load(open(sys.argv[1]))
|
||||
print("\n".join(a["hostname"] for a in cfg.get("audio_endpoints", []) if a.get("arch") == sys.argv[2]))
|
||||
PY
|
||||
)"
|
||||
|
||||
if [[ -z "$matches" ]]; then
|
||||
core_die "No ${want_arch} audio endpoint in $(basename "$CORE_CONFIG_PATH").
|
||||
Add one to the \"audio_endpoints\" list and re-run."
|
||||
fi
|
||||
|
||||
if [[ -z "$want_host" ]]; then
|
||||
if [[ "$(wc -l <<< "$matches")" -gt 1 ]]; then
|
||||
core_die "Several ${want_arch} audio endpoints are configured — say which one:
|
||||
$(sed 's/^/ /' <<< "$matches")"
|
||||
fi
|
||||
want_host="$matches"
|
||||
elif ! grep -qx "$want_host" <<< "$matches"; then
|
||||
core_die "'${want_host}' is not a configured ${want_arch} audio endpoint. Available:
|
||||
$(sed 's/^/ /' <<< "$matches")"
|
||||
fi
|
||||
|
||||
core_load --audio-endpoint "$want_host"
|
||||
}
|
||||
|
||||
# Deterministic identifier for a matched set of images, printed at build time and
|
||||
# written into every image as /etc/smarthome-build. Two ISOs built from the same
|
||||
# config carry the same PAIR_ID; if you ever end up holding a container-host ISO and
|
||||
# an llm-host ISO and can't remember whether they agree on addresses and tokens,
|
||||
# compare this and you know.
|
||||
core_pair_id() {
|
||||
local hash
|
||||
hash="$(python3 - "$CORE_CONFIG_PATH" <<'PY'
|
||||
import hashlib, json, sys
|
||||
# Hash the SEMANTIC content, not the bytes: reformatting the JSON or reordering keys
|
||||
# must not change the pair ID, because it didn't change what gets built.
|
||||
cfg = json.loads(open(sys.argv[1]).read())
|
||||
def strip(node):
|
||||
if isinstance(node, dict):
|
||||
return {k: strip(v) for k, v in sorted(node.items()) if not k.startswith("_")}
|
||||
if isinstance(node, list):
|
||||
return [strip(v) for v in node]
|
||||
return node
|
||||
print(hashlib.sha256(json.dumps(strip(cfg), sort_keys=True).encode()).hexdigest()[:12])
|
||||
PY
|
||||
)"
|
||||
echo "$hash"
|
||||
}
|
||||
|
||||
# Written into every image so a booted machine can say what it was built from.
|
||||
core_write_build_stamp() {
|
||||
local dest="$1" role="$2"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
cat > "$dest" <<EOF
|
||||
# Generated by tools/ at image build time — see tools/README.md
|
||||
SMARTHOME_ROLE=${role}
|
||||
SMARTHOME_PAIR_ID=$(core_pair_id)
|
||||
SMARTHOME_BUILT_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
SMARTHOME_CONTAINER_HOST=${CORE_CONTAINER_HOST_IP}
|
||||
SMARTHOME_LLM_HOST=${CORE_LLM_HOST_IP}
|
||||
EOF
|
||||
}
|
||||
|
||||
# Guard for the ISO builders: `lb build` needs root, and finding that out after the
|
||||
# config phase has already written files is worse than finding out now.
|
||||
core_require_root() {
|
||||
[[ $EUID -eq 0 ]] || core_die "This needs root (lb build does). Re-run with: sudo -E $0 $*"
|
||||
}
|
||||
|
|
@ -55,19 +55,25 @@
|
|||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CONFIGURATION — edit these before running
|
||||
# CONFIGURATION
|
||||
#
|
||||
# Every value here is `${VAR:-default}`, so anything already in the environment wins.
|
||||
# That is how the ISO built by tools/build-container-host-iso.sh configures this
|
||||
# script without editing it: the first-boot unit sources a generated env file derived
|
||||
# from CoreSystemConfig.json, and these defaults apply only to a hand-run install.
|
||||
# Edit them directly ONLY if you are running this script standalone.
|
||||
# ---------------------------------------------------------------------------
|
||||
BASE_DIR="/opt/smart-home" # Where all container config/data will live
|
||||
TIMEZONE="Europe/Vienna" # Adjust to your timezone
|
||||
ENABLE_MEALIE="false" # Set to "true" to also deploy Mealie
|
||||
ENABLE_INTEL_HWACCEL="false" # Set to "true" if this host has an Intel iGPU for Frigate
|
||||
BASE_DIR="${BASE_DIR:-/opt/smart-home}" # Where all container config/data will live
|
||||
TIMEZONE="${TIMEZONE:-Europe/Vienna}" # Adjust to your timezone
|
||||
ENABLE_MEALIE="${ENABLE_MEALIE:-false}" # Set to "true" to also deploy Mealie
|
||||
ENABLE_INTEL_HWACCEL="${ENABLE_INTEL_HWACCEL:-false}" # Set to "true" if this host has an Intel iGPU for Frigate
|
||||
|
||||
# --- Phase 1/9 add-ons — on by default, set to "false" to skip any of them ---
|
||||
ENABLE_NODERED="true"
|
||||
ENABLE_NETDATA="true"
|
||||
ENABLE_HOMEPAGE="true"
|
||||
ENABLE_NTFY="true"
|
||||
ENABLE_PORTAINER="true"
|
||||
ENABLE_NODERED="${ENABLE_NODERED:-true}"
|
||||
ENABLE_NETDATA="${ENABLE_NETDATA:-true}"
|
||||
ENABLE_HOMEPAGE="${ENABLE_HOMEPAGE:-true}"
|
||||
ENABLE_NTFY="${ENABLE_NTFY:-true}"
|
||||
ENABLE_PORTAINER="${ENABLE_PORTAINER:-true}"
|
||||
|
||||
# --- Gallery SMB share — off by default until a password is chosen ----------
|
||||
# Serves $BASE_DIR/gallery read-only over SMB so idle thin clients cycle through photos
|
||||
|
|
@ -75,43 +81,43 @@ ENABLE_PORTAINER="true"
|
|||
# auto-generated: the identical value has to be typed into
|
||||
# /etc/thinclient-agent/gallery-credentials on every thin client, so a secret only this
|
||||
# script ever saw would be a secret the other end cannot have. Pick one yourself.
|
||||
ENABLE_GALLERY_SMB="false"
|
||||
GALLERY_SMB_USERNAME="gallery"
|
||||
GALLERY_SMB_PASSWORD="" # <-- SET THIS before flipping the toggle above
|
||||
ENABLE_GALLERY_SMB="${ENABLE_GALLERY_SMB:-false}"
|
||||
GALLERY_SMB_USERNAME="${GALLERY_SMB_USERNAME:-gallery}"
|
||||
GALLERY_SMB_PASSWORD="${GALLERY_SMB_PASSWORD:-}" # <-- SET THIS before flipping the toggle above
|
||||
|
||||
# --- Scheduled backups (restic) — off by default until you pick a target ---
|
||||
# Set ENABLE_BACKUPS=true and RESTIC_REPOSITORY to a local path (e.g. an
|
||||
# external/USB drive mount, or a NAS mount), or a remote target restic
|
||||
# supports (s3:..., sftp:..., b2:..., rest:...). See https://restic.net
|
||||
ENABLE_BACKUPS="false"
|
||||
RESTIC_REPOSITORY="/mnt/backup/smart-home-restic"
|
||||
BACKUP_SCHEDULE="03:30" # systemd OnCalendar time, daily at this local time
|
||||
ENABLE_BACKUPS="${ENABLE_BACKUPS:-false}"
|
||||
RESTIC_REPOSITORY="${RESTIC_REPOSITORY:-/mnt/backup/smart-home-restic}"
|
||||
BACKUP_SCHEDULE="${BACKUP_SCHEDULE:-03:30}" # systemd OnCalendar time, daily at this local time
|
||||
|
||||
# --- Zigbee adapter: USB (CC2652P/CH340C, e.g. Haozee/Sonoff Dongle-P style) ---
|
||||
# Run `ls -l /dev/serial/by-id/` AFTER plugging the adapter in, and paste the
|
||||
# full path it shows here. This is more stable across reboots than /dev/ttyUSB0.
|
||||
# Example: /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0
|
||||
ZIGBEE_USB_DEVICE="/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0"
|
||||
ZIGBEE_USB_DEVICE="${ZIGBEE_USB_DEVICE:-/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0}"
|
||||
|
||||
# --- Quarter-daily LLM digest (Phase 12) — off by default until credentials
|
||||
# --- are provisioned. See digest-engine/README.md.
|
||||
ENABLE_DIGEST_ENGINE="false"
|
||||
ENABLE_DIGEST_ENGINE="${ENABLE_DIGEST_ENGINE:-false}"
|
||||
# WhatsApp ingestion is separately gated and highest-risk of the four message
|
||||
# platforms. Read digest-engine/README.md before setting this to "true" — real
|
||||
# ban risk even with the headful-Chromium mitigation; use a secondary number.
|
||||
ENABLE_WHATSAPP_INGEST="false"
|
||||
ENABLE_WHATSAPP_INGEST="${ENABLE_WHATSAPP_INGEST:-false}"
|
||||
# Where this repo's digest-engine/ directory lives on THIS host (build context).
|
||||
DIGEST_ENGINE_SRC="/opt/smart-home/src/digest-engine"
|
||||
DIGEST_WEB_PORT="8091" # LAN-facing read-only static serving
|
||||
DIGEST_SCHEDULE="00,06,12,18" # systemd OnCalendar hours, 4x/day
|
||||
DIGEST_ENGINE_SRC="${DIGEST_ENGINE_SRC:-/opt/smart-home/src/digest-engine}"
|
||||
DIGEST_WEB_PORT="${DIGEST_WEB_PORT:-8091}" # LAN-facing read-only static serving
|
||||
DIGEST_SCHEDULE="${DIGEST_SCHEDULE:-00,06,12,18}" # systemd OnCalendar hours, 4x/day
|
||||
|
||||
# --- On-demand sys-admin-llm display surface (Phase 13) — off by default until
|
||||
# --- ADMIN_CANVAS_TOKEN is provisioned. See admin-canvas/README.md.
|
||||
ENABLE_ADMIN_CANVAS="false"
|
||||
ENABLE_ADMIN_CANVAS="${ENABLE_ADMIN_CANVAS:-false}"
|
||||
# Where this repo's admin-canvas/ directory lives on THIS host (build context).
|
||||
ADMIN_CANVAS_SRC="/opt/smart-home/src/admin-canvas"
|
||||
ADMIN_CANVAS_PORT="8092" # internal only — no `ports:` mapping, HA-reachable only
|
||||
ADMIN_WEB_PORT="8094" # LAN-facing read-only static serving
|
||||
ADMIN_CANVAS_SRC="${ADMIN_CANVAS_SRC:-/opt/smart-home/src/admin-canvas}"
|
||||
ADMIN_CANVAS_PORT="${ADMIN_CANVAS_PORT:-8092}" # internal only — no `ports:` mapping, HA-reachable only
|
||||
ADMIN_WEB_PORT="${ADMIN_WEB_PORT:-8094}" # LAN-facing read-only static serving
|
||||
|
||||
# --- Kitchen-display camera cataloguing backend (Phase 17) — off by default until
|
||||
# --- PANTRY_VISION_TOKEN and GROCY_API_KEY are provisioned. See pantry-vision/README.md.
|
||||
|
|
@ -120,38 +126,38 @@ ADMIN_WEB_PORT="8094" # LAN-facing read-only static serving
|
|||
# deliberately unlike ADMIN_CANVAS_PORT above, it DOES get a `ports:` mapping. The
|
||||
# bearer token is the actual boundary here, not network placement — see
|
||||
# pantry-vision/README.md's "A real network listener, unlike admin-canvas" section.
|
||||
ENABLE_PANTRY_VISION="false"
|
||||
ENABLE_PANTRY_VISION="${ENABLE_PANTRY_VISION:-false}"
|
||||
# Where this repo's pantry-vision/ directory lives on THIS host (build context).
|
||||
PANTRY_VISION_SRC="/opt/smart-home/src/pantry-vision"
|
||||
PANTRY_VISION_PORT="8095" # LAN-facing — the kitchen display's kiosk browser calls this directly
|
||||
PANTRY_WEB_PORT="8096" # LAN-facing read-only static serving (the kiosk's frontend)
|
||||
PANTRY_VISION_SRC="${PANTRY_VISION_SRC:-/opt/smart-home/src/pantry-vision}"
|
||||
PANTRY_VISION_PORT="${PANTRY_VISION_PORT:-8095}" # LAN-facing — the kitchen display's kiosk browser calls this directly
|
||||
PANTRY_WEB_PORT="${PANTRY_WEB_PORT:-8096}" # LAN-facing read-only static serving (the kiosk's frontend)
|
||||
|
||||
# --- Person <-> BLE-identifier registry (Phase 6) — off by default until
|
||||
# --- IDENTITY_TOKEN, HA_TOKEN, and TRUSTED_ENTITY_PREFIXES are provisioned. See
|
||||
# --- identity/README.md. Same "published, unlike admin-canvas" reasoning as
|
||||
# --- ENABLE_PANTRY_VISION above — hosts/kitchen-display's and hosts/door-panel's
|
||||
# --- kiosk browsers call this directly.
|
||||
ENABLE_IDENTITY="false"
|
||||
ENABLE_IDENTITY="${ENABLE_IDENTITY:-false}"
|
||||
# Where this repo's identity/ directory lives on THIS host (build context).
|
||||
IDENTITY_SRC="/opt/smart-home/src/identity"
|
||||
IDENTITY_PORT="8097" # LAN-facing — kiosk browsers call this directly
|
||||
IDENTITY_WEB_PORT="8098" # LAN-facing read-only static serving (register.html/dashboard.html)
|
||||
IDENTITY_SRC="${IDENTITY_SRC:-/opt/smart-home/src/identity}"
|
||||
IDENTITY_PORT="${IDENTITY_PORT:-8097}" # LAN-facing — kiosk browsers call this directly
|
||||
IDENTITY_WEB_PORT="${IDENTITY_WEB_PORT:-8098}" # LAN-facing read-only static serving (register.html/dashboard.html)
|
||||
|
||||
# --- Trash collection date sync (Phase 19) — off by default until WASTE_ICS_URL and
|
||||
# --- CALDAV_TARGET_CALENDAR are provisioned. See trash-calendar/README.md. A oneshot,
|
||||
# --- like digest-engine, not a listener — no port, nothing to publish.
|
||||
ENABLE_TRASH_CALENDAR="false"
|
||||
ENABLE_TRASH_CALENDAR="${ENABLE_TRASH_CALENDAR:-false}"
|
||||
# Where this repo's trash-calendar/ directory lives on THIS host (build context).
|
||||
TRASH_CALENDAR_SRC="/opt/smart-home/src/trash-calendar"
|
||||
TRASH_CALENDAR_SRC="${TRASH_CALENDAR_SRC:-/opt/smart-home/src/trash-calendar}"
|
||||
|
||||
# --- Public transit "when's the next bus" voice lookup (Phase 19) — off by default
|
||||
# --- until TRANSIT_TOKEN and GTFS_FEED_URL are provisioned. See transit/README.md.
|
||||
# --- Published like pantry-vision/identity — homeassistant's network_mode: host
|
||||
# --- can't resolve container DNS names, so its rest_command needs a real port.
|
||||
ENABLE_TRANSIT="false"
|
||||
ENABLE_TRANSIT="${ENABLE_TRANSIT:-false}"
|
||||
# Where this repo's transit/ directory lives on THIS host (build context).
|
||||
TRANSIT_SRC="/opt/smart-home/src/transit"
|
||||
TRANSIT_PORT="8099" # reachable by HA's rest_command (voice lookups)
|
||||
TRANSIT_SRC="${TRANSIT_SRC:-/opt/smart-home/src/transit}"
|
||||
TRANSIT_PORT="${TRANSIT_PORT:-8099}" # reachable by HA's rest_command (voice lookups)
|
||||
|
||||
# --- On-demand route planning (Phase 19) — a SEPARATE opt-in from ENABLE_TRANSIT
|
||||
# --- above on purpose: a real OpenTripPlanner graph (OSM + GTFS) is a meaningfully
|
||||
|
|
@ -160,22 +166,22 @@ TRANSIT_PORT="8099" # reachable by HA's rest_command (voice looku
|
|||
# --- "Austria-wide" is a moderate commitment, "global" is a real infrastructure
|
||||
# --- decision, not a flag. This script does NOT build the OTP graph for you — that's
|
||||
# --- a manual, one-time (per OSM/GTFS update) step; see OpenTripPlanner's own docs.
|
||||
ENABLE_TRIP_PLANNING="false"
|
||||
OTP_GRAPHS_DIR="/opt/smart-home/otp-graphs" # you populate this by hand, see above
|
||||
ENABLE_TRIP_PLANNING="${ENABLE_TRIP_PLANNING:-false}"
|
||||
OTP_GRAPHS_DIR="${OTP_GRAPHS_DIR:-/opt/smart-home/otp-graphs}" # you populate this by hand, see above
|
||||
# Host-side published port for OTP's own web/GraphQL API. NOT 8080 — zigbee2mqtt's
|
||||
# frontend (always-on, below) already publishes 8080:8080; OTP's own container-
|
||||
# internal port stays 8080 regardless (transit.env.example's OTP_URL correctly
|
||||
# reaches it via container DNS as http://otp:8080), only the host-side mapping
|
||||
# needed to move to avoid the two colliding on the same host.
|
||||
OTP_PORT="8100"
|
||||
OTP_PORT="${OTP_PORT:-8100}"
|
||||
|
||||
# --- Household chore distribution + reminders + camera verification (Phase 20) —
|
||||
# --- off by default. Works with just ENABLE_IDENTITY on (assignment) and ntfy
|
||||
# --- (reminders); the camera-check and trash-day-eve steps each individually
|
||||
# --- no-op until their own env vars are set — see chores/README.md.
|
||||
ENABLE_CHORES="false"
|
||||
ENABLE_CHORES="${ENABLE_CHORES:-false}"
|
||||
# Where this repo's chores/ directory lives on THIS host (build context).
|
||||
CHORES_SRC="/opt/smart-home/src/chores"
|
||||
CHORES_SRC="${CHORES_SRC:-/opt/smart-home/src/chores}"
|
||||
|
||||
# --- Music Assistant (optional, additive — docs/project-plan.md §2) ---------
|
||||
# Unifies Spotify Connect + other sources behind one HA-native multi-room player.
|
||||
|
|
@ -195,7 +201,7 @@ CHORES_SRC="/opt/smart-home/src/chores"
|
|||
# Check Music Assistant's own docs/config for how to change its listen port
|
||||
# BEFORE flipping this on if ENABLE_PANTRY_VISION is also true — the sanity check
|
||||
# below only warns, it does not change either port for you.
|
||||
ENABLE_MUSIC_ASSISTANT="false"
|
||||
ENABLE_MUSIC_ASSISTANT="${ENABLE_MUSIC_ASSISTANT:-false}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sanity checks
|
||||
|
|
@ -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"
|
||||
|
|
@ -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" <<EOF
|
||||
# Generated by hosts/llm-host/scripts/setup-llm-host.sh — re-running the script
|
||||
# Generated by tools/setup-llm-host.sh — re-running the script
|
||||
# regenerates this file. Tier: ${tier}
|
||||
services:
|
||||
ollama:
|
||||
|
|
@ -0,0 +1,403 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate CoreSystemConfig.json — the gate every builder in tools/ runs first.
|
||||
|
||||
THE POINT OF THIS FILE: before CoreSystemConfig.json existed, an IP lived in six build
|
||||
scripts and a token in three, so "the door panel can't reach identity" could mean a
|
||||
typo in any one of them, discovered only after a 40-minute ISO build and a reboot.
|
||||
Centralising the values removes the duplication; this script removes the rest — it
|
||||
fails the build at second zero for anything it can prove wrong on paper.
|
||||
|
||||
Deliberately stdlib-only and dependency-free (no jsonschema): the whole point is that
|
||||
a fresh checkout can validate a config before anything is installed, on a machine
|
||||
where `pip install` may not even be available yet.
|
||||
|
||||
Exit codes: 0 = valid (warnings may still print) 1 = errors found 2 = unusable file
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
|
||||
KIOSK_TYPES = {"thin-client", "touch-panel", "door-panel", "kitchen-display"}
|
||||
TIERS = {"auto", "gpu", "cpu"}
|
||||
ARCHITECTURES = {"amd64", "arm64"}
|
||||
|
||||
# Tokens the validator insists on, and the enable-flag that makes each one required.
|
||||
# A token for a disabled service is not an error — you shouldn't have to invent a
|
||||
# transit token to build a door panel.
|
||||
TOKEN_REQUIREMENTS = [
|
||||
("identity_token", "identity", "identity, the door panel and the kitchen display"),
|
||||
("pantry_vision_token", "pantry_vision", "pantry-vision and the kitchen display"),
|
||||
("transit_token", "transit", "transit"),
|
||||
]
|
||||
|
||||
MIN_TOKEN_LEN = 32
|
||||
|
||||
# Substrings that mean "nobody actually generated this". Matched anywhere in the value,
|
||||
# case-insensitively, so padding a placeholder out to 32 characters doesn't sneak past.
|
||||
PLACEHOLDER_MARKERS = (
|
||||
"changeme", "change-me", "your-", "yourtoken", "placeholder", "example",
|
||||
"xxxxx", "secret", "password", "todo", "fixme", "insert",
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_placeholder(value: str) -> bool:
|
||||
lowered = value.lower()
|
||||
if any(marker in lowered for marker in PLACEHOLDER_MARKERS):
|
||||
return True
|
||||
# A "token" made of one repeated character (aaaa..., 0000...) is padding, not entropy.
|
||||
return len(set(value)) <= 2 and len(value) > 4
|
||||
|
||||
|
||||
class Report:
|
||||
def __init__(self) -> None:
|
||||
self.errors: list[str] = []
|
||||
self.warnings: list[str] = []
|
||||
|
||||
def error(self, where: str, msg: str) -> None:
|
||||
self.errors.append(f"{where}: {msg}")
|
||||
|
||||
def warn(self, where: str, msg: str) -> None:
|
||||
self.warnings.append(f"{where}: {msg}")
|
||||
|
||||
|
||||
def _get(cfg: dict, path: str, default: Any = None) -> Any:
|
||||
node = cfg
|
||||
for part in path.split("."):
|
||||
if not isinstance(node, dict) or part not in node:
|
||||
return default
|
||||
node = node[part]
|
||||
return node
|
||||
|
||||
|
||||
def _require(cfg: dict, path: str, kind, rep: Report, where: str | None = None) -> Any:
|
||||
"""Fetch a required key, reporting a precise path rather than a KeyError."""
|
||||
value = _get(cfg, path, None)
|
||||
label = where or path
|
||||
if value is None:
|
||||
rep.error(label, "missing (required)")
|
||||
return None
|
||||
if kind is not None and not isinstance(value, kind):
|
||||
rep.error(label, f"must be {getattr(kind, '__name__', kind)}, got {type(value).__name__}")
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def validate_household(cfg: dict, rep: Report) -> None:
|
||||
tz = _require(cfg, "household.timezone", str, rep)
|
||||
if tz:
|
||||
# Checked against the zoneinfo database actually present, not a regex — a
|
||||
# plausible-looking but wrong timezone ("Europe/Wien") silently gives every
|
||||
# image the wrong clock, and every timestamped log with it.
|
||||
if not Path(f"/usr/share/zoneinfo/{tz}").exists():
|
||||
rep.warn("household.timezone",
|
||||
f"'{tz}' isn't in this machine's /usr/share/zoneinfo — verify it exists on the target")
|
||||
_require(cfg, "household.locale", str, rep)
|
||||
layout = _require(cfg, "household.keyboard_layout", str, rep)
|
||||
if layout and not re.match(r"^[a-z]{2,6}$", layout):
|
||||
rep.error("household.keyboard_layout", f"'{layout}' doesn't look like an xkb layout (e.g. 'de', 'us')")
|
||||
_require(cfg, "household.debian_release", str, rep)
|
||||
|
||||
|
||||
def validate_network(cfg: dict, rep: Report) -> None:
|
||||
prefix = _require(cfg, "network.subnet_prefix", str, rep)
|
||||
if not prefix:
|
||||
return
|
||||
if prefix.endswith("."):
|
||||
rep.error("network.subnet_prefix", f"'{prefix}' must not end with a dot")
|
||||
return
|
||||
try:
|
||||
network = ipaddress.ip_network(f"{prefix}.0/24", strict=True)
|
||||
except ValueError as exc:
|
||||
rep.error("network.subnet_prefix", f"'{prefix}' is not a valid /24 prefix ({exc})")
|
||||
return
|
||||
|
||||
if not network.is_private:
|
||||
rep.warn("network.subnet_prefix",
|
||||
f"{network} is not a private range — this stack is designed to be LAN-only "
|
||||
"(docs/network-integration.md §1)")
|
||||
# 192.168.0.x and 192.168.1.x are what nearly every café, hotel and consumer router
|
||||
# uses, so a WireGuard split tunnel routing this VLAN would collide with them —
|
||||
# docs/network-integration.md §2.1 spells this out.
|
||||
if prefix in ("192.168.0", "192.168.1"):
|
||||
rep.warn("network.subnet_prefix",
|
||||
f"'{prefix}' collides with typical café/hotel LANs; a WireGuard split tunnel "
|
||||
"routing it will break connectivity on those networks (network-integration.md §2.1)")
|
||||
|
||||
gw = _get(cfg, "network.gateway_last_octet")
|
||||
if not isinstance(gw, int) or not 1 <= gw <= 254:
|
||||
rep.error("network.gateway_last_octet", f"must be an int 1-254, got {gw!r}")
|
||||
|
||||
dns = _get(cfg, "network.dns_servers")
|
||||
if not isinstance(dns, list) or not dns:
|
||||
rep.error("network.dns_servers", "must be a non-empty list of IP addresses")
|
||||
else:
|
||||
for entry in dns:
|
||||
try:
|
||||
ipaddress.ip_address(entry)
|
||||
except ValueError:
|
||||
rep.error("network.dns_servers", f"'{entry}' is not a valid IP address")
|
||||
|
||||
ssid = _get(cfg, "network.wifi.ssid", "")
|
||||
psk = _get(cfg, "network.wifi.psk", "")
|
||||
if ssid and not psk:
|
||||
rep.error("network.wifi.psk", "an SSID is set but the PSK is empty")
|
||||
if psk and not ssid:
|
||||
rep.error("network.wifi.ssid", "a PSK is set but the SSID is empty")
|
||||
if psk and len(psk) < 8:
|
||||
rep.error("network.wifi.psk", "WPA2 pre-shared keys are at least 8 characters")
|
||||
|
||||
|
||||
def validate_hosts(cfg: dict, rep: Report) -> None:
|
||||
"""The twinning check. The container host and the LLM host derive each other's
|
||||
addresses from these two numbers, so the one thing that must hold is that they are
|
||||
two distinct, valid, same-subnet addresses — get that right and no builder can
|
||||
produce a mismatched pair.
|
||||
"""
|
||||
octets: dict[str, int] = {}
|
||||
for host in ("container_host", "llm_host"):
|
||||
hostname = _require(cfg, f"{host}.hostname", str, rep)
|
||||
if hostname and not HOSTNAME_RE.match(hostname):
|
||||
rep.error(f"{host}.hostname",
|
||||
f"'{hostname}' is not a valid DNS label (lowercase letters, digits, hyphens; "
|
||||
"no leading/trailing hyphen)")
|
||||
octet = _get(cfg, f"{host}.ip_last_octet")
|
||||
if not isinstance(octet, int) or not 1 <= octet <= 254:
|
||||
rep.error(f"{host}.ip_last_octet", f"must be an int 1-254, got {octet!r}")
|
||||
else:
|
||||
octets[host] = octet
|
||||
disk = _require(cfg, f"{host}.install_disk", str, rep)
|
||||
if disk and not disk.startswith("/dev/"):
|
||||
rep.error(f"{host}.install_disk", f"'{disk}' should be a device path like /dev/sda or /dev/nvme0n1")
|
||||
_require(cfg, f"{host}.admin_username", str, rep)
|
||||
|
||||
if len(octets) == 2:
|
||||
if octets["container_host"] == octets["llm_host"]:
|
||||
rep.error("llm_host.ip_last_octet",
|
||||
f"the container host and LLM host both want .{octets['llm_host']} — "
|
||||
"they are two separate machines and need two addresses")
|
||||
gw = _get(cfg, "network.gateway_last_octet")
|
||||
for host, octet in octets.items():
|
||||
if octet == gw:
|
||||
rep.error(f"{host}.ip_last_octet", f".{octet} is the gateway address")
|
||||
|
||||
tier = _get(cfg, "llm_host.tier")
|
||||
if tier not in TIERS:
|
||||
rep.error("llm_host.tier", f"must be one of {sorted(TIERS)}, got {tier!r}")
|
||||
for key in ("text_model_gpu", "text_model_cpu", "vision_model"):
|
||||
_require(cfg, f"llm_host.{key}", str, rep)
|
||||
for key in ("max_loaded_models", "num_parallel"):
|
||||
value = _get(cfg, f"llm_host.{key}")
|
||||
if not isinstance(value, int) or value < 1:
|
||||
rep.error(f"llm_host.{key}", f"must be a positive int, got {value!r}")
|
||||
keep_alive = _get(cfg, "llm_host.keep_alive")
|
||||
if not isinstance(keep_alive, str) or not re.match(r"^\d+[smh]$|^-1$", str(keep_alive)):
|
||||
rep.error("llm_host.keep_alive", f"must look like '30m', '2h', '600s' or '-1', got {keep_alive!r}")
|
||||
|
||||
|
||||
def validate_ports(cfg: dict, rep: Report) -> None:
|
||||
"""Port uniqueness is the single highest-value check here: a duplicate produces a
|
||||
container that silently fails to bind, or two services fighting over one port with
|
||||
whichever won last boot answering. Both are miserable to diagnose from the symptom.
|
||||
"""
|
||||
ports = _get(cfg, "ports")
|
||||
if not isinstance(ports, dict):
|
||||
rep.error("ports", "missing or not an object")
|
||||
return
|
||||
|
||||
seen: dict[int, list[str]] = {}
|
||||
for name, value in ports.items():
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
if not isinstance(value, int) or not 1 <= value <= 65535:
|
||||
rep.error(f"ports.{name}", f"must be an int 1-65535, got {value!r}")
|
||||
continue
|
||||
if value < 1024:
|
||||
rep.warn(f"ports.{name}", f"{value} is a privileged port (<1024)")
|
||||
seen.setdefault(value, []).append(name)
|
||||
|
||||
for value, names in sorted(seen.items()):
|
||||
if len(names) > 1:
|
||||
rep.error("ports", f"port {value} is claimed by {len(names)} services: {', '.join(sorted(names))}")
|
||||
|
||||
|
||||
def validate_secrets(cfg: dict, rep: Report) -> None:
|
||||
enable = _get(cfg, "container_host.enable", {}) or {}
|
||||
for token_name, flag, used_by in TOKEN_REQUIREMENTS:
|
||||
value = _get(cfg, f"secrets.{token_name}", "") or ""
|
||||
if not enable.get(flag):
|
||||
if value:
|
||||
rep.warn(f"secrets.{token_name}",
|
||||
f"set, but container_host.enable.{flag} is false — it won't be used")
|
||||
continue
|
||||
if not value:
|
||||
rep.error(f"secrets.{token_name}",
|
||||
f"required because container_host.enable.{flag} is true (used by {used_by}). "
|
||||
"Generate one with: openssl rand -hex 32")
|
||||
# Placeholder BEFORE length: a long placeholder ("changeme-changeme-changeme-...")
|
||||
# would otherwise sail past the length check, and a short one would be reported
|
||||
# as merely too short — which invites someone to pad it rather than generate one.
|
||||
elif _looks_like_placeholder(value):
|
||||
rep.error(f"secrets.{token_name}",
|
||||
f"'{value[:24]}' is a placeholder, not a generated secret. "
|
||||
"Generate one with: openssl rand -hex 32")
|
||||
elif len(value) < MIN_TOKEN_LEN:
|
||||
rep.error(f"secrets.{token_name}",
|
||||
f"only {len(value)} characters; use at least {MIN_TOKEN_LEN} "
|
||||
"(openssl rand -hex 32)")
|
||||
|
||||
mqtt_user = _get(cfg, "secrets.mqtt_username", "") or ""
|
||||
mqtt_pass = _get(cfg, "secrets.mqtt_password", "") or ""
|
||||
if mqtt_user and not mqtt_pass:
|
||||
rep.error("secrets.mqtt_password", "an MQTT username is set but the password is empty")
|
||||
if not mqtt_user:
|
||||
rep.warn("secrets.mqtt_username",
|
||||
"empty — every kiosk will connect to Mosquitto anonymously. Fine while "
|
||||
"allow_anonymous is on; revisit before that changes")
|
||||
|
||||
if not (_get(cfg, "secrets.ha_token", "") or ""):
|
||||
rep.warn("secrets.ha_token",
|
||||
"empty — identity's /register and /presence can't reach Home Assistant until "
|
||||
"this is a real Long-Lived Access Token. It cannot be generated ahead of time; "
|
||||
"fill it in and re-run the builder once HA is up")
|
||||
|
||||
key = _get(cfg, "secrets.ssh_authorized_key", "") or ""
|
||||
if key and not re.match(r"^(ssh-(rsa|ed25519|dss)|ecdsa-sha2-\S+) \S+", key):
|
||||
rep.error("secrets.ssh_authorized_key",
|
||||
"doesn't look like an OpenSSH public key (should start 'ssh-ed25519 AAAA...')")
|
||||
if key and "PRIVATE KEY" in key:
|
||||
rep.error("secrets.ssh_authorized_key", "this is a PRIVATE key — put the .pub here instead")
|
||||
if not key:
|
||||
rep.warn("secrets.ssh_authorized_key",
|
||||
"empty — the built images will have no way in over SSH. Fine for a kiosk you "
|
||||
"only ever touch physically, painful for a headless host")
|
||||
|
||||
|
||||
def validate_kiosks(cfg: dict, rep: Report) -> None:
|
||||
kiosks = _get(cfg, "kiosks")
|
||||
if kiosks is None:
|
||||
rep.warn("kiosks", "no kiosks defined — only the core pair will be buildable")
|
||||
return
|
||||
if not isinstance(kiosks, list):
|
||||
rep.error("kiosks", "must be a list")
|
||||
return
|
||||
|
||||
hostnames: dict[str, int] = {}
|
||||
for index, kiosk in enumerate(kiosks):
|
||||
where = f"kiosks[{index}]"
|
||||
if not isinstance(kiosk, dict):
|
||||
rep.error(where, "must be an object")
|
||||
continue
|
||||
ktype = kiosk.get("type")
|
||||
if ktype not in KIOSK_TYPES:
|
||||
rep.error(f"{where}.type", f"must be one of {sorted(KIOSK_TYPES)}, got {ktype!r}")
|
||||
hostname = kiosk.get("hostname")
|
||||
if not isinstance(hostname, str) or not HOSTNAME_RE.match(hostname or ""):
|
||||
rep.error(f"{where}.hostname", f"{hostname!r} is not a valid DNS label")
|
||||
else:
|
||||
if hostname in hostnames:
|
||||
rep.error(f"{where}.hostname",
|
||||
f"'{hostname}' is already used by kiosks[{hostnames[hostname]}] — "
|
||||
"hostnames identify these devices on the network and in HA, so they must be unique")
|
||||
hostnames[hostname] = index
|
||||
if not kiosk.get("friendly_name"):
|
||||
rep.error(f"{where}.friendly_name", "missing — this is the name shown on the HA device")
|
||||
if not kiosk.get("kiosk_username"):
|
||||
rep.error(f"{where}.kiosk_username", "missing")
|
||||
|
||||
# Audio endpoints share the hostname namespace with kiosks: they are all devices on
|
||||
# one network, and two of anything answering to the same name is the same problem
|
||||
# regardless of what kind of device they are.
|
||||
for index, endpoint in enumerate(_get(cfg, "audio_endpoints", []) or []):
|
||||
where = f"audio_endpoints[{index}]"
|
||||
if not isinstance(endpoint, dict):
|
||||
rep.error(where, "must be an object")
|
||||
continue
|
||||
hostname = endpoint.get("hostname")
|
||||
if not isinstance(hostname, str) or not HOSTNAME_RE.match(hostname or ""):
|
||||
rep.error(f"{where}.hostname", f"{hostname!r} is not a valid DNS label")
|
||||
elif hostname in hostnames:
|
||||
rep.error(f"{where}.hostname",
|
||||
f"'{hostname}' is already used by kiosks[{hostnames[hostname]}] — "
|
||||
"kiosks and audio endpoints share one hostname namespace")
|
||||
else:
|
||||
hostnames[hostname] = index
|
||||
if endpoint.get("arch") not in ARCHITECTURES:
|
||||
rep.error(f"{where}.arch",
|
||||
f"must be one of {sorted(ARCHITECTURES)}, got {endpoint.get('arch')!r}")
|
||||
if not endpoint.get("friendly_name"):
|
||||
rep.error(f"{where}.friendly_name", "missing")
|
||||
|
||||
# Cross-check: a kiosk that talks to a disabled service will build fine and then
|
||||
# fail at runtime with a connection error, which is exactly the class of "works on
|
||||
# paper" mistake this file exists to catch.
|
||||
enable = _get(cfg, "container_host.enable", {}) or {}
|
||||
needs = {
|
||||
"door-panel": [("identity", "identity"), ("pantry_vision", "pantry-vision")],
|
||||
"kitchen-display": [("identity", "identity"), ("pantry_vision", "pantry-vision")],
|
||||
}
|
||||
for index, kiosk in enumerate(kiosks):
|
||||
if not isinstance(kiosk, dict):
|
||||
continue
|
||||
for flag, label in needs.get(kiosk.get("type", ""), []):
|
||||
if not enable.get(flag):
|
||||
rep.error(f"kiosks[{index}]",
|
||||
f"a {kiosk.get('type')} needs {label}, but container_host.enable.{flag} is false")
|
||||
|
||||
|
||||
def validate(cfg: dict) -> Report:
|
||||
rep = Report()
|
||||
validate_network(cfg, rep)
|
||||
validate_household(cfg, rep)
|
||||
validate_hosts(cfg, rep)
|
||||
validate_ports(cfg, rep)
|
||||
validate_secrets(cfg, rep)
|
||||
validate_kiosks(cfg, rep)
|
||||
return rep
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
path = Path(argv[1]) if len(argv) > 1 else Path(__file__).resolve().parent.parent / "CoreSystemConfig.json"
|
||||
|
||||
if not path.exists():
|
||||
template = path.parent / "CoreSystemConfig.json.template"
|
||||
print(f"error: {path} not found.", file=sys.stderr)
|
||||
if template.exists():
|
||||
print(f" Copy the template and fill it in:\n"
|
||||
f" cp {template.name} {path.name}\n"
|
||||
f" $EDITOR {path.name}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
cfg = json.loads(path.read_text())
|
||||
except json.JSONDecodeError as exc:
|
||||
# Line and column, because a trailing comma in a 200-line JSON file is otherwise
|
||||
# a genuinely annoying thing to find.
|
||||
print(f"error: {path} is not valid JSON — line {exc.lineno}, column {exc.colno}: {exc.msg}",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
rep = validate(cfg)
|
||||
|
||||
for warning in rep.warnings:
|
||||
print(f" warn {warning}")
|
||||
for error in rep.errors:
|
||||
print(f" ERROR {error}", file=sys.stderr)
|
||||
|
||||
if rep.errors:
|
||||
print(f"\n{len(rep.errors)} error(s), {len(rep.warnings)} warning(s) — {path.name} is not usable yet.",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"\n{path.name} is valid ({len(rep.warnings)} warning(s)).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
Loading…
Reference in New Issue