Skip to content

feat: add 11_Clock_Dashboard Arduino example (desk clock) - #6

Open
platima wants to merge 25 commits into
waveshareteam:mainfrom
platima:feat/clock-dashboard
Open

platima wants to merge 25 commits into
waveshareteam:mainfrom
platima:feat/clock-dashboard

Conversation

@platima

@platima platima commented Oct 5, 2026 •

Copy link
Copy Markdown

Summary

Adds 02_Example/Arduino/11_Clock_Dashboard, a desk clock for the ESP32-S3-RLCD-4.2 for the Arduino IDE (U8g2; the display driver is derived from 10_U8G2_Test). It is one new folder; nothing else in the repository changes.

The dashboard

What it does

  • Time: NTP, with the hardware RTC as a fallback and a measured clock-drift figure; the time zone is looked up from the location; 24 h or 12 h; six date formats.
  • Weather: Open-Meteo (no API key), now and the next two days, with the chance of rain and the moon phase. Indoor temperature and humidity from the SHTC3.
  • Battery: gauge and charge state, a runtime estimate, a calibration for the voltage reading, and a low-battery shutdown (deep sleep) so a bare LiPo is not run flat.
  • Spotify: now playing, with the KEY button as the remote.
  • Settings from an SD card: WiFi (and a backup network), units, formats, location, Spotify and power options in a text file, read at boot and kept in flash. Each of them can also be built in. WiFi can be switched off.
  • Firmware update from the SD card: copy the exported .ino.bin to the card and restart (hold KEY for five seconds); no cable, no WiFi. The file is checked first and installed into the second app slot, and it is kept only if it runs for a minute, otherwise the bootloader puts the old firmware back.
  • Power: runs at 80 MHz with PSRAM disabled (about 30 mA idle on USB, against 88 to 111 mA at 240 MHz with PSRAM on). Optional and off by default: the radio on only for the syncs, a slower processor while the radio is off, the USB console shut down.

Building

