SmartestHome/firmware/esp32-s3-touch-lcd-1.85c
Amir Alexander Abdelbaki ea82ee70ad 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>
2026-07-31 13:22:25 +02:00
..
README.md Add tools/ and CoreSystemConfig.json — one source of truth for every build 2026-07-31 13:22:25 +02:00
secrets.yaml.example Add Phase 13 admin canvas, Phase 14 voice/display satellite, and thin-client capture-card input 2026-07-29 11:03:11 +02:00
voice-display.yaml Add Phase 13 admin canvas, Phase 14 voice/display satellite, and thin-client capture-card input 2026-07-29 11:03:11 +02:00

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

  1. Install ESPHome (pip install esphome, or use the Home Assistant ESPHome add-on / dashboard instead — either works, this repo doesn't assume which).
  2. Copy the secrets template and fill it in:
    cp secrets.yaml.example secrets.yaml
    
    secrets.yaml is git-ignored (repo .gitignore covers 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 Assistant media_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 — whichever weather.* 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).
  3. api_encryption_key — generate one:
    python3 -c "import base64, os; print(base64.b64encode(os.urandom(32)).decode())"
    
  4. 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:

  1. room in voice-display.yaml's substitutions: — 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.

  2. media_player_entity_id in secrets.yaml — this is the one that actually controls what's on screen, and it's the one you can genuinely get wrong. Build a separate secrets.yaml per 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, see hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py's register_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_id can'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 real media_player entities, and point media_player_entity_id at the universal one instead of any single real device:
      media_player:
        - platform: universal
          name: "Living room media"
          children:
            - media_player.living_room_thinclient
            - media_player.living_room_spotify_connect
      
      Default child-selection behaviour is "whichever child isn't idle/off" — see the integration's own docs (active_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.

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:

  1. 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.
  2. GPIO46/GPIO45 strapping-pin warnings from esphome config are expected — they're the QSPI display's own data pins, not a mistake in this config; the community reference has the identical warnings.
  3. 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.
  4. micro_wake_word's "okay_nabu" model. Wake sensitivity/false-positive rate is whatever ESPHome's shipped model gives you — untuned, unlistened-to.
  5. Cover-art fetch. entity_picture is assumed to be a path relative to Home Assistant (ha_base_url gets prepended unless it already starts with http) — 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 what entity_picture actually contains for your specific media_player entity (Developer Tools → States in HA) before assuming the display is broken.
  6. Optional: drop a no_art.png into Home Assistant's config/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.
  7. 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_player state actually updates in practice (some integrations lag a second or two).
  8. 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.
  9. 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's media_player_entity_id against Developer Tools → States before trusting it — see Multiple rooms. This firmware has no way to detect a copy-pasted secrets.yaml pointing two units at the same entity.