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> |
||
|---|---|---|
| .. | ||
| README.md | ||
| secrets.yaml.example | ||
| voice-display.yaml | ||
README.md
ESP32-S3-Touch-LCD-1.85C-V2 — voice + status display
Phase 14 of docs/project-plan.md. A DIY voice
satellite + round-screen status display built on the Waveshare
ESP32-S3-Touch-LCD-1.85C-V2, evaluated in this project as a possible alternative to
the Home Assistant Voice PE.
Media status always takes priority on screen, and it's always THIS room's
media status. Whenever the configured media_player entity is playing, the
display shows its cover art, title and artist. Otherwise it idles, cycling every 8
seconds between a clock (time + date) and the weather. A ring visualizer overlays
whichever of those is showing whenever the assistant is listening, thinking, or
replying — it never replaces the page underneath, it just sits on top of it. See
Multiple rooms below for what actually guarantees the "this
room's" part, since ESPHome has no built-in concept of "the media player in my
room" — that has to be set up deliberately, per unit.
⚠️ This needs the V2 hardware revision specifically
Waveshare shipped two hardware revisions of this board. V1 has no echo-
cancellation circuit and different audio pins — this config's I2S pin numbers,
the ES7210/ES8311 audio path, and the display's reset wiring are all V2-specific
and will not work on a V1 unit. Confirm which you have before flashing (see
docs/project-plan.md's Phase 14 hardware note for how): PCB silkscreen showing
"Rev2.0", factory firmware reporting "Rev2.0", or a "V2" QC sticker on the case.
Before you flash
- Install ESPHome (
pip install esphome, or use the Home Assistant ESPHome add-on / dashboard instead — either works, this repo doesn't assume which). - Copy the secrets template and fill it in:
cp secrets.yaml.example secrets.yamlsecrets.yamlis git-ignored (repo.gitignorecovers it) — never commit a real one. Two of its values are not filled in by anyone yet, on purpose:media_player_entity_id— which Home Assistantmedia_player.*entity this unit mirrors. This must be the entity that reflects THIS unit's own room — see Multiple rooms below, it's not just a "pick any one" placeholder.weather_entity_id— whicheverweather.*entity your Home Assistant already has. Both entities need to be exposed to the Home Assistant API (the default for most entities; check Settings → Voice assistants → Expose if either doesn't show up).
api_encryption_key— generate one:python3 -c "import base64, os; print(base64.b64encode(os.urandom(32)).decode())"- This device plugs into the existing Phase 3 Assist pipeline (Wyoming faster-whisper + Piper) — there's nothing new to stand up on the Home Assistant side beyond adding the device itself (Settings → Devices → Add Device → ESPHome, after the first flash below) and picking an Assist pipeline for it, same as any other ESPHome voice satellite.
Flashing
esphome run voice-display.yaml
First flash needs USB-C (OTA needs the device already on Wi-Fi, which needs the
first flash). Subsequent updates can go over OTA (esphome run auto-detects a
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 (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:
-
roominvoice-display.yaml'ssubstitutions:— sets the device's own hostname/AP name/friendly name (voice-display-<room>), so it's identifiable in Home Assistant's device list and on the Wi-Fi fallback AP. This is purely identity — changing it doesn't change what the screen shows. -
media_player_entity_idinsecrets.yaml— this is the one that actually controls what's on screen, and it's the one you can genuinely get wrong. Build a separatesecrets.yamlper unit (or a separate entry per device if you're using the ESPHome dashboard), never one shared file — flashing two units from the same secrets means both show the same room's media.- If a room only ever has one real audio source (e.g. just the thin
client's own
media_player, seehosts/thin-client/agent/thinclient_agent/mqtt_discovery.py'sregister_media_player), point straight at that entity's ID and you're done. - If a room can have more than one active source (the thin client and a
Spotify Connect speaker and a cast device, say), a single hardcoded
entity_idcan't stay correct on its own — nothing tells this device "prefer whichever one is actually making sound." Build a Home Assistant Universal Media Player for that room that aggregates all of its realmedia_playerentities, and pointmedia_player_entity_idat the universal one instead of any single real device:
Default child-selection behaviour is "whichever child isn't idle/off" — see the integration's own docs (media_player: - platform: universal name: "Living room media" children: - media_player.living_room_thinclient - media_player.living_room_spotify_connectactive_child_template) if a room needs more deliberate priority between its sources. That Home Assistant config is not built by this repo, same "nothing here builds the HA side" convention as everywhere else.
- If a room only ever has one real audio source (e.g. just the thin
client's own
Nothing in this firmware can detect or correct a wrongly-configured
media_player_entity_id — an entity from the wrong room is a config mistake this
device has no way to notice, not a bug it degrades gracefully from. Double-check
it against Developer Tools → States in Home Assistant before trusting the display.
Mounting upside down
Two places to change, both commented # FLIP DISPLAY-style in
voice-display.yaml: display.rotation: 180° and touchscreen.transform.mirror_x
/ mirror_y: true. Both must change together or the touch coordinates and the
image will disagree.
Nothing here builds the Home Assistant side
Same convention as the rest of this repo (admin-canvas/README.md,
hosts/thin-client/README.md's own note): this device shows up in Home Assistant
automatically once flashed and adopted, like any ESPHome device — there's no HA
YAML to write for it. It is a new consumer of the existing Phase 3 Assist
pipeline, so if that pipeline has never been used by a device before, follow the
Phase 3 setup in docs/project-plan.md first.
Manual verification still outstanding
None of this has been run on real hardware — only validated with esphome config voice-display.yaml (ESPHome's own schema/config validator), which the file
currently passes cleanly. In rough order of what to check first on a real unit:
- Display bring-up. The full register init sequence is adapted from a
community config for this exact board
(ulsmith/home-assistant-esphome-esp32-s3-touch-lcd-185c)
and cross-checked against Waveshare's own V2-specific notes (EXIO2 reset,
80MHz,
invert_colors), but has not been flashed here. If it boots to static or a blank screen, that sequence is the first place to look. GPIO46/GPIO45strapping-pin warnings fromesphome configare expected — they're the QSPI display's own data pins, not a mistake in this config; the community reference has the identical warnings.- Audio / AEC path. The mic (ES7210) and speaker (ES8311) I2S pins are adapted from the same reference; whether the board's echo-cancellation reference-signal routing actually suppresses the speaker bleeding into the mic during TTS playback hasn't been tested.
micro_wake_word's "okay_nabu" model. Wake sensitivity/false-positive rate is whatever ESPHome's shipped model gives you — untuned, unlistened-to.- Cover-art fetch.
entity_pictureis assumed to be a path relative to Home Assistant (ha_base_urlgets prepended unless it already starts withhttp) — true for HA's own local media sources, but some integrations (Spotify's own card, certain streaming integrations) may return an already- absolute URL, or one on a different host entirely. If cover art never appears, check whatentity_pictureactually contains for your specificmedia_playerentity (Developer Tools → States in HA) before assuming the display is broken. - Optional: drop a
no_art.pnginto Home Assistant'sconfig/www/(served at/local/no_art.png) for a clean idle image instead of a broken-image glyph before the first track ever plays. Not required — the device works without it, just shows nothing there until either that file exists or a real track with artwork plays. - Idle-cycle and priority timing. The 8-second weather/clock cycle interval
and the "switch to media_page the instant state becomes playing" transition
are both unverified against how promptly Home Assistant's own
media_playerstate actually updates in practice (some integrations lag a second or two). - Settings-page mute toggle and the physical restart button are both simple, low-risk carryover from the reference config, not specifically requested — remove them if you'd rather have a leaner build.
- Multi-unit room correctness. Only ever built/tested against a single
secrets.yaml. If you deploy more than one of these, double-check each unit'smedia_player_entity_idagainst Developer Tools → States before trusting it — see Multiple rooms. This firmware has no way to detect a copy-pastedsecrets.yamlpointing two units at the same entity.