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
|
||
|---|---|---|
| .. | ||
| lib | ||
| README.md | ||
| build-all.sh | ||
| build-audio-endpoint-image-arm64.sh | ||
| build-audio-endpoint-iso-amd64.sh | ||
| build-container-host-iso.sh | ||
| build-core-pair.sh | ||
| build-door-panel-iso.sh | ||
| build-kitchen-display-iso.sh | ||
| build-llm-host-iso.sh | ||
| build-steam-tv-box-iso.sh | ||
| build-thin-client-iso.sh | ||
| build-touch-panel-iso.sh | ||
| config-export.py | ||
| export-proxy-ca.sh | ||
| fleet-bootstrap.sh | ||
| generate-caddyfile.sh | ||
| generate-tokens.py | ||
| setup-container-host.sh | ||
| setup-llm-host.sh | ||
| validate-config.py | ||
README.md
tools — the build system
Every build and setup script for this project, driven by one config file at the repo root.
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:
- Nothing is written twice. No script in this directory contains an IP address, a
port or a token. They read
CoreSystemConfig.json. - 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 |
generate-caddyfile.sh |
The reverse proxy's config, derived like everything else |
export-proxy-ca.sh |
Fetch Caddy's internal root CA so browsers stop warning |
generate-tokens.py |
Fill in empty service tokens and write tokens.txt |
validate-config.py |
Check the config |
config-export.py |
Config → shell variables, deriving URLs. Where the twinning happens |
lib/coreconfig.sh |
The loader every builder sources |
Building one image is supported but unusual: the images are a set that has to agree
with itself, which is why build-all.sh is the default and a failure in one image
doesn't abandon the rest.
One entry, one image
Kiosks and audio endpoints come from arrays in the config, and every entry gets its
own artifact in iso-out/, named smarthome-<hostname>-<pairid>.iso. That naming
is load-bearing rather than cosmetic: live-build always writes the same filename into
the same per-host tree, so two thin clients would otherwise have the second silently
overwrite the first — leaving one ISO carrying the second room's hostname and nothing
to indicate the first was lost.
Per-type builders take a hostname when several of that type exist, and refuse with the list of real ones if you omit it or get it wrong.
This holds for both audio-endpoint architectures: the arm64 image used to be
generic (named per unit in Raspberry Pi Imager at flash time) and is now pre-named
like everything else, because it was the last identity in the household typed in after
a build — and one that failed silently when mistyped, since spotify-connect-start
reads $(hostname) and a typo just yields a Spotify device under the wrong room name.
The cost is one full rpi-image-gen run per arm64 room, qemu-emulated on an x86
builder. Set "arm64_prebake": false in the config's build section to go back to a
single generic .img, built once regardless of how many arm64 rooms are listed. Wi-Fi
and SSH keys are still set in Imager for arm64 either way — only the hostname moved.
Tokens fill themselves in
Leave identity_token, pantry_vision_token and transit_token empty and the first
build generates them, writes them back into the config, and drops a tokens.txt
next to it with the values and what each is for.
Writing them back is the part that matters. A token is only useful because two machines
agree on it — the container host runs identity with it, and every kiosk image is built
with the same value baked into its URLs. Generating fresh randomness per build would
produce a door panel that cannot talk to the service it was built for. So blanks are
filled once, persisted, and never overwritten.
Only tokens this project can legitimately invent are generated. ha_token isn't (only
Home Assistant can mint one, and not until it's running), nor is mqtt_password (it has
to match Mosquitto), nor admin_password_hash (needs mkpasswd), nor
ssh_authorized_key (a generated key would have no private half you hold).
tokens.txt lists those too, with the reason, so an empty field is never a mystery.
Both tokens.txt and the filled-in config are gitignored.
HTTPS
proxy.enabled puts a Caddy reverse proxy in front of this repo's own services: one
hostname, a real HTTP→HTTPS redirect, and the API on the same origin as the page.
That last part isn't cosmetic — serving the admin panel over HTTPS while its ?api=
still pointed at http://…:8097 would have every call blocked as mixed content. And
the reason to want HTTPS at all is that 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. See proxy/README.md.
The ISOs contain secrets
This is deliberate — burning everything in is what makes installation unattended, with no env files to edit on a freshly-booted host. It also means every ISO is a credential: Wi-Fi PSK, service tokens, MQTT and HA credentials, all readable by anyone holding the stick.
.gitignore covers CoreSystemConfig.json and iso-out/, so neither can be
committed by accident. Wiping old USB sticks is on you.
Two things can't be burnt in
Neither exists at build time, so both need a human afterwards:
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 editidentity.envon the container host.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
portsand reference it inconfig-export.py's derived URLs. The duplicate check covers it from then on. - Another audio endpoint: add an entry to
audio_endpointswith itsarch.build-all.shbuilds every entry, so listing both anamd64and anarm64one 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
kioskswith its own hostname.build-all.shpicks it up; the per-type builder takes the hostname as an argument. - A new kiosk type: add it to
KIOSK_TYPESinvalidate-config.pyand add abuild-<type>-iso.sh.build-all.shfinds it by naming convention.
Manual verification still outstanding
- No ISO has ever been built with this.
lb buildneeds 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 onlylbstubbed (44 checks). Thelb config/lb buildinvocations themselves are unverified. - 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.
partman-auto/diskerases the configured disk without confirmation. That is what unattended means, and it's whyinstall_diskis worth double-checking against the actual machine you boot it on.- Static addressing assumes
eth0. Debian's predictable interface naming may well call itenp3s0on your hardware, in which case/etc/network/interfaces.d/smarthomeneeds the real name. - 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.