Same board settings as Tools-Configuration.png, with PSRAM disabled and CPU 80 MHz (the example's README has the exact list and an arduino-cli line). It needs U8g2 (the copy in 01_Arduino_Libraries/U8g2 is fine) and ArduinoJson 7.x. It compiles as it is: the private secrets.h is optional, because the WiFi details can come from the SD card.

Status

Run on a real board: the first version (clock, weather, indoor sensor, battery gauge, display), the 80 MHz setting and its frame timing (1154 frames sent, none late), settings read from a file on an SD card, firmware installed from the card (twice so far, one version to the next), and the processor slowed to 20 MHz between syncs.

Only compiled and checked on a PC so far: the deep-sleep shutdown, how good the runtime estimate is, the switch to the backup network, the clock-drift measurement, the Spotify linking, and what the optional power settings save (not measured yet). The example's README goes through it item by item under Not yet tried on the board.

The PC-side checks are in tools/: about 115,000 host-side checks (settings parser, moon phase, battery estimate and charge detection on simulated voltage traces, firmware-update decisions, the radio schedule over simulated days and more), a pixel-level layout test of the renderer in which every check has a negative control, and a fuzz test.

Since this was opened

The branch has moved on (20 commits; the example is version 1.7, and its README has the full list under Versions). What was added, by version:

1.4

  • Fixed, found on the board: the firmware update from the SD card stopped on every file with Cannot read the file (it checked the file from the wrong place). Going from the first version to a later one therefore takes one upload over USB.
  • Power settings, optional and off by default: wifi_mode = sync (the radio on only for the weather and time syncs, with its own rule for a network that is out of range), cpu_idle_mhz (a slower processor while the radio is off), spotify_live (whether music keeps the radio on), console (the USB serial console shut down when no computer is attached), and the unused audio chips powered down at start-up.
  • SD card: a card that will not mount says why (a GUID partition table, exFAT, not formatted); KEY held for five seconds restarts the clock so that a card is read without pulling the battery.
  • Settings and battery: every setting the card can set also has a build-time default; the battery voltage reading can be calibrated; the charging bolt sits inside the gauge.
  • A version number that moves, and a build id on the System info page to tell two builds apart.

1.5

  • Fixed, found on the board: a clock started on a nearly full battery read as full and gave no runtime estimate; under a light load there was no estimate at any charge level. Full now needs a charge that the clock saw (or hours without a fall), and the estimate runs whenever no charger shows.
  • The banner after a card update says what the card's settings file changed.

1.6

  • Fixed, found on the board: a cell just off the charger reads a little over 4.20 V, and the estimate saw no drain until it had come down.
  • The drain rate is shown finely enough to compare settings (two decimals), and docs/power-log.csv is a sheet for logging battery runs.

1.7

  • power_log = on (off by default) makes the clock write its own power readings into its flash every ten minutes and copy them to an SD card at the next start. Running on a board now; not yet confirmed there.

The Copilot review comments on the first commit are dealt with (926d51c).

Notes

  • Apache-2.0 like the rest of the repository; weather data by Open-Meteo (CC BY 4.0).
  • Happy to trim this or split it up if a smaller example is preferred.

Generated with both Human and LLM input. Fixes and features added since it was opened are in this PR's later commits, as listed above.

-Platima

Copilot AI balanced review requested due to automatic review settings October 5, 2026 05:48
A desk clock for the ESP32-S3-RLCD-4.2 (Arduino, U8g2): date and time with
NTP, the hardware RTC and a measured clock drift; Open-Meteo weather for
three days with the chance of rain and the moon; indoor temperature and
humidity; Spotify now-playing with the KEY button as remote; a battery gauge
with a runtime estimate and a low-battery shutdown; info, power and legend
pages.

Settings come from a file on an SD card (read at boot and kept in flash):
WiFi and a backup network, units and formats, location, Spotify, battery and
power options.  WiFi can be switched off.

The firmware can be updated from the SD card too: copy the .ino.bin that the
Arduino IDE exports to the card and restart.  The file is checked against its
SHA-256 before anything is written, then written to the other app slot; the
running firmware is not touched until the new image has verified.  A new
firmware is on trial for its first minute: a reset in that time makes the
bootloader put the old one back.

The sketch compiles without a secrets.h, so a fresh clone builds as it is.

Written and tested on a PC (renderer checks, fuzz, simulations, about 33,000
host-side checks) and compiled for the board.  The SD card features, the
deep-sleep shutdown, the runtime estimate and the backup network switch have
not run on the real board yet; the README lists what is untried.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Incomplete weather data is accepted, failed settings persistence is reported as successful, and failed OTA confirmation disables further handling.

Review effort: Balanced
Findings: 3 Medium severity

Open (3)
What changed in this PR

Adds a full-featured Arduino desk-clock example for the ESP32-S3-RLCD-4.2, including hardware integration, online services, configuration, update handling, documentation, and host-side validation tools.

Changes:

  • Implements clock, weather, battery, Spotify, display, networking, and power-management functionality.
  • Adds SD-card settings and rollback-capable firmware updates.
  • Adds documentation, screenshots, tests, preview tooling, fuzzing, and generators.

Paths are relative to 02_Example/Arduino/11_Clock_Dashboard/.

File Description
.gitattributes Configures repository attributes.
.gitignore Excludes generated tool outputs.
11_Clock_Dashboard.ino Coordinates startup and application runtime.
README.md Documents setup, features, and operation.
ST7305_U8g2.cpp Implements the display driver.
ST7305_U8g2.h Declares the display driver.
app_model.h Defines UI data models.
app_settings.cpp Loads and persists configuration.
app_settings.h Declares configuration integration.
app_state.cpp Implements shared application state.
app_state.h Declares synchronized state access.
battery_est.h Estimates battery runtime.
buttons.h Implements button-state handling.
calc.h Provides calculation helpers.
charge.h Detects charging state.
config.h Defines compile-time defaults.
datefmt.h Formats dates.
drift.h Estimates RTC drift.
frame_plan.h Schedules display frames.
fw_logic.h Encapsulates update decisions.
fw_update.cpp Implements SD firmware updates.
fw_update.h Declares update operations.
link_page.cpp Renders Spotify linking pages.
link_page.h Declares linking-page rendering.
log.h Provides logging helpers.
low_battery.h Implements shutdown decisions.
moon.h Calculates moon phases.
net_task.cpp Handles WiFi, NTP, and weather.
net_task.h Declares network-task control.
power.cpp Implements deep-sleep handling.
power.h Declares power operations.
sdcard.cpp Implements SD-card access.
sdcard.h Declares SD-card operations.
secrets.example.h Provides optional secret templates.
sensors.cpp Integrates RTC, SHTC3, and battery sensing.
sensors.h Declares sensor operations.
settings.cpp Parses and formats settings.
settings.h Defines settings structures and APIs.
spotify.cpp Implements Spotify OAuth and control.
spotify.h Declares Spotify integration.
spotify_helper_script.h Embeds the linking helper script.
spotify_parse.cpp Parses Spotify responses.
spotify_parse.h Declares Spotify parsers.
timeutil.h Provides calendar calculations.
tls.h Supplies TLS trust configuration.
tz_table.cpp Contains generated timezone mappings.
tz_table.h Declares timezone lookup.
ui.cpp Renders dashboard screens.
ui.h Declares UI rendering.
util.h Provides encoding and string helpers.
weather.cpp Parses weather and geocoding responses.
weather.h Declares weather parsers.
weather_codes.h Maps WMO conditions.
wifi_pick.h Implements network failover selection.
tools/​gen_helper_header.py Embeds the Spotify helper.
tools/​gen_tz_table.py Generates timezone mappings.
tools/​spotify_link.py Provides desktop Spotify linking.
tools/​tests/​battery_sim.cpp Simulates battery estimates.
tools/​tests/​charge_stats.cpp Evaluates charge detection.
tools/​tests/​check.h Provides host-test assertions.
tools/​tests/​fixtures/​esp32s3_app_head.bin Supplies application-image test data.
tools/​tests/​fixtures/​esp32s3_bootloader_head.bin Supplies bootloader test data.
tools/​tests/​fixtures/​open-meteo-forecast-vasse.json Supplies forecast test data.
tools/​tests/​fixtures/​open-meteo-geocode-vasse.json Supplies geocoding test data.
tools/​tests/​fixtures/​spotify-player-ad.json Supplies Spotify advertisement data.
tools/​tests/​fixtures/​spotify-player-episode.json Supplies Spotify episode data.
tools/​tests/​fixtures/​spotify-player-track.json Supplies Spotify track data.
tools/​tests/​run_charge_stats.sh Runs charging simulations.
tools/​tests/​run_tests.sh Builds and runs host tests.
tools/​tests/​test_features.cpp Tests settings, power, and update logic.
tools/​tests/​test_logic.cpp Tests parsers and utility logic.
tools/​tests/​test_spotify_link.py Tests the linking helper.
tools/​ui_preview/​build_u8g2.sh Builds host-side U8g2 objects.
tools/​ui_preview/​fuzz.cpp Fuzzes UI rendering.
tools/​ui_preview/​host_display.h Implements the preview framebuffer.
tools/​ui_preview/​preview.cpp Generates preview scenarios.
tools/​ui_preview/​run_fuzz.sh Runs sanitizer-backed fuzzing.
tools/​ui_preview/​run_preview.sh Builds and runs previews.
tools/​ui_preview/​to_png.py Converts previews to PNG.
docs/​ESP32-S3-RLCD-Config.example.txt Provides an example configuration.
docs/​battery-empty.png Illustrates empty-battery UI.
docs/​battery-states.png Illustrates battery states.
docs/​dashboard-12h.png Shows the 12-hour dashboard.
docs/​dashboard-idle.png Shows the idle dashboard.
docs/​dashboard.png Shows the primary dashboard.
docs/​firmware-update.png Shows update progress.
docs/​info.png Shows the information page.
docs/​key-feedback.png Shows key feedback.
docs/​legend.png Documents dashboard elements.
docs/​low-battery.png Shows low-battery UI.
docs/​now-playing.png Shows Spotify playback.
docs/​power.png Shows power information.
docs/​weather-band.png Shows forecast rendering.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread 02_Example/Arduino/11_Clock_Dashboard/app_settings.cpp Outdated
Comment thread 02_Example/Arduino/11_Clock_Dashboard/fw_update.cpp Outdated
Comment thread 02_Example/Arduino/11_Clock_Dashboard/weather.cpp Outdated
@platima
platima force-pushed the feat/clock-dashboard branch from ba7d7c7 to f9f6c39 Compare October 5, 2026 05:56
platima and others added 22 commits October 5, 2026 14:03
…r reply)

