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.
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).
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 |
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.
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.
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
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.
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.
Three things were written from datasheets and cannot be confirmed without the board. Check these first if something misbehaves:
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.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.