Repository navigation
Conversation
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>
There was a problem hiding this comment.
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
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.
ba7d7c7 to
f9f6c39
Compare
…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>
|
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, |
|
Hey Wulu,
Not a problem at all. I was going to create a separate repo anyway, but
would you like me to cancel this PR and submit a new one for just the
basic clock functionality with wifi & NTP?
Best,
P
|
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>
light_sleep = on (LIGHT_SLEEP 1; off by default) puts the ESP32-S3 into light sleep where the UI loop used to nap, up to the next thing that is due (the frame planner's next step, slow work, a banner), woken by the RTC timer or a low level on KEY / BOOT. Never while the radio is on, the console runs, a button is down, pressed in the last 8 s or recorded and not yet taken, or the loop has a redraw, the cube or a restart. * light_sleep.h (pure, host-tested): the decision, the steps of one nap against a small interface (power.cpp has the SDK calls), WallFix (the SDK re-derives the time of day from the RC count at every wake, awake time included; the difference is put back once it adds up to 2 ms), the share asleep, the Sleep row. * FramePlanner::idleUs(); button_edges: the pins' interrupts are held off for the nap (gpio_wakeup_enable sets a level interrupt type) and put back after it, a pin that is down then recorded as the press. * Settings: the 33rd setting; Settings::userSet is 64 bits (nothing of it is stored in flash). * Power page: a Sleep row. Power log and docs/power-log.csv: light_sleep and asleep_pct columns, a sixth run. * README: a Light sleep section with an estimate (datasheet v2.2 table 5-9 and the owner's 21 mA readings: about 14 to 16 mA on USB instead of 21; unmeasured), TESTING.md item 37. Tests: test_logic 1,903, test_features 42,187, test_radio 76,113 (new: idleUs probed in a simulated loop, the decision, the nap's steps, the time of day over a day, the loop over hours with and without it), build defaults 15 / 139 / 22 / 13, 0 failed; preview checks pass; 45 of 45 mutants caught. arduino-cli build: 1,540,689 bytes, no warnings in the sketch's files. Nothing of it has run on the board. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The curve from voltage to charge was a generic one, and it did not fit the board owner's cell at all. They ran a 3400 mAh 18650 from a full charge until the clock shut itself down at 3.30 V (17 to 20 October, firmware 1.9, wifi_mode = sync at 20 MHz, the power log on): 81 hours at a steady load. At a steady load the charge used is in proportion to the time, so each point of the new curve in calc.h is the share of that run still to go when the cell got there. Half charge is now 3.78 V, 20 % is 3.58 V, 5 % is 3.46 V. The old curve called 3.70 V 12 % where the cell still had 39 %, so a clock recharged at its "20 %" was about half full; and it called the first hours after a charge 5 %, which is why the estimate said 8 to 16 days for a cell that lasts 3.4. tools/tests: the run itself is a fixture (fixtures/rundown-2026-10.csv, a reading every ten minutes) and test_features replays it through the estimator, read every 5 s as the clock does. From the third hour off the charger on, 72 of 73 hourly figures are within 15 % of the time really left (4 % out on average, the worst 20 %); in the first three hours the figure is short, never long, and the test holds it to that. The negative control, the same test with the old curve: 11 of 73 within 15 %, up to three times too long, five checks fail. The curve checks and the light-load estimate tests take their figures from the new points (the latter now from the curve itself), the README's example power-log line reads 4.117 V for its 87.0 %, and battery_sim's sweep, rerun on the new curve, keeps the chosen window (median error 2 to 8 % in steady drains, was 4 to 16 %). README: the Battery section says where the curve comes from and what the estimate does in the first hours; TESTING.md items 11 to 14 updated. Not run on the board. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
display_power = low (DISPLAY_POWER "low"; normal by default) switches the ST7305 from its high power mode (25.5 Hz refresh) to its low power mode at 8 Hz, by the datasheet's sequence (V0.2, 7.11: the source voltages, 20 ms, 39h, 100 ms; back: 38h, 300 ms, the voltages, 20 ms). The frames are sent 43 ms earlier for the slower refresh, so the seconds still land on the second on average, within about 63 ms instead of 20. * display_power.h (pure, host-tested): FRCTRL 0x05 (HPM 25.5 Hz as before, LPM 8 Hz instead of Waveshare's unused 1 Hz), the frame periods, the extra latency, the switch steps. * ST7305_U8g2: B2h = 0x05 in fullInit, setLowPower(); FramePlanner::setLatencyUs(). * .ino: applyDisplayPower() at start-up and on the battery-empty screen; the cube runs in the high power mode; a Display row on the Power page (left out on the fullest page, so Units always fits); `power` says the mode. * Settings: the 34th (NVS "dpower"). Power log and docs/power-log.csv: a display_power column, a seventh run. * README: "The display's low power mode" (datasheet facts apart from what is worked out; the current saved is a guess, about 1 mA on USB from the only published figures, unmeasured), Versions 1.11; TESTING.md item 38. Tests: test_logic 1,922 (new: the panel's refresh modelled at 25.5 and 8 Hz with its oscillator 2 % off: -0.4 and -1.3 ms on average, within -21..+21 and -64..+63 ms; the 25.5 Hz timing on the 8 Hz panel +43 ms, the negative control), test_features 50,469, test_radio 76,118 (idleUs with the new figure and a switch half-way; the light-sleep loop over 3 h with it: 0 late frames, asleep 76.7 %), build defaults 15 / 143 / 22 / 13, 0 failed; preview checks pass. arduino-cli build: 1,541,909 bytes, no warnings in the sketch's files. Nothing of it has run on the board. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in two commits made on side branches from 6677b1b: light sleep between the frames (light_sleep, 0206693) and the ST7305's low power mode (display_power, 9105ab9). The first was numbered 1.10 on its branch, but 1.10 is now the measured battery curve, so the two go out together as 1.11. Conflicts, as resolved: * config.h: 1.11. * README: one 1.11 entry for both, above 1.10; the power log's notes say that 1.11 adds three columns at once (light_sleep, asleep_pct, display_power), so the change of heading happens once; light sleep is "new in 1.11" throughout; the example line keeps 1.10's 4.117 V for 87.0 % and gains the new asleep_pct column. * test_features, preview.cpp: the same, in the example line and on the Power page. Also in the docs: TESTING.md item 12 (seen on the board: the low-battery blink and the battery-empty screen at the shutdown) and the light-sleep section's heading; the README's "Not yet tried on the board" (the shutdown seen, the estimate now from the measured curve) and the measured runtime next to the old estimate in "Power" (81 hours from a 3400 mAh cell with the power saving on, about 40 mA, more than the USB reading makes; why is not known). Tests: test_logic 1,922, test_features 50,477, test_radio 76,118, build defaults 15 / 143 / 22 / 13, 0 failed; preview checks pass. Neither light sleep nor the low power mode has run on the board. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The battery-empty screen stays on the panel for as long as the clock sleeps, days perhaps, and the 3.3 V rail stays on through the deep sleep, so the ST7305 went on refreshing that one picture 25.5 times a second (8 with display_power = low). Before every deep sleep the clock now puts it into the low power mode at its slowest rate, 0.25 Hz (FRCTRL B2h = 0x00, written in the high power mode as the start-up writes it, then the datasheet's switch), whatever display_power says. A wake-up that starts the clock runs fullInit() and with it the usual rate; the five-minutely battery check does not touch the panel. A sub-session proposed this on 20 October; it had not been done. The only published figures for this panel (a community ESPHome driver: about 5 mA at 51 Hz, about 1 mA at 1 Hz) put the refresh at about 3 mA at 25.5 Hz, which would make it the largest drain of a sleeping clock by far. Not measured, and how the screen looks at 0.25 Hz has not been seen. * display_power.h: kRestFrameRate; ST7305_U8g2::setRestMode(); the .ino's displayRest() before each of the three powerDeepSleep() calls (the shutdown, the too-low start, sleeptest). * tools/tests: the register value (test_logic), and a scan of the .ino that every powerDeepSleep() follows a displayRest() (test_features; 3 of 3, and 0 of 3 with them taken out). * README: the shutdown and the display's low power mode say so, Versions 1.12, the untested list; TESTING.md item 39. Tests: test_logic 1,923, test_features 50,480, test_radio 76,118, build defaults 15 / 143 / 22 / 13, 0 failed. Not run on the board. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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 from10_U8G2_Test). It is one new folder; nothing else in the repository changes.What it does
.ino.binto 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.Building
Same board settings as
Tools-Configuration.png, with PSRAM disabled and CPU 80 MHz (the example's README has the exact list and anarduino-cliline). It needs U8g2 (the copy in01_Arduino_Libraries/U8g2is fine) and ArduinoJson 7.x. It compiles as it is: the privatesecrets.his 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
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.1.5
1.6
docs/power-log.csvis 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
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