To build this project, use Visual Studio Code with PlatformIO extension
src/
main.cpp setup() + loop(); screen/slot definitions, touch gestures
globals.h / .cpp APP_VERSION, global instances (dc, tm, sw, wp, buzzer),
shared helpers (parseHHMM, ALARM_CUTOFF_MS)
config/
webPrefsConfig.h DeviceConfig struct + input_fields[] (THE config definition)
webPrefsMacros.h FIELD_* macros
settings.html WebPrefs form (data-up / data-uppost / activation-rules)
hardware.h app-side pins (SPEAKER_PIN); display pins live in RetroGfx
storage.cpp EEPROM read/write, layout signature, WebPrefs callbacks
network.cpp WiFi, NTP, AP rescue, network info screen
<feature>.cpp crypto / weather / eggtimer / alarmclock / widgets / screens
lib/
WebPrefs/ async config server; html/ = source for page.h (auto-generated)
RetroGfx/ Display, ColorMatrix, PixelFont, Icons (LovyanGFX wrapper)
MelodyPlayer/
ApiClient/
SimpleWifi/
TimeManager/
SerLog/
data/ LittleFS content (/ColorMap.bmp, /fonts/*.bmp)
include/
secrets_template.h built-in credential defaults (committed)
secrets.h your own copy, git-ignored (see below)
minify.py PRE build: generates lib/WebPrefs/page.h (gzip) from html + settings.html
uploadlfs.py prod only: builds + flashes LittleFS automatically after upload
partitions_retroclock.csv partition table (OTA app0/app1 + littlefs)
(Optional) For testing and debugging purposes only globals.h includes secrets.h if it exists
and falls back to secrets_template.h otherwise, so the project builds out of the box.
For your own defaults, copy the template to include/secrets.h and edit it there; .gitignore
keeps that copy out of the repository, the template stays untouched.
These values are only the built-in defaults, used when the stored config is invalid or restore mode wipes it. They are compiled into the firmware in plain text, so a build made with real credentials must not be published or flashed onto anyone else's board.
A persistent parameter consists of three parts that must stay in sync:
Add it inside struct DeviceConfig before the marker char __STORAGE_END__;
(everything after __STORAGE_END__ is runtime-only and is NOT written to flash):
uint16_t my_new_value; // e.g. a new color valueFIELD_UINT16( my_new_value, "10", 0, 31, nullptr ),
// ^field name ^def ^min ^max ^callback (or nullptr)The VAR_NAME() trick in the macros uses the field name as a string ID - the name
in the FIELD_* macro must match the struct member and the id="" in
settings.html exactly.
<label>My New Value (0-31)
<input type="text" data-up id="my_new_value"></label>| Macro | Type | Signature |
|---|---|---|
FIELD_STRING |
char[] | (field, default, min_len, callback) |
FIELD_STRING_SZ |
char[] | (field, default, min_len, max_len, callback) |
FIELD_STRING_VALID |
char[] | (field, default, min_len, callback, validator) |
FIELD_PASSWORD / _SZ |
char[] | like STRING; field is masked |
FIELD_UINT16 / INT16 / UINT32 / INT32 |
int | (field, default, min, max, callback) |
FIELD_FLOAT |
float | (field, default, min, max, callback) - min/max are int32-bounded |
FIELD_CHECKBOX |
bool | (field, default, callback) - range fixed 0..1, default "on"/"off" |
Size/offset are derived automatically via sizeof/offsetof, so no manual byte
bookkeeping is needed.
storage.cpp::configLayoutSignature() hashes type, size, offset, name, min, max
of every field. If the layout changes (field added/removed/reordered/resized), the
signature stored in flash no longer matches -> on the next boot the defaults are
loaded automatically (no crash, but user settings are lost). A pure value/default
change leaves stored settings intact.
page.h is regenerated from settings.html on every build - never edit
page.h by hand.
Callbacks are declared in webPrefsConfig.h and implemented in storage.cpp /
alarmclock.cpp / crypto.cpp. There are two layers:
wp.begin(80, &dc, input_fields, cbDone, cbSave, cbBeforeResponse);
wp.setBusyCheck([]() { return (bool)g_fetchActive; });| Callback | When | Purpose |
|---|---|---|
cbBeforeResponse() |
before every JSON response (/getJson) |
write live values into dc before the page reads them (e.g. date_time, info_text, alarm_state = buzzer.isPlaying()) |
cbSave(int params) |
after every POST | write to flash - only when params > 1 (saves write cycles: single instant-fields are not persisted) |
cbDone() |
click on "Done/Reboot" | ESP.restart() |
setBusyCheck(fn) |
checked before every request | if fn()==true the request gets "busy" (blocks WebPrefs while an SSL fetch runs -> heap protection) |
Key point: a field callback fires only on an instant POST of a single field
(data-uppost, params == 1 in onDataReceive). On a full form save (multiple
fields at once) the field callbacks do not run - there only cbSave() runs,
and the reboot via "Done" applies the values.
data-uppost field changed
-> POST /postForm (1 param)
-> setValue() writes dc.<field> (after optional validate)
-> fields[i].callback() ← LIVE, immediately, no reboot
-> cbSave(1) ← does NOT write (params==1)
Therefore: fields that should take effect immediately (brightness, volume,
alarm-off) need data-uppost and a field callback. Examples from the project:
| Field | Callback | Effect |
|---|---|---|
brightness, night_brightness, night_from/to |
cbSetBrightness |
set backlight immediately |
crypto_alerts |
cryptoReloadAlerts |
re-parse + re-baseline alerts live (no reboot) |
alarm_state |
cbAlarmSwitch |
stop the buzzer when the switch is turned off |
ntp_server, gmt_offset, tz_string, … |
cbNTPConfigUpdate |
re-apply NTP config |
date_time |
cbSetClock |
set the clock manually |
Signature bool(*)(const char*). Runs in setValue before writing: returning
false discards the input and keeps the previous value. Examples: isValidTime
("HH:MM"), isValidDays (7-char 0/1 mask). Perfect so a typo can never move the
alarm or store garbage.
settings.html is only the form body; head.html, script.js and style.css
are assembled around it by minify.py. Each input's id must match the field
name in input_fields[] (the JSON roundtrip is keyed by id).
The change is not sent immediately. script.js::updatePost(el, post=false)
only shows the banner "Save & Reboot to apply". The value is written to dc on the
full form save (the "Save" button) and takes effect via "Done/Reboot".
-> for structural/boot parameters (SSID, screen layout, icon colors, …).
<input type="text" data-up id="wifi_ssid">The change is sent immediately as a single-field POST
(updatePost(el, post=true) -> POST /postForm with 1 param). The server runs the
field callback live. But it is only persisted on the next full save (cbSave
skips single-param posts).
-> for parameters you want to feel instantly (brightness, volume, alarm-off, alerts).
<input type="text" data-uppost id="brightness">Without
data-up/data-upposta field is neither sent live nor captured on save (nonameattribute -> not part of the FormData). One attribute is mandatory.
A JSON array of 1-based, absolute field indices in DOM order.
script.js::applyRuleset() counts over the selector set
input[checkbox|text|password], textarea (order = as in the HTML).
Rule semantics per index i:
- Positive (
i > 0): the target is enabled when the checkbox is on, disabled otherwise. - Negative (
-i): inverse - the target is disabled/cleared when the checkbox is on; if the target is itself a checkbox it is additionally force-unchecked (mutual exclusion).
Examples from settings.html:
<!-- static IP on -> fields 39..43 (IP/mask/GW/DNS1/DNS2) become editable -->
<input type="checkbox" data-up id="staticip_enabled" activation-rules="[39,40,41,42,43]">
<!-- web auth on -> user(45)+password(46) become editable -->
<input type="checkbox" data-up id="web_auth" activation-rules="[45,46]">
<!-- AP-only on -> mutually turns off field 38 (negative) -->
<input type="checkbox" data-up id="ap_only" activation-rules="[-38]">
<!-- NTP on -> field 48 enabled, field 52 (manual time) force-off -->
<input type="checkbox" data-uppost id="ntp_enabled" activation-rules="[48,-52]">The indices are absolute positions. Inserting/removing a field anywhere in the form shifts every following position - all
activation-rulesindices after it must be adjusted by the same amount. Theconsole.logininitEventListeners()prints the running indices on load (browser console), handy for recounting.
Three environments (platformio.ini), differing mainly in LOG_SERIAL_LEVEL
(bitmask: 1=ERROR 2=INFO 4=WARNING 8=DEBUG) and optimization:
| Env | Flags | Serial log | Auto LittleFS upload |
|---|---|---|---|
cyd-retroclock-debug |
-DDEBUG -DLOG_SERIAL_LEVEL=15 |
all | no |
cyd-retroclock-test |
-DTEST -DNDEBUG -Os -DLOG_SERIAL_LEVEL=7 |
E/I/W | no |
cyd-retroclock-prod |
-DNDEBUG -Os |
off | yes (post:uploadlfs.py) |
Boot-loop trap:
-DDEBUGwithout-DNDEBUGkeepsassert()live -> early abort. The environments above are already configured correctly.
page.h is regenerated from settings.html on every build (pre:minify.py)
after HTML changes a normal build is enough.
# Build/Flash LittleFS
pio run -e cyd-retroclock-debug -t buildfs
pio run -e cyd-retroclock-debug -t uploadfs
# Build/Flash Debug Firmware and show outputs
pio run -e cyd-retroclock-debug -t upload -t monitor
# Build/Flash Production Firmware & LittleFS (All)
pio run -e cyd-retroclock-prod -t upload
# -> in the prod env LittleFS is flashed automatically afterwards (uploadlfs.py)
# Build Test Firmware
pio run -e cyd-retroclock-test
# Fully erase flash (config/EEPROM + FS gone -> defaults on next boot)
pio run -e cyd-retroclock-prod -t erase
# Serial monitor (115200, with timestamp filter)
pio device monitor
# Clean build artifacts
pio run -t cleanapp0 ota_0 0x10000 0x160000 (1.375 MB app)
app1 ota_1 0x170000 0x160000 (OTA slot)
spiffs spiffs 0x2D0000 0x10000 (LittleFS, 64 KB - fonts/ColorMap)
The filesystem offset must match between
partitions_retroclock.csvand any shipped installer/partitions.bin- a stale offset makesdisplay.begin()fail to mount LittleFS (black screen).
Hold touch while booting -> restore mode: forces an open AP (AP_PASSWD), disables
WebPrefs auth and loads defaults so the settings page is always reachable. In
addition initWifi() automatically falls back to an open AP if a STA connection
fails (auto-rescue).
Always log through the macros LOG_SERROR / LOG_SINFO / LOG_SWARNING / LOG_SDEBUG (printf-style), never Serial.print* directly - the macros are
compiled in/out at build time depending on the LOG_SERIAL_LEVEL bitmask. The only
exceptions are the WiFi connection dots in SimpleWifi.cpp and Serial.begin() in
main.cpp. serlog.h has #pragma once (covers transitive multi-includes).
The heap is small and fragments: a TLS fetch needs a large contiguous block
(mbedTLS grabs two ~16 KB buffers per connection). Too little contiguous heap ->
fetches fail with -32512 / -11 and prices/weather silently stop updating.
Key rules when adding features:
inlinefor big shared data in headers. A plainconst std::vector(likeinput_fields) in a widely-included header gets one heap copy per.cpp- this once caused ~80% of fetch failures (~14 KB wasted). Mark itinline.- Long-lived buffers -> BSS/static or RTC_FAST, not the heap (heap buffers
fragment; BSS is near full -> mind linker overflow). See
ApiClient'sRTC_FAST_ATTRresponse buffer. - Reuse one TLS session per batch (
setupTlsClient()keep-alive), guard new handshakes withtlsHeapOk()/TLS_MIN_BLOCK, andhttp.end()before parsing.
Diagnose via max_block (largest contiguous block), not just total free -
both are logged each tick. If max_block nears ~17-20 KB, handshakes start
failing -> move a buffer off the heap or add an inline.