* Saving the SD card's settings to flash now reports a failure: every clear,
  write and remove is checked (an empty value is read back, as putString()
  returns 0 for it too), and a failed save is shown on the screen instead of
  only logged, since the card then comes out and the settings are gone at the
  next restart.
* A new firmware's trial flag is cleared only after the bootloader has accepted
  the confirmation.  A failed confirmation is tried again every 5 s, five
  times, and the Info page says "NOT confirmed yet" until it works.
* The weather reply has to carry every field the clock shows as a fact (the
  current temperature, feels-like, humidity, wind, condition and day or night,
  and each day's condition and temperatures) instead of turning a missing one
  into a clear sky and 0 % humidity.  The rain chance, sunrise, sunset and UV
  stay optional: the service leaves them null where it has no figure.  A zone
  name without an offset no longer counts as a zone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The ADC and the 1:3 divider can read a few percent low: a full cell (4.20 V on a
LiPo tester) showed as about 4.13 V, so the gauge stopped at 93 % and the charge
detector, which calls a cell full from 4.16 V, never said so.

* New setting battery_calibration, a multiplier for the measured voltage (the
  real voltage divided by the one shown; 0.80 to 1.25, default 1).
  readBattery() applies it, so the gauge, the runtime estimate, the shutdown and
  the charge detection all work with the corrected voltage, and the Power page
  shows the factor.
* Serial command "batcal 4.20" measures for two seconds (the median of 25
  readings), works the factor out from what a tester shows, saves it and
  restarts; "batcal reset" undoes it.
* config.h has defaults for both battery settings (BATTERY_CAPACITY_MAH and
  BATTERY_CALIBRATION), which secrets.h can override.

The factor arithmetic and the setting are covered by host tests; the ADC and the
serial command have not run on the board yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The charge state is now drawn inside the battery body instead of as a separate
icon beside it: a bolt in a cleared window of the fill while charging, a tick
cut out of the solid fill when full, nothing when it runs on the battery.  The
slot beside the gauge is gone, so the gauge no longer shifts sideways when the
state changes.

The pixel check makes sure the art stays inside the frame at every level and in
the inverted blink phase, that the gauge never moves with the state, and has
negative controls for art on the frame and for art that is missing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
wifi_power_save and ten more settings (wifi on/off, ntp_server, time and date
format, week numbers, spotify on/off, battery auto/none, low-battery shutdown
and its cutoff, cpu_mhz) could only be set from the SD card file.  Each now has
a macro in config.h that secrets.h can replace (WIFI_POWER_SAVE, TIME_FORMAT,
CPU_MHZ, ...), so a build can come pre-programmed with anything the card could
say.  APP_HOSTNAME and WEATHER_INTERVAL_MIN could not be replaced from
secrets.h before; they can now.

All the build defaults, the old ones included, go through build_defaults.h and
the same checks as the card (the same words, the same limits).  A value that is
refused leaves the factory value in place and is reported: in the serial log, in
a banner at start-up and on the Power and settings page.

test_builddefaults is built four ways, with a header standing in for secrets.h:
nothing set (equal to the settings' own defaults), everything set (equal to the
card file setting by setting, so a setting added without a build-time default
fails and is named), mistakes (refused and reported) and values on the limits.
It also checks that every macro is guarded by #ifndef and documented in the
README and in secrets.example.h.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Pulled from USB while full, the cell gives no step, and with a light load (the
clock with its radio mostly off draws 10 to 45 mA) the top of the curve falls
by only 0.07 to 0.3 mV a minute: the twelve-minute trend called that flat, so
the gauge kept its tick at 96 % and the Left line said "full" instead of
working out a runtime.

charge.h now also keeps one level per five minutes for four hours.  A charger
holding the cell keeps it within a few millivolts of its plateau, a load takes
it down for good: FULL ends when a slow level is 13 mV under the plateau, and
once FULL has ended for any reason flat cannot bring it back until the last
hour has not fallen by more than 1.2 mV.  In simulation a fall of 0.3 mV/min is
seen after 48 minutes (24 with the usual step at unplugging) instead of 81, a
fall of 0.07 mV/min after about three hours instead of never.  What a charger
does itself (a plateau that moves by +-4 mV, the cell relaxing after the end of
charge) still changes nothing.

Tests: slow-fall, plateau-jitter (300 sequences), relaxation, re-plug and
stickiness scenarios in test_logic, the same light loads in charge_stats, 16
mutants of the new rule all caught.  The test run now stops on any
undefined-behaviour report (they were only printed before), which found no
problem in the existing tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…tery

Two new settings, both off by default and both untried on the board:

wifi_mode = sync switches the radio off between sessions.  A session starts
when something needs the network (the weather every weather_interval_min, the
network time about hourly, a look at Spotify, a key press, the Now Playing page,
music playing), lasts while there is something to do and ends 2.5 seconds after.
A network that cannot be joined is retried after 1, 2, 5, 10 and then every 15
minutes; the weather and the place back off the same way while the radio is on.
radio_plan.h holds the rules (pure, simulated over a day on a PC: the radio is
on 0.6 % of the time, 96 sessions), net_task.cpp starts and stops the WiFi
stack and SNTP around each session, spotify.cpp tells the planner when it needs
the radio and lets go of its connections and the link page when it goes off.

cpu_idle_mhz (off, 80, 40, 20, 10) lowers the CPU clock while the radio is off
(also with wifi = off).  clock_policy.h keeps it at cpu_mhz while the radio is
on or about to be (WiFi works from 80 MHz up), for 8 seconds after a button
press, and while a computer is on the USB port (the console may stop below 80
MHz).  The UI loop, which owns the SPI bus, applies it; FramePlanner is told
about each change so the first frame is planned with the new draw and send costs.

Also: the picker goes back to the network that worked after a session, the Info
and Power pages show the radio's share and the next session, and the console
has a "power" command.  Both settings have build-time macros (WIFI_MODE,
CPU_IDLE_MHZ), documented in the README ("Saving power").

Tests: test_radio (sessions, back-off, a simulated day, clock policy, the frame
planner across clock changes) and 28 mutants of it, all caught.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…r settings

Follow-ups to the WiFi sync mode and the idle clock.  Like those, nothing here
has been run on the board.

A network that is not there is no longer a failed join.  The WiFi stack's "no
such network" (counted from the station's disconnect events; radio_plan.h,
Search) ends the session after one scan per network instead of after 25 or 45
seconds, and the clock looks again every 2 minutes for the first ten and every
5 from then on, so a hotspot switched on at work is found without waiting out a
back-off.  Any button makes it look at once, and a key press buys one attempt,
not 45 seconds of them.  The 1, 2, 5, 10, 15 minute back-off stays for a
network that is there and refuses the clock.  Should the radio report
differently from what the code expects, the old behaviour is what is left.

