# ESP32-S3-Touch-LCD-1.85C-V2 — voice + status display Phase 14 of [`docs/project-plan.md`](../../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](#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: ```sh 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](#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: ```sh 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 ```sh 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-`), 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](https://www.home-assistant.io/integrations/universal/) 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: ```yaml 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](https://github.com/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](#multiple-rooms). This firmware has no way to detect a copy-pasted `secrets.yaml` pointing two units at the same entity.