CLAUDE.md 13 KB

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

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

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.