spotify_live (SPOTIFY_LIVE): off lets the radio sleep while music plays and
looks again when the track should be over.  Default on, as before.

console (CONSOLE_MODE): auto shuts the USB serial console down when no computer
has been on the port for ten seconds, off does so at the end of start-up: no
more logging, the driver stopped, the USB transceiver off.  KEY held at start-up
keeps it for that run, and a new "set name = value" command changes a setting
from the console.

cpu_idle_mhz no longer offers 10 MHz: a frame would take a quarter of a second
to draw.  40 is the lowest clock Espressif gives current figures for, 20 the
lowest the Arduino core lists.  With an idle clock set the buttons are also
read by interrupt and replayed to the click detector, so a tap that begins and
ends inside a slow frame is not lost.

The unused audio chips are told to power down at start-up, with what Waveshare's
audio example writes when it closes them, and their I2S lines are given a level
(AUDIO_CHIPS_STANDBY 0 leaves them alone).

Fixed on the way, found by simulating weeks of uptime: times kept as 32-bit
milliseconds came round again after 24.8 days.  In sync mode the planner then
stopped starting sessions by itself, and a key press, the Now Playing page or a
button press from 25 days before held the radio or the CPU clock up.  The
battery estimate and the low-battery guard now take their seconds from the
64-bit timer.

