The headphones have no microphone, so the follow-me switch needs somewhere to
switch TO. The Loggia has no mic you can plug into the desktop, but it does
have a machine with one — the touch panel already in that room.
remote-mic.sh makes that panel's microphone appear on the desktop as an
ordinary source:
panel: pw-record ──ssh──> desktop: pw-cat into a null sink
└─ module-remap-source ─> micfollow_<room>
SSH RATHER THAN AN AUDIO PROTOCOL. RTP and PipeWire's pulse-tunnel are both
lower latency, and both want a new listening service on the panel, an ACL, and
config on two machines that has to agree. The panel already runs sshd as its
documented admin path and already trusts this desktop's key, so the transport
arrives with authentication and encryption already solved and nothing new
listening on the network. For a smoke-break voice call, 40ms of buffering is
not the constraint — a hot microphone in an empty room is. If the latency ever
matters, the null-sink half stays and only the transport changes.
And audio exists ONLY while the stream runs: no daemon, one SSH session,
`stop` closes it. That is a property of the transport rather than a promise in
a config file, which is the reason to prefer it.
A null sink plus module-remap-source, not the sink's monitor: a monitor is not
a real source, applications treat it as "record what the desktop is playing",
and this component's own audio layer refuses to select one on purpose.
The panel says so, in the room. hosts/touch-panel/ gains mic-in-use and a red
MIC LIVE badge on the dock, lit whenever ANYTHING is capturing that panel's
microphone — this feature or not. A room mic somebody elsewhere can open has to
be visible to the person standing in front of it; Home Assistant knowing is not
the same as them knowing. It reads PipeWire's actual capture streams rather
than trusting who asked, ignores monitor streams, and without jq it falls back
to over-reporting, which is the right direction to be wrong in for a warning
light. pipewire-bin and pulseaudio-utils join that image for it.
Fixed while building it: the agent looked for a source BEFORE running the start
hook, so a remote microphone — which does not exist as a PipeWire node until
its stream is up — could never be selected. It fell back to the desk every
time, correct by the letter of the code and useless in practice. Hooks now run
first, the source is waited for (source_timeout_seconds, default 6), and a
failed start has its stop hook run before falling back rather than leaving a
half-started stream behind.
Configuration is a `remote` block on the source instead of a source pattern;
the generator writes both hooks and the transport's parameters, because "stop
the stream" is a safety property and not something to rely on somebody having
typed correctly. Tests pin exactly that: every remote source produces both
hooks, a local one is left alone, and mic-in-use's monitor exclusion is checked
against fixtures. 24 tests now.
The one command that decides whether any of this works, and it needs the real
machines: ssh <user>@<host> pw-record --help
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7yp4NcmX4zqja1MKRjeWJ
A voice call that survives a smoke break. The person leaves their desk, the
locator says which room they are in, their machine's live microphone switches
to one that can hear them there, and back to the studio mic when they sit down
again. Written per CLIENT, so a second person with a second desktop is one
more entry in CoreSystemConfig.json and nothing else changes.
ONE RULE MAKES IT PREDICTABLE: off means the desk mic. The Follow-me switch
being off is not "ignore me", it is an active guarantee that the machine is on
its own microphone — reachable from the dock, the watch or MQTT, honoured when
the agent shuts down, and independent of whether presence is right or the
remote machine is up. Being live on the wrong mic is the failure you notice in
front of five other people, so the safe state has to be the cheap one.
Home Assistant decides where the person is; the desktop agent only knows how
to change the input. That split is the same one every other agent here uses —
the inbound control surface is MQTT discovery entities and nothing else, and
no presence logic runs on a desktop.
The agent sets the default source AND moves the already-running capture
streams of the configured applications. Anyone who owns a studio mic has
picked it explicitly in Discord, and an explicitly-picked device does not
follow the default; without the second half the switch would appear to do
nothing in the one application it exists for.
Three surfaces, one entity to read. sensor.mic_follow_<node>_status has as its
STATE the name of the microphone that is live right now — "Desk", "Loggia" —
so nothing has to reimplement the same three-way template:
Stream Dock a key showing that sensor, calling switch.toggle. The HA plugin
subscribes to the websocket, so it updates on state change
rather than on a timer, including when the watch moved it.
Leave the key title empty: "Loggia" in large type is readable
across a room, "Follow-me mic" over a small "Loggia" is not.
Pebble a new toggles screen, long-press Select on the plan. The live
microphone in large type, FOLLOWING / DESK ONLY as a coloured
pill, Select flips it, Up/Down cycles clients.
HA the switch and the select, like anything else.
The watch reaches HA through a new allowlist in identity (/toggles), not
directly: identity already holds an HA token and the phone already holds
identity's, so one button on a wrist does not put an HA admin token into a
watchapp's settings. Only switch.* entities that are named in
TOGGLE_ALLOWLIST_JSON, only on/off/toggle, anything else is a 404 — and each
entry may name a detail_entity whose state is served alongside, which is how
the watch displays the live microphone instead of deriving it. Every response
re-reads the state rather than assuming it: "I sent the command" is not the
same fact as "the switch is on".
start_command/stop_command on a source are the hook for a microphone that is
not simply plugged into the machine. The STOP hook is the important one: a room
microphone still streaming after the switch left it is a hot mic in somebody's
flat. It runs on every transition away and on shutdown, and the validator warns
about a start with no stop.
Tested, and it runs anywhere: 17 fixture cases over source selection — a
monitor source can never be selected (picking one transmits what the desktop is
PLAYING, the worst outcome available), exact names beat substrings, an
ambiguous pattern resolves the same way after a reboot instead of coin-flipping,
and only the configured applications' streams move. The new watch message is
round-tripped through the real JS packer and the real C parser, including the
two cases that decode as plausible garbage otherwise: a value containing the
field separator, and a record truncated mid-way. The generated HA package
parses as YAML for one client and for several.
Untested, and it needs the actual machines: every command that changes state
(pactl set-default-source, move-source-output), the pactl JSON shapes the
fixtures imitate, the discovery payloads against a real HA — and the one most
likely to bite, whether BLE presence reports rooms fast and accurately enough
to be worth wiring to a microphone at all. Room-level presence has never been
measured in this flat. RuView can say a room is occupied but not by whom, and
Frigate recognises faces at the door, not per room, so this rides on BLE with
both of those as corroboration. mic-follow/README.md sections 3 and 7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7yp4NcmX4zqja1MKRjeWJ
Two changes that share the config file, the exporter and the validator, so
they land together.
--- stream-dock/ ---
Four rotary encoders on a desk-side MiraBox N4 Pro (the xVSDinside-branded
one; Ajazz AKP05 family) as R / G / B / brightness for one room's lamps,
through OpenDeck.
No new plugin, deliberately. cgiesche/streamdeck-homeassistant already does
Home Assistant over websocket with encoder actions, and opendeck-akp05 already
teaches OpenDeck this non-Elgato hardware. What was missing was the
configuration between them, and one thing neither can do: relative colour. An
encoder emits "three ticks clockwise" and HA has brightness_step_pct but no
equivalent for a colour channel, so the dock sends only which channel and how
many ticks, and ha-package/stream_dock.yaml does the read-clamp-write against
the lamp's current rgb_color. Room-agnostic — the scripts take entity_id as a
field, so one copy serves every room and a second dock needs no new HA config.
Bindings are generated as a document to paste, not as an OpenDeck profile
file. That schema is not documented anywhere this could be checked against,
and a profile written to a guessed schema fails in the least useful way
available: OpenDeck starts, the profile looks present, the dials do nothing.
Rings 1-3 show their own channel's value in their own colour; ring 4 shows
what the room is actually emitting (rgb scaled by brightness). Off the dock's
lighting layer they fall back to the desktop's own palette from
~/Dotfiles/colors.conf, chasing one ring at a time, and Home Assistant is not
polled at all — the rings are shared hardware, and on another layer those
dials mean something else. A gate that cannot tell which layer is showing
reports "cannot tell", which is treated as "not ours": going idle is the
recoverable mistake, hijacking is not.
The one gap is the reload. The akp05 plugin reads leds.toml at startup and
holds the USB device open, so nothing else can drive those LEDs and no local
change makes it re-read. apply-leds.sh carries four strategies and a --probe
that walks them cheapest-first with the dock in front of you — its FIRST test
is whether the plugin already watches the file, in which case the rings are
live for free. The apply is rate-limited separately from the file write, so a
spun dial cannot re-initialise the device per detent and the last state is
never dropped. The real fix is upstream and small;
upstream-file-watch-request.md is written and ready to file.
Nothing here has touched hardware. Verified against a stub Home Assistant:
ring colours on/off, unreachable HA, the layer gate in all three states, the
idle chase, and the rate limiter (12 colour changes in 6s -> 3 applies, final
state on disk). Unverified, in bite-order: whether {{ticks}} substitutes (an
absolute-position variant is in the generated bindings if it does not), the
ring reload, the gate's discovery of OpenDeck's profile state, and the HA
scripts themselves.
--- Discord on the touch panel ---
An optional fourth app, kiosks[].enable_discord, validator-restricted to the
touch-panel type: no other image installs the Flatpak or has a workspace for
it, so elsewhere the flag would be silently ignored on a panel that boots
looking fine.
It is for the Loggia — the point is a voice call that survives stepping
outside for a cigarette, which is a switch-device action, not a read-messages
one. So it starts logged in at session boot and every control path (dock
button, HA "Show Discord", the Screen select) focuses the running app rather
than launching it. The Screen select's options are now derived from the app
table instead of being fixed, so a dropdown never offers a workspace this
image has no app for.
Three things follow from it being XWayland rather than native Wayland: sway
matches class, not app_id (an app_id rule would silently never fire); touch is
the emulated-pointer tier; and it deliberately does not inhibit idle, because
a call must not hold the panel's screen on in an empty room.
The Loggia panel itself is a new kiosk entry in the template. The real
CoreSystemConfig.json is gitignored, so its copy of that entry is local only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7yp4NcmX4zqja1MKRjeWJ
A new kiosk type, hosts/steam-tv-box/, plus tools/build-steam-tv-box-iso.sh.
It boots straight into Steam Big Picture and runs games ON the machine — the
opposite end of hosts/thin-client's Steam Link, which streams a game rendered
somewhere else and needs no graphics performance at all.
Native Steam (steam-installer, contrib) goes in via a hook rather than the
package list: it is unusable until `dpkg --add-architecture i386` has run, and
live-build installs package lists before hooks. With it come the i386 half of
the Mesa/Vulkan/SDL stack (most pre-2015 titles are still 32-bit and fail with
an opaque GL-context error without it), steam-devices' udev rules, gamemode,
mangohud, and gamescope when the release has it.
First host to override household.debian_release. A new optional per-kiosk
`debian_release` key pins this image to trixie; bookworm's Mesa 22.3 is fine
for every other host here — they are a browser and a Python agent — and not
fine for the one machine that renders. It is also the only thing on this box
that cannot be fixed later by editing a config file.
`gpu_vendor` (amd/intel/nvidia) is configuration, not detection, and the
validator requires it: the build host cannot see the target machine, and
installing the NVIDIA driver on an AMD box actively breaks it.
The media apps do not exist until you leave Big Picture. Firefox (uBlock
Origin + SponsorBlock), Spotify and mpv start the first time somebody actually
leaves it — not tidiness, but because this is the one machine here where a
background browser and an open Spotify audio stream turn up as stutter in a
frame-limited title. Two independent triggers, because there are two ways to
leave and neither can see the other: steam-session runs steam-big-picture in
the foreground and continues when the client exits, and session-watcher
subscribes to sway workspace-focus events for "exited Big Picture but Steam is
still running". Both call an idempotent media-session start, guarded per-app by
pgrep and as a whole by flock. Window-title matching was rejected: Steam's
titles and window structure have moved across client rewrites, and a media
session that silently stops appearing after an update reads as a broken image
rather than a moved string.
Going back into a game tears nothing down — people play music over games on
purpose. `media-session stop` exists behind one explicit HA button and nothing
calls it automatically.
Prism Launcher runs through Steam, not beside it. steam-shortcut-prism writes
it into the user's binary shortcuts.vdf as a non-Steam game and prism-launch
starts it via steam://rungameid/, so Minecraft runs inside the Steam Runtime
with Steam Input and the Steam Controller API live. That cannot be baked into
the ISO (userdata/<steamID3>/ does not exist until somebody logs into Steam,
and no Steam credentials go into an image), so it runs per-session and before
Steam starts — Steam rewrites that file from memory on exit. It falls back to a
direct launch when Steam is absent, and refuses to rewrite a shortcuts.vdf that
already holds other entries rather than risk eating them.
steamtv-agent adds one genuinely new entity to the kiosk vocabulary: a
session-mode sensor (gaming/steam/media/idle, from two cheap local facts) so HA
can answer "is somebody playing?" without guessing from power draw. The rest —
transport, volume, audio-output select, workspace select, launch buttons, CEC
display switch — is the thin client's surface over the same MQTT-only control
boundary: no HTTP listener, no exposed Sway IPC socket, every handler an
enumerated action, payloads never argv elements.
enable_installer defaults true here, unlike every other kiosk: a live system
keeps its writable layer in RAM and a Steam library would vanish on reboot.
Never built, never flashed, never booted, no hardware chosen. Four things are
reasoned rather than verified and are flagged in the host README and as open
decision #43: the shortcuts.vdf binary format and non-Steam AppID derivation
(Valve documents neither), gamescope's availability in trixie, the two Flathub
app IDs, and the NVIDIA driver package names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the workshop/office assistant and the plumbing several other features
were waiting on. The through-line: every new capability that could act on
its own proposes instead, and says out loud when it does not know something.
New service — workshop/
Project notebook (workshop.db) plus a never-pruned knowledge store
(workshop-knowledge.db): standing workflow instructions by activity,
keyword facts, durable project learnings, and the household's ONE hardware
inventory. GET /context returns everything applying right now in one call,
so the assistant is told the standing considerations rather than reminded
of them. Two databases because they have different lifetimes: rebuilding
the project store must not take the note about how you solder with it.
Hardware statuses distinguish reserved (still on the shelf) from in_use
(installed and working) — "can I use this right now" has different answers
for the two, and naming a project on an in_use item never silently demotes
it.
Gitea repos with append-only history: commit/push/branch yes, unattended;
force-push/rebase/amend/reset/filter-repo never, enforced server-side by
branch protection rather than only by this code refusing. When history
genuinely must be scrubbed, /scrub-request prints the commands for a human
to run — the manual step is the safety mechanism.
Fleet scripts: one monitoring-agent script per kind of machine, fetched by
each endpoint's fleet-bootstrap timer. Remote code execution by design, so
the constraints are the design — upload is a draft, publishing is separate,
scripts live in SQLite rather than on the writable share, every version is
kept, and the endpoint verifies the checksum and reports pass or fail.
Slots exist for the ESP32s and network appliances that cannot run a script
at all, holding the CheckMK-server-side config instead.
Infrastructure health
opnsense becomes a LIST of firewalls, each named, keyed by name rather than
index. CheckMK joins it. Both are polled by workshop (always-on) and read
by digest-engine, so the digest can say "critical since Tuesday" instead of
quoting a six-hour-old snapshot. Three states, because "I could not ask" is
not "nothing is wrong".
pantry-vision
All four stock movements are camera-driven; stock counts individual units
and folds brand-free via Grocy product groups. Door-sensor-triggered
appliance cameras record sightings as hints with timestamps, never as
stock — a camera at a door cannot tell in from out.
identity
Per-person colour and settable profile picture, assigned to avoid collisions
between people sharing an initial, on the 2-bit-per-channel lattice a colour
Pebble renders natively.
render/ — shared, vendored, dependency-free
media-visualiser: two-tier by necessity, since most endpoints have no local
audio; the synthetic tier says on screen that it is not an analysis.
floorplan-3d: canvas 2D rather than three.js — the scene is prisms on a
plane, which an isometric projection draws in ~200 lines, predictably on
weak panels, with the frontend still at zero dependencies.
Config and fleet plumbing
Rooms are one vocabulary (an HA area_id) from CoreSystemConfig through the
builders to suggested_area. Keycloak and FreeIPA are coupled as one
decision with USR_HA_ group naming, declaration-only for now and validated
as such. Immich alongside the photo share, read-only. Thin clients get the
full media-key set for a wireless remote.
Docs: fridge-item-location, workshop-assistant, rooms-and-endpoints,
endpoint-surfaces, pebble-presence-watchface.
Testing is stubbed suites and headless unit checks only — no real Grocy,
camera, vision model, CheckMK, Gitea, Samba or browser has been involved.
The CheckMK API shape and Gitea's branch-protection payload are written from
documentation and have version-sensitive field names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FanS1vyE2gLhGkqKq6HtYj
digest-engine grows from a single household digest into four per-person
sections, and gains the memory and inputs to make them worth reading.
Per-person sections. identity owns a digest_sections column per person,
edited in the admin panel and read by digest-engine at the start of every
run (GET /digest-preferences). A run generates the union the household
asked for: a section nobody wants costs no LLM call and no ingestion of
its sources. Each surface then filters to the person HA resolved. The
display half is a filter, not an access control, and says so.
Network is its own section, split out of household so the two can be
wanted separately.
Political section rebuilt around four questions (global class struggle,
organising in Vorarlberg, mid-term consequences, the International and
comrades' reports). ~30 international feeds added, each carrying owner
and bias, with a symmetric ownership analysis rather than a
reliability ranking; Zionist outlets get an explicit zero-trust rule that
is not inversion. RCI social/podcast ingestion (YouTube Atom, podcast RSS,
public Telegram via the existing session) feeds a watch-later window.
Globe markers carry summaries with fold-out sources; counter_run drops
citations whose URLs are not in the context.
Meeting agendas: a Tagesordnung arriving by mail or WhatsApp is matched to
its calendar event, read with pypdf, and its points extracted
mechanically. The political section owns the contents and derives
"Political todos"; the household section is told only that an agenda
exists, enforced structurally.
The archive keeps every ingested item and measured number across runs, so
trends may finally be stated with figures and dates attached.
ntfy push after each run, assembled from existing narrations, gated by the
same per-person sections.
identity gains GET /speaker: automatic recognition for the voice path from
BLE plus recent face sightings. Unresolved means show less, never ask, and
nothing displays a digest because someone walked past a screen.
OPNsense credentials move into CoreSystemConfig.json; thin clients gain a
Display switch (HDMI-CEC, DPMS fallback) so an empty room stops powering a
TV.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NpKsMV1Q2EHJ5cZVoLvK9M
TLS front door (Caddy) for this repo's own services: one hostname, a permanent
HTTP->HTTPS redirect, and the API on the same origin as the page. Home Assistant,
Grocy, Frigate and the rest keep their own ports — fronting HA brings its own
auth and websocket concerns and none of the problems below need it.
Three concrete reasons, not hygiene:
1. The admin panel's URL carries IDENTITY_TOKEN, which grants administrative
access to the person registry AND to the device grants that decide whether a
smart lock opens. On plain HTTP that is readable by anything on the smart-home
VLAN — a segment deliberately full of cheap IoT hardware.
2. getUserMedia requires a secure context, so register.html's camera cannot work
over http://192.168.x.x at all. It also failed SILENTLY: the call site used
`navigator.mediaDevices?.getUserMedia(...).then().catch()`, and optional
chaining short-circuits the whole chain — so neither handler ran, no "Camera
unavailable" message appeared, and registration proceeded photo-less with no
explanation. Verified in node. register.js now checks explicitly and says
which of the two cases it is; HTTPS is what actually fixes it.
3. Serving the page over HTTPS while ?api= still pointed at http://...:8097 would
have every call blocked as mixed content, so the API has to be proxied too.
tls: internal runs Caddy's own CA (no external dependency, works with no WAN at
all) with tools/export-proxy-ca.sh to fetch the root; tls: custom takes an
existing cert, which is how you'd use a real one from a DNS-01 challenge without
exposing anything. HSTS is deliberately not set — with an internal CA it would
turn a dismissible warning into a hard failure. Kiosks stay on plain HTTP for
now: a full-screen cert interstitial is not dismissible on a device with no
keyboard, so moving them is documented as a follow-up rather than done blind.
Empty service tokens now fill themselves in on the first build and land in
tokens.txt with what each is for. They are written BACK to the config, which is
the part that matters: a token is only useful because two machines agree on it,
so generating fresh randomness per build would produce a door panel that cannot
talk to the service it was built for. Blanks are filled once and never
overwritten. ha_token, mqtt_password, admin_password_hash and ssh_authorized_key
are deliberately not invented — tokens.txt lists them with the reason, so an
empty field is never a mystery.
32 new checks: token generation and stability across runs, disabled services
skipped, tokens.txt contents, config still valid after the rewrite, Caddyfile
routes and redirect, conditional pantry route, both TLS modes, and that the
derived admin URL keeps page and API on one origin. Nothing has been run against
a real Caddy — see proxy/README.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the oldest deferred item in the plan (open decision #22). /presence has
reported a best-effort `room` since Phase 6, but there was nothing to plot it
on: no floor plan, no room list, no coordinate format. The reason it stayed
deferred was sound — nothing here knows the shape of this flat, and inventing a
coordinate format against a guess would have been the wrong kind of work. So the
plan is DRAWN, by hand, in a new Floorplan tab.
Levels hold rooms; rooms are polygons (real rooms aren't rectangles) with an
optional background image to trace over. Click corners to draw, drag handles to
adjust. Each room maps to the HA area string /presence already reported, and the
editor offers a pick-list of the areas HA is actually reporting rather than
asking anyone to retype an area_id from Developer Tools — same anti-typo
reasoning as CoreSystemConfig.json. Tick "Show who's home" and occupied rooms
light up.
Coordinates are normalised 0-1 rather than pixels: the plan has to render on a
laptop now and possibly a wall panel later, and pixels would be right on exactly
one of them. The tradeoff is documented — replacing a background with one of a
different aspect ratio distorts existing rooms.
Two choices about failure worth calling out. A room with no HA area is legal and
labelled as such on the plan, because drawing the flat and wiring up presence are
separate jobs. And anyone who can't be placed — home with no room resolved, or
reporting an area no room claims — is listed under the plan, with unclaimed areas
named so you know what's left to draw. A floor plan that quietly lost people
would be worse than no floor plan.
The whole feature is still gated on AREA_ATTRIBUTE being right, which remains an
unconfirmed guess at what Bermuda publishes; every room can be drawn correctly
and still never light up. The editor makes that diagnosable rather than
mysterious, but can't fix it. Added to identity's verification list.
38 backend checks: level and room CRUD, polygon validation (too few points, out
of range, non-numeric, over the vertex cap), duplicate-area refusal, image
round-trip, the presence join including both unplaceable cases, area suggestions
degrading when HA is down, and cascade on level delete. The editor UI itself has
not been opened in a browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
arm64 was the last image in the household whose identity was typed in by hand
after building — Raspberry Pi Imager's OS Customisation dialog set the hostname
per unit at flash time. That's the exact step CoreSystemConfig.json exists to
remove, and it failed quietly when mistyped: spotify-connect-start reads
$(hostname) at service start, so a typo doesn't error, it just puts a Spotify
Connect device with the wrong room name in the picker.
The builder now substitutes the endpoint's hostname from the audio_endpoints
array into a generated copy of config/audio-endpoint.yaml and builds one image
per room, published like every other image as
iso-out/smarthome-<hostname>-<pairid>.img. The substitution is targeted rather
than a YAML round-trip: that template is mostly comments explaining an
unverified schema, and a load/dump would strip every one of them. It fails loudly
if it doesn't match exactly once, rather than shipping an image named after the
wrong room.
The cost is honest and documented: one full rpi-image-gen run per arm64 room,
qemu-emulated on an x86 builder, instead of one for all of them.
build.arm64_prebake: false restores the single generic image for households with
several arm64 rooms and a slow builder. Wi-Fi and SSH keys are still set in
Imager on arm64 either way — only the hostname moved into the build.
Also fixes the image-publishing search, which used `find -newer $BUILD_CONFIG`
and silently found nothing when the build finished within the same filesystem
timestamp granularity as the config write. Newest .img wins instead.
Resolves the per-room-identity asymmetry in project-plan Phase 15.5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Renames the image output directory from build-output/ to iso-out/ (config
default, .gitignore, docs).
Also documents the asymmetry that showed up when building a household with
several endpoints: kiosks and amd64 audio endpoints get one image per array
entry, because their hostname is baked in at build time; the arm64 audio image
is generic and built once no matter how many arm64 rooms are listed, because Pi
Imager sets hostname and Wi-Fi per unit at flash time. That is project-plan
Phase 15.5's existing decision, now reflected in what build-all actually does
rather than only in prose.
Verified with a stubbed full run of a six-endpoint household: one build-all
invocation produced nine distinctly-named images with no overwrites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to the tools/ build system: the kiosk and amd64 audio-endpoint
builders left their ISO in the live-build tree under live-build's fixed
filename, and never moved it to build-output/. Configure two thin clients and
build-all would build both into the same path — the second silently overwriting
the first, leaving one ISO carrying the second room's hostname and no sign the
first was ever produced.
core_publish_image() now moves each finished image to
build-output/smarthome-<hostname>-<pairid>.iso, so one array entry produces one
artifact. Verified: two thin clients now yield two distinct ISOs, and the
per-type builders still refuse with the real list when the hostname is omitted
or wrong.
The arm64 audio endpoint is the deliberate exception and is now handled as one:
Pi Imager sets hostname/Wi-Fi per unit at flash time, so a single generic .img
serves every room and build-all builds it once regardless of how many arm64
entries are listed. amd64 has no equivalent for a generic x86 ISO, so it bakes
the hostname in and does need one per room (project-plan Phase 15.5).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>