9.8 KiB
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 (hosts/thin-client/scripts/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.