Tests: test_radio 75,928 checks (the planner by hand and over simulated days:
away, refused, music with and without spotify_live, the wrap), test_logic
1,480, test_features 32,714; 103 mutants of the new logic, all caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two more times kept as 32-bit milliseconds where 0 stood for "none" or "now":

* a rejected access token was marked with 0, which reads as "valid for another
  25 days" once millis() is past 2^31, so the clock would have kept sending the
  token Spotify had just refused;
* each radio session of wifi_mode = sync set the next poll to 0 for "now",
  which after 24.8 days means "in 25 days": the session's look at the player
  would never have happened, and every session would have ended as a failure
  after three minutes.

Also: a poll that has been due for weeks (no network all that time) stays due,
and with console = on the USB port is not looked at at all, as before.

Not run on the board.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Sketch > Export Compiled Binary" (the README's way to get the file for an SD card update) writes the images into build/ inside the sketch folder, and they hold whatever secrets.h had: the WiFi password, the Spotify id.  Nothing ignored them, so a broad git add would have committed them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SD card is only read while the clock starts, and until now the only
restart without a computer was to take the power away.  KEY held for five
seconds shows a banner, and letting go restarts the clock: a card that was put
in while it ran gets read (settings, a firmware file) without the battery
coming out.  It waits for the button to come up because KEY held while the
clock starts means "keep the console" (and "start anyway" on the battery-empty
screen).  HoldRelease in buttons.h, fed from the click detector's debounced
state; the legend page's line for KEY says so.

Also: a console that was shut down (console = auto / off) left the two USB pins
as outputs held low, which a restart that is not a power-on does not undo;
consoleBegin() lets go of them before the port is started, so that such a
restart can bring the USB port back.

Tests: test_logic 1,503 checks (the hold: arms once, fires on release only,
chatter, the wrap of millis(), fed from recorded changes), 8 mutants caught;
the UI preview measures the legend's KEY column against the BOOT column and the
new banner (the longest one) in its band, each with a negative control.

Not run on the board.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first card tried on the board was FAT32 by Windows' account and the clock
called it "not FAT32".  It had a GUID partition table: the FAT library in the
core is built without GPT (and without exFAT), and the clock said the same
thing for every card whose file system would not mount.

Now it looks: after a failed mount the card is started once more without a
file system and its first sector is read, and that of its first partition
(sdcard.cpp).  sd_layout.h (pure, host-tested) tells a GUID partition table,
exFAT, NTFS, an unformatted card, a FAT volume that should have mounted and
anything else apart, and the banner, the Power and settings page and the log
say which: "SD card: GPT partitions, needs MBR", "SD card is exFAT: format it
as FAT32".  The README says how to put each right.

Tests: test_features 33,056 checks (made-up sectors of every kind, the lines
for the screen), 20 mutants caught; the UI preview measures each line in its
banner.  The look at the card itself has not run on the board.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first firmware update tried on the board ended in "Cannot read the file
(card error)" with the bar almost full.  The updater read the file's first 208
bytes for a look at the header and then began the checksum pass from where that
read had left off, not from the start: 208 bytes off, it ran out of file at the
very end, on every file.  Nothing was written.  The code had only ever been
compiled; the tests covered which file to accept, not how it was read.

The two passes (check the whole file against its SHA-256, then write it to the
other slot) are now fwlogic::installImage(), in which every read names its
place in the file, written against three small interfaces: the file, the hash,
the slot.  fw_update.cpp plugs in the card, mbedTLS and the Update class.
tools/tests runs the same code with a made-up file and flash: good files of
many sizes and read sizes, a flipped bit anywhere, a copy cut short, card and
flash errors in either pass, nothing written before the check is through, and
the mistake itself (a file read as "what comes next" fails just as the clock
did).  21 mutants, all caught.

A clock running a build from before this fix cannot be updated from the card
and needs one upload over USB.  Seen on the board with that try: the card
mounts, the file is found and read end to end.  Still not run there: writing
the slot, the restart into it, the trial minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The version is a constant that nobody had touched since the sketch was
published: every build of this branch said 1.3, the one with the mended SD
card updater as much as those without it.  It is 1.4 now, and the README has
a "Versions" section that says what the number stands for (it goes up by hand
with each batch of changes handed over to run on a board) and what came with
1.4 and with 1.3.

