# 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 | **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). Treat it 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 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. **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. ## 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. 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.