# 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-<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](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.