A version and a date still cannot tell two builds of one day apart, so the
Firmware line of the System info page now ends in a build id:

    Firmware  RLCD Clock 1.4, 2026-10-07, build 3f9a12c

It is the first seven hex digits of the ELF hash in the app descriptor: the
hash by which the updater already tells builds apart and which "fw" prints on
the console.  It is the SHA-256 of the .ino.elf built along with the .ino.bin
(compared on an exported build), so a file on a PC can be matched to a clock.
The start-up line of the log has it too.  A static_assert keeps the row from
being cut short by a longer name or version.

tools/tests: the version in config.h has to be the one the README gives, at
its top and first in its list of versions; the check is shown to fail when
either place says something else.

Not run on the board: the call that reads the hash there has not been seen to
answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found on the board: a card with four new settings and a firmware file.  The
update went in, and the first banner of the new firmware said "SD settings:
nothing new" of settings that had just been added.  They were in force.  The
start that installs a firmware applies the settings file first, saves it, and
restarts before it gets to its banner; the next start reads the same file and
finds nothing left to change.

The number of settings a card changed is now kept in flash until a banner has
told of it, so the start after an update says "SD settings: 4 changed".  A
firmware installed by 1.4 or older gets no such count: it says "SD settings:
4 in force" and leaves "none new" unsaid, as it cannot know.  Apart from
updates, a file whose settings all apply already says "4 in force, none new"
(was "nothing new"), and a file that is all comments "the file sets nothing".

The texts come from one pure function (formatSdBanner, settings.h): tested
with the card's own four lines applied twice, 17 mutants caught, every text
measured on the rendered banner.  Keeping the count in flash cannot be tested
off the board.

README: what the owner saw on the board today is noted where it was listed as
untried (a settings file applied from a card, a firmware installed from it,
the processor at 20 MHz with the pages drawn at that clock), and "Versions"
has 1.5.

Not run on the board.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…harger

Found on the board: restarted on its battery at 99 %, with the radio mostly
off and the processor at 20 MHz, the clock said "Left: full" and gave no
estimate.  Two things were behind it, both of them old, and both harmless
only as long as the clock drew enough for the voltage to be seen falling.

charge.h called a cell full when it sat flat at the top of the curve for
twelve minutes.  Under a light load a cell that carries the clock is just as
flat (0.03 to 0.1 mV a minute), so every start on a nearly full battery read
as full eleven minutes later, for hours.  Flat at the top is now FULL only for
a cell that was seen charging up to there, or that has not come down in 160
minutes of the slow history.  "Not come down" is judged against the highest
stretch in the window, not the oldest: a charge that ends inside the window
starts low, and the fall after it passed for "no lower than before" (the
rule that lets FULL come back was fooled the same way; the tests had only
ever started on a flat cell, never with the charge before it).

The estimator was fed only once the detector said "discharging", which needs
a fall of 0.3 mV a minute in twelve minutes.  A clock that draws 10 mA never
shows one, so after any restart on the battery there was no estimate, at any
charge level.  It is now fed whenever no charger shows (battest::Gate): the
state is neither charging nor full.  The Left line says "no drain to measure
(on a charger?)" where nothing falls, and the Info page "on battery?" where
it said "unknown".

Cost: a clock that is started on a charger which has already finished shows
the tick after about three hours instead of eleven minutes.

tools/tests: the detector's setups now charge the cell to full instead of
starting flat; new cases for a start on the battery at 99 % (0.03 to 0.12
mV a minute, sixteen hours, never full) and on a finished charger; the
detector, gate and estimator wired as in the sketch give a figure within the
hour and the right rate after five.  19 mutants, all caught.  charge_stats:
0 false "full" in 1200 simulated sixteen hour runs.  Simulated noise, not
the board's: none of this has run there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
To see what a power setting saves, one run is made with each on the same cell
and the clock's own readings are written down.  docs/power-log.csv is the
sheet for that: one line per reading, with the settings of the run, what the
two info pages show, and room for a meter's figure.  It comes with a line for
each of five runs (stock with and without WiFi, a middle ground, the two
lowest).  The README says how a run is made and where each column is read
("Logging a battery run").

