# CLAUDE.md Firmware for a desk clock / thermometer on the **WaveShare ESP32-S3-RLCD-4.2**. `README.md` is the product spec — layout, update rates and error behaviour are defined there and should be treated as requirements, not suggestions. ## Build ```sh pio run # build pio run -t upload # flash over USB-C pio device monitor # 115200 baud ``` No `secrets.h` is required — Wi-Fi, the outdoor URL, the timezone and the NTP server are runtime settings in NVS, entered through the setup portal. `include/secrets.h` is optional and gitignored; when present it only seeds a freshly erased device. Stored settings always win, so editing it changes nothing until NVS is erased (`pio run -t erase`). ## Hardware facts Confirmed from the vendor examples in [waveshareteam/ESP32-S3-RLCD-4.2](https://github.com/waveshareteam/ESP32-S3-RLCD-4.2) (`02_Example/Arduino/`), not from the wiki — the wiki does not publish a pinout. | Function | Pin / value | | --- | --- | | LCD controller | **ST7305**, 300 × 400 mono reflective, SPI | | LCD SCK / MOSI / DC / CS / RST / TE | 11 / 12 / 5 / 40 / 41 / 6 | | I²C SDA / SCL | 13 / 14 | | PCF85063 RTC | 0x51 | | SHTC3 temp/humidity | 0x70 | | KEY / BOOT buttons | 18 / 0, both active low | | Battery divider | ADC1_CH3 = GPIO4, ×3, downstream of the charger | | BH1750 (optional, user-fitted) | 0x23 or 0x5C on the same I²C bus | | I²S MCLK / BCLK / LRCLK | 16 / 9 / 45 | | ES7210 mic ADC | 0x40, data in on GPIO10 | | ES8311 speaker DAC | 0x18, data out on GPIO8 | | Speaker amplifier enable | 46, held low — playback is not built | | microSD (SDMMC, 1-bit) | CLK 38, CMD 21, D0 39 | **The two microphones are not PDM.** They are analog MEMS capsules into an ES7210 ADC, so they need an I²C register init before I²S produces anything but silence. The audio pins come from `07_Audio_Test` and are corroborated by Waveshare's own ESPHome example for this board; both were checked because a wrong pin here fails silently as a stream of zeros. **The panel has no backlight or frontlight and there is no onboard light sensor.** Both were checked against the vendor docs and examples. This is why "dim the screen" is not implementable and why darkness instead triggers sleep — a reflective panel holds its image with no power and is unreadable unlit. Because the divider is downstream of the charger, **charging cannot be detected** — on USB the node reads the same ~4.2 V as a full cell. Don't add a charging glyph without a hardware way to tell. | Module | ESP32-S3-WROOM-1-N16R8 — 16 MB flash, 8 MB **octal** PSRAM | The panel is native **300 wide × 400 high**. The app drives it rotated (`U8G2_R1`), so all drawing code works in a **400 × 300** landscape frame. Any new layout constant belongs in `include/config.h`, not inline in `display.cpp`. `board_build.arduino.memory_type = qio_opi` in `platformio.ini` is what selects octal PSRAM. Dropping it silently disables PSRAM at boot. ## Layout `lib/ST7305_U8g2/` is vendored verbatim from the vendor's `10_U8G2_Test` example (Apache-2.0). `lib/shine/` is vendored verbatim from toots/shine (**LGPL-2.1** — the one copyleft dependency here; see its README). Treat both as third party: fix bugs upstream-style, keep local changes minimal and commented. ```text src/main.cpp startup order + the loop; owns the indoor reading src/display.{h,cpp} the only code that touches the panel src/rtc.{h,cpp} PCF85063 driver, stores UTC src/temperature.{h,cpp} SHTC3 driver src/network.{h,cpp} Wi-Fi association + NTP, writes the RTC src/outdoor.{h,cpp} HTTP poll of the remote sensor src/settings.{h,cpp} NVS-backed runtime config src/portal.{h,cpp} setup web UI; the HTML/CSS/JS page lives here src/battery.{h,cpp} 18650 voltage and percentage src/ambient.{h,cpp} BH1750, with quiet-hours fallback src/power.{h,cpp} CPU clock, panel mode, dark-room light sleep src/audio.{h,cpp} ES7210 mics, I2S capture, MP3 encoding, destinations src/audio_portal.{h,cpp} the /audio page and its API include/config.h pins, layout, intervals, compiled-in defaults ``` ## Design decisions worth preserving **The RTC holds UTC, never local time.** Presentation goes through `localtime()` with `TIMEZONE_POSIX`, so a daylight-saving change never requires a write to the chip. Anything that writes the RTC must convert to UTC first. **Boot order is deliberate.** Panel → RTC → SHTC3 → first draw → *then* Wi-Fi. A useful screen appears in about a second even if the network never associates. Do not move `network::begin()` earlier. **Nothing in `loop()` blocks on the network.** `network::loop()` owns its own retry timing (`WIFI_RETRY_MS`); the clock and indoor reading must keep updating through an outage. **The panel is refreshed only when a *rendered string* changes**, not when a value changes. `display::update()` formats everything to text and compares against what is on screen, which is why a 21.54 → 21.51 drift causes no refresh. This is the mechanism behind the README's "avoid unnecessary full-screen refreshes"; keep it if you rework the renderer. **Missing data draws as `--.-` / `--:--`, never as a zero or a blank.** A stale outdoor reading keeps its last value on screen with a dashed rule under it — the README is explicit that the old number is preferable to an error value. **Losing Wi-Fi never raises the setup AP.** The portal appears only when NVS holds no SSID, or when KEY is held at power-up. Falling back to an AP on a router reboot would replace a working clock with a setup screen, which is the opposite of the "continued operation without Wi-Fi" goal. **Saving settings reboots the device** rather than reconfiguring in place, so what the user sees after a save is the same code path as a power-on. **`display::formatTimeText` and `display::formatDateLine` are the single source of truth for presentation.** `main.cpp` uses them to fill the model and the portal's `/api/preview` uses them to answer the browser, so the preview cannot drift from the panel. Do not reimplement either formatter in the page's JavaScript — the ESP32 has a real `strftime` and the browser does not. **`power::sleepWhileDark()` blocks until the room is lit again.** It is not a state machine — it owns the loop while resting, with Wi-Fi off, waking on a timer or on KEY. Callers treat a `true` return as "you were away for a while": refresh everything, re-poll the outdoor source, `display::invalidate()`. **Everything on screen is quantised before it is compared.** The battery is four bars, not a percentage, specifically so voltage noise cannot trigger a refresh. Anything new on the panel should be reduced the same way — put the quantisation in `update()` next to the existing dirty check, not in the caller. **80 MHz is a floor, not a preference.** It is the lowest the ESP32-S3 runs with Wi-Fi active, and at or above 80 MHz the APB clock stays 80 MHz so the display SPI and UART dividers are unaffected. `power::begin()` must run before `Serial.begin()` or the log comes out garbled. **Recording never touches the clock.** Capture and encoding run in a FreeRTOS task pinned to core 1, so `loop()` keeps updating the panel throughout. This is also why the live-stream destination does not write from its HTTP handler: the handler attaches the socket and returns, and `audio::loop()` drains the encoder into it a bounded chunk at a time. A blocking `while (recording) { write(); }` inside a `WebServer` handler would freeze the clock for the whole recording, which is the one thing this firmware must not do. **The stream handler relies on `WiFiClient` being reference counted.** `WebServer::handleClient()` releases its client with `_currentClient = WiFiClient()` rather than `.stop()`, and the socket descriptor lives in a `shared_ptr`, so the copy `audio::attachStreamClient()` keeps holds the connection open after the handler returns. This was verified against the installed core, not assumed — if the core is ever upgraded, check it again. **80 MHz is abandoned while recording, deliberately.** Shine cannot encode in real time at 80 MHz, so `audio::start()` raises the CPU to 240 MHz and the teardown puts it back. The raise happens before `i2s_driver_install()` so the I²S dividers are computed at the final clock, and the restore happens on the main task in `finishIfDone()` so it cannot race the encoder. Dark-room sleep is suppressed for the duration. **The microphones are unpowered unless a recording is running.** `audio::begin()` only probes for the ES7210 and mounts the card; the codec is configured and the I²S driver installed in `start()` and torn down again in `finishIfDone()`. Keep it that way — it is both a power decision and a privacy one. **The portal page is one self-contained document** in `portal.cpp` — no CDN, no external fonts. In setup mode the phone loading it has no route to the internet, so any external reference would simply hang. There is a build-time check on this: `python` extraction plus `node --check` on the script block is how the page's JS was verified, since nothing here has a browser test. ## Fonts All from U8g2's built-in set; no font files are shipped. `FONT_TIME` is `logisoso92_tn`, which is **digits, colon, period and minus only** — it cannot render letters, which is why the AM/PM suffix has its own `FONT_TIME_SUFFIX` and is drawn separately. `FONT_TEMP` must stay a `_tf` variant so `°` (U+00B0) resolves, and temperatures must be drawn with `drawUTF8`, not `drawStr`. The date line picks the largest font from the `DATE_FONTS` ladder that fits across the panel, because the format string is user-supplied and an arbitrary `strftime` pattern can produce a very long line. ## Unverified on hardware Three things were written from datasheets and cannot be confirmed without the board. Check these first if something misbehaves: - **ST7305 command 0x39 as low-power mode.** Inferred from the vendor's init sending 0x38 (high power). If the panel looks wrong, the "Panel low-power mode" checkbox in the portal turns it off without a reflash. - **Light sleep with USB-CDC.** `ARDUINO_USB_CDC_ON_BOOT=1` means serial logging may not survive the first rest. Harmless on battery; annoying on the bench, where unchecking "Sleep when the room is dark" avoids it. - **BH1750 timing.** A flat 180 ms one-shot delay, the datasheet maximum, with no busy polling. The whole audio path is also unverified, and is the largest block of untested code in the repository. Check in this order if recording misbehaves: - **The ES7210 register sequence** in `audio.cpp` is transcribed from ESPHome's `es7210` component, which follows esp-adf. The magic numbers are reproduced, not derived — the datasheet does not explain most of them. Only the 16 kHz coefficient row is copied, because that is the only rate this firmware records at; another rate needs the matching row from upstream's table. - **Silence** points at the codec init or the I²S pins, not the encoder. A valid MP3 of pure zeros is the expected failure mode for a wrong `data_in` pin, since the I²S peripheral happily clocks in nothing. - **Whether shine keeps up at 240 MHz.** Never measured on hardware. If it does not, the symptom is I²S overruns and choppy audio; drop `AUDIO_BITRATE_KBPS` or force mono before anything else. - **`SD_MMC` sharing GPIO38/21/39.** Nothing else in the firmware claims those pins, but the card has never been exercised alongside the display SPI. `AUDIO_MIC_GAIN` is a guess at a sensible default (21 dB). It is a compile-time constant, not a portal setting, because there is no hardware to calibrate it against yet. ## Not implemented Everything under README "Future Features", plus partial-region panel updates and the TE pin. Deep sleep is not used anywhere — light sleep retains RAM, which is what keeps the last outdoor reading across a dark night. **Audio playback.** The ES8311 and the speaker are wired and their pins are in `config.h`, but nothing drives them and `SPEAKER_PA_PIN` is held low. Recording is capture-only. Nothing about audio appears on the panel either — no recording indicator, no level meter — and KEY is untouched, so the button still means only "hold at power-up for setup". `platformio.ini` now pins `espressif32@6.5.0`. `audio.cpp` uses the legacy `driver/i2s.h` API, which arduino-esp32 3.x removed in favour of `driver/i2s_std.h`. Unpinning without rewriting the capture path will not compile. No runtime current measurement has been taken, so avoid quoting runtime figures in the docs. The portal is plain HTTP with no authentication: anyone on the LAN can read the outdoor URL and change settings. The Wi-Fi password is never sent to the browser, but it is stored in NVS in the clear. Acceptable for a home device on a trusted network; revisit before this goes anywhere else.