The Left line gave the rate with one decimal.  With the radio mostly off the
whole figure is 0.2 to 0.7 %/h, so two settings a third apart read the same:
it has two decimals now (below 10 %/h), it keeps giving the rate when the time
left is beyond a month (">30 d (0.11 %/h over 480 min)" where it said "more
than a month at this rate"), and the Current line has a decimal below 100 mA.
Nothing else changes in the firmware, so a run made with 1.5 compares with one
made with 1.6.

tools/tests reads the sheet as it will be read later: every line needs as many
fields as the heading, the heading's names have to be explained in the README,
and a column named after a setting may hold only what the settings file would
take (so "synch" or 30 MHz in a hand-filled line is named).  The checker is
tried on made-up sheets that are wrong in one way each.

docs/info.png and docs/power.png show the new lines; their sample figures now
agree with each other (87 % of 2500 mAh at 30 mA is three days), and the Power
page sample has the rows in the order of the real page.

Not run on the board.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read on the board: 4.223 V on the charger, 4.217 V seconds after it was
pulled, 4.210 V a minute later.  A cell just off the charger sits above the
top of the discharge curve, and so does any cell whose charger holds a shade
over 4.20 V or whose calibration is a shade high.

The gauge stops at 100 % from 4.20 V up, which is right for a gauge, and the
estimator took its percentage from the same function.  While such a cell came
down to 4.20 V (ten or twenty millivolts: hours, at 0.05 mV a minute) it saw
a level that stood still, reported no drain, and then averaged the hours
after with that.  A run started from a full charge, which is how the battery
runs are meant to be made, read far too low for most of a day.

calc::batteryPercentOpen() carries the curve on above its top with the slope
of its last stretch (10 mV to the percent), and the estimator uses it; the
time left is still worked out from no more than 100 %.

tools/tests: the curve above the top; an estimator fed 4.215 V falling 3 mV
an hour; and the detector, gate and estimator on that trace with noise, which
gave no figure at all before.  2 mutants, both caught.

README: the Versions entry of 1.6 no longer says that nothing but the display
changed; a reading a little over 4.20 V on the charger is not proof of a
wrong calibration.

Not run on the board.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Battery runs are compared by the readings of the two info pages, which had to
be copied by hand into docs/power-log.csv.  With power_log = on (off by
default; POWER_LOG in secrets.h) the clock writes them down itself: one line
of CSV ten seconds after every start and one every ten minutes, in the FAT
partition of its own flash ("ffat" in the partition scheme the README asks
for, which nothing used until now).

The lines have the sheet's thirty columns, by the same names and in the same
order, so they paste under the sheet's heading and one script reads both; a
test compares the two headings.  What the clock cannot know (the name of the
run, whether the card is still in, a meter's figure) stays empty.  The first
line of a run says why the clock started.

Getting it out: a start with an SD card in copies the log to the card as
ESP32-S3-RLCD-PowerLog.csv, once the card's settings are in force, so a card
that switches the log off still gets it; "powerlog" in the serial console
prints it and "powerlog clear" forgets it.  A Log row on the Power and
settings page says how much there is and when the last line was written, or
what is wrong, and "power" says how long the last line took to write.

Kept cheap.  The log is two files: when the newer one reaches half a megabyte
the older is deleted and the newer takes its place, so the newest 0.5 to 1 MB
stay (three to six weeks) and nothing is ever written out a second time.  The
partition is mounted once a run and stays mounted: unmounting makes the IDF's
wear levelling move a sector every time and mounting reads its position
records one by one, so doing both for every line would cost several times the
line.  A line rewrites two 4 KB sectors (the end of the file and the directory
entry), 0.1 to 0.3 s of flash activity, begun right after a second has started
so that the next one is drawn on time; by datasheet figures about 5 mA*s,
0.008 mA on average.  A clock that never had the log on never touches the
partition, and a failed mount is cleaned up by hand (the IDF leaves the wear
levelling running).

power_log.h holds everything that also runs on a PC: the line, when one is
due, the steps on the two files (against a small file interface), the POSIX
calls behind that interface, and the rows of the page.  tools/tests runs the
steps on a made-up file system that can be told to fail and on a real folder,
and puts every line through the sheet's own checks; 113 single changes to the
header were each caught by them.

power_log is the 32nd setting, so the userSet mask is full: the next one
needs a wider mask.

Not run on the board, none of it: the FAT file system in the flash, the first
format, how long a line takes, flash writes at 20 MHz, the copy to the card.
The README says so item by item, and what to look at first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The power log's test copies the longest status row for its report.  Built
without the sanitizers the compiler took the two rows of the array for one
string and warned that it might be cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KEY and BOOT held down together for ten seconds bring up a cube that turns,
with copies of itself falling away inside it for ever (an infinity mirror in
wireframe, white on black), until a button is pressed or a minute is over.
It is in no legend and not in the README, on purpose.

buttons.h: ChordHold says when two buttons have been down together long
enough.  What matters more is what must not happen meanwhile: KEY alone
restarts the clock after five seconds and BOOT alone inverts the screen after
one.  From the moment both are down until both are up and quiet again,
neither button's own gestures count, the restart hold is cancelled
(HoldRelease::cancel, also when KEY had already armed it and BOOT then
joined), and the press that ends the cube is not a click.

infinity_cube.h is the geometry (the lines of a frame at a time, for a screen
of a size); ui.cpp draws them.  While the cube is up nothing of the clock is
drawn; the battery is still read, so the charge detector does not start
afresh, and the processor stays at its working clock.

tools/tests: the two detectors, the restart hold and the chord wired as in
the sketch (together, a little too short, one after the other, one let go in
the middle, across the wrap of millis); the geometry.  The UI preview measures
three minutes of rendered frames against the edges of the screen, with a
negative control.  12 single changes to the two headers were each caught (one
by a crash).

Not run on the board: how many frames a second it manages there is not known
(a frame is about 130 lines).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What KEY and BOOT bring up after ten seconds is no longer a wireframe.  There
are two cubes now, and both are solid: only the faces that look at you are
drawn.

  * One with sand on its walls (sand_cube.h): every face has its own grains in
    a grid of 28 by 28, and they run to whichever side of the face is down the
    screen at the moment, heap up there, and pour away as the cube turns on.
  * One of mirrors (infinity_cube.h): each face is a window on the lattice of
    the cube's own lit edges, reflected without end, clipped to the face in
    three dimensions and fainter with every reflection (solid, dashes, dots,
    nothing).  A pulse of light comes up out of the depth now and then.

They share one tumble (infcube::tumble): about all three axes at once, each
now faster, now slower, now the other way, by slow swings that a seed picks
anew for every showing.  It is a function of one number, so counting that
number down turns the cube exactly back the way it came.

While a cube is up, a click of KEY does that (turns it back), a click of BOOT
shows the other cube where this one is, and a long press of either brings the
clock back, as do two minutes without a button.  Before, any press ended it.

tools/tests: the buttons wired as in the sketch, with the cube taking their
gestures while it is up; the tumble (never skewed, never a jerk, every side
comes to the front, another seed another way); the sand (none lost in ten
minutes of tumbling, it ends up where down is on the screen, a flat face
keeps it still); the lattice.  The UI preview measures three minutes of each
cube, for two seeds, against the edges of the screen.  22 single changes to
the three headers: 20 caught by a check, 2 by the test program falling over.

Not run on the board: what frame rate either cube reaches there is not known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The outline of the analogue clock was two circles, one inside the other (r and
r - 1).  Each is rounded to the pixel grid by itself, and where the two round
differently a pixel between them stays empty: the owner saw them on the panel
as single-pixel holes.  ui.cpp now draws the outline as one ring, every pixel
whose middle lies between r - 1.5 and r + 0.5 from the centre, in runs.

The UI preview measures it: over twelve times of day no pixel of that band is
missing and none lies outside it.  Its negative control draws the two circles
again and finds 32 pixels of the band left white.  Five of the README's
pictures show the clock and are rendered anew.

While a cube is up, a click of KEY no longer turns it back the way it came.
It gives it a shove (infcube::Shove): off in a direction that chance picks,
at 260 degrees a second to begin with, dying away to a third in every 0.6 s,
while the tumbling goes on underneath.  The shoves add up, and what they have
turned the cube by goes into tumble() beside the time.  BOOT and the long
press are as they were.

tools/tests: a shove has its set speed whatever the direction, the directions
are all different and average out to none, it comes to the same counted in
one go or frame by frame, it is still going after a sixth of a second and
over within two, and a cube with a shove in it is still three unit axes at
right angles.  Eight single changes to it, all caught.  1,903 checks in
test_logic.

Not run on the board: the outline is measured on the host's rendering only
(the same drawing code), and the shove has not been seen there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Waveshare-wulu

Copy link
Copy Markdown

Hi @platima ,

Thank you for sharing this impressive project and for all the effort you've put into it! It's great to see the ESP32-S3-RLCD-4.2 being used in such a comprehensive application.

Given the scope and complexity of the project, we'd suggest maintaining it in a dedicated GitHub repository rather than including it directly in our example collection. This would also give you more flexibility to continue developing and improving it independently.

If you decide to create a separate repository, please feel free to share the link with us. We'd be happy to consider featuring your project in the Community Showcase section of our product documentation, so more users can discover it.

Thanks again for your contribution and for supporting the community!

Best regards,
Wulu

@platima

platima commented Oct 8, 2026 via email

Copy link
Copy Markdown
Author

A checklist of everything that has been tested on a PC but not yet on the
board, 36 items, each marked seen, partly, not yet or problem.  The statuses
come from what the board's owner has reported and from the clock's own power
log of 8 to 17 October (firmware 1.7 to 1.9, sync mode at 20 MHz), which
settles several of them: the build id call, the charging and full
indicators, the reading on the charger (4.28 V, against 4.20 V for a full
cell off it), the log itself and its copy to the card.

It also shows one problem: just after a charge the runtime estimate said 8
to 16 days, and the cell went from 4.20 V to 3.71 V in about 57 hours.  The
voltage barely moves for the first 12 hours after a charge, and the battery
curve counts that stretch as 5 %.

The README's "Not yet tried on the board" points to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the battery out, on USB at 5 V, the clock draws 21.1 to 21.4 mA at the
20 MHz idle clock, about 29 mA at 80 MHz and 33 to 36 mA with the radio on.
With the radio on about 2 % of the time, the idle clock is nearly all of the
energy.

The cell is a good-quality 3400 mAh one.  At that draw it should last
roughly five days, yet 4.20 to 3.71 V took 57 hours, so at 3.7 V this cell is
probably still about half full where the gauge says 15 to 20 %: the battery
curve looks wrong at the bottom as well as the top.  Item 12 now asks for a
run down to the shutdown with the log on, which settles it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants