diff --git a/02_Example/Arduino/11_Clock_Dashboard/.gitattributes b/02_Example/Arduino/11_Clock_Dashboard/.gitattributes new file mode 100644 index 00000000..2aec7946 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/.gitattributes @@ -0,0 +1,6 @@ +# Keep LF line ends in the working tree whatever core.autocrlf says: the shell scripts in tools/ run in +# WSL / Linux (a CR breaks them) and the tests compare docs/ESP32-S3-RLCD-Config.example.txt byte for byte. +* text=auto eol=lf +*.png binary +*.pgm binary +*.bin binary diff --git a/02_Example/Arduino/11_Clock_Dashboard/.gitignore b/02_Example/Arduino/11_Clock_Dashboard/.gitignore new file mode 100644 index 00000000..940a2213 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/.gitignore @@ -0,0 +1,12 @@ +# Local credentials - copy secrets.example.h to secrets.h +secrets.h + +# Firmware images: what "Sketch > Export Compiled Binary" (or arduino-cli --export-binaries) writes here. +# They hold whatever secrets.h had (the WiFi password, the Spotify id), so they must never be committed. +/build/ +/*.bin + +# Host-side UI preview tool: build products +tools/ui_preview/build/ +tools/ui_preview/out/ +tools/tests/build/ diff --git a/02_Example/Arduino/11_Clock_Dashboard/11_Clock_Dashboard.ino b/02_Example/Arduino/11_Clock_Dashboard/11_Clock_Dashboard.ino new file mode 100644 index 00000000..1f3adee1 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/11_Clock_Dashboard.ino @@ -0,0 +1,1523 @@ +// 11_Clock_Dashboard - desk clock for the Waveshare ESP32-S3-RLCD-4.2 +// +// * joins WiFi, sets the clock over NTP and works out the time zone (with DST) +// * date (ISO by default) and time with seconds, plus an analog clock; 12 hour clock optional +// * current weather, today's high/low, the next two days, the moon and the chance of rain +// * indoor temperature and humidity from the on-board SHTC3 +// * battery gauge with a charging / discharging / full icon (worked out from the +// voltage, see charge.h) that blinks below 20 % unless it is charging; an estimate of the +// runtime left on the Info page; a gentle shutdown before the cell is flat +// * Spotify now-playing, controlled with the KEY button +// 1 click = play/pause 2 clicks = next 3 clicks = previous +// * BOOT button: click = next page, double click = home, hold = invert screen +// * settings from a file on an SD card (units, formats, WiFi, location, ...), see the README +// +// Setup: copy secrets.example.h to secrets.h and enter your WiFi details (or put them in a +// settings file on an SD card), adjust config.h if needed. Arduino IDE settings are in the README. +// +// Threads: this file is the UI task (core 1) which owns the display, the I2C +// bus and the buttons. net_task.cpp and spotify.cpp run on core 0 and hand data +// over through app_state.h, so a slow HTTPS request never stalls the clock. + +#include +#include +#include +#include + +#include "ST7305_U8g2.h" +#include "app_power_log.h" +#include "app_settings.h" +#include "app_state.h" +#include "battery_est.h" +#include "button_edges.h" +#include "buttons.h" +#include "calc.h" +#include "charge.h" +#include "clock_policy.h" +#include "config.h" +#include "console_policy.h" +#include "frame_plan.h" +#include "fw_update.h" +#include "infinity_cube.h" +#include "log.h" +#include "low_battery.h" +#include "moon.h" +#include "net_task.h" +#include "power.h" +#include "sdcard.h" +#include "sensors.h" +#include "spotify.h" +#include "timeutil.h" +#include "ui.h" +#include "util.h" + +SET_LOOP_TASK_STACK_SIZE(12 * 1024); + +static const char *TAG = "main"; +static const time_t kMinPlausibleEpoch = 1704067200; // 2024-01-01 + +static ST7305_U8g2 *s_lcd = nullptr; +static u8g2_t *s_u8g2 = nullptr; +static Preferences s_uiPrefs; // namespace "ui": remembers the screen polarity toggle + +static ClickDetector s_key(KEY_MULTI_CLICK_GAP_MS, KEY_LONG_PRESS_MS); +static ClickDetector s_boot(KEY_MULTI_CLICK_GAP_MS, BOOT_LONG_PRESS_MS); +static HoldRelease s_restartHold(KEY_RESTART_HOLD_MS); // KEY held for five seconds, then let go: restart +// KEY and BOOT held down together for ten seconds: a cube that tumbles (cubeFrame). It is in no legend and no +// manual. While it is up, KEY gives it a shove in some direction, BOOT shows the other of the two cubes, and a +// long press of either (or two minutes with no button touched) brings the clock back. +static const uint32_t kCubeHoldMs = 10000, kCubeIdleMs = 120000; +static ChordHold s_bothHeld(kCubeHoldMs, KEY_MULTI_CLICK_GAP_MS + 200); +static bool s_cubeOn = false; +static UiCube s_cube; // which cube (it stays the one that was shown last), and how far it has tumbled +static infcube::Shove s_cubeShove; // what KEY has done to it +static uint32_t s_cubeStartMs = 0, s_cubeFrameMs = 0, s_cubeTouchMs = 0; +static bool s_restartAsked = false; // ... which loop() then does + +static UiPage s_page = PAGE_DASHBOARD; +static bool s_forceRedraw = true; +static bool s_flipPolarity = false; // user toggled the screen polarity (XOR with config) + +// Frame pipeline. The coming second is drawn into the display buffer early (prepareFrame) and +// sent just before it starts (flushFrame), so a slow draw cannot make the digits late; the +// planner (frame_plan.h) decides when. +static FramePlanner s_planner(DISPLAY_LATENCY_MS * 1000); +static uint32_t s_lastFrameMs = 0; + +// The CPU clock (clock_policy.h): fast while the radio, a computer on the USB port or a button needs it +static uint32_t s_pokeUntilMs = 0; // a button was pressed: stay fast until then (0 = no) +static uint32_t s_clockSwitches = 0; // how often the clock was changed (shown by the "power" console command) +static const char *s_clockReason = ""; // why the clock is where it is, for the Power page +static int s_clockRefusedMhz = 0; // a clock the core would not set: not asked for again +static bool usbHostAttached(); // (defined with the clock code further down) + +// The USB serial console (console_policy.h, log.h) +static_assert((int)CONSOLE_ON == (int)consolepolicy::ON && (int)CONSOLE_AUTO == (int)consolepolicy::AUTO && + (int)CONSOLE_OFF == (int)consolepolicy::OFF, + "the console setting and its policy use the same numbers"); +static consolepolicy::Watch s_consoleWatch; +static bool s_consoleKeep = false; // KEY was held at start-up: the console stays on for this run +static int s_audioStandby = -1; // which audio chips took the power-down (sensors.h), -1 = not asked + +// How well the frames kept time, shown on the Info page +struct TimingStats { + uint32_t frames = 0; // frames sent at their scheduled moment + uint32_t late = 0; // ...of which this many went out more than 30 ms behind plan + int32_t worstLateUs = 0; // the worst of them + uint32_t clockSteps = 0; // times the system clock jumped (an NTP correction) by more than 5 ms + int32_t lastStepUs = 0; +}; +static TimingStats s_timing; + +static IndoorReading s_indoor; +static uint32_t s_indoorMs = 0; +static uint32_t s_lastSensorMs = 0; + +static float s_batVolts = 0; +static bool s_batHave = false; +static bool s_batLow = false; +static uint32_t s_lastBatMs = 0; +static ChargeDetector s_charge; // works out charging / discharging from how the voltage moves +static int s_chargeState = CHARGE_UNKNOWN; // UiCharge: the detector, overruled by the STAT pin if one is wired +static uint32_t s_lastBatLogMs = 0; + +static battest::Estimator s_est; // runtime left, from the discharge so far (battery_est.h) +static battest::Gate s_estGate; // ... fed whenever no charger shows +static lowbat::Guard s_guard; // switches the clock off before the cell is flat (low_battery.h) +static bool s_guardOverride = false; // the user held KEY on the "battery empty" screen: run anyway + +static char s_toast[40] = ""; +static int s_toastIcon = TOAST_NONE; +static uint32_t s_toastUntilMs = 0; +static uint32_t s_seenNoticeSeq = 0; + +// --------------------------------------------------------------------------- +// Toasts and key handling +// --------------------------------------------------------------------------- +static void showToast(const char *text, int icon, uint32_t durationMs = 2200) { + copyStr(s_toast, sizeof s_toast, text); + s_toastIcon = icon; + s_toastUntilMs = millis() + durationMs; + s_forceRedraw = true; +} + +static void applyPolarity() { + if (s_lcd) s_lcd->setInvert((DISPLAY_INK_IS_BLACK != 0) != s_flipPolarity); +} + +// KEY: Spotify transport. The toast appears immediately; the network task does +// the work and may replace the toast if Spotify refuses. +static void handleKey(ClickEvent e) { + // wifi_mode = sync: the radio comes on for any press of KEY (a few seconds before it is answered), also + // when Spotify is not set up: it is the way to say "look for the network now" + netWake(); + if (e == CLICK_LONG) { // refreshes the weather too, so it works without Spotify + showToast("Refreshing...", TOAST_NONE, 1500); + spotifyPost(SPOTIFY_CMD_REFRESH); + netRequestWeatherRefresh(); + return; + } + SpotifyStatus st; + { + StateLock lock; + st = g_state.spotify.status; + } + if (st == SPOTIFY_DISABLED) { + showToast(g_cfg.wifi ? "Spotify not set up" : "WiFi is off", TOAST_WARN); + return; + } + if (st == SPOTIFY_NEEDS_LINK) { + showToast("Link Spotify first", TOAST_WARN); + return; + } + switch (e) { + case CLICK_1: + if (st == SPOTIFY_PLAYING) { + showToast("Pause", TOAST_PAUSE); + } else { + showToast("Play", TOAST_PLAY); + } + spotifyPost(SPOTIFY_CMD_PLAY_PAUSE); + break; + case CLICK_2: + showToast("Next track", TOAST_NEXT); + spotifyPost(SPOTIFY_CMD_NEXT); + break; + case CLICK_3: + showToast("Previous track", TOAST_PREV); + spotifyPost(SPOTIFY_CMD_PREVIOUS); + break; + default: + break; + } +} + +// BOOT: pages and screen polarity. +static void handleBoot(ClickEvent e) { + netNudge(); // wifi_mode = sync: someone is here, so a clock that found none of its networks looks again now + switch (e) { + case CLICK_1: + s_page = (UiPage)((s_page + 1) % PAGE_COUNT); + break; + case CLICK_2: + s_page = PAGE_DASHBOARD; + break; + case CLICK_3: + s_page = PAGE_INFO; + break; + case CLICK_LONG: + s_flipPolarity = !s_flipPolarity; + s_uiPrefs.putBool("flip", s_flipPolarity); + applyPolarity(); + showToast("Screen inverted", TOAST_NONE, 1500); + break; + default: + break; + } + s_forceRedraw = true; +} + +static void cubeBegin(uint32_t nowMs) { + s_cubeOn = true; + s_cube.seed = esp_random() | 1u; // it tumbles another way every time + s_cube.turnMs = 0; + s_cube.elapsedMs = 0; + s_cubeShove = infcube::Shove(); + s_cubeStartMs = s_cubeFrameMs = s_cubeTouchMs = nowMs; +} + +static void cubeEnd() { + s_cubeOn = false; + s_forceRedraw = true; // the clock again, at once +} + +// A button while the cube is up. +static void cubeButton(bool key, ClickEvent e) { + if (e == CLICK_LONG) { + cubeEnd(); + return; + } + s_cubeTouchMs = millis(); + if (key) { + s_cubeShove.bump(esp_random()); // off in some direction, for a moment + } else { + s_cube.mirrors = !s_cube.mirrors; // the other cube, where this one is + } +} + +// What a button's gesture goes to: the cube while it is up, else what the button is for. +static void onKey(ClickEvent e) { + if (s_cubeOn) { + cubeButton(true, e); + } else { + handleKey(e); + } +} +static void onBoot(ClickEvent e) { + if (s_cubeOn) { + cubeButton(false, e); + } else { + handleBoot(e); + } +} + +static void pollButtons(uint32_t nowMs) { + bool active = false; + // the two buttons are down together (or were a moment ago): what each of them reports does not count + const bool muted = s_bothHeld.muted(); + if (buttonEdgesOn()) { + // With an idle clock: what the interrupts recorded while the loop was busy drawing, replayed in order, + // so that a tap which began and ended inside a slow frame still counts (button_edges.h). + bool down; + uint32_t at; + while (buttonEdgeTake(BUTTON_KEY, &down, &at)) { + active = true; + const ClickEvent e = s_key.edge(down, at); + if (e != CLICK_NONE && !muted) onKey(e); + } + while (buttonEdgeTake(BUTTON_BOOT, &down, &at)) { + active = true; + const ClickEvent e = s_boot.edge(down, at); + if (e != CLICK_NONE && !muted) onBoot(e); + } + nowMs = millis(); // not earlier than what was just replayed + } + const bool keyDown = digitalRead(PIN_KEY) == LOW, bootDown = digitalRead(PIN_BOOT) == LOW; + // (with the record, the loop's own look is one more change at "now": should the record have been full and + // the end of a press be missing from it, the press still gets the time it lasted) + ClickEvent k = buttonEdgesOn() ? s_key.edge(keyDown, nowMs) : s_key.update(keyDown, nowMs); + ClickEvent b = buttonEdgesOn() ? s_boot.edge(bootDown, nowMs) : s_boot.update(bootDown, nowMs); + // a button is down, or was a moment ago: the answer is drawn at the working clock (cpu_idle_mhz) + if (active || keyDown || bootDown || k != CLICK_NONE || b != CLICK_NONE) s_pokeUntilMs = (nowMs + clockpolicy::kPokeMs) | 1u; + if (k != CLICK_NONE && !muted) onKey(k); + if (b != CLICK_NONE && !muted) onBoot(b); + + // KEY and BOOT held down together for ten seconds: the cube. While the two are down, and until both are up + // again, neither is a long press, a click or the hold that restarts the clock. + if (s_bothHeld.update(s_key.isDown(), s_boot.isDown(), nowMs) == ChordHold::FIRED && !s_cubeOn) cubeBegin(nowMs); + if (s_bothHeld.muted()) { + s_restartHold.cancel(); + if (!strcmp(s_toast, UI_TOAST_RESTART_HOLD)) { // KEY was held long enough to restart, and then BOOT joined it + s_toast[0] = 0; + s_forceRedraw = true; + } + return; + } + + // KEY held for five seconds and then let go: a restart, which is when the clock reads the SD card (a settings + // file, a firmware file). It waits for the button to come up, because KEY held while the clock starts means + // "keep the console" (and, on the battery-empty screen, "start anyway"). + switch (s_restartHold.update(s_key.isDown(), s_key.heldMs())) { + case HoldRelease::ARMED: + showToast(UI_TOAST_RESTART_HOLD, TOAST_NONE, 60000); + break; + case HoldRelease::FIRED: + s_restartAsked = true; + break; + default: + break; + } +} + +// --------------------------------------------------------------------------- +// Sensors, battery, RTC (slow work, done away from the second boundary) +// --------------------------------------------------------------------------- +static const char *chargeName(int c) { + switch (c) { + case CHARGE_CHARGING: return "charging"; + case CHARGE_DISCHARGING: return "discharging"; + case CHARGE_FULL: return "full"; + default: return "unknown"; + } +} + +// What the battery is doing: the voltage-based guess, overruled by the charger's STAT pin when +// one is wired (PIN_CHARGE_STATUS in config.h). The pin only knows "charging" for certain: not +// charging could be unplugged or full, so then the voltage still has to say which. +static int resolveCharge() { + int state = CHARGE_UNKNOWN; + switch (s_charge.state()) { + case ChargeDetector::CHARGING: state = CHARGE_CHARGING; break; + case ChargeDetector::DISCHARGING: state = CHARGE_DISCHARGING; break; + case ChargeDetector::FULL: state = CHARGE_FULL; break; + default: break; + } +#if PIN_CHARGE_STATUS >= 0 + if (digitalRead(PIN_CHARGE_STATUS) == LOW) return CHARGE_CHARGING; + if (state == CHARGE_CHARGING) state = s_batVolts >= ChargeDetector::kTopV ? CHARGE_FULL : CHARGE_DISCHARGING; +#endif + return state; +} + +// The screen the clock leaves behind when it switches itself off, then deep sleep; never returns. +// (No [[noreturn]] here: the IDE generates a prototype for functions in a .ino without it.) +static void shutdownForLowBattery(float volts) { + LOGF(TAG, "battery %.2f V is under the %.2f V cut-off: switching off (restarts above %.2f V)", volts, + g_cfg.batteryCutoffV, lowbat::kRestartV); + u8g2_ClearBuffer(s_u8g2); + uiDrawBatteryEmpty(s_u8g2, volts, lowbat::kRestartV, true); + u8g2_SendBuffer(s_u8g2); + powerDeepSleep(true); +} + +// The screen of the firmware update from the SD card (fw_update.cpp calls this for every step). +static void showFirmwareScreen(const UiFwScreen &screen) { + u8g2_ClearBuffer(s_u8g2); + uiDrawFirmwareUpdate(s_u8g2, screen); + u8g2_SendBuffer(s_u8g2); +} + +static void readSlowSensors(uint32_t nowMs) { + if (s_lastSensorMs == 0 || nowMs - s_lastSensorMs >= SENSOR_INTERVAL_MS) { + IndoorReading r; + if (readIndoor(r)) { + s_indoor = r; + s_indoorMs = nowMs; + } + s_lastSensorMs = nowMs ? nowMs : 1; + } + if (s_lastBatMs == 0 || nowMs - s_lastBatMs >= BATTERY_INTERVAL_MS) { + BatteryReading b = readBattery(); + if (b.present && g_cfg.hasBattery()) { + // smooth out the dips caused by WiFi transmit bursts + s_batVolts = s_batHave ? s_batVolts * 0.75f + b.volts * 0.25f : b.volts; + s_batHave = true; + s_charge.addSample(b.volts, nowMs); // the raw reading: the detector filters it its own way + } else { // no cell, or "battery = none" in the settings: the gauge says USB + s_batHave = false; + s_charge.reset(); + } + s_chargeState = s_batHave ? resolveCharge() : CHARGE_UNKNOWN; + + // runtime estimate: the readings taken while the clock runs on its battery as far as it can tell, which is + // whenever no charger shows (not only once the detector says "discharging": a light load never earns that + // word), and a fresh start whenever a charger comes or goes + // seconds since start from the 64-bit timer: millis() / 1000 would jump back to 0 after 49.7 days + const uint32_t nowSec = (uint32_t)(esp_timer_get_time() / 1000000); + s_estGate.feed(s_est, s_batHave, s_chargeState == CHARGE_CHARGING || s_chargeState == CHARGE_FULL, nowSec, b.volts); + + // the cut-off: a minute under it, while not charging (low_battery.h) + if (s_batHave && s_guard.feed(nowSec, b.volts, s_chargeState != CHARGE_CHARGING && s_chargeState != CHARGE_FULL)) { + shutdownForLowBattery(b.volts); + } + + if (s_batHave && (s_lastBatLogMs == 0 || nowMs - s_lastBatLogMs >= 60000UL)) { + s_lastBatLogMs = nowMs ? nowMs : 1; + LOGF(TAG, "battery %.3f V (reading %.3f) %s trend %+.2f mV/min step %+.1f mV %.1f min of data", s_batVolts, + b.volts, chargeName(s_chargeState), s_charge.slopeMvPerMin(), s_charge.stepMv(), s_charge.minutesOfData()); + } + int pct = s_batHave ? calc::batteryPercent(s_batVolts) : 0; + if (!s_batLow && pct < BATTERY_LOW_PERCENT) { + s_batLow = true; + } else if (s_batLow && pct >= BATTERY_LOW_CLEAR_PERCENT) { + s_batLow = false; + } + if (!s_batHave) s_batLow = false; + s_lastBatMs = nowMs ? nowMs : 1; + } + if (netTakeRtcWriteRequest()) { + time_t t = time(nullptr); + if (t > kMinPlausibleEpoch && rtcWriteUtc(t)) LOGF(TAG, "RTC set from NTP"); + } +} + +// --------------------------------------------------------------------------- +// Building the frame model +// --------------------------------------------------------------------------- +static void cityFromZone(const char *zone, char *out, size_t cap) { + const char *slash = strrchr(zone, '/'); + copyStr(out, cap, slash ? slash + 1 : zone); + for (char *p = out; *p; ++p) { + if (*p == '_') *p = ' '; + } +} + +static void formatAge(uint32_t sec, char *out, size_t cap) { + if (sec < 90) { + snprintf(out, cap, "%us ago", (unsigned)sec); + } else if (sec < 5400) { + snprintf(out, cap, "%u min ago", (unsigned)(sec / 60)); + } else { + snprintf(out, cap, "%u h ago", (unsigned)(sec / 3600)); + } +} + +// "45 s", "9 min", "1 h 5 min" for a time still to come; "" when it is not known (negative). +static void formatIn(int32_t sec, char *out, size_t cap) { + if (sec < 0) { + if (cap) out[0] = 0; + } else if (sec < 90) { + snprintf(out, cap, "%d s", (int)sec); + } else if (sec < 5400) { + snprintf(out, cap, "%d min", (int)((sec + 30) / 60)); + } else { + snprintf(out, cap, "%d h %d min", (int)(sec / 3600), (int)((sec / 60) % 60)); + } +} + +// The firmware's build date as 2026-10-04 (from the compiler's "Oct 4 2026"). +static void buildDateIso(char *out, size_t cap) { + static const char *kMonths = "JanFebMarAprMayJunJulAugSepOctNovDec"; + const char *d = __DATE__; + int month = 0; + for (int i = 0; i < 12; i++) + if (!strncmp(d, kMonths + 3 * i, 3)) month = i + 1; + const int day = atoi(d + 4); + const int year = atoi(d + 7); + snprintf(out, cap, "%04d-%02d-%02d", year, month, day); +} + +// What the runtime estimate says, for the Info pages. +static void estimateText(char *out, size_t cap) { + if (!s_batHave) { + snprintf(out, cap, "%s", g_cfg.hasBattery() ? "no battery detected" : "no battery (set in the settings)"); + return; + } + switch (s_chargeState) { + case CHARGE_CHARGING: snprintf(out, cap, "charging"); return; + case CHARGE_FULL: snprintf(out, cap, "full"); return; + default: break; // discharging, or nothing seen of a charger: on its battery as far as the clock can tell + } + const battest::Estimate e = s_est.estimate(g_cfg.batteryCutoffV, (float)g_cfg.batteryCapacityMah); + char left[24]; + switch (e.state) { + case battest::Estimate::READY: + if (e.pctPerHour < battest::kMinRatePctPerHour && s_chargeState != CHARGE_DISCHARGING) { + // no fall to speak of, and no fall was ever seen: a charger that has finished looks like this for the + // first hours after a start (charge.h), and so does a clock that draws next to nothing + snprintf(out, cap, "no drain to measure (on a charger?)"); + } else if (e.pctPerHour < battest::kMinRatePctPerHour) { + snprintf(out, cap, "more than a month at this rate"); + } else { + battest::formatRemaining(e.hoursLeft, left, sizeof left); // (">30 d" beyond a month: the rate is still worth reading) + // two decimals under 10 %/h: with the radio mostly off the whole figure is 0.2 to 0.7, and rounded to + // one decimal two settings that differ by a third read the same + if (e.pctPerHour < 9.995f) { + snprintf(out, cap, "%s (%.2f %%/h over %d min)", left, (double)e.pctPerHour, e.windowMin); + } else { + snprintf(out, cap, "%s (%.1f %%/h over %d min)", left, (double)e.pctPerHour, e.windowMin); + } + } + break; + case battest::Estimate::LEARNING: snprintf(out, cap, "learning: first figure in %d min", e.learnMin); break; + default: snprintf(out, cap, "starting"); break; + } +} + +static const char *dateFormatName(int f) { + static const char *const names[] = {"ISO date", "DD/MM/YYYY", "MM/DD/YYYY", "DD.MM.YYYY", "D Mon YYYY", "Mon D, YYYY"}; + return (f >= 0 && f < (int)(sizeof names / sizeof *names)) ? names[f] : "?"; +} + +// SYSTEM INFO: network, time, weather, sensors, health of the frame pipeline. +static void buildInfoSystem(UiModel &m, const SharedState &s, uint32_t nowMs, time_t shown) { + int n = 0; + char age[24]; + // lines are cut to the width of the info page rather than overflowing it + auto add = [&](const char *label, const char *value) { + if (n < UI_INFO_LINES) snprintf(m.info[n++], UI_INFO_LINE_LEN, "%-9.9s %.*s", label, UI_INFO_LINE_LEN - 12, value); + }; + char v[128]; + + char built[16]; + buildDateIso(built, sizeof built); + // (the date is that of the day this file was compiled; the build id changes whenever the program does) + static_assert(sizeof(APP_NAME " " APP_VERSION ", 2026-10-07, build 0123456") - 1 <= UI_INFO_LINE_LEN - 12, + "the Firmware row of the Info page would be cut short: APP_NAME or APP_VERSION is too long for it"); + snprintf(v, sizeof v, APP_NAME " " APP_VERSION ", %s, build %s", built, fwBuildId()); + add("Firmware", v); + uint32_t trialLeft; + if (fwOnTrial(nowMs, &trialLeft)) { // just installed from the SD card: a reset now puts the old one back + if (trialLeft > 0) { + snprintf(v, sizeof v, "new firmware on trial, kept in %u s", (unsigned)trialLeft); + } else { + snprintf(v, sizeof v, "new firmware NOT confirmed yet (see the log)"); // the bootloader refused it so far + } + add("Update", v); + } else if (fwRolledBack()) { + add("Update", "the last SD card update was rolled back"); + } + + if (!g_cfg.wifi) { + snprintf(v, sizeof v, "off (wifi = off in the settings)"); + } else if (s.wifiUp) { + snprintf(v, sizeof v, "%s%s %d dBm", s.ssid, s.wifiBackup ? " (backup)" : "", s.rssi); + } else if (s.radioSync && (s.radioAsleep || s.radioTrouble == RADIO_AWAY)) { // wifi_mode = sync, between two sessions + char in[16]; + formatIn(s.radioWakeInSec, in, sizeof in); + const bool when = s.radioAsleep && s.radioWakeInSec >= 0; + if (s.radioTrouble == RADIO_AWAY && !s.radioAsleep) { + snprintf(v, sizeof v, "none in range, looking now"); + } else if (s.radioTrouble == RADIO_AWAY) { // none of its networks answered: it looks every few minutes, and now if a button is pressed + snprintf(v, sizeof v, "none in range%s%s", when ? ", looking again in " : "", when ? in : ""); + } else if (s.radioTrouble == RADIO_CANNOT_JOIN) { // a network is there and will not let the clock in + snprintf(v, sizeof v, "cannot join%s%s", when ? ", next try in " : "", when ? in : ""); + } else { + snprintf(v, sizeof v, "asleep (sync mode)%s%s", when ? ", next in " : "", when ? in : ""); + } + } else { + snprintf(v, sizeof v, "not connected"); + } + add("WiFi", v); + snprintf(v, sizeof v, "%s %s.local", s.ip[0] ? s.ip : "-", g_cfg.hostname); + add("Address", v); + + if (s.ntpSynced) { + formatAge((uint32_t)(shown > s.lastNtpSyncUtc ? shown - s.lastNtpSyncUtc : 0), age, sizeof age); + snprintf(v, sizeof v, "NTP synced %s", age); + if (s_timing.clockSteps) { // the size of the last correction the clock took + const size_t l = strlen(v); + snprintf(v + l, sizeof v - l, ", last step %+d ms", (int)(s_timing.lastStepUs / 1000)); + } + } else if (s.timeTrusted) { + snprintf(v, sizeof v, "%s", g_cfg.wifi ? "from RTC, NTP pending" : "from the RTC chip, no network time"); + } else { + snprintf(v, sizeof v, "%s", g_cfg.wifi ? "not set yet" : "not set: turn WiFi on once"); + } + add("Time", v); + snprintf(v, sizeof v, "%s %s", s.tzName[0] ? s.tzName : "-", s.tzPosix[0] ? s.tzPosix : "-"); + add("Zone", v); + if (s.locationKnown) { + snprintf(v, sizeof v, "%.3f, %.3f", s.lat, s.lon); + } else if (g_cfg.hasLatLon()) { + snprintf(v, sizeof v, "%.3f, %.3f", g_cfg.latitude, g_cfg.longitude); + } else { + snprintf(v, sizeof v, "unknown"); + } + add("Location", v); + + if (s.weather.valid) { + formatAge((nowMs - s.weatherFetchedMs) / 1000, age, sizeof age); + snprintf(v, sizeof v, "%s - Open-Meteo.com", age); // their terms ask for credit + } else { + snprintf(v, sizeof v, "%s", g_cfg.wifi ? "no data yet - Open-Meteo.com" : "off (no WiFi)"); + } + add("Weather", v); + + if (s_batHave) { + char how[32]; + if (s_chargeState == CHARGE_UNKNOWN && s_charge.warmingUp()) { + snprintf(how, sizeof how, "starting up"); // the first minutes after boot are not used + } else if (s_chargeState == CHARGE_UNKNOWN && !s_charge.ready()) { + snprintf(how, sizeof how, "learning %.0f of 8 min", s_charge.minutesOfData()); + } else if (s_charge.ready()) { + // (neither charging nor held full, and not seen to fall either, which a light load never is: on its battery, then) + snprintf(how, sizeof how, "%s %+.1f mV/min", s_chargeState == CHARGE_UNKNOWN ? "on battery?" : chargeName(s_chargeState), + s_charge.slopeMvPerMin()); + } else { + snprintf(how, sizeof how, "%s", chargeName(s_chargeState)); + } + snprintf(v, sizeof v, "%.2f V %d%%%s %s", s_batVolts, calc::batteryPercent(s_batVolts), s_batLow ? " LOW" : "", how); + } else { + snprintf(v, sizeof v, "%s", g_cfg.hasBattery() ? "no battery detected (USB)" : "none (battery = none): USB power"); + } + add("Battery", v); + estimateText(v, sizeof v); + add("Left", v); + + if (s_indoor.valid) { + snprintf(v, sizeof v, "%.1f C %.0f%% (sensor %.1f C)", s_indoor.tempC, s_indoor.rh, s_indoor.rawTempC); + } else { + snprintf(v, sizeof v, "sensor not answering"); + } + add("Indoor", v); + + switch (s.spotify.status) { + case SPOTIFY_DISABLED: + snprintf(v, sizeof v, "%s", + !g_cfg.wifi ? "off (no WiFi)" : (!g_cfg.spotify ? "off (spotify = off)" : "not set up (no client id)")); + break; + case SPOTIFY_NEEDS_LINK: snprintf(v, sizeof v, "waiting to be linked"); break; + case SPOTIFY_ERROR: snprintf(v, sizeof v, "%s", s.spotify.message); break; + case SPOTIFY_PLAYING: snprintf(v, sizeof v, "linked, playing"); break; + case SPOTIFY_PAUSED: snprintf(v, sizeof v, "linked, paused"); break; + default: snprintf(v, sizeof v, "linked, idle"); break; + } + if (s.spotify.linkDaysLeft >= 0 && s.spotify.status != SPOTIFY_NEEDS_LINK && s.spotify.status != SPOTIFY_DISABLED) { + size_t l = strlen(v); + snprintf(v + l, sizeof v - l, " (link: %d d left)", s.spotify.linkDaysLeft); + } + add("Spotify", v); + + uint32_t up = nowMs / 1000; + snprintf(v, sizeof v, "%ud %uh %um, %u KB free", (unsigned)(up / 86400), (unsigned)((up / 3600) % 24), + (unsigned)((up / 60) % 60), (unsigned)(ESP.getFreeHeap() / 1024)); + add("Uptime", v); + // Did the seconds come out on time? "late" is more than 30 ms behind plan; if you see a + // stutter but this stays at zero, the delay is outside the firmware (panel, NTP offset). + snprintf(v, sizeof v, "%u sent, %u late, worst %d ms", (unsigned)s_timing.frames, (unsigned)s_timing.late, + (int)(s_timing.worstLateUs / 1000)); + add("Frames", v); + m.infoCount = n; +} + +// POWER AND SETTINGS: what the clock draws and how long it will last, the accuracy of its +// clock, and where each setting came from. +static void buildInfoPower(UiModel &m, const SharedState &s) { + int n = 0; + auto add = [&](const char *label, const char *value) { + if (n < UI_INFO_LINES) snprintf(m.info[n++], UI_INFO_LINE_LEN, "%-9.9s %.*s", label, UI_INFO_LINE_LEN - 12, value); + }; + char v[128]; + + { // the clock, and why it is where it is when there is an idle clock (a line holds 42 characters of value) + const char *wifi = !g_cfg.wifi ? "WiFi off" : (g_cfg.wifiPowerSave == WIFISAVE_MAX ? "WiFi saver max" : "WiFi saver normal"); + if (g_cfg.cpuIdleMhz() > 0 && s_clockReason[0]) { + snprintf(v, sizeof v, "%u MHz (%s), %s", (unsigned)getCpuFrequencyMhz(), s_clockReason, wifi); + } else { + snprintf(v, sizeof v, "%u MHz, %s", (unsigned)getCpuFrequencyMhz(), wifi); + } + add("Power", v); + } + if (s.radioSync && g_cfg.wifi) { // wifi_mode = sync: how much of the time the radio was on + char in[16]; + formatIn(s.radioWakeInSec, in, sizeof in); + if (s.radioAsleep && s.radioWakeInSec >= 0) { + snprintf(v, sizeof v, "on %.1f %%, %u sessions, next in %s", s.radioOnPermille / 10.0, (unsigned)s.radioSessions, in); + } else { + snprintf(v, sizeof v, "on %.1f %% of the time, %u sessions", s.radioOnPermille / 10.0, (unsigned)s.radioSessions); + } + add("Radio", v); + } + if (psramFound()) add("PSRAM", "ON: wastes power (Tools > PSRAM)"); + if (!g_consoleOn) { // (a line holds 42 characters of value) + add("Console", "off: restart with KEY held to get it back"); + } else if (s_consoleKeep && g_cfg.console != CONSOLE_ON) { + add("Console", "kept on for this run (KEY was held)"); + } else if (g_cfg.console == CONSOLE_AUTO) { + add("Console", "on while a computer is on the USB port"); + } + + if (s_batHave) { + if (fabsf(g_cfg.batteryCalibration - 1.0f) > 0.0005f) { // say so when the voltage has been corrected + snprintf(v, sizeof v, "%.3f V (x%.4f) = %.1f %% on the curve", s_batVolts, (double)g_cfg.batteryCalibration, + (double)calc::batteryPercentF(s_batVolts)); + } else { + snprintf(v, sizeof v, "%.3f V = %.1f %% on the curve", s_batVolts, (double)calc::batteryPercentF(s_batVolts)); + } + } else { + snprintf(v, sizeof v, "no battery in use"); + } + add("Battery", v); + estimateText(v, sizeof v); + add("Left", v); + if (s_batHave && s_estGate.onBattery() && g_cfg.batteryCapacityMah > 0) { + const battest::Estimate e = s_est.estimate(g_cfg.batteryCutoffV, (float)g_cfg.batteryCapacityMah); + if (e.state == battest::Estimate::READY) { + if (e.avgMa < 99.95f) { // (one decimal while it tells something: 9.6 and 10.4 mA are both "about 10") + snprintf(v, sizeof v, "about %.1f mA (of %d mAh)", (double)e.avgMa, (int)g_cfg.batteryCapacityMah); + } else { + snprintf(v, sizeof v, "about %.0f mA (of %d mAh)", (double)e.avgMa, (int)g_cfg.batteryCapacityMah); + } + } else { + snprintf(v, sizeof v, "being measured (%d mAh)", (int)g_cfg.batteryCapacityMah); + } + } else if (g_cfg.batteryCapacityMah > 0) { + snprintf(v, sizeof v, "%d mAh battery", (int)g_cfg.batteryCapacityMah); + } else { + snprintf(v, sizeof v, "unknown: set battery_capacity_mah"); + } + add("Current", v); + { // the power log, when it is on (and what became of its copy to an SD card at this start) + char rows[2][powerlog::kStatusRow]; + const int count = powerlog::statusRows(powerLogStatus((uint32_t)(esp_timer_get_time() / 1000000)), rows); + for (int i = 0; i < count; i++) add(i == 0 ? "Log" : "", rows[i]); + } + if (!g_cfg.hasBattery()) { + snprintf(v, sizeof v, "off: no battery in use"); + } else if (!g_cfg.lowBatteryShutdown) { + snprintf(v, sizeof v, "OFF (low_battery_shutdown = off)"); + } else if (s_guardOverride) { + snprintf(v, sizeof v, "overridden for this run (KEY held)"); + } else { + snprintf(v, sizeof v, "at %.2f V, back on at %.2f V", (double)g_cfg.batteryCutoffV, (double)lowbat::kRestartV); + } + add("Shutdown", v); + + // (a line holds 42 characters of value) + if (!g_cfg.wifi) { + if (s.driftKnown) { + snprintf(v, sizeof v, "no sync; last %+.1f ppm = %+.2f s/day", (double)s.driftPpm, (double)s.driftPpm * 0.0864); + } else { + snprintf(v, sizeof v, "no sync (WiFi off), drift not measured"); + } + } else if (s.driftKnown) { + snprintf(v, sizeof v, "%+.1f ppm = %+.2f s/day over %.0f h%s", (double)s.driftPpm, (double)s.driftPpm * 0.0864, + (double)s.driftHours, s.driftLive ? "" : " (old)"); + } else { + snprintf(v, sizeof v, "measuring: needs 3 h of hourly syncs"); + } + add("Drift", v); + + char sum[96]; + cfgSummary(sum, sizeof sum); + add("Config", sum); + // (up to three of them, and fewer on a page that is nearly full: the last row is kept for the Units line) + for (int i = 0; i < cfgIssueCount() && i < 3 && n < UI_INFO_LINES - 1; i++) { + cfgIssueText(i, v, sizeof v); + add("", v); + } + snprintf(v, sizeof v, "%s, %s, %s%s", g_cfg.imperial() ? "imperial" : "metric", g_cfg.time12h() ? "12 h" : "24 h", + dateFormatName(g_cfg.dateFormat), g_cfg.showWeek ? ", weeks" : ""); + add("Units", v); + m.infoCount = n; +} + +// POWER LOG: the readings of the two pages above as plain values (power_log.h makes the line of CSV), so that +// the clock writes down by itself what would otherwise be copied off its screen by hand. +static void buildPowerLogReading(powerlog::Reading &r, uint32_t nowSec) { + bool trusted; + { + StateLock lock; + trusted = g_state.timeTrusted; + r.spotify = g_state.spotify.status; + r.radioSync = g_state.radioSync && g_cfg.wifi; // (as the Radio line: only with wifi_mode = sync) + r.radioOnPermille = g_state.radioOnPermille; + r.radioSessions = g_state.radioSessions; + } + r.version = APP_VERSION; + r.buildId = fwBuildId(); + r.settings = &g_cfg; + const time_t now = time(nullptr); + r.timeValid = trusted && now > kMinPlausibleEpoch; + if (r.timeValid) localtime_r(&now, &r.local); + r.uptimeSec = nowSec; + r.cpuMhz = (int)getCpuFrequencyMhz(); + r.clockReason = g_cfg.cpuIdleMhz() > 0 ? s_clockReason : ""; // (as the Power line: a reason only with an idle clock) + r.batteryPresent = s_batHave; + r.batteryVolts = s_batVolts; + r.charge = s_chargeState; + r.chargeWarmingUp = s_charge.warmingUp(); + r.chargeReady = s_charge.ready(); + if (s_batHave) r.estimate = s_est.estimate(g_cfg.batteryCutoffV, (float)g_cfg.batteryCapacityMah); + r.freeHeapBytes = ESP.getFreeHeap(); + r.framesSent = s_timing.frames; + r.framesLate = s_timing.late; + r.framesWorstMs = s_timing.worstLateUs / 1000; +} + +// The power log: a line a few seconds after the start and every ten minutes after that (power_log.h). +// Writing one holds the loop up for a tenth of a second or a few while the flash is erased and written, so it +// is only begun early in a second, when the frame of the next one is not due for a long while. +static void servicePowerLog(int32_t usec) { + if (usec > powerlog::kLatestStartUs) return; + const uint32_t nowSec = (uint32_t)(esp_timer_get_time() / 1000000); // (from the 64-bit timer, as the estimate's time is) + if (!powerLogDue(nowSec)) return; + powerlog::Reading r; + buildPowerLogReading(r, nowSec); + powerLogWrite(r, nowSec); +} + +// The moon today and on the next two days at this time of day; the series costs a millisecond +// or two of floating point, so it is worked out once a minute. +static void moonForModel(UiModel &m, time_t shown, bool southern) { + static int64_t s_minute = -1; + static uint8_t s_lit[3]; + static bool s_waxing[3]; + const int64_t minute = (int64_t)shown / 60; + if (minute != s_minute) { + s_minute = minute; + for (int i = 0; i < 3; i++) { + const moon::Phase p = moon::phaseAt((int64_t)shown + (int64_t)i * 86400); + s_lit[i] = (uint8_t)lroundf(p.lit * 100.0f); + s_waxing[i] = p.waxing; + } + } + m.moonValid = true; + for (int i = 0; i < 3; i++) { + m.moonLit[i] = s_lit[i]; + m.moonWaxing[i] = s_waxing[i]; + } + m.southern = southern; +} + +static void buildModel(UiModel &m, time_t shown, float frac) { + const uint32_t nowMs = millis(); + m = UiModel(); + + SharedState s; // small copy of what the frame needs; taken under the lock + { + StateLock lock; + s = g_state; + } + + // time --------------------------------------------------------------------- + m.timeValid = s.timeTrusted && shown > kMinPlausibleEpoch; + if (m.timeValid) { + localtime_r(&shown, &m.local); + m.utcOffsetMin = timeutil::utcOffsetMinutes(shown); + strftime(m.tzAbbrev, sizeof m.tzAbbrev, "%Z", &m.local); + m.secFrac = frac; + const double lat = s.locationKnown ? s.lat : g_cfg.latitude; + moonForModel(m, shown, lat < 0); + } + + // sensors ------------------------------------------------------------------ + m.indoorValid = s_indoor.valid && (nowMs - s_indoorMs) < 3UL * SENSOR_INTERVAL_MS; + m.indoorC = s_indoor.tempC; + m.indoorRh = s_indoor.rh; + + m.batPresent = s_batHave; + m.batVolts = s_batVolts; + m.batPercent = s_batHave ? calc::batteryPercent(s_batVolts) : 0; + m.batCharge = s_batHave ? s_chargeState : CHARGE_UNKNOWN; + m.batLow = s_batLow && s_chargeState != CHARGE_CHARGING; // no warning while it is being charged + m.batBlinkOn = (shown & 1) != 0; // 1 s on, 1 s off + + // network & data ----------------------------------------------------------- + m.wifiUp = s.wifiUp; + m.wifiOff = !g_cfg.wifi; + m.rssi = s.rssi; + // the place: what the settings call it, or what was found, or the city of the saved time zone; + // "Earth" while nothing is known at all + if (s.place[0]) { + copyStr(m.location, sizeof m.location, s.place); + } else if (s.tzName[0] && s.tzName[0] != '(' && strcmp(s.tzName, "UTC") != 0) { + cityFromZone(s.tzName, m.location, sizeof m.location); + } else { + copyStr(m.location, sizeof m.location, "Earth"); + } + copyStr(m.status, sizeof m.status, s.status); + + m.weather = s.weather; + if (m.weather.valid) m.weather.ageSec = (nowMs - s.weatherFetchedMs) / 1000; + + m.spotify = s.spotify; + if (m.spotify.status == SPOTIFY_PLAYING) { // the poll is seconds old: extrapolate + uint32_t p = m.spotify.progressMs + (nowMs - s.spotifySampledMs); + m.spotify.progressMs = (m.spotify.durationMs && p > m.spotify.durationMs) ? m.spotify.durationMs : p; + } + + // presentation ------------------------------------------------------------- + m.useFahrenheit = g_cfg.imperial(); + m.time12h = g_cfg.time12h(); + m.dateFormat = g_cfg.dateFormat; + m.showWeek = g_cfg.showWeek; + m.page = s_page; + if ((int32_t)(s_toastUntilMs - nowMs) > 0) { + copyStr(m.toast, sizeof m.toast, s_toast); + m.toastIcon = s_toastIcon; + } + if (s_page == PAGE_INFO) buildInfoSystem(m, s, nowMs, shown); + if (s_page == PAGE_POWER) buildInfoPower(m, s); +} + +// Draws the frame for second `shown` into the display buffer (nothing reaches the panel yet). +static void prepareFrame(time_t shown, float frac, bool scheduled) { + static UiModel model; + const uint32_t t0 = micros(); + buildModel(model, shown, frac); + u8g2_ClearBuffer(s_u8g2); + uiDraw(s_u8g2, model); + s_planner.drew(shown, micros() - t0, scheduled); +} + +// How late a scheduled frame went out: the transfer should end DISPLAY_LATENCY_MS before its +// second starts. +static void recordLateness() { + struct timeval tv; + gettimeofday(&tv, nullptr); + const int64_t afterStartUs = ((int64_t)tv.tv_sec - (int64_t)s_planner.preparedSec()) * 1000000 + tv.tv_usec; + const int32_t lateUs = (int32_t)(afterStartUs + s_planner.latencyUs()); + s_timing.frames++; + if (lateUs > 30000) { + s_timing.late++; + if (lateUs > 100000) { // rare, and the next frame is a second away, so a slow serial port costs nothing + LOGF(TAG, "late frame: %d ms (draw %u us, send %u us)", (int)(lateUs / 1000), (unsigned)s_planner.drawCostUs(), + (unsigned)s_planner.sendCostUs()); + } + } + if (lateUs > s_timing.worstLateUs) s_timing.worstLateUs = lateUs; +} + +// Sends the prepared frame to the panel. +static void flushFrame() { + const uint32_t t0 = micros(); + u8g2_SendBuffer(s_u8g2); + const uint32_t cost = micros() - t0; + if (s_planner.preparedScheduled()) recordLateness(); + s_planner.sent(cost); + s_lastFrameMs = millis(); + s_forceRedraw = false; +} + +// Draw and send at once (start-up, button feedback, the sweeping second hand). +static void renderFrame(time_t shown, float frac) { + prepareFrame(shown, frac, false); + flushFrame(); +} + +// Notices when the system clock jumps against the monotonic timer (an NTP correction): a jump +// of a few hundred milliseconds would show as a second that is short, long or repeated. +static void watchClock(const struct timeval &tv) { + static int64_t lastMono = 0, lastWall = 0; + const int64_t mono = esp_timer_get_time(); + const int64_t wall = (int64_t)tv.tv_sec * 1000000 + tv.tv_usec; + if (lastMono != 0) { + int64_t step = (wall - lastWall) - (mono - lastMono); + if (step > 5000 || step < -5000) { + if (step > 2000000000LL) step = 2000000000LL; + if (step < -2000000000LL) step = -2000000000LL; + s_timing.clockSteps++; + s_timing.lastStepUs = (int32_t)step; + LOGF(TAG, "clock stepped by %+d ms", (int)(step / 1000)); + } + } + lastMono = mono; + lastWall = wall; +} + +// --------------------------------------------------------------------------- +// Battery voltage calibration: "batcal 4.20" says what a LiPo tester or a meter shows right now +// --------------------------------------------------------------------------- +// The median of the raw battery voltage over about two seconds (WiFi transmit bursts pull single +// readings down). 0 if there was no battery to read. +static float measureRawBatteryVolts() { + float v[25]; + int n = 0; + for (int i = 0; i < 25; i++) { + const BatteryReading b = readBattery(); + if (b.present) v[n++] = b.rawVolts; + delay(80); + } + if (n < 10) return 0; + for (int i = 1; i < n; i++) { // insertion sort: small n + const float x = v[i]; + int j = i - 1; + while (j >= 0 && v[j] > x) { + v[j + 1] = v[j]; + j--; + } + v[j + 1] = x; + } + return (n & 1) ? v[n / 2] : 0.5f * (v[n / 2 - 1] + v[n / 2]); +} + +static void calibrateBattery(const char *arg) { + while (*arg == ' ') arg++; + if (!g_cfg.hasBattery()) { + Serial.println("battery = none in the settings: there is nothing to calibrate"); + return; + } + if (!*arg) { + const BatteryReading b = readBattery(); + Serial.printf("battery: measured %.3f V, corrected %.3f V (battery_calibration = %g)\n", b.rawVolts, b.volts, + (double)g_cfg.batteryCalibration); + Serial.println("tell the clock what a tester or meter shows right now: batcal 4.20 (undo it: batcal reset)"); + return; + } + char value[16]; + if (!strcmp(arg, "reset")) { + value[0] = 0; // an empty value forgets the setting: the default from config.h is back + } else { + const float truth = (float)atof(arg); + Serial.println("measuring for two seconds, leave the clock alone ..."); + const float raw = measureRawBatteryVolts(); + float factor = 1.0f; + if (raw <= 0) { + Serial.println("no battery reading: is a battery fitted? nothing changed"); + return; + } + if (!calc::calibrationFactor(truth, raw, &factor)) { + Serial.printf("the clock measures %.3f V and you say %.3f V: that is not one LiPo cell, or the two are more than " + "20 %% apart (check the wiring and the figure); nothing changed\n", + raw, truth); + return; + } + snprintf(value, sizeof value, "%.4f", (double)factor); + Serial.printf("the clock measures %.3f V, the tester says %.3f V: ", raw, truth); + } + applySetting(g_cfg, cfgBuildDefaults(), "battery_calibration", value); + const bool saved = cfgSaveToFlash(); + Serial.printf("battery_calibration = %g%s\n", (double)g_cfg.batteryCalibration, saved ? ", saved to flash" : ", NOT saved to flash"); + if (saved) { + Serial.println("restarting to start from a clean state ..."); + delay(300); + fwConfirmNow(); + ESP.restart(); + } + s_batHave = false; // not saved: it holds until the next restart, so forget what was learnt at the old scale + s_lastBatMs = 0; + s_charge.reset(); + s_est.reset(); +} + +// "set name = value": one setting, checked like a line of the SD card file, saved to flash, then a restart +// (most settings are read once at start-up). "set name =" forgets it, so the built-in default is back. It +// is also the way back from "console = off" without an SD card: restart with KEY held, then "set console = on". +static void setFromConsole(const char *arg) { + while (*arg == ' ') arg++; + char key[48]; + size_t n = 0; + while (arg[n] && arg[n] != '=' && arg[n] != ':' && n + 1 < sizeof key) { + key[n] = arg[n]; + n++; + } + key[n] = 0; + if (!key[0] || (arg[n] != '=' && arg[n] != ':')) { + Serial.println("usage: set name = value (the names are those of the SD card file, see \"config\"; set name = forgets one)"); + return; + } + char err[96] = ""; + const ApplyResult r = applySetting(g_cfg, cfgBuildDefaults(), key, arg + n + 1, err, sizeof err); + if (r == ApplyResult::UnknownKey || r == ApplyResult::BadValue) { + Serial.printf("not set: %s\n", err); + return; + } + if (r == ApplyResult::Unchanged) { + Serial.println("that is what it is already: nothing changed"); + return; + } + if (!cfgSaveToFlash()) { + Serial.println("could NOT save it to flash: it holds until the next restart only"); + return; + } + Serial.println(r == ApplyResult::Cleared ? "forgotten: the built-in default is back; restarting ..." : "saved to flash; restarting ..."); + delay(300); + fwConfirmNow(); // a restart you asked for is no failure of a new firmware + ESP.restart(); +} + +// --------------------------------------------------------------------------- +// Serial console (handy while developing): type "help" +// --------------------------------------------------------------------------- +static void serviceSerial() { + static char buf[200]; // (a "set" line with the longest name and value fits) + static size_t len = 0; + while (Serial.available() > 0) { + char c = (char)Serial.read(); + if (c == '\r' || c == '\n') { + buf[len] = 0; + len = 0; + if (!buf[0]) continue; + if (!strcmp(buf, "reboot")) { + fwConfirmNow(); // a restart you asked for is no failure of a new firmware + ESP.restart(); + } else if (!strncmp(buf, "set ", 4)) { + setFromConsole(buf + 4); + } else if (!strncmp(buf, "batcal", 6)) { + calibrateBattery(buf + 6); + } else if (!strcmp(buf, "fw")) { + fwPrint(Serial); + } else if (!strcmp(buf, "rollback")) { // go back to the firmware in the other app slot + if (fwSwitchToOtherSlot()) { + Serial.println("the other app slot is next: restarting into it"); + delay(200); + fwConfirmNow(); + ESP.restart(); + } + Serial.println("no firmware in the other app slot to go back to"); + } else if (!strcmp(buf, "fwforget")) { + fwForgetLast(); + Serial.println("forgot the last build installed from the card: it may install the same file again"); + } else if (!strcmp(buf, "refresh")) { + netRequestWeatherRefresh(); + spotifyPost(SPOTIFY_CMD_REFRESH); + Serial.println("refreshing weather and Spotify"); + } else if (!strcmp(buf, "invert")) { + handleBoot(CLICK_LONG); + } else if (!strcmp(buf, "unlink")) { + spotifyPost(SPOTIFY_CMD_UNLINK); + Serial.println("Spotify account forgotten"); + } else if (!strncmp(buf, "page ", 5)) { + s_page = (UiPage)(atoi(buf + 5) % PAGE_COUNT); + s_forceRedraw = true; + } else if (!strcmp(buf, "sleeptest")) { // the low-battery sleep without a flat battery: KEY or five minutes wake it + Serial.println("deep sleep with the battery-empty screen; KEY (or five minutes) wakes the clock"); + u8g2_ClearBuffer(s_u8g2); + uiDrawBatteryEmpty(s_u8g2, s_batVolts, lowbat::kRestartV, true); + u8g2_SendBuffer(s_u8g2); + powerDeepSleep(false); + } else if (!strcmp(buf, "config")) { + char sum[96]; + cfgSummary(sum, sizeof sum); + Serial.printf("settings (%s):\n", sum); + cfgPrint(Serial); + } else if (!strcmp(buf, "timing")) { + Serial.printf("frames %u sent, %u late (>30 ms), worst %d ms; draw %u us, send %u us; clock steps %u, last %+d ms\n", + (unsigned)s_timing.frames, (unsigned)s_timing.late, (int)(s_timing.worstLateUs / 1000), + (unsigned)s_planner.drawCostUs(), (unsigned)s_planner.sendCostUs(), (unsigned)s_timing.clockSteps, + (int)(s_timing.lastStepUs / 1000)); + } else if (!strncmp(buf, "powerlog", 8)) { // the power log as CSV, or "powerlog clear" + powerLogCommand(buf + 8, Serial); + } else if (!strcmp(buf, "power")) { + SharedState s; + { + StateLock lock; + s = g_state; + } + Serial.printf("cpu %u MHz (working %d, idle %d: %s), %u clock changes; USB host %s, radio %s\n", (unsigned)getCpuFrequencyMhz(), + g_cfg.cpuMhz(), g_cfg.cpuIdleMhz(), g_cfg.cpuIdleMhz() > 0 ? (s_clockReason[0] ? s_clockReason : "-") : "no idle clock", + (unsigned)s_clockSwitches, usbHostAttached() ? "attached" : "no", netRadioNeedsFastClock() ? "needs the fast clock" : "off or idle"); + Serial.printf("wifi_mode %s%s: radio on %.1f %% of the time since start, %u sessions, next in %d s%s; frames draw %u us, send %u us\n", + g_cfg.syncMode() ? "sync" : "always", g_cfg.wifi ? "" : " (wifi off)", s.radioOnPermille / 10.0, + (unsigned)s.radioSessions, (int)s.radioWakeInSec, + s.radioTrouble == RADIO_AWAY ? " (none of the networks in range)" : (s.radioTrouble == RADIO_CANNOT_JOIN ? " (cannot join)" : ""), + (unsigned)s_planner.drawCostUs(), (unsigned)s_planner.sendCostUs()); + Serial.printf("console %s%s; buttons %s; audio chips: %s\n", + g_cfg.console == CONSOLE_ON ? "on" : (g_cfg.console == CONSOLE_AUTO ? "auto" : "off"), + s_consoleKeep ? " (kept on for this run: KEY was held at start-up)" : "", + buttonEdgesOn() ? "read by interrupt as well (idle clock)" : "read by the loop", + s_audioStandby < 0 ? "left alone (AUDIO_CHIPS_STANDBY 0)" + : (s_audioStandby == (AUDIO_CODEC_ANSWERED | AUDIO_MIC_ADC_ANSWERED) ? "both powered down" : "not both answered (see the start of the log)")); + char rows[2][powerlog::kStatusRow]; + const int count = powerlog::statusRows(powerLogStatus((uint32_t)(esp_timer_get_time() / 1000000)), rows); + uint32_t lastMs, worstMs; // what a line costs in time: the flash is busy for most of it + powerLogTimings(&lastMs, &worstMs); + Serial.printf("power log: %s%s%s", count > 0 ? rows[0] : "off", count > 1 ? "; " : "", count > 1 ? rows[1] : ""); + if (worstMs > 0) Serial.printf("; the last line took %u ms to write, the slowest %u ms", (unsigned)lastMs, (unsigned)worstMs); + Serial.println(); + } else if (!strcmp(buf, "battery")) { + char est[64]; + estimateText(est, sizeof est); + Serial.printf("battery %s: %.3f V %d%% %s trend %+.2f mV/min step %+.1f mV %.1f min of data%s\n left: %s\n", + s_batHave ? "present" : "absent", s_batVolts, calc::batteryPercent(s_batVolts), + chargeName(s_chargeState), s_charge.slopeMvPerMin(), s_charge.stepMv(), s_charge.minutesOfData(), + s_charge.warmingUp() ? " (starting up: the first 3 minutes are not used)" + : (s_charge.ready() ? "" : " (still learning)"), + est); + } else if (!strcmp(buf, "status")) { + SharedState s; + { + StateLock lock; + s = g_state; + } + Serial.printf("wifi=%d rssi=%d ip=%s synced=%d zone=%s status='%s' spotify=%d '%s'\n", s.wifiUp, s.rssi, + s.ip, s.ntpSynced, s.tzPosix, s.status, (int)s.spotify.status, s.spotify.message); + } else { + Serial.println("commands: status | battery | batcal V | power | powerlog | powerlog clear | config | set name = value | timing | fw | rollback | fwforget | refresh | page N | invert | unlink | sleeptest | reboot"); + } + } else if (len < sizeof buf - 1) { + buf[len++] = c; + } + } +} + +// --------------------------------------------------------------------------- +// Start-up +// --------------------------------------------------------------------------- +static bool initDisplay() { + if (s_lcd) return true; + s_lcd = new ST7305_U8g2(PIN_LCD_SCK, PIN_LCD_MOSI, PIN_LCD_DC, PIN_LCD_CS, PIN_LCD_RST); + if (!s_lcd->begin(0, U8G2_R1)) { + LOGF(TAG, "display buffer allocation failed"); + for (;;) delay(1000); + } + s_u8g2 = s_lcd->getU8g2()->getU8g2(); + s_uiPrefs.begin("ui", false); + s_flipPolarity = s_uiPrefs.getBool("flip", false); + applyPolarity(); + return true; +} + +// First thing after the settings are read, before the display, WiFi or anything else draws power: +// is there enough battery to start? After a low-battery shutdown the answer stays no until the +// cell is back above lowbat::kRestartV (charging does that); a timer wake-up looks and goes back +// to sleep without touching the display. KEY held for 3 s on the "battery empty" screen runs the +// clock anyway. +static void lowBatteryGate() { + if (!g_cfg.hasBattery() || !g_cfg.lowBatteryShutdown) { + powerForgetLowShutdown(); + return; + } + const BatteryReading b = readBattery(); + if (!b.present) return; + const bool timerWake = powerWokeFromTimer(); + if (lowbat::bootDecision(true, b.volts, g_cfg.batteryCutoffV, powerWasLowShutdown()) == lowbat::BootAction::RUN) { + powerForgetLowShutdown(); + return; + } + LOGF(TAG, "battery %.2f V: too low to start (%s)", b.volts, timerWake ? "timer check" : "button or power-up"); + if (!timerWake) { + initDisplay(); + u8g2_ClearBuffer(s_u8g2); + uiDrawBatteryEmpty(s_u8g2, b.volts, lowbat::kRestartV, true); + u8g2_SendBuffer(s_u8g2); + const uint32_t start = millis(); + uint32_t heldSince = 0; + while (millis() - start < 8000) { // eight seconds in which KEY can say "start anyway" + if (digitalRead(PIN_KEY) == LOW) { + if (!heldSince) heldSince = millis(); + if (millis() - heldSince >= 3000) { + LOGF(TAG, "KEY held: starting despite the low battery"); + s_guardOverride = true; + powerForgetLowShutdown(); + return; + } + } else { + heldSince = 0; + } + delay(20); + } + } + powerDeepSleep(true); +} + +// The CPU clock. The display driver holds the SPI bus for good and the core waits for every SPI bus when +// the clock changes, so the driver lets go for the moment. A frame takes longer to draw at a lower clock, +// so the frame planner is told (the first frame after the switch is planned with the new costs). +static void applyClock(int mhz) { + const int from = (int)getCpuFrequencyMhz(); + if (from == mhz || mhz == s_clockRefusedMhz) return; + if (s_lcd) s_lcd->busRelease(); + powerSetCpuMhz(mhz); + if (s_lcd) s_lcd->busAcquire(); + const int now = (int)getCpuFrequencyMhz(); // what it is now: the core refuses a clock it cannot make + if (now == from) { + s_clockRefusedMhz = mhz; // (asked once: the loop would otherwise try again every few milliseconds) + LOGF(TAG, "the CPU clock stays at %d MHz: %d MHz was refused", from, mhz); + return; + } + s_planner.clockChanged((uint32_t)from, (uint32_t)now); + s_clockSwitches++; +} + +// The working clock from the settings (at start-up, and after the settings are read). +static void applyCpuSetting() { applyClock(g_cfg.cpuMhz()); } + +// A restart someone asked for with KEY (held for five seconds, then let go). The SD card is only read while +// the clock starts, so this is how a card that was put in while it ran gets read, without the battery coming out. +static void restartOnRequest() { + LOGF(TAG, "KEY was held for %d s: restarting (the SD card is read at start-up)", (int)(KEY_RESTART_HOLD_MS / 1000)); + showToast(UI_TOAST_RESTARTING, TOAST_NONE, 10000); + renderFrame(time(nullptr), 0); // on the glass before the chip goes down + delay(800); // (long enough to read, and to press KEY again for "keep the console") + fwConfirmNow(); // a restart you asked for is no failure of a new firmware + ESP.restart(); +} + +// Is a computer on the USB port? (Below 80 MHz the USB console may stop, which would look like a crash.) +// Always "no" once the console was shut down: the USB transceiver is off then, and there is no console to keep. +static bool usbHostAttached() { +#if ARDUINO_USB_CDC_ON_BOOT && ARDUINO_USB_MODE + return g_consoleOn && HWCDC::isPlugged(); +#else + return false; +#endif +} + +// The console: its commands while it runs, and the moment it is shut down for good ("console = auto" when no +// computer is on the USB port, "console = off" at the end of start-up; console_policy.h). +static void serviceConsole(uint32_t nowMs) { + if (!g_consoleOn) return; + // (with "console = on", or KEY held at start-up, the port is not even looked at: it runs as it always has) + if (g_cfg.console != CONSOLE_ON && !s_consoleKeep && s_consoleWatch.shouldStop(g_cfg.console, false, usbHostAttached(), nowMs)) { + LOGF(TAG, "console = %s: shutting the console and the USB port down until the next restart (KEY held at start-up keeps them)", + g_cfg.console == CONSOLE_AUTO ? "auto, and no computer on the USB port" : "off"); + consoleShutDown(); + return; + } + serviceSerial(); +} + +// cpu_idle_mhz: the clock follows what needs it (clock_policy.h). Called every time round the loop. +static void serviceClock(uint32_t nowMs, int32_t usec) { + if (s_pokeUntilMs != 0 && (int32_t)(s_pokeUntilMs - nowMs) <= 0) s_pokeUntilMs = 0; // over (a time long past must not come round again) + const int idle = g_cfg.cpuIdleMhz(); + if (idle == 0) return; // no idle clock: the working clock stays (start-up has set it) + static uint32_t s_usbCheckMs = 0; + static bool s_usb = false; + if (s_usbCheckMs == 0 || nowMs - s_usbCheckMs >= 500) { + s_usbCheckMs = nowMs ? nowMs : 1; + s_usb = usbHostAttached(); + } + clockpolicy::Needs needs; + needs.radio = netRadioNeedsFastClock(); + needs.usbHost = s_usb; + needs.poked = s_pokeUntilMs != 0; + const int want = clockpolicy::target(needs, g_cfg.cpuMhz(), idle); + s_clockReason = want == idle ? "idle" : (needs.radio ? "radio on" : (needs.usbHost ? "USB attached" : "button")); + if (clockpolicy::mayChange((int)getCpuFrequencyMhz(), want, s_planner.quiet(usec))) applyClock(want); +} + +// A short note about what the SD card did, if anything, or about a built-in default that was refused +// (a toast holds 39 characters). +static void announceConfig() { + char toast[40]; + // What a card's settings changed at the start before this one, if that start could not say so: it installed + // a firmware from the card and restarted, and this start finds the same file with nothing left to change. + const int carried = g_cfgStatus.carried; + switch (g_cfgStatus.sd) { + case CfgStatus::SD_FILE_APPLIED: { + const ConfigReport &r = g_cfgStatus.report; + uint32_t trialLeft; + const bool justInstalled = fwOnTrial(millis(), &trialLeft); + const bool warn = formatSdBanner(r, g_cfgStatus.savedToFlash, carried, justInstalled, toast, sizeof toast); + const bool lost = r.touched() > 0 && !g_cfgStatus.savedToFlash; // in force now, gone after the next restart + showToast(toast, warn ? TOAST_WARN : TOAST_NONE, lost ? 8000 : 6000); + break; + } + case CfgStatus::SD_EXAMPLE_WRITTEN: showToast("Wrote settings file to SD card", TOAST_NONE, 6000); break; + case CfgStatus::SD_WRITE_FAILED: showToast("SD card: cannot write", TOAST_WARN, 6000); break; + case CfgStatus::SD_READ_FAILED: showToast("SD card: cannot read file", TOAST_WARN, 6000); break; + case CfgStatus::SD_UNREADABLE: showToast(sdProblemText(), TOAST_WARN, 10000); break; // what the card holds instead of FAT + default: // no card: still say so when something in config.h / secrets.h was refused (the Info page has the details) + if (cfgBuildProblems() > 0) { + snprintf(toast, sizeof toast, "%d built-in default%s not used", cfgBuildProblems(), cfgBuildProblems() == 1 ? "" : "s"); + showToast(toast, TOAST_WARN, 8000); + } else if (carried > 0) { // the card came out during the restart: what it changed is still worth a line + formatSdBanner(ConfigReport(), true, carried, false, toast, sizeof toast); + showToast(toast, TOAST_NONE, 6000); + } + break; + } + cfgAnnounced(); // the count from the last start is for this banner only, whatever it said +} + +// The cube that KEY and BOOT bring up when they are held down together for ten seconds: a frame of it, as often +// as the loop comes round, until a button is held (cubeButton) or nobody has touched one for two minutes. +// Nothing of the clock is drawn meanwhile; the battery is still read, because a gap in its readings would start +// the charge detector afresh. +static void cubeFrame(uint32_t nowMs) { + if ((uint32_t)(nowMs - s_cubeTouchMs) >= kCubeIdleMs) { + cubeEnd(); + return; + } + s_pokeUntilMs = (nowMs + clockpolicy::kPokeMs) | 1u; // at the working clock, as after a button press (cpu_idle_mhz) + const float dtMs = (float)(uint32_t)(nowMs - s_cubeFrameMs); + s_cubeFrameMs = nowMs; + s_cube.turnMs += dtMs; + s_cubeShove.advance(dtMs); + for (int i = 0; i < 3; i++) s_cube.shove[i] = s_cubeShove.angle[i]; + s_cube.elapsedMs = nowMs - s_cubeStartMs; + u8g2_ClearBuffer(s_u8g2); + uiDrawCube(s_u8g2, s_cube); + u8g2_SendBuffer(s_u8g2); + readSlowSensors(nowMs); +} + +// --------------------------------------------------------------------------- +// Arduino entry points +// --------------------------------------------------------------------------- +void setup() { + consoleBegin(); + delay(100); + powerAfterWake(); + fwBegin(); // is this firmware on trial (just installed from the SD card)? + pinMode(PIN_KEY, INPUT_PULLUP); + pinMode(PIN_BOOT, INPUT_PULLUP); + delay(2); // (the pull-up needs a moment) + s_consoleKeep = digitalRead(PIN_KEY) == LOW; // KEY held at start-up: the console stays on for this run (console_policy.h) + + cfgLoadFlash(); // the defaults, then what the clock saved in its flash + applyCpuSetting(); // 80 MHz unless the settings say otherwise: a clock needs no more + lowBatteryGate(); // may put the clock straight back to sleep + LOGF(TAG, APP_NAME " " APP_VERSION " (build %s) starting, %u MHz%s", fwBuildId(), (unsigned)getCpuFrequencyMhz(), psramFound() ? ", PSRAM on" : ""); + + stateInit(); +#if PIN_CHARGE_STATUS >= 0 + pinMode(PIN_CHARGE_STATUS, INPUT_PULLUP); // the charger's STAT output pulls it low while charging +#endif + + // The time first (the RTC chip is on the I2C bus), so a file the clock writes to an SD card gets + // the right date. + bool shtc3 = sensorsBegin(); + LOGF(TAG, "SHTC3 %s", shtc3 ? "ok" : "not responding"); +#if AUDIO_CHIPS_STANDBY + // the audio chips share the I2C bus and are not used: power them down, and give their I2S lines a level + s_audioStandby = audioChipsStandby(); + powerQuietAudioPins(); + LOGF(TAG, "audio chips powered down: codec %s, microphone ADC %s", (s_audioStandby & AUDIO_CODEC_ANSWERED) ? "ok" : "NO ANSWER", + (s_audioStandby & AUDIO_MIC_ADC_ANSWERED) ? "ok" : "NO ANSWER"); +#endif + time_t rtc; + if (rtcReadUtc(&rtc)) { // show a plausible time straight away, before WiFi is up + struct timeval tv = {rtc, 0}; + settimeofday(&tv, nullptr); + { + StateLock lock; + g_state.timeTrusted = true; + } + LOGF(TAG, "clock set from RTC (%ld)", (long)rtc); + } else { + LOGF(TAG, "RTC not set; %s", g_cfg.wifi ? "waiting for NTP" : "WiFi is off, so it stays unset until WiFi is turned on once"); + } + + cfgImportSdCard(); // a settings file on an SD card, applied and saved to flash + applyCpuSetting(); // ... which may have changed the clock speed + if (g_cfg.cpuIdleMhz() > 0) buttonEdgesBegin(); // a frame at the idle clock can outlast a tap: the buttons by interrupt too + initDisplay(); + fwUpdateFromCard(showFirmwareScreen); // a firmware file on the card: install it and restart (else carry on) + powerLogBegin(); // power_log = on: the flash partition the log is kept in + s_guard.configure(g_cfg.lowBatteryShutdown && g_cfg.hasBattery() && !s_guardOverride, g_cfg.batteryCutoffV); + announceConfig(); + char summary[96]; + cfgSummary(summary, sizeof summary); + LOGF(TAG, "settings: %s", summary); + if (s_consoleKeep && g_cfg.console != CONSOLE_ON) { + LOGF(TAG, "KEY was held at start-up: the console stays on for this run (console = %s)", g_cfg.console == CONSOLE_AUTO ? "auto" : "off"); + if (!s_toast[0]) showToast("Console kept on for this run", TOAST_NONE, 4000); // (what the SD card did comes first) + } + + netLoadSettings(); + readSlowSensors(millis()); + renderFrame(time(nullptr), 0); + netBegin(); +} + +void loop() { + const uint32_t nowMs = millis(); + pollButtons(nowMs); + if (s_restartAsked) restartOnRequest(); + serviceConsole(nowMs); + fwTrialTick(nowMs); // a new firmware is kept once it has run for a minute + + // toasts raised by the network task ("No active Spotify device", ...) + { + StateLock lock; + if (g_state.noticeSeq != s_seenNoticeSeq) { + s_seenNoticeSeq = g_state.noticeSeq; + char text[sizeof g_state.notice]; + copyStr(text, sizeof text, g_state.notice); + int icon = g_state.noticeIcon; + showToast(text, icon); + } + } + if (s_toast[0] && (int32_t)(s_toastUntilMs - nowMs) <= 0) { // toast expired: repaint without it + s_toast[0] = 0; + s_forceRedraw = true; + } + + struct timeval tv; + gettimeofday(&tv, nullptr); + watchClock(tv); + spotifySetPageShown(s_page == PAGE_NOW_PLAYING); // wifi_mode = sync: the Now Playing page keeps the radio on + serviceClock(nowMs, (int32_t)tv.tv_usec); + if (s_cubeOn) { // (see cubeFrame) + cubeFrame(nowMs); + return; + } + +#if CLOCK_SWEEP_FPS > 0 + // A sweeping second hand: frames at a steady rate, nothing scheduled. + if (s_forceRedraw || (uint32_t)(nowMs - s_lastFrameMs) >= 1000UL / CLOCK_SWEEP_FPS) { + renderFrame(tv.tv_sec, tv.tv_usec / 1e6f); + } else { + if (1000000 - (int32_t)tv.tv_usec > 300000) readSlowSensors(nowMs); + servicePowerLog((int32_t)tv.tv_usec); + delay(4); + } +#else + const FramePlanner::Step step = s_planner.next(tv.tv_sec, (int32_t)tv.tv_usec, s_forceRedraw); + switch (step.action) { + case FramePlanner::DRAW_NOW: + renderFrame(step.sec, 0); + break; + case FramePlanner::PREPARE: + prepareFrame(step.sec, 0, true); + break; + case FramePlanner::SEND: + if (step.waitUs > 0) delayMicroseconds((uint32_t)step.waitUs); + flushFrame(); + break; + case FramePlanner::SLEEP: + delay(step.waitUs > 12000 ? 4 : 1); // short naps, so the buttons keep being polled + break; + default: + if (s_planner.quiet((int32_t)tv.tv_usec)) readSlowSensors(nowMs); // never close to a tick + servicePowerLog((int32_t)tv.tv_usec); // (nor this: only early in a second) + delay(4); + break; + } +#endif +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/README.md b/02_Example/Arduino/11_Clock_Dashboard/README.md new file mode 100644 index 00000000..c5527286 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/README.md @@ -0,0 +1,1209 @@ +# 11_Clock_Dashboard + +A desk clock for the Waveshare ESP32-S3-RLCD-4.2: it joins your WiFi, sets the time over NTP, works out the +time zone (including daylight saving) by itself, and shows everything on the reflective LCD. Version **1.10** +([what changed](#versions)). + +Dashboard + +*The pictures in this README are renders of the real drawing code (see [Developer tools](#developer-tools)), not photos.* + +| | | +| --- | --- | +| **Date and time** | Date (ISO `2026-10-04` unless you choose another of six formats), time with seconds (24 h, or a 12 h clock with AM / PM), weekday, ISO week number, day of year, zone name and UTC offset | +| **Analog clock** | Hour, minute and second hands; the second hand ticks exactly on the second (or sweeps, see `CLOCK_SWEEP_FPS`) | +| **Weather** | Now, today's high/low, and the next two days (Open-Meteo, no API key), each with the **chance of rain** (a drop) and the **moon** (how much of it is lit). Sunrise, sunset, UV and wind when nothing is playing | +| **Indoor** | Temperature and humidity from the on-board SHTC3, corrected for board self-heating | +| **Battery** | Gauge and percentage, with a bolt in it while charging and a tick when full; **blinks below 20 %** (not while charging); an **estimate of the runtime left** on the Info page; a **gentle shutdown** before the cell is flat; if you ask for it, a **log of its own power readings** for comparing settings | +| **Spotify** | What is playing, progress, device and volume; the **KEY button** is the remote: 1 click play/pause, 2 clicks next, 3 clicks previous | +| **Settings** | Units, time and date format, WiFi (and WiFi *off*), location, time zone, Spotify, battery and power options from a **file on an SD card**, read once at boot and kept in the clock's flash, so the card can come out again | +| **Updates** | A new build can go in **from the SD card** (no cable, no WiFi needed): copy the exported `.ino.bin`, restart. It is checked first, installed into the second app slot, and kept only if it runs for a minute, else the old one returns | +| **Also** | WiFi strength, a legend page that explains every symbol and button, system and power info pages, hardware RTC so the right time shows before WiFi is up | + +> **Status.** The first version of this sketch (clock, weather, indoor sensor, battery gauge, display) has run on the +> real board, and so have the 80 MHz setting and the frame timing described under [Power](#power) (measured by the +> board's owner). Of what came with 1.3 and after, the owner has since seen three things work there: **a settings +> file read from an SD card, a firmware installed from the card, and the processor slowed to 20 MHz** between syncs. +> The rest, **the shutdown, how good the runtime estimate is, the clock drift measurement, the moon, the backup +> network, most of [Saving power](#saving-power), the power log**, as well as the Spotify linking flow, the charging indicator and the +> frame scheduler from earlier, has so far only been checked on a PC (renderer, fuzz test, simulations, tens of +> thousands of host-side checks) and compiled for the board. [Not yet tried on the board](#not-yet-tried-on-the-board) +> goes through it item by item, and [Troubleshooting](#troubleshooting) says what to look at if a first guess is off. + +## Quick start + +1. **Arduino IDE settings** (as the repo's [Tools-Configuration.png](../../../Tools-Configuration.png), with two changes + that save power): ESP32S3 Dev Module, USB CDC On Boot *Enabled*, Flash Mode QIO 80 MHz, Flash Size 16 MB, Partition + Scheme *16M Flash (3MB APP/9.9MB FATFS)*, **PSRAM *Disabled***, **CPU Frequency *80MHz (WiFi)***. Core: esp32 by + Espressif **3.x** (built with 3.3.8). The clock needs neither the PSRAM nor the speed; together they cut the current + to a third, see [Power](#power). (The sketch also sets 80 MHz itself, and the *Power and settings* page warns if the + PSRAM is switched on.) The sketch is about 1.5 MB (just under half of the 3 MB app partition), so the default 1.2 MB + partition scheme is too small. The 9.9 MB FAT part of this scheme is where the clock keeps its + [power log](#the-clocks-own-log), if you switch that on; otherwise it is not touched. +2. **Libraries:** [U8g2](https://github.com/olikraus/u8g2) (a copy is in `01_Arduino_Libraries/U8g2`; copy it into your + Arduino `libraries` folder or install it from the Library Manager) and **ArduinoJson 7.x** (Library Manager). + WiFi, HTTPClient, WebServer, ESPmDNS, Preferences, Wire and the SD card driver come with the ESP32 core. +3. **WiFi and place:** either copy `secrets.example.h` to `secrets.h` and enter your WiFi name and password (2.4 GHz + only; `secrets.h` is git-ignored; it can also hold a second network, your place, your battery and any other setting + the card can set, see [Building the settings in](#building-the-settings-in)), **or** leave them out and put them in + a settings file on an SD card after the first start, see [Settings from an SD card](#settings-from-an-sd-card). + `secrets.h` is optional: without it the sketch compiles all the same and the clock starts with no network until the + card says otherwise. +4. Upload. Open the Serial Monitor at 115200 baud for a log (type `help`). From then on a new build can also go in + through the SD card, see [Updating the firmware from the SD card](#updating-the-firmware-from-the-sd-card). + +Or from a terminal: + +```bash +arduino-cli compile --fqbn esp32:esp32:esp32s3:CDCOnBoot=cdc,FlashSize=16M,PartitionScheme=app3M_fat9M_16MB,PSRAM=disabled,FlashMode=qio,CPUFreq=80 \ + --library ../../../01_Arduino_Libraries/U8g2 . +``` + +On the first start the status bar tells you what it is doing: *Connecting to WiFi*, *Syncing time*, *Updating weather*. +Until it knows where it is, the status bar says **Earth**. After that the clock keeps itself right: NTP re-syncs +hourly, weather refreshes every 15 minutes (`weather_interval_min`), and the time zone is saved so the correct local time +appears at power-up even before WiFi. + +## Settings + +Every setting has a built-in default in `config.h`, and `secrets.h` can replace any of them (see +[Building the settings in](#building-the-settings-in)). The clock also keeps settings in its own flash, and reads a +settings file from an SD card. Later wins: + +> built-in default < saved in flash < the file on the SD card + +### Settings from an SD card + +1. **Format a card as FAT32** (the usual format of cards up to 32 GB; larger cards come as exFAT, which the clock cannot + read). The card also needs the classic kind of **partition table (MBR)**, which is what cards are sold with: a + FAT32 volume on a card with a GUID partition table (GPT) shows as FAT32 in Windows and still cannot be read. The + clock looks at a card it cannot use and says which it is (*SD card is exFAT: format it as FAT32*, *SD card: GPT + partitions, needs MBR*); see [Troubleshooting](#troubleshooting) for how to put it right. Any small card will do. +2. Put the card in the slot and **restart** the clock: **hold KEY for five seconds and let go**, or turn it off and on + again (the card is only looked at while it starts). When it finds a card without a settings file it **writes one**, `ESP32-S3-RLCD-Config.txt`, which explains every + setting and has them all commented out, showing the value in use. A banner says *Wrote settings file to SD card*. + (A copy of that file is in this repository: [`docs/ESP32-S3-RLCD-Config.example.txt`](docs/ESP32-S3-RLCD-Config.example.txt).) +3. Take the card to a computer, open the file in any text editor, remove the `#` in front of a setting, change its value, + save. +4. Put the card back and restart. A banner says what happened (*SD settings: 4 changed*), the *Power and settings* page + lists it and names any line it did not understand. **The settings are now kept in the clock's flash** and the card + can be taken out. (It is only read at start-up, never while the clock runs, and a card that stays in costs a little + power.) With the card left in, every later start reads the file again and says *SD settings: 4 in force, none + new*: nothing was changed because all of it applies already. + +The file is forgiving about how it is typed: + +* One `name = value` per line. Spaces around the `=` do not matter, and `name: value` works too. +* Values can be in quotes or not: `"My Network"` and `My Network` are the same, with straight or curly quotes. Use quotes + if a value contains a `#` or starts or ends with a space (`wifi_password = "p#ss "`); a bare `# comment` after a + value is ignored. +* Names ignore capitals, spaces, `-` and `_` (`wifi_ssid`, `WiFi SSID` and `Wi-Fi-SSID` are one setting), and there are + aliases such as `ssid`, `password`, `lat`, `lon`, `tz`. +* Numbers may use a comma for the decimal point and carry a unit (`3,4 V`, `80 MHz`, `31.95 S`, `115.86 E`). +* Lines that start with `#`, `;` or `//` are comments. Windows line ends, and files saved as UTF-8 with a BOM or as + UTF-16, are fine. +* A setting that is not in the file keeps its value. **A line with nothing after the `=`** (`timezone =`) makes the clock + forget that saved setting and go back to the built-in default; `reset_all = yes` forgets all of them first. +* Something it does not understand is skipped and reported, never fatal. A wrong value leaves the old one in place. + +The last column is the name the same setting has in `config.h` / `secrets.h`, see [Building the settings in](#building-the-settings-in). + +| Setting | What it does (default) | Built in as | +| --- | --- | --- | +| `wifi` | `on` / `off`. Off switches the radio off: no network time, weather or Spotify, and less power drawn (`on`) | `WIFI_ENABLED` | +| `wifi_ssid`, `wifi_password` | The network (2.4 GHz). Passwords are plain text in the file and in the clock's flash. An open network: `wifi_password = ""` | `WIFI_SSID`, `WIFI_PASSWORD` | +| `wifi_backup_ssid`, `wifi_backup_password` | A second network, used when the first cannot be joined (a phone's hotspot, another router); see [A backup network](#a-backup-network). Empty: no backup | `WIFI_SSID_BACKUP`, `WIFI_PASSWORD_BACKUP` | +| `hostname` | The clock's name on the network, for the Spotify setup page `http://rlcd-clock.local` (`rlcd-clock`) | `APP_HOSTNAME` | +| `ntp_server` | A time server to try first (empty: `pool.ntp.org`, `time.cloudflare.com`, `time.google.com`) | `NTP_SERVER` | +| `wifi_power_save` | `normal` / `max`: how long the radio sleeps between messages. `max` saves a little more but can delay every reply, the network time included, by up to a third of a second (`normal`) | `WIFI_POWER_SAVE` | +| `wifi_mode` | `always` or `sync`. Sync switches the radio off between syncs, which draws far less on a battery; see [Saving power](#saving-power). **Not yet tried on the board** (`always`) | `WIFI_MODE` | +| `units` | `metric` (°C, km/h) or `imperial` (°F, mph) (`metric`) | `USE_FAHRENHEIT` (`1` = imperial) | +| `time_format` | `24h` or `12h` with AM / PM (`24h`) | `TIME_FORMAT` | +| `date_format` | `iso` 2026-10-04, `dmy` 04/10/2026, `mdy` 10/04/2026, `dmy-dot` 04.10.2026, `d-mon-y` 4 Oct 2026, `mon-d-y` Oct 4, 2026 (`iso`) | `DATE_FORMAT` | +| `show_week` | The ISO week number and the day of the year after the weekday (`on`) | `SHOW_WEEK` | +| `latitude`, `longitude` | Exact coordinates; they win over `location` | `LOCATION_LATITUDE`, `LOCATION_LONGITUDE` | +| `location_label` | What the status bar shows (empty: the name that was found) | `LOCATION_LABEL` | +| `location` | A place to look up (Open-Meteo), such as `Perth` or `"Paris, France"` | `LOCATION_QUERY` | +| `timezone` | An IANA name (`Australia/Perth`, not case sensitive) or a POSIX rule (`AWST-8`); empty: follow the location. **With WiFi off nothing can look it up: set it here** | `TIMEZONE_POSIX_OVERRIDE` | +| `spotify`, `spotify_client_id` | Spotify on / off, and the Client ID of your own app (see [Spotify](#spotify)) | `SPOTIFY_ENABLED`, `SPOTIFY_CLIENT_ID` | +| `spotify_live` | Only matters with `wifi_mode = sync`. `on`: the radio stays on while music plays and the strip follows your phone within seconds. `off`: the radio sleeps while music plays too and the clock looks again when the track should be over; see [Music in sync mode](#music-in-sync-mode). **Not yet tried on the board** (`on`) | `SPOTIFY_LIVE` (`1` / `0`) | +| `indoor_offset` | °C added to the indoor temperature, to make up for the board's own heat (`-4.0`) | `INDOOR_TEMP_OFFSET_C` | +| `battery` | `auto` or `none`. Say `none` when no battery is fitted: the gauge shows `USB` and nothing is estimated or shut down. The clock cannot tell by itself, see [Battery](#battery) (`auto`) | `BATTERY_MODE` | +| `battery_capacity_mah` | The battery's capacity, so the *Power and settings* page can show the average current (`0` = unknown) | `BATTERY_CAPACITY_MAH` | +| `battery_calibration` | A multiplier for the measured battery voltage, the real voltage divided by the one shown: 4.20 V on a tester against 4.133 V shown gives `1.016`. `1` = none. See [Calibrating the voltage](#calibrating-the-voltage) | `BATTERY_CALIBRATION` | +| `low_battery_shutdown`, `battery_cutoff_v` | Switch off before the cell is flat, and at what voltage, 3.10 to 3.60 (`on`, `3.30`) | `LOW_BATTERY_SHUTDOWN`, `BATTERY_CUTOFF_V` | +| `cpu_mhz` | `80`, `160` or `240`; 80 is plenty for a clock (`80`) | `CPU_MHZ` | +| `cpu_idle_mhz` | `off`, `80`, `40` or `20`: the CPU clock while the radio is off (`wifi = off`, or between the syncs of `wifi_mode = sync`); see [The idle clock](#the-idle-clock). **Not yet tried on the board** (`off`) | `CPU_IDLE_MHZ` (`0` = off) | +| `console` | `on`, `auto` or `off`: the USB serial console (the log and the commands). `auto` runs it only while a computer is on the USB port, `off` not at all; shut down, it takes the USB port with it. See [The console](#the-console). **Not yet tried on the board** (`on`) | `CONSOLE_MODE` | +| `power_log` | `on` / `off`. On: the clock writes its power readings into its own flash, one line at each start and every ten minutes, and copies the log to an SD card that is in the slot while it starts; see [The clock's own log](#the-clocks-own-log). The first time, the FAT partition of the flash is formatted. **Not yet tried on the board** (`off`) | `POWER_LOG` (`1` / `0`) | +| `weather_interval_min` | Minutes between weather updates, 5 to 240 (`15`) | `WEATHER_INTERVAL_MIN` | + +(`reset_all`, described above, is not a setting but an instruction.) The accepted spellings of every value are in +the example file the clock writes. + +### Building the settings in + +Everything the card can set can also be **pre-programmed into the firmware**, so a clock comes out of the box set up +(the way you want the WiFi, the units, the date format, the power saving) with no card at all. Copy +`secrets.example.h` to `secrets.h` (git-ignored, so the passwords stay out of the repository), and `#define` the ones you +want, using the names in the last column of the table above. `config.h` has the factory value of each, with a note. + +```c +#define WIFI_SSID "My Network" +#define WIFI_PASSWORD "my password" +#define WIFI_POWER_SAVE "max" // text values are the words the card file uses +#define TIME_FORMAT "12h" +#define DATE_FORMAT "d-mon-y" +#define SHOW_WEEK 0 // yes / no values are 1 and 0 +#define CPU_MHZ 80 +#define BATTERY_CUTOFF_V 3.40f // numbers are plain +``` + +* **Same rules as the card.** Text settings and choices are written as on the card (`"12h"`, `"max"`, `"d-mon-y"`; the + list of choices is in the example file and in `config.h`), yes / no settings as `1` or `0`, numbers as numbers, and + every value must pass the same checks, such as the 3.10 to 3.60 V of `BATTERY_CUTOFF_V`. A value that does not is + **left out, not half-used**: the clock keeps the factory value and says so, with a banner at start-up, on the *Info* + page and in the serial log (`built-in default not used: WIFI_POWER_SAVE: expected normal, max`). +* **The build is the bottom layer**: what an SD card saved in the clock's flash earlier, and the card file, win over it. + Flashing a new build does not clear that flash. So after changing `secrets.h` a setting that was once on a card + keeps its old value; put `reset_all = yes` in the file on a card once (or `wifi_power_save =` with nothing after the + `=` for just that setting) and the build's value counts again. +* Every setting in the table has a macro (a test in `tools/tests` fails if one is ever added without), so the two ways + of setting up a clock cannot drift apart. The Spotify Client ID, the WiFi passwords and your place are the ones you + would not want in a public repository: that is what `secrets.h` is for. + +### A backup network + +Give the clock a second network (`wifi_backup_ssid` and `wifi_backup_password`, or `WIFI_SSID_BACKUP` and +`WIFI_PASSWORD_BACKUP` in `secrets.h`) and it uses that when the first cannot be joined: a phone's hotspot, another +router, the guest network. + +* The main network always comes first, at start-up and after a drop. If it is not joined within 20 seconds the clock + tries the backup, and so on, one after the other, until one of them works. +* While it sits on the backup the clock **looks for the main network every 10 minutes** (a scan of a few seconds, not + while the Spotify setup page is being served) and goes back to it as soon as it is in range (-80 dBm or better), so it + does not stay on a phone's hotspot for days. If going back does not work (the main network is in range but will not + take the clock) the looks come further apart, 20 minutes, 40, then every hour, until the main network is joined again. + A *hidden* main network cannot be seen by a scan: the clock then stays on the backup until that drops. +* The *Info* page says `(backup)` after the network name when the clock is on the second network. +* "Joined" means the clock has an address on that network. A network that is up but has no internet is not noticed: the + clock would carry on trying the weather and the time there. +* A backup that has the same name as the main network is ignored. With only a backup set, it is simply the network. + +## Updating the firmware from the SD card + +A clock on a shelf, perhaps with WiFi off, can be updated without a cable: build the sketch on your computer, copy the +firmware file to the SD card, put the card in and restart the clock. + +The firmware update screen while installing, and the message for a file it will not use + +1. In the Arduino IDE choose **Sketch → Export Compiled Binary** (or add `--export-binaries` to `arduino-cli compile`), + with the same board settings as for a USB upload. The sketch's `build//` folder then holds several files; the one + you want is **`11_Clock_Dashboard.ino.bin`**. Not the `….merged.bin`: that is the whole 16 MB flash image for a first + install with a programmer, and the clock refuses it (*Too big*). +2. Copy it to the top folder of the FAT32 card, as it is or named **`ESP32-S3-RLCD-Firmware.bin`**. With more than one of + these files on the card the clock does nothing and says so. +3. Put the card in and restart the clock (hold KEY for five seconds and let go). At start-up it checks the file, shows a progress screen, writes the firmware and + restarts into it; about 15 to 30 seconds (an estimate, not measured). Afterwards the file is renamed `….done`, so it is + not installed again. A settings file on the same card is applied first, and kept; the banner about it (*SD settings: + 4 changed*) comes after the restart, from the new firmware. (If the firmware that does the installing is 1.4 or + older, that banner says *SD settings: 4 in force* instead: the settings were applied all the same.) + +How it stays safe: + +* **The firmware that runs is not touched** until the new one has been written and checked. The whole file is first + compared with the SHA-256 at its end (a damaged or cut-short copy is refused before anything is written), then it goes + into the *other* of the two app slots, and the bootloader is switched over only after that image has verified. A card + taken out half-way, or a power cut, only loses the update. +* **Files it will not use** (the screen says why): the wrong chip (it has to be an ESP32-S3 app image), a bootloader or + `.merged.bin`, a file too big for the slot, the build that is already running, a build the card installed before (so a card + left in the clock cannot make it install the same file again and again; `fwforget` on the serial console allows it), and + anything while the battery is under 3.60 V (plug in USB first). +* **A new firmware is on trial for its first minute.** If the clock resets in that minute (a crash, a hang the watchdog + catches, or you pulling the plug) the bootloader puts the previous firmware back, and the Info page says so (*the last + SD card update was rolled back*). After a minute without a reset the new one is kept. The Info page counts the trial + down; going to sleep on purpose and the `reboot` serial command count as passing it. If the bootloader refuses the + confirmation (a flash fault) the clock tries again a few times, and until it works the Info page says *NOT confirmed + yet*, as a reset would still undo the update. +* **Needs two app slots.** The partition scheme from the Quick start has them; a single-app scheme gets the message *No + second app slot*. The very first install of this feature has to come by USB, as the updater has to be in the firmware. + A USB upload always works, whatever happened to the slots. +* There is **no signing**: whoever can put a file on the card can change the firmware, as whoever has a USB cable can. + +**Which build is running?** The first line of the *System info* page says, for example, `RLCD Clock 1.4, 2026-10-07, +build 3f9a12c`: the [version](#versions), the day it was compiled, and a **build id**. The version only changes when +someone changes it and a day can see many builds; the id changes whenever the program does. It is the start of the +hash by which the updater tells builds apart: the SHA-256 of the `.ino.elf` that is built along with the `.ino.bin` +(`Get-FileHash` in PowerShell, `sha256sum` elsewhere), so you can tell which file a clock runs. The same sources built +in another folder (and so, as a rule, on another computer) get another id: the hash covers the debug information, and +that names the folders. + +Serial console: `fw` shows what runs and what is in the other slot (with sixteen digits of each build id), `rollback` +switches to the other slot now (and restarts), `fwforget` clears the memory of the last build installed from the card. + +If you leave **WiFi off**, nothing corrects the clock any more: it starts from the hardware RTC and then counts with its +own crystal. A clock crystal is good for a second or two a day, depending on the temperature; the clock **measures its own +error** while it has WiFi (see [Time zone and clock](#time-zone-and-clock)) and puts the figure into the example file. +Turn WiFi on for a few minutes now and then and it sets itself again. + +## Controls + +| Button | Gesture | Action | +| --- | --- | --- | +| **KEY** (GPIO18) | 1 click | Play / pause | +| | 2 clicks | Next track | +| | 3 clicks | Previous track (like most players, Spotify may restart the current track first if it is well in) | +| | hold 0.8 s | Refresh weather and Spotify now | +| | hold 5 s, then let go | **Restart the clock.** That is when it reads the SD card, so this is how a card you have just put in gets read (settings, a firmware file) without switching the clock off. A banner says when to let go | +| **BOOT** (GPIO0) | click | Next page: Dashboard, Now Playing, System info, Power and settings, Legend | +| | 2 clicks | Back to the dashboard | +| | 3 clicks | System info page | +| | hold 1 s | Invert the screen (remembered) | + +Each key action shows a banner at the bottom of the screen straight away; if Spotify refuses (no active device, rate +limited, no Premium) the banner changes to say why. With `wifi_mode = sync` a press of KEY also switches the radio on, and +any button makes a clock that found none of its networks look for them again at once (see [Saving power](#saving-power)). +Holding KEY while the clock starts keeps the serial console on for that run, whatever the `console` setting says. The +**Legend** page shows these and every symbol on the screens, drawn with the same code as the screens themselves: + +

+Legend page +Now Playing page +

+

+Key feedback +Low battery blink +

+ +*The legend page, the Now Playing page, the feedback banner for a key press, and one phase of the low-battery blink (the +gauge alternates between normal and inverted once per second below `BATTERY_LOW_PERCENT`).* + +## Weather, rain and the moon + +The weather band + +*Today (left) and the next two days. Top row of today: the moon and the chance of rain; below: the high and the low on one +line. The big temperature starts exactly where the condition text ("Clear") does.* + +* A drop and a percentage is the **chance of rain** (the day's maximum, from Open-Meteo). The drop is outlined like the + moon, 7 × 9 pixels, and its bottom row is the bottom row of the digits beside it. +* The **moon** is drawn as a 10 pixel disc, the lit part filled, with the share lit next to it. A growing moon is lit on + the right when seen from the northern hemisphere and on the left from the southern; the clock mirrors it by the sign of + your latitude (when it knows it). The three discs are the moon at this time of day today, tomorrow and the day after. +* There is no moon data in the weather service, so it is computed on the clock from the date (`moon.h`: the main terms of + the standard series for the Sun's and the Moon's longitude). Checked against 24 published new and full moons (the + eclipses of 2000 to 2025 and every new moon of 2024) and four quarters, the darkest and the brightest moments land + within half an hour of the almanac, and the share lit is good to a fraction of a percent. +* Wide numbers squeeze the row beside them (-12 °, 104 °F): the moon first loses its percentage, then the moon goes, and + the high and low shrink their gaps and then their type; nothing ever touches the separators (checked on the rendered + pixels). + +## Spotify + +Optional. Without a Client ID the clock simply has no Spotify features. Spotify has no way to ask your phone or PC what +is playing except through its Web API, so the clock needs your permission once. + +**You need:** a Spotify **Premium** account. Spotify requires Premium both to *own* a development-mode app and to +*control* playback through the API. A free account cannot be used for either. (Rules checked against Spotify's +documentation in October 2026; they have been changing, see the links at the end.) + +**Set up** + +1. Open the [Spotify developer dashboard](https://developer.spotify.com/dashboard), *Create app*. Any name; tick *Web API*. + Under **Redirect URIs** add exactly `http://127.0.0.1:8888/callback` and save (Spotify does not accept `localhost`). +2. In the app's *Settings > User Management* make sure your own Spotify account is listed (development-mode apps work + for up to 5 allow-listed users). +3. Copy the app's **Client ID** into `secrets.h` as `SPOTIFY_CLIENT_ID`, or into the SD card settings as + `spotify_client_id`. No client secret is used or stored. +4. Restart. The bottom strip of the dashboard now says *Spotify: open http://192.168.x.x to link*. Open that address (or + `http://rlcd-clock.local`) in a browser on the same network. The page offers two ways to finish: + * **A. The helper (easiest; needs Python 3 on the computer you are browsing from).** Download `spotify_link.py` + from the page (the clock serves it) and run `python spotify_link.py 192.168.x.x`. It opens Spotify; press *Agree* + and the clock is linked. Nothing is installed and the script exits when it is done. + * **B. By hand, any browser.** Follow the page's *Open Spotify* link and approve. The browser then lands on a + "can't reach this page" error for `127.0.0.1`; that is expected. Copy the **whole address** from the address bar + (`http://127.0.0.1:8888/callback?code=...`) and paste it into the box on the clock's page. +5. Play something on any device. It appears on the clock within about 10 seconds. If a device has been idle for a while + Spotify forgets it; press KEY once and the clock asks Spotify to resume on the last device it saw. + +**Why 127.0.0.1 and a helper?** Spotify refuses a plain-http redirect as *insecure*, with one exception: `127.0.0.1`, +which means "this computer". So Spotify's answer is always sent to the computer you approve on, and something there has +to pass it to the clock: the helper does (it listens on `127.0.0.1:8888` while you approve, up to five minutes, and +forwards the redirect to the clock's `/callback`), or you do, by pasting the address. An `https://` redirect straight to +the clock is possible in principle, but the clock would have to serve TLS with a self-signed certificate (so the browser +warns every time) and its address would have to be registered in the Spotify dashboard (so it needs a fixed IP); that was +not done. A small hosted redirect page would be another way, at the price of depending on a web host. + +**Good to know** + +* The link page is only served while no account is linked (and during the last 14 days before the link expires). The + little web server handles one connection at a time, so if the page takes a few seconds to appear the first time + (browsers sometimes open a spare connection), just wait or reload. While it is being served the radio's longer sleep + (`wifi_power_save = max`) is switched off so the page answers promptly. +* Only the *read playback state* and *modify playback state* permissions are requested. +* The refresh token is kept in the chip's flash (NVS), unencrypted, like any other WiFi-connected hobby device. + `unlink` in the serial console forgets it. +* **Spotify expires the link after 6 months** whatever you do. The Info page shows the days left; when 14 remain the + link page comes back so you can renew in a minute. If it lapses the clock goes back to *please link again*. +* Spotify gives development-mode apps a small request quota and has locked out polling that was too eager. The clock + polls every 10 s while playing (15 s paused, 30 s idle), extrapolates the progress bar in between, and obeys a `429` + `Retry-After` even across reboots (the dashboard says how long). A KEY press first looks at what the player is + really doing, so pressing it right after starting music on your phone pauses it instead of sending a useless "play". +* Titles in scripts the built-in fonts do not cover (CJK, Cyrillic, ...) show `?` for those letters; typographic quotes + and dashes are mapped to ASCII. + +## Time zone and clock + +* `location` -> Open-Meteo returns the IANA zone (`Australia/Perth`) -> `tz_table.cpp` (597 zones from the tz database) + maps it to a POSIX rule (`AWST-8`) -> `tzset()`. Daylight saving then happens on the device with no more lookups, + and the rule is saved to flash. A zone name newer than the table falls back to a fixed UTC offset that is refreshed + with every weather update. The `timezone` setting pins a zone by hand (an IANA name, or a POSIX rule). +* The PCF85063 RTC holds UTC. At boot it provides the time immediately; each NTP sync (hourly) updates it. HTTPS needs a + believable clock, so network requests wait for NTP or a valid RTC. With WiFi off the RTC is the only source, and a + board whose RTC was never set stays at `--:--:--` until WiFi has been on once. +* Digits that change every second, minute or day are laid out in fixed cells (`ui.cpp`), so the time does not shift + sideways as it counts: U8g2 measures a string by its ink, and centring on that moved the clock by up to 5 px + depending on which digits it held. The host renderer checks this on thousands of frames (the 24 h clock, the 12 h + clock with its colon, the date, the status-bar clock), each with a deliberate failure to make sure the check can see it. +* **How far off is the clock's crystal?** Each NTP sync corrects the system clock by the amount it had drifted since the + last. `drift.h` keeps the corrections, fits a line through them (one point per half hour, up to two days) and shows the + result on the *Power and settings* page as parts per million and seconds a day, once it has three hours of syncs. It is + also kept in flash for the note in the example settings file. In simulation, with 30 ms of network jitter, a day of + hourly syncs measures a drift to within one ppm (a ppm is 0.09 s a day). + +### Date and time formats + +`time_format = 12h` shows `9:45` large with the seconds and AM / PM small beside it; a one-digit hour leaves its cell +empty rather than moving everything. Dates in the text formats (`4 Oct 2026`, `Oct 4, 2026`) use English month names. +All six formats fit the date line in every month (checked on the rendered pixels). + +

+12 hour clock +

+ +### Keeping the seconds on time + +The panel refreshes itself at about 25 Hz, so a frame shows up 0 to 39 ms after it has been sent. The clock therefore +draws the coming second into the display buffer ahead of time (with at least 60 ms to spare, more if drawing has been +slow lately) and sends it just before the second starts, timed so the average error is zero (`DISPLAY_LATENCY_MS`). +A slow draw cannot make the digits late any more, only the short transfer is exposed. `frame_plan.h` holds the +schedule; in a simulation of the UI loop (not on the board) with one draw in ten taking four times as long, 148 of 1568 +frames went out more than 50 ms late with the earlier draw-just-in-time loop and none with this one, and with random +20-300 ms stalls (a flash write, WiFi activity) the count fell from 234 to 50 of 3525. A stall of more than about +150 ms still shows: nothing in software can hide a frozen CPU. **On the board, at 80 MHz, after a while: 1154 frames sent, +0 late, worst 2 ms** (the *Frames* line of the Info page). + +Two things are also kept away from the moment of the tick: slow work (the sensor reads) only runs when more than 300 ms +are left, and flash writes (saving a time zone or a Spotify token; the flash is only touched when the value changed) +wait for the middle of a second, because a flash write stops both cores for a while. The WiFi status calls are made +before the shared-state lock is taken, so a slow WiFi driver cannot hold the UI up. + +**If the seconds still stutter now and then,** the Info page says where to look: + +| Info page line | Meaning | +| --- | --- | +| `Frames 11520 sent, 2 late, worst 61 ms` | Frames that went out more than 30 ms behind plan, and the worst. Anything here means the firmware itself stalled (the serial log prints every frame more than 100 ms late; `timing` shows the draw and send times) | +| `Time NTP synced 12 min ago, last step -38 ms` | The size of the last correction NTP made to the clock. Steps of 100 ms or more mean the network's latency is uneven (satellite and mobile links can do that) and the displayed seconds were that far off for a while | + +Both quiet while you still see a stutter: the delay is in the panel itself. + +## Indoor temperature + +The SHTC3 sits beside the ESP32 and the battery charger, so it reads warm. `indoor_offset` defaults to **-4.0 °C**, +the value Waveshare's own driver subtracts; calibrate it against a thermometer you trust and put the sensor reading from +the Info page next to it. Humidity is corrected to match (relative humidity rises as air cools), which Waveshare's +driver does not do. + +## Battery + +Reads the divider on GPIO4 (x3), averages 16 ADC samples, smooths the result, and maps the voltage to a charge level +with a discharge curve measured in this clock: a 3400 mAh 18650 run from a full charge until the clock shut itself down +at 3.30 V, 81 hours at a steady load, each point being the share of that run still to go when the cell got there +(`calc.h`; the readings are in `tools/tests/fixtures/rundown-2026-10.csv`). Half charge is 3.78 V, 20 % is 3.58 V, and +below 3.46 V only 5 % is left. (The generic curve before it, from 1.0 to 1.9, put 3.70 V at 12 % where that cell still +had 39 %, so a clock recharged at its "20 %" was really about half full.) A cell of another make or chemistry can sit a +little higher or lower. The gauge blinks below `BATTERY_LOW_PERCENT` (20) and stops +once it has recovered to 23 so it cannot flicker around the threshold; it does not blink while the battery is being +charged. + +### Calibrating the voltage + +The ADC and the divider can read a few percent low, and then a cell that is full (4.20 V on a LiPo tester) shows as +about 4.13 V: the gauge stops at 93 %, and the clock, which calls a cell full from 4.16 V, never says so. A single +multiplier, `battery_calibration`, fixes it, and everything else (the gauge, the estimate, the shutdown, the charge +detection) then works with the corrected voltage. The *Power and settings* page shows the factor next to the voltage once +it is not 1. + +1. Read the voltage the clock shows on the *Power and settings* page (*Battery 4.133 V*), and measure the same battery at the + same time with a tester or meter (4.20 V). +2. The factor is the real voltage divided by the shown one: 4.20 / 4.133 = **1.016**. Give it to the clock in any of + three ways: + * the settings file on the SD card: `battery_calibration = 1.016`; + * the serial console: **`batcal 4.20`**. The clock measures for two seconds (the median of 25 readings, so WiFi bursts + do not skew it), works the factor out, saves it to flash and restarts; `batcal` alone shows the raw and the + corrected voltage, `batcal reset` goes back to no correction. Leave the clock alone while it measures; + * `BATTERY_CALIBRATION` in `secrets.h` (the build's default; the settings file and the console win over it). +3. Allowed are factors from 0.80 to 1.25; `batcal` refuses a figure that is further off than that, as it means a wiring + problem or a typo, not the ADC. The best moment to calibrate is a cell the charger has finished with (4.20 V): the + voltage then holds still while you read both, and a multiplier's error is largest at the top. The result is only as + good as the tester you hold. A clock that reads a little over 4.20 V on the charger afterwards (4.22 V, say) is not + proof of a wrong factor: chargers hold 4.20 V to within about a percent either way. Only the tester can tell the + two apart. + +Battery gauge states + +*The gauge, top to bottom: charging (a bolt in a window in the fill), running on the battery (nothing added), full (a tick +cut out of the solid fill), 17 % while charging (no blink), 17 % on the battery (blink phase). The gauge itself never +moves when the state changes.* + +**No battery fitted?** The clock cannot tell. On USB the empty battery connector reads about 4.2 V, like a full cell, so +a clock running from USB alone shows a full battery. Say `battery = none` in the settings and the gauge shows `USB` +instead, with no bolt or tick, no estimate and no shutdown. + +**How it knows what the battery is doing.** The board has no charge-status signal (the charger's STAT output only lights +an LED), so `charge.h` works it out from how the battery voltage moves: + +* plugging USB in makes the charger push current through the cell, so the voltage steps up by tens of millivolts at + once; unplugging steps it down. A step of 40 mV or more that stays is spotted about a minute after the cable moves; +* while charging the voltage climbs 1.5 mV per minute or more; on the battery it falls (steeply near full, slowly in the + flat middle of the curve); a charger that has finished holds it flat near 4.2 V, which is shown as full; +* **flat at the top is only called *full* when the clock saw the charge that led there**, or when the cell has not + fallen for some three hours. A clock that is started on its battery with a nearly full cell looks every bit as flat + for twelve minutes (the radio mostly off, the top of the curve falls by 0.03 to 0.1 mV a minute), and up to 1.4 that + was called *full* eleven minutes after every such start, and stayed so for hours (found on the board, at 99 %). With + no sign of a charger the clock now shows no icon and takes itself to be on its battery (the *System info* page says + *on battery?*, and *discharging* once it has seen the voltage fall, which under a light load may be never); +* that trend is judged over the last 12 minutes (8 before it is trusted). Readings are reduced to the upper end of each + 15 s so WiFi transmit dips do not count, and a change in load, which moves the voltage for good and fools a plain + straight-line fit, has to be confirmed by a second fit that allows one level shift; +* the first three minutes after boot are not used at all: the board draws more while it joins WiFi and fetches over + TLS, the battery sags under it and recovers, and in the simulations that recovery was taken for a plug-in on almost + every boot once the sag reached 60 mV; +* **a slow history, for ending *full*.** Unplugging a full battery gives no step, and with a light load (the clock with + its radio mostly off draws 10 to 45 mA from a 2500 mAh cell) the top of the curve falls by only 0.07 to 0.3 mV a minute, + which twelve minutes cannot tell from flat: the first version sat on *full* for hours. So one level per five minutes is + also kept, for four hours: a charger holding the cell keeps it within a few millivolts of its plateau, a load takes it + down for good, and **a level 13 mV below the plateau ends *full***. Once *full* has ended for any reason, flat does not + bring it back until the last hour has not come down by more than 1.2 mV (a recharge, or a cell that sits still), and + then only by one of the two ways above: a charge that is seen, or three hours without a fall. + +It is a heuristic and shows no icon rather than guess. Timings against simulated traces (noise, WiFi dips, level +shifts; 300 random sequences each), not against the board: + +| What happens | Icon changes after | +| --- | --- | +| USB plugged in or pulled out with a step of 45 mV or more | about 1 minute (not in the first ~5 minutes after boot) | +| ... with a small step | 5 to 10 minutes | +| After a restart on a charger that is charging | the bolt after about 13 minutes, sometimes up to 20 (the Info page says *starting up*, then *learning*) | +| After a restart on a charger that has finished | the tick after about three hours, four if the cell was still settling; until then no icon | +| After a restart on the battery | no icon, which is right; the runtime estimate starts at once | +| A full battery unplugged (no step, the voltage just starts to fall), a 1 to 2.5 mV/min fall | 7 to 15 minutes | +| ... a 0.5 mV/min fall (about 150 mA) | 30 minutes | +| ... a light load, a 0.3 mV/min fall (about 45 mA) | about 50 minutes, 30 with the 7 mV step an unplug usually has | +| ... a lighter load, 0.12 mV/min (about 20 mA), or 0.07 mV/min (about 10 mA) | about 2 hours, or 3 hours (an hour or so with the step) | + +What it cannot do: charging slower than about 0.1C (under roughly 1.5 mV/min) is not recognised; a charger that lets the +cell sag after it has finished looks like discharging, which for the cell it is; a sudden jump in load of 15 mV or +more is mistaken for a few minutes of charging about once in 60 jumps; and a *full* that has ended comes back without +a new charge only once the cell has stopped coming down: about an hour after a brief dip (a knock on the cable that +takes the voltage down by 13 mV), some three hours after a drop that stays, and not at all in the first three hours +after a start, when there is no history yet to judge by. + +**What that means for the runtime estimate.** The estimate runs **whenever no charger shows**: the icon says neither +charging nor full. (Up to 1.4 it waited for the clock to be *seen* discharging, which a light load never is: after a +restart on the battery the **Left** line said *full* or *waiting* for hours, or for good.) While the icon says *full* the +**Left** line says *full* and nothing is worked out, so a clock that is unplugged from a charger that had finished +starts its estimate only when *full* ends: one to three hours under a light load (the table). **To start measuring at +once, restart the clock after unplugging it** (hold KEY for five seconds): a clock that has seen nothing of a charger +takes itself to be on its battery, and the first figure comes 50 minutes later. If it is on a charger after all, the +line says *no drain to measure (on a charger?)* until the tick appears. + +The serial console command `battery` prints the voltage, the trend in mV/min, the last step and how much data the +detector has, and the same line is logged once a minute; if the icon ever disagrees with reality those are the numbers +to look at. The thresholds are named constants at the top of `charge.h`. + +**For a certain answer** solder a wire from the charger's STAT output (the net that lights the charge LED; check with a +meter that it goes low while charging) to a free GPIO and set `PIN_CHARGE_STATUS` in `config.h`. Low then means +charging; the voltage still decides between full and discharging. This is untested on real hardware. + +### How long will it last? + +The *System info* page has a **Left** line, and the *Power and settings* page the detail: + +

+System info page +Power and settings page +

+ +``` +Battery 4.12 V 87% discharging -0.1 mV/min +Left 3 d 0 h (1.20 %/h over 300 min) +Current about 30.0 mA (of 2500 mAh) <- when battery_capacity_mah is set +``` + +* The voltage is turned into a percentage through the discharge curve *first*. The same drain in mA is a very different + number of millivolts per minute depending on where on the curve the cell is (5 to 6 mV per percent near the top, 13 + just over 4 V, 5 to 9 in the middle, 37 in the last 5 %), so a trend in volts means little; a trend in percent is one + steady rate all the way down. +* It is worked out whenever no charger shows (the gauge has neither the bolt nor the tick), which after a start on the + battery is from the first minute: see *What that means for the runtime estimate* above for the one case that takes + longer, a clock unplugged from a charger that had finished. +* Above 4.20 V the gauge says 100 % and stays there, but the estimate goes on with the same 5.2 mV to the percent: a + cell just off the charger reads a little over 4.20 V (4.21 to 4.22 V on the board this was written on), and its + coming down from there is drain like any other. +* Readings are boiled down to one point per five minutes (their median, so the dips of WiFi transmissions vanish), the + first 15 minutes after the clock went onto the battery are ignored (a cell just off the charger is still settling), + and the first figure appears after 30 minutes of settled readings (the page says *learning: first figure in N min*): + 50 minutes after a start in all. +* The rate is a straight line through the points of the last **2 to 8 hours: long enough for the cell to lose about 20 % + in it**, so a fast drain (a small cell, Spotify playing) is judged over about two hours and a slow one over most of a + day. Longer is better because a few millivolts are a percent or more, so a 30 minute window chased noise (it was off + by 60 % or more one time in ten in simulation). The page says how many minutes the figure rests on. +* "Hours left" is the percent above the shutdown voltage divided by that rate: it promises the time at the *average* + load of the window, the best guess when the load varies. It cannot know what you will do next (turn WiFi off and the + real answer changes at once; the estimate follows over the next hours). +* **How good is it on the run the curve came from?** Replayed through the estimator (`test_features`), from the third + hour on 72 of 73 hourly figures were within 15 % of the time the cell really had left, 4 % out on average. In the + first three hours off the charger the figure is **short** (a quarter to three quarters of the truth): the cell sheds + what is left of the charge faster than it drains later. The generic curve, replayed the same way, was within 15 % for + 11 of the 73 and up to three times too long. +* **And in general?** `tools/tests/battery_sim.cpp` simulates whole discharges (a cell model with internal resistance, + ADC noise and WiFi dips; loads of 8 to 90 mA; capacities of 100 to 2500 mAh) and scores the estimate against the true + time left: in steady drains the typical (median) error was **2 to 8 %** and nine in ten estimates were within **5 to + 20 %**; with a load that changes every couple of hours it was worse, as it must be. That is a model of a cell, not + your cell: treat the figure as a rough guide, most of all in the first hours off the charger. + +### The shutdown + +Battery empty screen + +A bare LiPo with no protection board is ruined by being run flat. The board has a protection chip (S-8261) as a last resort, +but it cuts off abruptly and a lot lower, so the **software stops first**: + +* when the battery stays under `battery_cutoff_v` (3.30 V, measured while the clock runs) for a minute and the clock is not + charging, it draws a *Battery empty* screen (which stays on the panel) and goes into deep sleep; +* every five minutes it wakes for a moment to look at the battery, and it **starts again only above 3.70 V**, which means + after charging: a cell that has just been relieved of the load springs back by a hundred millivolts or more, so + restarting at the cut-off would boot, sag, shut down and repeat; +* a press of **KEY** wakes it too (not BOOT: that pin also selects the chip's download mode at reset, so it must not be + held down as the chip wakes): it shows the screen again for eight seconds, and **holding KEY for 3 seconds there starts + the clock anyway** (for this run only); +* it also checks the battery first thing at every power-up, before the display or WiFi are switched on; +* `low_battery_shutdown = off` or `battery = none` turns all this off. + +Deep sleep is not zero: the 3.3 V converter, the display, the RTC and the other chips on the board draw something (not +measured, probably a few hundred microamps), so a cell left alone for weeks in this state can still reach the protection +chip's cut-off. + +## Power + +The board's owner measured, on USB at 5 V with no battery: + +| Setting | Current | +| --- | --- | +| 240 MHz, PSRAM on (Arduino IDE defaults) | 88 to 111 mA | +| **80 MHz, PSRAM disabled** | **about 30 mA idle, peaks around 70 mA** | + +That is why the quick start asks for those two Tools settings, and why the sketch sets 80 MHz in code as well +(`cpu_mhz`; the PSRAM can only be switched off in the IDE, so the *Power and settings* page warns when it is on). +The frame timing survives it: after a while at 80 MHz the *Frames* line read **1154 sent, 0 late, worst 2 ms**. Those +figures are with the Arduino core's own WiFi power saving, which the clock still uses (`wifi_power_save = normal`: the +radio sleeps between beacons). `max` lets it sleep a little longer for a little less power, but nothing was measured and +every reply, the network time included, can then come up to a third of a second late, so it is off by default. + +On a battery the same power at 3.7 V is more current than at 5 V, so expect roughly 35 to 45 mA (an estimate, not a +measurement): an 18650 of 2500 mAh lasts about two days, a 1000 mAh pouch about a day. **`wifi = off`** removes the +radio's share (and with it the weather, Spotify and network time); it should lower the current further, but by how much +has not been measured. + +Things that were considered and **not** done, so nobody wonders: + +* **Light sleep between the seconds.** It would take an offline clock from some 20 mA to a few mA, but the chip's slow clock + during sleep is an internal RC oscillator that is off by a fraction of a percent (minutes a day) and would have to be + corrected from the RTC every second; that could not be tested here and a wrong clock is worse than a short battery life. + A route that looks workable: the RTC's interrupt pin is wired to GPIO15, and its 1 Hz countdown timer could wake the chip + every second from an accurate crystal. + +## Saving power + +Four settings go further than `wifi_power_save`: **`wifi_mode = sync`** keeps the radio off between syncs, +**`spotify_live = off`** lets it sleep while music plays as well, **`cpu_idle_mhz`** slows the processor while the radio is +off, and **`console = auto`** (or `off`) shuts the USB serial console down. All four are **off by default**, and they +have **only just been switched on on a real board** (7 October 2026: the processor was seen to drop to 20 MHz; nothing +has been measured yet): what follows is what the code does and what a PC simulation of its rules says, not a +measurement. If you have a USB power meter, the figures this section lacks are exactly the ones worth sending back, and +without one the clock's own runtime estimate will do: see [Logging a battery run](#logging-a-battery-run). +[What is switched off](#what-is-switched-off) lists every part of the board and what the clock does about it. + +### WiFi sync mode + +`wifi_mode = sync` (or `#define WIFI_MODE "sync"` in `secrets.h`) switches the radio off between *sessions*. A session +starts when something needs the network, lasts while there is something to do, and ends 2.5 seconds after the last thing: + +| What starts or holds a session | When | +| --- | --- | +| the weather | every `weather_interval_min` (15 minutes) | +| the network time | about every hour: in a session that is going on anyway when it is within 10 minutes of due, on its own when it is 5 minutes late. Drift measurement works as before | +| a look at Spotify (if linked) | once in every session | +| music playing | with `spotify_live = on` (the default) the radio stays on and the clock asks Spotify every 10 seconds, as in always mode; after a pause it stays on 5 minutes more. With `spotify_live = off` it does not: see [Music in sync mode](#music-in-sync-mode) | +| a key press | the radio comes on and stays on for 45 seconds: the toast shows at once, and the action follows as soon as the radio is up (about 3 to 8 seconds on a quiet radio) | +| the *Now Playing* page | on while it is on screen, and for a minute after: this is also how to get to the Spotify linking page (open the page with BOOT and wait for the address to appear) | + +**When the network is not there** (the clock went to work with you, the router is restarting) that is not held against +it. The WiFi stack answers "no such network" within a few seconds, the session ends there and then, and the clock **looks +again every 2 minutes for the first ten, and every 5 minutes from then on, for as long as it takes**. A look is one scan +for each network the clock knows: about 5 seconds the first time (it asks twice before it believes that home is gone), 2.5 +seconds after that, twice as long with a backup network (worked out from how long the chip takes to scan the channels, +not measured). So a phone's hotspot that is set as the [backup network](#a-backup-network) is found within 5 minutes of +being switched on, with nothing pressed, and **at once when you press a button**: any button makes a clock that found no +network look again (KEY also holds the radio for its 45 seconds). Some phones only announce their hotspot while its +settings page is open, so have that on screen. The status bar says *No known WiFi in range* meanwhile, and the *System +info* page *none in range, looking again in 4 min*. + +**When the network is there but the clock cannot get in** (a changed password, a router that refuses it), or it is +joined and nothing gets done, that is a failure: the session gives up after 25 seconds (45 with a backup network) and the +next one waits 1, 2, 5, 10 and then 15 minutes, so a clock that is locked out does not hammer the router. A key press +does not wait for that, and one press buys one attempt. The *System info* page says *cannot join, next try in 9 min*. +The backup network is tried first when it was the one that worked last time. + +(None of this applies to `wifi_mode = always`: there the radio keeps looking without a pause for as long as it has no +network, which is the dearest thing it can do. A clock that travels is better off in sync mode.) + +**What a PC simulation of those rules gives** (`tools/tests/test_radio.cpp`, a model of the network task with the real +rules): + +| Simulated | Radio on | | +| --- | --- | --- | +| A day with a good network | **0.6 %** of the time | 96 sessions of about 6 seconds; 0.9 % with Spotify linked | +| Eight hours away from the network | 0.9 % | 99 looks; 1.7 % with a backup network set (two scans a look) | +| Three hours with a router that refuses the clock | 3.5 % | 15 failed sessions of 25 seconds | +| The router up, the internet down | 2.9 % | the weather and the time retried after 1, 2, 5, 10, 15 minutes | +| Two hours of music, `spotify_live = on` | 100 % | | +| Two hours of music, `spotify_live = off` | 3.5 % | 42 sessions; the next title 5 seconds after the track changes | + +The *Power and settings* page shows the real figure (`on 0.8 %, 96 sessions, next in 9 min`) and the *System info* page +says `asleep (sync mode), next in 9 min` while the radio is off. What is not known is the current: how much a session +costs (association, the TLS handshakes) and what the board draws with the radio off. + +Things to expect in this mode: the status bar says *Connecting to WiFi...* for a few seconds every quarter of an hour; the +dashboard's Spotify strip is as old as the last session when nothing is playing (a paused track stays until a session +finds it gone); the first KEY press after a quiet time answers a few seconds late; and the radio stack is started and +stopped hundreds of times a day, so watch the *Uptime* line (it shows the free memory) for the first days. + +### Music in sync mode + +With `spotify_live = on` (the default) music keeps the radio on: once a session's look at Spotify finds something +playing, the clock stays connected and asks every 10 seconds (and just after the track should end), exactly as with +`wifi_mode = always`. The strip is then never more than those seconds behind your phone, and sync mode saves nothing +while the music plays. + +`spotify_live = off` (`#define SPOTIFY_LIVE 0`) lets the radio sleep while music plays too. The clock knows how long the +track is and how far in it was, so it counts the progress bar on by itself and **looks again when the track should be +over** (a second and a half after, because Spotify needs a moment to move on): one short session a track instead of a +radio that is on all the time. What that costs: + +* Something done **on a phone** shows late. A track you skip stays on the screen until it would have ended; a pause shows + as a bar that keeps moving until then. The latest it can be is the next weather update, because every session looks + at the player once. +* Anything done **with the clock** is live as before: a press of KEY brings the radio up, the clock looks first and then + acts, and the radio stays on for 45 seconds. So is the *Now Playing* page, for as long as it is open and a minute more. +* If the network goes away, the track is taken off the screen at the first look that fails. + +### The idle clock + +`cpu_idle_mhz = 40` (or `20`; `80` when `cpu_mhz` is higher; `off` for no change; `#define CPU_IDLE_MHZ 40`) lowers the +processor's clock **whenever the radio is off**: with `wifi = off`, or between the sessions of `wifi_mode = sync`. The +clock goes back to `cpu_mhz` **before** the radio is switched on (WiFi works from 80 MHz up) and drops **after** it is off. +It also goes back while a button is down and for 8 seconds after (a frame takes four times as long to draw at 20 MHz as at +80, and whoever pressed wants the answer quickly), and **for as long as a computer is on the USB port and the console +runs**: below 80 MHz the clock of some peripherals follows the CPU and the USB console may stop, which would look like a +crash while you are reading the log. So to see the idle clock at work, take the clock off the computer (a charger or a +power bank is fine, they send no data) or set `console = off`; `power` in the console says what the clock thinks. + +| `cpu_idle_mhz` | Drawing a frame | The chip alone, idle, radio off (Espressif) | | +| --- | --- | --- | --- | +| 80 (only below a higher `cpu_mhz`) | about 30 ms | 22.0 mA | the radio can run | +| **40** | about 60 ms | **13.2 mA** | **the one to try first**: the crystal's own frequency, and the lowest clock the datasheet gives figures for | +| 20 | about 120 ms | not published | the lowest the Arduino core lists for this chip | + +**Which is the lowest safe one?** 40 MHz is the safe choice and gets most of what there is to get: by the +[ESP32-S3 datasheet](https://documentation.espressif.com/esp32-s3_datasheet_en.pdf) (table 5-9, v2.2: both cores idle, +peripheral clocks off) the chip alone draws 13.2 mA at 40 MHz against 22.0 at 80, 27.6 at 160 and 32.9 at 240. 20 MHz +is the lowest on offer; it should save a few mA more, but Espressif publishes nothing below 40, so that is a guess until +someone measures it. **10 MHz is deliberately not offered**: the software would set it, but a frame would then take a +quarter of a second to draw, so the processor would be busy for a third of every second and there would be little left +to save, and the Arduino core does not list it for this chip. + +Two things in the firmware make the low clocks usable. The frame planner is told about every clock change and plans the +first frame at the new clock with the right costs; in the simulation no frame is late at either switch. And **with an +idle clock set the buttons are read by interrupt as well as by the loop**: a frame at 20 MHz takes longer to draw than a +quick tap lasts, so a loop that only looks between frames would lose taps (the test counts them, for taps of 40 to 150 ms +at every moment of the second: 3 in 100 at 40 MHz, 8 in 100 at 20 MHz, none with the changes recorded). Without an idle +clock the buttons are read as they always were. + +Things that are not known until someone tries it: the display's SPI clock and the battery ADC were written with 80 MHz +in mind (the Arduino core's own `getApbFrequency()` is known to say 80 MHz at lower clocks on the ESP32-S3, +[arduino-esp32 #7086](https://github.com/espressif/arduino-esp32/issues/7086)), so check that the screen looks right at the +idle clock and that the *Battery* voltage on the *Power and settings* page does not change by more than a few millivolts +when you press a button (which brings the clock up). (The first report from a board: at 20 MHz the pages are drawn and +can be read. The voltage and the frame timing have not been reported yet.) + +### The console + +The log and the commands run over the chip's own USB port. `console = auto` (`#define CONSOLE_MODE "auto"`) keeps them +**only while a computer is on the port**: ten seconds after the last sign of one, counted from start-up, the console is +shut down, so a clock on a battery or a charger drops it ten seconds after it started and one on your computer keeps it +until the cable is pulled. `console = off` shuts it down at the end of every start-up, computer or not. Shut down means: +nothing is formatted or printed any more, the serial driver is stopped (its interrupt and buffers freed), the USB +transceiver and its pull-up are switched off, and `cpu_idle_mhz` no longer waits for a computer to go away. It **stays +down until the next restart**, because with the transceiver off the clock cannot see a computer arrive, and the computer +no longer sees the clock: there is no serial port, and the Arduino IDE cannot reset it for an upload. Three ways back: + +* with `auto`, plug the cable into the computer and restart the clock: hold KEY for five seconds and let go (see + [Controls](#controls)); no need to switch it off; +* **restart the clock with KEY held down**: the console then stays on for that run whatever the setting says (the + *Power and settings* page says so). Without switching it off: hold KEY for five seconds, let go, and press KEY again + while the screen says *Restarting...*, keeping it down for two seconds. From there `set console = on` in the console + changes the setting for good, and an upload works as usual; +* hold **BOOT** while switching it on (the chip's own download mode, which no setting can take away), or use the + [SD card](#updating-the-firmware-from-the-sd-card), for the firmware as for the setting (`console = on` in the file). + +How much it saves is not measured and will be small next to the radio and the CPU clock (the port is a fixed block of +the chip, not a second processor); the datasheet has no figure for it. It is here so that nothing runs that is not used. +The block's own clock is left on: the SDK's check for a computer on the port keeps reading its registers. + +### What is switched off + +| Part of the board | What the clock does about it | +| --- | --- | +| Speaker amplifier (NS4150B) | Off: its enable pin (GPIO46) is held low from the first line of start-up, and through the deep sleep | +| Audio codec (ES8311), microphone ADC (ES7210) | Told to power down at start-up, over I2C, with exactly what Waveshare's audio example for this board (`07_Audio_Test`, `esp_codec_dev`) writes when it closes them: volumes to zero, the analog parts and the microphone bias off, the clocks off. The log says whether each one answered. The I2S lines to the chips are held low so they do not float. `#define AUDIO_CHIPS_STANDBY 0` in `secrets.h` leaves all of it alone (to compare) | +| The audio section's own 3.3 V regulator | Stays on (about 90 µA by its datasheet); the schematic shows no way to switch it | +| Bluetooth | Never started | +| WiFi | `always`: the radio sleeps between beacons (`wifi_power_save`). `sync`: off between sessions. `wifi = off`: never on | +| PSRAM | A choice in the Arduino IDE (*Tools > PSRAM: Disabled*; with 80 MHz it took the board from about 100 mA to about 30); the *Power and settings* page warns when it is on | +| USB serial port | On, unless `console = auto` or `off` | +| CPU | 80 MHz, and less with `cpu_idle_mhz` while the radio is off | +| Temperature sensor (SHTC3) | Asleep between readings (every 10 seconds) | +| SD card | Read once at start-up and released; the card itself keeps its supply and idles (take it out to save that) | +| The FAT partition of the flash | Not touched, unless `power_log` is on: then one short write every ten minutes, see [The clock's own log](#the-clocks-own-log) | +| Clock chip (PCF85063), battery divider (300 kΩ), charger, protection, the 3.3 V converter | Always on, by design: microamps | +| The display | On, it is the clock. It keeps its picture in deep sleep | + +The audio chips' power-down and the levels on the I2S lines are new and, like the rest of this section, **have not been +tried on the board**; what they save has not been measured either. The codec's datasheet says it is powered down after +power-on anyway (then nothing is gained there); for the microphone ADC, which draws 63 mW when it runs and 10 µA powered +down, no statement about its state after power-on was found, which is why both are told rather than left alone. + +### Logging a battery run + +What a setting saves shows in how fast the battery goes down, and the clock measures that itself. To compare settings, +make one run with each on the same cell and write the readings into [`docs/power-log.csv`](docs/power-log.csv), one +line per reading. The sheet comes with a line for each of five runs worth making (the stock settings with and without +WiFi, a middle ground, and the two lowest): copy a run's line for every reading you take of it. Or let the clock do +the writing: with `power_log = on` it keeps the same readings itself, every ten minutes, see +[The clock's own log](#the-clocks-own-log) below. + +1. Charge the cell until the charger has finished and unplug it. Put the card in with the settings of the run, with + every setting that differs between the runs written out (the clock keeps what an earlier card told it), and + **restart the clock: hold KEY for five seconds**. A clock that has seen no charger since it started measures from + the first minute, and every run then covers the same stretch of the cell. +2. Take the card out again (one that stays in draws a little), keep the music the same in every run, and take the first + reading. +3. Read again after an hour, and then every few hours. The first figure comes after 50 minutes and is rough; four to + six hours give a rate that two settings can be told apart by. + +| Column | What goes in it | +| --- | --- | +| `test` | A name for the run, the same on all its lines | +| `firmware` | The version and the build id, from the first line of the *System info* page: `1.6 3f9a12c` | +| `wifi`, `wifi_mode`, `wifi_power_save`, `cpu_idle_mhz`, `spotify_live`, `console` | The settings of the run, as the settings file has them | +| `battery_capacity_mah` | What is printed on the cell | +| `card`, `music` | `in` or `out`; `none`, `playing` or `paused` | +| `date`, `time`, `uptime` | When the reading was taken (`2026-10-08`, `14:30`), and the *Uptime* line (`0d 3h 12m`) | +| `clock_shown` | *Power and settings*, the *Power* line: `20 idle`. Wait ten seconds after the last button press: a press takes the processor to full speed for eight | +| `radio_on_pct`, `radio_sessions` | The *Radio* line (only with `wifi_mode = sync`): `0.8` and `96` | +| `battery_v`, `battery_pct` | *Power and settings*, the *Battery* line: `4.117` and `87.0` | +| `battery_state` | *System info*, the *Battery* line: `on battery?`, `discharging`, `charging` or `full` | +| `left`, `pct_per_hour`, `over_min` | The *Left* line, `3 d 0 h (1.20 %/h over 300 min)`: `3 d 0 h`, `1.20` and `300`. Copy the time exactly: up to 1.5 the rate is shown with one decimal only, and the time is then the finer figure of the two | +| `clock_ma`, `meter_ma` | The *Current* line (it needs `battery_capacity_mah` in the settings), and what a meter in the supply reads, if you have one | +| `free_kb`, `frames_sent`, `frames_late`, `frames_worst_ms` | *System info*, the *Uptime* and *Frames* lines: the memory left, and whether the seconds still come on time | +| `notes` | Anything else. In quotes if it has a comma in it | + +Edit it as text or in a spreadsheet (saved as CSV again). `bash tools/tests/run_tests.sh` reads the sheet: it names a +line that has a field too few or too many, and a setting's column that holds something the settings file would not +take. + +#### The clock's own log + +`power_log = on` (`#define POWER_LOG 1` in `secrets.h`) makes the clock write the readings down by itself: **one line +ten seconds after every start, and one every ten minutes**, into a file in its own flash. The lines have the sheet's +columns, by the same names and in the same order, so they can be pasted under the sheet's heading and one script reads +both. It is off by default, **new in 1.7 and not yet tried on the board**. + +* **What it fills in.** Every column of the table that the clock can know: the firmware, the six settings, the + capacity (when it is set), the date and the time (only once the clock is trusted; the uptime is always there), the + CPU clock and why it is there, the radio's share, the battery, the three figures of the *Left* line and the current, + the memory and the frames. `music` is what Spotify said last (`playing`, `paused` or `none`; empty when no account is + linked). `battery_state` can also say `starting up` or `learning` (the first minutes after a start) and `none` (no + battery in use). The figures of the runtime estimate stay empty until it has its first one, 50 minutes after a + start, and while a charger shows; a fall too slow to measure is given as the rate it is, with `>30 d` for the time. + On the first line of every run `notes` says why the clock started, which is how the runs are told apart: + `start (power on)`, `start (restart)`, `start (after sleep)` (the low-battery shutdown, over), `start (brownout)`, + `start (watchdog)`, `start (after a crash)`, `start (reset over USB)` (an upload) or just `start`. `test`, `card` + and `meter_ma` stay empty: the clock has no name for a run, only looks at the card while it starts, and has no meter. +* **Getting it out.** Put a FAT32 card in and restart the clock (hold KEY for five seconds). While it starts it copies + the log to the card as `ESP32-S3-RLCD-PowerLog.csv` (a file of that name is replaced), and the *Power and settings* + page says *copied to SD card at start (12 KB)*. That goes well with the runs above: the card that brings the settings + of the next run takes away the readings of the last one. The line of that very start is not in the copy yet, and a + log that is nearly full makes that start a few seconds longer (an estimate). A card whose settings file switches the + log off still gets the copy, that once. Or type **`powerlog`** in the serial console: it prints the whole log as + CSV. The clock stands still while it prints (a few seconds for a full log), and the console's own log lines are held + back so that none lands in the middle. `powerlog clear` forgets the log. +* **How much it keeps.** The newest 0.5 to 1 MB: 3,300 to 6,700 lines of the usual length, three to six weeks of them. + The log is two files; when the newer one reaches half a megabyte the older one is deleted and the newer takes its + place, so the oldest half goes at once and nothing is ever written out a second time. A firmware that logs other + columns starts a new file under its own heading, and the copy then has a second heading where the columns change. +* **Where it shows.** A *Log* row on the *Power and settings* page: `412 KB, 37 lines this run, 4 min ago`, or what is + wrong (see [Troubleshooting](#troubleshooting)). After three lines in a row that could not be written the log stops + until the next start, and a line that fails is not tried again before the next one is due. +* **What a line costs.** A line is about 160 bytes, but flash is rewritten 4 KB at a time: the sector with the end of + the file and the one with the directory entry (FAT keeps the file's length there) are each erased (about 45 ms) and + written (about 10 ms), while the flash chip draws some 20 mA. About every 25th line begins a new 4 KB of the file + and rewrites both copies of the allocation table as well, and about every 7th the wear levelling moves a sector. + That makes 0.1 to 0.3 seconds a line with the processor busy instead of idle: roughly **5 mA·s a line, 0.008 mA on + average** at six lines an hour, under a thousandth of a clock that draws 10 mA. Nothing happens in between: the + partition is mounted once, at start-up, and no file is open between two lines. The flash does not wear out over + it either (some 300 sector erases a day, spread over 2,500 sectors that take 100,000 each). All of this is worked + out from the datasheet of a typical 16 MB flash chip and the sources of the file system, **not measured**; `power` + in the serial console says how long the last line and the slowest took to write on your board, and the serial log + says it for every line. +* **What it touches.** The log lives in the FAT partition of the flash (`ffat`: the 9.9 MB of the partition scheme in + the [Quick start](#quick-start)), which nothing else in the sketch uses. A clock whose `power_log` was never on + never reads or writes it. The first time, the partition is formatted if it holds no FAT file system (about half a + second; anything else that was in it is gone). While the log is on the partition stays mounted, which takes about + 13 KB of memory (by the sizes of the file system's structures, not measured): the *Uptime* line, and the log's own + `free_kb`, read that much lower than in a run without the log. A line is written right after a second has begun, so + that the flash is done before the next second is drawn; a write that takes too long would show as one second that + stutters. Between two lines nothing is waiting to be written, so a restart, the shutdown or a pulled battery lose + nothing; a power cut in the very tenth of a second in which a line is written can cost the lines that share its + 4 KB of the file (FAT has no journal). + +## Troubleshooting + +| Symptom | What to try | +| --- | --- | +| White text on a black screen | Hold BOOT for a second (remembered), or set `DISPLAY_INK_IS_BLACK 0` in `config.h`. The default follows Waveshare's LVGL port and the ESPHome ST7305 driver (a set bit is white), so this should not be needed | +| Stuck on *Connecting to WiFi...* | Wrong name or password, or a 5 GHz-only network. With a backup network set the clock alternates between the two every 20 seconds, so check both | +| *Too big (the .merged.bin? use the .ino.bin)* on the firmware screen | The card holds the `….merged.bin` that the IDE exports next to the real firmware; copy `11_Clock_Dashboard.ino.bin` instead | +| *Cannot read the file (card error)* on the firmware screen, with the bar almost full | If it happens on every file: the clock runs version 1.3 (a build from before 7 October 2026), whose updater checked the file from the wrong place. Nothing was written. Upload the current build once over USB; from then on the card works. Otherwise it is what it says: copy the file again, or try another card | +| *This build was installed from the card before* | The card still holds a file the clock installed (and the firmware has since changed). Build again (every build is different), or type `fwforget` in the serial console | +| The Info page says *the last SD card update was rolled back* | The new firmware reset within its first minute, so the bootloader went back to the old one. Watch the new build's log over USB, fix it and try again | +| The Info page says *(backup)* | The main network could not be joined (or was out of range). The clock looks for it every 10 minutes and goes back by itself, see [A backup network](#a-backup-network) | +| *No WiFi name set (see README)* | `secrets.h` still has the placeholder name and there is no `wifi_ssid` in the settings | +| *No NTP reply (UDP 123 blocked?)* | The router or network blocks NTP; the clock needs it (or a previously set RTC) before it will do HTTPS | +| Weather missing, *Weather update failed* | Check the serial log; the clock retries every minute. A reply that lacks a field the clock shows as a fact (the temperature, humidity, wind, condition, day or night, or a day's conditions and temperatures) is refused rather than shown with a made-up default; the rain chance, sunrise, sunset and UV may be missing and then show 0 %, `--:--` and 0 | +| *Set a location (SD card or secrets.h)* | No `latitude` / `longitude` or `location` anywhere. The clock still tells the time, and the status bar says *Earth* | +| Wrong time zone | Check the coordinates, or set `timezone` | +| *SD card is exFAT: format it as FAT32* (or *is NTFS*, or *is not formatted*) | Format it as FAT32. Cards over 32 GB come as exFAT and Windows may not offer FAT32 for them: use a card of 32 GB or less | +| *SD card: GPT partitions, needs MBR* | Windows shows the volume as FAT32, but the card's partition table is a GUID one (GPT), which the clock's FAT library cannot read, and formatting does not change the table. In Windows *Disk Management*: delete the volume on the card, right-click the disk's label at the left and choose *Convert to MBR Disk*, then make a *New Simple Volume* formatted FAT32. (Everything on the card is lost. Mind which disk you pick.) The SD Association's *SD Memory Card Formatter* also puts a card of up to 32 GB back the way it was sold | +| *SD card: no FAT32 partition on it* / *no FAT32 found on it* | The card is partitioned for something else (Linux, a camera's own format). Re-make it as above | +| *SD card: FAT found, will not mount* | There is a FAT volume and the library refuses it: a damaged file system (let Windows check the card), or an odd format; formatting it afresh with the default allocation unit size settles it | +| *SD card: cannot read it* | The card answered and then gave no data: seat it again, try another card | +| *SD card: cannot write* | The card's write-protect switch is on, or it is damaged; the clock never formats a card | +| *SD settings: ... problems* | The *Power and settings* page lists the first three, with their line numbers | +| *SD settings: 4 in force, none new* (or *nothing new* from 1.4 and older), and you had just added them | They apply: the file was read and each of its settings already had that value, kept in the clock's flash from an earlier start. With a firmware file on the same card that earlier start is the one that installed it: it applies the settings first, then installs the firmware and restarts before its banner. From 1.5 on the banner after an update says what the settings file changed (*4 changed*). The *Power and settings* page shows the settings at work in any case | +| *SD settings: the file sets nothing* | Every line of the file is a comment, as in the file the clock writes: remove the `#` in front of a setting. If you did, the clock is reading another file: it takes `ESP32-S3-RLCD-Config.txt` before `ESP32-S31-RLCD-Config.txt` | +| *N built-in defaults not used* | A value in `secrets.h` fails the settings' own checks (a misspelt choice, a number outside its limits). It is left out and the factory value used; the *Power and settings* page and the serial log name the macro and say what it wants, see [Building the settings in](#building-the-settings-in) | +| A setting in `secrets.h` has no effect | The clock's flash holds a value for it from an earlier SD card, and that wins over the build. `reset_all = yes` on a card forgets them, or `name =` with nothing after the `=` forgets one | +| *N changed, NOT saved to flash* | The settings from the card are in force for this run, but the clock's flash would not take all of them (full or failing), so they are gone at the next restart. Leave the card in, or type `config` in the serial console to see what is in force | +| The settings file is ignored | It has to be in the card's top folder and called `ESP32-S3-RLCD-Config.txt` (`ESP32-S31-RLCD-Config.txt` is accepted too, in case of a typo); the clock only looks at start-up, so restart it with the card in (hold KEY for five seconds and let go) | +| No battery icon, or the wrong one | No icon is what a clock on its battery shows. The bolt takes about 13 minutes after a restart, the tick about three hours if the clock did not see the charge end, and it all has limits, see [Battery](#battery); type `battery` in the serial console and look at the trend and step | +| *Left* says *full* on a clock that runs on its battery | It watched the charge finish and has not seen the cell come down by 13 mV since, which takes one to three hours under a light load. Restart it (hold KEY for five seconds and let go): with no sign of a charger it starts measuring at once. (Up to 1.4 it also said *full*, for hours, after any restart with the cell above 96 %) | +| *Left* says *no drain to measure (on a charger?)* | The clock has seen neither a charger nor the battery going down: either it is on a charger that has finished (the tick comes about three hours after a start) or it draws so little that a month would not empty the cell | +| Gauge says full on USB with no battery | Say `battery = none` in the settings | +| The gauge stops at 93 to 97 % when the cell is full (a tester says 4.20 V), or the icon never says *full* | The ADC reads a little low. Calibrate it: `batcal 4.20` in the serial console, or `battery_calibration` in the settings, see [Calibrating the voltage](#calibrating-the-voltage) | +| *Battery empty* screen but the cell is charged | Hold KEY for 3 seconds on that screen to run anyway; check `battery_cutoff_v`; or `low_battery_shutdown = off` | +| The *Log* row says *OFF: no ffat partition* | `power_log = on`, but the firmware was built with a partition scheme that has no FAT partition. Choose *16M Flash (3MB APP/9.9MB FATFS)* as in the [Quick start](#quick-start) and upload over USB (the partition table does not come with a firmware from the SD card) | +| The *Log* row says *OFF: the flash partition will not mount* | The FAT partition could be neither mounted nor formatted; nothing is logged in this run. The serial log has the error code, right after the start | +| The *Log* row says *last write FAILED*, or *STOPPED after 3 write errors* | A line could not be written (the next is tried ten minutes later), and after three in a row the log gives up until the next restart. What is in it stays: `powerlog` in the serial console prints it. If it happens again after a restart, `powerlog clear` starts the log afresh | +| No `ESP32-S3-RLCD-PowerLog.csv` on the card | The log is copied only while the clock starts (hold KEY for five seconds with the card in), only with `power_log = on` (or just switched off by that card), and only once it has a line. The *Power and settings* page says what happened: *copied to SD card at start*, or *NOT copied to SD: card write error* (a card that is full or write-protected: the log is still in the clock, try another card) | +| *asleep (sync mode)* and no Spotify | Expected with `wifi_mode = sync`: press KEY once, or open the *Now Playing* page, and the radio comes on, see [Saving power](#saving-power) | +| *No known WiFi in range* | `wifi_mode = sync` and none of the clock's networks answered. It looks again every 2 minutes, later every 5, and at once when any button is pressed. If the network is right there, check the name (it has to match exactly) | +| *No WiFi, trying again later*, the Info page says *cannot join* | `wifi_mode = sync`: the network is there but the clock cannot get in (the password?), or it joins and nothing gets done. It tries again after 1, 2, 5, 10 and then every 15 minutes; KEY tries now | +| The Spotify strip shows a track that was skipped a while ago | `spotify_live = off`: the clock looks once a track, see [Music in sync mode](#music-in-sync-mode). Press KEY, open the *Now Playing* page, or set `spotify_live = on` | +| The computer does not see the clock (no serial port, the IDE cannot upload) | `console = auto` or `off` has shut the USB port down. Restart the clock with KEY held (the console stays on for that run; `set console = on` there makes it stay), or with `auto` just restart it with the cable in, or hold BOOT while switching on; see [The console](#the-console) | +| The serial console stops a minute after start with `cpu_idle_mhz` set | The clock was slowed below 80 MHz with a computer attached: the clock stays fast while the USB port shows a computer (`power` in the console says so), but a build with *USB Mode: USB-OTG (TinyUSB)* cannot tell. Set `cpu_idle_mhz = off` while debugging | +| The screen is wrong or the buttons feel late with `cpu_idle_mhz` | Try a higher idle clock (40 or 80) or `off`, and tell someone: see [The idle clock](#the-idle-clock) | +| Seconds stutter now and then | See [Keeping the seconds on time](#keeping-the-seconds-on-time): the Info page's *Frames* and *Time* lines tell the firmware, the network and the panel apart | +| Digits change a little early or late | Change `DISPLAY_LATENCY_MS` in `config.h` (default 20, the panel adds 0 to 39 ms on top) | +| *App owner needs Premium* | Spotify's rule for development-mode apps; see [Spotify](#spotify) | +| *No active Spotify device* | Start playback once on any device, then use KEY | +| *Rate limited, retry in 4h 46m* | Spotify's quota; wait it out, don't reboot repeatedly | +| Spotify says *redirect_uri: Insecure* | The Redirect URI in the Spotify dashboard is not exactly `http://127.0.0.1:8888/callback` (Spotify accepts plain http only for 127.0.0.1) | +| *Spotify refused the code* when linking | Codes are single use: start again from the first step of the page | + +Serial console commands: `status`, `battery`, `batcal V` (calibrate the battery voltage to what a tester shows, see +[Calibrating the voltage](#calibrating-the-voltage)), `power` (the CPU clock and why it is there, the radio's share of the +time, the console, the buttons, the audio chips, the power log and how long its last line took to write), `powerlog` +(the power log as CSV; `powerlog clear` forgets it, see [The clock's own log](#the-clocks-own-log)), `config` (every +setting, passwords hidden), `set name = value` (one +setting, by the name and with the rules of the SD card file: checked, saved to flash, then a restart; `set name =` +forgets it), `timing`, `refresh`, `page N`, `invert`, `unlink`, `sleeptest` (draws the *Battery empty* screen and goes +into the low-battery deep sleep without the battery being low: the way to try the shutdown on the bench; KEY or five +minutes wake it), `reboot`. + +## Not yet tried on the board + +Written and tested on a PC (and compiled), never run on the real board, with what the board's owner has seen there +since (7 October 2026) noted item by item. The same as a checklist, with what the clock's own power log of 8 to 17 +October showed, is in [TESTING.md](TESTING.md). + +* writing to the SD card (the example settings file, the rename of an installed firmware file), and the look at a + refused card's first sector that names the reason. Seen on the board: a card with a GUID partition table was turned + away, and a FAT32 card was mounted and a 1.5 MB firmware file found on it and read from end to end, so the slot, its + wiring and reading work; **a settings file was read, and its four settings were applied and kept in flash** (they were + in force, and found unchanged in the file, at the next start). How long the clock takes to notice that no card is in + the slot is unknown; +* the deep-sleep shutdown: the pins held through the sleep, the wake-up by timer and by KEY, the 3 second + override, and what the sleeping board draws (`sleeptest` in the serial console tries it without a flat battery); +* a `cpu_mhz` change from the SD card file while the display is already running (the display driver has to let go of the + SPI bus for the moment the clock speed changes; a code review found that without it the boot would hang); +* how close the runtime estimate comes with the real cell. Seen on the board: with WiFi always on at 80 MHz it gave a + figure (about six days); restarted at 99 % with the radio mostly off and the processor at 20 MHz it gave none and + said *full*, which is what 1.5 changes. The new rules (no *full* without a charge or three still hours, the estimate + whenever no charger shows) are tested on simulated voltage traces, with guessed noise, and have not run on the board; +* the clock drift measurement over real NTP syncs; +* the backup network on the real radio: the switch when the main network cannot be joined, and the scan while connected + that finds the main network again (the choice of network is tested on a PC, the WiFi calls are not); +* of the firmware update from the SD card: the trial minute (its countdown, the confirmation), the rename of the + installed file, and the rollback. **Seen on the board: a build was installed from the card and the clock restarted + into it** (1.4, on the second try). The first try found the file, read it, and then failed on a mistake in the updater + itself (it checked the file from the wrong place and ran out of it at the end); the two passes over the file now run + on a PC against a made-up file and flash, the mistake included, and what to do with a file is tested against the + header of a real exported build. **A clock that runs a build from before that fix (version 1.3) cannot be updated + from the card: it needs one upload over USB;** +* the count of changed settings that is kept in flash across the restart of a firmware update, so that the banner after + it can say what the card's settings file did (new in 1.5): the texts are tested on a PC and measured on the rendered + screen, the keeping is not; +* the build id on the *System info* page: that it is the start of the hash stored in the firmware file was checked on + an exported build, the call that reads it on the clock has not been seen to answer; +* the new screens on the real panel (the renders are exact, the panel is not: its contrast and refresh are not modelled); +* a built-in default that is refused (a typo in `secrets.h`) on the screen: the banner at start-up and the rows on the + *Power and settings* page. Which values are refused and what the message says is tested on a PC; showing it uses the + same rows as the problems of an SD card file, which have not been seen on the panel either; +* `wifi_mode = sync` and `cpu_idle_mhz` (see [Saving power](#saving-power)): nearly all of it. The scheduling is tested + against a simulated day and the clock policy against simulated frames. **Seen on the board: with `cpu_idle_mhz = 20` + and `wifi_mode = sync` the processor drops to 20 MHz, and the *Power and settings* page that says so is drawn at that + clock.** Not yet: the radio being stopped and started again over hours, whether frames come late around a clock + change, what the battery ADC and the I2C bus do at 20 MHz, the USB check, the Spotify strip and the Now Playing page + waking the radio, and the current actually saved; +* "the network is not there" in sync mode: the rule is tested on a PC, but it rests on what the WiFi stack reports when + it finds no network of that name (the reason code, how soon, how often), and that has not been watched on the radio. If + the reports come differently the clock falls back to what it did before: 25 or 45 seconds, then the 1 to 15 minute waits; +* `spotify_live = off`: the looks are simulated; what Spotify really reports around the end of a track is not; +* `console = auto` and `off`: stopping the serial driver and the USB transceiver while the clock runs, whether a computer + is noticed in the first ten seconds, the KEY-held start that keeps the console, and the `set` command; +* the buttons read by interrupt (only with `cpu_idle_mhz` set): the replay is tested on a PC with simulated taps and + contact chatter, the interrupts themselves are not; +* the restart by KEY (held for five seconds, then let go): when it arms and fires is tested on a PC and the banner is + measured on the rendered screen; the restart itself, and the card being read after it, are not; +* the audio chips' power-down at start-up and the pull-downs on their I2S lines (see + [What is switched off](#what-is-switched-off)); +* the power log (`power_log = on`, new in 1.7): **all of it.** What a line holds, when one is due and what is done + with the two files (the heading, the cap, reading the log back, writes that fail) run on a PC against a made-up file + system, the file calls themselves against a folder of the PC, and the sketch compiles. Never run on the board: a FAT + file system in the clock's own flash (the `ffat` partition under the IDF's wear levelling) and mounting it, formatting + that partition the first time, how long a line really takes to write and whether a second stutters for it, writing + to the flash while the processor runs at 20 or 40 MHz, the memory it takes, the copy to the SD card (writing to a + card is untried as a whole, see the first item), the `powerlog` commands, and the note that says why the clock + started. What a line costs is an estimate from a datasheet, see [The clock's own log](#the-clocks-own-log). **The + first thing to look at** is the *Log* row of the *Power and settings* page a minute after a start with the log on: + it should say `1 KB, 1 line this run, 50 s ago`, and `power` in the serial console how long that line took to write; +* from earlier: the Spotify linking flow, the charging indicator, `PIN_CHARGE_STATUS`. + +## Files + +| File | Role | +| --- | --- | +| `11_Clock_Dashboard.ino` | UI loop (core 1): start-up, frame timing, buttons, sensors, battery, building the frame and the info pages | +| `ui.cpp`, `ui.h` | All drawing (U8g2 C API only, no Arduino code): the screens, the legend, the shutdown screen | +| `settings.h`, `settings.cpp` | The settings: table, the tolerant file parser, the example file writer (pure logic, host-tested) | +| `build_defaults.h` | The defaults of `config.h` / `secrets.h` as a `Settings`, through the same rules as the card (pure logic, host-tested: no setting can lack a build-time default) | +| `app_settings.*`, `sdcard.*` | Settings in flash (`cfg` namespace) and on the SD card | +| `sd_layout.h` | What is on a card that will not mount (exFAT, a GUID partition table, nothing), read from its first sector (pure logic, host-tested) | +| `net_task.cpp` | Core 0 task: WiFi, NTP, location, weather, time zone, drift measurement | +| `spotify.cpp`, `spotify_parse.cpp` | Spotify: link page (OAuth with PKCE), tokens, polling, commands | +| `link_page.*`, `spotify_helper_script.h` | The Spotify setup page, and the helper script it serves (generated from `tools/spotify_link.py`) | +| `weather.cpp`, `weather_codes.h` | Open-Meteo parsing, weather code to icon/text | +| `sensors.cpp` | SHTC3, PCF85063 RTC and battery (no extra libraries); the power-down of the unused audio chips | +| `power.*` | CPU clock, the low-battery deep sleep, the levels of the unused audio pins | +| `radio_plan.h` | `wifi_mode = sync`: when the radio is on, what a missing network and a refusing one cost, what Spotify needs of it (pure logic, host-tested with a simulated day) | +| `clock_policy.h`, `console_policy.h` | When the CPU clock is lowered (`cpu_idle_mhz`), and when the console is shut down (`console`) | +| `log.h`, `log.cpp` | The serial log, and shutting the console and the USB port down | +| `button_edges.*` | The buttons by interrupt, used with an idle clock | +| `charge.h` | Charging / discharging / full from how the battery voltage moves | +| `battery_est.h`, `low_battery.h` | Runtime left; when to shut down and when to start again | +| `power_log.h`, `app_power_log.*` | The power log: the line of CSV, when one is due, the two files and their cap (pure logic, host-tested on a made-up file system), and the flash partition, the copy to the SD card and the serial commands | +| `drift.h`, `moon.h`, `datefmt.h` | Clock accuracy measurement, moon phase and its glyph, date formats | +| `wifi_pick.h` | Which WiFi network to try next (main, backup) and when to look for the main one again | +| `fw_logic.h`, `fw_update.h/.cpp` | Firmware update from the SD card: which file is accepted and the two passes over it, check then write (pure logic, host-tested), and the card, flash and trial minute | +| `frame_plan.h` | When to draw the coming second and when to send it | +| `ST7305_U8g2.*` | Display driver, derived from `10_U8G2_Test` with an inversion option | +| `app_state.*`, `app_model.h` | Data shared between the two cores | +| `calc.h`, `timeutil.h`, `buttons.h`, `util.h` | Pure helpers, covered by the host tests | +| `tz_table.cpp` | Generated by `tools/gen_tz_table.py` | +| `docs/` | The pictures, `ESP32-S3-RLCD-Config.example.txt`, and `power-log.csv`, the sheet for the readings of battery runs | +| `TESTING.md` | The checklist of what still has to be seen on the board, and what has been | + +## Developer tools + +All optional, and run on a PC (Linux or WSL with `g++`; Python 3 with Pillow for the PNGs). None of it is needed to +build the sketch. + +* **`bash tools/ui_preview/run_preview.sh`** compiles `ui.cpp` and the U8g2 C sources for the PC and renders the + screens (normal, low battery, the battery states, no data yet, long and non-Latin titles, key feedback, Fahrenheit, + 12 hour clock, WiFi off, wide numbers, the legend, the info pages, the shutdown screen ...) to `out/*.pgm`; + `python to_png.py out/*.pgm` makes PNGs (`--crop`, `--stack` and `--scale` cut out, compare and magnify details). That is + how the layout was designed without the board, and how the pictures above were made. The run also checks the layout on + the rendered pixels: numbers that change must not move (thousands of frames, 24 h and 12 h), label text must sit centred + on its plate, the battery gauge must not shift with the number of digits or run into the status message, today's big + temperature must start where the condition text does (168 combinations), nothing may touch the weather separators + (including with -30 °C and 110 °F), every date format must fit, no page may run off the screen, the moon glyph must + fill and mirror correctly, the legend's lines for KEY must stop short of the BOOT column and the longest banner must + fit its band; each check is shown to catch the old, faulty layout before it is trusted. + `bash tools/ui_preview/run_fuzz.sh` renders thousands of adversarial frames under AddressSanitizer. +* **`bash tools/tests/run_tests.sh`** runs the host tests: calendar and ISO week maths, every time zone rule in the table, + the SHTC3 CRC, battery curve and humidity maths, the Open-Meteo and Spotify parsers against fixtures (including a + recorded replies for Perth), PKCE encoding against the RFC 7636 test vector, the setup page, the button gesture detector, + the charging detector on simulated voltage traces and the frame scheduler on a simulated UI loop (`test_logic`); and + what came with 1.3 and after (`test_features`): the settings file parser (what people type, bad values, reset, UTF-16, 3000 fuzzed + files), the example file (it parses back to the same settings, hides passwords, and matches `docs/`), the moon against + 28 published phases, the date formats, the runtime estimate, the shutdown guard and the clock drift measurement on + simulated data; and the power log: its heading against `docs/power-log.csv`, the column of every setting through + the settings parser, the two files on a made-up file system (the cap, another firmware's columns, writes that fail + or stop half way) and again in a real folder through the calls the clock makes, and the rows of the *Power and + settings* page. `test_radio` covers the power logic: the radio sessions of `wifi_mode = sync` driven by hand and by a + model of the network task over simulated days (a good network, one that is away, one that refuses, no internet, music + with and without `spotify_live`), the same rules weeks into the run (where 32 bits of milliseconds wrap), the CPU + clock and console policies, and the frame planner across clock changes. A fourth program (`test_builddefaults`) is built four ways, with `override_*.h` standing in for a + `secrets.h`: nothing set (the factory build must equal the settings' own defaults), everything set (the result must + equal the SD file `all_settings.h`, setting by setting, so a setting without a build-time default is named), mistakes + (refused and reported) and values on the limits; it also checks that each macro is documented here and in + `secrets.example.h`, and that the version in `config.h` is the one this README gives. While they were written, the tests were checked by breaking the code in 50 ways and watching + them fail (and the build defaults in 70 more, the power logic in 80, the button and settings code that came with it + in 23, the banner about a card's settings in 17, the battery rules of 1.5 in 19, the power log in 113). Set + `ARDUINOJSON_SRC` to ArduinoJson's `src` folder if it is not in `~/Arduino/libraries`. +* **`tools/tests/battery_sim.cpp`** simulates whole battery discharges to choose the constants of `battery_est.h` (see + the file header: `g++ -std=c++17 -O2 -I../.. battery_sim.cpp`). +* **`bash tools/tests/run_charge_stats.sh`** measures how well `charge.h` does (including the light loads and the plateau + that wanders or relaxes): detection times and false-event rates + over 300 random voltage traces per scenario, which is where the table in [Battery](#battery) comes from. Give it a + scenario number and a sequence number to trace one case (`bash run_charge_stats.sh 300 31 7`). +* **`python tools/tests/test_spotify_link.py`** tests the Spotify helper script end to end against a fake clock. +* **`tools/spotify_link.py`** is the helper script; `python tools/gen_helper_header.py` regenerates + `spotify_helper_script.h` from it (`--check` verifies they match; the tests do too). +* **`tools/gen_tz_table.py`** regenerates `tz_table.cpp` from the tz database. + +## Versions + +`APP_VERSION` in `config.h`. It is not semver: the number goes up by hand with each batch of changes that is handed +over to run on a board. The *System info* page shows it together with a build id, which tells two builds of one version +apart (*Which build is running?* under [Updating the firmware from the SD card](#updating-the-firmware-from-the-sd-card)). + +**1.10** (21 October 2026) + +* **The battery curve is measured, not generic.** The board's owner ran a 3400 mAh cell from a full charge until the + clock shut itself down at 3.30 V (81 hours, `wifi_mode = sync` at 20 MHz), and the curve from voltage to percent is + now that run: half charge is 3.78 V, 20 % is 3.58 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 made so little of the first hours after a charge that + the runtime estimate said 8 to 16 days for a cell that lasts 3.4. Replayed through the estimator, from the third hour + off the charger on, 72 of 73 hourly figures were within 15 % of the time really left; in the first three hours the + figure is short. The gauge now reads lower from about 4.15 V down, and starts blinking (under 20 %) about 16 hours + before the shutdown instead of about 35. See [Battery](#battery). **Not yet run on the board.** + +**1.9** (8 October 2026) + +* **The outline of the analogue clock is whole.** It was drawn as two circles, one inside the other, and here and + there the two were rounded differently, which left single pixels between them empty. It is one band two pixels + wide now, with every pixel of it filled in. + +**1.8** (8 October 2026) + +* Something for those who hold on. + +**1.7** (8 October 2026) + +* **The clock keeps its own power log** (`power_log = on`; off by default): a line of CSV in its flash ten seconds + after every start and every ten minutes, with the columns of [`docs/power-log.csv`](docs/power-log.csv), so that a + battery run no longer has to be written down by hand. A start with an SD card in copies the log to the card as + `ESP32-S3-RLCD-PowerLog.csv`, `powerlog` in the serial console prints it, and the *Power and settings* page has a + *Log* row. The newest 0.5 to 1 MB are kept; a line costs a fraction of a second of flash writing. See + [The clock's own log](#the-clocks-own-log). **Not yet tried on the board**: it is the first use of the flash's FAT + partition, which is formatted the first time the log is switched on. Nothing changes for a clock that leaves it + off. + +**1.6** (8 October 2026) + +* **For comparing settings by their drain:** the *Left* line gives the rate with two decimals (`0.31 %/h`; with the + radio mostly off the whole figure is 0.2 to 0.7, and one decimal could not tell two settings apart), it keeps + giving the rate when the time left is more than a month (`>30 d (0.11 %/h over 480 min)`), and the *Current* line + has a decimal. [`docs/power-log.csv`](docs/power-log.csv) is a sheet for the readings, see + [Logging a battery run](#logging-a-battery-run). +* **A run that starts straight off the charger** (found on the board: 4.223 V on the charger, 4.210 V a minute after + it). The gauge stops at 100 % from 4.20 V up, and the estimate used the same figure: while the cell came down from + 4.22 V to 4.20 V, which takes hours under a light load, it saw a level that stood still and reported no drain, and + the hours after were averaged with that. The estimate now follows the voltage above 4.20 V too. A run that was + made with 1.5 and started above 4.20 V reads too low for its first hours; below 4.20 V the two versions agree. + +**1.5** (7 October 2026) + +* **The banner about the card's settings file tells the truth after a firmware update.** With settings and a firmware + on one card, the start that installs the firmware applies the settings first and restarts before its banner; the + next start reads the same file, finds nothing left to change, and in 1.4 said *SD settings: nothing new* of settings + that had just been added (found on the board). The number of settings changed is now kept across that restart and + the banner says *SD settings: 4 changed*. A file whose settings all apply already says *4 in force, none new*, and + a file that is all comments *the file sets nothing*. +* **The runtime estimate under a light load, and *full* without a charger** (found on the board: restarted on its + battery at 99 %, the clock said *full* and gave no estimate). Flat at the top of the curve is called *full* only + after a charge that the clock saw, or after three hours without a fall; and the estimate runs whenever no charger + shows, instead of waiting for a fall to be seen, which a clock that draws a few milliamps never showed. After a + start on the battery the first figure comes 50 minutes later, at any charge level. See [Battery](#battery). + +**1.4** (7 October 2026) + +* **Saving power:** `wifi_mode = sync` (the radio is on only for the syncs, with a rule of its own for a network that is + not there), `cpu_idle_mhz`, `spotify_live`, `console`, and the unused audio chips powered down, see + [Saving power](#saving-power). +* **Battery:** the voltage can be calibrated (`battery_calibration`, `batcal`), the charging bolt sits inside the gauge, + and a full battery that is unplugged no longer reads as *full* for hours. +* **Settings:** everything the card can set also has a build-time default for `secrets.h`, see + [Building the settings in](#building-the-settings-in). +* **SD card:** hold KEY for five seconds to restart the clock, which reads the card; a card that will not mount says why + (a GUID partition table, exFAT, ...). +* **Fixed:** the firmware update from the card, which in 1.3 stopped on every file with *Cannot read the file (card + error)*, so **going from 1.3 to 1.4 takes one upload over USB**; Spotify's timers after 25 days without a restart. +* The build id on the *System info* page. + +**1.3** (5 October 2026), the version the sketch was first published as: settings from an SD card, the firmware update +from the card, a backup network, the chance of rain and the moon in the weather band, the legend page, the runtime +estimate, the low-battery shutdown, the clock drift measurement, the date formats. + +**1.0 to 1.2** were never published and are not in this repository's history: the clock, the weather, the indoor sensor +and the battery gauge first, then Spotify, the charging indicator and the frame timing. + +## Credits and licences + +* Weather data by [Open-Meteo.com](https://open-meteo.com/) (CC BY 4.0, free for non-commercial use). +* Display driver derived from Waveshare's `10_U8G2_Test`; graphics by [U8g2](https://github.com/olikraus/u8g2) (BSD-2-Clause). +* [ArduinoJson](https://arduinojson.org/) (MIT). Time zone rules from the IANA tz database (public domain). +* The moon uses the main terms of the series in Jean Meeus, *Astronomical Algorithms*, ch. 47, and the Astronomical + Almanac's low-precision Sun. +* Spotify is a trademark of Spotify AB; this project is not affiliated with or endorsed by Spotify. +* Spotify rules referenced above: + [quota modes](https://developer.spotify.com/documentation/web-api/concepts/quota-modes), + [redirect URIs](https://developer.spotify.com/documentation/web-api/concepts/redirect_uri), + [refresh token expiry](https://developer.spotify.com/blog/2026-06-18-refresh-token-expiration), + [PKCE flow](https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow). + +Licensed under the Apache License 2.0 like the rest of this repository. diff --git a/02_Example/Arduino/11_Clock_Dashboard/ST7305_U8g2.cpp b/02_Example/Arduino/11_Clock_Dashboard/ST7305_U8g2.cpp new file mode 100644 index 00000000..86b78e97 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/ST7305_U8g2.cpp @@ -0,0 +1,296 @@ +#include "ST7305_U8g2.h" + +#define SPI_CLK 24000000 +#define ST7305_TILE_WIDTH 38 +#define ST7305_TILE_HEIGHT 50 +#define ST7305_BUFFER_ROW_BYTES (ST7305_TILE_WIDTH * 8) + +static ST7305_U8g2 *g_lcd_instance = nullptr; + +static u8x8_display_info_t st7305_display_info = { + /* chip_enable_level = */ 0, + /* chip_disable_level = */ 1, + /* post_chip_enable_wait_ns = */ 0, + /* pre_chip_disable_wait_ns = */ 0, + /* reset_pulse_width_ms = */ 20, + /* post_reset_wait_ms = */ 50, + /* sda_setup_time_ns = */ 0, + /* sck_pulse_width_ns = */ 0, + /* sck_clock_hz = */ SPI_CLK, + /* spi_mode = */ 0, + /* i2c_bus_clock_100kHz = */ 4, + /* data_setup_time_ns = */ 0, + /* write_pulse_width_ns = */ 0, + /* tile_width = */ ST7305_TILE_WIDTH, + /* tile_height = */ ST7305_TILE_HEIGHT, + /* default_x_offset = */ 0, + /* flip_mode_x_offset = */ 0, + /* pixel_width = */ 300, + /* pixel_height = */ 400 +}; + +ST7305_U8g2::ST7305_U8g2(int sck, int mosi, int dc, int cs, int rst) + : _sck(sck), _mosi(mosi), _dc(dc), _cs(cs), _rst(rst) +{ + _spi = new SPIClass(HSPI); + g_lcd_instance = this; +} + +ST7305_U8g2::~ST7305_U8g2() +{ + busRelease(); // end() takes the bus lock that the long transaction holds + if (_spi) { + _spi->end(); + delete _spi; + } + if (_my_buf) { + free(_my_buf); + } + if (g_lcd_instance == this) { + g_lcd_instance = nullptr; + } +} + +void ST7305_U8g2::_cmd(uint8_t cmd) +{ + digitalWrite(_dc, LOW); + digitalWrite(_cs, LOW); + _spi->transfer(cmd); + digitalWrite(_cs, HIGH); +} + +void ST7305_U8g2::_data(const uint8_t *data, size_t len) +{ + digitalWrite(_dc, HIGH); + digitalWrite(_cs, LOW); + _spi->transferBytes((uint8_t *)data, nullptr, len); + digitalWrite(_cs, HIGH); +} + +void ST7305_U8g2::_cmd_data(uint8_t cmd, const uint8_t *data, size_t len) +{ + digitalWrite(_dc, LOW); + digitalWrite(_cs, LOW); + _spi->transfer(cmd); + if (len > 0) { + digitalWrite(_dc, HIGH); + _spi->transferBytes((uint8_t *)data, nullptr, len); + } + digitalWrite(_cs, HIGH); +} + +void ST7305_U8g2::reset() +{ + digitalWrite(_rst, HIGH); + delay(50); + digitalWrite(_rst, LOW); + delay(20); + digitalWrite(_rst, HIGH); + delay(50); +} + +uint8_t ST7305_U8g2::u8x8_byte_custom(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) +{ + (void)u8x8; + (void)arg_int; + (void)arg_ptr; + + switch (msg) { + case U8X8_MSG_BYTE_INIT: + case U8X8_MSG_BYTE_START_TRANSFER: + case U8X8_MSG_BYTE_END_TRANSFER: + case U8X8_MSG_BYTE_SET_DC: + case U8X8_MSG_BYTE_SEND: + default: + return 1; + } +} + +uint8_t ST7305_U8g2::u8x8_d_st7305_custom(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) +{ + (void)arg_int; + + if (!g_lcd_instance) { + return 0; + } + + switch (msg) { + case U8X8_MSG_DISPLAY_SETUP_MEMORY: + u8x8_d_helper_display_setup_memory(u8x8, &st7305_display_info); + break; + + case U8X8_MSG_DISPLAY_INIT: + g_lcd_instance->fullInit(); + break; + + case U8X8_MSG_DISPLAY_DRAW_TILE: { + u8x8_tile_t *tile = (u8x8_tile_t *)arg_ptr; + uint8_t cnt = tile->cnt; + uint8_t y_pos = tile->y_pos; + uint8_t x_pos = tile->x_pos; + + int first_col = x_pos * 8; + int last_col = (x_pos + cnt) * 8 - 1; + if (last_col >= 300) { + last_col = 299; + } + + int addr_start = 0x12 + first_col / 12; + int addr_end = 0x12 + last_col / 12; + int send_start = (addr_start - 0x12) * 3; + int send_cnt = (addr_end - addr_start + 1) * 3; + + int addr_first_col = (addr_start - 0x12) * 12; + int addr_last_col = (addr_end - 0x12) * 12 + 11; + if (addr_last_col >= 300) { + addr_last_col = 299; + } + + uint8_t *row_base = tile->tile_ptr - ((uint16_t)x_pos * 8U); + + uint8_t col_bounds[] = {(uint8_t)(0x3C - addr_end), (uint8_t)(0x3C - addr_start)}; + g_lcd_instance->_cmd_data(0x2A, col_bounds, sizeof(col_bounds)); + + uint8_t row_bounds[] = {(uint8_t)(y_pos * 4), (uint8_t)(y_pos * 4 + 3)}; + g_lcd_instance->_cmd_data(0x2B, row_bounds, sizeof(row_bounds)); + + static const uint8_t st_lut[4][4] = { + {0x00, 0x80, 0x40, 0xC0}, + {0x00, 0x20, 0x10, 0x30}, + {0x00, 0x08, 0x04, 0x0C}, + {0x00, 0x02, 0x01, 0x03}, + }; + + // XOR mask applied to the U8g2 frame buffer: 0xFF flips every pixel. + const uint8_t inv = g_lcd_instance->_invert ? 0xFF : 0x00; + + uint8_t all_rows[300] = {0}; + for (int sr = 0; sr < 4; sr++) { + int shift = sr * 2; + int base_off = sr * send_cnt; + int idx = base_off + (addr_first_col >> 2) - send_start; + + for (int col = addr_first_col; col <= addr_last_col; col += 4, idx++) { + all_rows[idx] = st_lut[0][((row_base[col] ^ inv) >> shift) & 3] + | st_lut[1][((row_base[col + 1] ^ inv) >> shift) & 3] + | st_lut[2][((row_base[col + 2] ^ inv) >> shift) & 3] + | st_lut[3][((row_base[col + 3] ^ inv) >> shift) & 3]; + } + } + + g_lcd_instance->_cmd_data(0x2C, all_rows, (size_t)send_cnt * 4U); + break; + } + + default: + return 0; + } + + return 1; +} + +void ST7305_U8g2::busRelease() +{ + if (_spi && _busHeld) { + _spi->endTransaction(); + _busHeld = false; + } +} + +void ST7305_U8g2::busAcquire() +{ + if (_spi && !_busHeld) { + _spi->beginTransaction(SPISettings(SPI_CLK, MSBFIRST, SPI_MODE0)); + _busHeld = true; + } +} + +bool ST7305_U8g2::begin(uint8_t tile_buf_height, const u8g2_cb_t *rotation) +{ + pinMode(_dc, OUTPUT); + pinMode(_cs, OUTPUT); + pinMode(_rst, OUTPUT); + digitalWrite(_cs, HIGH); + digitalWrite(_dc, HIGH); + digitalWrite(_rst, HIGH); + + _spi->begin(_sck, -1, _mosi, -1); + busAcquire(); + + u8g2_t *u = u8g2_wrapper.getU8g2(); + u8x8_Setup(u8g2_GetU8x8(u), u8x8_d_st7305_custom, u8x8_dummy_cb, u8x8_byte_custom, u8x8_dummy_cb); + + uint8_t tbh = tile_buf_height == 0 ? ST7305_TILE_HEIGHT : tile_buf_height; + if (tbh > ST7305_TILE_HEIGHT) { + tbh = ST7305_TILE_HEIGHT; + } + + size_t buf_sz = ST7305_BUFFER_ROW_BYTES * tbh; + _my_buf = (uint8_t *)malloc(buf_sz); + if (!_my_buf) { + return false; + } + memset(_my_buf, 0, buf_sz); + + u8g2_SetupBuffer(u, _my_buf, tbh, u8g2_ll_hvline_vertical_top_lsb, rotation); + u8g2_InitDisplay(u); + u8g2_SetPowerSave(u, 0); + return true; +} + +void ST7305_U8g2::fullInit() +{ + reset(); + + const uint8_t d6[] = {0x17, 0x02}; + const uint8_t d1[] = {0x01}; + const uint8_t c0[] = {0x11, 0x04}; + const uint8_t c1[] = {0x69, 0x69, 0x69, 0x69}; + const uint8_t c2[] = {0x19, 0x19, 0x19, 0x19}; + const uint8_t c4[] = {0x4B, 0x4B, 0x4B, 0x4B}; + const uint8_t d8[] = {0x80, 0xE9}; + const uint8_t b2[] = {0x02}; + const uint8_t b3[] = {0xE5, 0xF6, 0x05, 0x46, 0x77, 0x77, 0x77, 0x77, 0x76, 0x45}; + const uint8_t b4[] = {0x05, 0x46, 0x77, 0x77, 0x77, 0x77, 0x76, 0x45}; + const uint8_t g_timing[] = {0x32, 0x03, 0x1F}; + const uint8_t b7[] = {0x13}; + const uint8_t b0[] = {0x64}; + const uint8_t c9[] = {0x00}; + const uint8_t m36[] = {0x48}; + const uint8_t m3a[] = {0x11}; + const uint8_t b9[] = {0x20}; + const uint8_t b8[] = {0x29}; + const uint8_t win_a[] = {0x12, 0x2A}; + const uint8_t win_b[] = {0x00, 0xC7}; + const uint8_t m35[] = {0x00}; + const uint8_t d0[] = {0xFF}; + + _cmd_data(0xD6, d6, sizeof(d6)); + _cmd_data(0xD1, d1, sizeof(d1)); + _cmd_data(0xC0, c0, sizeof(c0)); + _cmd_data(0xC1, c1, sizeof(c1)); + _cmd_data(0xC2, c2, sizeof(c2)); + _cmd_data(0xC4, c4, sizeof(c4)); + _cmd_data(0xC5, c2, sizeof(c2)); + _cmd_data(0xD8, d8, sizeof(d8)); + _cmd_data(0xB2, b2, sizeof(b2)); + _cmd_data(0xB3, b3, sizeof(b3)); + _cmd_data(0xB4, b4, sizeof(b4)); + _cmd_data(0x62, g_timing, sizeof(g_timing)); + _cmd_data(0xB7, b7, sizeof(b7)); + _cmd_data(0xB0, b0, sizeof(b0)); + _cmd(0x11); + delay(120); + _cmd_data(0xC9, c9, sizeof(c9)); + _cmd_data(0x36, m36, sizeof(m36)); + _cmd_data(0x3A, m3a, sizeof(m3a)); + _cmd_data(0xB9, b9, sizeof(b9)); + _cmd_data(0xB8, b8, sizeof(b8)); + _cmd(0x21); + _cmd_data(0x2A, win_a, sizeof(win_a)); + _cmd_data(0x2B, win_b, sizeof(win_b)); + _cmd_data(0x35, m35, sizeof(m35)); + _cmd_data(0xD0, d0, sizeof(d0)); + _cmd(0x38); + _cmd(0x29); +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/ST7305_U8g2.h b/02_Example/Arduino/11_Clock_Dashboard/ST7305_U8g2.h new file mode 100644 index 00000000..cc063f76 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/ST7305_U8g2.h @@ -0,0 +1,57 @@ +#pragma once + +// ST7305 300x400 reflective LCD driver for U8g2 (full-buffer mode). +// +// Derived from Waveshare's 10_U8G2_Test example. Changes: the pixel polarity +// can be inverted (so U8g2 "colour 1" can mean black ink), begin() reports +// allocation failure, and the SPI clock / pin handling is unchanged. + +#include +#include +#include + +class ST7305_U8g2 { +private: + int _sck; + int _mosi; + int _dc; + int _cs; + int _rst; + SPIClass *_spi = nullptr; + U8G2 u8g2_wrapper; + uint8_t *_my_buf = nullptr; + volatile bool _invert = false; + bool _busHeld = false; // begin() starts one long SPI transaction, see busRelease() + + void _cmd(uint8_t cmd); + void _data(const uint8_t *data, size_t len); + void _cmd_data(uint8_t cmd, const uint8_t *data, size_t len); + + static uint8_t u8x8_d_st7305_custom(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr); + static uint8_t u8x8_byte_custom(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr); + +public: + ST7305_U8g2(int sck = 11, int mosi = 12, int dc = 5, int cs = 40, int rst = 41); + ~ST7305_U8g2(); + + // tile_buf_height 0 means full-buffer mode (about 15 KB of RAM for 300x400). + // Returns false if the frame buffer could not be allocated. + bool begin(uint8_t tile_buf_height = 0, const u8g2_cb_t *rotation = U8G2_R0); + void reset(); + void fullInit(); + + // Flip the panel polarity: with invert = true a set U8g2 pixel is sent as 0. + // Takes effect on the next sendBuffer(). + void setInvert(bool invert) { _invert = invert; } + bool invert() const { return _invert; } + + // begin() opens one SPI transaction and keeps it open, which holds the SPI peripheral's lock + // for good and keeps every transfer cheap. The lock is not recursive, so whatever else needs + // it from the same task has to let go first: changing the CPU clock (the core tells every SPI + // bus about it and waits for the lock), or re-routing the SPI pins. Pairs of + // busRelease(); ...; busAcquire(); + void busRelease(); + void busAcquire(); + + U8G2 *getU8g2() { return &u8g2_wrapper; } +}; diff --git a/02_Example/Arduino/11_Clock_Dashboard/TESTING.md b/02_Example/Arduino/11_Clock_Dashboard/TESTING.md new file mode 100644 index 00000000..549b2507 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/TESTING.md @@ -0,0 +1,96 @@ +# Testing on the board + +What has been written and tested on a PC but still has to be seen on the real board, as a checklist. The README's +[Not yet tried on the board](README.md#not-yet-tried-on-the-board) says, item by item, how each one is tested on the PC. + +Each item has a status: **seen** (it works on the board), **partly** (some of it seen), **not yet**, or **problem** +(seen, and wrong). "The log" means the clock's own power log (`power_log = on`) of 8 to 17 October 2026: firmware 1.7 +to 1.9 with `wifi_mode = sync`, `wifi_power_save = max`, `cpu_idle_mhz = 20`, `spotify_live = off`, `console = auto`. + +Seen before this list was made: a card with a GUID partition table refused and a FAT32 card mounted; settings from the +card applied and kept in flash; firmware installed from the card; the processor at 20 MHz with the pages still drawn; +the analogue clock's outline (1.9) and both cubes with their buttons (1.8, 1.9). + +## SD card + +1. **Not yet.** The example settings file is written to a card that has none. +2. **Not yet.** Start-up with no card in the slot: any noticeable delay. + +## Firmware update from the card + +3. **Partly.** The trial minute after an install: the countdown and the confirmation. In the log, 1.8 and 1.9 each + came up about two minutes after the restart that installed them, and stayed through later restarts, so the build was + confirmed. The countdown itself has not been reported. +4. **Not yet.** The installed file is renamed on the card, so it is not installed twice. +5. **Not yet.** A rollback (only if a build ever fails its trial minute). +6. **Not yet.** The banner after an update says what the settings file did (for example *SD settings: 4 in force*). +7. **Seen.** The build id: the log's `firmware` column carries it (`1.9 5067f83`, the id of the build that was handed + over), so the call that reads it works. + +## Battery and charging + +8. **Partly.** No *full* at 99 % on battery, and an estimate with %/h about 50 minutes after a start on battery: the + estimate came at 50 minutes in every run. But on 15 October the clock said *full* for about six hours while it was on + battery (the cell stood at 4.20 V after a charge), and showed no estimate meanwhile. +9. **Seen.** The charging and full indicators: *charging* within ten minutes of plugging in, *full* one to one and + three-quarter hours later, *discharging* within ten minutes of unplugging (9 and 14 October). +10. **Seen.** The reading on the charger: 4.282 to 4.286 V for two days (9 to 11 October), against 4.19 to 4.22 V for + a full cell off the charger. Above about 4.25 V the clock is on the charger (with this board's calibration). +11. **Partly.** How close the estimate comes. Just after a charge it said 8 to 16 days; 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 (4.20 to 4.15 V took 12.8 + hours, the next 50 mV took 5.8), and the battery curve counts that stretch as 5 %, so the estimate takes the slow + start for the rate of the whole cell. 1.10 replaces the curve with the run in 12; replayed through the estimator + on a PC, from the third hour off the charger on, 72 of 73 hourly figures were within 15 % of the time really + left, and in the first three hours the figure is short (never long). Still to be seen on the board. +12. **Partly.** A run down to the shutdown, 17 to 20 October (firmware 1.9, the settings above): 81 hours from a full + charge to 3.30 V, a line every ten minutes to the last (3.307 V). It gave the battery curve of 1.10. Not reported + yet: the low-battery blink, the battery-empty screen, and the shutdown itself (the wake by timer and by KEY, the + 3-second override). What the board draws while the clock sleeps has not been measured (`sleeptest`). +13. **Partly.** The five battery runs (stock on WiFi, stock without WiFi, a middle ground, 20 MHz on WiFi, 20 MHz + without WiFi). Only the fourth has been logged (the settings above): 81 hours, 3.4 days, from full to the + shutdown. +14. **Seen.** Where the cell really is at 3.7 V: in the run in 12 it still had 39 % at 3.70 V, where the old curve + said 12 %. The good-quality 3400 mAh cell lasted 3.4 days, not the five that the USB reading in 24 suggested: on + the battery the clock drew roughly 40 mA from the cell (3400 mAh in 81 hours), where 105 mW from USB would make + about 28 mA. Why is not known. + +## Power saving + +15. **Seen.** `wifi_mode = sync` over days: the radio on 1.7 to 3.9 % of the time, about five sessions an hour. +16. **Partly.** Late frames: 7 in three and a half days, the worst 307 ms. One frame came **3.7 s** late, on + 9 October between 06:07 and 06:18; the cause is not known. +17. **Partly.** Readings at 20 MHz: the battery voltage is steady and plausible. The indoor sensor is not in the log. +18. **Not yet.** Buttons at 20 MHz: no lost taps or double clicks. +19. **Partly.** Sync mode with the network gone: on 13 October from about 06:40 to 20:10 no session connected, while + the radio still came on about 2.6 % of the time (looking for the network), and sessions came back by themselves. + Was the clock away from its network then, or was the network down? +20. **Not yet.** `spotify_live = off`: the strip around the end of a track, and the *Now Playing* page waking the + radio. +21. **Not yet.** `console = auto` and `off`: a computer noticed on USB, the KEY-held start that keeps the console, the + `set` command. (`auto` has run for days without trouble.) +22. **Not yet.** A `cpu_mhz` change from the settings file: the start does not hang. +23. **Not yet.** The audio chips switched off: no noise, and the difference on a meter. +24. **Partly.** The current, by a USB meter (5 V) with the battery out and the settings above, console off: 21.1 to + 21.4 mA at the 20 MHz idle clock (an inverted screen makes no difference worth counting), about 29 mA at 80 MHz + after a button press, 33 to 36 mA at 80 MHz with the radio on. With the radio on about 2 % of the time, the idle + clock is nearly all of the energy. Not yet: the current during `sleeptest` (the board without the processor). + +## Power log + +25. **Not yet.** The *Log* row on *Power and settings* (size, lines this run, age). +26. **Seen.** The copy to the card as `ESP32-S3-RLCD-PowerLog.csv` at start, with the whole log in it. The file's date + on the card is in UTC (8 hours early in Perth), and 1 January 1980 when the clock did not know the time yet. +27. **Not yet.** `powerlog`, `powerlog clear`, and the write time that `power` reports in the serial console. +28. **Seen.** A line every ten minutes for days, without the seconds stuttering (see 16). +29. **Seen.** The note that says why the clock started: `start (restart)`, `start (power on)`. + +## Everything else + +30. **Not yet.** The restart by KEY (held five seconds, then let go): the banner, the restart, the card read after it. +31. **Not yet.** The backup network: the switch to it when the main one cannot be joined, and back. +32. **Not yet.** The clock drift figure after real NTP syncs. +33. **Not yet.** Problem rows on *Power and settings* for a bad line in the settings file or a refused built-in default. +34. **Not yet.** The Spotify linking flow. +35. **Partly.** The newer screens on the real panel for contrast and legibility: the legend, the firmware update + screen, the weather band with its moon and rain. +36. **Partly.** The cubes: whether the frame rate looks smooth. diff --git a/02_Example/Arduino/11_Clock_Dashboard/app_model.h b/02_Example/Arduino/11_Clock_Dashboard/app_model.h new file mode 100644 index 00000000..861a7093 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/app_model.h @@ -0,0 +1,164 @@ +#pragma once + +// Plain data shared between the network task, the UI loop and the renderer. +// No Arduino headers here on purpose: ui.cpp and the host-side preview tool +// (tools/ui_preview) compile against this file too. + +#include +#include + +// --------------------------------------------------------------------------- +// Weather (Open-Meteo) +// --------------------------------------------------------------------------- +struct WeatherDay { + uint8_t code = 0; // WMO weather interpretation code + float tmax = 0; // degrees C + float tmin = 0; // degrees C + uint8_t rainPct = 0; // max precipitation probability, percent + uint8_t weekday = 0; // 0 = Sunday ... 6 = Saturday +}; + +struct WeatherData { + bool valid = false; + uint32_t ageSec = 0; // filled in when a snapshot is taken for the UI + float temp = 0; // degrees C + float feels = 0; // degrees C + float windKmh = 0; + uint8_t humidity = 0; // percent + uint8_t code = 0; // WMO code + bool isDay = true; + WeatherDay day[3]; // [0] today, [1] tomorrow, [2] the day after + char sunrise[6] = ""; // local "HH:MM" + char sunset[6] = ""; + float uvMax = 0; +}; + +// --------------------------------------------------------------------------- +// Spotify +// --------------------------------------------------------------------------- +enum SpotifyStatus : uint8_t { + SPOTIFY_DISABLED = 0, // no client id configured + SPOTIFY_NEEDS_LINK, // client id set but no refresh token yet + SPOTIFY_IDLE, // linked, nothing playing / no active device + SPOTIFY_PAUSED, + SPOTIFY_PLAYING, + SPOTIFY_ERROR // linked, but the last request failed (see message) +}; + +struct SpotifyInfo { + SpotifyStatus status = SPOTIFY_DISABLED; + char title[96] = ""; + char artist[96] = ""; // artists joined with ", " (or the show name for podcasts) + char album[80] = ""; + char device[40] = ""; + uint32_t durationMs = 0; + uint32_t progressMs = 0; // already extrapolated to "now" when handed to the UI + int8_t volume = -1; // percent, -1 = unknown + bool shuffle = false; + char message[56] = ""; // short hint or error, e.g. "Premium required" + char linkUrl[48] = ""; // where to open the linking page (set while it is being served) + int16_t linkDaysLeft = -1; // days until Spotify expires the link (6 months); -1 = unknown +}; + +// --------------------------------------------------------------------------- +// Everything the renderer needs for one frame +// --------------------------------------------------------------------------- +enum UiPage : uint8_t { + PAGE_DASHBOARD = 0, + PAGE_NOW_PLAYING = 1, + PAGE_INFO = 2, // system: network, time, weather, sensors + PAGE_POWER = 3, // battery, power, clock accuracy and the settings that are in force + PAGE_LEGEND = 4, // what the symbols and the button clicks mean + PAGE_COUNT = 5 +}; + +// What the battery is doing; shown as a small icon beside the gauge (nothing for UNKNOWN). +enum UiCharge : uint8_t { + CHARGE_UNKNOWN = 0, + CHARGE_DISCHARGING, // running on the battery + CHARGE_CHARGING, + CHARGE_FULL // charged and held there +}; + +#define UI_INFO_LINES 14 // lines on an info page (PAGE_INFO or PAGE_POWER) +#define UI_INFO_LINE_LEN 54 + +// The banner when KEY has been held long enough for a restart (it happens when the button is let go), and +// the one that follows. Here because the layout check in tools/ui_preview measures the first one: it is +// the longest banner the clock shows. +#define UI_TOAST_RESTART_HOLD "Let go of KEY to restart (reads SD)" +#define UI_TOAST_RESTARTING "Restarting..." + +// The screen shown at start-up while the firmware is updated from the SD card (uiDrawFirmwareUpdate). +enum UiFwKind : uint8_t { + FW_BUSY = 0, // checking or writing + FW_DONE, // installed, restarting + FW_PROBLEM // not installed; the clock carries on with the firmware it has +}; +struct UiFwScreen { + UiFwKind kind = FW_BUSY; + char what[64] = ""; // the line in larger type: what is happening, or what went wrong + char detail[64] = ""; // the file, or a hint + int percent = -1; // 0..100 draws a progress bar; -1 none + bool keepPowered = false; // "do not switch off or take the card out" +}; + +struct UiModel { + // time ------------------------------------------------------------------- + bool timeValid = false; + struct tm local = {}; // local time to display + float secFrac = 0; // 0..1 into the current second (sweeping second hand) + int utcOffsetMin = 0; + char tzAbbrev[8] = ""; + + // indoor sensor ---------------------------------------------------------- + bool indoorValid = false; + float indoorC = 0; + float indoorRh = 0; + + // battery ---------------------------------------------------------------- + bool batPresent = false; + float batVolts = 0; + int batPercent = 0; + bool batLow = false; // below the warning threshold (and not charging) + bool batBlinkOn = false; // current blink phase (only meaningful when batLow) + int batCharge = CHARGE_UNKNOWN; // UiCharge + + // network ---------------------------------------------------------------- + bool wifiUp = false; + bool wifiOff = false; // switched off in the settings: no weather, no network time, no Spotify + int rssi = -100; + char location[40] = ""; // place shown in the status bar ("Earth" when nothing is known) + char status[56] = ""; // transient progress / error message ("" = none) + + // data ------------------------------------------------------------------- + WeatherData weather; + SpotifyInfo spotify; + + // the moon at this time today, tomorrow and the day after (moon.h); the picture is mirrored + // in the southern hemisphere + bool moonValid = false; + uint8_t moonLit[3] = {0, 0, 0}; // percent of the disc that is lit + bool moonWaxing[3] = {true, true, true}; + bool southern = false; + + // presentation ----------------------------------------------------------- + UiPage page = PAGE_DASHBOARD; + bool useFahrenheit = false; + bool time12h = false; // 12 hour clock with AM / PM + uint8_t dateFormat = 0; // DateFormat (settings.h) + bool showWeek = true; // "Sunday - W40 - day 277" or just "Sunday" + char toast[40] = ""; // short feedback overlay ("" = none) + int toastIcon = 0; // UiToastIcon + int infoCount = 0; + char info[UI_INFO_LINES][UI_INFO_LINE_LEN] = {}; +}; + +enum UiToastIcon { + TOAST_NONE = 0, + TOAST_PLAY, + TOAST_PAUSE, + TOAST_NEXT, + TOAST_PREV, + TOAST_WARN +}; diff --git a/02_Example/Arduino/11_Clock_Dashboard/app_power_log.cpp b/02_Example/Arduino/11_Clock_Dashboard/app_power_log.cpp new file mode 100644 index 00000000..744a4e14 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/app_power_log.cpp @@ -0,0 +1,277 @@ +#include "app_power_log.h" + +#include +#include +#include +#include +#include +#include + +#include "app_settings.h" +#include "config.h" +#include "log.h" +#include "sdcard.h" + +namespace { + +using powerlog::Status; + +const char *const TAG = "plog"; +const char *const kMountPoint = "/ffat"; +const char *const kPartition = "ffat"; // the FAT partition of the Arduino core's partition schemes +const char *const kNamespace = "plog"; // flash: "used" = the clock has kept a log, the partition is its own +const size_t kChunk = 4096; // what is read and written at a time when the whole log is copied + +wl_handle_t s_wl = WL_INVALID_HANDLE; // the mounted partition +bool s_mountTried = false; // ... which is tried once a run +Status::Problem s_problem = Status::FINE; +powerlog::Schedule s_schedule; +uint32_t s_logBytes = 0; // what the two files hold +uint32_t s_lastMs = 0, s_worstMs = 0; // how long the writes took +Status::Copy s_copy = Status::COPY_NONE; +uint32_t s_copyBytes = 0; + +bool everUsed() { + Preferences p; + if (!p.begin(kNamespace, true)) return false; // nothing has ever been saved + const bool used = p.getBool("used", false); + p.end(); + return used; +} + +void markUsed() { + if (everUsed()) return; + Preferences p; + if (!p.begin(kNamespace, false)) return; + p.putBool("used", true); + p.end(); +} + +// Mounts the partition (once a run: one that will not mount now will not in ten minutes either). `format`: +// make a file system if there is none, which is what the first use of the log does. +bool mountFlash(bool format) { + if (s_wl != WL_INVALID_HANDLE) return true; + if (s_mountTried) return false; + s_mountTried = true; + if (!esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_FAT, kPartition)) { + s_problem = Status::NO_PARTITION; + LOGF(TAG, "the flash has no FAT partition called \"%s\": the power log needs the partition scheme of the README", kPartition); + return false; + } + esp_vfs_fat_mount_config_t cfg = {}; + cfg.format_if_mount_failed = format; + cfg.max_files = 1; // one file is open at a time, and every open file costs a sector of memory + cfg.allocation_unit_size = CONFIG_WL_SECTOR_SIZE; + wl_handle_t wl = WL_INVALID_HANDLE; + const uint32_t t0 = millis(); + const esp_err_t err = esp_vfs_fat_spiflash_mount_rw_wl(kMountPoint, kPartition, &cfg, &wl); + if (err != ESP_OK) { + // (when the file system will not mount, the IDF takes its own registrations back but leaves the wear + // levelling it started running, and the drive number it gave it on the books) + if (wl != WL_INVALID_HANDLE) { + ff_diskio_clear_pdrv_wl(wl); + wl_unmount(wl); + } + s_problem = Status::CANNOT_MOUNT; + LOGF(TAG, "the flash partition for the power log will not mount: 0x%x%s", (unsigned)err, format ? "" : " (not formatted: the log is off)"); + return false; + } + s_wl = wl; + s_problem = Status::FINE; + if (format) markUsed(); + LOGF(TAG, "flash partition mounted in %u ms", (unsigned)(millis() - t0)); + return true; +} + +void unmountFlash() { + if (s_wl == WL_INVALID_HANDLE) return; + esp_vfs_fat_spiflash_unmount_rw_wl(kMountPoint, s_wl); + s_wl = WL_INVALID_HANDLE; + s_mountTried = false; +} + +// The heading of this firmware's lines. +const char *header() { + static char text[powerlog::kMaxLine] = ""; + if (!text[0]) powerlog::formatHeader(text, sizeof text); + return text; +} + +// Why the clock started, for the note on the line of a start. +const char *startNote() { + switch (esp_reset_reason()) { + case ESP_RST_POWERON: return "start (power on)"; + case ESP_RST_SW: return "start (restart)"; + case ESP_RST_DEEPSLEEP: return "start (after sleep)"; // the low-battery shutdown, over + case ESP_RST_BROWNOUT: return "start (brownout)"; + case ESP_RST_PANIC: return "start (after a crash)"; + case ESP_RST_INT_WDT: + case ESP_RST_TASK_WDT: + case ESP_RST_WDT: return "start (watchdog)"; + case ESP_RST_USB: return "start (reset over USB)"; + default: return "start"; + } +} + +// The copy to the card, as exportLog() sees it. +struct CardSink { + int fd; + bool failed = false; + bool write(const char *data, uint32_t n) { + if (!sdWrite(fd, data, n)) failed = true; + return !failed; + } +}; + +// The serial port. It takes what fits in its buffer and no more, so what is left is offered again; if nothing +// has gone out for two seconds nobody is reading, and the rest is dropped. +struct PrintSink { + Print &out; + bool write(const char *data, uint32_t n) { + uint32_t movedMs = millis(); + while (n > 0) { + const uint32_t took = (uint32_t)out.write((const uint8_t *)data, n); + if (took > 0) { + data += took; + n -= took > n ? n : took; + movedMs = millis(); + } else if (millis() - movedMs >= 2000) { + return false; + } else { + delay(5); + } + } + return true; + } +}; + +const char *problemText() { + return s_problem == Status::NO_PARTITION ? "the flash has no \"ffat\" partition (use the partition scheme of the README)" + : "the flash partition will not mount"; +} + +} // namespace + +void powerLogBegin() { + if (!g_cfg.powerLog) return; + if (!mountFlash(true)) { + s_schedule.stop(); // nothing to write to: say so on the Power and settings page and leave it at that + return; + } + powerlog::FolderFs fs(kMountPoint); + s_logBytes = powerlog::logBytes(fs); + LOGF(TAG, "power log on: %u KB so far, a line now and every %u min (type \"powerlog\" to read it)", (unsigned)((s_logBytes + 1023) / 1024), + (unsigned)(powerlog::kIntervalSec / 60)); +} + +void powerLogCopyToCard(bool wasOn) { + if (!g_cfg.powerLog && !wasOn) return; // off, and not just switched off: the card is not written to + const bool hadLog = everUsed(); + if (!g_cfg.powerLog && !hadLog) return; // this clock has never kept a log: its flash partition is left alone + if (!mountFlash(g_cfg.powerLog)) { + if (hadLog) s_copy = Status::COPY_CANNOT_READ; + return; + } + powerlog::FolderFs fs(kMountPoint); + if (powerlog::logBytes(fs) > 0) { // (an empty log is not worth replacing an earlier copy on the card with) + char *buf = (char *)malloc(kChunk); + CardSink sink{buf ? sdCreate(POWER_LOG_FILE_NAME) : -1}; + powerlog::Exported e; + if (sink.fd >= 0) e = powerlog::exportLog(fs, header(), sink, buf, (uint32_t)kChunk); + const bool closed = sdCommit(sink.fd); // (always: it also closes the file) + free(buf); + if (e.ok && closed) { + s_copy = Status::COPY_DONE; + s_copyBytes = e.bytes; + LOGF(TAG, "power log copied to the SD card: %s, %u bytes", POWER_LOG_FILE_NAME, (unsigned)e.bytes); + } else { + // the log could not be read to its end, or (all the rest) the card would not take it: half a file is no + // use to anybody, and the log itself is still in the clock + const bool readError = sink.fd >= 0 && !sink.failed && !e.ok; + s_copy = readError ? Status::COPY_CANNOT_READ : Status::COPY_CARD_ERROR; + if (sink.fd >= 0) sdRemove(POWER_LOG_FILE_NAME); + LOGF(TAG, "power log NOT copied to the SD card: %s", readError ? "the log cannot be read" : "the card cannot be written"); + } + } + if (!g_cfg.powerLog) unmountFlash(); // the log is off: nothing more is written, and the memory is given back +} + +bool powerLogDue(uint32_t nowSec) { return g_cfg.powerLog && s_wl != WL_INVALID_HANDLE && s_schedule.due(nowSec); } + +void powerLogWrite(powerlog::Reading reading, uint32_t nowSec) { + if (s_wl == WL_INVALID_HANDLE) return; + if (s_schedule.startLine()) reading.note = startNote(); + const uint32_t t0 = millis(); + char line[powerlog::kMaxLine]; + powerlog::Appended a; + if (powerlog::formatLine(reading, line, sizeof line) > 0) { + powerlog::FolderFs fs(kMountPoint); + a = powerlog::appendLine(fs, header(), line); + } + const bool ok = a.what != powerlog::Append::FAILED; + s_schedule.done(nowSec, ok); + s_lastMs = millis() - t0; + if (s_lastMs > s_worstMs) s_worstMs = s_lastMs; + if (ok) { + s_logBytes = a.logBytes; + LOGF(TAG, "line %u written in %u ms, %u KB in the log%s", (unsigned)s_schedule.lines(), (unsigned)s_lastMs, (unsigned)((s_logBytes + 1023) / 1024), + a.what == powerlog::Append::ROTATED ? " (the oldest lines were dropped)" : ""); + } else { + LOGF(TAG, "a line could NOT be written (%d in a row)%s", s_schedule.failures(), s_schedule.stopped() ? ": no more are tried until the next start" : ""); + } +} + +powerlog::Status powerLogStatus(uint32_t nowSec) { + Status s; + s.on = g_cfg.powerLog; + s.problem = s_problem; + s.stopped = s_schedule.stopped() && s_problem == Status::FINE; + s.failures = s_schedule.failures(); + s.lines = s_schedule.lines(); + s.logBytes = s_logBytes; + s.sinceLineSec = nowSec - s_schedule.lastSec(); + s.copy = s_copy; + s.copyBytes = s_copyBytes; + return s; +} + +void powerLogTimings(uint32_t *lastMs, uint32_t *worstMs) { + *lastMs = s_lastMs; + *worstMs = s_worstMs; +} + +void powerLogCommand(const char *arg, Print &out) { + while (*arg == ' ') arg++; + const bool clear = !strcmp(arg, "clear"); + if (*arg && !clear) { + out.println("usage: powerlog (prints the log as CSV) powerlog clear (forgets it)"); + return; + } + if (!g_cfg.powerLog && !everUsed()) { + out.println("there is no power log: power_log has never been on (set power_log = on, or put it in the settings file)"); + return; + } + const bool wasMounted = s_wl != WL_INVALID_HANDLE; + if (!mountFlash(g_cfg.powerLog)) { + out.printf("the power log cannot be read: %s\n", problemText()); + return; + } + powerlog::FolderFs fs(kMountPoint); + if (clear) { + const bool gone = powerlog::clearLog(fs); + s_logBytes = powerlog::logBytes(fs); + out.println(gone ? "power log cleared" : "the power log could NOT be deleted"); + } else { + char *buf = (char *)malloc(kChunk); + powerlog::Exported e; + if (buf) { + consoleHold(true); // no log line of another task in the middle of the file + PrintSink sink{out}; + e = powerlog::exportLog(fs, header(), sink, buf, (uint32_t)kChunk); + consoleHold(false); + free(buf); + } + if (!e.ok) out.println("(cut short: the power log could not be read to its end, or nobody is reading this port)"); + } + if (!wasMounted && !g_cfg.powerLog) unmountFlash(); // mounted for this only +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/app_power_log.h b/02_Example/Arduino/11_Clock_Dashboard/app_power_log.h new file mode 100644 index 00000000..0dda18c0 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/app_power_log.h @@ -0,0 +1,45 @@ +#pragma once + +// The power log on the clock: the flash partition its two files live in, the copy to an SD card and the +// serial commands. What a line holds, when one is due and what is done with the files is in power_log.h +// (host-tested); this file is the part that touches the flash, the card and the serial port. +// +// * The files are in the FAT partition of the clock's own flash ("ffat" in the partition table, 9.9 MB in +// the scheme the README asks for), on top of the IDF's wear levelling. +// * The partition is mounted once, at start-up, and stays mounted while the clock runs. Mounting for every +// line would cost more than the line: an unmount makes the wear levelling move a sector each time, and a +// mount reads its position record from the start. Between two lines no file is open and nothing waits to +// be written, so a restart, a shutdown or a power cut at any other moment loses nothing. +// * The partition is only touched when the power_log setting is on, or has been on before (flash, "plog" / +// "used"): a clock that never kept a log leaves it alone. The first time, it is formatted if it holds no +// FAT file system. +// +// Everything here is called from the UI task. + +#include + +#include "power_log.h" + +// At start-up, once the settings are final: mounts the partition when the log is on (and formats it the first +// time, about half a second). +void powerLogBegin(); + +// At start-up, while an SD card is mounted and once its settings are in force: a copy of the log for the card +// (POWER_LOG_FILE_NAME), when the log is on and has lines. `wasOn`: the log was on until this card's settings +// were read, so a card that switches it off still gets what there is, once. +void powerLogCopyToCard(bool wasOn); + +// Is a line due? `nowSec`: seconds since the start. Never true while the log is off or cannot be written. +bool powerLogDue(uint32_t nowSec); + +// Writes one line (the note of a start is added here). Takes a tenth of a second or a few, so call it early +// in a second (powerlog::kLatestStartUs). +void powerLogWrite(powerlog::Reading reading, uint32_t nowSec); + +// For the Power and settings page (powerlog::statusRows), and how long the writes took, for the "power" command. +powerlog::Status powerLogStatus(uint32_t nowSec); +void powerLogTimings(uint32_t *lastMs, uint32_t *worstMs); + +// The serial commands "powerlog" (prints the log as CSV) and "powerlog clear" (forgets it). `arg` is what +// follows the word. +void powerLogCommand(const char *arg, Print &out); diff --git a/02_Example/Arduino/11_Clock_Dashboard/app_settings.cpp b/02_Example/Arduino/11_Clock_Dashboard/app_settings.cpp new file mode 100644 index 00000000..0412db95 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/app_settings.cpp @@ -0,0 +1,260 @@ +#include "app_settings.h" + +#include +#include + +#include "app_power_log.h" +#include "build_defaults.h" +#include "config.h" +#include "fw_update.h" +#include "log.h" +#include "sdcard.h" +#include "util.h" + +Settings g_cfg; +CfgStatus g_cfgStatus; + +namespace { + +const char *const TAG = "cfg"; +const char *const kNamespace = "cfg"; +const size_t kMaxFileBytes = 32 * 1024; // a settings file is a few KB; more than this is not one +const size_t kExampleCap = 12 * 1024; + +BuildReport s_build; // what was wrong with config.h / secrets.h, found when the defaults were first worked out + +// The measured clock drift, kept by the network task in the "clock" namespace, for the note in the +// example file. Returns "" if nothing was measured yet. +void driftNote(char *out, size_t cap) { + out[0] = 0; + Preferences p; + if (!p.begin("clock", true)) return; + const float ppm = p.getFloat("dppm", NAN); + const float hours = p.getFloat("dhours", 0); + p.end(); + if (isnan(ppm) || hours < 3) return; + snprintf(out, cap, "This clock measured itself while it had WiFi: %+.1f ppm, %+.2f s a day, over %.0f hours.\n" + "Without WiFi it would keep that error (and slowly change with temperature).", + (double)ppm, (double)ppm * 0.0864, (double)hours); +} + +// Writes the settings that came from flash or the card, and drops the ones that went back to their +// defaults. True only if every step worked: a full or failing flash must not look like success, as the +// card then comes out and the settings are gone at the next restart. +bool saveToFlash(const Settings &s, bool clearFirst) { + Preferences p; + if (!p.begin(kNamespace, false)) return false; + bool ok = true; + if (clearFirst && !p.clear()) ok = false; + char text[96]; + for (size_t i = 0; i < settingCount(); i++) { + const char *key = settingNvsKey(i); + if (settingIsUserSet(s, i)) { + if (!formatSetting(s, i, text, sizeof text)) { // too long to keep + ok = false; + continue; + } + // putString() returns the length it wrote, which is 0 for a failure and also for an empty string + // that went in fine: for that one, look at what is stored + const size_t n = p.putString(key, text); + const bool stored = n == strlen(text) && (n > 0 || (p.isKey(key) && p.getString(key, "x").length() == 0)); + if (!stored) ok = false; + } else if (p.isKey(key) && !p.remove(key)) { + ok = false; + } + } + p.end(); + return ok; +} + +// Not a setting: how many settings a card has changed that no banner has told of yet (cfgAnnounced()). +const char *const kNoteKey = kSdNoteNvsKey; + +int readNote() { + Preferences p; + if (!p.begin(kNamespace, true)) return 0; // nothing has ever been saved + const int n = p.isKey(kNoteKey) ? (int)p.getInt(kNoteKey, 0) : 0; + p.end(); + return n > 0 ? n : 0; +} + +void writeNote(int n) { // 0 takes it away + Preferences p; + if (!p.begin(kNamespace, false)) return; + if (n > 0) { + p.putInt(kNoteKey, n); + } else if (p.isKey(kNoteKey)) { + p.remove(kNoteKey); + } + p.end(); +} + +// The last thing done with the card at start-up, once its settings are in force: a copy of the power log, when +// the clock keeps one. `logWasOn`: it did until this card was read (which may just have switched it off). +void leaveCard(bool logWasOn) { + powerLogCopyToCard(logWasOn); + sdUnmount(); +} + +} // namespace + +Settings cfgBuildDefaults() { + // Worked out once (it is asked for again whenever a setting is forgotten), and the problems are + // logged once. + static const Settings d = [] { + const Settings s = buildDefaults(&s_build); + for (int i = 0; i < s_build.kept; i++) LOGF(TAG, "built-in default not used: %s", s_build.text[i]); + if (s_build.problems > s_build.kept) LOGF(TAG, "... and %d more", s_build.problems - s_build.kept); + return s; + }(); + return d; +} + +int cfgBuildProblems() { + cfgBuildDefaults(); + return s_build.problems; +} + +bool cfgSaveToFlash() { return saveToFlash(g_cfg, false); } + +void cfgLoadFlash() { + g_cfg = cfgBuildDefaults(); + g_cfgStatus = CfgStatus(); + Preferences p; + if (!p.begin(kNamespace, true)) return; // nothing has ever been saved + for (size_t i = 0; i < settingCount(); i++) { + const char *key = settingNvsKey(i); + if (!p.isKey(key)) continue; + const String value = p.getString(key, ""); + if (applyStoredSetting(g_cfg, i, value.c_str())) g_cfgStatus.fromFlash++; + } + p.end(); +} + +void cfgImportSdCard() { + CfgStatus &st = g_cfgStatus; + const bool logWasOn = g_cfg.powerLog; // (what the flash said, before a card has its say) + st.carried = readNote(); + if (st.carried > 0) LOGF(TAG, "the start before this one changed %d setting(s) from a card and restarted before saying so", st.carried); + const SdStatus mount = sdMount(); + if (mount == SdStatus::NO_CARD) { + st.sd = CfgStatus::SD_NO_CARD; + return; + } + if (mount == SdStatus::UNREADABLE) { + st.sd = CfgStatus::SD_UNREADABLE; + LOGF(TAG, "an SD card answered, but no FAT file system on it could be mounted: %s", sdProblemText()); + return; + } + sdCardSummary(st.card, sizeof st.card); + fwScanCard(); // is there a firmware file on the card too? (fwUpdateFromCard acts on it once the display is up) + + const char *names[] = {CONFIG_FILE_NAME, CONFIG_FILE_NAME_ALT}; + const char *found = nullptr; + for (const char *n : names) { + if (sdFileExists(n)) { + found = n; + break; + } + } + + if (!found) { // a card with no settings file: leave an example (every line commented out) + copyStr(st.file, sizeof st.file, CONFIG_FILE_NAME); + char *buf = (char *)malloc(kExampleCap); + char note[200]; + driftNote(note, sizeof note); + const size_t n = buf ? renderExampleConfig(g_cfg, note, buf, kExampleCap, true) : 0; // Windows line ends + const bool ok = n > 0 && sdWriteFile(CONFIG_FILE_NAME, buf, n); + free(buf); + st.sd = ok ? CfgStatus::SD_EXAMPLE_WRITTEN : CfgStatus::SD_WRITE_FAILED; + LOGF(TAG, "%s: %s on the %s card", CONFIG_FILE_NAME, ok ? "example file written" : "could not write the example file", st.card); + leaveCard(logWasOn); + return; + } + + copyStr(st.file, sizeof st.file, found); + char *data = nullptr; + size_t len = 0; + const bool read = sdReadFile(found, &data, &len, kMaxFileBytes); + if (!read) { + leaveCard(logWasOn); + st.sd = CfgStatus::SD_READ_FAILED; + LOGF(TAG, "could not read %s", found); + return; + } + st.report = applyConfigText(g_cfg, cfgBuildDefaults(), data, len); + free(data); + st.sd = CfgStatus::SD_FILE_APPLIED; + char sum[96]; + formatConfigSummary(st.report, sum, sizeof sum); + LOGF(TAG, "%s: %s", found, sum); + for (int i = 0; i < st.report.issueCount; i++) LOGF(TAG, " %s", st.report.issues[i].text); + + if (st.report.touched() > 0) { // keep what the card said, so the card can come out + st.savedToFlash = saveToFlash(g_cfg, st.report.resetAll); + LOGF(TAG, "settings %s flash", st.savedToFlash ? "saved to" : "could NOT be saved to"); + // ... and how many there were, until the banner has said so: with a firmware file on the card too, this + // start ends in a restart before the banner, and the next one finds nothing left to change in the file. + // (Settings that could not be saved are found changed again by the next start: nothing to carry.) + if (st.savedToFlash) writeNote(st.carried + st.report.touched()); + } + leaveCard(logWasOn); // (the settings are safe in flash by now, whatever the card does next) +} + +void cfgAnnounced() { + if (readNote() > 0) writeNote(0); +} + +void cfgSummary(char *out, size_t cap) { + const CfgStatus &st = g_cfgStatus; + char sum[96]; + switch (st.sd) { + case CfgStatus::SD_NO_CARD: + case CfgStatus::SD_NOT_TRIED: + if (st.fromFlash > 0) { + snprintf(out, cap, "%d saved in flash, no SD card", st.fromFlash); + } else { + snprintf(out, cap, "built-in defaults, no SD card"); + } + break; + case CfgStatus::SD_UNREADABLE: snprintf(out, cap, "%s", sdProblemText()); break; // what the card holds instead + case CfgStatus::SD_EXAMPLE_WRITTEN: snprintf(out, cap, "wrote an example file to the card"); break; + case CfgStatus::SD_WRITE_FAILED: snprintf(out, cap, "SD card: cannot write (locked?)"); break; + case CfgStatus::SD_READ_FAILED: snprintf(out, cap, "SD card: cannot read the file"); break; + case CfgStatus::SD_FILE_APPLIED: + formatConfigSummary(st.report, sum, sizeof sum); + snprintf(out, cap, "SD: %s", sum); // (the info page holds 42 characters of this) + break; + } +} + +// The built-in defaults that were not used come first, then the problems of the SD file. +int cfgIssueCount() { + cfgBuildDefaults(); + return s_build.kept + (g_cfgStatus.sd == CfgStatus::SD_FILE_APPLIED ? g_cfgStatus.report.issueCount : 0); +} + +void cfgIssueText(int i, char *out, size_t cap) { + if (i < 0 || i >= cfgIssueCount()) { + if (cap) out[0] = 0; + return; + } + if (i < s_build.kept) { + copyStr(out, cap, s_build.text[i]); + } else { + copyStr(out, cap, g_cfgStatus.report.issues[i - s_build.kept].text); + } +} + +void cfgPrint(Print &out) { + char v[100]; + for (size_t i = 0; i < settingCount(); i++) { + if (settingIsSecret(i)) { + formatSetting(g_cfg, i, v, sizeof v); + snprintf(v, sizeof v, "%s", v[0] ? "(set)" : "(empty)"); + } else if (!formatSetting(g_cfg, i, v, sizeof v)) { + snprintf(v, sizeof v, "?"); + } + out.printf(" %-22s = %s%s\n", settingKey(i), v, settingIsUserSet(g_cfg, i) ? "" : " (default)"); + } +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/app_settings.h b/02_Example/Arduino/11_Clock_Dashboard/app_settings.h new file mode 100644 index 00000000..3ba4774c --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/app_settings.h @@ -0,0 +1,67 @@ +#pragma once + +// The settings in force, and where they come from: the defaults in config.h and secrets.h, then +// what the clock saved in its flash ("cfg" namespace), then the settings file on an SD card, +// which is read once at boot and saved to flash so the card can be taken out again. The format +// and the parsing live in settings.h / settings.cpp (host-tested); this file is the part that +// touches flash and the card. +// +// g_cfg is filled in during setup(), before any task starts, and only read afterwards. + +#include + +#include "settings.h" + +extern Settings g_cfg; + +struct CfgStatus { + enum Sd : uint8_t { + SD_NOT_TRIED = 0, + SD_NO_CARD, + SD_UNREADABLE, // a card, but no file system on it would mount (exFAT, a GPT partition table, ...: sdProblemText()) + SD_EXAMPLE_WRITTEN, // a card with no settings file: an example was written to it + SD_WRITE_FAILED, // ... or could not be (write-protected card?) + SD_READ_FAILED, // the file is there but could not be read (too big, card error) + SD_FILE_APPLIED // the file was read; `report` says what it did + }; + Sd sd = SD_NOT_TRIED; + int fromFlash = 0; // settings that came from flash at this boot + bool savedToFlash = false; + int carried = 0; // settings a card changed at the start before this one, which restarted (to install a + // firmware from the card) before it could say so; see cfgAnnounced() + char file[40] = ""; // the file name that was found or written + char card[24] = ""; // "SDHC 14.8 GB" + ConfigReport report; +}; +extern CfgStatus g_cfgStatus; + +// The defaults this build ships with: config.h and secrets.h (see build_defaults.h). A value in +// there that the settings rules refuse is left out, and counted here. +Settings cfgBuildDefaults(); +int cfgBuildProblems(); + +// g_cfg = defaults, then flash. Quick; call it first in setup(). +void cfgLoadFlash(); + +// Writes the settings in force that did not come from the defaults to flash (after something changed +// g_cfg at run time, as the serial command "batcal" does). False if any of it could not be kept. +bool cfgSaveToFlash(); + +// Looks for a card. With a settings file: applies it on top of g_cfg and saves what it changed +// to flash. Without one: writes an example file to the card. Unmounts the card again. +void cfgImportSdCard(); + +// The banner about what the card's settings did has been shown. Until then the number of settings a card +// changed is kept in flash: a start that goes on to install a firmware from the card restarts before it +// gets to the banner, and the start after it reads the same file and finds nothing left to change. +// g_cfgStatus.carried is that number as the start before this one left it. +void cfgAnnounced(); + +// One line for the Info page / a toast, and the first few problems: built-in defaults that were not +// used, then those of the SD file. +void cfgSummary(char *out, size_t cap); +int cfgIssueCount(); +void cfgIssueText(int i, char *out, size_t cap); + +// Every setting on the serial port, passwords hidden. +void cfgPrint(Print &out); diff --git a/02_Example/Arduino/11_Clock_Dashboard/app_state.cpp b/02_Example/Arduino/11_Clock_Dashboard/app_state.cpp new file mode 100644 index 00000000..e59eeefc --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/app_state.cpp @@ -0,0 +1,47 @@ +#include "app_state.h" + +#include +#include + +#include "util.h" + +SharedState g_state; +static SemaphoreHandle_t s_mutex = nullptr; + +void stateInit() { + if (!s_mutex) s_mutex = xSemaphoreCreateMutex(); +} + +StateLock::StateLock() { + if (s_mutex) xSemaphoreTake(s_mutex, portMAX_DELAY); +} + +StateLock::~StateLock() { + if (s_mutex) xSemaphoreGive(s_mutex); +} + +void stateSetStatus(const char *fmt, ...) { + char buf[sizeof(g_state.status)]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof buf, fmt, ap); + va_end(ap); + StateLock lock; + copyStr(g_state.status, sizeof g_state.status, buf); +} + +void stateNotice(const char *text, int icon) { + StateLock lock; + copyStr(g_state.notice, sizeof g_state.notice, text); + g_state.noticeIcon = icon; + g_state.noticeSeq++; +} + +void waitForQuietPhase() { + for (int i = 0; i < 100; i++) { // 100 x 10 ms: gives up after a second + struct timeval tv; + gettimeofday(&tv, nullptr); + if (tv.tv_usec > 200000 && tv.tv_usec < 600000) return; + vTaskDelay(pdMS_TO_TICKS(10)); + } +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/app_state.h b/02_Example/Arduino/11_Clock_Dashboard/app_state.h new file mode 100644 index 00000000..b31edaf7 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/app_state.h @@ -0,0 +1,90 @@ +#pragma once + +// State shared between the network task (core 0) and the UI loop (core 1). +// Writers take a StateLock for the few microseconds a copy needs; the UI takes +// one snapshot per frame. + +#include +#include +#include +#include + +#include "app_model.h" + +// wifi_mode = sync: how the last radio session ended, if not well. +enum RadioTrouble : uint8_t { + RADIO_FINE = 0, + RADIO_AWAY, // none of the clock's networks is in range: it looks again every few minutes + RADIO_CANNOT_JOIN // a network is there but the clock could not get in (or get anything done): it backs off +}; + +struct SharedState { + // --- network & time ------------------------------------------------------- + bool wifiUp = false; + bool wifiBackup = false; // the connection is the backup network, not the main one + int rssi = -100; + char ssid[33] = ""; + char ip[16] = ""; + bool timeTrusted = false; // system clock is believed correct (NTP synced, or valid RTC) + bool ntpSynced = false; // NTP has completed at least once since boot + time_t lastNtpSyncUtc = 0; + char status[56] = ""; // transient progress / error text for the status bar + + // --- the radio (wifi_mode = sync) ------------------------------------------- + bool radioSync = false; // sync mode is on + bool radioAsleep = false; // ... and the radio is off at the moment + uint8_t radioTrouble = 0; // how the last session ended if not well: RADIO_FINE, RADIO_AWAY, RADIO_CANNOT_JOIN + int32_t radioWakeInSec = -1; // seconds until the next session by the clock, -1 = not known + uint16_t radioOnPermille = 0; // the share of the time since start that the radio was on, in thousandths + uint32_t radioSessions = 0; + + // --- clock accuracy (drift.h) ---------------------------------------------- + bool driftKnown = false; // a figure exists: measured now, or kept in flash from an earlier run + bool driftLive = false; // ... measured during this run + float driftPpm = 0; // positive: the clock gains + float driftErrPpm = 0; + float driftHours = 0; // the history the figure rests on + + // --- location & zone ------------------------------------------------------ + bool locationKnown = false; + double lat = 0; + double lon = 0; + char place[40] = ""; + char tzName[48] = ""; // IANA name, e.g. "Australia/Perth" + char tzPosix[72] = ""; // POSIX rule in effect, e.g. "AWST-8" + + // --- weather -------------------------------------------------------------- + WeatherData weather; + uint32_t weatherFetchedMs = 0; // millis() of the last good fetch + + // --- Spotify (progressMs is the value at spotifySampledMs) ---------------- + SpotifyInfo spotify; + uint32_t spotifySampledMs = 0; + + // --- one-shot notices from background tasks, shown by the UI as a toast --- + char notice[40] = ""; + int noticeIcon = 0; + uint32_t noticeSeq = 0; +}; + +extern SharedState g_state; + +void stateInit(); + +class StateLock { + public: + StateLock(); + ~StateLock(); +}; + +// printf-style transient status line ("" clears it). +void stateSetStatus(const char *fmt, ...) __attribute__((format(printf, 1, 2))); + +// Queue a toast for the UI. `icon` is a UiToastIcon. +void stateNotice(const char *text, int icon); + +// A flash write (NVS) stops both cores from running code out of flash for a few +// milliseconds, now and then for a few tens of them. Call this first so the write +// lands in the middle of a second, away from the moment the UI sends the next frame. +// Blocks the caller for at most a second. +void waitForQuietPhase(); diff --git a/02_Example/Arduino/11_Clock_Dashboard/battery_est.h b/02_Example/Arduino/11_Clock_Dashboard/battery_est.h new file mode 100644 index 00000000..3c0320ea --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/battery_est.h @@ -0,0 +1,328 @@ +#pragma once + +// Battery runtime estimate, for the Info page (never the main screen). +// +// The voltage is turned into a charge percentage through the discharge curve first. The same +// drain in mA is a very different number of mV per minute depending on where on the curve the +// cell is (about 2 mV per percent in the middle, 15 and more near empty), so a trend in volts +// means little; a trend in percent is one steady rate all the way down. +// +// * readings are boiled down to one point per 5 minutes, the median of that bin, which throws +// away the dips of WiFi transmit bursts; +// * the first 15 minutes after the clock went onto the battery are ignored (a cell just off +// the charger is still settling and falls faster than it really drains); +// * the rate is the slope of a straight line through the points of a window that adapts to the +// drain: long enough that the cell loses about 20 % inside it, but never under 2 hours or +// over 8. (The middle of the curve is nearly flat, a few millivolts there are several +// percent, so short windows chase noise: a 30 minute fit was off by 60 % or more one time in +// ten in simulation, the adaptive 2 to 8 hour window by 15 to 40 %. A fast drain still +// gets the short end of that range, a slow one the long.) Until two hours of history exist +// it uses all there is, and the Info page says how many minutes the figure is based on; +// * one round of outlier rejection, then "hours left" is the percent above the cut-off level +// divided by that rate. +// +// It promises the time left at the *average* load of the window, which is the best guess when the +// load varies; it cannot know what you will do next. Header-only and free of Arduino dependencies +// so tools/tests can simulate whole discharges; the constants in Params were chosen with +// tools/tests/battery_sim.cpp. That is a model of a cell, not a measurement of yours. + +#include +#include +#include +#include +#include + +#include "calc.h" + +namespace battest { + +const int kMaxBins = 96; // 8 hours of 5 minute points +const int kMaxSamples = 40; // readings kept per bin (one every 5 s fills 60 per bin; the first 40 count) +const float kMinRatePctPerHour = 0.02f; // a fall slower than this is no fall that can be measured: no rate, no time left + +struct Params { + uint32_t binSec = 300; + uint32_t skipSec = 900; // ignore this long after the cell went onto the battery + uint32_t readySec = 1800; // the first estimate comes when this much settled history exists + uint32_t minWindowSec = 7200; // the fit never looks at less than this (or at everything there is, while less) + uint32_t maxWindowSec = 28800; // ... and never further back than this + float targetDropPct = 20.0f; // aim for a window in which the cell loses about this much (0 = always the maximum) +}; + +struct Estimate { + enum State : uint8_t { OFF, LEARNING, READY }; + State state = OFF; // OFF: nothing to say (not on battery yet, or reset) + float pctPerHour = 0; // drain rate, positive while discharging (READY) + float hoursLeft = 0; // until the cut-off level at that rate (READY; meaningless if unbounded) + bool unbounded = false; // the drain is too slow to measure: "more than a month" + float avgMa = 0; // average current in mA when the capacity is known, else 0 + float levelPct = 0; // the fitted charge level now + int windowMin = 0; // minutes of history the rate was fitted on + int learnMin = 0; // LEARNING: minutes until the first estimate +}; + +// "12 min", "5 h 40 min", "2 d 4 h", ">30 d" +inline void formatRemaining(float hours, char *out, size_t cap) { + if (!(hours >= 0)) { + snprintf(out, cap, "--"); + return; + } + const int mins = (int)lroundf(hours * 60.0f); + if (mins < 60) { + snprintf(out, cap, "%d min", mins); + } else if (mins < 48 * 60) { + snprintf(out, cap, "%d h %02d min", mins / 60, mins % 60); + } else if (mins < 30 * 24 * 60) { + snprintf(out, cap, "%d d %d h", mins / 1440, (mins % 1440) / 60); + } else { + snprintf(out, cap, ">30 d"); + } +} + +class Estimator { + public: + explicit Estimator(const Params &p = Params()) : p_(p) {} + + // Forget everything: the charger came or went, the battery was swapped. + void reset() { + nBins_ = 0; + curN_ = 0; + curOffsetSum_ = 0; // the open bin's time offsets belong to the history that is being dropped + started_ = false; + } + + // One reading (volts at the battery) taken while running on the battery. + void add(uint32_t nowSec, float volts) { + if (!(volts > 0.5f && volts < 6.0f)) return; + if (started_ && nowSec < lastSec_) reset(); // the time base restarted + const uint32_t bin = nowSec / p_.binSec; + if (!started_) { + started_ = true; + startSec_ = nowSec; + curBin_ = bin; + } else if (bin != curBin_) { + closeBin(); + curBin_ = bin; + } + lastSec_ = nowSec; + if (curN_ < kMaxSamples) { + cur_[curN_++] = calc::batteryPercentOpen(volts); // (not cut off at 100: a fall from 4.22 V to 4.20 V is a fall) + curOffsetSum_ += (double)(nowSec - bin * p_.binSec); + } + } + + // cutoffV: the level the clock shuts down at; capacityMah: 0 if unknown. + Estimate estimate(float cutoffV, float capacityMah) const { + Estimate e; + if (!started_) return e; + const uint32_t settledAt = startSec_ + p_.skipSec; + double *t = t_, *y = y_; // scratch in the object, not on the (small) task stack + int n = 0; + for (int i = 0; i < nBins_; i++) + if (bins_[i].t >= settledAt) { + t[n] = (double)bins_[i].t; + y[n] = (double)bins_[i].pct; + n++; + } + const double span = n >= 2 ? t[n - 1] - t[0] : 0; + if (n < 4 || span < (double)p_.readySec - 1.0) { + e.state = Estimate::LEARNING; + // when will the first estimate be there: the end of the skip plus the history it needs + const double ready = (double)settledAt + (double)p_.readySec + (double)p_.binSec; + const double left = ready - (double)lastSec_; + e.learnMin = left > 0 ? (int)ceil(left / 60.0) : 1; + return e; + } + + const double tLast = t[n - 1]; + LineFit fit = fitWindow(t, y, n, tLast, (double)p_.maxWindowSec); + if (p_.targetDropPct > 0 && fit.ok && fit.slope < 0) { + const double want = (double)p_.targetDropPct / (-fit.slope); // seconds for that drop at this rate + double w = want < (double)p_.minWindowSec ? (double)p_.minWindowSec : want; + if (w > (double)p_.maxWindowSec) w = (double)p_.maxWindowSec; + if (w < (double)p_.maxWindowSec) { + const LineFit shorter = fitWindow(t, y, n, tLast, w); + if (shorter.ok) fit = shorter; + } + } + if (!fit.ok) { + e.state = Estimate::LEARNING; + e.learnMin = 5; + return e; + } + + e.state = Estimate::READY; + e.windowMin = (int)lround(fit.window / 60.0); + e.levelPct = (float)fit.level; + e.pctPerHour = (float)(-fit.slope * 3600.0); + if (e.pctPerHour < 0) e.pctPerHour = 0; + if (capacityMah > 0) e.avgMa = e.pctPerHour / 100.0f * capacityMah; + const float cutPct = calc::batteryPercentF(cutoffV); + float above = (e.levelPct > 100.0f ? 100.0f : e.levelPct) - cutPct; // (a cell holds no more than all of its charge) + if (above < 0) above = 0; + if (e.pctPerHour < kMinRatePctPerHour) { + e.unbounded = true; + e.hoursLeft = 0; + } else { + e.hoursLeft = above / e.pctPerHour; + if (e.hoursLeft > 24.0f * 30.0f) e.unbounded = true; + } + return e; + } + + int binCount() const { return nBins_; } + const Params ¶ms() const { return p_; } + + private: + struct Bin { + uint32_t t; + float pct; + }; + struct LineFit { + bool ok = false; + double slope = 0; // percent per second + double level = 0; // percent at the newest point + double window = 0; // seconds covered + }; + + // Median of the readings in the bin that just ended, placed at their mean time. + void closeBin() { + if (curN_ == 0) return; + float s[kMaxSamples]; + memcpy(s, cur_, sizeof(float) * (size_t)curN_); + for (int i = 1; i < curN_; i++) { // insertion sort: small n + const float v = s[i]; + int j = i - 1; + while (j >= 0 && s[j] > v) { + s[j + 1] = s[j]; + j--; + } + s[j + 1] = v; + } + const float median = (curN_ & 1) ? s[curN_ / 2] : 0.5f * (s[curN_ / 2 - 1] + s[curN_ / 2]); + Bin b; + b.t = curBin_ * p_.binSec + (uint32_t)(curOffsetSum_ / curN_ + 0.5); + b.pct = median; + curN_ = 0; + curOffsetSum_ = 0; + + // A cell that is being discharged cannot gain charge: a clear rise means a charger came + // (or the battery was changed) without anyone telling us. Start over from here. + if (nBins_ > 0 && b.pct > bins_[nBins_ - 1].pct + 2.5f) { + nBins_ = 0; + startSec_ = b.t; + } + if (nBins_ == kMaxBins) { + memmove(bins_, bins_ + 1, sizeof(Bin) * (kMaxBins - 1)); + nBins_--; + } + bins_[nBins_++] = b; + } + + // Least-squares line through the points of the last `window` seconds, after throwing out + // the points that sit far from a first fit. + LineFit fitWindow(const double *t, const double *y, int n, double tLast, double window) const { + LineFit f; + double *tt = tt_, *yy = yy_, *res = res_; + bool *keep = keep_; + int m = 0; + for (int i = 0; i < n; i++) + if (t[i] >= tLast - window - 1e-9) { + tt[m] = t[i]; + yy[m] = y[i]; + m++; + } + if (m < 4) return f; + for (int i = 0; i < m; i++) keep[i] = true; + double slope = 0, level = 0; + for (int pass = 0; pass < 2; pass++) { + double st = 0, sy = 0; + int k = 0; + for (int i = 0; i < m; i++) + if (keep[i]) { + st += tt[i]; + sy += yy[i]; + k++; + } + if (k < 4) return f; + const double mt = st / k, my = sy / k; + double sxx = 0, sxy = 0; + for (int i = 0; i < m; i++) + if (keep[i]) { + sxx += (tt[i] - mt) * (tt[i] - mt); + sxy += (tt[i] - mt) * (yy[i] - my); + } + if (sxx <= 0) return f; + slope = sxy / sxx; + level = my + slope * (tLast - mt); + if (pass == 1) break; + // residuals against this line: the median absolute one sets the scale + int r = 0; + for (int i = 0; i < m; i++) + if (keep[i]) res[r++] = fabs(yy[i] - (my + slope * (tt[i] - mt))); + for (int i = 1; i < r; i++) { + const double v = res[i]; + int j = i - 1; + while (j >= 0 && res[j] > v) { + res[j + 1] = res[j]; + j--; + } + res[j + 1] = v; + } + const double mad = (r & 1) ? res[r / 2] : 0.5 * (res[r / 2 - 1] + res[r / 2]); + double limit = 3.0 * 1.4826 * mad; + if (limit < 0.3) limit = 0.3; // percent: the curve table itself is no finer than this + bool dropped = false; + for (int i = 0; i < m; i++) + if (keep[i] && fabs(yy[i] - (my + slope * (tt[i] - mt))) > limit) { + keep[i] = false; + dropped = true; + } + if (!dropped) break; + } + f.ok = true; + f.slope = slope; + f.level = level; + f.window = tt[m - 1] - tt[0]; + return f; + } + + Params p_; + Bin bins_[kMaxBins]; + int nBins_ = 0; + // working space of estimate(): a few KB that would be a lot on a task stack + mutable double t_[kMaxBins], y_[kMaxBins], tt_[kMaxBins], yy_[kMaxBins], res_[kMaxBins]; + mutable bool keep_[kMaxBins]; + float cur_[kMaxSamples]; + int curN_ = 0; + double curOffsetSum_ = 0; + uint32_t curBin_ = 0; + uint32_t startSec_ = 0; + uint32_t lastSec_ = 0; + bool started_ = false; +}; + +// When the estimator is fed: while the clock runs on its battery as far as anyone can tell, which is whenever +// nothing shows a charger (the charge detector says neither "charging" nor "held full"). Waiting for the +// detector to say "discharging" will not do: under a light load the voltage falls too slowly for it ever to +// say so (0.05 mV a minute against the 0.3 it asks for in twelve minutes), and nothing would be estimated at +// all. The history starts afresh whenever a charger comes or goes. +class Gate { + public: + // One battery reading. `charger`: one shows. Returns true if the reading went to the estimator. + bool feed(Estimator &est, bool haveBattery, bool charger, uint32_t nowSec, float volts) { + const bool on = haveBattery && !charger; + if (on != on_) { + est.reset(); + on_ = on; + } + if (on) est.add(nowSec, volts); + return on; + } + bool onBattery() const { return on_; } + + private: + bool on_ = false; +}; + +} // namespace battest diff --git a/02_Example/Arduino/11_Clock_Dashboard/build_defaults.h b/02_Example/Arduino/11_Clock_Dashboard/build_defaults.h new file mode 100644 index 00000000..475fa115 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/build_defaults.h @@ -0,0 +1,124 @@ +#pragma once + +// The settings as this build ships them: the defaults in config.h, each of which secrets.h can replace, +// turned into a Settings. They are the bottom layer, under what the clock saved in its flash and +// under the SD card file. +// +// Every setting the card can set has a macro here (settings.cpp has the list, and tools/tests fails +// if one is missing), so a build can be pre-programmed with anything the card could say. A value goes +// through the same checks as the card's, in the same words ("12h", "max", "d-mon-y"), with the same +// limits. One that does not pass leaves the factory default in place and is reported, so a typo in +// secrets.h shows up on the Info page instead of being used. +// +// No Arduino dependency: app_settings.cpp uses it in the sketch and tools/tests on a PC. + +#include +#include +#include + +#include "config.h" +#include "settings.h" + +// What was wrong with the build's defaults: how many, and the first few as text for the screen. +struct BuildReport { + static const int kKept = 3; + int problems = 0; + int kept = 0; + char text[kKept][128] = {}; // "MACRO_NAME: the reason" (a name is 23 characters at most, a reason 79) +}; + +namespace builddef { + +inline bool isPlaceholderSsid(const char *s) { return !strcmp(s, "YourWiFiName") || !strcmp(s, "YourBackupWiFiName"); } + +// One setting, by the name it has in the settings file, from the text of its macro. +inline void put(Settings &d, BuildReport *rep, const char *macro, const char *key, const char *text) { + size_t i = 0; + while (i < settingCount() && strcmp(settingKey(i), key) != 0) i++; + char err[80] = "no such setting"; + if (i < settingCount() && applyStoredSetting(d, i, text, err, sizeof err)) return; + if (!rep) return; + rep->problems++; + if (rep->kept < BuildReport::kKept) snprintf(rep->text[rep->kept++], sizeof rep->text[0], "%s: %s", macro, err); +} + +inline void putBool(Settings &d, BuildReport *rep, const char *macro, const char *key, bool v) { + put(d, rep, macro, key, v ? "on" : "off"); +} + +inline void putInt(Settings &d, BuildReport *rep, const char *macro, const char *key, long v) { + char t[24]; + snprintf(t, sizeof t, "%ld", v); + put(d, rep, macro, key, t); +} + +// Six digits for a float: 3.10f is 3.0999999 as a double, which a limit of "3.10 and up" would refuse. +inline void putFloat(Settings &d, BuildReport *rep, const char *macro, const char *key, double v) { + char t[32]; + snprintf(t, sizeof t, "%.6g", v); + put(d, rep, macro, key, t); +} + +inline void putDouble(Settings &d, BuildReport *rep, const char *macro, const char *key, double v) { + char t[32]; + snprintf(t, sizeof t, "%.8g", v); + put(d, rep, macro, key, t); +} + +} // namespace builddef + +inline Settings buildDefaults(BuildReport *rep = nullptr) { + using namespace builddef; + Settings d; + + // WiFi + putBool(d, rep, "WIFI_ENABLED", "wifi", WIFI_ENABLED); + const bool noMain = isPlaceholderSsid(WIFI_SSID); // the example file's name: no network yet + put(d, rep, "WIFI_SSID", "wifi_ssid", noMain ? "" : WIFI_SSID); + put(d, rep, "WIFI_PASSWORD", "wifi_password", noMain ? "" : WIFI_PASSWORD); + const bool noBackup = isPlaceholderSsid(WIFI_SSID_BACKUP); + put(d, rep, "WIFI_SSID_BACKUP", "wifi_backup_ssid", noBackup ? "" : WIFI_SSID_BACKUP); + put(d, rep, "WIFI_PASSWORD_BACKUP", "wifi_backup_password", noBackup ? "" : WIFI_PASSWORD_BACKUP); + put(d, rep, "APP_HOSTNAME", "hostname", APP_HOSTNAME); + put(d, rep, "NTP_SERVER", "ntp_server", NTP_SERVER); + put(d, rep, "WIFI_POWER_SAVE", "wifi_power_save", WIFI_POWER_SAVE); + put(d, rep, "WIFI_MODE", "wifi_mode", WIFI_MODE); + + // Units and formats + put(d, rep, "USE_FAHRENHEIT", "units", USE_FAHRENHEIT ? "imperial" : "metric"); + put(d, rep, "TIME_FORMAT", "time_format", TIME_FORMAT); + put(d, rep, "DATE_FORMAT", "date_format", DATE_FORMAT); + putBool(d, rep, "SHOW_WEEK", "show_week", SHOW_WEEK); + + // Location + putDouble(d, rep, "LOCATION_LATITUDE", "latitude", LOCATION_LATITUDE); + putDouble(d, rep, "LOCATION_LONGITUDE", "longitude", LOCATION_LONGITUDE); + put(d, rep, "LOCATION_LABEL", "location_label", LOCATION_LABEL); + put(d, rep, "LOCATION_QUERY", "location", LOCATION_QUERY); + put(d, rep, "TIMEZONE_POSIX_OVERRIDE", "timezone", TIMEZONE_POSIX_OVERRIDE); + + // Spotify + putBool(d, rep, "SPOTIFY_ENABLED", "spotify", SPOTIFY_ENABLED); + put(d, rep, "SPOTIFY_CLIENT_ID", "spotify_client_id", SPOTIFY_CLIENT_ID); + putBool(d, rep, "SPOTIFY_LIVE", "spotify_live", SPOTIFY_LIVE); + + // Sensors and battery + putFloat(d, rep, "INDOOR_TEMP_OFFSET_C", "indoor_offset", INDOOR_TEMP_OFFSET_C); + put(d, rep, "BATTERY_MODE", "battery", BATTERY_MODE); + putInt(d, rep, "BATTERY_CAPACITY_MAH", "battery_capacity_mah", BATTERY_CAPACITY_MAH); + putFloat(d, rep, "BATTERY_CALIBRATION", "battery_calibration", BATTERY_CALIBRATION); + putBool(d, rep, "LOW_BATTERY_SHUTDOWN", "low_battery_shutdown", LOW_BATTERY_SHUTDOWN); + putFloat(d, rep, "BATTERY_CUTOFF_V", "battery_cutoff_v", BATTERY_CUTOFF_V); + + // Power + putInt(d, rep, "CPU_MHZ", "cpu_mhz", CPU_MHZ); + putInt(d, rep, "CPU_IDLE_MHZ", "cpu_idle_mhz", CPU_IDLE_MHZ); + put(d, rep, "CONSOLE_MODE", "console", CONSOLE_MODE); + putBool(d, rep, "POWER_LOG", "power_log", POWER_LOG); + + // Weather + putInt(d, rep, "WEATHER_INTERVAL_MIN", "weather_interval_min", WEATHER_INTERVAL_MIN); + + d.userSet = 0; // these are the defaults, not something the user chose + return d; +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/button_edges.cpp b/02_Example/Arduino/11_Clock_Dashboard/button_edges.cpp new file mode 100644 index 00000000..78d0ea8a --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/button_edges.cpp @@ -0,0 +1,55 @@ +#include "button_edges.h" + +#include "config.h" + +namespace { + +// One writer (the interrupt) and one reader (the loop), both on the core that attached the interrupt. +const uint8_t kCap = 16; // a power of two; a press and its release with their bounces fit several times over +struct EdgeLog { + volatile uint8_t head = 0; // the interrupt writes here + volatile uint8_t tail = 0; // the loop reads here + uint32_t ms[kCap]; + bool down[kCap]; +}; +EdgeLog s_log[2]; +bool s_on = false; + +void ARDUINO_ISR_ATTR record(EdgeLog &q, uint8_t pin) { + const uint8_t next = (uint8_t)((q.head + 1) & (kCap - 1)); + if (next == q.tail) return; // full (a long burst of bounces): the loop's own look at the pin puts it right + q.ms[q.head] = millis(); + q.down[q.head] = digitalRead(pin) == LOW; + q.head = next; +} + +void ARDUINO_ISR_ATTR onKeyChange() { record(s_log[BUTTON_KEY], PIN_KEY); } +void ARDUINO_ISR_ATTR onBootChange() { record(s_log[BUTTON_BOOT], PIN_BOOT); } + +} // namespace + +void buttonEdgesBegin() { + if (s_on) return; + attachInterrupt(PIN_KEY, onKeyChange, CHANGE); + attachInterrupt(PIN_BOOT, onBootChange, CHANGE); + s_on = true; +} + +void buttonEdgesEnd() { + if (!s_on) return; + detachInterrupt(PIN_KEY); + detachInterrupt(PIN_BOOT); + s_on = false; +} + +bool buttonEdgesOn() { return s_on; } + +bool buttonEdgeTake(ButtonId button, bool *down, uint32_t *atMs) { + EdgeLog &q = s_log[button]; + const uint8_t t = q.tail; + if (t == q.head) return false; + *down = q.down[t]; + *atMs = q.ms[t]; + q.tail = (uint8_t)((t + 1) & (kCap - 1)); + return true; +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/button_edges.h b/02_Example/Arduino/11_Clock_Dashboard/button_edges.h new file mode 100644 index 00000000..9df365d2 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/button_edges.h @@ -0,0 +1,21 @@ +#pragma once + +// The buttons by interrupt, for the idle clock (cpu_idle_mhz). +// +// The UI loop reads the two buttons between frames. At the working clock a frame takes some 30 ms to draw, +// shorter than any tap. At 20 MHz it takes 120 ms, and a quick tap can begin and end inside that, unseen. +// So with an idle clock set, every change of the two pins is also recorded by an interrupt, with its time, +// and the loop replays the record to its click detectors (ClickDetector::edge) before it looks at the pins +// itself. Without an idle clock nothing here is used and the buttons are read as they always were. + +#include + +enum ButtonId : uint8_t { BUTTON_KEY = 0, BUTTON_BOOT = 1 }; + +void buttonEdgesBegin(); // start recording (the pins are inputs with pull-ups already) +void buttonEdgesEnd(); // stop (before deep sleep) +bool buttonEdgesOn(); + +// The oldest change of that button not yet taken: the level it changed to and when (millis()). +// False when there is none. Call it from the task that called buttonEdgesBegin(). +bool buttonEdgeTake(ButtonId button, bool *down, uint32_t *atMs); diff --git a/02_Example/Arduino/11_Clock_Dashboard/buttons.h b/02_Example/Arduino/11_Clock_Dashboard/buttons.h new file mode 100644 index 00000000..90ce971e --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/buttons.h @@ -0,0 +1,162 @@ +#pragma once + +// Debounced multi-click / long-press detector for one active-low button. +// Pure logic with no Arduino dependency (tools/tests drives it with synthetic +// timestamps). Feed update() the raw pressed/not-pressed level every few +// milliseconds; it returns a gesture once one is complete: +// +// CLICK_1 / CLICK_2 / CLICK_3 that many clicks, reported once the button has +// been idle for `multiClickGapMs` (3+ clicks = CLICK_3) +// CLICK_LONG held for `longPressMs`, reported while still held + +#include + +enum ClickEvent : uint8_t { + CLICK_NONE = 0, + CLICK_1 = 1, + CLICK_2 = 2, + CLICK_3 = 3, + CLICK_LONG = 4 +}; + +class ClickDetector { + public: + ClickDetector(uint32_t multiClickGapMs, uint32_t longPressMs, uint32_t debounceMs = 25) + : gapMs_(multiClickGapMs), longMs_(longPressMs), debounceMs_(debounceMs) {} + + ClickEvent update(bool pressed, uint32_t nowMs) { + // time does not run backwards (a level read just after an interrupt recorded a later change, see edge()) + if (seen_ && (int32_t)(nowMs - lastMs_) < 0) nowMs = lastMs_; + seen_ = true; + lastMs_ = nowMs; + // debounce: accept a level once it has been steady for debounceMs_ + if (pressed != raw_) { + raw_ = pressed; + rawSinceMs_ = nowMs; + } + if (raw_ != stable_ && (uint32_t)(nowMs - rawSinceMs_) >= debounceMs_) { + stable_ = raw_; + if (stable_) { // pressed + pressStartMs_ = nowMs; + longFired_ = false; + } else if (longFired_) { // released after a long press: nothing more to report + clicks_ = 0; + } else { // released after a short press: count it + if (clicks_ < 255) clicks_++; + lastReleaseMs_ = nowMs; + } + } + + if (stable_ && !longFired_ && (uint32_t)(nowMs - pressStartMs_) >= longMs_) { + longFired_ = true; + clicks_ = 0; + return CLICK_LONG; + } + if (!stable_ && clicks_ > 0 && (uint32_t)(nowMs - lastReleaseMs_) >= gapMs_) { + uint8_t c = clicks_; + clicks_ = 0; + return c >= 3 ? CLICK_3 : (c == 2 ? CLICK_2 : CLICK_1); + } + return CLICK_NONE; + } + + // A change of the level that happened at `atMs`, recorded by an interrupt while the loop was too busy to + // look (a slow frame at the idle clock): time passes with the level as it was, and then it changes. So a + // tap that began and ended between two looks still counts, with its real length. + ClickEvent edge(bool pressed, uint32_t atMs) { + const ClickEvent before = update(raw_, atMs); + const ClickEvent after = update(pressed, atMs); + return before != CLICK_NONE ? before : after; + } + + // The debounced state of the button, and for how long it has been down (0 when it is up), by the time + // of the last update. + bool isDown() const { return stable_; } + uint32_t heldMs() const { return stable_ ? (uint32_t)(lastMs_ - pressStartMs_) : 0; } + + private: + uint32_t gapMs_, longMs_, debounceMs_; + bool seen_ = false; + uint32_t lastMs_ = 0; + bool raw_ = false; + bool stable_ = false; + bool longFired_ = false; + uint8_t clicks_ = 0; + uint32_t rawSinceMs_ = 0; + uint32_t pressStartMs_ = 0; + uint32_t lastReleaseMs_ = 0; +}; + +// A very long hold that acts when the button is let go: "armed" once it has been down for `holdMs` (the moment +// to say so on the screen), "fired" when it then comes up. The clock restarts on this, and it must not restart +// with the button still down: KEY held while the clock starts means something else. Feed it the debounced +// state of a ClickDetector (isDown(), heldMs()). +class HoldRelease { + public: + enum Event : uint8_t { NONE = 0, ARMED, FIRED }; + + explicit HoldRelease(uint32_t holdMs) : holdMs_(holdMs) {} + + Event update(bool down, uint32_t heldMs) { + if (down) { + if (armed_ || heldMs < holdMs_) return NONE; + armed_ = true; + return ARMED; + } + if (!armed_) return NONE; + armed_ = false; + return FIRED; + } + + bool armed() const { return armed_; } + // The hold has come to mean something else (a second button joined it): letting go will not fire. + void cancel() { armed_ = false; } + + private: + uint32_t holdMs_; + bool armed_ = false; +}; + +// Two buttons held down together for `holdMs`: "fired" once, while both are still down. From the moment +// both are down until both are up again, and for `quietMs` after that, the buttons' own gestures must not +// count (muted()): each one's long press comes due long before the two together do, a hold that acts on +// letting go would act, and two buttons let go are not two clicks. The quiet time is for the click that a +// detector reports a moment after its button came up. Feed it the debounced states of the two +// ClickDetectors (isDown()). +class ChordHold { + public: + enum Event : uint8_t { NONE = 0, FIRED }; + + ChordHold(uint32_t holdMs, uint32_t quietMs) : holdMs_(holdMs), quietMs_(quietMs) {} + + Event update(bool aDown, bool bDown, uint32_t nowMs) { + const bool both = aDown && bDown; + if (both && !both_) { // the two together, from now: one that was let go and pressed again starts the count afresh + sinceMs_ = nowMs; + fired_ = false; + } + both_ = both; + if (both) engaged_ = true; + if (engaged_ && !aDown && !bDown) { // both are up again + engaged_ = false; + quiet_ = true; + quietSinceMs_ = nowMs; + } + if (quiet_ && (uint32_t)(nowMs - quietSinceMs_) >= quietMs_) quiet_ = false; + if (both && !fired_ && (uint32_t)(nowMs - sinceMs_) >= holdMs_) { + fired_ = true; + return FIRED; + } + return NONE; + } + + bool muted() const { return engaged_ || quiet_; } + + private: + uint32_t holdMs_, quietMs_; + bool both_ = false; // the two are down together right now + bool fired_ = false; // ... and that has been reported + bool engaged_ = false; // they were, and not both have come up since + bool quiet_ = false; // both came up a moment ago + uint32_t sinceMs_ = 0, quietSinceMs_ = 0; +}; diff --git a/02_Example/Arduino/11_Clock_Dashboard/calc.h b/02_Example/Arduino/11_Clock_Dashboard/calc.h new file mode 100644 index 00000000..4425f2a1 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/calc.h @@ -0,0 +1,168 @@ +#pragma once + +// Pure calculations used by the sensor code. Header-only and free of Arduino +// dependencies so tools/tests can check them on a PC. + +#include +#include +#include +#include +#include + +namespace calc { + +// --------------------------------------------------------------------------- +// Battery: single-cell Li-ion / 18650 voltage, as the clock reads it while it runs -> state of charge. +// Piecewise-linear through a measured discharge: a 3400 mAh 18650 in this clock, run from a full charge until it +// shut itself down at 3.30 V (17 to 20 October 2026, firmware 1.9, wifi_mode = sync, cpu_idle_mhz = 20; 81 hours). +// The load was steady, so the charge used was in proportion to the time, and each point is the share of the run +// still to go when the cell got there (tools/tests/fixtures/rundown-2026-10.csv; test_features replays it). The +// generic curve this replaced put 3.70 V at 12 % where the cell still had 39 %, and 4.15 V at 95 % where it had 90. +// A cell of another make or chemistry can sit a little higher or lower. +// --------------------------------------------------------------------------- +struct BatteryPoint { + float v; + int pct; +}; +inline const BatteryPoint *batteryCurve(int *count) { + static const BatteryPoint kCurve[] = { + {4.200f, 100}, {4.174f, 95}, {4.145f, 90}, {4.099f, 85}, {4.034f, 80}, {3.981f, 75}, {3.945f, 70}, + {3.907f, 65}, {3.868f, 60}, {3.826f, 55}, {3.784f, 50}, {3.749f, 45}, {3.705f, 40}, {3.672f, 35}, + {3.644f, 30}, {3.619f, 25}, {3.584f, 20}, {3.534f, 15}, {3.502f, 10}, {3.457f, 5}, {3.270f, 0}}; + *count = (int)(sizeof(kCurve) / sizeof(kCurve[0])); + return kCurve; +} + +// The ADC and the 1:3 divider can read a few percent low (a full cell shows as 4.13 V). The correction is +// one multiplier: what a tester says divided by what the clock measured. False if either voltage cannot be +// a single LiPo cell (a floating pin, a typo) or the factor falls outside the setting's range, which means +// something is wrong with the wiring or the figure, not the ADC. +const float kCalibrationMin = 0.80f, kCalibrationMax = 1.25f; +inline bool calibrationFactor(float trueVolts, float measuredVolts, float *factor) { + // (the range of the factor bounds the measured voltage from above: 4.5 / 0.8 is under 5.7 V) + if (!(trueVolts >= 2.5f && trueVolts <= 4.5f) || !(measuredVolts >= 2.5f)) return false; + const float f = trueVolts / measuredVolts; + if (!(f >= kCalibrationMin && f <= kCalibrationMax)) return false; + *factor = f; + return true; +} + +// The same, not rounded: the battery-life estimate needs the fractions. +inline float batteryPercentF(float volts) { + int n; + const BatteryPoint *c = batteryCurve(&n); + if (volts >= c[0].v) return 100.0f; + for (int i = 1; i < n; i++) { + if (volts >= c[i].v) { + float span = c[i - 1].v - c[i].v; + float frac = (volts - c[i].v) / span; + return c[i].pct + frac * (c[i - 1].pct - c[i].pct); + } + } + return 0.0f; +} + +inline int batteryPercent(float volts) { return (int)lroundf(batteryPercentF(volts)); } + +// The same curve carried on above its top, with the slope of its last stretch (5.2 mV to the percent): 4.223 V is +// 104.4. For the runtime estimate only, which measures how fast the level falls. A cell that has just come off +// the charger reads above 4.20 V for a while, and so does any cell whose charger holds a shade over 4.20 V (they +// may, by a percent) or whose calibration is a shade high. Cut off at 100 the level would stand still while the +// voltage came down, and under a light load that is hours in which the estimate sees no drain at all (seen on the +// board: 4.223 V on the charger, 4.210 V a minute after it, then 0.05 mV a minute). The gauge stops at 100. +inline float batteryPercentOpen(float volts) { + int n; + const BatteryPoint *c = batteryCurve(&n); + if (volts > c[0].v) return 100.0f + (volts - c[0].v) * (float)(c[0].pct - c[1].pct) / (c[0].v - c[1].v); + return batteryPercentF(volts); +} + +// The curve backwards: the voltage at which a cell reads `pct` percent (0..100). +inline float batteryVoltsForPercent(float pct) { + int n; + const BatteryPoint *c = batteryCurve(&n); + if (pct >= 100.0f) return c[0].v; + if (pct <= 0.0f) return c[n - 1].v; + for (int i = 1; i < n; i++) { + if (pct >= (float)c[i].pct) { + float frac = (pct - (float)c[i].pct) / (float)(c[i - 1].pct - c[i].pct); + return c[i].v + frac * (c[i - 1].v - c[i].v); + } + } + return c[n - 1].v; +} + +// --------------------------------------------------------------------------- +// SHTC3 (Sensirion): CRC-8, polynomial 0x31, init 0xFF. +// Datasheet check value: crc8({0xBE, 0xEF}) == 0x92. +// --------------------------------------------------------------------------- +inline uint8_t crc8(const uint8_t *data, size_t len) { + uint8_t crc = 0xFF; + for (size_t i = 0; i < len; i++) { + crc ^= data[i]; + for (int bit = 0; bit < 8; bit++) { + crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ 0x31) : (uint8_t)(crc << 1); + } + } + return crc; +} + +// Decodes a 6 byte "temperature first" measurement: T msb, T lsb, crc, RH msb, +// RH lsb, crc. Returns false on a CRC mismatch. +inline bool shtc3Decode(const uint8_t b[6], float *tempC, float *rhPercent) { + if (crc8(b, 2) != b[2] || crc8(b + 3, 2) != b[5]) return false; + uint16_t rawT = (uint16_t)((b[0] << 8) | b[1]); + uint16_t rawH = (uint16_t)((b[3] << 8) | b[4]); + *tempC = -45.0f + 175.0f * (float)rawT / 65536.0f; + *rhPercent = 100.0f * (float)rawH / 65536.0f; + return true; +} + +// --------------------------------------------------------------------------- +// Humidity correction. Relative humidity depends on temperature: if the sensor +// is warmer than the room, it under-reports RH. Keeping the water vapour +// pressure fixed (Magnus formula) gives the RH the room air really has. +// --------------------------------------------------------------------------- +inline float saturationVapourPressure(float tempC) { // hPa + return 6.112f * expf(17.62f * tempC / (243.12f + tempC)); +} + +inline float humidityAtTemperature(float rhMeasured, float measuredC, float actualC) { + float rh = rhMeasured * saturationVapourPressure(measuredC) / saturationVapourPressure(actualC); + if (rh < 0.0f) rh = 0.0f; + if (rh > 100.0f) rh = 100.0f; + return rh; +} + +// --------------------------------------------------------------------------- +// Calendar <-> epoch (UTC) without relying on timegm(). +// --------------------------------------------------------------------------- +// Days since 1970-01-01 for a proleptic Gregorian date (Howard Hinnant's algorithm). +inline int64_t daysFromCivil(int y, int m, int d) { + y -= m <= 2; + const int64_t era = (y >= 0 ? y : y - 399) / 400; + const unsigned yoe = (unsigned)(y - era * 400); + const unsigned doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; + const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097 + (int64_t)doe - 719468; +} + +inline int64_t epochFromUtc(int year, int month, int day, int hour, int minute, int second) { + return daysFromCivil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second; +} + +// POSIX TZ string for a fixed UTC offset, e.g. +28800 -> "<+08>-8", +// +19800 -> "<+0530>-5:30", -12600 -> "<-0330>3:30". +inline void posixFromUtcOffset(int offsetSec, char *out, size_t cap) { + int a = offsetSec < 0 ? -offsetSec : offsetSec; + int hh = (a / 3600) % 100, mm = (a % 3600) / 60; + char sign = offsetSec < 0 ? '-' : '+'; + const char *posixSign = offsetSec > 0 ? "-" : ""; // POSIX counts hours *west* of UTC + if (mm) { + snprintf(out, cap, "<%c%02d%02d>%s%d:%02d", sign, hh, mm, posixSign, hh, mm); + } else { + snprintf(out, cap, "<%c%02d>%s%d", sign, hh, posixSign, hh); + } +} + +} // namespace calc diff --git a/02_Example/Arduino/11_Clock_Dashboard/charge.h b/02_Example/Arduino/11_Clock_Dashboard/charge.h new file mode 100644 index 00000000..81239eca --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/charge.h @@ -0,0 +1,440 @@ +#pragma once + +// Infers "charging / discharging / full" from the battery voltage alone. +// +// The ESP32-S3-RLCD-4.2 has no software-readable charge signal: the charger's +// STAT output only drives an LED and the sole power signal wired to the MCU is +// the battery voltage on GPIO4 (see PIN_CHARGE_STATUS in config.h if you add a +// wire to it). What the voltage does tells the story, though: +// +// * plugging USB in makes the charger push current through the cell's internal +// resistance, so the terminal voltage steps UP by tens of millivolts at once; +// pulling it out steps it DOWN; +// * while charging at constant current the voltage climbs a few mV a minute; +// while the cell carries the load it falls (steeply near full, slowly in the +// flat middle of the curve); +// * once the charger finishes, the voltage sits flat near 4.2 V. +// +// Readings are reduced to one value per 15 seconds, taken from the upper end of +// the bin: a WiFi transmit burst can pull a single reading down by tens of +// millivolts but never up, so the highest readings are the clean ones. Twelve +// minutes of those are kept. Two things are looked at: +// 1. a step: the last 30 s against 90..150 s ago, which must stay beyond +// +-40 mV for three evaluations running. This reacts about a minute after +// the cable moves; +// 2. the trend: the slope of the whole history, which settles the cases a step +// cannot (booting on a charger, unplugging a full battery where no current +// flowed so nothing steps). It needs eight minutes of data. A change in +// load moves the level for good, and to a plain straight-line fit that looks +// like a ramp (a 12 mV shift mid-window reads as +1.5 mV/min), so a claim of +// charging also needs a second slope, fitted with one level shift allowed +// anywhere (the pooled slope of the two stretches either side of the best +// split), to agree. A ramp survives that, a shift does not. The trend is +// not consulted while a step is being judged, nor for twelve minutes after +// one (the step spoils it: a decline and a jump can cancel into "flat"). +// +// The readings of the first three minutes are thrown away: the board draws more +// while it joins WiFi and fetches over TLS, the battery sags, and the recovery +// looks like a plug-in. +// +// A third thing is looked at for ending FULL: a slow history of one level per five +// minutes (four hours). Unplugging a full battery gives no step, and a light load +// (the clock with its radio mostly off draws 10 to 45 mA, a 2500 mAh cell) makes the top of the +// curve fall by only 0.07 to 0.3 mV a minute, which the twelve-minute trend cannot tell +// from flat. A charger holding the cell keeps it within a few millivolts of its plateau, +// though, while a load takes it down for good: FULL ends when the slow level has fallen +// 13 mV below the plateau (not counting the first minutes of FULL, in +// which the cell relaxes after the charger ended) ends FULL. However +// FULL ended, flat is not taken for FULL again until the cell has not fallen by more than +// 1.2 mV in the last hour (a recharge, or a cell that sits still, gives that). A fall of 0.3 mV a minute ends FULL after +// about an hour, one of 0.07 after three. +// +// It is a heuristic and says UNKNOWN rather than guessing when the evidence is +// thin (for instance in the flat middle of the discharge curve, or in the first +// minutes after boot). FULL means "sitting flat at the top of the curve", which +// is what a charger holding the cell at 4.2 V looks like; it also reads as full +// for a while after unplugging a full battery (an hour or two with a light load). +// +// FULL is said only of a cell that was seen charging up to its plateau, or that has +// not come down for 160 minutes of the slow history. Twelve flat minutes alone say +// nothing: a clock started on its battery with a nearly full cell is just as flat +// (found on the board at 99 %: FULL eleven minutes after the start, for hours). So +// UNKNOWN is also what a clock on its battery says for as long as the fall is too +// slow to see, which under a light load is for good; whoever asks "is a charger +// there?" should take CHARGING and FULL for yes and the other two for no. +// Pure logic with no Arduino dependency; tools/tests simulates plausible traces. + +#include +#include +#include + +class ChargeDetector { + public: + enum State : uint8_t { UNKNOWN = 0, DISCHARGING = 1, CHARGING = 2, FULL = 3 }; + + // Tunables (volts, volts per minute). + static constexpr uint32_t kBinMs = 15000; // one value per 15 s + static constexpr uint32_t kMaxGapMs = 45000; // a longer silence breaks the time axis: start over + static constexpr uint32_t kWarmupMs = 180000; // readings from the first three minutes are ignored + static constexpr int kBinSamples = 8; // readings kept per bin + static constexpr int kBins = 48; // twelve minutes of history + static constexpr int kMinBinsForStep = 10; // 150 s before a step can be judged + static constexpr int kMinBinsForSlope = 32; // eight minutes before the trend is trusted + static constexpr int kMinStretch = 8; // the level shift may sit no closer than 2 minutes to either end + static constexpr int kStepPersist = 3; // evaluations (15 s apart) a step must survive + static constexpr float kStepV = 0.040f; // 40 mV + static constexpr float kChargeSlope = 0.0015f; // +1.5 mV/min or more: charging + static constexpr float kChargeSlopeFromFull = 0.0025f; // ...but +2.5 mV/min to leave FULL for it + static constexpr float kDischargeSlope = -0.0003f; // -0.3 mV/min or less: discharging + static constexpr float kHardFallSlope = -0.0010f; // -1.0 mV/min: a fall that cannot be noise, even up at the top + static constexpr float kFlatSlope = 0.00025f; // within +-0.25 mV/min: flat + static constexpr float kFullV = 4.16f; // flat at or above this: held full + static constexpr float kTopV = 4.15f; // up here only a hard fall counts as discharging + static constexpr float kFullDropV = 0.015f; // 15 mV below the plateau, and still falling, ends FULL + + // The slow history: one level per five minutes, four hours of them, for ending FULL. + static constexpr int kBinsPerSlow = 20; // 20 bins of 15 s + static constexpr int kSlowBins = 48; + static constexpr int kSlowSettle = 1; // the first five minutes of FULL are not part of the plateau + static constexpr float kLongDropV = 0.013f; // a slow level this far below the plateau ends FULL + static constexpr int kSlowFlatLen = 16; // over the last 80 minutes, the newest four slow levels against the highest four in a row: + static constexpr float kSlowFlatDropV = 0.0012f; // ... no more than this lower, and FULL may come back + static constexpr int kSlowSteadyLen = 32; // over the last 160 minutes, the newest eight slow levels against the highest eight in a row: + static constexpr float kSlowSteadyDropV = 0.0012f; // ... no more than this lower: nothing draws on the cell (FULL without having seen a charge) + static constexpr int kTrendLen[3] = {8, 16, 32}; // windows of the slow trend shown on the Info page + + // Feed one voltage reading (volts) every few seconds. + void addSample(float volts, uint32_t nowMs) { + if (started_ && (uint32_t)(nowMs - lastSampleMs_) > kMaxGapMs) reset(); + lastSampleMs_ = nowMs; + if (!started_) { + started_ = true; + startMs_ = nowMs; + } + // Right after boot the board is busy joining WiFi and fetching over TLS, the battery sags + // under that and then recovers, which to this detector looks like a plug-in. So the first + // minutes of readings are ignored. + if ((uint32_t)(nowMs - startMs_) < kWarmupMs) return; + if (!binStarted_) { + binStarted_ = true; + binStartMs_ = nowMs; + } + if (count_ < kBinSamples) samples_[count_++] = volts; + if ((uint32_t)(nowMs - binStartMs_) >= kBinMs) { + finishBin(upperLevel(samples_, count_)); + count_ = 0; + binStartMs_ = nowMs; + } + } + + // Forget everything (battery removed, long gap in the readings). + void reset() { *this = ChargeDetector(); } + + State state() const { return state_; } + // Still in the first minutes after start, whose readings are thrown away. + bool warmingUp() const { return started_ && (uint32_t)(lastSampleMs_ - startMs_) < kWarmupMs; } + // Minutes of history gathered so far (capped at the 12 kept), not counting the warm-up. + float minutesOfData() const { return (float)bins_ * (kBinMs / 60000.0f); } + // True once the trend is trusted, i.e. the detector has had eight minutes. + bool ready() const { return bins_ >= kMinBinsForSlope; } + // The trend in mV per minute (level shifts excluded); 0 until ready(). + float slopeMvPerMin() const { return slope_ * 1000.0f; } + // The last step measurement (last 30 s against 90..150 s ago) in mV; 0 until 150 s of data. + float stepMv() const { return step_ * 1000.0f; } + // The slow history's trend in mV per minute, over the longest of the 160, 80 and 40 minutes there is + // data for (0 before 40 minutes). Only shown; the decision about FULL is the drop below the plateau. + float longSlopeMvPerMin() const { + for (int w = 2; w >= 0; w--) + if (slowCount_ >= kTrendLen[w]) return slowSlope(slowCount_ - kTrendLen[w], slowCount_) * 1000.0f; + return 0; + } + // FULL ended (the cell is on its own) and has not been charged or sat still since: FULL cannot come back by + // merely looking flat. + bool leftFull() const { return fullBlocked_; } + + private: + // The value a bin stands for: its highest reading when there are three or fewer, the second + // highest for four, and so on (one reading in three is thrown away from the top end). + static float upperLevel(float *v, int n) { + for (int i = 1; i < n; i++) { // insertion sort, n <= kBinSamples + const float x = v[i]; + int j = i - 1; + while (j >= 0 && v[j] > x) { + v[j + 1] = v[j]; + j--; + } + v[j + 1] = x; + } + return v[n - 1 - (n - 1) / 3]; + } + + void finishBin(float level) { + if (bins_ < kBins) { + means_[bins_++] = level; + } else { + memmove(means_, means_ + 1, (kBins - 1) * sizeof(float)); + means_[kBins - 1] = level; + } + slowAcc_[slowAccN_++] = level; + if (slowAccN_ == kBinsPerSlow) { // five minutes of bins: one more slow level (before the trend looks at the state) + slowAccN_ = 0; + pushSlow(mean(slowAcc_, kBinsPerSlow)); + } + evaluate(); + } + + // (The bins are the upper levels of their readings already, so the mean of twenty of them is steady enough.) + static float mean(const float *v, int n) { + float sum = 0; + for (int i = 0; i < n; i++) sum += v[i]; + return sum / (float)n; + } + + void pushSlow(float level) { + if (slowCount_ < kSlowBins) { + slow_[slowCount_++] = level; + } else { + memmove(slow_, slow_ + 1, (kSlowBins - 1) * sizeof(float)); + slow_[kSlowBins - 1] = level; + } + slowTotal_++; + evaluateSlow(); + } + + // The slow levels, once per five minutes. While FULL: the plateau is the highest slow level since the + // first few minutes of FULL (which hold the charger's last minutes and the cell relaxing); a level 13 mV + // below it ends FULL. A load takes a cell down for good, a charger holding it keeps it within a few + // millivolts of the plateau (the tests move it by 4 mV either way every 20 to 60 minutes), so the drop + // does not depend on how steep the fall is, which at 0.07 mV a minute cannot be told from noise in + // twelve minutes. `fullBlocked_` then stays (it is also set by every other way out of FULL) until the last + // hour has not fallen by more than 1.2 mV, as a recharge or a cell that sits still gives: the twelve minute + // trend of a light load is "flat" and must not read as full again. + void evaluateSlow() { + const int n = slowCount_; + if (state_ == FULL) { + const int settled = (int)(slowTotal_ - fullSince_) - kSlowSettle; // slow levels since FULL began, minus the settling + const int count = settled < n - 1 ? settled : n - 1; // the newest level is the one being judged + if (count >= 1) { + const float plateau = slowMax(n - 1 - count, n - 1); + if (slow_[n - 1] < plateau - kLongDropV) { + fullBlocked_ = true; + state_ = DISCHARGING; // not full, whatever the twelve minute trend says + } + } + } + if (fullBlocked_ && slowNotFallen(kSlowFlatLen, 4, kSlowFlatDropV)) fullBlocked_ = false; + } + + // The highest of slow_[lo..hi): the plateau. + float slowMax(int lo, int hi) const { + float mx = slow_[lo]; + for (int i = lo + 1; i < hi; i++) + if (slow_[i] > mx) mx = slow_[i]; + return mx; + } + + // Has the level not come down over the last `len` slow levels? The mean of the newest `k` is compared with + // the highest mean of `k` in a row among them. With the highest, not the oldest: a charge that ended inside + // the window starts low, and the fall of a cell that was unplugged right after it would then pass for "no + // lower than before". + bool slowNotFallen(int len, int k, float dropV) const { + const int n = slowCount_; + if (n < len) return false; + float sum = 0; + for (int i = n - len; i < n - len + k; i++) sum += slow_[i]; + float top = sum; + for (int i = n - len + k; i < n; i++) { // slide the window of k up to the newest + sum += slow_[i] - slow_[i - k]; + if (sum > top) top = sum; + } + return (top - sum) / (float)k <= dropV; // (`sum` is now that of the newest k) + } + + // Has the cell held its level for the last 160 minutes? A cell that carries the clock has not, however light + // the load: 0.03 mV a minute (5 mA from a 2500 mAh cell at the top of the curve) is 3.6 mV in that time. + bool slowSteady() const { return slowNotFallen(kSlowSteadyLen, 8, kSlowSteadyDropV); } + + // Least-squares slope of slow_[lo..hi) in volts per minute. + float slowSlope(int lo, int hi) const { + const int n = hi - lo; + double mean = 0; + for (int i = lo; i < hi; i++) mean += slow_[i]; + mean /= n; + const double xMean = 0.5 * (n - 1); + double sxy = 0, sxx = 0; + for (int i = 0; i < n; i++) { + const double dx = i - xMean; + sxy += dx * (slow_[lo + i] - mean); + sxx += dx * dx; + } + return (float)(sxy / sxx / (double)kSlowMinutes); + } + + void evaluate() { + const int n = bins_; + const State before = state_; + + // 1. A plug / unplug step. + if (n >= kMinBinsForStep) { + const float recent = (means_[n - 1] + means_[n - 2]) * 0.5f; + const float ref = (means_[n - 7] + means_[n - 8] + means_[n - 9] + means_[n - 10]) * 0.25f; + step_ = recent - ref; + if (step_ >= kStepV) { + upStreak_++; + downStreak_ = 0; + } else if (step_ <= -kStepV) { + downStreak_++; + upStreak_ = 0; + } else { + upStreak_ = downStreak_ = 0; + } + if (upStreak_ >= kStepPersist) { + state_ = CHARGING; + holdoff_ = kBins; + } + if (downStreak_ >= kStepPersist) { + state_ = DISCHARGING; + holdoff_ = kBins; + } + } + + // 2. The trend over the history. The step itself spoils it (a decline and a jump can cancel + // into "flat", say), so the trend stays out of the verdict while a step is being judged and + // until the step has left the twelve minutes it looks at. + if (holdoff_ > 0) holdoff_--; + if (n >= kMinBinsForSlope) { + slope_ = plainSlope(n); + if (holdoff_ == 0 && upStreak_ == 0 && downStreak_ == 0) applyTrend(n); + } + + // However FULL ended, a gentle fall must not be able to bring it back by looking flat for twelve minutes. + // (What lifts that is in evaluateSlow(): the last hour has not fallen, which charging brings about too.) + if (before == FULL && state_ == DISCHARGING) fullBlocked_ = true; + } + + void applyTrend(int n) { + const float level = (lifted(n - 1) + lifted(n - 2) + lifted(n - 3) + lifted(n - 4)) * 0.25f; // the last minute + + // Charging is the claim most worth guarding, and a level shift fools the plain slope into + // it, so the slope that allows for a shift has to agree (at half the strength: it is a + // noisier estimate, because the fit gets to pick where the shift sits). Off a full plateau + // a smaller shift passes for a ramp, and a real restart of charging comes with a step + // anyway, so there the climb has to be clearer. + const float needed = (state_ == FULL || fullBlocked_) ? kChargeSlopeFromFull : kChargeSlope; + const bool rising = slope_ >= needed && shiftTolerantSlope(n) >= 0.5f * needed; + const bool falling = slope_ <= kDischargeSlope; + const bool fallingHard = slope_ <= kHardFallSlope; + const bool flat = fabsf(slope_) < kFlatSlope; + + if (rising) { + state_ = CHARGING; + } else if (state_ == FULL) { + // Off the plateau for good: clearly below where it settled, and still going down. + if (level < fullRef_ - kFullDropV && falling) state_ = DISCHARGING; + } else if (fallingHard || (falling && level < kTopV)) { + // Up at the top of the curve (where a charger parks the cell) noise must not read as a + // fall, so there only a hard one counts; lower down a gentle one is enough. + state_ = DISCHARGING; + } + // Flat at the top of the curve: a charger is holding the cell full. (Not while the slow history says the + // cell is falling: a light load makes that look flat in twelve minutes.) Twelve flat minutes say "held" + // only of a cell that was seen being charged up to here. Of any other they say nothing: a clock that is + // started on a nearly full cell, with no charger, looks just as flat (0.03 to 0.1 mV a minute under a light + // load), and calling that full hid the fall, and with it the runtime estimate, for hours. Without a charge + // the slow history has to agree: no fall in 160 minutes. + const bool held = state_ == FULL || state_ == CHARGING || slowSteady(); + if (flat && level >= kFullV && !fullBlocked_ && held) { + fullRef_ = (state_ == FULL) ? fullRef_ + 0.02f * (level - fullRef_) : level; // follow the plateau slowly + if (state_ != FULL) fullSince_ = slowTotal_; + state_ = FULL; + } + } + + // A bin lifted to the highest of itself and the two before it: a load burst of up to 30 s + // pulls one or two bins down, and this hides it from the trend without hiding a real shift + // (which keeps every bin lower for good). + float lifted(int i) const { + const int p1 = i >= 1 ? i - 1 : 0, p2 = i >= 2 ? i - 2 : 0; // (the first bins have no earlier ones: they compare with themselves) + float v = means_[i]; + if (means_[p1] > v) v = means_[p1]; + if (means_[p2] > v) v = means_[p2]; + return v; + } + + // Least-squares slope of the (lifted) history in volts per minute. + float plainSlope(int n) const { + float mean = 0; + for (int i = 0; i < n; i++) mean += lifted(i); + mean /= (float)n; + const float xMean = 0.5f * (float)(n - 1); + float sxy = 0, sxx = 0; + for (int i = 0; i < n; i++) { + const float dx = (float)i - xMean; + sxy += dx * (lifted(i) - mean); + sxx += dx * dx; + } + return (sxy / sxx) * (60000.0f / (float)kBinMs); + } + + // Slope of the (lifted) history in volts per minute with one level shift allowed: for every + // split point the two stretches get their own level but share one slope (their pooled + // least-squares slope), and the split that leaves the smallest residual wins. + float shiftTolerantSlope(int n) const { + double px[kBins + 1], py[kBins + 1], pxx[kBins + 1], pxy[kBins + 1], pyy[kBins + 1]; // prefix sums + px[0] = py[0] = pxx[0] = pxy[0] = pyy[0] = 0; + const double y0 = lifted(0); + for (int i = 0; i < n; i++) { + const double x = i, y = lifted(i) - y0; + px[i + 1] = px[i] + x; + py[i + 1] = py[i] + y; + pxx[i + 1] = pxx[i] + x * x; + pxy[i + 1] = pxy[i] + x * y; + pyy[i + 1] = pyy[i] + y * y; + } + double bestSse = 1e30, bestSlope = 0; + for (int k = kMinStretch; k <= n - kMinStretch; k++) { + double sxx[2], sxy[2], syy[2]; + const int lo[2] = {0, k}, hi[2] = {k, n}; + for (int s = 0; s < 2; s++) { // each stretch about its own mean + const double m = hi[s] - lo[s]; + const double sx = px[hi[s]] - px[lo[s]], sy = py[hi[s]] - py[lo[s]]; + sxx[s] = (pxx[hi[s]] - pxx[lo[s]]) - sx * sx / m; + sxy[s] = (pxy[hi[s]] - pxy[lo[s]]) - sx * sy / m; + syy[s] = (pyy[hi[s]] - pyy[lo[s]]) - sy * sy / m; + } + const double b = (sxy[0] + sxy[1]) / (sxx[0] + sxx[1]); + const double sse = syy[0] + syy[1] - b * (sxy[0] + sxy[1]); + if (sse < bestSse) { + bestSse = sse; + bestSlope = b; + } + } + return (float)(bestSlope * (60000.0 / (double)kBinMs)); + } + + State state_ = UNKNOWN; + bool started_ = false; + bool binStarted_ = false; + uint32_t startMs_ = 0; + uint32_t binStartMs_ = 0; + uint32_t lastSampleMs_ = 0; + float samples_[kBinSamples] = {}; + int count_ = 0; + float means_[kBins] = {}; + int bins_ = 0; + int upStreak_ = 0; + int downStreak_ = 0; + int holdoff_ = 0; // evaluations left in which the trend may not change the state + float step_ = 0; // volts + float slope_ = 0; // volts per minute + float fullRef_ = 0; // volts: where the plateau sits while FULL + + static constexpr float kSlowMinutes = (float)kBinsPerSlow * (float)kBinMs / 60000.0f; // 5: minutes per slow value + float slow_[kSlowBins] = {}; // volts, one level per five minutes, oldest first + int slowCount_ = 0; + uint32_t slowTotal_ = 0; // slow values since the start (does not wrap around the 48 kept) + uint32_t fullSince_ = 0; // slowTotal_ when FULL began + float slowAcc_[kBinsPerSlow] = {}; // the bins of the slow value being gathered + int slowAccN_ = 0; + bool fullBlocked_ = false; // FULL ended; flat may not make it FULL again until the cell is charged or sat still +}; diff --git a/02_Example/Arduino/11_Clock_Dashboard/clock_policy.h b/02_Example/Arduino/11_Clock_Dashboard/clock_policy.h new file mode 100644 index 00000000..67699f23 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/clock_policy.h @@ -0,0 +1,40 @@ +#pragma once + +// Which CPU clock the clock runs at: the working clock (cpu_mhz) while something needs it, the idle clock +// (cpu_idle_mhz) when nothing does. What needs speed: +// * the radio: WiFi works from 80 MHz up, so the clock is raised before it is switched on and lowered +// after it is off; +// * a computer on the USB port: below 80 MHz the clock of some peripherals follows the CPU, and the USB +// console may stop, which would look like a crash when someone is watching the log; +// * a button press, for a few seconds: a frame takes four times as long to draw at 20 MHz as at 80, and +// the person pressing wants the answer quickly. +// +// Pure logic with no Arduino dependency; the UI loop (which owns the display, and so the SPI bus the core +// has to be told about) applies it, see powerSetCpuMhz(). + +#include + +namespace clockpolicy { + +const uint32_t kPokeMs = 8000; // after a button press the clock stays fast this long + +struct Needs { + bool radio = false; // the radio is on or about to be + bool usbHost = false; // a computer is on the USB port + bool poked = false; // a button was pressed a moment ago +}; + +// The clock to run at, in MHz. No idle clock (0), or one that is not below the working clock, means "no change". +inline int target(const Needs &n, int workMhz, int idleMhz) { + if (idleMhz <= 0 || idleMhz >= workMhz) return workMhz; + return (n.radio || n.usbHost || n.poked) ? workMhz : idleMhz; +} + +// May the clock change now? Up at once (something is waiting for it); down only between frames, when +// nothing is drawn or waiting to be sent, so that no frame is planned with the old costs. +inline bool mayChange(int currentMhz, int targetMhz, bool quiet) { + if (targetMhz == currentMhz) return false; + return targetMhz > currentMhz || quiet; +} + +} // namespace clockpolicy diff --git a/02_Example/Arduino/11_Clock_Dashboard/config.h b/02_Example/Arduino/11_Clock_Dashboard/config.h new file mode 100644 index 00000000..4196138c --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/config.h @@ -0,0 +1,354 @@ +#pragma once + +// ============================================================================ +// Built-in defaults. +// +// Most of what is here is only the factory default. The clock also reads a settings file from a +// FAT32 SD card (README: "Settings from an SD card") and keeps what it finds in its own flash, so +// the card can come out again afterwards. Later wins: +// +// this file and secrets.h < saved in flash < the SD card file +// +// Every setting the card can set has a default here, so a build can come pre-programmed: define it +// in secrets.h (which git ignores, so it can hold credentials too) and it replaces the default below. +// The values are checked by the same rules as the card's, in the same words ("12h", "max"). +// README: "Building the settings in". +// ============================================================================ + +#define APP_NAME "RLCD Clock" +// Not semver: 1.0 was the first release, and the number goes up by hand with every batch of changes that is +// handed over to run on a board (README: "Versions"). Two builds of one version are told apart by the build +// id next to it on the System info page, which changes whenever the program does. +#define APP_VERSION "1.10" + +// The settings file the clock looks for in the root of the SD card, and writes (with every +// setting explained) when the card has none. Upper or lower case, it is FAT. +#define CONFIG_FILE_NAME "ESP32-S3-RLCD-Config.txt" +#define CONFIG_FILE_NAME_ALT "ESP32-S31-RLCD-Config.txt" // accepted too, in case of a typo + +// The firmware file the clock installs from the root of the SD card (README: "Updating the firmware from +// the SD card"): the app image the Arduino IDE exports with Sketch > Export Compiled Binary, or a copy of +// it under the first name. With more than one of these on the card the clock does nothing. After an +// install the file gets FIRMWARE_DONE_SUFFIX appended to its name. +#define FIRMWARE_FILE_NAME "ESP32-S3-RLCD-Firmware.bin" +#define FIRMWARE_FILE_NAME_ALT "ESP32-S31-RLCD-Firmware.bin" // accepted too, in case of a typo +#define FIRMWARE_FILE_EXPORT "11_Clock_Dashboard.ino.bin" // what the Arduino IDE calls it +#define FIRMWARE_DONE_SUFFIX ".done" +// A freshly installed firmware is on trial: unless it has run this long, a reset puts the old one back. +#define FIRMWARE_TRIAL_MS 60000UL + +// The copy of the power log that the clock leaves in the root of an SD card which is in the slot while it +// starts (README: "Logging a battery run"); a file of that name on the card is replaced. +#define POWER_LOG_FILE_NAME "ESP32-S3-RLCD-PowerLog.csv" + +// secrets.h is optional: copy secrets.example.h to secrets.h to build your WiFi name and password in. +// Without it the clock starts with no network and takes them from the SD card settings file. +// (The host tests define CONFIG_NO_SECRETS, so that a secrets.h lying around cannot get into them.) +#if __has_include("secrets.h") && !defined(CONFIG_NO_SECRETS) +#include "secrets.h" +#endif + +// ---------------------------------------------------------------------------- +// Location: used for the weather and to work out the time zone. +// +// The device asks Open-Meteo for the IANA zone name of these coordinates +// (e.g. "Australia/Perth") and turns it into a POSIX TZ rule, so daylight +// saving changes are handled on the device without further network calls. +// +// - Exact coordinates win. Any maps app will give you these. +// - Otherwise LOCATION_QUERY is looked up once (Open-Meteo geocoder) and the +// result is remembered: "Perth", "Paris, France", ... +// - LOCATION_LABEL is what the status bar shows (empty = the resolved name). +// +// Put your own in secrets.h (#define LOCATION_LATITUDE ...) or in the SD card settings file. +// ---------------------------------------------------------------------------- +#ifndef LOCATION_LATITUDE +#define LOCATION_LATITUDE 0 +#endif +#ifndef LOCATION_LONGITUDE +#define LOCATION_LONGITUDE 0 +#endif +#ifndef LOCATION_QUERY +#define LOCATION_QUERY "" +#endif +#ifndef LOCATION_LABEL +#define LOCATION_LABEL "" +#endif + +// Optional time zone that skips automatic detection: an IANA name ("Australia/Perth") or a +// POSIX rule ("AWST-8"). Leave empty to use the zone of the location above. +#ifndef TIMEZONE_POSIX_OVERRIDE +#define TIMEZONE_POSIX_OVERRIDE "" +#endif + +// ---------------------------------------------------------------------------- +// Units and formats +// ---------------------------------------------------------------------------- +#ifndef USE_FAHRENHEIT +#define USE_FAHRENHEIT 0 // 0 = degC and km/h, 1 = degF and mph +#endif +#ifndef TIME_FORMAT +#define TIME_FORMAT "24h" // "24h" or "12h" (12h shows AM or PM) +#endif +// "iso" (2026-10-04), "dmy" (04/10/2026), "mdy" (10/04/2026), "dmy-dot" (04.10.2026), +// "d-mon-y" (4 Oct 2026) or "mon-d-y" (Oct 4, 2026) +#ifndef DATE_FORMAT +#define DATE_FORMAT "iso" +#endif +#ifndef SHOW_WEEK +#define SHOW_WEEK 1 // 1 = the ISO week number and the day of the year after the weekday +#endif + +// ---------------------------------------------------------------------------- +// WiFi and network time +// ---------------------------------------------------------------------------- +// 0 keeps the radio off: no network time, weather or Spotify, and less power drawn. Without network +// time the clock runs on its own crystal and drifts (README: "Running without WiFi"). +#ifndef WIFI_ENABLED +#define WIFI_ENABLED 1 +#endif + +// "normal" or "max": how long the radio sleeps between messages. Max draws a little less, but every +// reply can come up to a third of a second late, the network time included. The Spotify setup +// page always runs in normal. +#ifndef WIFI_POWER_SAVE +#define WIFI_POWER_SAVE "normal" +#endif + +// "always" keeps the radio connected. "sync" switches it off between syncs: it wakes for the network time (hourly) +// and the weather (every WEATHER_INTERVAL_MIN), looks at Spotify once each time, and stays on while music plays +// (see SPOTIFY_LIVE), after a key press and on the Now Playing page. When none of its networks is in range it looks +// again every 2 minutes, later every 5, and at once when a button is pressed. Far less power on a battery, see the +// README ("Saving power"). Not yet tried on the board. +#ifndef WIFI_MODE +#define WIFI_MODE "always" +#endif + +// The clock's name on the network: letters, digits and '-'. The Spotify setup page is http://.local +#ifndef APP_HOSTNAME +#define APP_HOSTNAME "rlcd-clock" +#endif + +// ---------------------------------------------------------------------------- +// Time servers +// ---------------------------------------------------------------------------- +// NTP_SERVER is tried first, the three below follow ("" = just those three). Also the "ntp_server" setting. +#ifndef NTP_SERVER +#define NTP_SERVER "" +#endif +#define NTP_SERVER_1 "pool.ntp.org" +#define NTP_SERVER_2 "time.cloudflare.com" +#define NTP_SERVER_3 "time.google.com" + +// ---------------------------------------------------------------------------- +// Clock face +// ---------------------------------------------------------------------------- +// 0 = the second hand ticks once per second, exactly on the second. +// >0 = redraw this many times per second so the second hand sweeps smoothly. +// Uses noticeably more power; 5-10 is plenty. +#define CLOCK_SWEEP_FPS 0 + +// The next second is drawn ahead of time and sent so that the transfer ends this long +// before the second starts. The panel refreshes itself at about 25 Hz (every 39 ms), +// so a frame shows up 0-39 ms after it has been sent; half of that makes the average +// error zero. Raise it if the digits seem to change late, lower it if early. +#define DISPLAY_LATENCY_MS 20 + +// ---------------------------------------------------------------------------- +// Display polarity +// ---------------------------------------------------------------------------- +// 1 = black ink on a white background (what you want on a reflective LCD). +// If your panel shows the opposite, set this to 0. You can also flip it +// without reflashing: hold the BOOT button for one second (remembered). +#define DISPLAY_INK_IS_BLACK 1 + +// ---------------------------------------------------------------------------- +// Indoor sensor (SHTC3) +// ---------------------------------------------------------------------------- +// The sensor sits next to the ESP32, the charger and the display driver, so it +// reads warmer than the room. Waveshare's own driver subtracts 4 degC; tune +// this against a thermometer you trust (or with "indoor_offset" on the SD card). +// Humidity is corrected for the same offset (relative humidity falls as air warms). +#ifndef INDOOR_TEMP_OFFSET_C +#define INDOOR_TEMP_OFFSET_C (-4.0f) +#endif +#define SENSOR_INTERVAL_MS 10000UL + +// ---------------------------------------------------------------------------- +// Battery +// ---------------------------------------------------------------------------- +#define BATTERY_LOW_PERCENT 20 // below this the gauge blinks (unless it is charging) +#define BATTERY_LOW_CLEAR_PERCENT 23 // ... and keeps blinking until it climbs back to this +#define BATTERY_INTERVAL_MS 5000UL + +// "auto" or "none". Say "none" when no battery is fitted (running from USB): the gauge then shows USB, +// nothing is estimated and the clock never shuts down for a low battery. The clock cannot tell by +// itself: on USB the empty battery socket reads like a full battery. +#ifndef BATTERY_MODE +#define BATTERY_MODE "auto" +#endif + +// 1 = shut down (deep sleep, with a message on the screen) before the battery is flat, so a LiPo is not +// ruined. The clock wakes by itself once charging has brought the battery back, or on a button press. +// BATTERY_CUTOFF_V is the voltage, as measured while the clock runs, at which it shuts down (3.10 to 3.60). +#ifndef LOW_BATTERY_SHUTDOWN +#define LOW_BATTERY_SHUTDOWN 1 +#endif +#ifndef BATTERY_CUTOFF_V +#define BATTERY_CUTOFF_V 3.30f +#endif + +// Capacity of your battery in mAh, so the Power and settings page can show the average current it +// draws (0 = unknown; the runtime estimate does not need it). Also a setting on the SD card +// (battery_capacity_mah). Put your own value in secrets.h. +#ifndef BATTERY_CAPACITY_MAH +#define BATTERY_CAPACITY_MAH 0 +#endif + +// Correction for the battery voltage the clock measures, as a multiplier (1 = none). The ADC and the +// 1:3 divider read a little low on many boards: a full cell (4.20 V on a LiPo tester) shows as +// 4.13 V, so the gauge stops at 93 % and the clock never sees the cell as full. The factor is +// the real voltage divided by what the Power and settings page shows (4.20 / 4.13 = 1.017); the +// serial command "batcal 4.20" works it out. Also a setting on the SD card (battery_calibration). +// Put your own value in secrets.h. +#ifndef BATTERY_CALIBRATION +#define BATTERY_CALIBRATION 1.0f +#endif + +// The board has no software-readable charge signal, so charging / discharging / full +// is inferred from how the battery voltage moves (charge.h): it needs about a minute +// to notice the cable being plugged or pulled and about thirteen after a reboot. For a +// certain answer, wire the charger's STAT output (the net that lights the charge LED +// on the board) to a free GPIO and put that pin number here. UNTESTED on real +// hardware: check with a meter first that the pin goes LOW while charging and is +// high or floating otherwise. -1 = not wired, use the voltage. +#define PIN_CHARGE_STATUS -1 + +// ---------------------------------------------------------------------------- +// Power +// ---------------------------------------------------------------------------- +// CPU clock in MHz: 80, 160 or 240. 80 uses the least power and is plenty for a clock. (WiFi needs 80 +// at least.) The PSRAM switch is a build option of the Arduino IDE and cannot be set here. +#ifndef CPU_MHZ +#define CPU_MHZ 80 +#endif +// The clock while the radio is off (WIFI_ENABLED 0, or between the syncs of WIFI_MODE "sync"): 0 = the same as CPU_MHZ, +// or 80, 40 or 20. Slower draws less, and a frame takes longer to draw (about 30 ms at 80 MHz, 120 at 20). 40 is the +// lowest clock Espressif gives current figures for (13 mA idle against 22 at 80), so try that first. Not yet tried on +// the board: below 80 MHz the clock of some peripherals follows the CPU, and the USB console may stop, so the clock +// stays at CPU_MHZ for as long as a computer is on the USB port and the console runs. +#ifndef CPU_IDLE_MHZ +#define CPU_IDLE_MHZ 0 +#endif + +// The USB serial console (the log, and the commands of the README): "on", "auto" or "off". "auto" runs it while a +// computer is on the USB port and shuts it down, with the USB transceiver, when there has been none for 10 seconds; +// "off" shuts it down at the end of every start-up. It then stays off until the next restart, and the USB port is +// dead: to upload a firmware restart the clock with KEY held down (the console stays on for that run), or hold BOOT +// while switching it on, or use the SD card. Saves a little power. Not yet tried on the board. +#ifndef CONSOLE_MODE +#define CONSOLE_MODE "on" +#endif + +// 1 = the clock keeps a log of its own power readings (the battery, the runtime estimate, the CPU clock, the radio's +// share of the time): one line of CSV at each start and every 10 minutes, in the FAT partition of its flash, the +// newest 0.5 to 1 MB of them. A start with an SD card in copies the log to the card (POWER_LOG_FILE_NAME), and the +// serial command "powerlog" prints it. The first time, the partition is formatted. A line costs a fraction of a +// second of flash writing (README: "Logging a battery run"). Not yet tried on the board. +#ifndef POWER_LOG +#define POWER_LOG 0 +#endif + +// The audio chips on the board (a codec, a microphone ADC, an amplifier) are not used by the clock. 1 = at +// start-up they are told to power down over I2C, with the sequences Waveshare's own audio example uses when it +// closes them, and the I2S lines to them are held low. 0 = they are left as they come out of power-on (the +// amplifier is held off either way). Not yet tried on the board. +#ifndef AUDIO_CHIPS_STANDBY +#define AUDIO_CHIPS_STANDBY 1 +#endif + +// ---------------------------------------------------------------------------- +// Weather +// ---------------------------------------------------------------------------- +#ifndef WEATHER_INTERVAL_MIN +#define WEATHER_INTERVAL_MIN 15 // minutes, 5 to 240; Open-Meteo refreshes every 15 min +#endif +#define WEATHER_RETRY_MS (60UL * 1000UL) + +// ---------------------------------------------------------------------------- +// Spotify (needs a Client ID: SPOTIFY_CLIENT_ID in secrets.h, or "spotify_client_id" on the SD card) +// ---------------------------------------------------------------------------- +#ifndef SPOTIFY_ENABLED +#define SPOTIFY_ENABLED 1 // 0 = off, even with a Client ID +#endif +// Only matters with WIFI_MODE "sync". 1 = while music plays the radio stays on and the clock asks Spotify every +// 10 seconds. 0 = the radio sleeps while music plays too and the clock looks again when the track on screen should +// be over: far less radio, but a track skipped or paused on a phone shows late. Not yet tried on the board. +#ifndef SPOTIFY_LIVE +#define SPOTIFY_LIVE 1 +#endif + +// Spotify gives development-mode apps a small, unpublished request quota; people +// polling every 3 s around the clock have been locked out for hours. These +// intervals stay well inside it. The progress bar is extrapolated between polls +// and a button press always triggers an immediate refresh. +#define SPOTIFY_POLL_PLAYING_MS 10000UL +#define SPOTIFY_POLL_PAUSED_MS 15000UL +#define SPOTIFY_POLL_IDLE_MS 30000UL // doubled after ~30 minutes of nothing playing + +// KEY button timing +#define KEY_MULTI_CLICK_GAP_MS 320 // pause after the last click before the count is acted on +#define KEY_LONG_PRESS_MS 800 +#define KEY_RESTART_HOLD_MS 5000 // KEY held this long, then let go: the clock restarts (and so reads the SD card) +#define BOOT_LONG_PRESS_MS 1000 + +// ---------------------------------------------------------------------------- +// Board pins (Waveshare ESP32-S3-RLCD-4.2) - fixed by the hardware +// ---------------------------------------------------------------------------- +#define PIN_LCD_SCK 11 +#define PIN_LCD_MOSI 12 +#define PIN_LCD_DC 5 +#define PIN_LCD_CS 40 +#define PIN_LCD_RST 41 +#define PIN_I2C_SDA 13 +#define PIN_I2C_SCL 14 +#define PIN_BATTERY_ADC 4 // battery through a 1:3 divider +#define PIN_KEY 18 // user key, active low +#define PIN_BOOT 0 // BOOT key, active low +#define PIN_SD_CLK 38 // SD card slot, SDMMC in 1-bit mode (same wiring as the 06_SD_Card example) +#define PIN_SD_CMD 21 +#define PIN_SD_D0 39 + +// The audio section, which the clock does not use: the amplifier's enable and the I2S lines that go out to the chips +// (the same numbers as Waveshare's board file for this board in 02_Example/XiaoZhi) +#define PIN_AUDIO_AMP 46 // amplifier enable, high = on +#define PIN_I2S_MCLK 16 +#define PIN_I2S_BCLK 9 +#define PIN_I2S_WS 45 +#define PIN_I2S_DOUT 8 // data to the codec (the line from it, GPIO10, is the codec's to drive) + +#define BATTERY_DIVIDER 3.0f +#define I2C_ADDR_SHTC3 0x70 +#define I2C_ADDR_PCF85063 0x51 +#define I2C_ADDR_ES8311 0x18 // audio codec +#define I2C_ADDR_ES7210 0x40 // microphone ADC + +// ---------------------------------------------------------------------------- +// Credentials (from secrets.h, included above) +// ---------------------------------------------------------------------------- +#ifndef WIFI_SSID +#define WIFI_SSID "" +#endif +#ifndef WIFI_PASSWORD +#define WIFI_PASSWORD "" +#endif +// An optional second network, used when the first cannot be joined. +#ifndef WIFI_SSID_BACKUP +#define WIFI_SSID_BACKUP "" +#endif +#ifndef WIFI_PASSWORD_BACKUP +#define WIFI_PASSWORD_BACKUP "" +#endif +#ifndef SPOTIFY_CLIENT_ID +#define SPOTIFY_CLIENT_ID "" +#endif diff --git a/02_Example/Arduino/11_Clock_Dashboard/console_policy.h b/02_Example/Arduino/11_Clock_Dashboard/console_policy.h new file mode 100644 index 00000000..1b382170 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/console_policy.h @@ -0,0 +1,44 @@ +#pragma once + +// When the USB serial console (the log and the commands) is shut down: "console = on | auto | off". +// +// on never. +// auto when no computer has been on the USB port for kNoHostMs, counted from start-up: a clock on a battery +// or a charger drops the console ten seconds after it started, one on a computer keeps it until the +// cable is pulled. +// off at once, which is the end of start-up (the first time round the loop). +// +// Once shut down it stays down until the next restart: with the USB transceiver off there is no way to see a +// computer arrive. A restart with KEY held down keeps the console for that run whatever the setting (`keep`), +// so a clock set to "off" can still be talked to and flashed without an SD card. +// +// Pure logic with no Arduino dependency; the sketch does the shutting down (log.cpp). + +#include + +namespace consolepolicy { + +enum Mode : uint8_t { ON = 0, AUTO, OFF }; // the values of the "console" setting (ConsoleMode in settings.h) + +const uint32_t kNoHostMs = 10000; + +class Watch { + public: + // Call every time round the loop while the console runs. `hostNow`: a computer is on the USB port right + // now. True: shut the console down now. + bool shouldStop(int mode, bool keep, bool hostNow, uint32_t nowMs) { + if (!started_ || hostNow) { // the count starts with the first call, and again whenever a computer is seen + started_ = true; + lastHostMs_ = nowMs; + } + if (keep || mode == ON) return false; + if (mode == OFF) return true; + return (uint32_t)(nowMs - lastHostMs_) >= kNoHostMs; + } + + private: + bool started_ = false; + uint32_t lastHostMs_ = 0; +}; + +} // namespace consolepolicy diff --git a/02_Example/Arduino/11_Clock_Dashboard/datefmt.h b/02_Example/Arduino/11_Clock_Dashboard/datefmt.h new file mode 100644 index 00000000..8336c2a0 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/datefmt.h @@ -0,0 +1,46 @@ +#pragma once + +// Date and time text in the formats the settings offer. Header-only and free of Arduino +// dependencies so tools/tests and the UI preview can use it. + +#include +#include +#include + +#include "settings.h" // DateFormat +#include "timeutil.h" // monthShort + +namespace datefmt { + +// The 12 hour clock face: 0 -> 12 AM, 1 -> 1 AM, 12 -> 12 PM, 13 -> 1 PM, 23 -> 11 PM. +inline int hour12(int hour24, bool *pm) { + const int h = ((hour24 % 24) + 24) % 24; + if (pm) *pm = h >= 12; + const int h12 = h % 12; + return h12 == 0 ? 12 : h12; +} + +// month 1..12. Numeric parts are zero padded to keep a column's width fixed, except in the +// formats with a month name, where "4 Oct 2026" is how people write it. +// DATE_ISO 2026-10-04 +// DATE_DMY 04/10/2026 +// DATE_MDY 10/04/2026 +// DATE_DMY_DOT 04.10.2026 +// DATE_D_MON_Y 4 Oct 2026 +// DATE_MON_D_Y Oct 4, 2026 +inline void formatDate(int year, int month, int day, uint8_t format, char *out, size_t cap) { + switch (format) { + case DATE_DMY: snprintf(out, cap, "%02d/%02d/%04d", day, month, year); break; + case DATE_MDY: snprintf(out, cap, "%02d/%02d/%04d", month, day, year); break; + case DATE_DMY_DOT: snprintf(out, cap, "%02d.%02d.%04d", day, month, year); break; + case DATE_D_MON_Y: snprintf(out, cap, "%d %s %04d", day, timeutil::monthShort(month - 1), year); break; + case DATE_MON_D_Y: snprintf(out, cap, "%s %d, %04d", timeutil::monthShort(month - 1), day, year); break; + case DATE_ISO: + default: snprintf(out, cap, "%04d-%02d-%02d", year, month, day); break; + } +} + +// "9:45:21 PM" style pieces for the 12 hour layout. +inline const char *ampm(bool pm) { return pm ? "PM" : "AM"; } + +} // namespace datefmt diff --git a/02_Example/Arduino/11_Clock_Dashboard/docs/ESP32-S3-RLCD-Config.example.txt b/02_Example/Arduino/11_Clock_Dashboard/docs/ESP32-S3-RLCD-Config.example.txt new file mode 100644 index 00000000..ec931c40 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/docs/ESP32-S3-RLCD-Config.example.txt @@ -0,0 +1,191 @@ +# ESP32-S3 RLCD clock: settings file +# +# The clock found no settings file on this card, so it wrote this one. Every setting +# below is commented out (it starts with #) and shows the value the clock uses now. +# To change one: remove the # in front of its line, edit the value, save the file, +# put the card back in the clock and restart it. The clock saves what it reads in its +# own flash, so the card can be taken out again afterwards. +# +# The rules +# * One "name = value" per line. Spaces around the = do not matter. +# * Values can be in quotes or not: "My Network" and My Network mean the same. +# Use quotes if a value contains a # or begins or ends with a space. +# * Names ignore case, spaces, - and _: wifi_ssid, WiFi SSID and Wi-Fi-SSID are one setting. +# * Lines starting with # or ; are comments. +# * A setting you leave out keeps whatever the clock has now. +# * A line with nothing after the = (for example "timezone =") makes the clock forget +# that saved setting and go back to its built-in default. +# * reset_all = yes forgets every saved setting first; remove that line afterwards. +# * What the clock does not understand is ignored and listed on its Power and settings +# page (the second info page). +# +# WiFi passwords are plain text in this file and in the clock's flash: keep the card +# to yourself. + +# ---- WiFi -------------------------------------------------------- +# +# on or off. Off keeps the radio off: no network time, weather or Spotify, and less +# power drawn. Without network time the clock runs on its own crystal and drifts +# (see the note at the end of this file). +# wifi = on +# +# Name of your WiFi network (2.4 GHz only). +# wifi_ssid = "" +# +# Its password. Kept as plain text here and in the clock's flash. For an open +# network write two quotes: wifi_password = "" +# wifi_password = "your password" +# +# A second network, for when the first cannot be joined (a phone's hotspot, another +# router). The clock tries the main network first. While it is on the backup it looks +# for the main one every 10 minutes and goes back to it. Empty = no backup. +# wifi_backup_ssid = "" +# +# Its password (for an open network write two quotes). +# wifi_backup_password = "your password" +# +# The clock's name on your network: letters, digits and '-'. +# hostname = "rlcd-clock" +# +# Time server to try first (pool.ntp.org, time.cloudflare.com and time.google.com +# follow). Empty = the built-in list. +# ntp_server = "" +# +# normal or max. Max lets the radio sleep longer between messages: a little less +# power, but every reply can come up to a third of a second late, the network time +# included (look at "last step" on the Info page). The Spotify setup page always +# runs in normal. +# wifi_power_save = normal +# +# always or sync. Always keeps the radio connected. Sync switches it off between syncs: it +# wakes for the network time (hourly) and the weather (every weather_interval_min), looks at +# Spotify once each time, and stays on while music plays (see spotify_live), after a key press +# and on the Now Playing page. Draws far less on a battery; the Spotify strip and the link page +# need that page or a key press to wake the radio. When none of its networks is in range the +# clock looks again every 2 minutes, later every 5, and at once when a button is pressed. +# See the README. +# wifi_mode = always + +# ---- Units and formats ------------------------------------------- +# +# metric (degrees C, km/h) or imperial (degrees F, mph). +# units = metric +# +# 24h or 12h (12h shows AM or PM). +# time_format = 24h +# +# iso (2026-10-04), dmy (04/10/2026), mdy (10/04/2026), dmy-dot (04.10.2026), +# d-mon-y (4 Oct 2026) or mon-d-y (Oct 4, 2026). +# date_format = iso +# +# on or off. Shows the ISO week number and the day of the year after the weekday. +# show_week = on + +# ---- Location ---------------------------------------------------- +# +# Exact coordinates of the clock, in degrees: -31.952 and 115.861, or 31.952 S and +# 115.861 E. They win over the place name below. +# latitude = 0 +# longitude = 0 +# +# What the status bar shows. Empty = the name that was found. +# location_label = "" +# +# A place to look up (Open-Meteo), such as Perth or "Paris, France". Used when no +# coordinates are set. +# location = "" +# +# Overrides the automatic time zone: an IANA name (Australia/Perth) or a POSIX rule +# (AWST-8). Empty = follow the location, which needs WiFi: with wifi = off set it here. +# timezone = "" + +# ---- Spotify ----------------------------------------------------- +# +# on or off. Spotify also needs the Client ID below. +# spotify = on +# +# The Client ID of your app in the Spotify developer dashboard (see the README). +# spotify_client_id = "your client id" +# +# on or off. Only matters with wifi_mode = sync. On: while music plays the radio stays on +# and the clock asks Spotify every 10 seconds, so the strip follows your phone within seconds. +# Off: the radio sleeps while music plays too, and the clock looks again when the track on +# screen should be over. Far less radio, but a track skipped or paused on a phone shows late: +# when it would have ended, or at the next weather update. KEY presses and the Now Playing +# page are live either way. +# spotify_live = on + +# ---- Sensors and battery ----------------------------------------- +# +# Degrees C added to the indoor temperature to make up for the board warming the +# sensor (the factory value is -4.0). +# indoor_offset = -4 +# +# auto or none. Say none when no battery is fitted (running from USB): the gauge +# then shows USB, nothing is estimated and the clock never shuts down for a low +# battery. The clock cannot tell by itself: on USB the empty battery socket reads +# like a full battery. +# battery = auto +# +# Capacity of your battery in mAh, so the Power and settings page can show the +# average current. 0 = unknown. +# battery_capacity_mah = 0 +# +# Corrects the battery voltage the clock measures: the real voltage (from a LiPo tester +# or a meter) divided by what the Power and settings page shows. 1 = no correction. +# Example: the page says 4.13 V while the tester says 4.20 V, so 4.20 / 4.13 = 1.017. +# Or type batcal 4.20 in the serial console: the clock works it out and keeps it. +# battery_calibration = 1 +# +# on or off. Shut down (deep sleep, with a message on the screen) before the battery +# is flat, so a LiPo is not ruined. It wakes by itself once charging has brought the +# battery back, or when you press a button. +# low_battery_shutdown = on +# +# Battery voltage, as measured while the clock runs, at which it shuts down: +# 3.10 to 3.60 (default 3.30). +# battery_cutoff_v = 3.3 + +# ---- Power ------------------------------------------------------- +# +# 80, 160 or 240. 80 uses the least power and is plenty for a clock. +# cpu_mhz = 80 +# +# off, 80, 40 or 20: the CPU clock while the radio is off (wifi = off, or between the syncs +# of wifi_mode = sync). Slower draws less and a frame takes longer to draw: about 30 ms at +# 80, 60 at 40, 120 at 20. Try 40 first: it is the lowest clock the chip maker gives figures +# for. The clock goes back to cpu_mhz while the radio is on, for a few seconds after a button +# press, and while a computer is on the USB port with the console running (the console may +# stop below 80 MHz). off = no change. +# cpu_idle_mhz = off +# +# on, auto or off: the USB serial console (the log and the commands). On keeps it running. +# Auto runs it while a computer is on the USB port and shuts it down, with the USB +# transceiver, once there has been none for 10 seconds; it then stays off until the clock is +# restarted with the cable in. Off shuts it down at the end of every start-up, computer or +# not. A console that is shut down saves a little power, and the USB port is dead: to upload +# a firmware, restart the clock with KEY held down (the console then stays on for that run), +# or hold BOOT while switching it on, or use the SD card. +# console = on +# +# on or off. On: the clock writes its own power readings (the battery, the runtime estimate, +# the CPU clock, the radio's share of the time) into its flash as one line of CSV at each start +# and every 10 minutes, for comparing settings by how fast the battery goes down. A start +# with an SD card in copies the log to the card as ESP32-S3-RLCD-PowerLog.csv. The newest +# 0.5 to 1 MB are kept (weeks). The first time, the flash partition for it is formatted. +# See the README. +# power_log = off + +# ---- Weather ----------------------------------------------------- +# +# Minutes between weather updates, 5 to 240 (default 15). +# weather_interval_min = 15 + +# ---- Clock drift when WiFi is off --------------------------------- +# +# With wifi = off the clock cannot ask the network for the time. It starts from the +# time kept by its real-time clock chip and then counts with its own crystal, which +# is good for a second or two a day (10 ppm is about 0.9 seconds a day) and depends on +# temperature. Over weeks that adds up to a visible error; nothing corrects it until +# WiFi has been on for a few minutes again. If you leave WiFi off, switch it on now +# and then to let the clock set itself. diff --git a/02_Example/Arduino/11_Clock_Dashboard/docs/battery-empty.png b/02_Example/Arduino/11_Clock_Dashboard/docs/battery-empty.png new file mode 100644 index 00000000..53598425 Binary files /dev/null and b/02_Example/Arduino/11_Clock_Dashboard/docs/battery-empty.png differ diff --git a/02_Example/Arduino/11_Clock_Dashboard/docs/battery-states.png b/02_Example/Arduino/11_Clock_Dashboard/docs/battery-states.png new file mode 100644 index 00000000..27c46eaa Binary files /dev/null and b/02_Example/Arduino/11_Clock_Dashboard/docs/battery-states.png differ diff --git a/02_Example/Arduino/11_Clock_Dashboard/docs/dashboard-12h.png b/02_Example/Arduino/11_Clock_Dashboard/docs/dashboard-12h.png new file mode 100644 index 00000000..8ea620da Binary files /dev/null and b/02_Example/Arduino/11_Clock_Dashboard/docs/dashboard-12h.png differ diff --git a/02_Example/Arduino/11_Clock_Dashboard/docs/dashboard-idle.png b/02_Example/Arduino/11_Clock_Dashboard/docs/dashboard-idle.png new file mode 100644 index 00000000..eee98196 Binary files /dev/null and b/02_Example/Arduino/11_Clock_Dashboard/docs/dashboard-idle.png differ diff --git a/02_Example/Arduino/11_Clock_Dashboard/docs/dashboard.png b/02_Example/Arduino/11_Clock_Dashboard/docs/dashboard.png new file mode 100644 index 00000000..d3597000 Binary files /dev/null and b/02_Example/Arduino/11_Clock_Dashboard/docs/dashboard.png differ diff --git a/02_Example/Arduino/11_Clock_Dashboard/docs/firmware-update.png b/02_Example/Arduino/11_Clock_Dashboard/docs/firmware-update.png new file mode 100644 index 00000000..a3623f6f Binary files /dev/null and b/02_Example/Arduino/11_Clock_Dashboard/docs/firmware-update.png differ diff --git a/02_Example/Arduino/11_Clock_Dashboard/docs/info.png b/02_Example/Arduino/11_Clock_Dashboard/docs/info.png new file mode 100644 index 00000000..c0462346 Binary files /dev/null and b/02_Example/Arduino/11_Clock_Dashboard/docs/info.png differ diff --git a/02_Example/Arduino/11_Clock_Dashboard/docs/key-feedback.png b/02_Example/Arduino/11_Clock_Dashboard/docs/key-feedback.png new file mode 100644 index 00000000..0e52e5fa Binary files /dev/null and b/02_Example/Arduino/11_Clock_Dashboard/docs/key-feedback.png differ diff --git a/02_Example/Arduino/11_Clock_Dashboard/docs/legend.png b/02_Example/Arduino/11_Clock_Dashboard/docs/legend.png new file mode 100644 index 00000000..7c14c26f Binary files /dev/null and b/02_Example/Arduino/11_Clock_Dashboard/docs/legend.png differ diff --git a/02_Example/Arduino/11_Clock_Dashboard/docs/low-battery.png b/02_Example/Arduino/11_Clock_Dashboard/docs/low-battery.png new file mode 100644 index 00000000..17a3c866 Binary files /dev/null and b/02_Example/Arduino/11_Clock_Dashboard/docs/low-battery.png differ diff --git a/02_Example/Arduino/11_Clock_Dashboard/docs/now-playing.png b/02_Example/Arduino/11_Clock_Dashboard/docs/now-playing.png new file mode 100644 index 00000000..07648904 Binary files /dev/null and b/02_Example/Arduino/11_Clock_Dashboard/docs/now-playing.png differ diff --git a/02_Example/Arduino/11_Clock_Dashboard/docs/power-log.csv b/02_Example/Arduino/11_Clock_Dashboard/docs/power-log.csv new file mode 100644 index 00000000..b121d44a --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/docs/power-log.csv @@ -0,0 +1,6 @@ +test,firmware,wifi,wifi_mode,wifi_power_save,cpu_idle_mhz,spotify_live,console,battery_capacity_mah,card,music,date,time,uptime,clock_shown,radio_on_pct,radio_sessions,battery_v,battery_pct,battery_state,left,pct_per_hour,over_min,clock_ma,meter_ma,free_kb,frames_sent,frames_late,frames_worst_ms,notes +1 stock wifi,,on,always,normal,off,on,on,,,,,,,,,,,,,,,,,,,,,, +2 stock no wifi,,off,always,normal,off,on,on,,,,,,,,,,,,,,,,,,,,,, +3 middle,,on,sync,max,40,on,auto,,,,,,,,,,,,,,,,,,,,,, +4 20 MHz wifi,,on,sync,max,20,off,auto,,,,,,,,,,,,,,,,,,,,,, +5 20 MHz no wifi,,off,sync,max,20,off,auto,,,,,,,,,,,,,,,,,,,,,, diff --git a/02_Example/Arduino/11_Clock_Dashboard/docs/power.png b/02_Example/Arduino/11_Clock_Dashboard/docs/power.png new file mode 100644 index 00000000..552b0691 Binary files /dev/null and b/02_Example/Arduino/11_Clock_Dashboard/docs/power.png differ diff --git a/02_Example/Arduino/11_Clock_Dashboard/docs/weather-band.png b/02_Example/Arduino/11_Clock_Dashboard/docs/weather-band.png new file mode 100644 index 00000000..0464eb49 Binary files /dev/null and b/02_Example/Arduino/11_Clock_Dashboard/docs/weather-band.png differ diff --git a/02_Example/Arduino/11_Clock_Dashboard/drift.h b/02_Example/Arduino/11_Clock_Dashboard/drift.h new file mode 100644 index 00000000..d937a6af --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/drift.h @@ -0,0 +1,148 @@ +#pragma once + +// Measures how far the clock's crystal runs from true time, from the corrections that network +// time syncs make. The microsecond timer (esp_timer_get_time) counts on the crystal and is never +// adjusted; the system clock is that timer plus an offset K that each sync resets to the right +// time. So K_i - K_(i-1), the size of a sync's correction, divided by the time between them is the +// crystal's error rate, and a line through many (timer, K) points averages the network jitter +// (tens of milliseconds a sync) down to a fraction of a ppm over a day. +// +// ppm > 0: the clock runs fast (gains), ppm < 0: slow (loses). 1 ppm = 0.0864 s a day. +// +// This is what the clock would drift by with WiFi off. Header-only and free of Arduino +// dependencies so tools/tests can check it on a PC. + +#include +#include + +namespace drift { + +const int kMaxPoints = 48; +const int kMinPoints = 4; +const double kMinSpanSec = 3.0 * 3600.0; // no figure before this much history +const int64_t kMinGapUs = 30LL * 60 * 1000000; // one point per half hour at most + +struct Result { + bool valid = false; + float ppm = 0; // positive: the clock gains + float errorPpm = 0; // one standard error of that figure + float secPerDay = 0; // the same as seconds per day (positive: gains) + float spanHours = 0; // how much history it rests on + int points = 0; +}; + +class Tracker { + public: + void reset() { n_ = 0; } + + // Call right after a time sync has set the clock: monoUs = esp_timer_get_time(), sysUs = + // the system clock in microseconds since 1970, both read together. + void addSync(int64_t monoUs, int64_t sysUs) { + if (n_ > 0 && monoUs <= mono_[n_ - 1]) { // the timer restarted: the old points mean nothing + reset(); + } + if (n_ == 0) { + mono0_ = monoUs; + k0_ = sysUs - monoUs; + } else if (monoUs - mono_[n_ - 1] < kMinGapUs) { + return; + } + if (n_ == kMaxPoints) { + for (int i = 1; i < n_; i++) { + mono_[i - 1] = mono_[i]; + k_[i - 1] = k_[i]; + } + n_--; + } + mono_[n_] = monoUs; + k_[n_] = (sysUs - monoUs) - k0_; + n_++; + } + + Result result() const { + Result r; + r.points = n_; + if (n_ < kMinPoints) return r; + double t[kMaxPoints], y[kMaxPoints]; + for (int i = 0; i < n_; i++) { + t[i] = (double)(mono_[i] - mono0_) / 1e6; // seconds + y[i] = (double)k_[i] / 1e6; // seconds + } + const double span = t[n_ - 1] - t[0]; + if (span < kMinSpanSec) return r; + + bool keep[kMaxPoints]; + for (int i = 0; i < n_; i++) keep[i] = true; + double slope = 0, se = 0; + int kept = n_; + for (int pass = 0; pass < 2; pass++) { + double mt = 0, my = 0; + for (int i = 0; i < n_; i++) + if (keep[i]) { + mt += t[i]; + my += y[i]; + } + mt /= kept; + my /= kept; + double sxx = 0, sxy = 0; + for (int i = 0; i < n_; i++) + if (keep[i]) { + sxx += (t[i] - mt) * (t[i] - mt); + sxy += (t[i] - mt) * (y[i] - my); + } + if (sxx <= 0) return r; + slope = sxy / sxx; + double ss = 0; + double res[kMaxPoints]; + int m = 0; + for (int i = 0; i < n_; i++) + if (keep[i]) { + const double e = y[i] - (my + slope * (t[i] - mt)); + ss += e * e; + res[m++] = fabs(e); + } + se = kept > 2 ? sqrt(ss / (kept - 2) / sxx) : 0; + if (pass == 1) break; + // one round of outlier rejection: a sync that landed on a slow network path + for (int i = 1; i < m; i++) { + const double v = res[i]; + int j = i - 1; + while (j >= 0 && res[j] > v) { + res[j + 1] = res[j]; + j--; + } + res[j + 1] = v; + } + const double mad = (m & 1) ? res[m / 2] : 0.5 * (res[m / 2 - 1] + res[m / 2]); + double limit = 3.0 * 1.4826 * mad; + if (limit < 0.025) limit = 0.025; // 25 ms: NTP over WiFi is no better than that + int dropped = 0; + for (int i = 0; i < n_; i++) + if (keep[i]) { + const double e = fabs(y[i] - (my + slope * (t[i] - mt))); + if (e > limit) { + keep[i] = false; + dropped++; + } + } + if (dropped == 0 || kept - dropped < kMinPoints) break; + kept -= dropped; + } + r.valid = true; + r.ppm = (float)(-slope * 1e6); + r.errorPpm = (float)(se * 1e6); + r.secPerDay = (float)(-slope * 86400.0); + r.spanHours = (float)(span / 3600.0); + return r; + } + + int points() const { return n_; } + + private: + int64_t mono_[kMaxPoints]; + int64_t k_[kMaxPoints]; // relative to k0_, microseconds + int64_t mono0_ = 0, k0_ = 0; + int n_ = 0; +}; + +} // namespace drift diff --git a/02_Example/Arduino/11_Clock_Dashboard/frame_plan.h b/02_Example/Arduino/11_Clock_Dashboard/frame_plan.h new file mode 100644 index 00000000..b3766cf2 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/frame_plan.h @@ -0,0 +1,136 @@ +#pragma once + +// When to draw the coming second and when to send it to the panel. +// +// Drawing a frame takes tens of milliseconds and the time varies (cache misses, the +// WiFi stack taking the memory bus, a flash write on the other core), while sending +// it is short. So the coming second is drawn into the display buffer early, with +// room to spare, and sent just before it starts: the draw may run long without the +// digits changing late. The panel refreshes itself at about 25 Hz, so a frame shows +// up 0-39 ms after it has been sent; the send is timed to end `latencyUs` before the +// second starts, which puts the average error at zero. +// +// Pure logic with no Arduino dependency: the UI loop asks next() whenever it wakes up +// and does what it says; tools/tests drives it with simulated clocks and stalls. + +#include +#include + +class FramePlanner { + public: + enum Action : uint8_t { + IDLE, // nothing to do yet; slow work may run if quiet() allows + DRAW_NOW, // draw `sec`, then send it at once (something has to be on screen now) + PREPARE, // draw `sec` into the buffer, but do not send it yet + SEND, // wait `waitUs` (at most kSpinUs), then send the prepared frame + SLEEP, // the prepared frame is not due for `waitUs`: sleep a little and ask again + }; + struct Step { + Action action = IDLE; + time_t sec = 0; + int32_t waitUs = 0; + }; + + static constexpr int32_t kSpinUs = 5000; // waits shorter than this are spun, longer ones slept + static constexpr int32_t kMarginUs = 60000; // spare time between a frame being drawn and being due + static constexpr int32_t kQuietUs = 300000; // slow work only with more than this left before the tick + + explicit FramePlanner(int32_t latencyUs) : latencyUs_(latencyUs) {} + + // `sec` / `usec`: the clock now. `force`: something has to be on the glass immediately + // (a key press, a toast, a page change). + Step next(time_t sec, int32_t usec, bool force) { + const int32_t usToNext = 1000000 - usec; + const int32_t sendLead = (int32_t)sendCostUs_ + latencyUs_; + const int32_t drawLead = sendLead + (int32_t)drawCostUs_ + kMarginUs; + Step st; + + if (prepared_ && preparedSec_ > sec + 1) prepared_ = false; // the clock went backwards: start over + + if (force) { + // A frame drawn and sent now is on the glass `cost` from now. Close to a tick there would + // be no time left afterwards to prepare the next second the usual way, so show the second + // that is about to start instead (up to about 65 ms early beats a late second). + const int32_t cost = (int32_t)(drawCostUs_ + sendCostUs_); + st.action = DRAW_NOW; + st.sec = usToNext <= 2 * cost + latencyUs_ ? sec + 1 : sec; + return st; + } + if (prepared_) { + const int32_t waitUs = preparedSec_ > sec ? usToNext - sendLead : 0; // already due if its second has begun + st.waitUs = waitUs > 0 ? waitUs : 0; + st.action = waitUs > kSpinUs ? SLEEP : SEND; + return st; + } + if (lastSentSec_ != sec && lastSentSec_ != sec + 1) { // behind (start-up, a long stall): catch up at once + st.action = DRAW_NOW; + st.sec = sec; + return st; + } + if (usToNext <= drawLead && lastSentSec_ != sec + 1) { + st.action = PREPARE; + st.sec = sec + 1; + return st; + } + return st; // IDLE + } + + // May slow work (I2C sensors, battery) run now? Never close to a tick. + bool quiet(int32_t usec) const { return !prepared_ && 1000000 - usec > kQuietUs; } + + // The caller reports what it did. `scheduled`: the frame is meant to go out at its precise + // moment (as opposed to a forced redraw), so its timing is worth recording. + void drew(time_t sec, uint32_t costUs, bool scheduled) { + drawCostUs_ = (drawCostUs_ * 3 + clampCost(costUs)) / 4; + prepared_ = true; + preparedSec_ = sec; + preparedScheduled_ = scheduled; + } + void sent(uint32_t costUs) { + sendCostUs_ = (sendCostUs_ * 3 + clampCost(costUs)) / 4; + lastSentSec_ = preparedSec_; + prepared_ = false; + } + + // The CPU clock changed from `fromMhz` to `toMhz` (below 80 MHz everything takes longer: 30 ms to draw + // at 80 MHz is 120 ms at 20): what a draw and a send are expected to cost scales the other way round, so + // the first frame at the new clock is planned with the right lead. The ceiling on one measurement + // (a stall must not move the schedule) scales with it. + void clockChanged(uint32_t fromMhz, uint32_t toMhz) { + if (fromMhz == 0 || toMhz == 0 || fromMhz == toMhz) return; + drawCostUs_ = scaleCost(drawCostUs_, fromMhz, toMhz); + sendCostUs_ = scaleCost(sendCostUs_, fromMhz, toMhz); + mhz_ = toMhz; + } + uint32_t mhz() const { return mhz_; } + + bool prepared() const { return prepared_; } + time_t preparedSec() const { return preparedSec_; } + bool preparedScheduled() const { return preparedScheduled_; } + time_t lastSentSec() const { return lastSentSec_; } + uint32_t drawCostUs() const { return drawCostUs_; } + uint32_t sendCostUs() const { return sendCostUs_; } + int32_t latencyUs() const { return latencyUs_; } + + private: + // One freak measurement (a stall in the middle of a draw) must not move the schedule for long. (The + // ceiling is for the clocks of 80 MHz and up; below that a normal draw takes longer, and so does the ceiling.) + uint32_t clampCost(uint32_t us) const { + const uint32_t cap = mhz_ < 80 ? (uint32_t)((uint64_t)150000 * 80 / mhz_) : 150000; + return us > cap ? cap : us; + } + uint32_t scaleCost(uint32_t us, uint32_t fromMhz, uint32_t toMhz) const { + const uint64_t v = (uint64_t)us * fromMhz / toMhz; + const uint32_t cap = toMhz < 80 ? (uint32_t)((uint64_t)150000 * 80 / toMhz) : 150000; + return v > cap ? cap : (uint32_t)v; + } + + int32_t latencyUs_; + bool prepared_ = false; + bool preparedScheduled_ = false; + time_t preparedSec_ = 0; + time_t lastSentSec_ = 0; + uint32_t drawCostUs_ = 30000; + uint32_t sendCostUs_ = 15000; + uint32_t mhz_ = 80; // the CPU clock the costs are for +}; diff --git a/02_Example/Arduino/11_Clock_Dashboard/fw_logic.h b/02_Example/Arduino/11_Clock_Dashboard/fw_logic.h new file mode 100644 index 00000000..9f721e30 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/fw_logic.h @@ -0,0 +1,214 @@ +#pragma once + +// Updating the firmware from a file on the SD card: when a file is accepted and when it is left alone. +// Pure logic (no Arduino, no flash), so tools/tests covers it on a PC; fw_update.cpp does the card, the +// flash and the screen. +// +// The file is the app image the Arduino IDE exports (Sketch > Export Compiled Binary): .ino.bin. +// An ESP-IDF app image is a 24 byte header, then segments (the first starts with the app descriptor), then +// a SHA-256 of everything before it (the header says so). A build is identified by the ELF hash inside the +// descriptor, which changes with every build. The tests check these offsets against the real image. + +#include +#include +#include +#include + +namespace fwlogic { + +const size_t kHeadBytes = 208; // enough to reach the ELF hash (bytes 176..207) +const size_t kDigestBytes = 32; // SHA-256 +const size_t kElfShaOffset = 176; // esp_app_desc_t starts at byte 32; app_elf_sha256 is 144 bytes into it +const uint32_t kMinImageBytes = 128 * 1024; // an app is far bigger than a bootloader (20 KB) or a partition table (3 KB) +const uint16_t kChipEsp32S3 = 9; +const uint8_t kImageMagic = 0xE9; +const uint32_t kAppDescMagic = 0xABCD5432; +const int kMinBatteryMv = 3600; // below this on battery the update waits for a charge + +enum class Problem : uint8_t { + NONE = 0, + TOO_SMALL, // a bootloader or partition table, or a copy that was cut short + TOO_BIG, // does not fit the app slot: the .merged.bin, or another partition scheme + NOT_AN_IMAGE, // the first byte is not 0xE9 + WRONG_CHIP, // built for another ESP32 + NOT_AN_APP, // an image, but without an app descriptor (the bootloader) + NO_DIGEST // no SHA-256 at the end, so nothing to check the copy against +}; + +inline uint32_t le32(const uint8_t *p) { return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); } + +// `head` holds the first kHeadBytes of the file. slotSize 0 means unknown (no size limit applied). +inline Problem inspectImage(const uint8_t *head, size_t headLen, uint32_t fileSize, uint32_t slotSize) { + if (fileSize < kMinImageBytes || headLen < kHeadBytes) return Problem::TOO_SMALL; + if (slotSize != 0 && fileSize > slotSize) return Problem::TOO_BIG; + if (head[0] != kImageMagic) return Problem::NOT_AN_IMAGE; + if ((uint16_t)(head[12] | (head[13] << 8)) != kChipEsp32S3) return Problem::WRONG_CHIP; + if (le32(head + 32) != kAppDescMagic) return Problem::NOT_AN_APP; + if (head[23] != 1) return Problem::NO_DIGEST; + return Problem::NONE; +} + +inline const uint8_t *elfHash(const uint8_t *head) { return head + kElfShaOffset; } + +enum class Verdict : uint8_t { + NOTHING, // no firmware file on the card + SEVERAL, // more than one: which is the newer? Leave one. + NO_SLOT, // the partition scheme has no second app slot to write to + BAD_FILE, // see Decision::problem + SAME_AS_RUNNING, // this very build is what runs now + TRIED_BEFORE, // the card already installed this build once (it did not stay: rolled back, or flashed over) + LOW_BATTERY, + INSTALL +}; + +struct Inputs { + int files = 0; // firmware files found on the card + uint32_t fileSize = 0; // of the one to use + uint8_t head[kHeadBytes] = {}; // its first bytes + uint32_t slotSize = 0; // the app slot it would go to; 0 = there is none + uint8_t runningElf[kDigestBytes] = {}; // the build that runs now + bool haveLast = false; // the last build the card installed is known ... + uint8_t lastElf[kDigestBytes] = {}; // ... and this is it + bool hasBattery = false; // a battery is fitted (not "battery = none") + int batteryMv = 0; // its voltage; 0 = unknown +}; + +struct Decision { + Verdict verdict = Verdict::NOTHING; + Problem problem = Problem::NONE; +}; + +inline Decision decide(const Inputs &in) { + Decision d; + if (in.files <= 0) return d; + if (in.files > 1) { + d.verdict = Verdict::SEVERAL; + return d; + } + if (in.slotSize == 0) { + d.verdict = Verdict::NO_SLOT; + return d; + } + d.problem = inspectImage(in.head, kHeadBytes, in.fileSize, in.slotSize); + if (d.problem != Problem::NONE) { + d.verdict = Verdict::BAD_FILE; + return d; + } + if (!memcmp(elfHash(in.head), in.runningElf, kDigestBytes)) { + d.verdict = Verdict::SAME_AS_RUNNING; + return d; + } + if (in.haveLast && !memcmp(elfHash(in.head), in.lastElf, kDigestBytes)) { + d.verdict = Verdict::TRIED_BEFORE; + return d; + } + if (in.hasBattery && in.batteryMv > 0 && in.batteryMv < kMinBatteryMv) { + d.verdict = Verdict::LOW_BATTERY; + return d; + } + d.verdict = Verdict::INSTALL; + return d; +} + +// What to tell the person at the clock (one line of the small heading type, 46 characters at most); +// empty for the verdicts that need no message. +inline void describe(const Decision &d, int batteryMv, char *out, size_t cap) { + const char *s = ""; + switch (d.verdict) { + case Verdict::SEVERAL: s = "More than one firmware file on the card"; break; + case Verdict::NO_SLOT: s = "No second app slot: pick the 3MB APP scheme"; break; + case Verdict::BAD_FILE: + switch (d.problem) { + case Problem::TOO_SMALL: s = "The file is too small to be the firmware"; break; + case Problem::TOO_BIG: s = "Too big (the .merged.bin? use the .ino.bin)"; break; + case Problem::NOT_AN_IMAGE: s = "The file is not a firmware image"; break; + case Problem::WRONG_CHIP: s = "Built for another chip (need ESP32-S3)"; break; + case Problem::NOT_AN_APP: s = "Not an app image (the bootloader?)"; break; + case Problem::NO_DIGEST: s = "The image has no checksum: rebuild it"; break; + default: break; + } + break; + case Verdict::TRIED_BEFORE: s = "This build was installed from the card before"; break; + case Verdict::LOW_BATTERY: + snprintf(out, cap, "Battery %d.%02d V: plug in USB, then restart", batteryMv / 1000, (batteryMv % 1000) / 10); + return; + default: break; + } + snprintf(out, cap, "%s", s); +} + +// --------------------------------------------------------------------------- +// Installing: the two passes over the file +// --------------------------------------------------------------------------- +// First the whole file is read and checked against the SHA-256 at its end, so that nothing is written until +// the copy on the card is known to be good; then it is read again and written to the other app slot. +// +// Written against three small interfaces, so that tools/tests runs this very code on a PC with a made-up +// file and a made-up flash (fw_update.cpp plugs in the card, mbedTLS and the core's Update class): +// +// Source bool readAt(uint32_t offset, void *buf, uint32_t n) all n bytes from that place, or false +// Hash void update(const uint8_t *p, uint32_t n); void finish(uint8_t *out32) +// Sink bool begin(uint32_t size); bool write(uint8_t *p, uint32_t n); bool end(); +// const char *error(); void abort() +// progress(Pass, percent) +// +// Every read says where it reads from. (The first version read "what comes next", after the caller had +// already read the header for a look: the check ran 208 bytes off and came up short at the very end, on +// every file. It had only ever been compiled.) +enum class Pass : uint8_t { CHECK, WRITE }; + +const char *const kReadError = "Cannot read the file (card error)"; +const char *const kDamaged = "The file is damaged: copy it again"; +const char *const kTooSmall = "The file is too small to be the firmware"; + +// Returns nullptr when the new firmware is in place, else a short reason for the screen. +template +const char *installImage(Source &source, uint32_t size, Hash &hash, Sink &sink, uint8_t *buf, uint32_t chunk, Progress progress) { + if (size <= kDigestBytes || chunk == 0) return kTooSmall; + + // 1. the file against its own checksum + const uint32_t body = size - (uint32_t)kDigestBytes; + progress(Pass::CHECK, 0); + int shown = 0; + for (uint32_t done = 0; done < body;) { + const uint32_t n = body - done < chunk ? body - done : chunk; + if (!source.readAt(done, buf, n)) return kReadError; + hash.update(buf, n); + done += n; + const int pct = (int)((uint64_t)done * 100 / body); + if (pct >= shown + 4 || (done == body && pct != shown)) { + shown = pct; + progress(Pass::CHECK, pct); + } + } + uint8_t calc[kDigestBytes], trailer[kDigestBytes]; + hash.finish(calc); + if (!source.readAt(body, trailer, (uint32_t)kDigestBytes)) return kReadError; + if (memcmp(calc, trailer, kDigestBytes) != 0) return kDamaged; + + // 2. the other app slot: the whole file, checksum and all + progress(Pass::WRITE, 0); + if (!sink.begin(size)) return sink.error(); + const char *err = nullptr; + shown = 0; + for (uint32_t done = 0; done < size && !err;) { + const uint32_t n = size - done < chunk ? size - done : chunk; + if (!source.readAt(done, buf, n)) { + err = kReadError; + } else if (!sink.write(buf, n)) { + err = sink.error(); + } else { + done += n; + const int pct = (int)((uint64_t)done * 100 / size); + if (pct >= shown + 2 || (done == size && pct != shown)) { + shown = pct; + progress(Pass::WRITE, pct); + } + } + } + if (!err && !sink.end()) err = sink.error(); + if (err) sink.abort(); + return err; +} + +} // namespace fwlogic diff --git a/02_Example/Arduino/11_Clock_Dashboard/fw_update.cpp b/02_Example/Arduino/11_Clock_Dashboard/fw_update.cpp new file mode 100644 index 00000000..133689ee --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/fw_update.cpp @@ -0,0 +1,318 @@ +#include "fw_update.h" + +#include +#include +#include +#include +#include +#include + +#include "app_settings.h" +#include "config.h" +#include "fw_logic.h" +#include "log.h" +#include "sdcard.h" +#include "sensors.h" +#include "util.h" + +// The core confirms a freshly installed image the moment it starts unless the sketch says it will do it +// later. This one does it after FIRMWARE_TRIAL_MS (fwTrialTick), so a build that dies on the way up is +// undone by the bootloader at the next reset. (Overrides the core's weak function of the same name.) +extern "C" bool verifyRollbackLater() { return true; } + +namespace { + +const char *const TAG = "fw"; +const char *const kNamespace = "fw"; // flash: "last" = the build the card installed last +const size_t kChunk = 4096; + +char s_name[40] = ""; // the firmware file found on the card (the first of them) +int s_count = 0; // how many there are +uint32_t s_size = 0; // size of the first +bool s_trial = false; // the running image is on trial +bool s_rolledBack = false; + +const char *stateName(esp_ota_img_states_t s) { + switch (s) { + case ESP_OTA_IMG_NEW: return "new"; + case ESP_OTA_IMG_PENDING_VERIFY: return "on trial"; + case ESP_OTA_IMG_VALID: return "valid"; + case ESP_OTA_IMG_INVALID: return "invalid"; + case ESP_OTA_IMG_ABORTED: return "rolled back"; + default: return "undefined"; + } +} + +void toHex(const uint8_t *in, size_t n, char *out) { + static const char *const digits = "0123456789abcdef"; + for (size_t i = 0; i < n; i++) { + out[2 * i] = digits[in[i] >> 4]; + out[2 * i + 1] = digits[in[i] & 15]; + } + out[2 * n] = 0; +} + +bool fromHex(const char *s, uint8_t *out, size_t n) { + for (size_t i = 0; i < n; i++) { + int v = 0; + for (int k = 0; k < 2; k++) { + const char c = s[2 * i + k]; + int d = (c >= '0' && c <= '9') ? c - '0' : (c >= 'a' && c <= 'f') ? c - 'a' + 10 : (c >= 'A' && c <= 'F') ? c - 'A' + 10 : -1; + if (d < 0) return false; + v = v * 16 + d; + } + out[i] = (uint8_t)v; + } + return true; +} + +// The build in a partition: the ELF hash in its app descriptor. +bool partitionElf(const esp_partition_t *p, uint8_t *out) { + esp_app_desc_t desc; + if (!p || esp_ota_get_partition_description(p, &desc) != ESP_OK) return false; + memcpy(out, desc.app_elf_sha256, fwlogic::kDigestBytes); + return true; +} + +bool loadLast(uint8_t *out) { + Preferences p; + if (!p.begin(kNamespace, true)) return false; + const String hex = p.getString("last", ""); + p.end(); + return hex.length() == 2 * fwlogic::kDigestBytes && fromHex(hex.c_str(), out, fwlogic::kDigestBytes); +} + +void saveLast(const uint8_t *elf) { + Preferences p; + if (!p.begin(kNamespace, false)) return; + char hex[2 * fwlogic::kDigestBytes + 1]; + toHex(elf, fwlogic::kDigestBytes, hex); + p.putString("last", hex); + p.end(); +} + +void say(FwShowFn show, UiFwKind kind, const char *what, const char *detail, int percent, bool keepPowered) { + UiFwScreen s; + s.kind = kind; + copyStr(s.what, sizeof s.what, what); + copyStr(s.detail, sizeof s.detail, detail); + s.percent = percent; + s.keepPowered = keepPowered; + show(s); +} + +// What fwlogic::installImage() works with on the clock: the file on the card, mbedTLS's SHA-256, and the +// other app slot by way of the core's Update class. + +// Every read names its place in the file, so nothing depends on what was read before. +struct CardFile { + int fd; + bool readAt(uint32_t offset, void *buf, uint32_t n) { return sdSeek(fd, offset) && sdRead(fd, buf, n) == (int)n; } +}; + +struct Sha256 { + mbedtls_sha256_context ctx; + Sha256() { + mbedtls_sha256_init(&ctx); + mbedtls_sha256_starts(&ctx, 0); + } + ~Sha256() { mbedtls_sha256_free(&ctx); } + void update(const uint8_t *p, uint32_t n) { mbedtls_sha256_update(&ctx, p, n); } + void finish(uint8_t *out) { mbedtls_sha256_finish(&ctx, out); } +}; + +// The Update class holds back the first bytes of the image until the end, so a half-written slot cannot +// boot, and switches the boot slot only after the image has been verified. +struct OtherSlot { + bool begin(uint32_t size) { return Update.begin(size, U_FLASH); } + bool write(uint8_t *p, uint32_t n) { return Update.write(p, n) == n; } + bool end() { return Update.end(); } + const char *error() { return Update.errorString(); } + void abort() { Update.abort(); } +}; + +// Checks the whole file against the SHA-256 at its end, then writes it to the other app slot. Returns +// nullptr when the new firmware is in place, else a short reason. +const char *installFrom(int fd, uint32_t size, const char *detail, FwShowFn show) { + uint8_t *buf = (uint8_t *)malloc(kChunk); + if (!buf) return "Out of memory"; + CardFile file{fd}; + Sha256 sha; + OtherSlot slot; + const char *err = fwlogic::installImage(file, size, sha, slot, buf, (uint32_t)kChunk, [&](fwlogic::Pass pass, int percent) { + say(show, FW_BUSY, pass == fwlogic::Pass::CHECK ? "Checking the file" : "Installing the new firmware", detail, percent, true); + }); + free(buf); + return err; +} + +} // namespace + +const char *fwBuildId() { + static char id[8] = ""; + if (!id[0]) esp_app_get_elf_sha256(id, sizeof id); // (as many hex digits as fit: seven) + return id; +} + +void fwBegin() { + const esp_partition_t *run = esp_ota_get_running_partition(); + esp_ota_img_states_t state = ESP_OTA_IMG_UNDEFINED; + s_trial = run && esp_ota_get_state_partition(run, &state) == ESP_OK && state == ESP_OTA_IMG_PENDING_VERIFY; + s_rolledBack = esp_ota_get_last_invalid_partition() != nullptr; + if (s_trial) LOGF(TAG, "this firmware is on trial: kept once it has run %lu s", (unsigned long)(FIRMWARE_TRIAL_MS / 1000)); + if (s_rolledBack) LOGF(TAG, "the other app slot holds an image that was rolled back"); +} + +void fwScanCard() { + s_count = 0; + s_name[0] = 0; + s_size = 0; + const char *const names[] = {FIRMWARE_FILE_NAME, FIRMWARE_FILE_NAME_ALT, FIRMWARE_FILE_EXPORT}; + for (const char *n : names) { + if (!sdFileExists(n)) continue; + if (++s_count == 1) { + copyStr(s_name, sizeof s_name, n); + sdFileSize(n, &s_size); + } + } + if (s_count) LOGF(TAG, "firmware file on the card: %s, %u bytes%s", s_name, (unsigned)s_size, s_count > 1 ? " (and more)" : ""); +} + +void fwUpdateFromCard(FwShowFn show) { + if (s_count == 0) return; + + fwlogic::Inputs in; + in.files = s_count; + in.fileSize = s_size; + const esp_partition_t *run = esp_ota_get_running_partition(); + const esp_partition_t *next = esp_ota_get_next_update_partition(nullptr); + in.slotSize = (next && next != run) ? next->size : 0; + partitionElf(run, in.runningElf); + in.haveLast = loadLast(in.lastElf); + in.hasBattery = g_cfg.hasBattery(); + if (in.hasBattery) { + const BatteryReading b = readBattery(); + in.batteryMv = b.present ? (int)lroundf(b.volts * 1000.0f) : 0; + } + + char detail[64] = ""; + int fd = -1; + const char *problem = nullptr; + if (sdMount() != SdStatus::READY) { + problem = "Cannot read the SD card"; + } else if (s_count == 1) { + char size[16]; + snprintf(size, sizeof size, "%.2f MB", (double)s_size / 1048576.0); + snprintf(detail, sizeof detail, "%s, %s", s_name, size); + fd = sdOpen(s_name); + if (fd < 0 || !CardFile{fd}.readAt(0, in.head, (uint32_t)fwlogic::kHeadBytes)) { + // a file shorter than the header is inspected as it is: the head stays zero and "too small" is the verdict + if (fd < 0 || s_size >= fwlogic::kHeadBytes) problem = "Cannot read the firmware file"; + } + } + + fwlogic::Decision d; + char text[64] = ""; + if (!problem) { + d = fwlogic::decide(in); + LOGF(TAG, "firmware file: verdict %d, problem %d, battery %d mV", (int)d.verdict, (int)d.problem, in.batteryMv); + if (d.verdict == fwlogic::Verdict::NOTHING || d.verdict == fwlogic::Verdict::SAME_AS_RUNNING) { + sdClose(fd); + sdUnmount(); + return; // nothing to do; a leftover copy of what runs now is not worth a message + } + if (d.verdict != fwlogic::Verdict::INSTALL) { + fwlogic::describe(d, in.batteryMv, text, sizeof text); + problem = text; + } + } + if (!problem) problem = installFrom(fd, s_size, detail, show); + + sdClose(fd); + if (problem) { + LOGF(TAG, "not installed: %s", problem); + sdUnmount(); + say(show, FW_PROBLEM, problem, detail, -1, false); + delay(6000); + return; + } + + // installed: remember which build it was, move the file out of the way, restart into it + saveLast(fwlogic::elfHash(in.head)); + say(show, FW_DONE, "Restarting", detail, 100, false); + char doneName[64]; + snprintf(doneName, sizeof doneName, "%s%s", s_name, FIRMWARE_DONE_SUFFIX); + const bool renamed = sdRename(s_name, doneName); + LOGF(TAG, "installed %s (%s); restarting into it", s_name, renamed ? "file renamed" : "file could not be renamed"); + sdUnmount(); + delay(2500); + ESP.restart(); +} + +// The image stays "on trial" until the bootloader has accepted the confirmation: if the call fails (flash +// trouble) the next reset could still undo the update, so the flag, and the Info page, must say so. +void fwConfirmNow() { + if (!s_trial) return; + const esp_err_t err = esp_ota_mark_app_valid_cancel_rollback(); + if (err == ESP_OK) { + s_trial = false; + LOGF(TAG, "firmware confirmed"); + } else { + LOGF(TAG, "firmware could NOT be confirmed (%s): still on trial", esp_err_to_name(err)); + } +} + +void fwTrialTick(uint32_t nowMs) { + // a confirmation that failed is tried again every 5 s, five times in all; after that it is left alone + static uint8_t tries = 0; + static uint32_t lastTryMs = 0; + if (!s_trial || nowMs < FIRMWARE_TRIAL_MS || tries >= 5) return; + if (tries > 0 && nowMs - lastTryMs < 5000) return; + tries++; + lastTryMs = nowMs ? nowMs : 1; + fwConfirmNow(); +} + +bool fwOnTrial(uint32_t nowMs, uint32_t *secondsLeft) { + if (!s_trial) return false; + *secondsLeft = nowMs >= FIRMWARE_TRIAL_MS ? 0 : (FIRMWARE_TRIAL_MS - nowMs + 999) / 1000; + return true; +} + +bool fwRolledBack() { return s_rolledBack; } + +bool fwSwitchToOtherSlot() { return Update.canRollBack() && Update.rollBack(); } + +void fwForgetLast() { + Preferences p; + if (p.begin(kNamespace, false)) { + p.remove("last"); + p.end(); + } +} + +void fwPrint(Print &out) { + const esp_partition_t *run = esp_ota_get_running_partition(); + const esp_partition_t *other = esp_ota_get_next_update_partition(nullptr); + auto line = [&](const char *which, const esp_partition_t *p) { + if (!p) { + out.printf(" %-8s none\n", which); + return; + } + esp_ota_img_states_t st = ESP_OTA_IMG_UNDEFINED; + const bool haveState = esp_ota_get_state_partition(p, &st) == ESP_OK; + uint8_t elf[fwlogic::kDigestBytes]; + char hex[17] = "-"; + if (partitionElf(p, elf)) toHex(elf, 8, hex); + out.printf(" %-8s %-5s 0x%06x %u KB build %s %s\n", which, p->label, (unsigned)p->address, (unsigned)(p->size / 1024), hex, + haveState ? stateName(st) : "state n/a"); + }; + line("running", run); + line("other", other == run ? nullptr : other); + uint8_t last[fwlogic::kDigestBytes]; + char lastHex[17] = "none"; + if (loadLast(last)) toHex(last, 8, lastHex); + out.printf(" last build installed from the card: %s\n", lastHex); + uint32_t left; + if (fwOnTrial(millis(), &left)) out.printf(" on trial: kept in %u s\n", (unsigned)left); +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/fw_update.h b/02_Example/Arduino/11_Clock_Dashboard/fw_update.h new file mode 100644 index 00000000..531b5a72 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/fw_update.h @@ -0,0 +1,50 @@ +#pragma once + +// Updating the firmware from a file on the SD card, and keeping a new firmware on trial. +// +// * At start-up the clock looks for one firmware file in the card's top folder (config.h: +// FIRMWARE_FILE_NAME and friends: the .ino.bin that the Arduino IDE exports). If it is a good app +// image for this chip that is not what runs already, it is checked against its own SHA-256, written to +// the other app slot and the clock restarts into it. The running firmware is not touched until the +// new image has been written and checked; a card pulled out half way only loses the update. +// * A freshly installed firmware is on trial: the bootloader puts the old one back if the new one resets +// before it has run FIRMWARE_TRIAL_MS (the core would otherwise confirm it at once). +// +// What makes a file acceptable is in fw_logic.h (host-tested); this file is the part that touches the +// card, the flash and the screen. Needs a partition scheme with two app slots (the README's one has). + +#include + +#include "app_model.h" + +// Who are we: on trial or not, was the last update rolled back. First thing in setup(). +void fwBegin(); + +// The build that runs: the first seven hex digits of the hash that also tells builds apart for the updater +// (the serial command "fw" prints sixteen). It changes whenever the program does, which a version number +// and a date do not. The hash is that of the whole ELF file, debug information and all, and that names the +// folder it was built in: the same sources built in another folder, or on another computer, get another id. +const char *fwBuildId(); + +// Called while the card is mounted for the settings file: notes which firmware files are on it. +void fwScanCard(); + +// Draws a firmware-update screen (clear, draw, send); supplied by the sketch, which owns the display. +typedef void (*FwShowFn)(const UiFwScreen &screen); + +// After the display is up. With one good firmware file on the card: installs it, with progress on the +// screen, and restarts (so it does not return). With a file that cannot be used: says why for a few +// seconds and returns. With nothing on the card: returns at once. +void fwUpdateFromCard(FwShowFn show); + +// A new firmware confirms itself once it has run FIRMWARE_TRIAL_MS. Call from loop(). +void fwTrialTick(uint32_t nowMs); +// Confirm it now: going to sleep on purpose, or a reset the user asked for, are not failures. +void fwConfirmNow(); + +bool fwOnTrial(uint32_t nowMs, uint32_t *secondsLeft); // for the Info page +bool fwRolledBack(); // the other app slot holds an image the bootloader gave up on: the last update was undone + +bool fwSwitchToOtherSlot(); // serial "rollback": the next start runs the other slot +void fwForgetLast(); // serial "fwforget": the card may install the same file again +void fwPrint(Print &out); // serial "fw": what runs, what is in the other slot diff --git a/02_Example/Arduino/11_Clock_Dashboard/infinity_cube.h b/02_Example/Arduino/11_Clock_Dashboard/infinity_cube.h new file mode 100644 index 00000000..df660649 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/infinity_cube.h @@ -0,0 +1,236 @@ +#pragma once + +// An infinity-mirror cube: a cube of mirrors with lights along its edges, turning. It is solid (only the faces +// that look at you are drawn), and each of those faces is a window: behind it the lit frame of the cube is seen +// again and again, one cube's length further on each time, in every direction, as mirrors facing mirrors show +// it. That is a lattice of light without end, and every reflection costs a little of it: the nearest lines are +// solid, the ones behind them dashes, then dots, then nothing. Now and then a pulse of light comes up out of +// the depth. +// +// Pure geometry (no drawing, no Arduino): draw() hands the picture at a time to a sink, line by line with how +// bright each is, then the corners that glint, for a screen of a given size. ui.cpp draws them, and tools/tests +// and the UI preview run the same code on a PC. + +#include +#include +#include + +namespace infcube { + +enum Style : uint8_t { + BOLD = 0, // an edge of the cube itself + SOLID, // the nearest reflections, and the pulse + FADE1, // further in: three pixels in four ... + FADE2, // ... one in two ... + FADE3, // ... one in four ... + FADE4, // ... one in eight + kStyles, + NONE = kStyles // too faint to draw +}; + +const int kDepth = 12; // how many cubes deep the lattice is drawn behind a face +const int kSide = 2; // ... and how many cubes to each side of the cube's own +const float kCamera = 6.0f; // the eye's distance from the centre, in half edges of the cube +const float kFill = 0.90f; // how much of the screen's half height the corners may reach +const uint32_t kPulseStepMs = 110; // the pulse comes one cube nearer this often ... +const int kPulseRest = 10; // ... and waits this many steps before the next one sets out +const int kSideCost = 2; // what a cube to the side costs in brightness, against one straight behind + +// How bright a line is that has been reflected `bounces` times on its way to the eye (1 = the cube's own far side). +// A reflection to the side counts double (kSideCost): seen at a slant, a mirror gives back less. +inline Style fade(int bounces) { + return bounces <= 1 ? SOLID : bounces <= 2 ? FADE1 : bounces <= 4 ? FADE2 : bounces <= 7 ? FADE3 : bounces <= 11 ? FADE4 : NONE; +} + +// How deep the pulse is at `ms` (kDepth = the deepest, 1 = the nearest; 0 or less = between two pulses). +inline int pulseAt(uint32_t ms) { return kDepth - (int)((ms / kPulseStepMs) % (uint32_t)(kDepth + kPulseRest)); } + +struct V3 { + float x, y, z; +}; +inline V3 add(V3 a, V3 b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; } +inline V3 sub(V3 a, V3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; } +inline V3 mul(V3 a, float k) { return {a.x * k, a.y * k, a.z * k}; } +inline float dot(V3 a, V3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; } +inline V3 cross(V3 a, V3 b) { return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x}; } + +// How a cube tumbles: where its three axes point (x to the right, y up, z away from the eye) when it is +// `turnMs` along its way, and has been shoved round by `shove` on top of that (Shove::angle; null for none). +// It turns about all three axes at once, and on each of them now faster, now slower, now the other way, by a +// slow rule of its own that `seed` picks: no two showings turn alike, and nothing ever jerks. Without a shove +// the way is a function of turnMs alone, so a cube that is taken back along it (turnMs going down) turns +// exactly the other way round. +inline void tumble(float turnMs, uint32_t seed, const float shove[3], V3 out[3]) { + // six phases out of the seed (a small generator: the same seed, the same tumble) + float phase[6]; + uint32_t r = seed ? seed : 1; + for (int i = 0; i < 6; i++) { + r ^= r << 13; + r ^= r >> 17; + r ^= r << 5; + phase[i] = (float)(r & 0xFFFFu) * (6.2831853f / 65536.0f); + } + // each angle: a steady turn, and two slow swings on top of it that speed it up, hold it back and at times + // turn it round (in radians, with turnMs in milliseconds: a swing takes 25 to 50 seconds) + const float more[3] = {shove ? shove[0] : 0.0f, shove ? shove[1] : 0.0f, shove ? shove[2] : 0.0f}; + const float yaw = more[0] + 0.00030f * turnMs + 1.5f * sinf(0.00021f * turnMs + phase[0]) + 0.8f * sinf(0.00013f * turnMs + phase[1]); + const float pitch = more[1] + 0.00022f * turnMs + 1.4f * sinf(0.00017f * turnMs + phase[2]) + 0.9f * sinf(0.00025f * turnMs + phase[3]); + const float roll = more[2] + 0.00026f * turnMs + 1.3f * sinf(0.00019f * turnMs + phase[4]) + 0.9f * sinf(0.00015f * turnMs + phase[5]); + const float cy = cosf(yaw), sy = sinf(yaw), cp = cosf(pitch), sp = sinf(pitch), cr = cosf(roll), sr = sinf(roll); + const V3 turned[3] = {{cy, sy * sp, -sy * cp}, {0, cp, sp}, {sy, -cy * sp, cy * cp}}; // about the upright axis, then tipped + for (int i = 0; i < 3; i++) out[i] = {turned[i].x * cr - turned[i].y * sr, turned[i].x * sr + turned[i].y * cr, turned[i].z}; // then rolled +} + +inline void tumble(float turnMs, uint32_t seed, V3 out[3]) { tumble(turnMs, seed, nullptr, out); } + +// A shove given to a tumbling cube: it spins off in some direction, fast at first, and that dies away in under +// a second while the tumbling goes on underneath. `angle` is what all the shoves so far have turned the cube +// by, for tumble(). +const float kShoveRate = 0.0045f; // radians a millisecond at the moment of the shove: 260 degrees a second +const float kShoveFadeMs = 600.0f; // ... falling to a third of what it was in every stretch of this long +struct Shove { + float angle[3] = {0, 0, 0}; + float rate[3] = {0, 0, 0}; // how fast the angles still grow, radians a millisecond + + // One shove, in the direction that `random` (any 32 bits of chance) picks. + void bump(uint32_t random) { + float v[3], length2 = 0; + for (int i = 0; i < 3; i++) { + v[i] = (float)((random >> (10 * i)) & 1023u) - 511.5f; + length2 += v[i] * v[i]; + } + const float scale = kShoveRate / sqrtf(length2); // (never nought: the parts are halves) + for (int i = 0; i < 3; i++) rate[i] += v[i] * scale; + } + + // `dtMs` later (the same whether it is counted in one go or frame by frame). + void advance(float dtMs) { + const float keep = expf(-dtMs / kShoveFadeMs); + for (int i = 0; i < 3; i++) { + angle[i] = fmodf(angle[i] + rate[i] * kShoveFadeMs * (1.0f - keep), 6.2831853f); + rate[i] *= keep; + } + } +}; + +// What can be seen through a face from the eye: the inside of the four planes through the eye and the face's edges. +struct Window { + V3 eye; + V3 normal[4]; // of those planes, pointing inwards + + // The part of the segment from p to p + d that is seen through the window, as the range t0..t1 of 0..1 along + // it. False if none of it is. + bool clip(V3 p, V3 d, float *t0, float *t1) const { + float lo = 0, hi = 1; + const V3 rel = sub(p, eye); + for (int i = 0; i < 4; i++) { + const float at = dot(normal[i], rel); // how far p is inside this plane (negative: outside) + const float go = dot(normal[i], d); // ... and how that changes along the segment + if (go > -1e-6f && go < 1e-6f) { + if (at < 0) return false; // alongside the plane, outside it + continue; + } + const float t = -at / go; + if (go > 0) { + if (t > lo) lo = t; // comes in here + } else { + if (t < hi) hi = t; // goes out here + } + if (lo >= hi) return false; + } + *t0 = lo; + *t1 = hi; + return true; + } +}; + +// Hands the picture of the cube with its axes at `axis` (tumble()), `ms` after it came up (the pulse goes by +// that), for a screen of w x h pixels, to `sink`: +// sink.line(x0, y0, x1, y1, style) the lattice behind each face first, the cube's own edges (BOLD) last, so +// that they are drawn over the ends of what is behind them +// sink.dot(x, y) a glint on every corner that is seen +template +void draw(const V3 axis[3], uint32_t ms, int w, int h, Sink &sink) { + const V3 eye = {0, 0, -kCamera}; + // A corner is sqrt(3) from the centre, and wherever the cube has turned to it shows no further from the middle + // of the screen than focal * sqrt(3) / sqrt(kCamera^2 - 3): the focal length makes that kFill of half the height. + const float focal = kFill * 0.5f * (float)h * sqrtf(kCamera * kCamera - 3.0f) / 1.7320508f; + const float midX = 0.5f * (float)(w - 1), midY = 0.5f * (float)(h - 1); + auto line = [&](V3 a, V3 b, Style style) { + const float ka = focal / (kCamera + a.z), kb = focal / (kCamera + b.z); + const int x0 = (int)lroundf(midX + a.x * ka), y0 = (int)lroundf(midY - a.y * ka); + const int x1 = (int)lroundf(midX + b.x * kb), y1 = (int)lroundf(midY - b.y * kb); + if (x0 == x1 && y0 == y1 && style != BOLD) return; // nothing to see of it + sink.line(x0, y0, x1, y1, style); + }; + const int pulse = pulseAt(ms); + const int reach = 2 * kSide + 1; // the lattice's lines stand at the odd places from -reach to reach + + V3 corners[3][4]; // of the faces that are seen, for their own edges at the end + int seen = 0; + for (int f = 0; f < 6 && seen < 3; f++) { + const float side = (f & 1) ? -1.0f : 1.0f; + const V3 n = mul(axis[f >> 1], side); // the face's centre, and the way it looks + const V3 u = axis[((f >> 1) + 1) % 3], v = axis[((f >> 1) + 2) % 3]; + if (n.z > -1.0f / kCamera - 0.02f) continue; // it looks away from the eye (or is seen all but edge on) + // a place in the lattice as seen through this face: `depth` half edges behind it, a and b along its two sides + auto at = [&](float depth, float a, float b) { return add(mul(n, 1.0f - depth), add(mul(u, a), mul(v, b))); }; + V3 *c = corners[seen++]; + c[0] = at(0, 1, 1); + c[1] = at(0, -1, 1); + c[2] = at(0, -1, -1); + c[3] = at(0, 1, -1); + Window win; + win.eye = eye; + for (int i = 0; i < 4; i++) { + V3 m = cross(sub(c[i], eye), sub(c[(i + 1) & 3], eye)); + if (dot(m, sub(n, eye)) < 0) m = mul(m, -1.0f); // (inwards: the middle of the face is inside) + win.normal[i] = m; + } + // A piece of a lattice line from p to p + d, of which the part t0..t1 is seen: in `cells` equal lengths, the + // i-th of them bounces(i) reflections away. + auto pieces = [&](V3 p, V3 d, int cells, auto bounces) { + float t0, t1; + if (!win.clip(p, d, &t0, &t1)) return; + for (int i = (int)floorf(t0 * (float)cells); i < cells; i++) { + const float from = (float)i / (float)cells, to = (float)(i + 1) / (float)cells; + if (from >= t1) break; + const Style style = bounces(i); + if (style == NONE) continue; + const float a = from > t0 ? from : t0, b = to < t1 ? to : t1; + if (b > a) line(add(p, mul(d, a)), add(p, mul(d, b)), style); + } + }; + auto away = [](int place) { return (abs(place) - 1) / 2; }; // cubes to the side of the cube's own: 0, 1, 2 ... + // the rails: the lines that run away from the eye, through every corner of every cube of the lattice + for (int a = -reach; a <= reach; a += 2) { + for (int b = -reach; b <= reach; b += 2) { + const int sideways = kSideCost * (away(a) + away(b)); + pieces(at(0, (float)a, (float)b), mul(n, -2.0f * (float)kDepth), kDepth, [&](int i) { return fade(i + 1 + sideways); }); + } + } + // the frames: at every cube's length of depth, the lines across, both ways + for (int k = 1; k <= kDepth; k++) { + const float depth = 2.0f * (float)k; + for (int m = -reach; m <= reach; m += 2) { + // (cell i of such a line lies between the places -reach + 2 i and -reach + 2 i + 2: i - kSide cubes to the side) + auto bounces = [&](int i) { + const int sideways = kSideCost * (away(m) + abs(i - kSide)); + return (k == pulse && sideways == 0) ? SOLID : fade(k + sideways); + }; + pieces(at(depth, (float)-reach, (float)m), mul(u, 2.0f * (float)reach), reach, bounces); + pieces(at(depth, (float)m, (float)-reach), mul(v, 2.0f * (float)reach), reach, bounces); + } + } + } + for (int f = 0; f < seen; f++) + for (int i = 0; i < 4; i++) line(corners[f][i], corners[f][(i + 1) & 3], BOLD); + for (int f = 0; f < seen; f++) { + for (int i = 0; i < 4; i++) { + const float k = focal / (kCamera + corners[f][i].z); + sink.dot((int)lroundf(midX + corners[f][i].x * k), (int)lroundf(midY - corners[f][i].y * k)); + } + } +} + +} // namespace infcube diff --git a/02_Example/Arduino/11_Clock_Dashboard/link_page.cpp b/02_Example/Arduino/11_Clock_Dashboard/link_page.cpp new file mode 100644 index 00000000..eb989ba5 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/link_page.cpp @@ -0,0 +1,117 @@ +#include "link_page.h" + +#include +#include + +namespace { + +// Bounded appender: once anything does not fit, the whole render is reported as failed. +class Out { + public: + Out(char *buf, size_t cap) : buf_(buf), cap_(cap) { + if (cap_) buf_[0] = 0; + } + void add(const char *s) { + size_t l = strlen(s); + if (n_ + l + 1 > cap_) { + overflow_ = true; + return; + } + memcpy(buf_ + n_, s, l + 1); + n_ += l; + } + void addEscaped(const char *s) { // for text and attribute values + for (; *s; ++s) { + switch (*s) { + case '&': add("&"); break; + case '<': add("<"); break; + case '>': add(">"); break; + case '"': add("""); break; + case '\'': add("'"); break; + default: { + char c[2] = {*s, 0}; + add(c); + } + } + } + } + void addInt(int v) { + char t[16]; + snprintf(t, sizeof t, "%d", v); + add(t); + } + size_t size() const { return overflow_ ? 0 : n_; } + + private: + char *buf_; + size_t cap_; + size_t n_ = 0; + bool overflow_ = false; +}; + +} // namespace + +size_t renderLinkPageBody(const LinkPageInfo &i, char *out, size_t cap) { + Out o(out, cap); + + o.add("

"); + o.addEscaped(i.appName); + o.add(": connect Spotify

Status: "); + o.add(i.linked ? "linked" : "not linked"); + if (i.linked && i.daysLeft >= 0) { + o.add(" (Spotify expires the link in "); + o.addInt(i.daysLeft); + o.add(" days; linking again renews it)"); + } + o.add("

"); + if (i.message[0]) { + o.add(i.messageIsError ? "

" : "

"); + o.addEscaped(i.message); + o.add("

"); + } + + o.add("

1. Allow the redirect

" + "

In the Spotify developer dashboard " + "open your app, choose Settings, add this Redirect URI and Save:

" + "

Why 127.0.0.1 and not the clock's own address? Spotify refuses plain-http redirects as " + ""insecure", with one exception: 127.0.0.1, which means "this computer". So Spotify's " + "answer comes back to the computer you are browsing from, and something on it has to hand it to the clock. " + "That is step 2.

"); + + o.add("

2. Approve access (pick one)

" + "

A. With the helper: easiest, needs Python 3 on this computer

    " + "
  1. Download spotify_link.py (a small script served by the clock)
  2. " + "
  3. Run it: python spotify_link.py "); + o.addEscaped(i.clockAddress); + o.add("

A browser tab opens Spotify. Press Agree and the clock is linked.

" + "

B. By hand: any browser, no helper

  1. Open Spotify and approve access
  2. " + "
  3. The browser then ends on an error page for 127.0.0.1 ("can't reach this page"). That is " + "expected: nothing there is listening. Copy the whole address from the address bar and paste it here:" + "
    " + "
" + "

Needs a Spotify Premium account (Spotify's rules for developer apps and for playback control). " + "Only the permissions to read and control playback are requested.

"); + return o.size(); +} + +size_t renderPageDocument(const char *appName, const char *body, char *out, size_t cap) { + Out o(out, cap); + o.add("" + ""); + o.addEscaped(appName); + o.add(" - Spotify"); + o.add(body); + o.add(""); + return o.size(); +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/link_page.h b/02_Example/Arduino/11_Clock_Dashboard/link_page.h new file mode 100644 index 00000000..f03994b2 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/link_page.h @@ -0,0 +1,26 @@ +#pragma once + +// The Spotify setup page served by the clock. Rendering is a pure function of +// plain values (no Arduino types) so the host tests can render it, check it and +// write it out as an HTML file to look at in a browser. + +#include + +struct LinkPageInfo { + const char *appName = ""; // e.g. "RLCD Clock" + bool linked = false; + int daysLeft = -1; // days until Spotify expires the link; -1 = unknown + const char *message = ""; // result of the last attempt ("" = none) + bool messageIsError = false; + const char *redirectUri = ""; // what the user must register in the Spotify app + const char *clockAddress = ""; // the clock's IP address, used in the helper command + const char *authorizeUrl = ""; // Spotify approval link for the manual route +}; + +// Renders the page body (everything between and ) into `out`. +// Returns its length, or 0 if `cap` was too small. +size_t renderLinkPageBody(const LinkPageInfo &info, char *out, size_t cap); + +// Wraps a rendered body in the complete HTML document (head, styles, tail) used by +// every page the clock serves. Returns the length, or 0 if `cap` was too small. +size_t renderPageDocument(const char *appName, const char *body, char *out, size_t cap); diff --git a/02_Example/Arduino/11_Clock_Dashboard/log.cpp b/02_Example/Arduino/11_Clock_Dashboard/log.cpp new file mode 100644 index 00000000..b7df3f87 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/log.cpp @@ -0,0 +1,67 @@ +#include "log.h" + +#include + +#if ARDUINO_USB_CDC_ON_BOOT && ARDUINO_USB_MODE +#include +#include // Serial is the chip's USB Serial/JTAG port +#include +#include // the two USB pins +#endif + +volatile bool g_consoleOn = true; + +namespace { +std::atomic s_printing{0}; // tasks that are inside a LOGF right now +std::atomic s_hold{false}; // LOGF lines are dropped for the moment (consoleHold) +} + +void consoleBegin() { +#if ARDUINO_USB_CDC_ON_BOOT && ARDUINO_USB_MODE + // A console that was shut down left the two USB pins as plain outputs held low (that is how Serial.end() + // takes the port off the bus), and a restart that is not a power-on puts no pin back: the restart by KEY, + // the "set" command, a firmware update. So let go of them before the port is started. After a power-on + // they are not outputs and nothing is touched. + for (int pin : {USB_INT_PHY0_DM_GPIO_NUM, USB_INT_PHY0_DP_GPIO_NUM}) { + if ((GPIO.enable >> pin) & 1u) gpio_set_direction((gpio_num_t)pin, GPIO_MODE_DISABLE); + } +#endif + Serial.begin(115200); // (on the USB port this also switches the transceiver and its pull-up on) +#if ARDUINO_USB_CDC_ON_BOOT + // Don't stall for the default 100 ms when no USB host is reading. Not 0: with a + // zero timeout HWCDC::write() can spin forever if the host stops draining data. + Serial.setTxTimeoutMs(10); +#endif +} + +bool logBegin() { + s_printing.fetch_add(1); + if (g_consoleOn && !s_hold.load()) return true; + s_printing.fetch_sub(1); + return false; +} + +void logEnd() { s_printing.fetch_sub(1); } + +void consoleHold(bool hold) { + s_hold.store(hold); + if (hold) { + for (int i = 0; i < 200 && s_printing.load() > 0; i++) delay(5); // the line another task is in the middle of + } +} + +void consoleShutDown() { + if (!g_consoleOn) return; + Serial.flush(); + g_consoleOn = false; // no new lines from here on ... + for (int i = 0; i < 200 && s_printing.load() > 0; i++) delay(5); // ... and the one another task is in the middle of gets to finish + + // Serial.end() stops the driver: the interrupt is freed, the buffers go, and on the USB port the core turns + // D+ and D- into plain pins held low, which also takes the USB pad and its pull-up away from the USB block. + Serial.end(); +#if ARDUINO_USB_CDC_ON_BOOT && ARDUINO_USB_MODE + // Said outright as well: the transceiver off. (The block's bus clock is left running: the SDK's check for a + // computer on the port keeps reading its registers from the tick interrupt.) + usb_serial_jtag_ll_phy_enable_pad(false); +#endif +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/log.h b/02_Example/Arduino/11_Clock_Dashboard/log.h new file mode 100644 index 00000000..a1b59677 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/log.h @@ -0,0 +1,37 @@ +#pragma once + +// Minimal serial logging: LOGF("tag", "format %d", value) +// +// The console can be shut down for good ("console = auto" or "off", console_policy.h): from then on LOGF +// prints nothing. Any task may log, and the port must not be taken away under a task that is in the middle +// of a line, so every line is bracketed by logBegin() / logEnd() and the shutdown waits for the last one. + +#include + +// False once the console was shut down. Code that talks to Serial directly (the commands) checks it. +extern volatile bool g_consoleOn; + +bool logBegin(); // true: go ahead and print, then call logEnd() +void logEnd(); + +// Starts the serial port. First thing in setup(). (Also undoes what consoleShutDown() did to the USB pins, +// which outlives a restart that is not a power-on.) +void consoleBegin(); + +// Shuts the console down until the next restart: nothing more is printed, the serial driver is stopped (its +// interrupt and buffers freed) and, when Serial is the chip's USB port, the USB transceiver with its pull-up +// is switched off, so a computer no longer sees the clock. Call it from the UI task. +void consoleShutDown(); + +// While something long is printed that other tasks' lines must not get into the middle of (the power log, +// which is copied from the console as a file): LOGF lines are dropped, not kept for later. consoleHold(true) +// returns once the line another task was in the middle of is out. +void consoleHold(bool hold); + +#define LOGF(tag, fmt, ...) \ + do { \ + if (logBegin()) { \ + Serial.printf("[%8lu] %-8s " fmt "\n", (unsigned long)millis(), tag, ##__VA_ARGS__); \ + logEnd(); \ + } \ + } while (0) diff --git a/02_Example/Arduino/11_Clock_Dashboard/low_battery.h b/02_Example/Arduino/11_Clock_Dashboard/low_battery.h new file mode 100644 index 00000000..0b66dfc2 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/low_battery.h @@ -0,0 +1,87 @@ +#pragma once + +// When to switch the clock off before it flattens the cell. A bare LiPo with no protection +// board is ruined by being run to zero; this board has a protection chip as a last resort, but +// that cuts off abruptly and a lot lower, so the software stops first. +// +// * Guard: fed with each battery reading while running on the battery. The reading has to stay +// under the cut-off for a minute (and for at least three readings), so a WiFi burst that drags +// the voltage down for a moment never trips it. +// * bootDecision(): asked first thing after every power-up or wake-up, before the display or +// WiFi are started. After a low-battery shutdown the clock only starts again once the cell is +// back above kRestartV. That is well above the cut-off on purpose: a cell that has just been +// relieved of the load springs back by a hundred millivolts or more, so restarting at the +// cut-off would boot, sag, shut down and repeat. Charging lifts it past kRestartV; waiting +// does not. +// +// Header-only and free of Arduino dependencies so tools/tests can check it on a PC. + +#include + +namespace lowbat { + +const float kMinCutoffV = 3.10f; // limits of the setting (see settings.cpp) +const float kMaxCutoffV = 3.60f; +const float kRestartV = 3.70f; // restart only above this after a low-battery shutdown +const float kHysteresisV = 0.02f; // a reading must come this far above the cut-off to cancel a trip +const uint32_t kHoldSec = 60; // ... and under it for this long to trip +const int kMinReadings = 3; +const uint32_t kSleepCheckSec = 300; // while shut down: look at the battery this often + +class Guard { + public: + void configure(bool enabled, float cutoffV) { + enabled_ = enabled; + cutoff_ = cutoffV < kMinCutoffV ? kMinCutoffV : (cutoffV > kMaxCutoffV ? kMaxCutoffV : cutoffV); + reset(); + } + + void reset() { + below_ = false; + count_ = 0; + } + + // One reading of the battery voltage. onBattery is false while charging or when the battery + // is not what the clock runs on (the guard then stands down). True means: shut down now. + bool feed(uint32_t nowSec, float volts, bool onBattery) { + if (!enabled_ || !onBattery || !(volts > 0.5f)) { + reset(); + return false; + } + if (volts < cutoff_) { + if (!below_) { + below_ = true; + belowSince_ = nowSec; + count_ = 0; + } + count_++; + } else if (volts >= cutoff_ + kHysteresisV) { + reset(); + return false; + } + return below_ && count_ >= kMinReadings && (nowSec - belowSince_) >= kHoldSec; + } + + float cutoffV() const { return cutoff_; } + bool enabled() const { return enabled_; } + bool tripping() const { return below_; } // under the cut-off right now (the clock may warn) + + private: + bool enabled_ = true; + float cutoff_ = 3.30f; + bool below_ = false; + uint32_t belowSince_ = 0; + int count_ = 0; +}; + +enum class BootAction : uint8_t { RUN, STAY_ASLEEP }; + +// volts: the battery reading taken first thing; shutdownFlag: the previous run ended with a +// low-battery shutdown (kept in RTC memory or flash). +inline BootAction bootDecision(bool enabled, float volts, float cutoffV, bool shutdownFlag) { + if (!enabled || !(volts > 0.5f)) return BootAction::RUN; // off, or no reading to go by + if (shutdownFlag) return volts >= kRestartV ? BootAction::RUN : BootAction::STAY_ASLEEP; + return volts < cutoffV ? BootAction::STAY_ASLEEP : BootAction::RUN; +} + +} // namespace lowbat diff --git a/02_Example/Arduino/11_Clock_Dashboard/moon.h b/02_Example/Arduino/11_Clock_Dashboard/moon.h new file mode 100644 index 00000000..cc80104d --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/moon.h @@ -0,0 +1,88 @@ +#pragma once + +// Moon phase from the date alone: no network, no table. The angle between the Sun and the +// Moon comes from the main terms of the standard series for both longitudes (Meeus, "Astronomical +// Algorithms", ch. 47, and the Astronomical Almanac's low-precision Sun). Checked against 24 +// published new and full moons and four quarters (tools/tests): the darkest and brightest moments +// land within half an hour of the almanac, and the lit fraction is good to a fraction of a +// percent, far finer than the 10 pixel disc it is drawn in. Header-only and free of Arduino +// dependencies so tools/tests can check it on a PC. + +#include +#include + +namespace moon { + +struct Phase { + float lit = 0; // 0..1, the fraction of the disc that is lit + bool waxing = true; // growing (new -> full); false when shrinking (full -> new) + float age = 0; // 0..1 round the lunation: 0 new, 0.25 first quarter, 0.5 full, 0.75 last quarter +}; + +inline Phase phaseAt(int64_t unixUtc) { + const double kRad = 3.14159265358979323846 / 180.0; + const double d = (double)unixUtc / 86400.0 + 2440587.5 - 2451545.0; // days since J2000.0 + + // Sun: apparent ecliptic longitude, degrees + const double sunMean = 280.460 + 0.9856474 * d; + const double g = (357.528 + 0.9856003 * d) * kRad; + const double sunLon = sunMean + 1.915 * sin(g) + 0.020 * sin(2 * g); + + // Moon: mean longitude, elongation, Sun's and Moon's anomaly, argument of latitude + const double lMoon = 218.3164477 + 13.17639648 * d; + const double D = (297.8501921 + 12.19074912 * d) * kRad; + const double M = (357.5291092 + 0.98560028 * d) * kRad; + const double Mp = (134.9633964 + 13.06499295 * d) * kRad; + const double F = (93.2720950 + 13.22935025 * d) * kRad; + const double moonLon = lMoon + 6.288774 * sin(Mp) + 1.274027 * sin(2 * D - Mp) + 0.658314 * sin(2 * D) + + 0.213618 * sin(2 * Mp) - 0.185116 * sin(M) - 0.114332 * sin(2 * F) + + 0.058793 * sin(2 * D - 2 * Mp) + 0.057066 * sin(2 * D - M - Mp) + + 0.053322 * sin(2 * D + Mp) + 0.045758 * sin(2 * D - M) - 0.040923 * sin(M - Mp) - + 0.034720 * sin(D) - 0.030383 * sin(M + Mp) + 0.015327 * sin(2 * D - 2 * F); + const double moonLat = 5.128122 * sin(F) + 0.280602 * sin(Mp + F) + 0.277693 * sin(Mp - F) + + 0.173237 * sin(2 * D - F) + 0.055413 * sin(2 * D - Mp + F) + + 0.046271 * sin(2 * D - Mp - F) + 0.032573 * sin(2 * D + F); + + double dLon = fmod(moonLon - sunLon, 360.0); + if (dLon < 0) dLon += 360.0; + // elongation: the angle between the two bodies in the sky + const double cosPsi = cos(moonLat * kRad) * cos(dLon * kRad); + double psi = acos(cosPsi < -1 ? -1 : (cosPsi > 1 ? 1 : cosPsi)); // radians, 0..pi + psi += 0.1468 * kRad * sin(psi); // the Sun is not infinitely far: the terminator is a touch later + + Phase p; + p.lit = (float)((1.0 - cos(psi)) / 2.0); + p.waxing = dLon < 180.0; + p.age = (float)(dLon / 360.0); + return p; +} + +// One pixel of a moon disc of `diameter` pixels, for drawing the phase. `col` and `row` count +// from the top-left of the disc's bounding box. A moon is lit on its right side while it grows +// when seen from the northern hemisphere; from the southern hemisphere the picture is mirrored +// (litOnRight = waxing != southern). The disc is round, the terminator an ellipse. +struct DiscPixel { + bool inside = false; + bool lit = false; +}; + +inline DiscPixel discPixel(int col, int row, int diameter, float lit, bool litOnRight) { + DiscPixel px; + const float centre = (diameter - 1) * 0.5f; + const float x = (float)col - centre, y = (float)row - centre; + const float r = diameter * 0.5f; + const float r2 = r * r + 1.0f; // a little over r: small discs look rounder + if (x * x + y * y > r2) return px; + px.inside = true; + if (lit <= 0.005f) return px; + if (lit >= 0.995f) { + px.lit = true; + return px; + } + const float halfChord = sqrtf(r2 - y * y); + const float terminator = (1.0f - 2.0f * lit) * halfChord; // +chord at new moon, -chord at full + px.lit = litOnRight ? (x > terminator) : (-x > terminator); + return px; +} + +} // namespace moon diff --git a/02_Example/Arduino/11_Clock_Dashboard/net_task.cpp b/02_Example/Arduino/11_Clock_Dashboard/net_task.cpp new file mode 100644 index 00000000..495a4085 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/net_task.cpp @@ -0,0 +1,732 @@ +#include "net_task.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include "app_settings.h" +#include "app_state.h" +#include "calc.h" +#include "config.h" +#include "drift.h" +#include "log.h" +#include "power.h" +#include "radio_plan.h" +#include "spotify.h" +#include "tls.h" +#include "tz_table.h" +#include "util.h" +#include "weather.h" +#include "wifi_pick.h" + +namespace { + +const char *const TAG = "net"; +const int kReturnMinRssi = -80; // dBm: the main network must be at least this strong for the clock to go back to it +const time_t kMinPlausibleEpoch = 1704067200; // 2024-01-01: anything earlier means "clock not set" + +Preferences s_prefs; // namespace "clock" +volatile uint32_t s_lastSyncUtc = 0; // seconds; 32 bits so the lwIP task and this one never see a torn value +volatile bool s_rtcWriteRequest = false; +volatile bool s_weatherNow = false; +std::atomic s_demandUntilMs{0}; // wifi_mode = sync: a key press holds the radio on until then (netWake); 0 = none +std::atomic s_demandSeq{1}; // ... and counts up with every press, so that one press buys one attempt (radio_plan.h) +std::atomic s_lookNow{false}; // a button was pressed: a clock that found no network looks again now (netNudge) +volatile bool s_radioNeedsFast = false; // the radio is on or about to be: the CPU clock must stay at 80 MHz or more +bool s_tzFromOverride = false; + +const char *const kStatusConnecting = "Connecting to WiFi..."; +const char *const kStatusAway = "No known WiFi in range"; // none of the clock's networks answered: it looks again by itself +const char *const kStatusFailed = "No WiFi, trying again later"; // a network is there but the clock cannot get in + +// What the WiFi stack has answered since the network task last began an attempt (WiFi.begin): how often "no +// such network" (it scanned every channel and found nothing by that name) and how often anything else (a +// wrong password, a router that refuses, a connection that broke). Counted by the WiFi event task, read and +// reset by the network task; sync mode ends a session early on the first kind (radioplan::Search). +std::atomic s_attemptAtMs{0}; // millis() of that begin, 0 = none yet +std::atomic s_notFound{0}, s_otherDrops{0}; + +void onStationEvent(arduino_event_t *ev) { + if (ev->event_id != ARDUINO_EVENT_WIFI_STA_DISCONNECTED) return; + const uint32_t began = s_attemptAtMs.load(); + if (began == 0 || (uint32_t)(millis() - began) < radioplan::kMinScanMs) return; // an answer to the attempt before this one + const uint8_t reason = ev->event_info.wifi_sta_disconnected.reason; + if (reason == WIFI_REASON_NO_AP_FOUND) { + s_notFound++; + } else if (reason != WIFI_REASON_ASSOC_LEAVE) { // (leaving is our own doing) + s_otherDrops++; + } +} + +void beginAttemptCount() { + s_attemptAtMs = millis() | 1u; // first: from here on, what still comes in about the attempt before is ignored + s_notFound = 0; + s_otherDrops = 0; +} + +// The timer and the system clock read together at the end of each NTP sync, for the drift +// measurement (drift.h). Written by the SNTP task, read by the network task: a sequence counter +// (odd while being written) makes the pair consistent without a lock. +struct SyncStamp { + volatile int64_t mono = 0; + volatile int64_t sys = 0; + std::atomic seq{0}; +}; +SyncStamp s_stamp; +drift::Tracker s_drift; // network task only + +// Called from the lwIP/SNTP task: only record the facts here. The stamp goes first: the network +// task acts on a new s_lastSyncUtc, and must then find this sync's stamp, not the last one's. +void onTimeSync(struct timeval *tv) { + struct timeval now; + gettimeofday(&now, nullptr); + const int64_t mono = esp_timer_get_time(); + s_stamp.seq.fetch_add(1, std::memory_order_acq_rel); // odd: being written + s_stamp.mono = mono; + s_stamp.sys = (int64_t)now.tv_sec * 1000000 + now.tv_usec; + s_stamp.seq.fetch_add(1, std::memory_order_release); // even: complete + s_rtcWriteRequest = true; + s_lastSyncUtc = (uint32_t)tv->tv_sec; +} + +bool takeSyncStamp(int64_t *mono, int64_t *sys) { + for (int i = 0; i < 5; i++) { + const uint32_t a = s_stamp.seq.load(std::memory_order_acquire); + if (a & 1) continue; + *mono = s_stamp.mono; + *sys = s_stamp.sys; + std::atomic_thread_fence(std::memory_order_acquire); + if (s_stamp.seq.load(std::memory_order_relaxed) == a) return a != 0; + } + return false; +} + +// One more sync for the drift figure; publishes it, and keeps it in flash for the example file +// the clock writes to an SD card (once an hour at most, when it rests on at least six hours). +void noteSyncForDrift() { + static uint32_t lastSaveMs = 0; + int64_t mono, sys; + if (!takeSyncStamp(&mono, &sys)) return; + s_drift.addSync(mono, sys); + const drift::Result r = s_drift.result(); + if (!r.valid) return; + { + StateLock lock; + g_state.driftKnown = true; + g_state.driftLive = true; + g_state.driftPpm = r.ppm; + g_state.driftErrPpm = r.errorPpm; + g_state.driftHours = r.spanHours; + } + LOGF(TAG, "clock drift %+.2f ppm (+-%.2f) = %+.2f s/day over %.1f h", r.ppm, r.errorPpm, r.secPerDay, r.spanHours); + const uint32_t now = millis(); + if (r.spanHours >= 6.0f && (lastSaveMs == 0 || now - lastSaveMs > 3600UL * 1000UL)) { + lastSaveMs = now ? now : 1; + waitForQuietPhase(); + s_prefs.putFloat("dppm", r.ppm); + s_prefs.putFloat("dhours", r.spanHours); + } +} + +void applyTimezone(const char *posix, const char *iana, bool persist) { + setenv("TZ", posix, 1); + tzset(); + { + StateLock lock; + copyStr(g_state.tzPosix, sizeof g_state.tzPosix, posix); + copyStr(g_state.tzName, sizeof g_state.tzName, iana); + } + if (persist) { + waitForQuietPhase(); + s_prefs.putString("tz", posix); + s_prefs.putString("tzn", iana); + } +} + +bool clockTrusted() { + StateLock lock; + return g_state.timeTrusted; +} + +// --------------------------------------------------------------------------- +// HTTPS GET with certificate verification. Returns the HTTP status (or <= 0). +// --------------------------------------------------------------------------- +int httpsGet(const String &url, String *body) { + NetworkClientSecure client; + useCaBundle(client); + HTTPClient http; + http.setConnectTimeout(8000); + http.setTimeout(10000); + http.setUserAgent(APP_NAME "/" APP_VERSION); + if (!http.begin(client, url)) return -1; + int code = http.GET(); + if (code > 0 && httpStatusHasBody(code)) *body = http.getString(); + http.end(); + return code; +} + +// --------------------------------------------------------------------------- +// Location +// --------------------------------------------------------------------------- +bool resolveLocation() { + if (g_cfg.hasLatLon()) { + StateLock lock; + g_state.lat = g_cfg.latitude; + g_state.lon = g_cfg.longitude; + copyStr(g_state.place, sizeof g_state.place, g_cfg.locationLabel); + g_state.locationKnown = true; + return true; + } + + const char *query = g_cfg.location; + if (!query[0]) { + stateSetStatus("Set a location (SD card or secrets.h)"); + LOGF(TAG, "no location configured (latitude/longitude or location)"); + return false; + } + + // reuse the answer from an earlier boot for the same query + if (s_prefs.getString("q", "") == query && s_prefs.isKey("lat") && s_prefs.isKey("lon")) { + StateLock lock; + g_state.lat = s_prefs.getDouble("lat", 0); + g_state.lon = s_prefs.getDouble("lon", 0); + copyStr(g_state.place, sizeof g_state.place, g_cfg.locationLabel[0] ? g_cfg.locationLabel : s_prefs.getString("place", "").c_str()); + g_state.locationKnown = true; + return true; + } + + char enc[3 * sizeof g_cfg.location + 1]; // every byte may become %XX + if (!urlEncode(query, enc, sizeof enc)) { + stateSetStatus("Location name too long"); + return false; + } + String url = String("https://geocoding-api.open-meteo.com/v1/search?count=1&language=en&format=json&name=") + enc; + String body; + int code = httpsGet(url, &body); + GeoResult g; + if (code != 200 || !parseOpenMeteoGeocode(body.c_str(), body.length(), &g)) { + LOGF(TAG, "geocoding \"%s\" failed (http %d)", query, code); + stateSetStatus(code == 200 ? "Location not found" : "Location lookup failed"); + return false; + } + waitForQuietPhase(); + s_prefs.putString("q", query); + s_prefs.putDouble("lat", g.lat); + s_prefs.putDouble("lon", g.lon); + s_prefs.putString("place", g.name); + LOGF(TAG, "\"%s\" -> %s, %s (%.4f, %.4f)", query, g.name, g.admin1, g.lat, g.lon); + + StateLock lock; + g_state.lat = g.lat; + g_state.lon = g.lon; + copyStr(g_state.place, sizeof g_state.place, g_cfg.locationLabel[0] ? g_cfg.locationLabel : g.name); + g_state.locationKnown = true; + return true; +} + +// --------------------------------------------------------------------------- +// Weather + time zone +// --------------------------------------------------------------------------- +void updateTimezone(const ZoneInfo &zone) { + if (s_tzFromOverride || !zone.iana[0]) return; + const char *posix = tzPosixForIana(zone.iana); + char fixed[40]; + if (!posix) { // zone name newer than the table: fall back to the current offset + calc::posixFromUtcOffset(zone.utcOffsetSec, fixed, sizeof fixed); + posix = fixed; + LOGF(TAG, "zone %s not in table, using fixed offset %s", zone.iana, fixed); + } + char current[sizeof g_state.tzPosix]; + char currentName[sizeof g_state.tzName]; + { + StateLock lock; + copyStr(current, sizeof current, g_state.tzPosix); + copyStr(currentName, sizeof currentName, g_state.tzName); + } + if (strcmp(current, posix) != 0 || strcmp(currentName, zone.iana) != 0) { + LOGF(TAG, "time zone: %s (%s)", zone.iana, posix); + applyTimezone(posix, zone.iana, true); + } +} + +bool fetchWeather() { + double lat, lon; + { + StateLock lock; + lat = g_state.lat; + lon = g_state.lon; + } + char url[460]; + snprintf(url, sizeof url, + "https://api.open-meteo.com/v1/forecast?latitude=%.4f&longitude=%.4f" + "¤t=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m" + "&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,sunrise,sunset,uv_index_max" + "&timezone=auto&forecast_days=3", + lat, lon); + String body; + int code = httpsGet(url, &body); + WeatherData w; + ZoneInfo zone; + if (code != 200 || !parseOpenMeteoForecast(body.c_str(), body.length(), &w, &zone)) { + LOGF(TAG, "weather fetch failed (http %d, %u bytes)", code, (unsigned)body.length()); + return false; + } + updateTimezone(zone); + { + StateLock lock; + g_state.weather = w; + g_state.weatherFetchedMs = millis(); + } + LOGF(TAG, "weather: %.1f C, code %d, today %.0f/%.0f", w.temp, w.code, w.day[0].tmax, w.day[0].tmin); + return true; +} + +// --------------------------------------------------------------------------- +// The task +// --------------------------------------------------------------------------- +void publishWifi(bool connected) { + // The WiFi calls take the driver's own locks and can wait on it for a while (a scan, a + // reconnect), so make them first: the UI takes StateLock every frame and must never end up + // waiting behind them. + int rssi = -100; + char ssid[sizeof g_state.ssid] = "", ip[sizeof g_state.ip] = ""; + bool backup = false; + if (connected) { + rssi = WiFi.RSSI(); + copyStr(ssid, sizeof ssid, WiFi.SSID().c_str()); + copyStr(ip, sizeof ip, WiFi.localIP().toString().c_str()); + backup = g_cfg.hasBackupWifi() && + wifipick::classify(ssid, g_cfg.wifiSsid, g_cfg.wifiBackupSsid) == wifipick::NET_BACKUP; + } + StateLock lock; + g_state.wifiUp = connected; + g_state.wifiBackup = backup; + g_state.rssi = rssi; + if (connected) { + copyStr(g_state.ssid, sizeof g_state.ssid, ssid); + copyStr(g_state.ip, sizeof g_state.ip, ip); + } else { + g_state.ip[0] = 0; + } +} + +// Is the main network in range? A scan takes a few seconds. A hidden network does not show up in +// one: then the clock simply stays on the backup until that drops. +bool mainNetworkInRange() { + const int n = WiFi.scanNetworks(false /*wait for it*/, false /*no hidden*/, false /*active*/, 120 /*ms per channel*/); + bool seen = false; + for (int i = 0; i < n && !seen; i++) seen = WiFi.RSSI(i) >= kReturnMinRssi && WiFi.SSID(i) == g_cfg.wifiSsid; + WiFi.scanDelete(); + return seen; +} + +// Drops a progress message ("Connecting to WiFi...") that the radio going off would leave behind; a failure +// message ("Weather update failed") stays until the next success. +void clearProgressStatus() { + static const char *const progress[] = {kStatusConnecting, "Syncing time...", "Finding location...", "Updating weather..."}; + StateLock lock; + for (const char *p : progress) { + if (!strcmp(g_state.status, p)) { + g_state.status[0] = 0; + return; + } + } +} + +// What the Info page says about the radio (wifi_mode = sync). `wakeInSec` is -1 when not known; `trouble` is +// how the last session ended if not well (RADIO_AWAY, RADIO_CANNOT_JOIN). +void publishRadio(bool sync, bool asleep, int32_t wakeInSec, uint32_t onMs, uint32_t sinceMs, uint32_t sessions, uint8_t trouble) { + StateLock lock; + g_state.radioSync = sync; + g_state.radioAsleep = asleep; + g_state.radioTrouble = trouble; + g_state.radioWakeInSec = wakeInSec; + g_state.radioOnPermille = sinceMs ? (uint16_t)((uint64_t)onMs * 1000 / sinceMs) : 0; + g_state.radioSessions = sessions; +} + +void netTask(void *) { + const bool haveBackup = g_cfg.hasBackupWifi(); + const bool wifiConfigured = g_cfg.hasMainWifi() || haveBackup; + const wifi_ps_type_t saveMode = g_cfg.wifiPowerSave == WIFISAVE_MAX ? WIFI_PS_MAX_MODEM : WIFI_PS_MIN_MODEM; + const bool syncMode = g_cfg.syncMode(); // wifi_mode = sync: the radio is on only for a session (radio_plan.h) + bool lowLatency = false; + + spotifyBegin(); + + bool sntpStarted = false; + bool locationOk = false; + uint32_t lastBeginMs = 0, nextLocationMs = 0, nextWeatherMs = 0, lastRssiMs = 0, syncWaitStartMs = 0; + bool wasConnected = false; + int lastYday = -1; + uint32_t seenSyncUtc = 0; + wifipick::Picker pick; // main network first, the backup when that cannot be joined (wifi_pick.h) + pick.configure(g_cfg.hasMainWifi(), haveBackup); + bool leavingBackup = false; // we dropped the backup on purpose to go back to the main network + + // wifi_mode = sync. The planner says when the radio is on; these are the timers it is told about. (net_task + // in always mode keeps none of this: SNTP runs by itself every hour and the radio never goes off.) + radioplan::Planner plan(syncMode, haveBackup ? radioplan::kConnectTimeoutMs : 25000UL); + bool radioOn = false; + bool ntpEver = false; // the network time was set at least once in this run + uint32_t ntpAtMs = 0, ntpRetryAtMs = 0 /* 0 = no wait */, lastRadioPublishMs = 0, bootMs = millis(); + int weatherFails = 0, locationFails = 0; + radioplan::Search search; // which of the clock's networks the WiFi stack could not find in this session + int attemptNet = -1; // the network of the attempt under way: 0 = main, 1 = backup, -1 = none yet + if (syncMode) WiFi.onEvent(onStationEvent, ARDUINO_EVENT_WIFI_STA_DISCONNECTED); + + auto startRadio = [&]() { + s_radioNeedsFast = true; // WiFi works from 80 MHz up: the UI loop raises the clock if it is lower + for (int i = 0; i < 300 && getCpuFrequencyMhz() < 80; i++) vTaskDelay(pdMS_TO_TICKS(10)); + // a clock that was online last time wants to hear "no such network" twice before it believes it; one + // that is away already takes the first answer (a missed router then costs only the wait for the next look) + search.begin(g_cfg.hasMainWifi(), haveBackup, plan.awayLooks() == 0 ? radioplan::kAwayScansFirst : radioplan::kAwayScansAgain); + attemptNet = -1; + s_attemptAtMs = 0; + WiFi.persistent(false); + WiFi.setHostname(g_cfg.hostname); // before mode(): turning the station on is what copies the name to the network interface + WiFi.mode(WIFI_STA); + WiFi.setAutoReconnect(true); + WiFi.setSleep(saveMode); + lowLatency = false; + wasConnected = false; + lastBeginMs = 0; + radioOn = true; + spotifyWindowBegin(); + if (syncMode) LOGF(TAG, "radio on (session %u)", (unsigned)plan.sessions() + 1); + }; + // `keepTrack`: the session did what it came for, so the track it saw stays on the screen (spotifyRadioDown) + auto stopRadio = [&](bool keepTrack) { + spotifyRadioDown(keepTrack); + if (sntpStarted) { + esp_sntp_stop(); + sntpStarted = false; + } + WiFi.disconnect(true, false); + WiFi.mode(WIFI_OFF); + pick.radioOff(); + publishWifi(false); + clearProgressStatus(); + wasConnected = false; + radioOn = false; + s_radioNeedsFast = false; // the UI loop may slow the clock down now + LOGF(TAG, "radio off (on for %u s so far, %u sessions)", (unsigned)(plan.radioOnMs(millis()) / 1000), (unsigned)plan.sessions()); + }; + + if (!syncMode && wifiConfigured) startRadio(); // always on, from the start (with no network name there is nothing to join) + + for (;;) { + const uint32_t now = millis(); + + if (g_powerDown) { // on the way to deep sleep: no new WiFi calls while the UI task switches the radio off + vTaskDelay(pdMS_TO_TICKS(500)); + continue; + } + + if (!wifiConfigured) { + s_radioNeedsFast = false; // no radio, ever + stateSetStatus("No WiFi name set (see README)"); + publishWifi(false); + spotifyTick(); // answers key presses with "no network" instead of queueing them + vTaskDelay(pdMS_TO_TICKS(1000)); + continue; + } + + { // the forecast days shift at local midnight: refresh then instead of up to 15 min later + time_t t = time(nullptr); + struct tm lt; + localtime_r(&t, <); + if (lastYday >= 0 && lt.tm_yday != lastYday) s_weatherNow = true; + lastYday = lt.tm_yday; + } + + // Work that has been due for weeks (no network all that time) stays due: a time more than 24.8 days back + // would otherwise look like one still to come when millis() wraps. + radioplan::keepDue(now, &nextLocationMs); + radioplan::keepDue(now, &nextWeatherMs); + radioplan::keepDue(now, &ntpAtMs); + if (ntpRetryAtMs != 0 && radioplan::due(now, ntpRetryAtMs)) ntpRetryAtMs = 0; + + if (syncMode) { + // What needs the radio? (the timers are the ones of the always mode, plus the hourly network time) + radioplan::Inputs in; + in.online = radioOn && WiFi.status() == WL_CONNECTED; + const bool ntpWake = (!ntpEver || radioplan::due(now, ntpAtMs + radioplan::kNtpGraceMs)) && ntpRetryAtMs == 0; + const bool placeWake = !locationOk && radioplan::due(now, nextLocationMs); + const bool weatherWake = locationOk && (s_weatherNow || radioplan::due(now, nextWeatherMs)); + in.workDue = ntpWake || placeWake || weatherWake || spotifyPeekPending() || spotifyLookDue(); + in.holdSpotify = spotifyWantsRadio(); + uint32_t demandUntil = s_demandUntilMs.load(); + in.demand = radioplan::pending(now, demandUntil); + if (demandUntil != 0 && !in.demand) s_demandUntilMs.compare_exchange_strong(demandUntil, 0); // over (unless a new press just came) + in.demandSeq = s_demandSeq.load(); + in.absent = radioOn && search.noneInRange(); + if (s_lookNow.exchange(false)) plan.lookNow(); + const bool wantOn = plan.update(now, in); + if (wantOn && !radioOn) startRadio(); + if (!wantOn && radioOn) stopRadio(plan.lastOutcome() == radioplan::Planner::DONE); + if (plan.takeGaveUp()) { + const bool away = plan.lastOutcome() == radioplan::Planner::AWAY; + const unsigned waitSec = (unsigned)((plan.retryAtMs() - now) / 1000); + if (away) { + LOGF(TAG, "none of the clock's networks is in range: looking again in %u s", waitSec); + } else { + LOGF(TAG, "could not join WiFi (%d in a row): next try in %u s", plan.failures(), waitSec); + } + spotifyFlushQueue(away ? "Spotify: no WiFi in range" : "Spotify: no network"); + stateSetStatus("%s", away ? kStatusAway : kStatusFailed); + } + if (now - lastRadioPublishMs >= 1000) { + lastRadioPublishMs = now ? now : 1; + int32_t wake = -1; + if (!radioOn) { // the earliest of what is due, and not before the back-off after a failed session ends + uint32_t earliest = now + 3600000UL; + bool any = false; + auto consider = [&](uint32_t at) { + if (!any || (int32_t)(at - earliest) < 0) earliest = at; + any = true; + }; + consider(ntpEver ? ntpAtMs + radioplan::kNtpGraceMs : now); + if (locationOk) consider(s_weatherNow ? now : nextWeatherMs); + else consider(nextLocationMs); + uint32_t at = earliest; + if ((int32_t)(plan.retryAtMs() - at) > 0 && plan.retryAtMs() != 0) at = plan.retryAtMs(); + wake = (int32_t)(at - now) > 0 ? (int32_t)((at - now) / 1000) : 0; + } + const uint8_t trouble = plan.lastOutcome() == radioplan::Planner::AWAY ? RADIO_AWAY + : plan.lastOutcome() == radioplan::Planner::FAILED ? RADIO_CANNOT_JOIN + : RADIO_FINE; + publishRadio(true, !radioOn, wake, plan.radioOnMs(now), now - bootMs, plan.sessions(), trouble); + } + if (!radioOn) { + vTaskDelay(pdMS_TO_TICKS(100)); + continue; + } + } + + const bool connected = WiFi.status() == WL_CONNECTED; + if (connected != wasConnected || (connected && now - lastRssiMs > 3000)) { + publishWifi(connected); + lastRssiMs = now; + if (connected != wasConnected) { + if (connected) { + LOGF(TAG, "WiFi connected: %s, %s, %d dBm", WiFi.SSID().c_str(), WiFi.localIP().toString().c_str(), + WiFi.RSSI()); + pick.joined(wifipick::classify(WiFi.SSID().c_str(), g_cfg.wifiSsid, g_cfg.wifiBackupSsid), now); + search.forget(); + } else { + LOGF(TAG, "WiFi disconnected"); + pick.lost(); + // a drop the stack did not cause gets its own auto-reconnect a 20 s head start; going back to the main + // network on purpose starts the attempt at once + lastBeginMs = leavingBackup ? 0 : (now ? now : 1); + leavingBackup = false; + search.forget(); // what the stack answers from here on is about the reconnect, to the network it was on + beginAttemptCount(); + } + } + wasConnected = connected; + } + + if (!connected) { + // (a clock that found none of its networks keeps saying so while it looks again: a look is a few seconds) + stateSetStatus("%s", syncMode && plan.lastOutcome() == radioplan::Planner::AWAY ? kStatusAway : kStatusConnecting); + // Sync mode: a network the WiFi stack says is not there is not waited for. The other one is tried at + // once, and when neither is there the planner ends the session (Inputs::absent, next time round). + bool moveOn = false; + if (syncMode && attemptNet >= 0) moveOn = search.note(attemptNet, s_notFound.load(), s_otherDrops.load()) && !search.noneInRange(); + if (lastBeginMs == 0 || now - lastBeginMs > 20000 || moveOn) { // (re)start the association + const bool backup = pick.nextAttempt() == wifipick::NET_BACKUP; + const char *ssid = backup ? g_cfg.wifiBackupSsid : g_cfg.wifiSsid; + const char *pass = backup ? g_cfg.wifiBackupPassword : g_cfg.wifiPassword; + LOGF(TAG, "WiFi: trying %s network \"%s\"%s", backup ? "the backup" : "the main", ssid, moveOn ? " (the one tried before is not in range)" : ""); + WiFi.disconnect(); + WiFi.begin(ssid, pass[0] ? pass : nullptr); // no password: an open network + lastBeginMs = now ? now : 1; + attemptNet = backup ? 1 : 0; + beginAttemptCount(); + } + if (!syncMode) spotifyTick(); // answers key presses with "no network" instead of queueing them (in sync mode they wait for the radio) + vTaskDelay(pdMS_TO_TICKS(250)); + continue; + } + + { // associated again: drop the "Connecting" message, or the one that said there was no network + StateLock lock; + if (!strcmp(g_state.status, kStatusConnecting) || !strcmp(g_state.status, kStatusAway) || !strcmp(g_state.status, kStatusFailed)) g_state.status[0] = 0; + } + + // The radio sleeps between beacons to save power; while the Spotify linking page is being + // served it stays awake so the page answers at once. + const bool wantLowLatency = spotifyLowLatencyWanted(); + if (wantLowLatency != lowLatency) { + lowLatency = wantLowLatency; + WiFi.setSleep(lowLatency ? WIFI_PS_MIN_MODEM : saveMode); + } + + // On the backup: now and then look for the main network, and go back to it when it is there (not while + // the Spotify setup page is being served: a scan would stall it). + if (!lowLatency && pick.timeToLookForMain(now)) { + const bool seen = mainNetworkInRange(); + pick.lookedForMain(now, seen); + if (seen) { + LOGF(TAG, "the main WiFi is back in range: leaving the backup"); + leavingBackup = true; + WiFi.disconnect(); + vTaskDelay(pdMS_TO_TICKS(250)); + continue; // the next pass sees the drop and starts on the main network at once + } + } + + // The network time. Always mode: SNTP runs from the first connection on and syncs by itself every hour. + // Sync mode: it is started in a session that is near the hour (or has none yet), and stopped again once + // it has answered, or after twenty seconds without an answer (tried again in five minutes). + if (!sntpStarted && (!syncMode || ((!ntpEver || radioplan::due(now, ntpAtMs - radioplan::kNtpEarlyMs)) && ntpRetryAtMs == 0))) { + char tz[sizeof g_state.tzPosix]; + { + StateLock lock; + copyStr(tz, sizeof tz, g_state.tzPosix); + } + sntp_set_time_sync_notification_cb(onTimeSync); + if (g_cfg.ntpServer[0]) { // the chosen server first, two of the usual ones behind it + configTzTime(tz[0] ? tz : "UTC0", g_cfg.ntpServer, NTP_SERVER_1, NTP_SERVER_2); + } else { + configTzTime(tz[0] ? tz : "UTC0", NTP_SERVER_1, NTP_SERVER_2, NTP_SERVER_3); + } + sntp_set_sync_interval(3600UL * 1000UL); // the SDK default is longer; an hour keeps the RTC tight + sntpStarted = true; + syncWaitStartMs = now; + } + + const uint32_t syncUtc = s_lastSyncUtc; + if (syncUtc != seenSyncUtc) { // a (new) NTP sync completed + seenSyncUtc = syncUtc; + { + StateLock lock; + g_state.ntpSynced = true; + g_state.timeTrusted = true; + g_state.lastNtpSyncUtc = (time_t)syncUtc; + } + LOGF(TAG, "NTP synced"); // never log while holding the lock: a stalled serial port would freeze the UI + noteSyncForDrift(); + ntpEver = true; + ntpAtMs = now + 3600UL * 1000UL; + if (syncMode && sntpStarted) { // done for this session + esp_sntp_stop(); + sntpStarted = false; + } + } else if (syncMode && sntpStarted && now - syncWaitStartMs > 20000) { + LOGF(TAG, "NTP: no reply in 20 s, trying again in 5 minutes"); + esp_sntp_stop(); + sntpStarted = false; + ntpRetryAtMs = (now + 300000UL) | 1u; + } + + // TLS needs a believable clock: wait for NTP (or a valid RTC reading). + if (!clockTrusted()) { + stateSetStatus(now - syncWaitStartMs > 20000 ? "No NTP reply (UDP 123 blocked?)" : "Syncing time..."); + spotifyTick(); + vTaskDelay(pdMS_TO_TICKS(250)); + continue; + } + + if (!locationOk && (int32_t)(now - nextLocationMs) >= 0) { + stateSetStatus("Finding location..."); + locationOk = resolveLocation(); + if (!locationOk) nextLocationMs = now + (syncMode ? radioplan::retryAfterMs(++locationFails) : 30000UL); + } + + if (locationOk && (s_weatherNow || (int32_t)(now - nextWeatherMs) >= 0)) { + stateSetStatus("Updating weather..."); + s_weatherNow = false; + bool ok = fetchWeather(); + if (ok) weatherFails = 0; + nextWeatherMs = millis() + (ok ? (uint32_t)g_cfg.weatherIntervalMin * 60UL * 1000UL + : (syncMode ? radioplan::retryAfterMs(++weatherFails) : WEATHER_RETRY_MS)); + stateSetStatus(ok ? "" : "Weather update failed"); + } else if (locationOk) { + // keep a stale failure message only until the next success + StateLock lock; + if (!strcmp(g_state.status, "Syncing time...") || !strcmp(g_state.status, "Finding location...")) g_state.status[0] = 0; + } + + spotifyTick(); + vTaskDelay(pdMS_TO_TICKS(40)); + } +} + +} // namespace + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- +void netLoadSettings() { + s_prefs.begin("clock", false); + + // what the status bar can say before the network is up: the label from the settings, or the + // place found by an earlier lookup of the same query + { + char place[sizeof g_state.place] = ""; + if (g_cfg.locationLabel[0]) { + copyStr(place, sizeof place, g_cfg.locationLabel); + } else if (!g_cfg.hasLatLon() && g_cfg.location[0] && s_prefs.getString("q", "") == g_cfg.location) { + copyStr(place, sizeof place, s_prefs.getString("place", "").c_str()); + } + // the drift measured on an earlier run, for the Info page + const float ppm = s_prefs.getFloat("dppm", NAN); + StateLock lock; + copyStr(g_state.place, sizeof g_state.place, place); + if (!isnan(ppm)) { + g_state.driftKnown = true; + g_state.driftPpm = ppm; + g_state.driftHours = s_prefs.getFloat("dhours", 0); + } + } + + if (g_cfg.timezone[0]) { // an IANA name or a POSIX rule from the settings + s_tzFromOverride = true; + char canon[48]; + const char *posix = tzFindIanaIgnoreCase(g_cfg.timezone, canon, sizeof canon); + applyTimezone(posix ? posix : g_cfg.timezone, posix ? canon : "(set in settings)", false); + return; + } + String tz = s_prefs.getString("tz", ""); + if (tz.length()) { + applyTimezone(tz.c_str(), s_prefs.getString("tzn", "").c_str(), false); + LOGF(TAG, "saved time zone: %s", tz.c_str()); + } else { + applyTimezone("UTC0", "UTC", false); + } +} + +void netBegin() { + if (!g_cfg.wifi) { // WiFi off in the settings: no radio, no network time, weather or Spotify + stateSetStatus("WiFi off"); + LOGF(TAG, "WiFi is switched off in the settings"); + return; + } + s_radioNeedsFast = true; // until the network task knows better (the first session, or no radio at all) + xTaskCreatePinnedToCore(netTask, "net", 16384, nullptr, 1, nullptr, 0); +} + +bool netTakeRtcWriteRequest() { + bool p = s_rtcWriteRequest; + s_rtcWriteRequest = false; + return p; +} + +void netRequestWeatherRefresh() { s_weatherNow = true; } + +void netWake() { + s_demandSeq.fetch_add(1); + s_demandUntilMs.store(radioplan::demandUntil(millis())); +} + +void netNudge() { s_lookNow.store(true); } + +bool netRadioNeedsFastClock() { return s_radioNeedsFast; } diff --git a/02_Example/Arduino/11_Clock_Dashboard/net_task.h b/02_Example/Arduino/11_Clock_Dashboard/net_task.h new file mode 100644 index 00000000..f832347d --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/net_task.h @@ -0,0 +1,33 @@ +#pragma once + +// Network task: WiFi, NTP, location, weather, time zone and Spotify. +// Runs on core 0 next to the WiFi stack so the UI loop on core 1 never waits on +// the network. + +#include +#include + +// Restores the saved time zone / location from NVS (or applies the time zone from the settings). +// Call once from setup(), after the settings are loaded and before netBegin(). +void netLoadSettings(); + +// Starts the network task (not at all when WiFi is switched off in the settings). +void netBegin(); + +// True once per NTP sync: the UI task should then store the time in the RTC. +bool netTakeRtcWriteRequest(); + +// Fetch the weather again as soon as possible. +void netRequestWeatherRefresh(); + +// wifi_mode = sync: a key press (or any other wish for an answer from the network) switches the radio on and +// holds it for 45 seconds. Does nothing in always mode. Any task may call it. +void netWake(); + +// wifi_mode = sync: someone is using the clock (any button). If it found none of its networks last time it +// looks again now rather than at the next look, so a hotspot that was just switched on is picked up at once. +// Does nothing when the clock has a network, or in always mode. Any task may call it. +void netNudge(); + +// True while the radio is on or about to be: the CPU clock must not go below 80 MHz then (clock_policy.h). +bool netRadioNeedsFastClock(); diff --git a/02_Example/Arduino/11_Clock_Dashboard/power.cpp b/02_Example/Arduino/11_Clock_Dashboard/power.cpp new file mode 100644 index 00000000..a66eb13d --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/power.cpp @@ -0,0 +1,108 @@ +#include "power.h" + +#include +#include +#include +#include +#include +#include + +#include "button_edges.h" +#include "config.h" +#include "fw_update.h" +#include "log.h" +#include "low_battery.h" + +volatile bool g_powerDown = false; + +namespace { + +const char *const TAG = "power"; +const uint32_t kLowMagic = 0x4C4F5742; // "LOWB" +RTC_DATA_ATTR uint32_t s_lowShutdown = 0; // kLowMagic after a low-battery shutdown; survives deep sleep, not a power cut + +// The lines that must not float while the chip sleeps, and the level each idles at: the display's +// chip select, reset and data/command (high), its clock and data (low) and the audio amplifier's +// enable (low: off). The pad hold is what keeps them there. +struct Pad { + int pin; + int level; +}; +const Pad kPads[] = {{PIN_LCD_CS, 1}, {PIN_LCD_RST, 1}, {PIN_LCD_DC, 1}, {PIN_LCD_SCK, 0}, {PIN_LCD_MOSI, 0}, {PIN_AUDIO_AMP, 0}}; + +// Output at a fixed level. The IDF calls rather than Arduino's pinMode(): once the SPI bus owns +// a pad, pinMode() detaches it from the bus, which takes the bus lock that the display driver +// holds (see ST7305_U8g2::busRelease()), and the task waits for itself for ever. +void driveLevel(const Pad &p) { + esp_rom_gpio_connect_out_signal((uint32_t)p.pin, SIG_GPIO_OUT_IDX, false, false); // the pad's own GPIO output, not a peripheral's + gpio_set_direction((gpio_num_t)p.pin, GPIO_MODE_OUTPUT); + gpio_set_level((gpio_num_t)p.pin, p.level); +} + +} // namespace + +void powerSetCpuMhz(int mhz) { + if ((int)getCpuFrequencyMhz() != mhz) setCpuFrequencyMhz((uint32_t)mhz); +} + +void powerAfterWake() { + // The holds outlive deep sleep and also a watchdog reset, so this runs after every kind of + // start. gpio_hold_dis() hands a pad to its reset default (an input), so each one is driven + // to the level it was held at first. + for (const Pad &p : kPads) driveLevel(p); + gpio_deep_sleep_hold_dis(); + for (const Pad &p : kPads) gpio_hold_dis((gpio_num_t)p.pin); + if (esp_sleep_get_wakeup_cause() != ESP_SLEEP_WAKEUP_UNDEFINED) rtc_gpio_deinit((gpio_num_t)PIN_KEY); +} + +void powerQuietAudioPins() { + // The clock never starts I2S, so the lines that go out to the audio chips would float, and an input that + // floats can draw current. The chip's own pull-downs give them a level: weak, so nothing fights if a + // chip should drive a line after all. + for (int pin : {PIN_I2S_MCLK, PIN_I2S_BCLK, PIN_I2S_WS, PIN_I2S_DOUT}) pinMode(pin, INPUT_PULLDOWN); +} + +bool powerWokeFromTimer() { return esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_TIMER; } + +bool powerWasLowShutdown() { return s_lowShutdown == kLowMagic; } + +void powerForgetLowShutdown() { s_lowShutdown = 0; } + +[[noreturn]] void powerDeepSleep(bool markLowShutdown) { + fwConfirmNow(); // a firmware that puts itself to sleep on purpose has started properly; the next reset is no failure + g_powerDown = true; + buttonEdgesEnd(); // KEY becomes a wake-up pin below + if (markLowShutdown) s_lowShutdown = kLowMagic; + delay(50); // a WiFi call the network task is in the middle of gets a moment to finish + WiFi.disconnect(true, false); + WiFi.mode(WIFI_OFF); + + for (const Pad &p : kPads) { + driveLevel(p); + gpio_hold_en((gpio_num_t)p.pin); + } + gpio_deep_sleep_hold_en(); + + // Ways out: the timer (to look at the battery) and KEY, pulled up on the chip. A KEY that is + // down right now (still held from the press that woke us, or stuck) would wake the chip at + // once and for ever, so it is given a few seconds to come up and left out if it does not. + esp_sleep_enable_timer_wakeup((uint64_t)lowbat::kSleepCheckSec * 1000000ULL); + pinMode(PIN_KEY, INPUT_PULLUP); + for (int i = 0; i < 300 && digitalRead(PIN_KEY) == LOW; i++) delay(10); + if (digitalRead(PIN_KEY) == HIGH) { + rtc_gpio_init((gpio_num_t)PIN_KEY); + rtc_gpio_set_direction((gpio_num_t)PIN_KEY, RTC_GPIO_MODE_INPUT_ONLY); + rtc_gpio_pulldown_dis((gpio_num_t)PIN_KEY); + rtc_gpio_pullup_en((gpio_num_t)PIN_KEY); + esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); // the pull-up needs it + const esp_err_t err = esp_sleep_enable_ext1_wakeup_io(1ULL << PIN_KEY, ESP_EXT1_WAKEUP_ANY_LOW); + if (err != ESP_OK) LOGF(TAG, "KEY wake-up not armed: 0x%x", (unsigned)err); + } else { + LOGF(TAG, "KEY is held down: sleeping on the timer alone"); + } + + Serial.flush(); + esp_deep_sleep_start(); + for (;;) { // not reached + } +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/power.h b/02_Example/Arduino/11_Clock_Dashboard/power.h new file mode 100644 index 00000000..4036d092 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/power.h @@ -0,0 +1,30 @@ +#pragma once + +// Power: the CPU clock, and the low-battery shutdown (deep sleep with a timer and the KEY button +// as ways out). See low_battery.h for when the shutdown happens and when the clock starts again. + +#include + +// Sets the CPU clock (80, 160 or 240 MHz; WiFi works from 80 up). Does nothing if it is already +// there. Once the display is running, wrap the call in ST7305_U8g2::busRelease() / busAcquire(): +// the core tells every SPI bus about a clock change and waits for its lock. +void powerSetCpuMhz(int mhz); + +// First thing in setup(): takes the display pins back from a deep sleep (their levels are +// carried over, so nothing floats in between). The audio amplifier's enable is among them, held low (off). +void powerAfterWake(); + +// Gives the I2S lines to the unused audio chips a level (the chip's weak pull-downs), so they do not float. +void powerQuietAudioPins(); +bool powerWokeFromTimer(); // woken by the five minute battery check, not by a person +bool powerWasLowShutdown(); // the last run ended in a low-battery shutdown (kept through deep sleep) +void powerForgetLowShutdown(); + +// True once the clock is on its way to deep sleep: the network task then leaves the radio alone. +extern volatile bool g_powerDown; + +// Deep sleep; never returns. The display keeps its picture, the timer wakes the chip every few +// minutes to look at the battery, and a press of KEY wakes it too (BOOT cannot: GPIO0 is the +// download-mode strapping pin). markLowShutdown remembers "this was a low-battery shutdown", so +// the restart waits for the battery to recover; false is for testing the sleep itself. +[[noreturn]] void powerDeepSleep(bool markLowShutdown); diff --git a/02_Example/Arduino/11_Clock_Dashboard/power_log.h b/02_Example/Arduino/11_Clock_Dashboard/power_log.h new file mode 100644 index 00000000..6c6e435f --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/power_log.h @@ -0,0 +1,588 @@ +#pragma once + +// The power log: the readings of the two info pages, written down by the clock itself as one line of CSV at +// each start and every ten minutes after it, so that a battery run no longer has to be noted by hand (README: +// "Logging a battery run"). The columns are those of the sheet docs/power-log.csv, by the same names and in +// the same order, so a line can be pasted under the sheet's heading and one script reads both. The ones the +// clock cannot know (the name of the run, whether the card is still in, what a meter reads) stay empty. +// +// This file is the part that runs on a PC as well, and tools/tests runs it there: +// * the heading and the line (formatHeader, formatLine); +// * when a line is due (Schedule); +// * what happens to the files: a line appended, the oldest lines dropped once the log is full, the whole +// log read back in one piece (appendLine, exportLog). They are written against a small file interface, +// so the very same steps run on a made-up file system that can be told to fail; +// * the files of a real folder behind that interface (FolderFs: plain POSIX calls), which on the clock is +// the mounted flash partition (app_power_log.cpp) and in the tests a folder of the PC; +// * what the Power and settings page says about the log (statusRows). +// +// The log is kept in two files, so that dropping the oldest lines never means writing the log out again: +// lines go to kFile until it holds half of kMaxBytes; then kOldFile is deleted, kFile becomes kOldFile and a +// new kFile is begun. What is kept is therefore the newest half to the whole of kMaxBytes. Each file starts +// with the heading its lines were written under, and a file begun by a firmware with other columns is put +// aside the same way, so no line ever stands under a heading that is not its own. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "app_model.h" +#include "battery_est.h" +#include "settings.h" + +namespace powerlog { + +const uint32_t kIntervalSec = 600; // a line this often ... +const uint32_t kFirstSec = 10; // ... and one this long after a start, when the first frames are out +const uint32_t kMaxBytes = 1024 * 1024; // the two files together never hold more +const int kMaxFailures = 3; // writes in a row that may fail before the log gives up for this run +const size_t kMaxLine = 512; // a line, or the heading, with its line end and a terminator +// A write takes a tenth of a second or a few, and has to be over before the coming second is drawn +// (frame_plan.h): it only starts this early in a second. +const int32_t kLatestStartUs = 200000; + +const char *const kFile = "powerlog.csv"; // the newest lines +const char *const kOldFile = "powerlog.old"; // the ones before them + +// --------------------------------------------------------------------------- +// The columns: those of docs/power-log.csv (a test compares the two headings) +// --------------------------------------------------------------------------- +enum Column : uint8_t { + C_TEST = 0, // (not known to the clock: the name of the run) + C_FIRMWARE, + C_WIFI, // the six settings a run is made with, by the names and in the words of the settings file + C_WIFI_MODE, + C_WIFI_POWER_SAVE, + C_CPU_IDLE_MHZ, + C_SPOTIFY_LIVE, + C_CONSOLE, + C_BATTERY_CAPACITY_MAH, + C_CARD, // (not known: the card is only looked at while the clock starts) + C_MUSIC, + C_DATE, + C_TIME, + C_UPTIME, + C_CLOCK_SHOWN, + C_RADIO_ON_PCT, + C_RADIO_SESSIONS, + C_BATTERY_V, + C_BATTERY_PCT, + C_BATTERY_STATE, + C_LEFT, + C_PCT_PER_HOUR, + C_OVER_MIN, + C_CLOCK_MA, + C_METER_MA, // (not known: what a meter in the supply reads) + C_FREE_KB, + C_FRAMES_SENT, + C_FRAMES_LATE, + C_FRAMES_WORST_MS, + C_NOTES, + kColumnCount +}; + +inline const char *columnName(int c) { + static const char *const names[] = { + "test", "firmware", "wifi", "wifi_mode", "wifi_power_save", "cpu_idle_mhz", + "spotify_live", "console", "battery_capacity_mah", "card", "music", + "date", "time", "uptime", "clock_shown", "radio_on_pct", "radio_sessions", + "battery_v", "battery_pct", "battery_state", "left", "pct_per_hour", + "over_min", "clock_ma", "meter_ma", "free_kb", "frames_sent", "frames_late", + "frames_worst_ms", "notes"}; + static_assert(sizeof names / sizeof names[0] == kColumnCount, "one name for each column"); + return (c >= 0 && c < kColumnCount) ? names[c] : ""; +} + +// What the clock knew when the line was taken. Plain values: the text is made by formatLine(). +struct Reading { + const char *version = ""; // APP_VERSION + const char *buildId = ""; // the build id of the System info page + const Settings *settings = nullptr; // the settings in force + SpotifyStatus spotify = SPOTIFY_DISABLED; + + bool timeValid = false; // the clock is trusted: `local` is the date and time + struct tm local = {}; + uint32_t uptimeSec = 0; + + int cpuMhz = 0; // the CPU clock right now ... + const char *clockReason = ""; // ... and why it is there ("idle", "radio on", ...); "" when no idle clock is set + + bool radioSync = false; // wifi_mode = sync with the radio in use: the two figures below mean something + uint16_t radioOnPermille = 0; + uint32_t radioSessions = 0; + + bool batteryPresent = false; + float batteryVolts = 0; + int charge = CHARGE_UNKNOWN; // UiCharge + bool chargeWarmingUp = false; // the charge detector: the first minutes after a start are not used ... + bool chargeReady = false; // ... and it has enough readings to judge by + battest::Estimate estimate; // the runtime estimate (state OFF when there is none) + + uint32_t freeHeapBytes = 0; + uint32_t framesSent = 0, framesLate = 0; + int32_t framesWorstMs = 0; + + const char *note = ""; // "start (power on)" on the line of a start +}; + +// The setting `key` as the settings file has it ("sync", "20", "off"); "" if there is no such setting. +inline void settingText(const Settings &s, const char *key, char *out, size_t cap) { + if (cap == 0) return; + out[0] = 0; + for (size_t i = 0; i < settingCount(); i++) { + if (strcmp(settingKey(i), key) != 0) continue; + if (!formatSetting(s, i, out, cap)) out[0] = 0; + return; + } +} + +// What the System info page says the battery is doing, in a word or two. +inline const char *batteryStateText(bool present, int charge, bool warmingUp, bool ready) { + if (!present) return "none"; + switch (charge) { + case CHARGE_CHARGING: return "charging"; + case CHARGE_DISCHARGING: return "discharging"; + case CHARGE_FULL: return "full"; + default: break; + } + // nothing seen of a charger, and no fall seen either (under a light load that is how it stays) + return warmingUp ? "starting up" : (ready ? "on battery?" : "learning"); +} + +// One field as CSV wants it: as it is, or in quotes (with the quotes inside doubled) when it holds a comma, +// a quote or a line end. Returns the length, or 0 if it does not fit (an empty field is 0 too, and fits). +inline size_t csvField(const char *text, char *out, size_t cap) { + const bool quote = strpbrk(text, ",\"\r\n") != nullptr; + size_t n = 0; + auto put = [&](char c) { + if (n + 1 < cap) out[n] = c; + n++; + }; + if (quote) put('"'); + for (const char *p = text; *p; ++p) { + if (*p == '"') put('"'); + put(*p); + } + if (quote) put('"'); + if (cap == 0 || n + 1 > cap) return 0; + out[n] = 0; + return n; +} + +const char *const kLineEnd = "\r\n"; // what spreadsheets and Notepad expect of a CSV file + +// The heading: the column names. Returns the length, or 0 if `cap` is too small. +inline size_t formatHeader(char *out, size_t cap) { + size_t n = 0; + for (int c = 0; c < kColumnCount; c++) { + const int w = snprintf(out + n, cap - n, "%s%s", c ? "," : "", columnName(c)); + if (w < 0 || n + (size_t)w >= cap) return 0; + n += (size_t)w; + } + const int w = snprintf(out + n, cap - n, "%s", kLineEnd); + if (w < 0 || n + (size_t)w >= cap) return 0; + return n + (size_t)w; +} + +// One line. Returns the length, or 0 if `cap` is too small (nothing useful is in `out` then). +inline size_t formatLine(const Reading &r, char *out, size_t cap) { + const size_t kField = 32; + char f[kColumnCount][kField]; + for (int c = 0; c < kColumnCount; c++) f[c][0] = 0; + + // which firmware, which settings + snprintf(f[C_FIRMWARE], kField, "%s%s%s", r.version, r.buildId[0] ? " " : "", r.buildId); + if (r.settings) { + for (int c : {C_WIFI, C_WIFI_MODE, C_WIFI_POWER_SAVE, C_CPU_IDLE_MHZ, C_SPOTIFY_LIVE, C_CONSOLE}) { + settingText(*r.settings, columnName(c), f[c], kField); // (the column is named after its setting) + } + // (0 means "not known", and what is not known is left empty) + if (r.settings->batteryCapacityMah > 0) snprintf(f[C_BATTERY_CAPACITY_MAH], kField, "%d", (int)r.settings->batteryCapacityMah); + } + switch (r.spotify) { // what Spotify said last; with no account linked the clock does not know + case SPOTIFY_PLAYING: snprintf(f[C_MUSIC], kField, "playing"); break; + case SPOTIFY_PAUSED: snprintf(f[C_MUSIC], kField, "paused"); break; + case SPOTIFY_IDLE: snprintf(f[C_MUSIC], kField, "none"); break; + default: break; + } + + // when + if (r.timeValid) { + snprintf(f[C_DATE], kField, "%04d-%02d-%02d", (r.local.tm_year + 1900) % 10000, (r.local.tm_mon + 1) % 100, r.local.tm_mday % 100); + snprintf(f[C_TIME], kField, "%02d:%02d", r.local.tm_hour % 100, r.local.tm_min % 100); + } + snprintf(f[C_UPTIME], kField, "%ud %uh %um", (unsigned)(r.uptimeSec / 86400), (unsigned)((r.uptimeSec / 3600) % 24), + (unsigned)((r.uptimeSec / 60) % 60)); + + // the CPU clock and the radio + if (r.cpuMhz > 0) snprintf(f[C_CLOCK_SHOWN], kField, "%d%s%.20s", r.cpuMhz % 1000, r.clockReason[0] ? " " : "", r.clockReason); + if (r.radioSync) { + snprintf(f[C_RADIO_ON_PCT], kField, "%.1f", r.radioOnPermille / 10.0); + snprintf(f[C_RADIO_SESSIONS], kField, "%u", (unsigned)r.radioSessions); + } + + // the battery, and what the runtime estimate makes of it (the Left and Current lines of the pages) + snprintf(f[C_BATTERY_STATE], kField, "%s", batteryStateText(r.batteryPresent, r.charge, r.chargeWarmingUp, r.chargeReady)); + if (r.batteryPresent) { + snprintf(f[C_BATTERY_V], kField, "%.3f", (double)r.batteryVolts); + snprintf(f[C_BATTERY_PCT], kField, "%.1f", (double)calc::batteryPercentF(r.batteryVolts)); + const battest::Estimate &e = r.estimate; + if (r.charge != CHARGE_CHARGING && r.charge != CHARGE_FULL && e.state == battest::Estimate::READY) { + if (e.pctPerHour < battest::kMinRatePctPerHour) { + snprintf(f[C_LEFT], kField, ">30 d"); // no fall that can be measured: the page says "more than a month" + } else { + battest::formatRemaining(e.hoursLeft, f[C_LEFT], kField); + } + if (e.pctPerHour < 9.995f) { // two decimals where the page has two + snprintf(f[C_PCT_PER_HOUR], kField, "%.2f", (double)e.pctPerHour); + } else { + snprintf(f[C_PCT_PER_HOUR], kField, "%.1f", (double)e.pctPerHour); + } + snprintf(f[C_OVER_MIN], kField, "%d", e.windowMin); + if (r.settings && r.settings->batteryCapacityMah > 0) { // the current needs the capacity + if (e.avgMa < 99.95f) { + snprintf(f[C_CLOCK_MA], kField, "%.1f", (double)e.avgMa); + } else { + snprintf(f[C_CLOCK_MA], kField, "%.0f", (double)e.avgMa); + } + } + } + } + + // memory, and whether the seconds came on time + snprintf(f[C_FREE_KB], kField, "%u", (unsigned)(r.freeHeapBytes / 1024)); + snprintf(f[C_FRAMES_SENT], kField, "%u", (unsigned)r.framesSent); + snprintf(f[C_FRAMES_LATE], kField, "%u", (unsigned)r.framesLate); + snprintf(f[C_FRAMES_WORST_MS], kField, "%d", (int)r.framesWorstMs); + snprintf(f[C_NOTES], kField, "%s", r.note); + + size_t n = 0; + for (int c = 0; c < kColumnCount; c++) { + if (c) { + if (n + 1 >= cap) return 0; + out[n++] = ','; + } + if (!f[c][0]) continue; + const size_t w = csvField(f[c], out + n, cap - n); + if (w == 0) return 0; + n += w; + } + const size_t endLen = strlen(kLineEnd); + if (n + endLen >= cap) return 0; + memcpy(out + n, kLineEnd, endLen + 1); + return n + endLen; +} + +// --------------------------------------------------------------------------- +// When a line is due +// --------------------------------------------------------------------------- +// The first line kFirstSec after the start, the next ones kIntervalSec after the one before: the flash is +// never written more often than that, also when a write fails (the next try is a whole interval later). +// After kMaxFailures failed writes in a row the log gives up until the next start. Times are seconds since +// the start; only differences are taken, so a counter that runs over does no harm. +class Schedule { + public: + bool due(uint32_t nowSec) const { + if (stopped_) return false; + return begun_ ? (uint32_t)(nowSec - lastSec_) >= kIntervalSec : nowSec >= kFirstSec; + } + bool startLine() const { return !begun_; } // the line that comes next is the one of the start + + // A line was written (`ok`), or could not be. + void done(uint32_t nowSec, bool ok) { + begun_ = true; + lastSec_ = nowSec; + if (ok) { + failures_ = 0; + lines_++; + } else if (++failures_ >= kMaxFailures) { + stopped_ = true; + } + } + void stop() { stopped_ = true; } // nothing to write to: no line is ever due + + bool stopped() const { return stopped_; } + int failures() const { return failures_; } + uint32_t lines() const { return lines_; } // lines written since the start + uint32_t lastSec() const { return lastSec_; } // when the last one was written or tried + + private: + bool begun_ = false, stopped_ = false; + uint32_t lastSec_ = 0, lines_ = 0; + int failures_ = 0; +}; + +// --------------------------------------------------------------------------- +// The files +// --------------------------------------------------------------------------- +// Written against two small interfaces (file names are those above, in one folder): +// +// Fs long size(name) the file's length, or -1 if there is no such file +// long read(name, offset, buf, n) the bytes read: n, or fewer at the end of the file; -1 on an error +// bool append(name, data, n) at the end of the file, which is made if there is none; true only +// when all of it is in the file for good +// bool remove(name) true when the file is gone, also when there was none +// bool rename(from, to) there is no file `to` +// Sink bool write(data, n) +// +// Every read names its place in the file, and nothing is kept open between two calls. + +enum class Append : uint8_t { FAILED = 0, OK, ROTATED }; // ROTATED: written, and the oldest lines were dropped first +struct Appended { + Append what = Append::FAILED; + uint32_t logBytes = 0; // what the two files hold afterwards +}; + +// What the two files hold, in bytes. +template +uint32_t logBytes(Fs &fs) { + const long a = fs.size(kFile), b = fs.size(kOldFile); + return (uint32_t)(a > 0 ? a : 0) + (uint32_t)(b > 0 ? b : 0); +} + +// Adds `line` (with its line end) to the log; `header` is the heading of this firmware's lines (with its line +// end). A new file begins with the heading. +template +Appended appendLine(Fs &fs, const char *header, const char *line, uint32_t maxBytes = kMaxBytes) { + Appended res; + const uint32_t headerLen = (uint32_t)strlen(header), lineLen = (uint32_t)strlen(line); + if (headerLen == 0 || headerLen >= kMaxLine || lineLen == 0 || lineLen >= kMaxLine) return res; + + long size = fs.size(kFile); + bool rotate = false, midLine = false; + if (size > 0) { + char head[kMaxLine]; + const long got = fs.read(kFile, 0, head, headerLen); + if (got < 0) return res; + if ((uint32_t)got != headerLen || memcmp(head, header, headerLen) != 0) { + rotate = true; // begun under another heading (a firmware with other columns): those lines keep it, in the old file + } else if ((uint64_t)size + lineLen > maxBytes / 2) { + rotate = true; // full + } else { + char last = 0; + if (fs.read(kFile, (uint32_t)size - 1, &last, 1) != 1) return res; + midLine = last != '\n'; // a write that was cut short left part of a line: the new one starts on a line of its own + } + } + if (rotate) { + if (!fs.remove(kOldFile) || !fs.rename(kFile, kOldFile)) return res; + size = 0; + } + + char buf[2 * kMaxLine]; + uint32_t n = 0; + if (size <= 0) { + memcpy(buf, header, headerLen); + n = headerLen; + } else if (midLine) { + n = (uint32_t)strlen(kLineEnd); + memcpy(buf, kLineEnd, n); + } + memcpy(buf + n, line, lineLen); + n += lineLen; + if (!fs.append(kFile, buf, n)) return res; + + res.what = rotate ? Append::ROTATED : Append::OK; + const long old = fs.size(kOldFile); + res.logBytes = (uint32_t)(size > 0 ? size : 0) + n + (uint32_t)(old > 0 ? old : 0); + return res; +} + +struct Exported { + bool ok = false; + uint32_t bytes = 0; // what went to the sink +}; + +// The whole log, oldest line first, as one CSV file: the old file, then the new one. A file's heading is +// left out when it is the heading that is already in force, so that the usual log has one heading, at the top; +// lines under other columns come with their own. An empty log is the heading alone. `buf` (at least +// kMaxLine bytes) is the piece that is read and written at a time. +template +Exported exportLog(Fs &fs, const char *header, Sink &sink, char *buf, uint32_t cap) { + Exported out; + if (cap < kMaxLine) return out; + char inForce[kMaxLine] = ""; // the heading that was put out last + uint32_t inForceLen = 0; + bool any = false, atLineStart = true; + auto put = [&](const char *p, uint32_t n) -> bool { + if (n == 0) return true; + if (!sink.write(p, n)) return false; + out.bytes += n; + atLineStart = p[n - 1] == '\n'; + return true; + }; + + for (const char *name : {kOldFile, kFile}) { + const long size = fs.size(name); + if (size <= 0) continue; + long got = fs.read(name, 0, buf, (uint32_t)kMaxLine); + if (got <= 0) return out; + uint32_t headLen = 0; // the file's first line with its line end (0: it has none, so there is no heading to speak of) + for (long i = 0; i < got; i++) { + if (buf[i] == '\n') { + headLen = (uint32_t)i + 1; + break; + } + } + uint32_t pos = 0; + if (headLen > 0 && headLen == inForceLen && memcmp(buf, inForce, headLen) == 0) { + pos = headLen; // the same columns as the lines before: once is enough + } else { + memcpy(inForce, buf, headLen); + inForceLen = headLen; + } + if (!atLineStart && !put(kLineEnd, (uint32_t)strlen(kLineEnd))) return out; // (the file before ended in the middle of a line) + while (pos < (uint32_t)size) { + const uint32_t want = (uint32_t)size - pos < cap ? (uint32_t)size - pos : cap; + got = fs.read(name, pos, buf, want); + if (got <= 0) return out; + if (!put(buf, (uint32_t)got)) return out; + pos += (uint32_t)got; + } + any = true; + } + if (!any && !put(header, (uint32_t)strlen(header))) return out; + out.ok = true; + return out; +} + +// Forgets the log. True when both files are gone. +template +bool clearLog(Fs &fs) { + const bool a = fs.remove(kFile), b = fs.remove(kOldFile); + return a && b; +} + +// --------------------------------------------------------------------------- +// The files of a folder +// --------------------------------------------------------------------------- +// The `Fs` of the steps above for a real folder, through the POSIX calls: on the clock the folder is the mounted +// flash partition, in tools/tests one on the PC, so the calls themselves (which flags, which place in the file, +// what counts as an error) have run before they run on the board. Every call opens and closes what it needs: +// no file is open between two of them, and nothing is left unwritten. +class FolderFs { + public: + explicit FolderFs(const char *folder) : folder_(folder) {} + + long size(const char *name) const { + char path[kMaxPath]; + struct stat st; + return (pathFor(name, path) && stat(path, &st) == 0 && S_ISREG(st.st_mode)) ? (long)st.st_size : -1; + } + long read(const char *name, uint32_t offset, char *buf, uint32_t n) const { + char path[kMaxPath]; + if (!pathFor(name, path)) return -1; + const int fd = open(path, O_RDONLY); + if (fd < 0) return -1; + long got = -1; + if (lseek(fd, (off_t)offset, SEEK_SET) == (off_t)offset) got = (long)::read(fd, buf, n); + close(fd); + return got; + } + bool append(const char *name, const char *data, uint32_t n) const { + char path[kMaxPath]; + if (!pathFor(name, path)) return false; + const int fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0666); + if (fd < 0) return false; + const bool written = ::write(fd, data, n) == (ssize_t)n; + const bool flushed = fsync(fd) == 0; // the data and the file's new length: all of it on the disk + const bool closed = close(fd) == 0; + return written && flushed && closed; + } + bool remove(const char *name) const { + char path[kMaxPath]; + if (!pathFor(name, path)) return false; + return unlink(path) == 0 || errno == ENOENT; + } + bool rename(const char *from, const char *to) const { + char a[kMaxPath], b[kMaxPath]; + return pathFor(from, a) && pathFor(to, b) && ::rename(a, b) == 0; + } + + private: + static constexpr size_t kMaxPath = 128; + // False if the path would not fit (a path that is cut short names another file). + bool pathFor(const char *name, char *out) const { + const int n = snprintf(out, kMaxPath, "%s/%s", folder_, name); + return n > 0 && (size_t)n < kMaxPath; + } + const char *folder_; +}; + +// --------------------------------------------------------------------------- +// What the Power and settings page says (its "Log" row, and a second row about the copy to the SD card) +// --------------------------------------------------------------------------- +struct Status { + enum Problem : uint8_t { FINE = 0, NO_PARTITION, CANNOT_MOUNT }; + enum Copy : uint8_t { COPY_NONE = 0, COPY_DONE, COPY_CANNOT_READ, COPY_CARD_ERROR }; + + bool on = false; // the power_log setting + Problem problem = FINE; // why nothing can be written at all + bool stopped = false; // given up after kMaxFailures failed writes + int failures = 0; // failed writes in a row + uint32_t lines = 0; // lines written since the start + uint32_t logBytes = 0; // what the log holds + uint32_t sinceLineSec = 0; // seconds since the last line + Copy copy = COPY_NONE; // what became of the copy to the SD card at this start + uint32_t copyBytes = 0; +}; + +const size_t kStatusRow = 43; // an info page shows 42 characters of a value + +// Fills up to two rows and returns how many: the state of the log (only when it is on), then the copy to the +// card (only when one was tried at this start). +// +// The numbers are kept to as many digits as the row has room for (a megabyte is 1024 KB, and 99999 lines are +// two years of them), so that every text fits whatever it is given. +inline int statusRows(const Status &s, char rows[2][kStatusRow]) { + auto atMost = [](uint32_t v, uint32_t top) { return (unsigned)(v > top ? top : v); }; + const unsigned lines = atMost(s.lines, 99999); + int n = 0; + if (s.on) { + char *o = rows[n++]; + if (s.problem == Status::NO_PARTITION) { + snprintf(o, kStatusRow, "OFF: no ffat partition (see the README)"); + } else if (s.problem == Status::CANNOT_MOUNT) { + snprintf(o, kStatusRow, "OFF: the flash partition will not mount"); + } else if (s.stopped) { + snprintf(o, kStatusRow, "STOPPED after %u write errors (%u lines)", atMost(s.failures > 0 ? (uint32_t)s.failures : 0, 9), lines); + } else if (s.failures > 0) { + snprintf(o, kStatusRow, "%u line%s this run, last write FAILED", lines, lines == 1 ? "" : "s"); + } else if (s.lines == 0) { + snprintf(o, kStatusRow, "on: first line in a moment"); + } else { + char ago[8]; + if (s.sinceLineSec < 90) { + snprintf(ago, sizeof ago, "%u s", atMost(s.sinceLineSec, 89)); + } else if (s.sinceLineSec < 5400) { + snprintf(ago, sizeof ago, "%u min", atMost((s.sinceLineSec + 30) / 60, 90)); + } else { + snprintf(ago, sizeof ago, "%u h", atMost(s.sinceLineSec / 3600, 999)); + } + snprintf(o, kStatusRow, "%u KB, %u line%s this run, %s ago", atMost((s.logBytes + 1023) / 1024, 9999), lines, lines == 1 ? "" : "s", ago); + } + } + if (s.copy != Status::COPY_NONE) { + char *o = rows[n]; + const char *lead = n == 0 ? "off, " : ""; // (the log is off, but there was one to copy) + n++; + if (s.copy == Status::COPY_DONE) { + snprintf(o, kStatusRow, "%scopied to SD card at start (%u KB)", lead, atMost((s.copyBytes + 1023) / 1024, 9999)); + } else if (s.copy == Status::COPY_CANNOT_READ) { + snprintf(o, kStatusRow, "%sNOT copied to SD: cannot read the log", lead); + } else { + snprintf(o, kStatusRow, "%sNOT copied to SD: card write error", lead); + } + } + return n; +} + +} // namespace powerlog diff --git a/02_Example/Arduino/11_Clock_Dashboard/radio_plan.h b/02_Example/Arduino/11_Clock_Dashboard/radio_plan.h new file mode 100644 index 00000000..a2cf3ed0 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/radio_plan.h @@ -0,0 +1,293 @@ +#pragma once + +// When the radio is on, in "wifi_mode = sync" (and the CPU clock policy that goes with it). +// +// In sync mode the radio is off between "sessions". A session starts when something needs the +// network (the time, the place, the weather, a look at Spotify, a key press), lasts while there +// is something to do, and ends a moment after the last thing was done. The network task works out +// what is due and tells the planner; the planner says whether the radio should be on, and keeps +// the books on what went wrong: +// +// * A network that is there but cannot be joined (a wrong password, a router that refuses), or +// work that never gets done, is a failure: the next session waits 1, 2, 5, 10 and then 15 +// minutes, so a clock that is locked out does not hammer the router all day. +// * A network that is not there is not a failure. The WiFi stack says so within a few seconds +// ("no such network"), the session ends there and then, and the clock looks again every 2 +// minutes for the first ten and every 5 from then on, for as long as it takes: a clock taken +// to work finds the phone's hotspot soon after it is switched on. +// +// A key press starts a session whatever the wait (the person wants an answer) and holds the radio +// for 45 seconds; one press buys one attempt. Any button press makes a clock that found no +// network look again at once. +// +// Pure logic with no Arduino dependency: the times are passed in, tools/tests drives it with a +// simulated day. + +#include + +namespace radioplan { + +const uint32_t kConnectTimeoutMs = 45000; // a session that is not online by now is given up (main 20 s, backup 20 s, and a margin) +const uint32_t kSettleMs = 2500; // the radio stays on this long after the last thing was done (sockets close, a command is confirmed) +const uint32_t kDemandMs = 45000; // a key press holds the radio for this long (follow-up presses, the confirming poll) +const uint32_t kSessionMaxMs = 180000; // work that is still "due" after this long, with nothing holding the radio, ends the session as a failure +const int kBackoffSteps = 5; +const uint32_t kBackoffMs[kBackoffSteps] = {60000UL, 120000UL, 300000UL, 600000UL, 900000UL}; // after the 1st, 2nd, ... failed session in a row + +// None of the clock's networks is in range: it looks again after kAwayQuickMs for the first kAwayQuickLooks +// looks (a router that is restarting is back within a few minutes), and every kAwaySlowMs from then on. A look +// is a scan for each network, two or three seconds each, so even the slow rate costs about a hundredth of +// what a radio left on would. +const uint32_t kAwayQuickMs = 120000UL; +const int kAwayQuickLooks = 5; +const uint32_t kAwaySlowMs = 300000UL; +inline uint32_t awayAfterMs(int looks) { return looks <= kAwayQuickLooks ? kAwayQuickMs : kAwaySlowMs; } + +// How many "no such network" answers it takes, per network, before the clock believes it: two when the last +// session worked (one scan can miss a router that is there), one while it is away already (a miss then only +// costs the wait until the next look). +const int kAwayScansFirst = 2; +const int kAwayScansAgain = 1; +// An answer that comes sooner than this after an attempt began belongs to the attempt before it: a scan of +// every channel takes well over a second. +const uint32_t kMinScanMs = 700; + +// A time still to come, kept as millis(): 0 means "none". A time that has passed must be put back to 0 (or +// kept fresh with keepDue()), because 32 bits of milliseconds wrap: one that lies more than 24.8 days back +// looks like one that is still to come. +inline bool pending(uint32_t nowMs, uint32_t untilMs) { return untilMs != 0 && (int32_t)(untilMs - nowMs) > 0; } +// The times a person's key press holds the radio (`netWake()` records `demandUntil(now)`). +inline uint32_t demandUntil(uint32_t nowMs) { return (nowMs + kDemandMs) | 1u; } +inline bool demandActive(uint32_t nowMs, uint32_t untilMs) { return pending(nowMs, untilMs); } +// How long to wait before trying a piece of work again (the weather) after it failed `streak` times in a +// row while the radio was joined: 1, 2, 5, 10 minutes and then every 15. +inline uint32_t retryAfterMs(int streak) { + const int step = streak < 1 ? 0 : (streak > kBackoffSteps ? kBackoffSteps - 1 : streak - 1); + return kBackoffMs[step]; +} + +// A session that is going on anyway also asks for the network time when it is within kNtpEarlyMs of being +// due, and the time only starts a session of its own when it is kNtpGraceMs overdue: the sessions come every +// quarter of an hour for the weather, and a separate session just for the time would cost more than a +// few minutes early or late. +const uint32_t kNtpEarlyMs = 10UL * 60UL * 1000UL; +const uint32_t kNtpGraceMs = 5UL * 60UL * 1000UL; + +// Is a time (millis) due? Wrap-safe for times up to 24.8 days back; see keepDue() for longer. +inline bool due(uint32_t nowMs, uint32_t atMs) { return (int32_t)(nowMs - atMs) >= 0; } +// Work that has been due for a long time (the network was away for weeks) must stay due: call this every time +// round the loop and a time more than 12 days back is moved up to 12 days back, well short of the wrap. +const uint32_t kStaleMs = 0x40000000UL; +inline void keepDue(uint32_t nowMs, uint32_t *atMs) { + if ((int32_t)(nowMs - *atMs) > (int32_t)kStaleMs) *atMs = nowMs - kStaleMs; +} + +// What Spotify needs of the radio. A command that waits and the Now Playing page hold it in any case. Music +// (playing, or paused a few minutes ago) holds it only with spotify_live = on: then the clock asks the player +// every ten seconds, as it does with the radio always on. With spotify_live = off the radio goes off between +// looks and the next look is due when the track on screen should be over, see trackLookAfterMs(). +struct SpotifyNeeds { + bool commandWaiting = false; + bool pageOpen = false; + bool playing = false; + bool pausedRecently = false; + bool live = true; +}; +inline bool spotifyHolds(const SpotifyNeeds &n) { + return n.commandWaiting || n.pageOpen || (n.live && (n.playing || n.pausedRecently)); +} +// spotify_live = off: how long after a look at the player the next one is due, for a track of `durationMs` that +// is `progressMs` in: when it should be over, and a moment more because Spotify needs one to move on. A track +// that is over already by Spotify's own figures gets just that moment. +const uint32_t kTrackEndSlackMs = 1500; +inline uint32_t trackLookAfterMs(uint32_t durationMs, uint32_t progressMs) { + return (durationMs > progressMs ? durationMs - progressMs : 0) + kTrackEndSlackMs; +} + +// What the network task knows, every time round its loop. +struct Inputs { + bool online = false; // associated, with an address + bool workDue = false; // the time, the place, the weather or a look at Spotify is due + bool holdSpotify = false; // music is playing, a command waits, it was playing a minute ago, the Now Playing page is open + bool demand = false; // a key press (or a refresh) asked for the radio and its time has not run out + uint32_t demandSeq = 1; // ... and which press that was: it counts up with every press + bool absent = false; // the WiFi stack has looked for each of the clock's networks and found none (Search::noneInRange) +}; + +// One session's search for the clock's networks. The network task tries one network at a time and passes on +// what the WiFi stack has answered since the attempt began: how often "no such network" (a scan of every +// channel found nothing by that name), how often anything else (a wrong password, a router that refuses, a +// connection that broke). +class Search { + public: + // The radio came on. `scansNeeded`: kAwayScansFirst or kAwayScansAgain. + void begin(bool hasMain, bool hasBackup, int scansNeeded) { + has_[0] = hasMain; + has_[1] = hasBackup; + need_ = scansNeeded < 1 ? 1 : scansNeeded; + forget(); + } + // A connection came up, or broke: what was learnt before says nothing about now. + void forget() { + absent_[0] = absent_[1] = false; + sawOther_ = false; + } + // The answers to the attempt on `net` (0 = the main network, 1 = the backup) so far. True, once, when it + // turns out that this network is not there: the next one can be tried at once instead of waiting the + // attempt out. + bool note(int net, unsigned notFound, unsigned other) { + if (other > 0) sawOther_ = true; + if (net < 0 || net > 1 || absent_[net] || notFound < (unsigned)need_) return false; + absent_[net] = true; + return true; + } + // None of the clock's networks is there, and nothing else went wrong on the way: the session can end. + bool noneInRange() const { + return (has_[0] || has_[1]) && !sawOther_ && (!has_[0] || absent_[0]) && (!has_[1] || absent_[1]); + } + + private: + bool has_[2] = {false, false}; + bool absent_[2] = {false, false}; + bool sawOther_ = false; + int need_ = kAwayScansFirst; +}; + +class Planner { + public: + enum State : uint8_t { OFF, CONNECTING, ONLINE }; + // How the last session ended: everything done, none of the networks in range, or given up (the network + // would not let the clock in, or work never finished). + enum Outcome : uint8_t { NONE, DONE, AWAY, FAILED }; + + // `sync` false: the radio is always on and the planner only mirrors it. `connectTimeoutMs`: how long a + // session may take to join a network before it is given up (long enough to try both networks if there + // are two, see kConnectTimeoutMs). + explicit Planner(bool sync = true, uint32_t connectTimeoutMs = kConnectTimeoutMs) : sync_(sync), connectTimeoutMs_(connectTimeoutMs) { + if (!sync) state_ = CONNECTING; + } + + // Should the radio be on? Call it every time round the loop. + bool update(uint32_t nowMs, const Inputs &in) { + accountOnTime(nowMs); + if (!sync_) { + state_ = in.online ? ONLINE : CONNECTING; + return true; + } + // a key press buys one attempt: the one a session was given up on does not start the next + const bool demand = in.demand && in.demandSeq != spentSeq_; + const bool wants = in.workDue || in.holdSpotify || demand; + switch (state_) { + case OFF: + if (retryAtMs_ != 0 && due(nowMs, retryAtMs_)) retryAtMs_ = 0; // the wait is over (and a time long past must not come round again) + if (wants && (retryAtMs_ == 0 || demand)) { // (a key press does not wait) + state_ = CONNECTING; + sessionStartMs_ = nowMs; + askedFor_ = demand || nudged_; + nudged_ = false; + } + break; + case CONNECTING: + if (in.online) { + state_ = ONLINE; + onlineSinceMs_ = nowMs; + lastActiveMs_ = nowMs; + failures_ = 0; + awayLooks_ = 0; + } else if (in.absent) { + end(nowMs, in, AWAY); + } else if ((uint32_t)(nowMs - sessionStartMs_) >= connectTimeoutMs_) { + end(nowMs, in, FAILED); + } + break; + case ONLINE: + if (!in.online) { // dropped: join again (the time allowed starts over) + state_ = CONNECTING; + sessionStartMs_ = nowMs; + break; + } + if (wants) lastActiveMs_ = nowMs; + if (!in.holdSpotify && !demand && (uint32_t)(nowMs - sessionStartMs_) >= kSessionMaxMs && in.workDue) { + end(nowMs, in, FAILED); // work that never gets done must not keep the radio on for ever + } else if ((uint32_t)(nowMs - lastActiveMs_) >= kSettleMs) { + state_ = OFF; // everything is done + retryAtMs_ = 0; + outcome_ = DONE; + sessions_++; + } + break; + } + return state_ != OFF; + } + + // Someone is using the clock (any button): if it found none of its networks last time, it looks again now + // instead of waiting for the next look. Does nothing otherwise. + void lookNow() { + if (!sync_ || state_ != OFF || outcome_ != AWAY) return; + retryAtMs_ = 0; + nudged_ = true; + } + + State state() const { return state_; } + bool radioWanted() const { return state_ != OFF; } + + // True once after a session ended without the work done (AWAY or FAILED, see lastOutcome()). + bool takeGaveUp() { + const bool g = gaveUp_; + gaveUp_ = false; + return g; + } + Outcome lastOutcome() const { return outcome_; } + // When the next session may start although something is due (0 = now); only means something while OFF. + uint32_t retryAtMs() const { return retryAtMs_; } + int failures() const { return failures_; } // failed sessions in a row + int awayLooks() const { return awayLooks_; } // looks in a row that found none of the networks + uint32_t sessions() const { return sessions_; } + + // Milliseconds the radio was on (CONNECTING and ONLINE) up to `nowMs`. + uint32_t radioOnMs(uint32_t nowMs) { + accountOnTime(nowMs); + return onMs_; + } + + private: + void end(uint32_t nowMs, const Inputs &in, Outcome why) { + uint32_t wait; + if (why == AWAY) { + // a look someone asked for (a button) is extra: it does not use up one of the quick looks + if (awayLooks_ == 0 || (!askedFor_ && awayLooks_ < 1000)) awayLooks_++; + wait = awayAfterMs(awayLooks_); + } else { + if (failures_ < 1000) failures_++; + wait = retryAfterMs(failures_); + } + retryAtMs_ = nowMs + wait; + if (retryAtMs_ == 0) retryAtMs_ = 1; + if (in.demand) spentSeq_ = in.demandSeq; // that key press has had its attempt + state_ = OFF; + outcome_ = why; + gaveUp_ = true; + } + void accountOnTime(uint32_t nowMs) { + if (started_ && state_ != OFF) onMs_ += (uint32_t)(nowMs - lastTickMs_); + lastTickMs_ = nowMs; + started_ = true; + } + + bool sync_; + uint32_t connectTimeoutMs_; + State state_ = OFF; + Outcome outcome_ = NONE; + uint32_t sessionStartMs_ = 0, onlineSinceMs_ = 0, lastActiveMs_ = 0; + uint32_t retryAtMs_ = 0; + uint32_t spentSeq_ = 0; + bool nudged_ = false, askedFor_ = false; + bool gaveUp_ = false; + int failures_ = 0; + int awayLooks_ = 0; + uint32_t sessions_ = 0; + uint32_t onMs_ = 0, lastTickMs_ = 0; + bool started_ = false; +}; + +} // namespace radioplan diff --git a/02_Example/Arduino/11_Clock_Dashboard/sand_cube.h b/02_Example/Arduino/11_Clock_Dashboard/sand_cube.h new file mode 100644 index 00000000..43fcdc90 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/sand_cube.h @@ -0,0 +1,197 @@ +#pragma once + +// A cube with sand on its walls: it tumbles (infcube::tumble()), and on every face the grains run to whatever side is down at the +// moment, pile up there, and pour away again as the cube goes on turning. Each face has its own grains, in a +// grid; "down" is the bottom of the screen, as that face sees it. A face that lies flat keeps its sand where it +// is. +// +// Pure logic (no drawing, no Arduino): Sand keeps the grains and moves them, draw() hands the picture to a sink +// for a screen of a given size. ui.cpp draws it, and tools/tests and the UI preview run the same code on a PC. + +#include +#include +#include + +#include "infinity_cube.h" // (the vectors, the eye and the styles of line) + +namespace sandcube { + +using infcube::V3; + +const int kCells = 28; // cells along an edge of a face (32 at most: a row is the bits of a word) +const int kFillPercent = 36; // how much of every face is sand +const uint32_t kStepMs = 35; // the grains move this often +const int kMaxStepsAtOnce = 4; // ... and no more than this many times in one call, however late it comes +const float kFlat = 0.10f; // a face that sees less than this much of gravity keeps its sand still +const float kGrain = 0.74f; // a grain's width, of the cell it sits in + +// The two axes that span face `f` (0..5: the faces at +x, -x, +y, -y, +z, -z of the cube), and the way it looks. +inline void faceAxes(const V3 ax[3], int f, V3 *n, V3 *u, V3 *v) { + *n = infcube::mul(ax[f >> 1], (f & 1) ? -1.0f : 1.0f); + *u = ax[((f >> 1) + 1) % 3]; + *v = ax[((f >> 1) + 2) % 3]; +} + +class Sand { + public: + Sand() { reset(1); } + + // Sand at random places, the same amount on every face; the same `seed` gives the same sand. + void reset(uint32_t seed) { + rng_ = seed ? seed : 1; + steps_ = 0; + memset(rows_, 0, sizeof rows_); + const int want = kCells * kCells * kFillPercent / 100; + for (int f = 0; f < 6; f++) { + for (int placed = 0; placed < want;) { + const uint32_t r = next(); + const int i = (int)(r % (uint32_t)kCells), j = (int)((r >> 12) % (uint32_t)kCells); + if (has(f, i, j)) continue; + put(f, i, j, true); + placed++; + } + } + } + + // Lets the sand run until `ms` after the start, with the cube's axes at `ax` (infcube::tumble()). Call it with + // the time of every frame. + void advance(uint32_t ms, const V3 ax[3]) { + const uint32_t due = ms / kStepMs; + if (due < steps_) steps_ = due; // (the time started again) + int n = (int)(due - steps_); + if (n > kMaxStepsAtOnce) n = kMaxStepsAtOnce; // a frame that came late is not made up for + for (int k = n; k > 0; k--) step(ax); + steps_ = due; + } + + bool has(int f, int i, int j) const { return (rows_[f][j] >> i) & 1u; } + int grains(int f) const { + int n = 0; + for (int j = 0; j < kCells; j++) + for (int i = 0; i < kCells; i++) n += has(f, i, j) ? 1 : 0; + return n; + } + // Where the middle of face `f`'s sand is, each way across the face, from -1 to 1 (0, 0 = the middle of the face). + void centre(int f, float *a, float *b) const { + long si = 0, sj = 0, n = 0; + for (int j = 0; j < kCells; j++) + for (int i = 0; i < kCells; i++) + if (has(f, i, j)) { + si += 2 * i + 1; + sj += 2 * j + 1; + n++; + } + *a = n ? (float)si / (float)(n * kCells) - 1.0f : 0.0f; + *b = n ? (float)sj / (float)(n * kCells) - 1.0f : 0.0f; + } + + // One step of the sand, with the cube's axes at `ax`. + void step(const V3 ax[3]) { + for (int f = 0; f < 6; f++) { + V3 n, u, v; + faceAxes(ax, f, &n, &u, &v); + stepFace(f, -u.y, -v.y); // gravity pulls down the screen: what of it lies along each of the face's two sides + } + } + + // One step on face `f`, with gravity gu along its first side and gv along its second (as parts of 1). + void stepFace(int f, float gu, float gv) { + const float au = fabsf(gu), av = fabsf(gv), pull = sqrtf(gu * gu + gv * gv); + if (pull < kFlat) return; + const int su = gu > 0 ? 1 : -1, sv = gv > 0 ? 1 : -1; + const uint32_t chanceU = (uint32_t)(au / (au + av) * 256.0f); // of 256: this grain's step is along the first side + const uint32_t chanceMove = (uint32_t)((pull > 0.8f ? 1.0f : pull * 1.25f) * 256.0f); // of 256: it moves at all (slowly on a gentle slope) + // from the corner that is lowest, so that a grain can follow the one below it in the same step + for (int b = 0; b < kCells; b++) { + for (int a = 0; a < kCells; a++) { + const int i = su > 0 ? kCells - 1 - a : a, j = sv > 0 ? kCells - 1 - b : b; + if (!has(f, i, j)) continue; + const uint32_t r = next(); + if ((r & 255u) >= chanceMove) continue; + const bool alongU = ((r >> 8) & 255u) < chanceU; + // Down, the way this grain takes for down (the steeper side of the face more often than the other). If + // something lies there: down and to one side, as sand slips off a heap, to the side gravity leans to + // more often than not; then to the other. + const int di = alongU ? su : 0, dj = alongU ? 0 : sv; + if (go(f, i, j, i + di, j + dj)) continue; + const uint32_t lean = alongU ? 256u - chanceU : chanceU; // of 256: how much of the pull goes sideways for this grain + const int toSide = (((r >> 16) & 255u) < 128u + lean) ? 1 : -1; // (half and half when it pulls straight down) + const int si = alongU ? 0 : su * toSide, sj = alongU ? sv * toSide : 0; + if (go(f, i, j, i + di + si, j + dj + sj)) continue; + if (go(f, i, j, i + di - si, j + dj - sj)) continue; + // stuck on the heap: now and then it rolls sideways along it, which is what makes a heap settle flat + if (((r >> 24) & 7u) == 0) go(f, i, j, i + si, j + sj); + } + } + } + + // Hands the picture (the cube with its axes at `ax`, the sand as it lies) for a screen of w x h to `sink`: + // sink.grain(x, y, size) a grain: a square of `size` pixels with its middle at x, y + // sink.line(x0, y0, x1, y1, style) the edges of the faces that are seen (infcube::BOLD), after their sand + // sink.dot(x, y) a glint on every corner that is seen + template + void draw(const V3 ax[3], int w, int h, Sink &sink) const { + const float focal = infcube::kFill * 0.5f * (float)h * sqrtf(infcube::kCamera * infcube::kCamera - 3.0f) / 1.7320508f; + const float midX = 0.5f * (float)(w - 1), midY = 0.5f * (float)(h - 1); + const float cell = 2.0f / (float)kCells; + V3 corners[3][4]; + int seen = 0; + for (int f = 0; f < 6 && seen < 3; f++) { + V3 n, u, v; + faceAxes(ax, f, &n, &u, &v); + if (n.z > -1.0f / infcube::kCamera - 0.02f) continue; // it looks away from the eye + for (int j = 0; j < kCells; j++) { + if (!rows_[f][j]) continue; + const float b = -1.0f + cell * ((float)j + 0.5f); + for (int i = 0; i < kCells; i++) { + if (!has(f, i, j)) continue; + const float a = -1.0f + cell * ((float)i + 0.5f); + const V3 p = {n.x + u.x * a + v.x * b, n.y + u.y * a + v.y * b, n.z + u.z * a + v.z * b}; + const float k = focal / (infcube::kCamera + p.z); + int size = (int)lroundf(cell * k * kGrain); + if (size < 1) size = 1; + sink.grain((int)lroundf(midX + p.x * k), (int)lroundf(midY - p.y * k), size); + } + } + static const float kCorner[4][2] = {{1, 1}, {-1, 1}, {-1, -1}, {1, -1}}; + for (int i = 0; i < 4; i++) + corners[seen][i] = {n.x + u.x * kCorner[i][0] + v.x * kCorner[i][1], n.y + u.y * kCorner[i][0] + v.y * kCorner[i][1], + n.z + u.z * kCorner[i][0] + v.z * kCorner[i][1]}; + seen++; + } + auto px = [&](const V3 &p) { return (int)lroundf(midX + p.x * focal / (infcube::kCamera + p.z)); }; + auto py = [&](const V3 &p) { return (int)lroundf(midY - p.y * focal / (infcube::kCamera + p.z)); }; + for (int f = 0; f < seen; f++) + for (int i = 0; i < 4; i++) sink.line(px(corners[f][i]), py(corners[f][i]), px(corners[f][(i + 1) & 3]), py(corners[f][(i + 1) & 3]), infcube::BOLD); + for (int f = 0; f < seen; f++) + for (int i = 0; i < 4; i++) sink.dot(px(corners[f][i]), py(corners[f][i])); + } + + private: + uint32_t next() { // xorshift + rng_ ^= rng_ << 13; + rng_ ^= rng_ >> 17; + rng_ ^= rng_ << 5; + return rng_; + } + void put(int f, int i, int j, bool on) { + if (on) { + rows_[f][j] |= 1u << i; + } else { + rows_[f][j] &= ~(1u << i); + } + } + // Moves the grain at i, j to ni, nj if that is on the face and free. + bool go(int f, int i, int j, int ni, int nj) { + if (ni < 0 || ni >= kCells || nj < 0 || nj >= kCells || has(f, ni, nj)) return false; + put(f, i, j, false); + put(f, ni, nj, true); + return true; + } + + uint32_t rows_[6][kCells]; // a bit for every cell: bit i of row j of face f + uint32_t rng_ = 1; + uint32_t steps_ = 0; // steps taken so far +}; + +} // namespace sandcube diff --git a/02_Example/Arduino/11_Clock_Dashboard/sd_layout.h b/02_Example/Arduino/11_Clock_Dashboard/sd_layout.h new file mode 100644 index 00000000..a0cc4b83 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/sd_layout.h @@ -0,0 +1,134 @@ +#pragma once + +// Why an SD card would not mount: worked out from its first sector, and from the first sector of its first +// partition where it has one, so that the clock can say what is on the card instead of guessing. +// +// The FAT library in the ESP32 Arduino core (FatFs) mounts a FAT12, FAT16 or FAT32 volume on a card with a +// classic partition table (MBR) or with none. It is built without exFAT and without GUID partition tables +// (GPT), so a card that Windows shows as "FAT32" still fails when its partition table is a GPT. +// +// Pure logic with no Arduino dependency: sdcard.cpp reads the sectors, tools/tests feeds it made-up ones. + +#include +#include + +namespace sdlayout { + +const int kSectorBytes = 512; + +enum class Kind : uint8_t { + UNKNOWN = 0, // not looked at (the card could not be started again for a look) + READ_ERROR, // the card would not give up the sector + BLANK, // no boot signature, or a partition table with nothing in it: never formatted, or wiped + GPT, // a GUID partition table (the first sector is its "protective MBR", type 0xEE) + EXFAT, // an exFAT volume + NTFS, // an NTFS volume + FAT, // a FAT volume, which should have mounted: damaged, or formatted in a way the library refuses + OTHER // a partition table whose first partition is none of these (Linux, ...) +}; + +// What the first sector says. With `lookAtPartition` the answer is in the partition's own first sector +// (`partitionLba`): read it and ask inspectPartition(). +struct First { + Kind kind = Kind::UNKNOWN; + bool lookAtPartition = false; + uint32_t partitionLba = 0; + uint8_t partitionType = 0; // the type byte of the partition table entry; 0 = no table +}; + +inline bool hasBootSignature(const uint8_t *s) { return s[510] == 0x55 && s[511] == 0xAA; } + +// The boot sector of a volume, by the name it carries. +inline bool isExfat(const uint8_t *s) { return hasBootSignature(s) && !memcmp(s + 3, "EXFAT ", 8); } +inline bool isNtfs(const uint8_t *s) { return hasBootSignature(s) && !memcmp(s + 3, "NTFS ", 8); } +// A FAT boot sector: the jump at the start, 512 byte sectors, and the type name where FAT32 keeps it (byte 82) +// or where FAT12 and FAT16 do (byte 54). +inline bool isFat(const uint8_t *s) { + if (!hasBootSignature(s)) return false; + if (s[0] != 0xEB && s[0] != 0xE9 && s[0] != 0xE8) return false; + if ((uint16_t)(s[11] | (s[12] << 8)) != 512) return false; + return !memcmp(s + 82, "FAT32 ", 8) || !memcmp(s + 54, "FAT12 ", 8) || !memcmp(s + 54, "FAT16 ", 8); +} + +inline Kind volumeKind(const uint8_t *s) { + if (isExfat(s)) return Kind::EXFAT; + if (isNtfs(s)) return Kind::NTFS; + if (isFat(s)) return Kind::FAT; + return Kind::UNKNOWN; +} + +inline bool isFatPartitionType(uint8_t type) { + return type == 0x01 || type == 0x04 || type == 0x06 || type == 0x0B || type == 0x0C || type == 0x0E; +} + +// The first sector of the card. +inline First inspectFirst(const uint8_t *s) { + First f; + if (!hasBootSignature(s)) { + f.kind = Kind::BLANK; + return f; + } + const Kind whole = volumeKind(s); // a volume that fills the card, with no partition table + if (whole != Kind::UNKNOWN) { + f.kind = whole; + return f; + } + // a partition table: four entries of 16 bytes from byte 446 (type at +4, first sector at +8) + for (int i = 0; i < 4; i++) { + const uint8_t *e = s + 446 + 16 * i; + const uint32_t lba = (uint32_t)e[8] | ((uint32_t)e[9] << 8) | ((uint32_t)e[10] << 16) | ((uint32_t)e[11] << 24); + if (e[4] == 0 || lba == 0) continue; + f.partitionType = e[4]; + f.partitionLba = lba; + if (e[4] == 0xEE) { // the placeholder a GUID partition table puts here + f.kind = Kind::GPT; + } else { + f.lookAtPartition = true; + } + return f; + } + f.kind = Kind::BLANK; // a boot signature and an empty table + return f; +} + +// The first sector of the partition that inspectFirst() pointed at. +inline Kind inspectPartition(const uint8_t *s, uint8_t partitionType) { + const Kind k = volumeKind(s); + if (k != Kind::UNKNOWN) return k; + // nothing recognisable there: go by what the table calls it + if (isFatPartitionType(partitionType)) return Kind::FAT; // a FAT partition with a damaged start + return Kind::OTHER; +} + +// One line for the screen (a banner holds 39 characters; the layout check in tools/ui_preview measures +// every one of these). +inline const char *text(Kind k) { + switch (k) { + case Kind::READ_ERROR: return "SD card: cannot read it"; + case Kind::BLANK: return "SD card is not formatted: use FAT32"; + case Kind::GPT: return "SD card: GPT partitions, needs MBR"; + case Kind::EXFAT: return "SD card is exFAT: format it as FAT32"; + case Kind::NTFS: return "SD card is NTFS: format it as FAT32"; + case Kind::FAT: return "SD card: FAT found, will not mount"; + case Kind::OTHER: return "SD card: no FAT32 partition on it"; + default: return "SD card: no FAT32 found on it"; + } +} + +const Kind kAllKinds[] = {Kind::UNKNOWN, Kind::READ_ERROR, Kind::BLANK, Kind::GPT, Kind::EXFAT, Kind::NTFS, Kind::FAT, Kind::OTHER}; + +// A word for the log. +inline const char *name(Kind k) { + switch (k) { + case Kind::READ_ERROR: return "read error"; + case Kind::BLANK: return "blank"; + case Kind::GPT: return "GPT"; + case Kind::EXFAT: return "exFAT"; + case Kind::NTFS: return "NTFS"; + case Kind::FAT: return "FAT"; + case Kind::OTHER: return "other"; + default: return "unknown"; + } +} + +} // namespace sdlayout diff --git a/02_Example/Arduino/11_Clock_Dashboard/sdcard.cpp b/02_Example/Arduino/11_Clock_Dashboard/sdcard.cpp new file mode 100644 index 00000000..9d186a79 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/sdcard.cpp @@ -0,0 +1,223 @@ +#include "sdcard.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "config.h" +#include "log.h" +#include "sd_layout.h" + +namespace { + +const char *const TAG = "sd"; +const char *const kMountPoint = "/sdcard"; +sdmmc_card_t *s_card = nullptr; +sdlayout::Kind s_problem = sdlayout::Kind::UNKNOWN; // what is on a card that would not mount +bool s_probed = false; // ... looked at once per run (the card is asked for twice at start-up) + +void pathFor(const char *name, char *out, size_t cap) { snprintf(out, cap, "%s/%s", kMountPoint, name); } + +// The file system would not mount. Start the card once more, without a file system, and read its first +// sector (and that of its first partition) to see what is on it: sd_layout.h says what the bytes mean. +void probeCard(const sdmmc_slot_config_t &slot) { + if (s_probed) return; + s_probed = true; + s_problem = sdlayout::Kind::UNKNOWN; + sdmmc_host_t host = SDMMC_HOST_DEFAULT(); + if ((*host.init)() != ESP_OK) return; + sdmmc_card_t *card = (sdmmc_card_t *)calloc(1, sizeof(sdmmc_card_t)); + uint8_t *sector = (uint8_t *)heap_caps_malloc(sdlayout::kSectorBytes, MALLOC_CAP_DMA); + uint8_t type = 0; + uint32_t lba = 0; + if (card && sector && sdmmc_host_init_slot(host.slot, &slot) == ESP_OK && sdmmc_card_init(&host, card) == ESP_OK) { + if (sdmmc_read_sectors(card, sector, 0, 1) != ESP_OK) { + s_problem = sdlayout::Kind::READ_ERROR; + } else { + const sdlayout::First first = sdlayout::inspectFirst(sector); + s_problem = first.kind; + type = first.partitionType; + lba = first.partitionLba; + if (first.lookAtPartition) { + s_problem = sdmmc_read_sectors(card, sector, lba, 1) == ESP_OK ? sdlayout::inspectPartition(sector, type) : sdlayout::Kind::READ_ERROR; + } + } + } + free(sector); + free(card); + if (host.flags & SDMMC_HOST_FLAG_DEINIT_ARG) { + host.deinit_p(host.slot); + } else { + host.deinit(); + } + if (type) { + LOGF(TAG, "the card holds: %s (first partition: type 0x%02X at sector %lu)", sdlayout::name(s_problem), type, (unsigned long)lba); + } else { + LOGF(TAG, "the card holds: %s (no partition table)", sdlayout::name(s_problem)); + } +} + +} // namespace + +const char *sdProblemText() { return sdlayout::text(s_problem); } + +SdStatus sdMount() { + if (s_card) return SdStatus::READY; + esp_vfs_fat_sdmmc_mount_config_t mount = {}; + mount.format_if_mount_failed = false; // never format somebody's card + mount.max_files = 3; + mount.allocation_unit_size = 16 * 1024; + + sdmmc_host_t host = SDMMC_HOST_DEFAULT(); + sdmmc_slot_config_t slot = SDMMC_SLOT_CONFIG_DEFAULT(); + slot.width = 1; + slot.clk = (gpio_num_t)PIN_SD_CLK; + slot.cmd = (gpio_num_t)PIN_SD_CMD; + slot.d0 = (gpio_num_t)PIN_SD_D0; + slot.d1 = slot.d2 = slot.d3 = GPIO_NUM_NC; // 1-bit mode: the chip's defaults for these are other things on this board + slot.flags |= SDMMC_SLOT_FLAG_INTERNAL_PULLUP; // belt and braces: the board has its own pull-ups + + const esp_err_t err = esp_vfs_fat_sdmmc_mount(kMountPoint, &host, &slot, &mount, &s_card); + if (err == ESP_OK) return SdStatus::READY; + s_card = nullptr; + LOGF(TAG, "mount failed: 0x%x", (unsigned)err); + // ESP_FAIL: the card answered but the file system did not mount; anything else (a timeout, as + // when the slot is empty) means there is no usable card + if (err != ESP_FAIL) return SdStatus::NO_CARD; + probeCard(slot); // ... and why not: sdProblemText() says + return SdStatus::UNREADABLE; +} + +void sdUnmount() { + if (!s_card) return; + esp_vfs_fat_sdcard_unmount(kMountPoint, s_card); + s_card = nullptr; +} + +void sdCardSummary(char *out, size_t cap) { + if (!s_card) { + snprintf(out, cap, "no card"); + return; + } + const double gb = (double)s_card->csd.capacity * (double)s_card->csd.sector_size / 1e9; + const char *kind = (s_card->ocr & (1UL << 30)) ? "SDHC" : "SD"; // CCS bit: high capacity + if (gb >= 1.0) { + snprintf(out, cap, "%s %.1f GB", kind, gb); + } else { + snprintf(out, cap, "%s %.0f MB", kind, gb * 1000.0); + } +} + +bool sdFileExists(const char *name) { + if (!s_card) return false; + char path[80]; + pathFor(name, path, sizeof path); + struct stat st; + return stat(path, &st) == 0 && S_ISREG(st.st_mode); +} + +bool sdReadFile(const char *name, char **data, size_t *len, size_t maxBytes) { + *data = nullptr; + *len = 0; + if (!s_card) return false; + char path[80]; + pathFor(name, path, sizeof path); + FILE *f = fopen(path, "rb"); + if (!f) return false; + struct stat st; + const long size = (stat(path, &st) == 0) ? (long)st.st_size : -1; + if (size < 0 || (size_t)size > maxBytes) { + fclose(f); + return false; + } + char *buf = (char *)malloc((size_t)size + 1); + if (!buf) { + fclose(f); + return false; + } + const size_t got = size ? fread(buf, 1, (size_t)size, f) : 0; + fclose(f); + if (got != (size_t)size) { + free(buf); + return false; + } + buf[size] = 0; + *data = buf; + *len = (size_t)size; + return true; +} + +bool sdWriteFile(const char *name, const char *data, size_t len) { + if (!s_card) return false; + char path[80]; + pathFor(name, path, sizeof path); + FILE *f = fopen(path, "wb"); + if (!f) return false; + const size_t put = fwrite(data, 1, len, f); + const bool flushed = fflush(f) == 0 && fsync(fileno(f)) == 0; + const bool closed = fclose(f) == 0; + return put == len && flushed && closed; +} + +bool sdFileSize(const char *name, uint32_t *size) { + if (!s_card) return false; + char path[80]; + pathFor(name, path, sizeof path); + struct stat st; + if (stat(path, &st) != 0 || !S_ISREG(st.st_mode) || st.st_size < 0) return false; + *size = (uint32_t)st.st_size; + return true; +} + +int sdOpen(const char *name) { + if (!s_card) return -1; + char path[80]; + pathFor(name, path, sizeof path); + return open(path, O_RDONLY); +} + +int sdRead(int fd, void *buf, size_t len) { return (int)read(fd, buf, len); } + +bool sdSeek(int fd, uint32_t offset) { return lseek(fd, (off_t)offset, SEEK_SET) == (off_t)offset; } + +void sdClose(int fd) { + if (fd >= 0) close(fd); +} + +bool sdRename(const char *from, const char *to) { + if (!s_card) return false; + char a[80], b[80]; + pathFor(from, a, sizeof a); + pathFor(to, b, sizeof b); + unlink(b); // FAT will not rename onto a name that exists + return rename(a, b) == 0; +} + +int sdCreate(const char *name) { + if (!s_card) return -1; + char path[80]; + pathFor(name, path, sizeof path); + return open(path, O_WRONLY | O_CREAT | O_TRUNC, 0666); +} + +bool sdWrite(int fd, const void *buf, size_t len) { return fd >= 0 && write(fd, buf, len) == (ssize_t)len; } + +bool sdCommit(int fd) { + if (fd < 0) return false; + const bool flushed = fsync(fd) == 0; + const bool closed = close(fd) == 0; + return flushed && closed; +} + +bool sdRemove(const char *name) { + if (!s_card) return false; + char path[80]; + pathFor(name, path, sizeof path); + return unlink(path) == 0; +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/sdcard.h b/02_Example/Arduino/11_Clock_Dashboard/sdcard.h new file mode 100644 index 00000000..e524a919 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/sdcard.h @@ -0,0 +1,53 @@ +#pragma once + +// The SD card slot, used for one thing: the settings file, read (and, if the card has none, +// written) at boot. The card is mounted for that and unmounted again; nothing touches it at +// run time. Wiring as in the 06_SD_Card example: SDMMC, 1-bit, CLK 38, CMD 21, D0 39. +// FAT32 only (the core's FatFs has no exFAT, which is what 64 GB and larger cards come +// formatted with). Long file names work. + +#include + +enum class SdStatus : uint8_t { + NO_CARD, // nothing answered (this also covers a card that is not seated) + UNREADABLE, // a card answered but its file system would not mount: sdProblemText() says what is on it + READY // mounted +}; + +// Mounts the card. With no card in the slot this takes up to about a second. +SdStatus sdMount(); +void sdUnmount(); + +// After sdMount() gave UNREADABLE: one line for the screen about what the card holds instead of a FAT file +// system the clock can read ("SD card is exFAT: format it as FAT32", "SD card: GPT partitions, needs MBR", ...). +// The clock reads the card's first sector to find out (sd_layout.h). +const char *sdProblemText(); + +// "SDHC 14.8 GB" for the Info page (after a successful sdMount()). +void sdCardSummary(char *out, size_t cap); + +// Files in the root directory. +bool sdFileExists(const char *name); +// Reads a whole file into a malloc'd, NUL-terminated buffer (the caller frees it). False if it +// cannot be opened, is longer than maxBytes or memory is short. An empty file gives len 0. +bool sdReadFile(const char *name, char **data, size_t *len, size_t maxBytes); +// Creates or replaces a file and flushes it to the card. Returns true if every byte was written. +bool sdWriteFile(const char *name, const char *data, size_t len); + +// For the firmware file, which is read in pieces: its size, and a handle to read it with (-1 if it +// cannot be opened). Plain POSIX reads, so 4 KB requests go straight to the card. +bool sdFileSize(const char *name, uint32_t *size); +int sdOpen(const char *name); +int sdRead(int fd, void *buf, size_t len); // bytes read, or -1 +bool sdSeek(int fd, uint32_t offset); +void sdClose(int fd); +// Renames a file; an existing file with the new name is replaced. +bool sdRename(const char *from, const char *to); + +// For the copy of the power log, which is written in pieces: a new file (one of that name is replaced) and a +// handle to write it with (-1 if it cannot be made), the pieces, and the end, which flushes and closes it. +// sdCommit() always closes; true only if the file is safely on the card. +int sdCreate(const char *name); +bool sdWrite(int fd, const void *buf, size_t len); // true if every byte was taken +bool sdCommit(int fd); +bool sdRemove(const char *name); diff --git a/02_Example/Arduino/11_Clock_Dashboard/secrets.example.h b/02_Example/Arduino/11_Clock_Dashboard/secrets.example.h new file mode 100644 index 00000000..0fbbbb95 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/secrets.example.h @@ -0,0 +1,66 @@ +#pragma once + +// Copy this file to "secrets.h" and fill it in. +// secrets.h is listed in .gitignore, so your passwords stay out of git. +// +// Everything here can also be set from a settings file on an SD card (see the README), which +// is handier once the clock is built: no reflashing to change the WiFi, the units or the place. +// And the other way round: every setting the card can set can be built in here, to come pre-programmed. +// The names are in the last column of the README's settings table ("Building the settings in"); the +// ones below are the ones people usually want. config.h has the factory value and the choices of each. +// Text and choices are written as on the card ("12h", "max"), yes / no as 1 / 0. A value that does +// not pass the card's own checks is left out and reported on the Info page. + +// WiFi (2.4 GHz; the ESP32-S3 does not do 5 GHz) +#define WIFI_SSID "YourWiFiName" +#define WIFI_PASSWORD "YourWiFiPassword" + +// A backup network (optional): tried when the one above cannot be joined, say a phone's hotspot. +// While the clock is on the backup it looks for the main network every 10 minutes and goes back. +// #define WIFI_SSID_BACKUP "YourBackupWiFiName" +// #define WIFI_PASSWORD_BACKUP "YourBackupWiFiPassword" + +// Spotify (optional). Paste the Client ID of your own app from +// https://developer.spotify.com/dashboard to turn on now-playing and the +// KEY button controls. Leave it empty to run without Spotify. +// The README walks through creating the app and linking your account. +#define SPOTIFY_CLIENT_ID "" + +// Your battery (optional): its capacity in mAh, so the Power and settings page can show the average current, +// and a correction for the voltage the clock measures (real voltage / shown voltage, see the README). +// #define BATTERY_CAPACITY_MAH 2500 +// #define BATTERY_CALIBRATION 1.016f +// #define BATTERY_MODE "none" // "auto", or "none" when no battery is fitted (USB only) +// #define LOW_BATTERY_SHUTDOWN 1 // 1 = shut down before the cell is flat, 0 = never +// #define BATTERY_CUTOFF_V 3.30f // ... at this voltage, 3.10 to 3.60 + +// Where the clock is (optional, for the weather and the time zone). Give coordinates, or a place +// to look up; see config.h for the details. Without either the clock still tells the time. +// #define LOCATION_LATITUDE (-33.865) +// #define LOCATION_LONGITUDE 151.209 +// #define LOCATION_QUERY "Sydney" +// #define LOCATION_LABEL "Home" // what the status bar shows +// #define TIMEZONE_POSIX_OVERRIDE "Australia/Sydney" // an IANA name or a POSIX rule; needed with WiFi off + +// Units and formats (optional) +// #define USE_FAHRENHEIT 1 // 1 = degF and mph +// #define TIME_FORMAT "12h" // "24h" or "12h" +// #define DATE_FORMAT "d-mon-y" // iso, dmy, mdy, dmy-dot, d-mon-y or mon-d-y +// #define SHOW_WEEK 0 // 0 = no week number and day of the year + +// WiFi and power (optional) +// #define WIFI_ENABLED 0 // 0 = radio off: no network time, weather or Spotify, much less power +// #define WIFI_POWER_SAVE "max" // "normal" or "max": the radio sleeps longer (replies can come a third of a second late) +// #define WIFI_MODE "sync" // "always", or "sync": the radio is on only for the syncs, key presses and Spotify (README: Saving power) +// #define APP_HOSTNAME "desk-clock" // the clock's name on the network +// #define NTP_SERVER "time.nist.gov" // a time server to try first +// #define CPU_MHZ 80 // 80, 160 or 240 +// #define CPU_IDLE_MHZ 40 // the clock while the radio is off: 0 (= CPU_MHZ), 80, 40 or 20 (untried: see config.h) +// #define CONSOLE_MODE "auto" // the USB serial console: "on", "auto" (only while a computer is on the USB port) or "off" (README: Saving power) +// #define POWER_LOG 1 // 1 = a line of power readings in the clock's flash every 10 minutes, copied to an SD card at start-up (README: Logging a battery run) +// #define SPOTIFY_ENABLED 0 // 0 = off, even with a Client ID +// #define SPOTIFY_LIVE 0 // with WIFI_MODE "sync": 0 = the radio sleeps while music plays, one look a track (1 = it stays on) + +// Other (optional) +// #define INDOOR_TEMP_OFFSET_C (-4.0f) // degrees C added to the indoor temperature +// #define WEATHER_INTERVAL_MIN 15 // minutes between weather updates, 5 to 240 diff --git a/02_Example/Arduino/11_Clock_Dashboard/sensors.cpp b/02_Example/Arduino/11_Clock_Dashboard/sensors.cpp new file mode 100644 index 00000000..eab47614 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/sensors.cpp @@ -0,0 +1,150 @@ +#include "sensors.h" + +#include + +#include "app_settings.h" +#include "calc.h" +#include "config.h" + +// --------------------------------------------------------------------------- +// SHTC3 +// --------------------------------------------------------------------------- +static const uint16_t SHTC3_WAKEUP = 0x3517; +static const uint16_t SHTC3_SLEEP = 0xB098; +static const uint16_t SHTC3_MEASURE_T_FIRST = 0x7866; // normal power, no clock stretching + +static bool shtc3Write(uint16_t cmd) { + Wire.beginTransmission(I2C_ADDR_SHTC3); + Wire.write((uint8_t)(cmd >> 8)); + Wire.write((uint8_t)(cmd & 0xFF)); + return Wire.endTransmission() == 0; +} + +bool sensorsBegin() { + Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL, 400000); + Wire.setTimeOut(50); + analogSetPinAttenuation(PIN_BATTERY_ADC, ADC_11db); + IndoorReading probe; + return readIndoor(probe); +} + +bool readIndoor(IndoorReading &out) { + out.valid = false; + if (!shtc3Write(SHTC3_WAKEUP)) return false; + delayMicroseconds(300); // wake-up time, datasheet 240 us + if (!shtc3Write(SHTC3_MEASURE_T_FIRST)) { + shtc3Write(SHTC3_SLEEP); + return false; + } + delay(15); // measurement takes up to 12.1 ms + + uint8_t buf[6]; + bool ok = Wire.requestFrom((uint8_t)I2C_ADDR_SHTC3, (size_t)6) == 6; + if (ok) { + for (int i = 0; i < 6; i++) buf[i] = (uint8_t)Wire.read(); + } + shtc3Write(SHTC3_SLEEP); // back to ~0.3 uA + if (!ok) return false; + + float t, rh; + if (!calc::shtc3Decode(buf, &t, &rh)) return false; + out.rawTempC = t; + out.tempC = t + g_cfg.indoorOffsetC; + out.rh = calc::humidityAtTemperature(rh, t, out.tempC); + out.valid = true; + return true; +} + +// --------------------------------------------------------------------------- +// The audio chips, which the clock does not use +// --------------------------------------------------------------------------- +static bool writeRegisters(uint8_t address, const uint8_t (*pairs)[2], size_t count) { + for (size_t i = 0; i < count; i++) { + Wire.beginTransmission(address); + Wire.write(pairs[i][0]); + Wire.write(pairs[i][1]); + if (Wire.endTransmission() != 0) return false; + } + return true; +} + +int audioChipsStandby() { + // Register and value, in the order of es8311_suspend() and es7210_stop() in Waveshare's audio example for + // this board (07_Audio_Test/src/ExternLib/esp_codec_dev/device/...): what that library writes when it + // closes the chips. Volumes to zero, the analog parts and the microphone bias off, the clocks off. + static const uint8_t kEs8311[][2] = {{0x32, 0x00}, {0x17, 0x00}, {0x0E, 0xFF}, {0x12, 0x02}, {0x14, 0x00}, + {0x0D, 0xFA}, {0x15, 0x00}, {0x02, 0x10}, {0x00, 0x00}, {0x00, 0x1F}, + {0x01, 0x30}, {0x01, 0x00}, {0x45, 0x00}, {0x0D, 0xFC}, {0x02, 0x00}}; + static const uint8_t kEs7210[][2] = {{0x47, 0xFF}, {0x48, 0xFF}, {0x49, 0xFF}, {0x4A, 0xFF}, {0x4B, 0xFF}, + {0x4C, 0xFF}, {0x40, 0xC0}, {0x01, 0x7F}, {0x06, 0x07}}; + int answered = 0; + if (writeRegisters(I2C_ADDR_ES8311, kEs8311, sizeof kEs8311 / sizeof kEs8311[0])) answered |= AUDIO_CODEC_ANSWERED; + if (writeRegisters(I2C_ADDR_ES7210, kEs7210, sizeof kEs7210 / sizeof kEs7210[0])) answered |= AUDIO_MIC_ADC_ANSWERED; + return answered; +} + +// --------------------------------------------------------------------------- +// Battery +// --------------------------------------------------------------------------- +BatteryReading readBattery() { + BatteryReading r; + uint32_t sum = 0; + const int kSamples = 16; + analogSetPinAttenuation(PIN_BATTERY_ADC, ADC_11db); // also needed when this is the very first call at boot + for (int i = 0; i < kSamples; i++) { + sum += analogReadMilliVolts(PIN_BATTERY_ADC); // factory-calibrated millivolts + delayMicroseconds(200); + } + r.rawVolts = (sum / (float)kSamples) / 1000.0f * BATTERY_DIVIDER; + r.volts = r.rawVolts * g_cfg.batteryCalibration; + r.present = r.rawVolts >= 2.5f; // a floating / unpopulated pin reads far below any real cell + r.percent = r.present ? calc::batteryPercent(r.volts) : 0; + return r; +} + +// --------------------------------------------------------------------------- +// PCF85063 RTC. Registers 0x04..0x0A hold sec, min, hour, day, weekday, month, +// year as BCD; bit 7 of the seconds register is the oscillator-stop flag. +// --------------------------------------------------------------------------- +static const uint8_t RTC_REG_SECONDS = 0x04; + +static inline uint8_t bcdToDec(uint8_t v) { return (uint8_t)((v >> 4) * 10 + (v & 0x0F)); } +static inline uint8_t decToBcd(int v) { return (uint8_t)(((v / 10) << 4) | (v % 10)); } + +bool rtcReadUtc(time_t *utc) { + Wire.beginTransmission(I2C_ADDR_PCF85063); + Wire.write(RTC_REG_SECONDS); + if (Wire.endTransmission(false) != 0) return false; + if (Wire.requestFrom((uint8_t)I2C_ADDR_PCF85063, (size_t)7) != 7) return false; + uint8_t b[7]; + for (int i = 0; i < 7; i++) b[i] = (uint8_t)Wire.read(); + + if (b[0] & 0x80) return false; // oscillator stopped: contents are not trustworthy + + int sec = bcdToDec(b[0] & 0x7F); + int min = bcdToDec(b[1] & 0x7F); + int hour = bcdToDec(b[2] & 0x3F); + int day = bcdToDec(b[3] & 0x3F); + int mon = bcdToDec(b[5] & 0x1F); + int year = 2000 + bcdToDec(b[6]); + if (sec > 59 || min > 59 || hour > 23 || day < 1 || day > 31 || mon < 1 || mon > 12) return false; + if (year < 2024) return false; // never set, or reset to the chip default (2000) + + *utc = (time_t)calc::epochFromUtc(year, mon, day, hour, min, sec); + return true; +} + +bool rtcWriteUtc(time_t utc) { + struct tm t; + gmtime_r(&utc, &t); + Wire.beginTransmission(I2C_ADDR_PCF85063); + Wire.write(RTC_REG_SECONDS); + Wire.write(decToBcd(t.tm_sec) & 0x7F); // clears the oscillator-stop flag + Wire.write(decToBcd(t.tm_min)); + Wire.write(decToBcd(t.tm_hour)); + Wire.write(decToBcd(t.tm_mday)); + Wire.write((uint8_t)t.tm_wday); + Wire.write(decToBcd(t.tm_mon + 1)); + Wire.write(decToBcd(t.tm_year % 100)); + return Wire.endTransmission() == 0; +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/sensors.h b/02_Example/Arduino/11_Clock_Dashboard/sensors.h new file mode 100644 index 00000000..8b4a58c4 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/sensors.h @@ -0,0 +1,43 @@ +#pragma once + +// Board sensors: SHTC3 temperature/humidity, PCF85063 real-time clock and the +// battery voltage divider. Everything here is called from the UI task only +// (it owns the I2C bus). + +#include +#include + +struct IndoorReading { + bool valid = false; + float tempC = 0; // corrected for self-heating (INDOOR_TEMP_OFFSET_C) + float rh = 0; // corrected to match + float rawTempC = 0; // as measured +}; + +struct BatteryReading { + bool present = false; + float volts = 0; // corrected by the battery_calibration setting + float rawVolts = 0; // as the ADC and the divider gave it, before that + int percent = 0; +}; + +// Starts the I2C bus. Returns true when the SHTC3 answered. +bool sensorsBegin(); + +// One blocking measurement (about 15 ms). Returns false on a bus or CRC error. +bool readIndoor(IndoorReading &out); + +// Average of several ADC samples, converted through the 1:3 divider and multiplied by the +// battery_calibration setting (everything else works with the corrected voltage). +BatteryReading readBattery(); + +// Hardware RTC, kept in UTC. rtcReadUtc() is false when the clock has never +// been set or lost power (the chip's oscillator-stop flag). +bool rtcReadUtc(time_t *utc); +bool rtcWriteUtc(time_t utc); + +// The two audio chips on the same I2C bus, which the clock does not use (an ES8311 codec and an ES7210 +// microphone ADC): told to power down, with what Waveshare's audio example writes when it closes them. +// Returns which of them answered. Call it once, after sensorsBegin(). +enum : int { AUDIO_CODEC_ANSWERED = 1, AUDIO_MIC_ADC_ANSWERED = 2 }; +int audioChipsStandby(); diff --git a/02_Example/Arduino/11_Clock_Dashboard/settings.cpp b/02_Example/Arduino/11_Clock_Dashboard/settings.cpp new file mode 100644 index 00000000..6d1ca0c2 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/settings.cpp @@ -0,0 +1,997 @@ +#include "settings.h" + +#include +#include +#include +#include +#include + +#include "tz_table.h" + +namespace { + +// --------------------------------------------------------------------------- +// The table +// --------------------------------------------------------------------------- +enum Type : uint8_t { T_BOOL, T_INT, T_FLOAT, T_DOUBLE, T_STRING, T_ENUM }; + +struct EnumName { + const char *name; // the first name listed for a value is the one written out + int value; +}; + +struct Def { + const char *key; + const char *nvs; // flash key, 15 characters at most + Type type; + size_t offset; // into Settings + size_t size; // string buffer size + double lo, hi; // numeric limits + const EnumName *names; + bool secret; + const char *group; // heading in the example file, printed when it changes + const char *help; // comment lines for the example file, '\n' separated +}; + +const EnumName kUnitNames[] = {{"metric", UNITS_METRIC}, {"celsius", UNITS_METRIC}, {"c", UNITS_METRIC}, + {"si", UNITS_METRIC}, {"imperial", UNITS_IMPERIAL}, {"fahrenheit", UNITS_IMPERIAL}, + {"f", UNITS_IMPERIAL}, {"us", UNITS_IMPERIAL}, {"mph", UNITS_IMPERIAL}, + {nullptr, 0}}; +const EnumName kTimeNames[] = {{"24h", TIME_24H}, {"24", TIME_24H}, {"24hour", TIME_24H}, {"24hours", TIME_24H}, + {"12h", TIME_12H}, {"12", TIME_12H}, {"12hour", TIME_12H}, {"12hours", TIME_12H}, + {"ampm", TIME_12H}, {"am/pm", TIME_12H}, {nullptr, 0}}; +const EnumName kDateNames[] = { + {"iso", DATE_ISO}, {"yyyy-mm-dd", DATE_ISO}, {"ymd", DATE_ISO}, {"iso8601", DATE_ISO}, + {"dmy", DATE_DMY}, {"dd/mm/yyyy", DATE_DMY}, {"d/m/yyyy", DATE_DMY}, + {"mdy", DATE_MDY}, {"mm/dd/yyyy", DATE_MDY}, {"m/d/yyyy", DATE_MDY}, + {"dmy-dot", DATE_DMY_DOT}, {"dd.mm.yyyy", DATE_DMY_DOT}, {"d.m.yyyy", DATE_DMY_DOT}, + {"d-mon-y", DATE_D_MON_Y}, {"d mon yyyy", DATE_D_MON_Y}, {"dd mon yyyy", DATE_D_MON_Y}, {"d mmm yyyy", DATE_D_MON_Y}, + {"mon-d-y", DATE_MON_D_Y}, {"mon d, yyyy", DATE_MON_D_Y}, {"mmm d, yyyy", DATE_MON_D_Y}, {"mon d yyyy", DATE_MON_D_Y}, + {nullptr, 0}}; +const EnumName kBatteryNames[] = {{"auto", BATTERY_AUTO}, {"automatic", BATTERY_AUTO}, {"fitted", BATTERY_AUTO}, + {"present", BATTERY_AUTO}, {"yes", BATTERY_AUTO}, {"on", BATTERY_AUTO}, + {"none", BATTERY_NONE}, + {"no", BATTERY_NONE}, {"usb", BATTERY_NONE}, {"absent", BATTERY_NONE}, + {"off", BATTERY_NONE}, {nullptr, 0}}; +const EnumName kWifiSaveNames[] = {{"normal", WIFISAVE_NORMAL}, {"off", WIFISAVE_NORMAL}, {"min", WIFISAVE_NORMAL}, + {"default", WIFISAVE_NORMAL}, {"max", WIFISAVE_MAX}, {"maximum", WIFISAVE_MAX}, + {"on", WIFISAVE_MAX}, {"saver", WIFISAVE_MAX}, {nullptr, 0}}; +const EnumName kWifiModeNames[] = {{"always", WIFIMODE_ALWAYS}, {"on", WIFIMODE_ALWAYS}, {"connected", WIFIMODE_ALWAYS}, {"stay", WIFIMODE_ALWAYS}, + {"sync", WIFIMODE_SYNC}, {"sync-only", WIFIMODE_SYNC}, {"periodic", WIFIMODE_SYNC}, {"saver", WIFIMODE_SYNC}, + {nullptr, 0}}; +const EnumName kCpuIdleNames[] = {{"off", CPUIDLE_OFF}, {"same", CPUIDLE_OFF}, {"none", CPUIDLE_OFF}, {"no", CPUIDLE_OFF}, {"0", CPUIDLE_OFF}, + {"80", CPUIDLE_80}, {"80mhz", CPUIDLE_80}, {"40", CPUIDLE_40}, {"40mhz", CPUIDLE_40}, + {"20", CPUIDLE_20}, {"20mhz", CPUIDLE_20}, {nullptr, 0}}; +const EnumName kConsoleNames[] = {{"on", CONSOLE_ON}, {"yes", CONSOLE_ON}, {"always", CONSOLE_ON}, {"true", CONSOLE_ON}, {"1", CONSOLE_ON}, + {"auto", CONSOLE_AUTO}, {"usb", CONSOLE_AUTO}, {"automatic", CONSOLE_AUTO}, + {"off", CONSOLE_OFF}, {"no", CONSOLE_OFF}, {"never", CONSOLE_OFF}, {"false", CONSOLE_OFF}, {"0", CONSOLE_OFF}, + {nullptr, 0}}; +const EnumName kCpuNames[] = {{"80", CPU_80}, {"80mhz", CPU_80}, {"160", CPU_160}, {"160mhz", CPU_160}, + {"240", CPU_240}, {"240mhz", CPU_240}, {nullptr, 0}}; + +#define OFF(field) offsetof(Settings, field) +#define SZ(field) sizeof(((Settings *)nullptr)->field) + +const Def kDefs[] = { + // 0 + {"wifi", "wifi", T_BOOL, OFF(wifi), 0, 0, 0, nullptr, false, "WiFi", + "on or off. Off keeps the radio off: no network time, weather or Spotify, and less\n" + "power drawn. Without network time the clock runs on its own crystal and drifts\n" + "(see the note at the end of this file)."}, + {"wifi_ssid", "ssid", T_STRING, OFF(wifiSsid), SZ(wifiSsid), 0, 0, nullptr, false, "WiFi", + "Name of your WiFi network (2.4 GHz only)."}, + {"wifi_password", "pass", T_STRING, OFF(wifiPassword), SZ(wifiPassword), 0, 0, nullptr, true, "WiFi", + "Its password. Kept as plain text here and in the clock's flash. For an open\n" + "network write two quotes: wifi_password = \"\""}, + {"wifi_backup_ssid", "ssid2", T_STRING, OFF(wifiBackupSsid), SZ(wifiBackupSsid), 0, 0, nullptr, false, "WiFi", + "A second network, for when the first cannot be joined (a phone's hotspot, another\n" + "router). The clock tries the main network first. While it is on the backup it looks\n" + "for the main one every 10 minutes and goes back to it. Empty = no backup."}, + {"wifi_backup_password", "pass2", T_STRING, OFF(wifiBackupPassword), SZ(wifiBackupPassword), 0, 0, nullptr, true, "WiFi", + "Its password (for an open network write two quotes)."}, + {"hostname", "host", T_STRING, OFF(hostname), SZ(hostname), 0, 0, nullptr, false, "WiFi", + "The clock's name on your network: letters, digits and '-'."}, + {"ntp_server", "ntp", T_STRING, OFF(ntpServer), SZ(ntpServer), 0, 0, nullptr, false, "WiFi", + "Time server to try first (pool.ntp.org, time.cloudflare.com and time.google.com\n" + "follow). Empty = the built-in list."}, + {"wifi_power_save", "wps", T_ENUM, OFF(wifiPowerSave), 0, 0, 0, kWifiSaveNames, false, "WiFi", + "normal or max. Max lets the radio sleep longer between messages: a little less\n" + "power, but every reply can come up to a third of a second late, the network time\n" + "included (look at \"last step\" on the Info page). The Spotify setup page always\n" + "runs in normal."}, + {"wifi_mode", "wmode", T_ENUM, OFF(wifiMode), 0, 0, 0, kWifiModeNames, false, "WiFi", + "always or sync. Always keeps the radio connected. Sync switches it off between syncs: it\n" + "wakes for the network time (hourly) and the weather (every weather_interval_min), looks at\n" + "Spotify once each time, and stays on while music plays (see spotify_live), after a key press\n" + "and on the Now Playing page. Draws far less on a battery; the Spotify strip and the link page\n" + "need that page or a key press to wake the radio. When none of its networks is in range the\n" + "clock looks again every 2 minutes, later every 5, and at once when a button is pressed.\n" + "See the README."}, + // 8 + {"units", "units", T_ENUM, OFF(units), 0, 0, 0, kUnitNames, false, "Units and formats", + "metric (degrees C, km/h) or imperial (degrees F, mph)."}, + {"time_format", "tfmt", T_ENUM, OFF(timeFormat), 0, 0, 0, kTimeNames, false, "Units and formats", + "24h or 12h (12h shows AM or PM)."}, + {"date_format", "dfmt", T_ENUM, OFF(dateFormat), 0, 0, 0, kDateNames, false, "Units and formats", + "iso (2026-10-04), dmy (04/10/2026), mdy (10/04/2026), dmy-dot (04.10.2026),\n" + "d-mon-y (4 Oct 2026) or mon-d-y (Oct 4, 2026)."}, + {"show_week", "week", T_BOOL, OFF(showWeek), 0, 0, 0, nullptr, false, "Units and formats", + "on or off. Shows the ISO week number and the day of the year after the weekday."}, + // 12 + {"latitude", "lat", T_DOUBLE, OFF(latitude), 0, -90, 90, nullptr, false, "Location", + "Exact coordinates of the clock, in degrees: -31.952 and 115.861, or 31.952 S and\n" + "115.861 E. They win over the place name below."}, + {"longitude", "lon", T_DOUBLE, OFF(longitude), 0, -180, 180, nullptr, false, "Location", nullptr}, + {"location_label", "label", T_STRING, OFF(locationLabel), SZ(locationLabel), 0, 0, nullptr, false, "Location", + "What the status bar shows. Empty = the name that was found."}, + {"location", "place", T_STRING, OFF(location), SZ(location), 0, 0, nullptr, false, "Location", + "A place to look up (Open-Meteo), such as Perth or \"Paris, France\". Used when no\n" + "coordinates are set."}, + {"timezone", "tz", T_STRING, OFF(timezone), SZ(timezone), 0, 0, nullptr, false, "Location", + "Overrides the automatic time zone: an IANA name (Australia/Perth) or a POSIX rule\n" + "(AWST-8). Empty = follow the location, which needs WiFi: with wifi = off set it here."}, + // 17 + {"spotify", "spot", T_BOOL, OFF(spotify), 0, 0, 0, nullptr, false, "Spotify", + "on or off. Spotify also needs the Client ID below."}, + {"spotify_client_id", "spid", T_STRING, OFF(spotifyClientId), SZ(spotifyClientId), 0, 0, nullptr, true, "Spotify", + "The Client ID of your app in the Spotify developer dashboard (see the README)."}, + {"spotify_live", "splive", T_BOOL, OFF(spotifyLive), 0, 0, 0, nullptr, false, "Spotify", + "on or off. Only matters with wifi_mode = sync. On: while music plays the radio stays on\n" + "and the clock asks Spotify every 10 seconds, so the strip follows your phone within seconds.\n" + "Off: the radio sleeps while music plays too, and the clock looks again when the track on\n" + "screen should be over. Far less radio, but a track skipped or paused on a phone shows late:\n" + "when it would have ended, or at the next weather update. KEY presses and the Now Playing\n" + "page are live either way."}, + // 19 + {"indoor_offset", "ioff", T_FLOAT, OFF(indoorOffsetC), 0, -15, 15, nullptr, false, "Sensors and battery", + "Degrees C added to the indoor temperature to make up for the board warming the\n" + "sensor (the factory value is -4.0)."}, + {"battery", "bat", T_ENUM, OFF(battery), 0, 0, 0, kBatteryNames, false, "Sensors and battery", + "auto or none. Say none when no battery is fitted (running from USB): the gauge\n" + "then shows USB, nothing is estimated and the clock never shuts down for a low\n" + "battery. The clock cannot tell by itself: on USB the empty battery socket reads\n" + "like a full battery."}, + {"battery_capacity_mah", "batmah", T_INT, OFF(batteryCapacityMah), 0, 0, 20000, nullptr, false, "Sensors and battery", + "Capacity of your battery in mAh, so the Power and settings page can show the\n" + "average current. 0 = unknown."}, + {"battery_calibration", "batcal", T_FLOAT, OFF(batteryCalibration), 0, 0.80, 1.25, nullptr, false, "Sensors and battery", + "Corrects the battery voltage the clock measures: the real voltage (from a LiPo tester\n" + "or a meter) divided by what the Power and settings page shows. 1 = no correction.\n" + "Example: the page says 4.13 V while the tester says 4.20 V, so 4.20 / 4.13 = 1.017.\n" + "Or type batcal 4.20 in the serial console: the clock works it out and keeps it."}, + {"low_battery_shutdown", "shut", T_BOOL, OFF(lowBatteryShutdown), 0, 0, 0, nullptr, false, "Sensors and battery", + "on or off. Shut down (deep sleep, with a message on the screen) before the battery\n" + "is flat, so a LiPo is not ruined. It wakes by itself once charging has brought the\n" + "battery back, or when you press a button."}, + {"battery_cutoff_v", "cutoff", T_FLOAT, OFF(batteryCutoffV), 0, 3.10, 3.60, nullptr, false, "Sensors and battery", + "Battery voltage, as measured while the clock runs, at which it shuts down:\n" + "3.10 to 3.60 (default 3.30)."}, + // 25 + {"cpu_mhz", "cpu", T_ENUM, OFF(cpuSpeed), 0, 0, 0, kCpuNames, false, "Power", + "80, 160 or 240. 80 uses the least power and is plenty for a clock."}, + {"cpu_idle_mhz", "cpuidle", T_ENUM, OFF(cpuIdle), 0, 0, 0, kCpuIdleNames, false, "Power", + "off, 80, 40 or 20: the CPU clock while the radio is off (wifi = off, or between the syncs\n" + "of wifi_mode = sync). Slower draws less and a frame takes longer to draw: about 30 ms at\n" + "80, 60 at 40, 120 at 20. Try 40 first: it is the lowest clock the chip maker gives figures\n" + "for. The clock goes back to cpu_mhz while the radio is on, for a few seconds after a button\n" + "press, and while a computer is on the USB port with the console running (the console may\n" + "stop below 80 MHz). off = no change."}, + {"console", "console", T_ENUM, OFF(console), 0, 0, 0, kConsoleNames, false, "Power", + "on, auto or off: the USB serial console (the log and the commands). On keeps it running.\n" + "Auto runs it while a computer is on the USB port and shuts it down, with the USB\n" + "transceiver, once there has been none for 10 seconds; it then stays off until the clock is\n" + "restarted with the cable in. Off shuts it down at the end of every start-up, computer or\n" + "not. A console that is shut down saves a little power, and the USB port is dead: to upload\n" + "a firmware, restart the clock with KEY held down (the console then stays on for that run),\n" + "or hold BOOT while switching it on, or use the SD card."}, + {"power_log", "plog", T_BOOL, OFF(powerLog), 0, 0, 0, nullptr, false, "Power", + "on or off. On: the clock writes its own power readings (the battery, the runtime estimate,\n" + "the CPU clock, the radio's share of the time) into its flash as one line of CSV at each start\n" + "and every 10 minutes, for comparing settings by how fast the battery goes down. A start\n" + "with an SD card in copies the log to the card as ESP32-S3-RLCD-PowerLog.csv. The newest\n" + "0.5 to 1 MB are kept (weeks). The first time, the flash partition for it is formatted.\n" + "See the README."}, + {"weather_interval_min", "wxint", T_INT, OFF(weatherIntervalMin), 0, 5, 240, nullptr, false, "Weather", + "Minutes between weather updates, 5 to 240 (default 15)."}, +}; +const size_t kDefCount = sizeof kDefs / sizeof kDefs[0]; +// (32 settings fill the mask: the next one needs a wider `userSet`, and wider shifts where it is used) +static_assert(sizeof kDefs / sizeof kDefs[0] <= 32, "userSet is a 32 bit mask"); + +struct Alias { + const char *from; + const char *to; +}; +const Alias kAliases[] = { + {"ssid", "wifi_ssid"}, {"network", "wifi_ssid"}, {"wifi_name", "wifi_ssid"}, + {"password", "wifi_password"}, {"pass", "wifi_password"}, {"wifi_pass", "wifi_password"}, + {"wifi_key", "wifi_password"}, {"lat", "latitude"}, {"lon", "longitude"}, + {"lng", "longitude"}, {"long", "longitude"}, {"tz", "timezone"}, + {"time_zone", "timezone"}, {"client_id", "spotify_client_id"}, {"spotify_id", "spotify_client_id"}, + {"unit", "units"}, {"temperature_units", "units"}, {"hour_format", "time_format"}, + {"clock_format", "time_format"}, {"label", "location_label"}, {"place", "location"}, + {"city", "location"}, {"wifi_enabled", "wifi"}, {"cpu", "cpu_mhz"}, + {"cpu_speed", "cpu_mhz"}, {"offset", "indoor_offset"}, {"temp_offset", "indoor_offset"}, + {"wifi_sync", "wifi_mode"}, {"radio_mode", "wifi_mode"}, {"cpu_idle", "cpu_idle_mhz"}, + {"idle_cpu", "cpu_idle_mhz"}, {"idle_clock", "cpu_idle_mhz"}, + {"serial", "console"}, {"serial_console", "console"}, {"usb_console", "console"}, + {"console_mode", "console"}, {"log", "console"}, + {"spotify_follow", "spotify_live"}, {"spotify_stay_connected", "spotify_live"}, + {"battery_log", "power_log"}, {"log_power", "power_log"}, + {"backup_ssid", "wifi_backup_ssid"}, {"backup_wifi", "wifi_backup_ssid"}, + {"backup_network", "wifi_backup_ssid"}, {"wifi_ssid2", "wifi_backup_ssid"}, + {"ssid2", "wifi_backup_ssid"}, {"second_ssid", "wifi_backup_ssid"}, + {"fallback_ssid", "wifi_backup_ssid"}, {"alt_ssid", "wifi_backup_ssid"}, + {"backup_password", "wifi_backup_password"}, {"backup_pass", "wifi_backup_password"}, + {"backup_wifi_password", "wifi_backup_password"}, {"wifi_password2", "wifi_backup_password"}, + {"password2", "wifi_backup_password"}, {"pass2", "wifi_backup_password"}, + {"second_password", "wifi_backup_password"}, {"fallback_password", "wifi_backup_password"}, + {"alt_password", "wifi_backup_password"}, {"backup_key", "wifi_backup_password"}, + {"battery_cal", "battery_calibration"}, {"adc_calibration", "battery_calibration"}, + {"voltage_calibration", "battery_calibration"}, {"battery_scale", "battery_calibration"}, + {"battery_gain", "battery_calibration"}, {"adc_scale", "battery_calibration"}, + {"battery_capacity", "battery_capacity_mah"}, {"capacity_mah", "battery_capacity_mah"}, +}; + +// --------------------------------------------------------------------------- +// Small text helpers +// --------------------------------------------------------------------------- +char lower(char c) { return (c >= 'A' && c <= 'Z') ? (char)(c + 32) : c; } +bool isSpace(char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\v' || c == '\f'; } + +void trim(char *s) { + char *start = s; + while (isSpace(*start)) start++; + size_t n = strlen(start); + while (n > 0 && isSpace(start[n - 1])) n--; + start[n] = 0; + if (start != s) memmove(s, start, n + 1); +} + +// Lower case with spaces, '-' and '_' removed: "Date-Format", "date format" and "dateformat" +// compare equal, and so do "Wi-Fi password" and "wifi_password". +void normalizeToken(const char *in, char *out, size_t cap) { + size_t n = 0; + for (; *in && n + 1 < cap; ++in) { + if (isSpace(*in) || *in == '-' || *in == '_') continue; + out[n++] = lower(*in); + } + out[n] = 0; +} + +// Quote marks come in two families, so the apostrophe in "Bob's WiFi" does not close it. +enum QuoteKind { Q_NONE = 0, Q_DOUBLE, Q_SINGLE }; + +// Length in bytes of the quote mark at p (straight, curly or guillemet), or 0. +int quoteAt(const char *p, QuoteKind *kind) { + const unsigned char c = (unsigned char)p[0]; + *kind = Q_NONE; + if (c == '"') return *kind = Q_DOUBLE, 1; + if (c == '\'') return *kind = Q_SINGLE, 1; + if (c == 0xE2 && (unsigned char)p[1] == 0x80) { + const unsigned char d = (unsigned char)p[2]; + if (d == 0x9C || d == 0x9D || d == 0x9E || d == 0x9F) return *kind = Q_DOUBLE, 3; // “ ” „ ‟ + if (d == 0x98 || d == 0x99 || d == 0x9A || d == 0x9B) return *kind = Q_SINGLE, 3; // ‘ ’ ‚ ‛ + } + if (c == 0xC2 && ((unsigned char)p[1] == 0xAB || (unsigned char)p[1] == 0xBB)) return *kind = Q_DOUBLE, 2; // « » + return 0; +} + +bool onlyCommentFollows(const char *p) { + while (isSpace(*p)) p++; + return *p == 0 || *p == '#' || *p == ';'; +} + +// Takes the text after "=". A value in quotes loses them, and whatever follows the closing +// quote is a comment; a bare value loses a trailing " # comment". Sets *quoted. +// +// The closing quote is the first mark of the same family that is followed by nothing or a +// comment, so "My "Network"" and "Home" # it's the "good" one both come out right; with +// no such mark the last one on the line is taken. A leading " without any closing quote is +// dropped (a forgotten quote); a lone ' is kept, it may be an apostrophe. +void unquote(char *v, bool *quoted) { + trim(v); + *quoted = false; + QuoteKind kind; + const int open = quoteAt(v, &kind); + if (open) { + char *inner = v + open; + char *close = nullptr, *last = nullptr; + for (char *p = inner; *p;) { + QuoteKind k; + const int l = quoteAt(p, &k); + if (l && k == kind) { + last = p; + if (!close && onlyCommentFollows(p + l)) close = p; + } + p += l ? l : 1; + } + if (!close) close = last; + if (close || kind == Q_DOUBLE) { + *quoted = true; + if (close) *close = 0; + memmove(v, inner, strlen(inner) + 1); + if (!close) trim(v); + return; + } + } + for (char *p = v; *p; ++p) { // a comment needs a space before the '#' + if (*p == '#' && p > v && isSpace(p[-1])) { + *p = 0; + break; + } + } + trim(v); +} + +bool parseBool(const char *v, bool *out) { + char t[16]; + normalizeToken(v, t, sizeof t); + static const char *const yes[] = {"on", "yes", "true", "1", "y", "enabled", "enable", "t", nullptr}; + static const char *const no[] = {"off", "no", "false", "0", "n", "disabled", "disable", "f", nullptr}; + for (int i = 0; yes[i]; i++) + if (!strcmp(t, yes[i])) return *out = true, true; + for (int i = 0; no[i]; i++) + if (!strcmp(t, no[i])) return *out = false, true; + return false; +} + +bool parseEnum(const EnumName *names, const char *v, int *out) { + char t[40], n[40]; + normalizeToken(v, t, sizeof t); + for (const EnumName *e = names; e->name; ++e) { + normalizeToken(e->name, n, sizeof n); + if (!strcmp(t, n)) return *out = e->value, true; + } + return false; +} + +// "12.5", "12,5", "-3 C", "80 MHz", "31.952 S". `suffix` receives what follows the number. +bool parseNumber(const char *v, double *out, char *suffix, size_t suffixCap) { + char buf[48]; + size_t n = 0; + bool dot = strchr(v, '.') != nullptr; + for (; v[n] && n + 1 < sizeof buf; n++) buf[n] = (v[n] == ',' && !dot) ? '.' : v[n]; + buf[n] = 0; + char *end = nullptr; + const double d = strtod(buf, &end); + if (end == buf || !isfinite(d)) return false; + while (isSpace(*end)) end++; + if (suffix) snprintf(suffix, suffixCap, "%s", end); + *out = d; + return true; +} + +// What may follow a number: nothing, or a unit such as "V", "min", "MHz", "C", "%" or a +// degree sign. A second number ("3.3.0") is not a unit. +bool plainSuffix(const char *s) { + if (!*s) return true; + const unsigned char first = (unsigned char)*s; + if (!(isalpha(first) || first == '%' || first == 0xC2)) return false; + size_t n = 0; + for (; *s; ++s, ++n) { + const unsigned char c = (unsigned char)*s; + if (!(isalpha(c) || c == '%' || c == '/' || c == 0xC2 || c == 0xB0 || c == ' ')) return false; + } + return n <= 14; +} + +// What follows a coordinate: nothing, a degree mark ("°", "deg", "degrees") and then a compass +// letter or word for the right axis: "31.95", "31.95 S", "31.95° south", "115.86 E", "74 W". +// Returns -1 for south/west (the number becomes negative), +1 for plain or north/east, and 0 +// when the text makes no sense for that axis ("33 E" as a latitude). +int hemisphereSign(const char *suffix, bool latitude) { + char t[24]; + size_t n = 0; + for (const char *p = suffix; *p && n + 1 < sizeof t; ++p) { + const unsigned char c = (unsigned char)*p; + if (isSpace((char)c) || c == 0xC2 || c == 0xB0) continue; // blanks and the two bytes of "°" + t[n++] = lower((char)c); + } + t[n] = 0; + const char *rest = t; + if (!strncmp(rest, "degrees", 7)) rest += 7; + else if (!strncmp(rest, "degree", 6)) rest += 6; + else if (!strncmp(rest, "deg", 3)) rest += 3; + if (!*rest) return 1; + if (latitude) { + if (!strcmp(rest, "n") || !strcmp(rest, "north")) return 1; + if (!strcmp(rest, "s") || !strcmp(rest, "south")) return -1; + } else { + if (!strcmp(rest, "e") || !strcmp(rest, "east")) return 1; + if (!strcmp(rest, "w") || !strcmp(rest, "west")) return -1; + } + return 0; +} + +// Finds a setting by its name or one of the aliases, ignoring case, spaces, '-' and '_'. +const Def *findDef(const char *key, size_t *index) { + char k[48], t[48]; + normalizeToken(key, k, sizeof k); + const char *wanted = k; + char target[48]; + for (const Alias &a : kAliases) { + normalizeToken(a.from, t, sizeof t); + if (!strcmp(k, t)) { + normalizeToken(a.to, target, sizeof target); + wanted = target; + break; + } + } + for (size_t i = 0; i < kDefCount; i++) { + normalizeToken(kDefs[i].key, t, sizeof t); + if (!strcmp(wanted, t)) { + if (index) *index = i; + return &kDefs[i]; + } + } + return nullptr; +} + +char *fieldPtr(Settings &s, const Def &d) { return reinterpret_cast(&s) + d.offset; } +const char *fieldPtr(const Settings &s, const Def &d) { return reinterpret_cast(&s) + d.offset; } + +int enumValue(const Settings &s, const Def &d) { return *reinterpret_cast(fieldPtr(s, d)); } + +const char *enumName(const Def &d, int value) { + for (const EnumName *e = d.names; e->name; ++e) + if (e->value == value) return e->name; + return "?"; +} + +void enumChoices(const Def &d, char *out, size_t cap) { + size_t n = 0; + out[0] = 0; + int last = -1; + for (const EnumName *e = d.names; e->name && n + 24 < cap; ++e) { + if (e->value == last) continue; // one name per value + last = e->value; + n += (size_t)snprintf(out + n, cap - n, "%s%s", n ? ", " : "", e->name); + } +} + +// Hostnames, time servers and the like. +bool allChars(const char *s, const char *extra, bool allowDigits = true) { + if (!*s) return false; + for (; *s; ++s) { + const unsigned char c = (unsigned char)*s; + if (isalpha(c) || (allowDigits && isdigit(c)) || strchr(extra, c)) continue; + return false; + } + return true; +} + +bool printable(const char *s) { + for (; *s; ++s) + if ((unsigned char)*s < 0x20 || *s == 0x7F) return false; + return true; +} + +// POSIX rules such as AWST-8, <+08>-8 or CET-1CEST,M3.5.0,M10.5.0/3: only a sanity check. +bool plausiblePosixTz(const char *s) { + const size_t n = strlen(s); + if (n < 3 || n > 70) return false; + if (!(isalpha((unsigned char)s[0]) || s[0] == '<')) return false; + bool digit = false; + for (const char *p = s; *p; ++p) { + const unsigned char c = (unsigned char)*p; + if (isdigit(c)) digit = true; + if (!(isalnum(c) || strchr("<>+-:,./", c))) return false; + } + return digit; +} + +bool validateString(const Def &d, char *value, char *err, size_t errCap) { + auto fail = [&](const char *msg) { + if (err) snprintf(err, errCap, "%s", msg); + return false; + }; + if (!printable(value)) return fail("contains a control character"); + if (strlen(value) >= d.size) { + if (err) snprintf(err, errCap, "too long (at most %d characters)", (int)d.size - 1); + return false; + } + const char *k = d.key; + if (!value[0]) return strcmp(k, "hostname") != 0 || fail("cannot be empty"); // empty is "none" for the rest + if (!strcmp(k, "hostname")) { + const size_t n = strlen(value); + if (!allChars(value, "-") || value[0] == '-' || value[n - 1] == '-') return fail("use letters, digits and '-' only"); + } else if (!strcmp(k, "ntp_server")) { + if (!allChars(value, ".-:")) return fail("expected a host name or an address"); + } else if (!strcmp(k, "spotify_client_id")) { + const size_t n = strlen(value); + if (n < 16 || !allChars(value, "")) return fail("expected the 32 letters and digits of the Client ID"); + } else if (!strcmp(k, "timezone")) { + char canon[48]; + if (tzFindIanaIgnoreCase(value, canon, sizeof canon)) { + snprintf(value, d.size, "%s", canon); // the canonical spelling + } else if (!plausiblePosixTz(value)) { + return fail("not a known zone: use e.g. Australia/Perth or a rule like AWST-8"); + } + } + return true; +} + +// --------------------------------------------------------------------------- +// Reading and writing one setting +// --------------------------------------------------------------------------- +bool setFromText(Settings &s, const Def &d, const char *text, char *err, size_t errCap) { + auto fail = [&](const char *msg) { + if (err) snprintf(err, errCap, "%s", msg); + return false; + }; + char *field = fieldPtr(s, d); + switch (d.type) { + case T_BOOL: { + bool b; + if (!parseBool(text, &b)) return fail("expected on or off"); + *reinterpret_cast(field) = b; + return true; + } + case T_ENUM: { + int v; + if (!parseEnum(d.names, text, &v)) { + char choices[96]; + enumChoices(d, choices, sizeof choices); + if (err) snprintf(err, errCap, "expected %s", choices); + return false; + } + *reinterpret_cast(field) = (uint8_t)v; + return true; + } + case T_INT: + case T_FLOAT: + case T_DOUBLE: { + double v; + char suffix[24]; + if (!parseNumber(text, &v, suffix, sizeof suffix)) return fail("expected a number"); + const bool lat = !strcmp(d.key, "latitude"), lon = !strcmp(d.key, "longitude"); + if (lat || lon) { + const int sign = hemisphereSign(suffix, lat); + if (sign == 0) return fail(lat ? "expected degrees, e.g. -31.95 or 31.95 S" : "expected degrees, e.g. 115.86 or 115.86 E"); + if (sign < 0 && v > 0) v = -v; + } else if (!plainSuffix(suffix)) { + return fail("expected a number"); + } + if (v < d.lo || v > d.hi) { + if (err) snprintf(err, errCap, "must be between %g and %g", d.lo, d.hi); + return false; + } + if (d.type == T_INT) { + *reinterpret_cast(field) = (int32_t)lround(v); + } else if (d.type == T_FLOAT) { + *reinterpret_cast(field) = (float)v; + } else { + *reinterpret_cast(field) = v; + } + return true; + } + case T_STRING: { + char value[96]; + snprintf(value, sizeof value, "%s", text); + if (strlen(text) >= sizeof value) return fail("too long"); + if (!validateString(d, value, err, errCap)) return false; + snprintf(field, d.size, "%s", value); + return true; + } + } + return false; +} + +bool formatValue(const Settings &s, const Def &d, char *out, size_t cap) { + const char *field = fieldPtr(s, d); + int n = 0; + switch (d.type) { + case T_BOOL: n = snprintf(out, cap, "%s", *reinterpret_cast(field) ? "on" : "off"); break; + case T_ENUM: n = snprintf(out, cap, "%s", enumName(d, enumValue(s, d))); break; + case T_INT: n = snprintf(out, cap, "%d", (int)*reinterpret_cast(field)); break; + case T_FLOAT: n = snprintf(out, cap, "%g", (double)*reinterpret_cast(field)); break; + case T_DOUBLE: n = snprintf(out, cap, "%.8g", *reinterpret_cast(field)); break; + case T_STRING: n = snprintf(out, cap, "%s", field); break; + } + return n >= 0 && (size_t)n < cap; +} + +void restoreDefault(Settings &s, const Settings &defaults, const Def &d, size_t i) { + const size_t len = (d.type == T_STRING) ? d.size + : d.type == T_BOOL ? sizeof(bool) + : d.type == T_ENUM ? sizeof(uint8_t) + : d.type == T_INT ? sizeof(int32_t) + : d.type == T_FLOAT ? sizeof(float) + : sizeof(double); + memcpy(fieldPtr(s, d), fieldPtr(defaults, d), len); + s.userSet &= ~(1u << i); +} + +ApplyResult applyParsed(Settings &s, const Settings &defaults, const char *key, const char *raw, bool quoted, + char *err, size_t errCap) { + size_t i = 0; + const Def *d = findDef(key, &i); + if (!d) { + if (err) snprintf(err, errCap, "unknown setting '%.30s'", key); + return ApplyResult::UnknownKey; + } + char before[100]; + formatValue(s, *d, before, sizeof before); + const bool wasSet = (s.userSet >> i) & 1u; + + if (!raw[0] && !quoted) { // "key =" forgets the saved setting + restoreDefault(s, defaults, *d, i); + return wasSet ? ApplyResult::Cleared : ApplyResult::Unchanged; + } + Settings trial = s; + char msg[80] = ""; + if (!setFromText(trial, *d, raw, msg, sizeof msg)) { + if (err) snprintf(err, errCap, "'%s': %s", d->key, msg); + return ApplyResult::BadValue; + } + s = trial; + s.userSet |= 1u << i; + char after[100]; + formatValue(s, *d, after, sizeof after); + return strcmp(before, after) != 0 ? ApplyResult::Changed : ApplyResult::Unchanged; +} + +} // namespace + +// --------------------------------------------------------------------------- +// Table access +// --------------------------------------------------------------------------- +size_t settingCount() { return kDefCount; } +const char *settingKey(size_t i) { return i < kDefCount ? kDefs[i].key : ""; } +const char *settingNvsKey(size_t i) { return i < kDefCount ? kDefs[i].nvs : ""; } +bool settingIsSecret(size_t i) { return i < kDefCount && kDefs[i].secret; } +bool settingIsUserSet(const Settings &s, size_t i) { return i < kDefCount && ((s.userSet >> i) & 1u); } + +bool formatSetting(const Settings &s, size_t i, char *out, size_t cap) { + return i < kDefCount && formatValue(s, kDefs[i], out, cap); +} + +bool applyStoredSetting(Settings &s, size_t i, const char *value, char *err, size_t errCap) { + if (err && errCap) err[0] = 0; + if (i >= kDefCount || !value) return false; + if (!value[0] && kDefs[i].type != T_STRING) { // an empty text setting is a real choice + if (err) snprintf(err, errCap, "empty"); + return false; + } + Settings trial = s; + if (!setFromText(trial, kDefs[i], value, err, errCap)) return false; + s = trial; + s.userSet |= 1u << i; + return true; +} + +ApplyResult applySetting(Settings &s, const Settings &defaults, const char *key, const char *value, char *err, + size_t errCap) { + char buf[160]; + snprintf(buf, sizeof buf, "%s", value ? value : ""); + bool quoted; + unquote(buf, "ed); + return applyParsed(s, defaults, key, buf, quoted, err, errCap); +} + +// --------------------------------------------------------------------------- +// The file +// --------------------------------------------------------------------------- +namespace { + +void addIssue(ConfigReport &r, int line, const char *text) { + if (r.issueCount >= kConfigMaxIssues) return; + ConfigIssue &is = r.issues[r.issueCount++]; + is.line = line; + snprintf(is.text, sizeof is.text, "%s", text); +} + +const size_t kMaxLine = 300; + +// Calls fn(line, lineNo, tooLong) for every line of the text (LF, CRLF or CR ends; a last +// line without one counts too). A line longer than kMaxLine arrives cut off, flagged. +template +int eachLine(const char *buf, size_t n, F fn) { + int lineNo = 0; + size_t pos = 0; + while (pos < n) { + size_t e = pos; + while (e < n && buf[e] != '\n' && buf[e] != '\r') e++; + char line[kMaxLine + 1]; + const size_t len = e - pos; + const size_t take = len > kMaxLine ? kMaxLine : len; + memcpy(line, buf + pos, take); + line[take] = 0; + fn(line, ++lineNo, len > kMaxLine); + pos = e; + if (pos < n) { + if (buf[pos] == '\r' && pos + 1 < n && buf[pos + 1] == '\n') pos++; + pos++; + } + } + return lineNo; +} + +// What a line is: nothing to do, a "key = value" (split in place), or neither. +enum class LineKind { Skip, Pair, Broken }; + +LineKind splitLine(char *line, char **key, char **value) { + trim(line); + if (!line[0] || line[0] == '#' || line[0] == ';' || (line[0] == '/' && line[1] == '/')) return LineKind::Skip; + if (line[0] == '[' && line[strlen(line) - 1] == ']') return LineKind::Skip; // an INI [section] heading + char *sep = line; + while (*sep && *sep != '=' && *sep != ':') sep++; + if (!*sep) return LineKind::Broken; + *sep = 0; + trim(line); + *key = line; + *value = sep + 1; + return LineKind::Pair; +} + +bool isResetAll(const char *key) { + char t[48]; + normalizeToken(key, t, sizeof t); + return !strcmp(t, "resetall"); +} + +void processLine(Settings &s, const Settings &defaults, char *line, int lineNo, ConfigReport &r) { + char *key = nullptr, *value = nullptr; + char msg[144]; // "line 99999: " and the longest error text; addIssue cuts it to the 96 the screen keeps + const LineKind kind = splitLine(line, &key, &value); + if (kind == LineKind::Skip) return; + if (kind == LineKind::Broken) { + snprintf(msg, sizeof msg, "line %d: no '=' found", lineNo); + r.bad++; + addIssue(r, lineNo, msg); + return; + } + bool quoted; + unquote(value, "ed); + + if (isResetAll(key)) { // already acted on before the first line was read + bool b; + if (!parseBool(value, &b)) { + snprintf(msg, sizeof msg, "line %d: reset_all: expected on or off", lineNo); + r.bad++; + addIssue(r, lineNo, msg); + } + return; + } + + char err[80] = ""; + switch (applyParsed(s, defaults, key, value, quoted, err, sizeof err)) { + case ApplyResult::Changed: r.changed++; break; + case ApplyResult::Unchanged: r.unchanged++; break; + case ApplyResult::Cleared: r.cleared++; break; + case ApplyResult::UnknownKey: + r.unknown++; + snprintf(msg, sizeof msg, "line %d: %s", lineNo, err); + addIssue(r, lineNo, msg); + break; + case ApplyResult::BadValue: + r.bad++; + snprintf(msg, sizeof msg, "line %d: %s", lineNo, err); + addIssue(r, lineNo, msg); + break; + } +} + +// UTF-16 (what Notepad calls "Unicode") and UTF-8 with a byte-order mark to plain UTF-8, so +// such a file still reads. Returns a malloc'd buffer; NUL bytes inside become '?'. +char *decodeText(const char *text, size_t len, size_t *outLen) { + const unsigned char *b = (const unsigned char *)text; + const bool utf16 = len >= 2 && ((b[0] == 0xFF && b[1] == 0xFE) || (b[0] == 0xFE && b[1] == 0xFF)); + char *buf = (char *)malloc(utf16 ? len + len / 2 + 4 : len + 1); // a UTF-16 unit makes at most 3 bytes + if (!buf) return nullptr; + size_t n = 0; + if (utf16) { + const bool le = b[0] == 0xFF; + for (size_t i = 2; i + 1 < len; i += 2) { + const uint32_t u = le ? (uint32_t)(b[i] | (b[i + 1] << 8)) : (uint32_t)((b[i] << 8) | b[i + 1]); + if (u == 0) { + buf[n++] = '?'; + } else if (u < 0x80) { + buf[n++] = (char)u; + } else if (u < 0x800) { + buf[n++] = (char)(0xC0 | (u >> 6)); + buf[n++] = (char)(0x80 | (u & 0x3F)); + } else if (u >= 0xD800 && u < 0xE000) { + buf[n++] = '?'; // half of a surrogate pair + } else { + buf[n++] = (char)(0xE0 | (u >> 12)); + buf[n++] = (char)(0x80 | ((u >> 6) & 0x3F)); + buf[n++] = (char)(0x80 | (u & 0x3F)); + } + } + } else { + const size_t start = (len >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF) ? 3 : 0; + for (size_t i = start; i < len; i++) buf[n++] = text[i] ? text[i] : '?'; + } + buf[n] = 0; + *outLen = n; + return buf; +} + +} // namespace + +ConfigReport applyConfigText(Settings &s, const Settings &defaults, const char *text, size_t len) { + ConfigReport r; + size_t n = 0; + char *buf = text ? decodeText(text, len, &n) : nullptr; + if (!buf) return r; + + // "reset_all = yes" acts first wherever it stands, so the rest of the file applies on top + eachLine(buf, n, [&](char *line, int, bool) { + char *key = nullptr, *value = nullptr; + if (splitLine(line, &key, &value) != LineKind::Pair || !isResetAll(key)) return; + bool quoted, b; + unquote(value, "ed); + if (parseBool(value, &b) && b) { + s = defaults; + s.userSet = 0; + r.resetAll = true; + } + }); + + r.lines = eachLine(buf, n, [&](char *line, int lineNo, bool tooLong) { + if (tooLong) { + char msg[40]; + snprintf(msg, sizeof msg, "line %d: line too long", lineNo); + r.bad++; + addIssue(r, lineNo, msg); + return; + } + processLine(s, defaults, line, lineNo, r); + }); + free(buf); + return r; +} + +void formatConfigSummary(const ConfigReport &r, char *out, size_t cap) { + if (cap == 0) return; + size_t n = 0; + out[0] = 0; + auto append = [&](const char *sep, const char *text) { // never past the end of `out` + const int w = snprintf(out + n, cap - n, "%s%s", sep, text); + if (w > 0) n = (n + (size_t)w < cap) ? n + (size_t)w : cap - 1; + }; + auto add = [&](int count, const char *what) { + if (count <= 0) return; + char item[32]; + snprintf(item, sizeof item, "%d %s", count, what); + append(n ? ", " : "", item); + }; + if (r.resetAll) append("", "all forgotten"); + add(r.changed, "changed"); + add(r.cleared, "forgotten"); + add(r.unchanged, "unchanged"); + add(r.problems(), r.problems() == 1 ? "problem" : "problems"); + if (n == 0) snprintf(out, cap, "nothing to apply"); +} + +// --------------------------------------------------------------------------- +// The example file +// --------------------------------------------------------------------------- +namespace { + +class Out { + public: + Out(char *buf, size_t cap, bool crlf) : buf_(buf), cap_(cap), crlf_(crlf) { + if (cap_) buf_[0] = 0; + } + void add(const char *s) { + for (; *s; ++s) { + if (*s == '\n' && crlf_) put('\r'); + put(*s); + } + } + void comment(const char *text) { // each '\n' separated line behind "# " + const char *p = text; + while (p && *p) { + const char *nl = strchr(p, '\n'); + const size_t l = nl ? (size_t)(nl - p) : strlen(p); + if (l == 0) { + add("#\n"); // a blank line inside the comment, without a trailing space + } else { + add("# "); + char tmp[120]; + snprintf(tmp, sizeof tmp, "%.*s", (int)l, p); + add(tmp); + add("\n"); + } + p = nl ? nl + 1 : nullptr; + } + } + size_t size() const { return overflow_ ? 0 : n_; } + + private: + void put(char c) { + if (n_ + 2 > cap_) { // room for this character and the terminator + overflow_ = true; + return; + } + buf_[n_++] = c; + buf_[n_] = 0; + } + + char *buf_; + size_t cap_; + bool crlf_; + size_t n_ = 0; + bool overflow_ = false; +}; + +} // namespace + +size_t renderExampleConfig(const Settings &cur, const char *driftNote, char *out, size_t cap, bool crlf) { + Out o(out, cap, crlf); + o.comment( + "ESP32-S3 RLCD clock: settings file\n" + "\n" + "The clock found no settings file on this card, so it wrote this one. Every setting\n" + "below is commented out (it starts with #) and shows the value the clock uses now.\n" + "To change one: remove the # in front of its line, edit the value, save the file,\n" + "put the card back in the clock and restart it. The clock saves what it reads in its\n" + "own flash, so the card can be taken out again afterwards.\n" + "\n" + "The rules\n" + " * One \"name = value\" per line. Spaces around the = do not matter.\n" + " * Values can be in quotes or not: \"My Network\" and My Network mean the same.\n" + " Use quotes if a value contains a # or begins or ends with a space.\n" + " * Names ignore case, spaces, - and _: wifi_ssid, WiFi SSID and Wi-Fi-SSID are one setting.\n" + " * Lines starting with # or ; are comments.\n" + " * A setting you leave out keeps whatever the clock has now.\n" + " * A line with nothing after the = (for example \"timezone =\") makes the clock forget\n" + " that saved setting and go back to its built-in default.\n" + " * reset_all = yes forgets every saved setting first; remove that line afterwards.\n" + " * What the clock does not understand is ignored and listed on its Power and settings\n" + " page (the second info page).\n" + "\n" + "WiFi passwords are plain text in this file and in the clock's flash: keep the card\n" + "to yourself."); + + const char *lastGroup = ""; + for (size_t i = 0; i < kDefCount; i++) { + const Def &d = kDefs[i]; + if (strcmp(d.group, lastGroup) != 0) { + lastGroup = d.group; + o.add("\n# ---- "); + o.add(d.group); + o.add(" "); + for (size_t k = strlen(d.group); k < 60; k++) o.add("-"); + o.add("\n"); + } + if (d.help) { + o.add("#\n"); + o.comment(d.help); + } + char value[100] = ""; + if (d.secret) { + snprintf(value, sizeof value, "%s", strstr(d.key, "password") ? "\"your password\"" : "\"your client id\""); + } else if (d.type == T_STRING) { + char raw[90]; + formatValue(cur, d, raw, sizeof raw); + snprintf(value, sizeof value, "\"%s\"", raw); + } else { + formatValue(cur, d, value, sizeof value); + } + o.add("# "); + o.add(d.key); + o.add(" = "); + o.add(value); + o.add("\n"); + } + + o.add("\n# ---- Clock drift when WiFi is off "); + for (size_t k = 33; k < 66; k++) o.add("-"); + o.add("\n#\n"); + o.comment( + "With wifi = off the clock cannot ask the network for the time. It starts from the\n" + "time kept by its real-time clock chip and then counts with its own crystal, which\n" + "is good for a second or two a day (10 ppm is about 0.9 seconds a day) and depends on\n" + "temperature. Over weeks that adds up to a visible error; nothing corrects it until\n" + "WiFi has been on for a few minutes again. If you leave WiFi off, switch it on now\n" + "and then to let the clock set itself."); + if (driftNote && driftNote[0]) { + o.add("#\n"); + o.comment(driftNote); + } + return o.size(); +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/settings.h b/02_Example/Arduino/11_Clock_Dashboard/settings.h new file mode 100644 index 00000000..374e9023 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/settings.h @@ -0,0 +1,199 @@ +#pragma once + +// The clock's settings: what they are, how a hand-written settings file turns into +// them, how they are written back out as an example file, and how each one is turned +// into text for flash. Pure logic with no Arduino dependency (the sketch keeps the +// values in flash and reads the file from the SD card), so tools/tests covers all of +// it on a PC. +// +// A setting has three possible sources, later ones winning: +// built-in default (config.h / secrets.h) < saved in flash < the SD card file +// `userSet` records which settings came from the last two, so the file can say +// "forget that one" (an empty value) and the defaults come back. + +#include +#include +#include +#include + +enum UnitSystem : uint8_t { UNITS_METRIC = 0, UNITS_IMPERIAL }; +enum TimeFormat : uint8_t { TIME_24H = 0, TIME_12H }; +enum DateFormat : uint8_t { + DATE_ISO = 0, // 2026-10-04 + DATE_DMY, // 04/10/2026 + DATE_MDY, // 10/04/2026 + DATE_DMY_DOT, // 04.10.2026 + DATE_D_MON_Y, // 4 Oct 2026 + DATE_MON_D_Y, // Oct 4, 2026 + DATE_FORMAT_COUNT +}; +enum BatteryMode : uint8_t { BATTERY_AUTO = 0, BATTERY_NONE }; +enum WifiSave : uint8_t { WIFISAVE_NORMAL = 0, WIFISAVE_MAX }; +enum WifiMode : uint8_t { WIFIMODE_ALWAYS = 0, WIFIMODE_SYNC }; // radio connected all the time, or only for the syncs (radio_plan.h) +enum CpuSpeed : uint8_t { CPU_80 = 0, CPU_160, CPU_240 }; +enum CpuIdle : uint8_t { CPUIDLE_OFF = 0, CPUIDLE_80, CPUIDLE_40, CPUIDLE_20 }; // the clock while the radio is off +// The USB serial console: always, only while a computer is on the USB port, or never (console_policy.h) +enum ConsoleMode : uint8_t { CONSOLE_ON = 0, CONSOLE_AUTO, CONSOLE_OFF }; + +struct Settings { + // --- WiFi --------------------------------------------------------------------- + bool wifi = true; + char wifiSsid[33] = ""; + char wifiPassword[65] = ""; + char wifiBackupSsid[33] = ""; // tried when the main network cannot be joined; "" = no backup + char wifiBackupPassword[65] = ""; + char hostname[32] = "rlcd-clock"; + char ntpServer[48] = ""; // "" = the built-in list + uint8_t wifiPowerSave = WIFISAVE_NORMAL; // what the Arduino core does anyway; "max" is untried and can delay the time sync + uint8_t wifiMode = WIFIMODE_ALWAYS; // "sync": the radio is off between syncs (untried on the board) + // --- units and formats -------------------------------------------------------- + uint8_t units = UNITS_METRIC; + uint8_t timeFormat = TIME_24H; + uint8_t dateFormat = DATE_ISO; + bool showWeek = true; // ISO week number and day of the year after the weekday + // --- location ----------------------------------------------------------------- + double latitude = 0; + double longitude = 0; + char locationLabel[40] = ""; + char location[64] = ""; // a place name to look up, used when there are no coordinates + char timezone[72] = ""; // IANA name or POSIX rule; "" = follow the location + // --- Spotify ------------------------------------------------------------------ + bool spotify = true; + char spotifyClientId[40] = ""; + bool spotifyLive = true; // wifi_mode = sync: the radio stays on while music plays; off = it looks once a track (untried on the board) + // --- sensors and battery ------------------------------------------------------ + float indoorOffsetC = -4.0f; + uint8_t battery = BATTERY_AUTO; + int32_t batteryCapacityMah = 0; + float batteryCalibration = 1.0f; // multiplies the measured battery voltage (real / shown); 1 = none + bool lowBatteryShutdown = true; + float batteryCutoffV = 3.30f; + // --- power -------------------------------------------------------------------- + uint8_t cpuSpeed = CPU_80; + uint8_t cpuIdle = CPUIDLE_OFF; // the CPU clock while the radio is off; off = no change (untried on the board) + uint8_t console = CONSOLE_ON; // the USB serial console; auto and off shut it down (untried on the board) + bool powerLog = false; // a line of readings in the clock's flash every ten minutes (power_log.h; untried on the board) + // --- weather ------------------------------------------------------------------ + int32_t weatherIntervalMin = 15; + + // Bit i is set when setting i (in table order, see settingKey()) came from flash or + // the SD card rather than the built-in defaults. + uint32_t userSet = 0; + + bool hasMainWifi() const { return wifiSsid[0] != 0; } + // A backup that is the same network as the main one is no backup. + bool hasBackupWifi() const { return wifiBackupSsid[0] != 0 && strcmp(wifiBackupSsid, wifiSsid) != 0; } + bool imperial() const { return units == UNITS_IMPERIAL; } + bool time12h() const { return timeFormat == TIME_12H; } + bool hasLatLon() const { return latitude != 0 || longitude != 0; } + int cpuMhz() const { return cpuSpeed == CPU_240 ? 240 : (cpuSpeed == CPU_160 ? 160 : 80); } + // The clock while the radio is off, or 0 for "the same as cpuMhz()". Never above cpuMhz(). (40 MHz is the + // lowest clock Espressif gives figures for and 20 the lowest the Arduino core lists for this chip; 10 is left + // out: a frame would take a quarter of a second to draw, which leaves little of each second to save in.) + int cpuIdleMhz() const { + const int mhz = cpuIdle == CPUIDLE_80 ? 80 : cpuIdle == CPUIDLE_40 ? 40 : cpuIdle == CPUIDLE_20 ? 20 : 0; + return mhz > 0 && mhz < cpuMhz() ? mhz : 0; + } + bool syncMode() const { return wifi && wifiMode == WIFIMODE_SYNC; } + bool hasBattery() const { return battery != BATTERY_NONE; } +}; + +// --------------------------------------------------------------------------- +// The settings table +// --------------------------------------------------------------------------- +size_t settingCount(); +const char *settingKey(size_t i); // as written in the file: "wifi_ssid" +const char *settingNvsKey(size_t i); // short name for flash (15 characters at most) +bool settingIsSecret(size_t i); // passwords and ids: never echoed +bool settingIsUserSet(const Settings &s, size_t i); + +// The setting as text for flash: booleans "on"/"off", enums by name, strings as they are. +// Returns false if it does not fit. +bool formatSetting(const Settings &s, size_t i, char *out, size_t cap); + +// Applies text previously produced by formatSetting() (flash load), or the build's defaults: the +// text is the value as it is, with none of the file's quoting and comment rules, but it has to pass +// the same checks. A value that does not (a newer firmware changed the rules) is ignored, and `err` +// (if given) says why. Marks it user-set. +bool applyStoredSetting(Settings &s, size_t i, const char *value, char *err = nullptr, size_t errCap = 0); + +// --------------------------------------------------------------------------- +// The settings file +// --------------------------------------------------------------------------- +const int kConfigMaxIssues = 8; + +struct ConfigIssue { + int line = 0; + char text[96] = ""; +}; + +struct ConfigReport { + int lines = 0; // lines in the file + int changed = 0; // settings that now differ from what they were + int unchanged = 0; // ... set to what they already were + int cleared = 0; // ... forgotten (empty value), so the default is back + int unknown = 0; // names the clock does not know + int bad = 0; // known names with a value that does not fit + bool resetAll = false; + int issueCount = 0; // the first few problems, for the screen + ConfigIssue issues[kConfigMaxIssues]; + + int problems() const { return unknown + bad; } + int touched() const { return changed + cleared + (resetAll ? 1 : 0); } +}; + +// Reads a whole settings file and applies every active line to `s`. `defaults` is what a +// forgotten setting reverts to. Tolerant of the things people do: UTF-8 / UTF-16 byte-order +// marks, CR or CRLF line ends, spaces around "=" (or ":"), quoted or bare values (straight +// or curly quotes), upper case, "-" or spaces in names, a comma as the decimal mark, a unit +// after a number, N/S/E/W after a coordinate, and #-comments after bare values. +ConfigReport applyConfigText(Settings &s, const Settings &defaults, const char *text, size_t len); + +// One "name = value" on its own, with the same tolerance. An empty value forgets the +// setting. On failure `err` says why (if given). +enum class ApplyResult { Changed, Unchanged, Cleared, UnknownKey, BadValue }; +ApplyResult applySetting(Settings &s, const Settings &defaults, const char *key, const char *value, + char *err = nullptr, size_t errCap = 0); + +// One line like "7 changed, 1 unchanged, 2 problems" for the screen. +void formatConfigSummary(const ConfigReport &r, char *out, size_t cap); + +// What the banner says about the settings file of an SD card, after a start that read one (a banner holds +// 39 characters). Returns true if it is a warning. +// carried settings that the start before this one changed and saved without being able to say so: +// it went on to install a firmware from the same card and restarted. This start reads the +// same file, finds nothing left to change, and would report that the card did nothing. +// justInstalled this firmware came from the card at the last start, which read the same file. If +// nothing was carried over (the firmware that ran then did not keep a count yet), whether +// the file changed anything is not known, so "none new" is left unsaid. +// (Here, and not in settings.cpp, so that the UI preview can measure the texts it makes.) +const char *const kSdNoteNvsKey = "sdnote"; // where `carried` is kept in flash, next to the settings: no setting may use this name +inline bool formatSdBanner(const ConfigReport &r, bool savedToFlash, int carried, bool justInstalled, char *out, size_t cap) { + if (carried < 0) carried = 0; + const int changed = r.touched() + carried; + if (r.touched() > 0 && !savedToFlash) { // in force for this run, gone after the next restart + snprintf(out, cap, "%d changed, NOT saved to flash", r.touched()); + return true; + } + if (r.problems() > 0) { + snprintf(out, cap, "SD: %d changed, %d problem%s", changed, r.problems(), r.problems() == 1 ? "" : "s"); + return true; + } + if (changed > 0) { + snprintf(out, cap, "SD settings: %d changed", changed); + } else if (r.unchanged == 0) { // every line is a comment: the file the clock wrote, as it wrote it + snprintf(out, cap, "SD settings: the file sets nothing"); + } else if (justInstalled) { + snprintf(out, cap, "SD settings: %d in force", r.unchanged); + } else { + snprintf(out, cap, "SD settings: %d in force, none new", r.unchanged); + } + return false; +} + +// The file the clock writes to a card that has none: every setting commented out with its +// current value (never passwords or ids) and an explanation. `driftNote` (may be empty) +// is the measured clock drift, put into the note about running without WiFi. +// Returns the length, or 0 if `cap` is too small. `crlf` writes Windows line ends (the clock +// does, for the sake of Notepad on the other side of the card); the parser reads either. +size_t renderExampleConfig(const Settings ¤t, const char *driftNote, char *out, size_t cap, bool crlf = false); diff --git a/02_Example/Arduino/11_Clock_Dashboard/spotify.cpp b/02_Example/Arduino/11_Clock_Dashboard/spotify.cpp new file mode 100644 index 00000000..09d8791d --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/spotify.cpp @@ -0,0 +1,888 @@ +#include "spotify.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app_settings.h" +#include "app_state.h" +#include "config.h" +#include "log.h" +#include "spotify_parse.h" +#include "spotify_helper_script.h" +#include "link_page.h" +#include "radio_plan.h" +#include "timeutil.h" +#include "tls.h" +#include "util.h" + +// How this works, in short: +// * Linking uses OAuth "authorization code with PKCE", so no client secret is +// stored on the device. While no account is linked the device serves a small +// page (http:///) with an authorize link; Spotify redirects the browser to +// http://127.0.0.1:8888/callback?code=... (nothing listens there, which is +// fine), and the user pastes that address back into the page. +// * The refresh token lives in NVS. Spotify expires it after six months, so the +// time of linking is stored too and a countdown is shown. +// * Polling is deliberately gentle: Spotify's development-mode quota is +// unpublished and people have been locked out for hours by 3-second polling. +// Progress is extrapolated between polls and 429 / Retry-After is honoured +// across reboots. + +namespace { + +const char *const TAG = "spotify"; +const char *const kApiBase = "https://api.spotify.com/v1"; +const char *const kTokenUrl = "https://accounts.spotify.com/api/token"; +const char *const kAuthorizeUrl = "https://accounts.spotify.com/authorize"; +const char *const kRedirectUri = "http://127.0.0.1:8888/callback"; +const char *const kScopes = "user-read-playback-state user-modify-playback-state"; + +const uint32_t kRefreshTokenLifetimeS = 180UL * 86400UL; // Spotify: refresh tokens last 6 months +const int kRenewHintDays = 14; +const time_t kMinPlausibleEpoch = 1704067200; // 2024-01-01 +const int kIdlePollsBeforeSlowdown = 60; // ~30 minutes at the idle rate +const uint32_t kPausedHoldMs = 5UL * 60UL * 1000UL; // wifi_mode = sync: the radio stays on this long after music was playing or a command was sent +const uint32_t kPageHoldMs = 60UL * 1000UL; // ... and this long after the Now Playing page was last on screen + +struct Reply { + int code = -1; // HTTP status, or <= 0 for a transport error + String body; + int retryAfterSec = 0; +}; + +// ---- credentials & persistent state ----------------------------------------- +Preferences s_prefs; +char s_refreshToken[256] = ""; +char s_accessToken[512] = ""; +uint32_t s_accessValidUntilMs = 0; // millis() deadline; 0 = no valid access token +uint32_t s_authEpoch = 0; // when the user linked (UTC seconds) +uint32_t s_blockedUntilEpoch = 0; // rate-limit lockout (UTC seconds) +char s_deviceId[48] = ""; // last device seen playing, used to wake an idle one +bool s_linked = false; + +// ---- runtime state (network task only) -------------------------------------- +SpotifyInfo s_info; +uint32_t s_sampledMs = 0; +uint32_t s_nextPollMs = 0; +bool s_peekDone = false; // wifi_mode = sync: this radio session has looked at the player once +uint32_t s_lastActiveMs = 0; // when the player was last seen playing, or a command was sent +volatile uint32_t s_pageHoldUntilMs = 0; // the UI task shows the Now Playing page: the radio stays on until then (0 = not) +uint32_t s_trackLookAtMs = 0; // spotify_live = off: when the track on screen should be over, time for the next look (0 = none) +int s_idlePolls = 0; +int s_errorStreak = 0; +int s_authFailures = 0; +uint32_t s_lastBlockMsgMs = 0; +QueueHandle_t s_queue = nullptr; + +// ---- HTTP: one long-lived TLS connection to the API for keep-alive ---------- +NetworkClientSecure s_apiClient; +HTTPClient s_apiHttp; + +// ---- linking page ----------------------------------------------------------- +WebServer *s_web = nullptr; +char s_verifier[65] = ""; +char s_state[17] = ""; +char s_pageMessage[160] = ""; +bool s_pageMessageIsError = false; + +time_t nowEpoch() { + time_t t = time(nullptr); + return t > kMinPlausibleEpoch ? t : 0; +} + +bool timeTrusted() { + StateLock lock; + return g_state.timeTrusted; +} + +// Does the clock follow the player all the time while music plays? Always, except in wifi_mode = sync with +// spotify_live = off: there the radio goes off between looks and the clock looks again when the track on +// screen should be over (spotifyLookDue()). +bool followLive() { return !g_cfg.syncMode() || g_cfg.spotifyLive; } + +void publish() { + if (s_info.status == SPOTIFY_PLAYING) { + s_lastActiveMs = millis(); + } else { + s_trackLookAtMs = 0; // nothing is playing: nothing ends + } + StateLock lock; + g_state.spotify = s_info; + g_state.spotifySampledMs = s_sampledMs; +} + +void setStatus(SpotifyStatus st, const char *message = "") { + s_info.status = st; + copyStr(s_info.message, sizeof s_info.message, message); + publish(); +} + +// "4h 46m", "12m", "45s" +void formatRemaining(uint32_t sec, char *out, size_t cap) { + if (sec >= 3600) { + snprintf(out, cap, "%uh %um", (unsigned)(sec / 3600), (unsigned)((sec / 60) % 60)); + } else if (sec >= 60) { + snprintf(out, cap, "%um", (unsigned)(sec / 60)); + } else { + snprintf(out, cap, "%us", (unsigned)sec); + } +} + +// ---- persistence ------------------------------------------------------------ +void loadPrefs() { + s_prefs.begin("spotify", false); + copyStr(s_refreshToken, sizeof s_refreshToken, s_prefs.getString("rt", "").c_str()); + s_authEpoch = s_prefs.getUInt("auth", 0); + s_blockedUntilEpoch = s_prefs.getUInt("blk", 0); + copyStr(s_deviceId, sizeof s_deviceId, s_prefs.getString("dev", "").c_str()); + s_linked = s_refreshToken[0] != 0; +} + +void forgetAccount(const char *reason) { + LOGF(TAG, "unlinking: %s", reason); + waitForQuietPhase(); + s_prefs.remove("rt"); + s_prefs.remove("auth"); + s_refreshToken[0] = 0; + s_accessToken[0] = 0; + s_accessValidUntilMs = 0; + s_authEpoch = 0; + s_linked = false; + s_info.title[0] = s_info.artist[0] = s_info.album[0] = s_info.device[0] = 0; + setStatus(SPOTIFY_NEEDS_LINK, reason); +} + +// ---- HTTP helpers ----------------------------------------------------------- +bool containsNoCase(const char *haystack, const char *needle) { + size_t n = strlen(needle); + for (; *haystack; ++haystack) { + if (strncasecmp(haystack, needle, n) == 0) return true; + } + return false; +} + +// Calls the Web API with the current access token, reusing the TLS connection. +Reply apiCall(const char *method, const String &path, const char *jsonBody = nullptr) { + static const char *kHeaderKeys[] = {"Retry-After"}; + Reply r; + for (int attempt = 0; attempt < 2; attempt++) { + // In arduino-esp32 3.3.8 every stop() of a NetworkClientSecure (server idle + // timeout, "Connection: close", WiFi loss, ...) wipes its CA bundle hook, and + // the next connect() would then fail verification for good. Attaching the + // bundle only stores pointers, so do it before every request. + useCaBundle(s_apiClient); + s_apiHttp.setReuse(true); + s_apiHttp.setConnectTimeout(8000); + s_apiHttp.setTimeout(8000); + s_apiHttp.collectHeaders(kHeaderKeys, 1); + if (!s_apiHttp.begin(s_apiClient, String(kApiBase) + path)) return r; + s_apiHttp.addHeader("Authorization", String("Bearer ") + s_accessToken); + s_apiHttp.addHeader("Accept", "application/json"); + + int code; + if (jsonBody) { + s_apiHttp.addHeader("Content-Type", "application/json"); + code = s_apiHttp.sendRequest(method, (uint8_t *)jsonBody, strlen(jsonBody)); + } else if (strcmp(method, "GET") == 0) { + code = s_apiHttp.GET(); + } else { + // Spotify answers 411 Length Required to a bodyless PUT/POST without this, + // and HTTPClient only sends the header when there is a payload. + s_apiHttp.addHeader("Content-Length", "0"); + code = s_apiHttp.sendRequest(method, (uint8_t *)nullptr, 0); + } + r.code = code; + if (code > 0) { + if (httpStatusHasBody(code)) r.body = s_apiHttp.getString(); + r.retryAfterSec = s_apiHttp.header("Retry-After").toInt(); + } + s_apiHttp.end(); + if (code > 0) break; + s_apiClient.stop(); // a stale kept-alive connection: reconnect and try once more + // Retry only if the request cannot have been executed (never reached the + // server) or is a read-only GET; a read timeout after a POST may mean it ran. + bool neverSent = code == HTTPC_ERROR_CONNECTION_REFUSED || code == HTTPC_ERROR_SEND_HEADER_FAILED || + code == HTTPC_ERROR_SEND_PAYLOAD_FAILED || code == HTTPC_ERROR_NOT_CONNECTED || + code == HTTPC_ERROR_CONNECTION_LOST; + if (!neverSent && strcmp(method, "GET") != 0) break; + } + return r; +} + +// POST to the accounts service (form encoded, no auth header). +Reply tokenCall(const String &form) { + Reply r; + NetworkClientSecure client; + useCaBundle(client); + HTTPClient http; + http.setConnectTimeout(8000); + http.setTimeout(8000); + if (!http.begin(client, kTokenUrl)) return r; + http.addHeader("Content-Type", "application/x-www-form-urlencoded"); + r.code = http.POST(form); + if (r.code > 0 && httpStatusHasBody(r.code)) r.body = http.getString(); + http.end(); + return r; +} + +// ---- tokens ----------------------------------------------------------------- +bool storeTokens(const SpotifyTokenReply &t) { + copyStr(s_accessToken, sizeof s_accessToken, t.accessToken); + uint32_t life = t.expiresInSec > 120 ? (uint32_t)t.expiresInSec - 60 : 30; + s_accessValidUntilMs = (millis() + life * 1000UL) | 1u; // (never 0, which means "none") + if (t.refreshToken[0] && strcmp(t.refreshToken, s_refreshToken) != 0) { + // Spotify may rotate the refresh token; persist it before it is ever used. + copyStr(s_refreshToken, sizeof s_refreshToken, t.refreshToken); + waitForQuietPhase(); + s_prefs.putString("rt", s_refreshToken); + } + return true; +} + +// Returns true when s_accessToken is usable. +bool ensureAccessToken() { + // (0 is "no valid token" at every age of the clock: taken as a time it would look valid again after 24.8 days) + if (s_accessToken[0] && radioplan::pending(millis(), s_accessValidUntilMs)) return true; + if (!s_refreshToken[0]) return false; + + char enc[400]; + if (!urlEncode(s_refreshToken, enc, sizeof enc)) return false; + String form = String("grant_type=refresh_token&refresh_token=") + enc + "&client_id=" + g_cfg.spotifyClientId; + Reply r = tokenCall(form); + + char reason[48], msg[96]; + parseSpotifyError(r.body.c_str(), r.body.length(), reason, sizeof reason, msg, sizeof msg); + if (r.code == 200) { + SpotifyTokenReply t; + if (parseTokenReply(r.body.c_str(), r.body.length(), &t)) { + storeTokens(t); + LOGF(TAG, "access token refreshed (valid %d s)", t.expiresInSec); + return true; + } + LOGF(TAG, "token reply not understood"); + return false; + } + if (r.code == 400 && strcmp(reason, "invalid_grant") == 0) { + // revoked, or past Spotify's 6 month limit: needs the user to link again + forgetAccount("Link expired - please link again"); + return false; + } + LOGF(TAG, "token refresh failed: http %d %s %s", r.code, reason, msg); + return false; +} + +// ---- rate limiting ---------------------------------------------------------- +void applyRateLimit(int retryAfterSec) { + if (retryAfterSec <= 0) retryAfterSec = 60; + time_t now = nowEpoch(); + s_blockedUntilEpoch = now ? (uint32_t)now + (uint32_t)retryAfterSec : 0; + if (s_blockedUntilEpoch) { + waitForQuietPhase(); + s_prefs.putUInt("blk", s_blockedUntilEpoch); + } + char rem[16]; + formatRemaining((uint32_t)retryAfterSec, rem, sizeof rem); + char msg[56]; + snprintf(msg, sizeof msg, "Rate limited, retry in %s", rem); + LOGF(TAG, "429: backing off for %d s", retryAfterSec); + setStatus(SPOTIFY_ERROR, msg); + stateNotice("Spotify rate limited", TOAST_WARN); +} + +// Returns remaining lockout seconds (0 = not blocked). +uint32_t rateLimitRemaining() { + time_t now = nowEpoch(); + if (!now || !s_blockedUntilEpoch) return 0; + if ((uint32_t)now >= s_blockedUntilEpoch) { + s_blockedUntilEpoch = 0; + waitForQuietPhase(); + s_prefs.remove("blk"); + return 0; + } + return s_blockedUntilEpoch - (uint32_t)now; +} + +// ---- polling ---------------------------------------------------------------- +uint32_t progressNow() { + uint32_t p = s_info.progressMs; + if (s_info.status == SPOTIFY_PLAYING) p += millis() - s_sampledMs; + if (s_info.durationMs && p > s_info.durationMs) p = s_info.durationMs; + return p; +} + +void schedulePoll(uint32_t afterMs) { s_nextPollMs = millis() + afterMs; } + +// 10 s, 20 s, 40 s, 80 s, then 120 s between attempts while errors continue. +uint32_t errorBackoffMs() { + int shift = s_errorStreak > 1 ? (s_errorStreak > 5 ? 4 : s_errorStreak - 1) : 0; + uint32_t ms = 10000UL << shift; + return ms > 120000UL ? 120000UL : ms; +} + +void pollPlayer() { + s_peekDone = true; + if (!ensureAccessToken()) { + if (s_linked) { // say so instead of leaving the last track frozen on screen + s_errorStreak++; + setStatus(SPOTIFY_ERROR, "Spotify unreachable"); + schedulePoll(errorBackoffMs()); + } + return; + } + Reply r = apiCall("GET", "/me/player?additional_types=episode"); + + if (r.code == 200) { + SpotifyInfo parsed = s_info; + char dev[48]; + if (!parsePlayerState(r.body.c_str(), r.body.length(), &parsed, dev, sizeof dev)) { + LOGF(TAG, "player reply not understood (%u bytes)", (unsigned)r.body.length()); + setStatus(SPOTIFY_ERROR, "Unexpected Spotify reply"); + schedulePoll(20000); + return; + } + parsed.message[0] = 0; + s_info = parsed; + s_sampledMs = millis(); + s_errorStreak = 0; + s_authFailures = 0; + s_idlePolls = 0; + if (dev[0] && strcmp(dev, s_deviceId) != 0) { + copyStr(s_deviceId, sizeof s_deviceId, dev); + waitForQuietPhase(); + s_prefs.putString("dev", s_deviceId); + } + publish(); + + uint32_t interval = s_info.status == SPOTIFY_PLAYING ? SPOTIFY_POLL_PLAYING_MS : SPOTIFY_POLL_PAUSED_MS; + if (s_info.status == SPOTIFY_PLAYING && s_info.durationMs > s_info.progressMs) { + // look again just after the track ends so the next title shows promptly + uint32_t untilEnd = s_info.durationMs - s_info.progressMs + 1500; + if (untilEnd < interval) interval = untilEnd < 1500 ? 1500 : untilEnd; + } + schedulePoll(interval); + // spotify_live = off: the radio may go off now, and the next look is due when this track should be over + // (at once if it already is; never for something with no length, which the look of every session follows) + if (s_info.status == SPOTIFY_PLAYING && s_info.durationMs > 0) { + s_trackLookAtMs = (millis() + radioplan::trackLookAfterMs(s_info.durationMs, s_info.progressMs)) | 1u; + } else { + s_trackLookAtMs = 0; + } + return; + } + + if (r.code == 204) { // nothing playing and no active device + s_info.title[0] = s_info.artist[0] = s_info.album[0] = s_info.device[0] = 0; + s_info.durationMs = s_info.progressMs = 0; + s_info.message[0] = 0; + s_info.status = SPOTIFY_IDLE; + s_sampledMs = millis(); + s_errorStreak = 0; + s_authFailures = 0; + publish(); + s_idlePolls++; + schedulePoll(s_idlePolls > kIdlePollsBeforeSlowdown ? SPOTIFY_POLL_IDLE_MS * 2 : SPOTIFY_POLL_IDLE_MS); + return; + } + + char reason[48], msg[96]; + parseSpotifyError(r.body.c_str(), r.body.length(), reason, sizeof reason, msg, sizeof msg); + if (r.code == 401) { // access token rejected: refresh and try again shortly + s_accessValidUntilMs = 0; + if (++s_authFailures >= 3) { // a fresh token keeps being refused: don't hammer the servers + s_authFailures = 0; + setStatus(SPOTIFY_ERROR, "Spotify rejected the token"); + schedulePoll(60000); + } else { + schedulePoll(500); + } + return; + } + if (r.code == 429) { + applyRateLimit(r.retryAfterSec); + return; + } + LOGF(TAG, "poll failed: http %d %s %s", r.code, reason, msg); + s_errorStreak++; + if (r.code == 403) { + bool premium = containsNoCase(msg, "premium"); + setStatus(SPOTIFY_ERROR, premium ? "App owner needs Premium" : "Access denied (403)"); + schedulePoll(60000); + return; + } + char text[56]; + if (r.code <= 0) { + snprintf(text, sizeof text, "Spotify unreachable"); + } else { + snprintf(text, sizeof text, "Spotify error %d", r.code); + } + setStatus(SPOTIFY_ERROR, text); + schedulePoll(errorBackoffMs()); +} + +// ---- commands --------------------------------------------------------------- +void handleResult(const Reply &r, const char *what) { + if (r.code >= 200 && r.code < 300) { + schedulePoll(900); // confirm the new state shortly (Spotify needs a moment) + return; + } + char reason[48], msg[96]; + parseSpotifyError(r.body.c_str(), r.body.length(), reason, sizeof reason, msg, sizeof msg); + LOGF(TAG, "%s failed: http %d %s %s", what, r.code, reason, msg); + if (r.code == 429) { + applyRateLimit(r.retryAfterSec); + return; + } + schedulePoll(500); // whatever went wrong, find out what the player is really doing + if (r.code == 401) { + s_accessValidUntilMs = 0; + stateNotice("Spotify: try again", TOAST_WARN); + } else if (r.code == 403 && strcmp(reason, "PREMIUM_REQUIRED") == 0) { + stateNotice("Spotify Premium required", TOAST_WARN); + } else if (r.code == 404) { + stateNotice("No active Spotify device", TOAST_WARN); + } else if (r.code == 403) { + stateNotice("Spotify refused that", TOAST_WARN); + } else { + char text[40]; + snprintf(text, sizeof text, r.code > 0 ? "Spotify error %d" : "Spotify unreachable", r.code); + stateNotice(text, TOAST_WARN); + } +} + +void handleCommand(SpotifyCommand cmd) { + s_lastActiveMs = millis(); // (wifi_mode = sync: the radio stays on for a while, for the confirming poll and what comes next) + if (cmd == SPOTIFY_CMD_UNLINK) { + forgetAccount("Unlinked"); + return; + } + if (cmd == SPOTIFY_CMD_REFRESH) { + schedulePoll(0); + return; + } + if (!s_linked) { + stateNotice("Spotify not linked", TOAST_WARN); + return; + } + if (rateLimitRemaining()) { + stateNotice("Spotify rate limited", TOAST_WARN); + return; + } + if (!ensureAccessToken()) { + stateNotice("Spotify unavailable", TOAST_WARN); + return; + } + + Reply r; + switch (cmd) { + case SPOTIFY_CMD_PLAY_PAUSE: { + // Polling is slow while idle, so the state we hold can be 30 s old. Look + // first: a press right after starting music on a phone must pause it, not + // send a useless "play". + const bool assumedPlaying = s_info.status == SPOTIFY_PLAYING; // what the UI based its toast on + if ((uint32_t)(millis() - s_sampledMs) > 2500) { + pollPlayer(); + if (rateLimitRemaining()) { + stateNotice("Spotify rate limited", TOAST_WARN); + return; + } + if (!s_linked) return; // the refresh token turned out to be revoked + } + bool playing = s_info.status == SPOTIFY_PLAYING; + if (playing != assumedPlaying) { // the screen was out of date: correct the toast it showed + stateNotice(playing ? "Pause" : "Play", playing ? TOAST_PAUSE : TOAST_PLAY); + } + r = apiCall("PUT", playing ? "/me/player/pause" : "/me/player/play"); + if (!playing && r.code == 404 && s_deviceId[0]) { + // The device went idle (Spotify forgets it after a while): wake it. + String body = String("{\"device_ids\":[\"") + s_deviceId + "\"],\"play\":true}"; + r = apiCall("PUT", "/me/player", body.c_str()); + } + if (r.code >= 200 && r.code < 300) { // flip the display now, the poll will confirm + uint32_t p = progressNow(); + s_info.progressMs = p; + s_sampledMs = millis(); + s_info.status = playing ? SPOTIFY_PAUSED : SPOTIFY_PLAYING; + publish(); + } + handleResult(r, "play/pause"); + break; + } + case SPOTIFY_CMD_NEXT: + r = apiCall("POST", "/me/player/next"); + handleResult(r, "next"); + break; + case SPOTIFY_CMD_PREVIOUS: + r = apiCall("POST", "/me/player/previous"); + handleResult(r, "previous"); + break; + default: + break; + } +} + +// ---- linking page ----------------------------------------------------------- +void newPkce() { + static const char *kAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; // 66 chars + for (int i = 0; i < 64; i++) s_verifier[i] = kAlphabet[esp_random() % 66]; + s_verifier[64] = 0; + for (int i = 0; i < 16; i++) s_state[i] = kAlphabet[esp_random() % 62]; + s_state[16] = 0; +} + +String authorizeUrl() { + uint8_t digest[32]; + mbedtls_sha256((const unsigned char *)s_verifier, strlen(s_verifier), digest, 0); + char challenge[48]; + base64UrlEncode(digest, sizeof digest, challenge, sizeof challenge); + char url[640]; + if (!buildAuthorizeUrl(g_cfg.spotifyClientId, kRedirectUri, kScopes, challenge, s_state, url, sizeof url)) { + return String(); + } + return String(url); +} + +String htmlEscape(const char *s) { + String out; + for (; *s; ++s) { + switch (*s) { + case '&': out += "&"; break; + case '<': out += "<"; break; + case '>': out += ">"; break; + case '"': out += """; break; + default: out += *s; break; + } + } + return out; +} + +// Wraps `body` in the page chrome shared by the setup page and the callback answer. +void sendPage(int code, const String &body) { + const size_t cap = body.length() + 1400; + char *doc = (char *)malloc(cap); + if (!doc || !renderPageDocument(APP_NAME, body.c_str(), doc, cap)) { + free(doc); + s_web->send(500, "text/plain", "Could not build the page"); + return; + } + s_web->send(code, "text/html; charset=utf-8", doc); + free(doc); +} + +void sendLinkPage() { + const String ip = WiFi.localIP().toString(); + const String authorize = authorizeUrl(); + LinkPageInfo info; + info.appName = APP_NAME; + info.linked = s_linked; + info.daysLeft = s_info.linkDaysLeft; + info.message = s_pageMessage; + info.messageIsError = s_pageMessageIsError; + info.redirectUri = kRedirectUri; + info.clockAddress = ip.c_str(); + info.authorizeUrl = authorize.c_str(); + + const size_t cap = 6000; + char *body = (char *)malloc(cap); + if (!body || !renderLinkPageBody(info, body, cap)) { + free(body); + s_web->send(500, "text/plain", "Could not build the page"); + return; + } + sendPage(200, String(body)); + free(body); +} + +bool exchangeCode(const char *code, char *error, size_t errorCap) { + char codeEnc[600], redirEnc[96]; + if (!urlEncode(code, codeEnc, sizeof codeEnc) || !urlEncode(kRedirectUri, redirEnc, sizeof redirEnc)) { + copyStr(error, errorCap, "The code in that address is too long."); + return false; + } + String form = String("grant_type=authorization_code&code=") + codeEnc + "&redirect_uri=" + redirEnc + + "&client_id=" + g_cfg.spotifyClientId + "&code_verifier=" + s_verifier; + Reply r = tokenCall(form); + if (r.code != 200) { + char reason[48], msg[96]; + parseSpotifyError(r.body.c_str(), r.body.length(), reason, sizeof reason, msg, sizeof msg); + LOGF(TAG, "code exchange failed: http %d %s %s", r.code, reason, msg); + if (r.code <= 0) { + copyStr(error, errorCap, "Could not reach Spotify. Check the clock's internet connection."); + } else { + snprintf(error, errorCap, "Spotify refused the code (%s). Please start again.", reason[0] ? reason : "error"); + } + return false; + } + SpotifyTokenReply t; + if (!parseTokenReply(r.body.c_str(), r.body.length(), &t) || !t.refreshToken[0]) { + copyStr(error, errorCap, "Spotify's reply was not understood."); + return false; + } + storeTokens(t); + time_t now = nowEpoch(); + s_authEpoch = (uint32_t)now; + waitForQuietPhase(); + s_prefs.putUInt("auth", s_authEpoch); + s_linked = true; + s_idlePolls = 0; + s_errorStreak = 0; + return true; +} + +// Finishes the OAuth flow with the code Spotify sent back, whichever way it +// arrived (the helper / a forwarded redirect on /callback, or the pasted address). +// `state` may be empty. `message` always receives a sentence for the user. +bool completeLinking(const char *code, const char *state, char *message, size_t cap) { + if (state[0] && strcmp(state, s_state) != 0) { + copyStr(message, cap, "That redirect belongs to a different attempt. Please start again from the setup page."); + return false; + } + if (!timeTrusted()) { + copyStr(message, cap, "The clock has not got the time yet. Try again in a minute."); + return false; + } + char error[120]; + if (!exchangeCode(code, error, sizeof error)) { + copyStr(message, cap, error); + newPkce(); // a used or mismatched code cannot be reused + return false; + } + s_info.linkDaysLeft = (int16_t)(kRefreshTokenLifetimeS / 86400UL); + setStatus(SPOTIFY_IDLE); + schedulePoll(0); + stateNotice("Spotify linked", TOAST_PLAY); + copyStr(message, cap, "Linked! You can close this page; the clock will show what is playing."); + return true; +} + +// POST /link: the user pasted the address their browser ended up on. +void handleLink() { + String url = s_web->arg("url"); + char code[512], state[40], err[48], message[160]; + s_pageMessageIsError = true; + + if (queryParam(url.c_str(), "error", err, sizeof err)) { + snprintf(s_pageMessage, sizeof s_pageMessage, "Spotify reported: %s. Please start again from step 2.", err); + } else if (!queryParam(url.c_str(), "code", code, sizeof code)) { + copyStr(s_pageMessage, sizeof s_pageMessage, + "That address does not contain a code=... part. Paste the full address Spotify redirected you to."); + } else { + if (!queryParam(url.c_str(), "state", state, sizeof state)) state[0] = 0; + bool ok = completeLinking(code, state, message, sizeof message); + copyStr(s_pageMessage, sizeof s_pageMessage, message); + s_pageMessageIsError = !ok; + } + sendLinkPage(); +} + +// GET /callback?code=...&state=...: Spotify's redirect, handed over by the helper +// (or by any forward of 127.0.0.1:8888 to this port). The helper shows our +// answer in the browser tab. +void handleCallback() { + char message[160]; + bool ok = false; + if (s_web->hasArg("error")) { + snprintf(message, sizeof message, "Spotify reported: %s. Please start again.", s_web->arg("error").c_str()); + } else if (!s_web->hasArg("code")) { + copyStr(message, sizeof message, "This address has no code=... part, so there is nothing to link."); + } else { + String code = s_web->arg("code"), state = s_web->arg("state"); + ok = completeLinking(code.c_str(), state.c_str(), message, sizeof message); + } + String b = ok ? "

Linked

" : "

Not linked

"; + b += htmlEscape(message); + b += "

"; + if (!ok) b += "

Back to the clock's setup page

"; + sendPage(ok ? 200 : 400, b); +} + +// GET /go: straight to Spotify's approval page (used by the helper). +void handleGo() { + s_web->sendHeader("Location", authorizeUrl(), true); + s_web->send(302, "text/plain", ""); +} + +// GET /spotify_link.py: the helper, so it is one click away from the setup page. +void handleHelperScript() { + s_web->sendHeader("Content-Disposition", "attachment; filename=\"spotify_link.py\""); + s_web->send(200, "text/x-python; charset=utf-8", String(kSpotifyHelperScript)); +} + +void serveLinkPage(bool wanted, bool wifiUp) { + if (wanted && wifiUp) { + if (!s_web) { + if (!s_verifier[0]) newPkce(); + s_web = new WebServer(80); + s_web->on("/", HTTP_GET, sendLinkPage); + s_web->on("/link", HTTP_POST, handleLink); + s_web->on("/callback", HTTP_GET, handleCallback); + s_web->on("/go", HTTP_GET, handleGo); + s_web->on("/spotify_link.py", HTTP_GET, handleHelperScript); + s_web->onNotFound([]() { + s_web->sendHeader("Location", "/"); + s_web->send(302, "text/plain", ""); + }); + s_web->begin(); + if (MDNS.begin(g_cfg.hostname)) MDNS.addService("http", "tcp", 80); + LOGF(TAG, "link page at http://%s/ (or http://%s.local/)", WiFi.localIP().toString().c_str(), g_cfg.hostname); + } + s_web->handleClient(); + } else if (s_web) { + s_web->stop(); + delete s_web; + s_web = nullptr; + MDNS.end(); + } +} + +// Keeps linkUrl / linkDaysLeft in the published state current. +void refreshLinkInfo(bool serving, bool wifiUp) { + int16_t days = -1; + time_t now = nowEpoch(); + if (s_linked && s_authEpoch && now) { + int64_t left = (int64_t)s_authEpoch + kRefreshTokenLifetimeS - (int64_t)now; + days = left <= 0 ? 0 : (int16_t)(left / 86400); + } + char url[sizeof s_info.linkUrl] = ""; + if (serving && wifiUp) snprintf(url, sizeof url, "http://%s", WiFi.localIP().toString().c_str()); + if (days != s_info.linkDaysLeft || strcmp(url, s_info.linkUrl) != 0) { + s_info.linkDaysLeft = days; + copyStr(s_info.linkUrl, sizeof s_info.linkUrl, url); + publish(); + } +} + +} // namespace + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- +bool spotifyConfigured() { return g_cfg.wifi && g_cfg.spotify && g_cfg.spotifyClientId[0] != 0; } + +bool spotifyLowLatencyWanted() { return s_web != nullptr; } + +void spotifyBegin() { + if (!s_queue) s_queue = xQueueCreate(6, sizeof(uint8_t)); + if (!spotifyConfigured()) { + s_info.status = SPOTIFY_DISABLED; + publish(); + return; + } + loadPrefs(); + s_info.status = s_linked ? SPOTIFY_IDLE : SPOTIFY_NEEDS_LINK; + publish(); + LOGF(TAG, "%s", s_linked ? "account linked" : "no account linked yet"); +} + +bool spotifyPost(SpotifyCommand cmd) { + if (!spotifyConfigured() || !s_queue) return false; + uint8_t c = (uint8_t)cmd; + return xQueueSend(s_queue, &c, 0) == pdTRUE; +} + +void spotifyTick() { + if (!spotifyConfigured()) return; + const bool wifiUp = WiFi.status() == WL_CONNECTED; + // The linking page (or the renewal hint) is served all the time, except in wifi_mode = sync, where it + // needs the radio on and is served only while the Now Playing page is open. + const bool pageOpen = !g_cfg.syncMode() || radioplan::demandActive(millis(), s_pageHoldUntilMs); + const bool serving = (!s_linked || (s_info.linkDaysLeft >= 0 && s_info.linkDaysLeft <= kRenewHintDays)) && pageOpen; + + refreshLinkInfo(serving && wifiUp, wifiUp); + serveLinkPage(serving, wifiUp); + + uint8_t c; + while (s_queue && xQueueReceive(s_queue, &c, 0) == pdTRUE) { + if (wifiUp) { + handleCommand((SpotifyCommand)c); + } else { + stateNotice("Spotify: no network", TOAST_WARN); + } + } + if (!s_linked || !wifiUp || !timeTrusted()) return; + + if (uint32_t remaining = rateLimitRemaining()) { + if ((uint32_t)(millis() - s_lastBlockMsgMs) > 30000UL || s_info.status != SPOTIFY_ERROR) { + char rem[16], msg[56]; + formatRemaining(remaining, rem, sizeof rem); + snprintf(msg, sizeof msg, "Rate limited, retry in %s", rem); + setStatus(SPOTIFY_ERROR, msg); + s_lastBlockMsgMs = millis(); + } + return; + } + radioplan::keepDue(millis(), &s_nextPollMs); // a poll that has been due for weeks (no network) stays due + if ((int32_t)(millis() - s_nextPollMs) >= 0) pollPlayer(); +} + +// ---- wifi_mode = sync: the radio comes and goes (net_task.cpp, radio_plan.h) ---------------------------- +void spotifySetPageShown(bool shown) { + if (shown) s_pageHoldUntilMs = (millis() + kPageHoldMs) | 1u; // a minute from now (the next call, while it is shown, moves it on) +} + +bool spotifyWantsRadio() { + if (!spotifyConfigured()) return false; + const uint32_t now = millis(); + // a time long past must not come round again when millis() wraps (the UI task sets a fresh one every + // time round its loop while the page is shown, so nothing is lost if it does so right now) + if (s_pageHoldUntilMs != 0 && !radioplan::pending(now, s_pageHoldUntilMs)) s_pageHoldUntilMs = 0; + radioplan::SpotifyNeeds n; + n.commandWaiting = s_queue && uxQueueMessagesWaiting(s_queue) > 0; + n.playing = s_info.status == SPOTIFY_PLAYING; + n.pausedRecently = s_info.status == SPOTIFY_PAUSED && (uint32_t)(now - s_lastActiveMs) < kPausedHoldMs; + n.pageOpen = radioplan::pending(now, s_pageHoldUntilMs); + n.live = followLive(); + return radioplan::spotifyHolds(n); +} + +void spotifyWindowBegin() { + s_peekDone = false; + s_nextPollMs = millis(); // look at the player as soon as the radio is up (now, not "0": that is a time too) +} + +bool spotifyPeekPending() { + return spotifyConfigured() && s_linked && !s_peekDone && timeTrusted() && rateLimitRemaining() == 0; +} + +// spotify_live = off: the track on screen should be over by now, so it is time for another look. +bool spotifyLookDue() { + if (!spotifyConfigured() || !s_linked || followLive()) return false; + if (s_info.status != SPOTIFY_PLAYING || s_trackLookAtMs == 0) return false; + return radioplan::due(millis(), s_trackLookAtMs) && timeTrusted() && rateLimitRemaining() == 0; +} + +// The radio is about to be switched off: let go of the connections. `keepTrack`: the session ended as it +// should, so what it saw last stays on the screen until the next look (a paused track; with spotify_live = +// off also a playing one, its progress counted on by the clock). Otherwise the network is gone, and a track +// that nobody can follow any more is taken off. +void spotifyRadioDown(bool keepTrack) { + s_apiHttp.end(); + s_apiClient.stop(); + if (s_web) { + s_web->stop(); + delete s_web; + s_web = nullptr; + MDNS.end(); + } + if (!keepTrack && (s_info.status == SPOTIFY_PLAYING || s_info.status == SPOTIFY_PAUSED)) { + s_info.title[0] = s_info.artist[0] = s_info.album[0] = s_info.device[0] = 0; + s_info.durationMs = s_info.progressMs = 0; + s_info.status = SPOTIFY_IDLE; + } + s_info.linkUrl[0] = 0; + publish(); +} + +void spotifyFlushQueue(const char *why) { + if (!s_queue) return; + uint8_t c; + bool any = false; + while (xQueueReceive(s_queue, &c, 0) == pdTRUE) any = true; + if (any) stateNotice(why, TOAST_WARN); +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/spotify.h b/02_Example/Arduino/11_Clock_Dashboard/spotify.h new file mode 100644 index 00000000..b1a41f6a --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/spotify.h @@ -0,0 +1,57 @@ +#pragma once + +// Spotify now-playing and remote control, via the Spotify Web API. +// +// Everything here runs in the network task except spotifyPost(), which any +// task may call. The linking flow (OAuth with PKCE, no client secret) is a small +// web page served by the device while no account is linked. + +#include + +enum SpotifyCommand : uint8_t { + SPOTIFY_CMD_PLAY_PAUSE = 1, + SPOTIFY_CMD_NEXT, + SPOTIFY_CMD_PREVIOUS, + SPOTIFY_CMD_REFRESH, // poll again right now + SPOTIFY_CMD_UNLINK // forget the linked account +}; + +// True when there is a Client ID (secrets.h or the SD card settings), Spotify is switched on +// ("spotify = on") and WiFi is not switched off. +bool spotifyConfigured(); + +// True while the linking page is being served: the network task keeps WiFi out of its +// power-saving sleep then, so the page answers promptly. +bool spotifyLowLatencyWanted(); + +// Loads saved credentials and publishes the initial status. Call once from +// the network task before the first spotifyTick(). +void spotifyBegin(); + +// Services polling, queued commands and the linking page. Call every ~40 ms +// from the network task; it only does real work when something is due. +void spotifyTick(); + +// Queue a command (thread safe, never blocks). False if Spotify is not +// configured or the queue is full. +bool spotifyPost(SpotifyCommand cmd); + +// --- wifi_mode = sync: the radio is off between sessions (net_task.cpp, radio_plan.h) ------------------- +// The UI task says every time round its loop whether the Now Playing page is on screen: it needs the radio +// (and the linking page) while it is, and for a minute after. +void spotifySetPageShown(bool shown); +// Network task: does Spotify need the radio right now? A command waits, the Now Playing page is open, or +// (with spotify_live = on) music is playing or was a few minutes ago. +bool spotifyWantsRadio(); +// A radio session looks at the player once, even when nothing needs it for longer: begin the session with +// spotifyWindowBegin(), and spotifyPeekPending() says whether that look is still to come. +void spotifyWindowBegin(); +bool spotifyPeekPending(); +// spotify_live = off: the radio does not stay on for the music; instead a look is due when the track on screen +// should be over. True from then until that look has been taken. +bool spotifyLookDue(); +// Let go of the connections. Call before the radio goes off. `keepTrack` false (the network is gone) also +// takes a track nobody can follow any more off the screen. +void spotifyRadioDown(bool keepTrack); +// Drop the commands waiting for a radio that could not be joined, with a notice (`why`). +void spotifyFlushQueue(const char *why); diff --git a/02_Example/Arduino/11_Clock_Dashboard/spotify_helper_script.h b/02_Example/Arduino/11_Clock_Dashboard/spotify_helper_script.h new file mode 100644 index 00000000..fe476b35 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/spotify_helper_script.h @@ -0,0 +1,105 @@ +// GENERATED FILE - do not edit. Regenerate with tools/gen_helper_header.py +// Source: tools/spotify_link.py, served by the clock at /spotify_link.py +#pragma once + +static const char kSpotifyHelperScript[] = R"PYSCRIPT(#!/usr/bin/env python3 +"""Link the RLCD clock to your Spotify account. + +Spotify only accepts a plain-http redirect to 127.0.0.1 (this computer), never +to the clock's own address. This helper listens on 127.0.0.1:8888 just long +enough to catch that redirect and hand it to the clock. + + python spotify_link.py [clock-address] + +clock-address is the clock's IP address (shown on its screen and on its web +page) or its name; the default is rlcd-clock.local. Needs Python 3.7 or newer +and nothing else. The clock also serves this file at http:///spotify_link.py +""" +import argparse +import sys +import threading +import urllib.error +import urllib.request +import webbrowser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def main(): + ap = argparse.ArgumentParser(description="Link the RLCD clock to Spotify.") + ap.add_argument("clock", nargs="?", default="rlcd-clock.local", help="clock IP address or name") + ap.add_argument("--port", type=int, default=8888, help="must match the Redirect URI in your Spotify app") + ap.add_argument("--no-browser", action="store_true", help="print the link instead of opening it") + ap.add_argument("--timeout", type=int, default=300, help="seconds to wait for you to approve") + args = ap.parse_args() + + clock = args.clock.replace("http://", "").strip("/") + base = "http://" + clock + + # Is the clock there, and is it offering the link page? + try: + urllib.request.urlopen(base + "/", timeout=8).close() + except Exception as exc: # noqa: BLE001 - any failure means "can't use it" + print("Cannot reach the clock at %s/\n (%s)" % (base, exc)) + print("Give its IP address, e.g.: python spotify_link.py 192.168.1.50") + print("The clock only offers this page while no account is linked (and in the last 14 days") + print("before a link expires).") + return 1 + + done = threading.Event() + outcome = {} + + class Catch(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 - http.server API + if not self.path.startswith("/callback"): + self.send_error(404) + return + try: # pass Spotify's redirect (code and state) on to the clock, show its answer + with urllib.request.urlopen(base + self.path, timeout=40) as r: + status, body = r.status, r.read() + ctype = r.headers.get("Content-Type", "text/html; charset=utf-8") + except urllib.error.HTTPError as exc: + status, body = exc.code, exc.read() + ctype = exc.headers.get("Content-Type", "text/html; charset=utf-8") + except Exception as exc: # noqa: BLE001 + status = 502 + body = ("Could not reach the clock at %s: %s" % (base, exc)).encode() + ctype = "text/plain; charset=utf-8" + self.send_response(status) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + outcome["status"] = status + done.set() + + def log_message(self, *args): # keep the console quiet + pass + + try: + server = ThreadingHTTPServer(("127.0.0.1", args.port), Catch) + except OSError as exc: + print("Cannot listen on 127.0.0.1:%d (%s).\nClose whatever is using that port and try again." % (args.port, exc)) + return 1 + threading.Thread(target=server.serve_forever, daemon=True).start() + + url = base + "/go" # the clock redirects this to Spotify's approval page + print("Opening Spotify's approval page. Press Agree there.") + print("If no browser opens, go to: " + url) + if not args.no_browser: + webbrowser.open(url) + + finished = done.wait(args.timeout) + server.shutdown() + if not finished: + print("Timed out after %d seconds without hearing from Spotify." % args.timeout) + return 1 + if outcome.get("status") == 200: + print("Done: the clock is linked to Spotify.") + return 0 + print("The clock answered with status %s; see the browser tab for why." % outcome.get("status")) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) +)PYSCRIPT"; diff --git a/02_Example/Arduino/11_Clock_Dashboard/spotify_parse.cpp b/02_Example/Arduino/11_Clock_Dashboard/spotify_parse.cpp new file mode 100644 index 00000000..aa51bdf0 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/spotify_parse.cpp @@ -0,0 +1,120 @@ +#include "spotify_parse.h" + +#include +#include + +#include "util.h" + +bool parsePlayerState(const char *json, size_t len, SpotifyInfo *out, char *deviceId, + size_t deviceIdCap) { + // The reply can run to several KB; keep only the fields we show. + JsonDocument filter; + filter["is_playing"] = true; + filter["progress_ms"] = true; + filter["shuffle_state"] = true; + filter["currently_playing_type"] = true; + filter["device"]["id"] = true; + filter["device"]["name"] = true; + filter["device"]["volume_percent"] = true; + filter["item"]["name"] = true; + filter["item"]["duration_ms"] = true; + filter["item"]["artists"][0]["name"] = true; + filter["item"]["album"]["name"] = true; + filter["item"]["show"]["name"] = true; + + JsonDocument doc; + if (deserializeJson(doc, json, len, DeserializationOption::Filter(filter))) return false; + + SpotifyInfo s = *out; // keep fields owned by the caller (message, linkUrl, ...) + s.status = (doc["is_playing"] | false) ? SPOTIFY_PLAYING : SPOTIFY_PAUSED; + s.progressMs = doc["progress_ms"] | 0u; + s.shuffle = doc["shuffle_state"] | false; + s.title[0] = s.artist[0] = s.album[0] = 0; + s.durationMs = 0; + + const char *type = doc["currently_playing_type"] | ""; + JsonObjectConst item = doc["item"]; + if (item.isNull()) { + // Ads and some unsupported content have no item. + copyStr(s.title, sizeof s.title, strcmp(type, "ad") == 0 ? "Advertisement" : "Unknown"); + } else { + copyStr(s.title, sizeof s.title, item["name"] | ""); + s.durationMs = item["duration_ms"] | 0u; + + JsonArrayConst artists = item["artists"]; + if (!artists.isNull() && artists.size() > 0) { + size_t n = 0; + for (JsonVariantConst a : artists) { + const char *name = a["name"] | ""; + if (!*name) continue; + size_t need = strlen(name) + (n ? 2 : 0); + if (n + need + 1 > sizeof s.artist) break; + if (n) { + s.artist[n++] = ','; + s.artist[n++] = ' '; + } + memcpy(s.artist + n, name, strlen(name)); + n += strlen(name); + s.artist[n] = 0; + } + } else { + // Podcast episode: show the show's name where the artist would be. + copyStr(s.artist, sizeof s.artist, item["show"]["name"] | ""); + } + copyStr(s.album, sizeof s.album, item["album"]["name"] | ""); + } + if (!s.title[0]) copyStr(s.title, sizeof s.title, "Unknown"); + + copyStr(s.device, sizeof s.device, doc["device"]["name"] | ""); + s.volume = doc["device"]["volume_percent"].isNull() ? -1 : (int8_t)(doc["device"]["volume_percent"] | -1); + if (deviceId && deviceIdCap) copyStr(deviceId, deviceIdCap, doc["device"]["id"] | ""); + + *out = s; + return true; +} + +bool parseTokenReply(const char *json, size_t len, SpotifyTokenReply *out) { + JsonDocument doc; + if (deserializeJson(doc, json, len)) return false; + const char *access = doc["access_token"] | ""; + if (!*access || strlen(access) >= sizeof out->accessToken) return false; + SpotifyTokenReply r; + copyStr(r.accessToken, sizeof r.accessToken, access); + const char *refresh = doc["refresh_token"] | ""; + if (strlen(refresh) >= sizeof r.refreshToken) return false; + copyStr(r.refreshToken, sizeof r.refreshToken, refresh); + r.expiresInSec = doc["expires_in"] | 3600; + *out = r; + return true; +} + +void parseSpotifyError(const char *json, size_t len, char *reason, size_t reasonCap, char *message, + size_t messageCap) { + if (reasonCap) reason[0] = 0; + if (messageCap) message[0] = 0; + JsonDocument doc; + if (!json || !len || deserializeJson(doc, json, len)) return; + JsonVariantConst err = doc["error"]; + if (err.is()) { // accounts service + copyStr(reason, reasonCap, err.as()); + copyStr(message, messageCap, doc["error_description"] | ""); + } else if (err.is()) { // Web API + copyStr(reason, reasonCap, err["reason"] | ""); + copyStr(message, messageCap, err["message"] | ""); + } +} + +size_t buildAuthorizeUrl(const char *clientId, const char *redirectUri, const char *scopes, + const char *codeChallenge, const char *state, char *out, size_t cap) { + char id[96], redirect[200], scope[240], challenge[96], st[96]; + if (!urlEncode(clientId, id, sizeof id) || !urlEncode(redirectUri, redirect, sizeof redirect) || + !urlEncode(scopes, scope, sizeof scope) || !urlEncode(codeChallenge, challenge, sizeof challenge) || + !urlEncode(state, st, sizeof st)) { + return 0; + } + int n = snprintf(out, cap, + "https://accounts.spotify.com/authorize?client_id=%s&response_type=code&redirect_uri=%s" + "&scope=%s&code_challenge_method=S256&code_challenge=%s&state=%s", + id, redirect, scope, challenge, st); + return (n > 0 && (size_t)n < cap) ? (size_t)n : 0; +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/spotify_parse.h b/02_Example/Arduino/11_Clock_Dashboard/spotify_parse.h new file mode 100644 index 00000000..7b13ab91 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/spotify_parse.h @@ -0,0 +1,36 @@ +#pragma once + +// JSON parsing for the Spotify Web API and accounts service. Pure functions +// (ArduinoJson only), covered by the host-side tests in tools/tests. + +#include + +#include "app_model.h" + +// Parses a 200 reply of GET /v1/me/player into `out` (status becomes PLAYING or +// PAUSED). Copies the active device id (used to resume after the device went +// idle) into deviceId. Returns false if the JSON is malformed. +bool parsePlayerState(const char *json, size_t len, SpotifyInfo *out, char *deviceId, + size_t deviceIdCap); + +struct SpotifyTokenReply { + char accessToken[512] = ""; + char refreshToken[256] = ""; // empty when the server did not rotate it + int expiresInSec = 0; +}; + +// Parses a successful reply of POST accounts.spotify.com/api/token. +bool parseTokenReply(const char *json, size_t len, SpotifyTokenReply *out); + +// Extracts a short machine-readable reason and a human message from an error +// body. Handles both shapes Spotify uses: +// {"error":"invalid_grant","error_description":"Refresh token revoked"} +// {"error":{"status":404,"message":"Player command failed: No active device found", +// "reason":"NO_ACTIVE_DEVICE"}} +void parseSpotifyError(const char *json, size_t len, char *reason, size_t reasonCap, char *message, + size_t messageCap); + +// Builds the accounts.spotify.com/authorize URL for the PKCE flow (all values are +// percent-encoded). Returns the length, or 0 if `cap` is too small. +size_t buildAuthorizeUrl(const char *clientId, const char *redirectUri, const char *scopes, + const char *codeChallenge, const char *state, char *out, size_t cap); diff --git a/02_Example/Arduino/11_Clock_Dashboard/timeutil.h b/02_Example/Arduino/11_Clock_Dashboard/timeutil.h new file mode 100644 index 00000000..e5f1c1b9 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/timeutil.h @@ -0,0 +1,82 @@ +#pragma once + +// Small calendar helpers. Header-only and free of Arduino dependencies so the +// host-side tests (tools/tests) can exercise them. + +#include +#include +#include + +namespace timeutil { + +// Offset of the current time zone (set through TZ / tzset()) from UTC, in +// minutes, at instant t. Includes any daylight saving in effect. +inline int utcOffsetMinutes(time_t t) { + struct tm lt, gt; + localtime_r(&t, <); + gmtime_r(&t, >); + int dayDiff = lt.tm_yday - gt.tm_yday; // -1, 0 or +1 (or a year wrap) + if (dayDiff > 1) dayDiff = -1; + if (dayDiff < -1) dayDiff = 1; + return dayDiff * 1440 + (lt.tm_hour - gt.tm_hour) * 60 + (lt.tm_min - gt.tm_min); +} + +inline bool isLeap(int y) { return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; } + +// 0 = Sunday ... 6 = Saturday (Sakamoto's method). month is 1..12. +inline int weekday(int year, int month, int day) { + static const int t[] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4}; + if (month < 3) year -= 1; + return (year + year / 4 - year / 100 + year / 400 + t[month - 1] + day) % 7; +} + +inline int weeksInIsoYear(int y) { + auto p = [](int yy) { return (yy + yy / 4 - yy / 100 + yy / 400) % 7; }; + return 52 + ((p(y) == 4 || p(y - 1) == 3) ? 1 : 0); +} + +// ISO 8601 week number. yday0 is tm_yday (0-based), wday0 is tm_wday (0 = Sunday). +inline int isoWeek(int year, int yday0, int wday0) { + int wd = (wday0 == 0) ? 7 : wday0; // Monday = 1 ... Sunday = 7 + int week = (yday0 + 1 - wd + 10) / 7; + if (week < 1) return weeksInIsoYear(year - 1); + if (week == 53 && weeksInIsoYear(year) == 52) return 1; + return week; +} + +// "+08:00" / "-03:30" / "+00:00" +inline void formatUtcOffset(int minutes, char *out, size_t cap) { + char sign = minutes < 0 ? '-' : '+'; + int a = minutes < 0 ? -minutes : minutes; + snprintf(out, cap, "%c%02d:%02d", sign, (a / 60) % 100, a % 60); +} + +inline const char *weekdayName(int wday0) { + static const char *const n[] = {"Sunday", "Monday", "Tuesday", "Wednesday", + "Thursday", "Friday", "Saturday"}; + return n[((wday0 % 7) + 7) % 7]; +} + +inline const char *weekdayShort(int wday0) { + static const char *const n[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; + return n[((wday0 % 7) + 7) % 7]; +} + +inline const char *monthShort(int mon0) { + static const char *const n[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; + return n[((mon0 % 12) + 12) % 12]; +} + +// "m:ss" or "h:mm:ss" +inline void formatDuration(uint32_t ms, char *out, size_t cap) { + uint32_t s = ms / 1000; + uint32_t h = s / 3600, m = (s / 60) % 60, sec = s % 60; + if (h > 0) { + snprintf(out, cap, "%u:%02u:%02u", (unsigned)h, (unsigned)m, (unsigned)sec); + } else { + snprintf(out, cap, "%u:%02u", (unsigned)m, (unsigned)sec); + } +} + +} // namespace timeutil diff --git a/02_Example/Arduino/11_Clock_Dashboard/tls.h b/02_Example/Arduino/11_Clock_Dashboard/tls.h new file mode 100644 index 00000000..f801d76e --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tls.h @@ -0,0 +1,16 @@ +#pragma once + +// TLS helper: verify servers against the Mozilla root CA bundle that ships +// inside the ESP32 Arduino core (the same list a browser trusts), so there is no +// need for setInsecure() or for pinning certificates that expire every month. +// The system clock must be roughly right for certificate validity checks. + +#include + +extern const uint8_t rootca_crt_bundle_start[] asm("_binary_x509_crt_bundle_start"); +extern const uint8_t rootca_crt_bundle_end[] asm("_binary_x509_crt_bundle_end"); + +inline void useCaBundle(WiFiClientSecure &client) { + client.setCACertBundle(rootca_crt_bundle_start, rootca_crt_bundle_end - rootca_crt_bundle_start); + client.setHandshakeTimeout(10); // seconds +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/gen_helper_header.py b/02_Example/Arduino/11_Clock_Dashboard/tools/gen_helper_header.py new file mode 100644 index 00000000..6aeae12f --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/gen_helper_header.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Regenerate spotify_helper_script.h (the copy of tools/spotify_link.py that the +clock serves at /spotify_link.py), or verify it is current: + + python gen_helper_header.py write ../spotify_helper_script.h + python gen_helper_header.py --check exit 1 if the header is out of date +""" +import pathlib +import sys + +DELIM = ')PYSCRIPT"' + + +def build(src: str) -> str: + if DELIM in src: + raise SystemExit("spotify_link.py contains the raw-string terminator %s" % DELIM) + return ( + "// GENERATED FILE - do not edit. Regenerate with tools/gen_helper_header.py\n" + "// Source: tools/spotify_link.py, served by the clock at /spotify_link.py\n" + "#pragma once\n\n" + 'static const char kSpotifyHelperScript[] = R"PYSCRIPT(' + src + ')PYSCRIPT";\n' + ) + + +def main() -> int: + here = pathlib.Path(__file__).resolve().parent + src = (here / "spotify_link.py").read_text(encoding="utf-8").replace("\r\n", "\n") + want = build(src) + target = here.parent / "spotify_helper_script.h" + if "--check" in sys.argv: + have = target.read_text(encoding="utf-8").replace("\r\n", "\n") if target.exists() else "" + if have != want: + print("spotify_helper_script.h is out of date: run tools/gen_helper_header.py") + return 1 + print("spotify_helper_script.h is current") + return 0 + target.write_text(want, encoding="utf-8", newline="\n") + print("wrote", target) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/gen_tz_table.py b/02_Example/Arduino/11_Clock_Dashboard/tools/gen_tz_table.py new file mode 100644 index 00000000..da160100 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/gen_tz_table.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Generate tz_table.cpp: an IANA zone name -> POSIX TZ string lookup table. + +The firmware asks Open-Meteo for the IANA zone name of your location (for +example "Australia/Perth"), then needs a POSIX TZ string ("AWST-8") to hand to +tzset() so DST changes are handled by the C library with no further network +calls. The POSIX string is the footer of each compiled TZif file in the tz +database, so this script just reads those footers. + +Usage: + python3 gen_tz_table.py [zoneinfo_dir] > ../tz_table.cpp + +zoneinfo_dir defaults to /usr/share/zoneinfo (Linux, macOS, WSL). On Windows +you can `pip install tzdata` and point at /tzdata/zoneinfo. +""" +import os +import sys + +SKIP_NAMES = { + "Factory", "localtime", "posixrules", "leapseconds", "SECURITY", +} + + +def tzif_footer(path): + with open(path, "rb") as f: + data = f.read() + if not data.startswith(b"TZif") or not data.endswith(b"\n"): + return None + body = data[:-1] + return body[body.rfind(b"\n") + 1:].decode("ascii") + + +def main(): + root = sys.argv[1] if len(sys.argv) > 1 else "/usr/share/zoneinfo" + version = "" + ver_file = os.path.join(root, "+VERSION") + if os.path.exists(ver_file): + version = open(ver_file).read().strip() + + zones = {} + for dirpath, _dirs, files in os.walk(root): + rel = os.path.relpath(dirpath, root).replace("\\", "/") + if rel.split("/")[0] in ("posix", "right"): + continue + for fn in files: + name = fn if rel == "." else rel + "/" + fn + if "." in fn or name in SKIP_NAMES: + continue + footer = tzif_footer(os.path.join(dirpath, fn)) + if footer: + zones[name] = footer + + out = [] + out.append("// GENERATED FILE - do not edit. Regenerate with tools/gen_tz_table.py") + out.append("// Source: IANA tz database%s, footer line of each compiled TZif file." % (" " + version if version else "")) + out.append('#include "tz_table.h"') + out.append("") + out.append("#include ") + out.append("") + out.append("namespace {") + out.append("struct TzEntry {") + out.append(" const char *name;") + out.append(" const char *posix;") + out.append("};") + out.append("") + out.append("// Sorted by name (strcmp order) for binary search.") + out.append("const TzEntry kZones[] = {") + for name in sorted(zones): + out.append(' {"%s", "%s"},' % (name, zones[name])) + out.append("};") + out.append("} // namespace") + out.append("") + out.append("const char *tzPosixForIana(const char *iana) {") + out.append(" if (!iana || !*iana) return nullptr;") + out.append(" size_t lo = 0, hi = sizeof(kZones) / sizeof(kZones[0]);") + out.append(" while (lo < hi) {") + out.append(" size_t mid = (lo + hi) / 2;") + out.append(" int c = strcmp(iana, kZones[mid].name);") + out.append(" if (c == 0) return kZones[mid].posix;") + out.append(" if (c < 0) hi = mid; else lo = mid + 1;") + out.append(" }") + out.append(" return nullptr;") + out.append("}") + out.append("") + out.append('const char *tzFindIanaIgnoreCase(const char *name, char *canonOut, size_t cap) {') + out.append(' if (!name || !*name) return nullptr;') + out.append(' for (size_t i = 0; i < sizeof(kZones) / sizeof(kZones[0]); i++) {') + out.append(' const char *a = name, *b = kZones[i].name;') + out.append(' while (*a && *b) {') + out.append(' char ca = *a, cb = *b;') + out.append(" if (ca >= 'A' && ca <= 'Z') ca = (char)(ca + 32);") + out.append(" if (cb >= 'A' && cb <= 'Z') cb = (char)(cb + 32);") + out.append(" if (ca == ' ') ca = '_';") + out.append(' if (ca != cb) break;') + out.append(' ++a;') + out.append(' ++b;') + out.append(' }') + out.append(' if (!*a && !*b) {') + out.append(' if (canonOut && cap) {') + out.append(' strncpy(canonOut, kZones[i].name, cap - 1);') + out.append(' canonOut[cap - 1] = 0;') + out.append(' }') + out.append(' return kZones[i].posix;') + out.append(' }') + out.append(' }') + out.append(' return nullptr;') + out.append('}') + sys.stdout.write("\n".join(out) + "\n") + sys.stderr.write("wrote %d zones\n" % len(zones)) + + +if __name__ == "__main__": + main() diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/spotify_link.py b/02_Example/Arduino/11_Clock_Dashboard/tools/spotify_link.py new file mode 100644 index 00000000..ab2be9b0 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/spotify_link.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Link the RLCD clock to your Spotify account. + +Spotify only accepts a plain-http redirect to 127.0.0.1 (this computer), never +to the clock's own address. This helper listens on 127.0.0.1:8888 just long +enough to catch that redirect and hand it to the clock. + + python spotify_link.py [clock-address] + +clock-address is the clock's IP address (shown on its screen and on its web +page) or its name; the default is rlcd-clock.local. Needs Python 3.7 or newer +and nothing else. The clock also serves this file at http:///spotify_link.py +""" +import argparse +import sys +import threading +import urllib.error +import urllib.request +import webbrowser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def main(): + ap = argparse.ArgumentParser(description="Link the RLCD clock to Spotify.") + ap.add_argument("clock", nargs="?", default="rlcd-clock.local", help="clock IP address or name") + ap.add_argument("--port", type=int, default=8888, help="must match the Redirect URI in your Spotify app") + ap.add_argument("--no-browser", action="store_true", help="print the link instead of opening it") + ap.add_argument("--timeout", type=int, default=300, help="seconds to wait for you to approve") + args = ap.parse_args() + + clock = args.clock.replace("http://", "").strip("/") + base = "http://" + clock + + # Is the clock there, and is it offering the link page? + try: + urllib.request.urlopen(base + "/", timeout=8).close() + except Exception as exc: # noqa: BLE001 - any failure means "can't use it" + print("Cannot reach the clock at %s/\n (%s)" % (base, exc)) + print("Give its IP address, e.g.: python spotify_link.py 192.168.1.50") + print("The clock only offers this page while no account is linked (and in the last 14 days") + print("before a link expires).") + return 1 + + done = threading.Event() + outcome = {} + + class Catch(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 - http.server API + if not self.path.startswith("/callback"): + self.send_error(404) + return + try: # pass Spotify's redirect (code and state) on to the clock, show its answer + with urllib.request.urlopen(base + self.path, timeout=40) as r: + status, body = r.status, r.read() + ctype = r.headers.get("Content-Type", "text/html; charset=utf-8") + except urllib.error.HTTPError as exc: + status, body = exc.code, exc.read() + ctype = exc.headers.get("Content-Type", "text/html; charset=utf-8") + except Exception as exc: # noqa: BLE001 + status = 502 + body = ("Could not reach the clock at %s: %s" % (base, exc)).encode() + ctype = "text/plain; charset=utf-8" + self.send_response(status) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + outcome["status"] = status + done.set() + + def log_message(self, *args): # keep the console quiet + pass + + try: + server = ThreadingHTTPServer(("127.0.0.1", args.port), Catch) + except OSError as exc: + print("Cannot listen on 127.0.0.1:%d (%s).\nClose whatever is using that port and try again." % (args.port, exc)) + return 1 + threading.Thread(target=server.serve_forever, daemon=True).start() + + url = base + "/go" # the clock redirects this to Spotify's approval page + print("Opening Spotify's approval page. Press Agree there.") + print("If no browser opens, go to: " + url) + if not args.no_browser: + webbrowser.open(url) + + finished = done.wait(args.timeout) + server.shutdown() + if not finished: + print("Timed out after %d seconds without hearing from Spotify." % args.timeout) + return 1 + if outcome.get("status") == 200: + print("Done: the clock is linked to Spotify.") + return 0 + print("The clock answered with status %s; see the browser tab for why." % outcome.get("status")) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/all_settings.h b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/all_settings.h new file mode 100644 index 00000000..c033216c --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/all_settings.h @@ -0,0 +1,39 @@ +#pragma once + +// A settings file that sets every setting to something other than its default. test_features.cpp +// uses it for the file parser and the flash round trip; test_builddefaults.cpp checks that a build +// whose secrets.h says the same things (override_all.h) ends up with the very same settings. + +[[maybe_unused]] static const char *const kAllSettings = + "wifi = off\n" + "wifi_ssid = Cafe Net\n" + "wifi_password = p@ss w0rd\n" + "wifi_backup_ssid = Phone Hotspot\n" + "wifi_backup_password = h0tsp0t pw\n" + "hostname = my-clock\n" + "ntp_server = time.nist.gov\n" + "wifi_power_save = max\n" + "wifi_mode = sync\n" + "units = imperial\n" + "time_format = 12h\n" + "date_format = mdy\n" + "show_week = off\n" + "latitude = 40.7128 N\n" + "longitude = 74.0060 W\n" + "location_label = New York\n" + "location = New York\n" + "timezone = America/New_York\n" + "spotify = off\n" + "spotify_client_id = 0123456789abcdef0123456789abcdef\n" + "spotify_live = off\n" + "indoor_offset = -2.5\n" + "battery = none\n" + "battery_capacity_mah = 2500\n" + "battery_calibration = 1.0157\n" + "low_battery_shutdown = off\n" + "battery_cutoff_v = 3.45\n" + "cpu_mhz = 160\n" + "cpu_idle_mhz = 40\n" + "console = auto\n" + "power_log = on\n" + "weather_interval_min = 30\n"; diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/battery_sim.cpp b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/battery_sim.cpp new file mode 100644 index 00000000..25a5b4ab --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/battery_sim.cpp @@ -0,0 +1,276 @@ +// Simulates whole discharges to choose the constants of battery_est.h (the averaging window). +// +// ./battery_sim prints a table: window settings against several kinds of drain +// ./battery_sim trace [n] prints one discharge (scenario n) estimate by estimate against the truth +// +// The cell model: open-circuit voltage from a *smooth* curve through the clock's discharge table +// (the estimator only has the table, so the two differ a little, as a real cell and the table +// do), minus current x internal resistance, minus an optional "just came off the charger" +// settling offset that decays with a ten minute time constant; the ADC adds noise and an +// occasional WiFi transmit dip. The truth is the time until the *loaded* voltage at the +// present current reaches the cut-off, which is what the estimate promises (it cannot know +// about future load changes). +// +// This is a model, not a measurement: it picks sensible constants and shows how the estimate +// behaves; the real cell will differ. Build: g++ -std=c++17 -O2 -I../.. battery_sim.cpp -o battery_sim +// +// What the sweep showed (median / 90th percentile of |estimate - truth| / truth, estimates from +// one hour after the start, six noise seeds), the chosen setting being "2h..8h 20 %": the window +// is the span in which the cell loses about 20 %, but never under 2 h or over 8 h. +// +// fixed 30 min fixed 2 h fixed 8 h 2h..8h 20 % (chosen) +// 35 mA, 2500 mAh from 95 % 17% / 57% 12% / 44% 7% / 27% 7% / 27% +// 35 mA, 1000 mAh from 80 % 16% / 89% 12% / 57% 7% / 14% 7% / 21% +// 35 mA, 300 mAh from 95 % 11% / 57% 5% / 14% 9% / 16% 5% / 14% +// 90 mA, 1000 mAh from 90 % 25% / 83% 16% / 37% 25% / 57% 16% / 37% +// 8 mA, 2500 mAh (sleeping) 33% / 100% 9% / 20% 6% / 11% 6% / 11% +// +// Short windows chase the noise of the nearly flat middle of the discharge curve (a few mV there +// are several percent) and a window of a fixed length is wrong for either a fast or a slow drain; +// letting the window follow the drain does well for both. With a load that changes every couple +// of hours every window does badly (the truth moves under it), which cannot be helped. +// +// That table is from the generic discharge curve of 1.0 to 1.9. With the curve measured on the owner's cell (1.10), +// which is far less flat in the middle, every window does better and the chosen one stays among the best: in the +// steady drains a median of 2 to 8 % and a 90th percentile of 5 to 20 % (run ./battery_sim for the whole table). + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "battery_est.h" +#include "calc.h" + +namespace { + +struct Rng { + uint64_t s; + explicit Rng(uint64_t seed) : s(seed * 2654435761u + 88172645463325252ull) {} + uint32_t next() { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + return (uint32_t)(s >> 11); + } +}; + +double uni(Rng &r) { return ((r.next() & 0xFFFFFF) + 0.5) / 16777216.0; } +double gauss(Rng &r) { return sqrt(-2.0 * log(uni(r))) * cos(6.283185307179586 * uni(r)); } + +// --------------------------------------------------------------------------- +// The cell: a smooth monotone curve through the table's points (Fritsch-Carlson cubic) +// --------------------------------------------------------------------------- +struct SmoothCurve { + std::vector x, y, m; // percent, volts, slopes + SmoothCurve() { + int n; + const calc::BatteryPoint *c = calc::batteryCurve(&n); + for (int i = n - 1; i >= 0; i--) { + x.push_back(c[i].pct); + y.push_back(c[i].v); + } + const size_t k = x.size(); + std::vector d(k - 1), mm(k); + for (size_t i = 0; i + 1 < k; i++) d[i] = (y[i + 1] - y[i]) / (x[i + 1] - x[i]); + mm[0] = d[0]; + mm[k - 1] = d[k - 2]; + for (size_t i = 1; i + 1 < k; i++) mm[i] = (d[i - 1] * d[i] <= 0) ? 0 : 2 * d[i - 1] * d[i] / (d[i - 1] + d[i]); + for (size_t i = 0; i + 1 < k; i++) { // keep it monotone + if (d[i] == 0) { + mm[i] = mm[i + 1] = 0; + continue; + } + const double a = mm[i] / d[i], b = mm[i + 1] / d[i], s = a * a + b * b; + if (s > 9) { + const double t = 3 / sqrt(s); + mm[i] = t * a * d[i]; + mm[i + 1] = t * b * d[i]; + } + } + m = mm; + } + double volts(double pct) const { + if (pct <= x.front()) return y.front(); + if (pct >= x.back()) return y.back(); + size_t i = 0; + while (x[i + 1] < pct) i++; + const double h = x[i + 1] - x[i], t = (pct - x[i]) / h; + const double t2 = t * t, t3 = t2 * t; + return (2 * t3 - 3 * t2 + 1) * y[i] + (t3 - 2 * t2 + t) * h * m[i] + (-2 * t3 + 3 * t2) * y[i + 1] + + (t3 - t2) * h * m[i + 1]; + } + // the charge at which the unloaded voltage is v + double pctFor(double v) const { + double lo = 0, hi = 100; + for (int i = 0; i < 60; i++) { + const double mid = 0.5 * (lo + hi); + (volts(mid) < v ? lo : hi) = mid; + } + return 0.5 * (lo + hi); + } +}; +const SmoothCurve &truthCurve() { + static SmoothCurve c; + return c; +} + +struct Scenario { + const char *name; + double capacityMah; + double startPct; + double rintOhm; + double settleMv; // offset just after leaving the charger + std::function loadMa; // current at time t (seconds) + double maxHours; + double truthMa = 0; // judge against this average current instead of the present one +}; + +const double kCutoffV = 3.30; + +struct Score { + std::vector errors; // relative |estimate - truth| / truth, per checkpoint +}; + +double percentile(std::vector v, double p) { + if (v.empty()) return NAN; + std::sort(v.begin(), v.end()); + return v[(size_t)((v.size() - 1) * p)]; +} + +// Runs one discharge; every estimate (every 5 minutes) after `from` seconds is scored. +void run(const Scenario &sc, const battest::Params ¶ms, uint64_t seed, double from, Score *out, bool trace) { + Rng rng(seed); + battest::Estimator est(params); + const SmoothCurve &cell = truthCurve(); + double soc = sc.startPct; + const double dt = 5.0; + double nextCheck = 0; + for (double t = 0; t < sc.maxHours * 3600.0; t += dt) { + const double i = sc.loadMa(t); + const double cut = cell.pctFor(kCutoffV + i / 1000.0 * sc.rintOhm); // charge at which the loaded cell hits the cut-off + if (soc <= cut + 0.3) break; + soc -= i / sc.capacityMah * dt / 3600.0 * 100.0; + double v = cell.volts(soc) - i / 1000.0 * sc.rintOhm; + v += sc.settleMv / 1000.0 * exp(-t / 600.0); + // the clock's ADC: 16 averaged samples, a few millivolts of noise; a transmit burst drags + // an occasional reading down + v += gauss(rng) * 0.003; + if (uni(rng) < 0.01) v -= 0.04 + 0.08 * uni(rng); + est.add((uint32_t)(1000 + t), (float)v); + if (t >= nextCheck) { + nextCheck = t + 300.0; + const battest::Estimate e = est.estimate((float)kCutoffV, 0); + if (e.state != battest::Estimate::READY || t < from) continue; + const double judgedMa = sc.truthMa > 0 ? sc.truthMa : i; // the load the promise is about + const double judgedCut = cell.pctFor(kCutoffV + judgedMa / 1000.0 * sc.rintOhm); + const double rate = judgedMa / sc.capacityMah * 100.0; // percent per hour + const double truth = (soc - judgedCut) / rate; // hours + if (truth < 0.25) continue; // the last quarter hour is not interesting + if (e.unbounded) { + out->errors.push_back(truth > 24 * 30 ? 0.0 : 1.0); + } else { + out->errors.push_back(fabs(e.hoursLeft - truth) / truth); + } + if (trace) + printf("t %5.1f h soc %5.1f %% V %.3f est %7.2f h (%5.2f %%/h over %3d min) truth %7.2f h (%5.2f %%/h) err %+6.1f %%\n", + t / 3600.0, soc, v, e.hoursLeft, e.pctPerHour, e.windowMin, truth, rate, (e.hoursLeft - truth) / truth * 100.0); + } + } +} + +std::vector scenarios() { + std::vector s; + auto steady = [](double ma) { return [ma](double) { return ma; }; }; + auto stepped = [](double a, double b, double every) { + return [=](double t) { return fmod(t, 2 * every) < every ? a : b; }; + }; + s.push_back({"35 mA, 2500 mAh from 95 %", 2500, 95, 0.25, 0, steady(35), 40}); + s.push_back({"35 mA, 1000 mAh from 80 %", 1000, 80, 0.25, 0, steady(35), 40}); + s.push_back({"35 mA, 1000 mAh from 45 %", 1000, 45, 0.25, 0, steady(35), 40}); + s.push_back({"35 mA, 1000 mAh, R 0.5 ohm", 1000, 85, 0.50, 0, steady(35), 40}); + s.push_back({"35 mA, 300 mAh from 95 %", 300, 95, 0.25, 0, steady(35), 40}); + s.push_back({"35 mA, 100 mAh from 90 %", 100, 90, 0.25, 0, steady(35), 40}); + s.push_back({"90 mA, 1000 mAh from 90 %", 1000, 90, 0.25, 0, steady(90), 40}); + s.push_back({"35 mA, 300 mAh, off charger", 300, 100, 0.25, 40, steady(35), 40}); + s.push_back({"35 mA, 100 mAh, off charger", 100, 100, 0.25, 60, steady(35), 40}); + s.push_back({"8 mA, 2500 mAh (sleeping)", 2500, 95, 0.25, 0, steady(8), 60}); + s.push_back({"35/90 mA every 2 h, 1000 mAh", 1000, 90, 0.25, 0, stepped(35, 90, 7200), 40}); + s.push_back({"35/90 mA every 2 h, vs average", 1000, 90, 0.25, 0, stepped(35, 90, 7200), 40, 62.5}); + s.push_back({"35/90 mA every 40 min, 600 mAh", 600, 90, 0.25, 0, stepped(35, 90, 2400), 40}); + s.push_back({"35/90 mA every 40 min, vs avg", 600, 90, 0.25, 0, stepped(35, 90, 2400), 40, 62.5}); + return s; +} + +} // namespace + +int main(int argc, char **argv) { + const std::vector all = scenarios(); + if (argc > 1 && !strcmp(argv[1], "trace")) { + const int which = argc > 2 ? atoi(argv[2]) : 3; + battest::Params p; + if (argc > 3) p.maxWindowSec = (uint32_t)atoi(argv[3]) * 60; + Score sc; + run(all[which], p, 1, 0, &sc, true); + return 0; + } + + struct Variant { + const char *label; + battest::Params p; + }; + std::vector variants; + auto add = [&](const char *label, uint32_t minW, uint32_t maxW, float drop, uint32_t skip = 900) { + battest::Params p; + p.minWindowSec = minW; + p.maxWindowSec = maxW; + p.targetDropPct = drop; + p.skipSec = skip; + variants.push_back({label, p}); + }; + add("fixed 1 h", 3600, 3600, 0); + add("fixed 2 h", 7200, 7200, 0); + add("fixed 4 h", 14400, 14400, 0); + add("fixed 8 h", 28800, 28800, 0); + add("2h..8h 5 %", 7200, 28800, 5.0f); + add("2h..8h 10 %", 7200, 28800, 10.0f); + add("2h..8h 15 %", 7200, 28800, 15.0f); + add("2h..8h 20 %", 7200, 28800, 20.0f); + add("1h..8h 10 %", 3600, 28800, 10.0f); + add("1h..8h 15 %", 3600, 28800, 15.0f); + add("3h..8h 10 %", 10800, 28800, 10.0f); + add("2h..6h 15 %", 7200, 21600, 15.0f); + + printf("median / 90th percentile of |estimate - truth| / truth, checkpoints from 1 h after the start\n\n"); + printf("%-34s", ""); + for (const Variant &v : variants) printf("| %-13.13s", v.label); + printf("\n"); + std::vector sumMed(variants.size(), 0), sumP90(variants.size(), 0); + std::vector counted(variants.size(), 0); + for (const Scenario &sc : all) { + printf("%-34s", sc.name); + for (size_t k = 0; k < variants.size(); k++) { + Score score; + for (uint64_t seed = 1; seed <= 6; seed++) run(sc, variants[k].p, seed, 3600, &score, false); + const double med = percentile(score.errors, 0.5), p90 = percentile(score.errors, 0.9); + printf("| %4.0f%% /%4.0f%% ", med * 100, p90 * 100); + if (!std::isnan(med)) { + sumMed[k] += med; + sumP90[k] += p90; + counted[k]++; + } + } + printf("\n"); + } + printf("%-34s", "mean over scenarios"); + for (size_t k = 0; k < variants.size(); k++) + printf("| %4.0f%% /%4.0f%% ", sumMed[k] / counted[k] * 100, sumP90[k] / counted[k] * 100); + printf("\n"); + return 0; +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/charge_stats.cpp b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/charge_stats.cpp new file mode 100644 index 00000000..291bf21f --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/charge_stats.cpp @@ -0,0 +1,474 @@ +// How well does charge.h do? Detection latency and false-event rates over many random +// voltage traces, which is where the figures in the README's Battery section and the +// thresholds in charge.h come from. Not part of run_tests.sh (it takes a minute). +// +// bash run_charge_stats.sh [sequences] all scenarios, 300 sequences each by default +// bash run_charge_stats.sh 300 trace scenario [n] on one sequence: the detector's +// internals once a minute, and the bins at the moment +// it first says CHARGING (for finding out why) +// +// The traces are what a battery plausibly does: a reading every 5 s with Gaussian noise +// (4 mV), one reading in ten pulled down 20-70 mV as if a WiFi burst hit the ADC, and a slow +// 1.5 mV wander. The scenarios (steps when USB goes in or out, slow charging and discharging, +// a charger holding 4.2 V, level shifts from a change in load, a start-up sag) are guesses +// about the board, not measurements of it; the point is to see how the detector copes. + +#include +#include +#include +#include +#include + +#include +#include +#include + +// The trace output looks inside the detector. +#define private public +#include "../../charge.h" +#undef private + +using CS = ChargeDetector; + +// Plain least-squares slope of the detector's (lifted) bins [from, to) in mV per minute. +static double plainSlope(const CS &d, int from, int to) { + const int n = to - from; + double mean = 0; + for (int i = from; i < to; i++) mean += d.lifted(i); + mean /= n; + const double xm = 0.5 * (n - 1); + double sxy = 0, sxx = 0; + for (int i = 0; i < n; i++) { + sxy += (i - xm) * (d.lifted(from + i) - mean); + sxx += (i - xm) * (i - xm); + } + return sxy / sxx * 4.0 * 1000.0; // four 15 s bins to the minute +} + +struct Sim { + CS d; + uint32_t t = 0; // ms since the trace began + uint64_t rng; + double wander; + unsigned seen = 0; // bit per State seen since clearSeen() + double firstAt[4] = {-1, -1, -1, -1}; // minute each state was first seen since clearSeen() + int changes = 0; // state changes since clearSeen() + CS::State last = CS::UNKNOWN; + double noiseMv = 4.0, wanderMv = 1.5, dipProb = 0.10; + bool trace = false; + uint32_t nextPrint = 0; + + explicit Sim(uint64_t seed) : rng(seed * 0x9E3779B97F4A7C15ull + 12345) { wander = uniform() * 6.2831853; } + double uniform() { + rng = rng * 6364136223846793005ull + 1442695040888963407ull; + return (double)(rng >> 11) / 9007199254740992.0; + } + double gauss() { + const double u1 = uniform() + 1e-12, u2 = uniform(); + return sqrt(-2.0 * log(u1)) * cos(6.283185307179586 * u2); + } + void clearSeen() { + seen = 0; + changes = 0; + last = d.state(); + for (double &f : firstAt) f = -1; + } + double now() const { return t / 60000.0; } + + // Runs `minutes` of a trace whose true battery voltage is truth(minutes since the start). + template + void run(double minutes, F truth) { + const uint32_t end = t + (uint32_t)(minutes * 60000.0 + 0.5); + while (t < end) { + const double m = t / 60000.0; + double v = truth(m) + noiseMv / 1000.0 * gauss() + wanderMv / 1000.0 * sin(m * 1.26 + wander); + if (uniform() < dipProb) v -= 0.020 + 0.050 * uniform(); + d.addSample((float)v, t); + const CS::State s = d.state(); + seen |= 1u << s; + if (firstAt[s] < 0) firstAt[s] = m; + if (trace) printTrace(m, s, truth(m)); + if (s != last) { + changes++; + last = s; + } + t += 4500 + (uint32_t)(uniform() * 1000.0); // readings 5 s apart, give or take + } + } + + void printTrace(double m, CS::State s, double truth) { + static const char *names[] = {"UNKNOWN", "DISCHARGING", "CHARGING", "FULL"}; + if (s == CS::CHARGING && last != CS::CHARGING) { + const int n = d.bins_; + printf("--- bins at the flip to CHARGING (t=%.2f min, n=%d), oldest first; mV above 3.80 V (bin/lifted) ---\n", m, n); + for (int i = 0; i < n; i++) + printf("%s%5.1f/%5.1f%s", i % 8 == 0 ? " " : " ", (d.means_[i] - 3.80f) * 1000, (d.lifted(i) - 3.80f) * 1000, + i % 8 == 7 ? "\n" : ""); + printf("\n detector slope %+.2f mV/min, shift-tolerant slope %+.2f mV/min\n", d.slope_ * 1000, + d.shiftTolerantSlope(n) * 1000); + } + if (t >= nextPrint) { + nextPrint = t + 60000; + const int n = d.bins_; + const float level = n >= 4 ? (d.lifted(n - 1) + d.lifted(n - 2) + d.lifted(n - 3) + d.lifted(n - 4)) * 0.25f : 0; + printf("t=%7.2f min truth=%.4f level=%.4f trend=%+6.2f plainLS=%+6.2f step=%+6.1f mV fullRef=%.4f slow=%.4f blk=%d %s\n", m, truth, + level, d.slope_ * 1000, n >= 2 ? plainSlope(d, 0, n) : 0.0, d.step_ * 1000, d.fullRef_, + d.slowCount_ ? d.slow_[d.slowCount_ - 1] : 0.0f, d.fullBlocked_ ? 1 : 0, names[s]); + } + } +}; + +static int N = 300; +static int g_only = -1, g_idx = 0; +static uint64_t g_traceSeed = 0; + +// Latency report: fn runs one sequence and returns minutes until the event, or < 0 if it never happened. +static void latency(const char *name, std::function fn) { + const int idx = g_idx++; + if (g_only >= 0 && idx != g_only) return; + std::vector v; + int miss = 0; + for (int i = 1; i <= N; i++) { + if (g_traceSeed && (uint64_t)i != g_traceSeed) continue; + Sim s(i); + s.trace = g_traceSeed != 0; + const double r = fn(s); + if (r < 0) { + miss++; + } else { + v.push_back(r); + } + } + std::sort(v.begin(), v.end()); + if (v.empty()) { + printf("[%d] %-58s never detected (%d of %d)\n", idx, name, miss, N); + return; + } + printf("[%d] %-58s median %5.1f p90 %5.1f max %5.1f min missed %d of %d\n", idx, name, v[v.size() / 2], + v[v.size() * 9 / 10], v.back(), miss, N); +} + +// Rate report: fn returns how many wrong events it saw in one sequence. +static void rate(const char *name, const char *unit, std::function fn) { + const int idx = g_idx++; + if (g_only >= 0 && idx != g_only) return; + int total = 0, seqs = 0; + std::vector bad; + for (int i = 1; i <= N; i++) { + if (g_traceSeed && (uint64_t)i != g_traceSeed) continue; + Sim s(1000 + i); + s.trace = g_traceSeed != 0; + const int r = fn(s); + total += r; + if (r) { + seqs++; + if (bad.size() < 6) bad.push_back(i); + } + } + printf("[%d] %-58s %d events, in %d of %d sequences (%s)", idx, name, total, seqs, N, unit); + if (!bad.empty()) { + printf(" first failing i:"); + for (int b : bad) printf(" %d", b); + } + printf("\n"); +} + +// The load is heavier for a minute or so after power-up (WiFi join, TLS fetches), then eases. +static double startupSag(double minutes, double sagMv) { + return minutes < 1.0 ? sagMv : (minutes < 2.5 ? sagMv * (2.5 - minutes) / 1.5 : 0.0); +} + +// How a clock comes to FULL: a charge is seen, from 4.10 V at 3 mV a minute up to a plateau of 4.19 V, which the +// charger then holds (an hour in all; the detector says FULL some twelve minutes after the voltage stops). A cell +// that is merely flat near the top when the clock starts is not called full for 160 minutes. +static bool chargeToFull(Sim &s) { + const double t0 = s.now(); + s.run(60, [t0](double m) { + const double x = 4.10 + 0.003 * (m - t0); + return x > 4.19 ? 4.19 : x; + }); + return s.d.state() == CS::FULL; +} + +int main(int argc, char **argv) { + if (argc > 1) N = atoi(argv[1]); + if (argc > 2) g_only = atoi(argv[2]); + if (argc > 3) g_traceSeed = strtoull(argv[3], nullptr, 10); + const CS::State DIS = CS::DISCHARGING, CHG = CS::CHARGING, FUL = CS::FULL; + const unsigned powerBits = (1u << CHG) | (1u << FUL); + + printf("== latency: cable events ==\n"); + for (double stepMv : {90.0, 60.0, 45.0}) { + char name[96]; + snprintf(name, sizeof name, "plug-in while discharging, +%.0f mV step, then +2.5 mV/min", stepMv); + latency(name, [stepMv](Sim &s) { + s.run(20, [](double m) { return 3.83 - 0.0006 * m; }); + const double t0 = s.now(); + s.clearSeen(); + s.run(30, [=](double m) { return 3.83 - 0.0006 * t0 + stepMv / 1000 + 0.0025 * (m - t0); }); + return s.firstAt[CHG] < 0 ? -1 : s.firstAt[CHG] - t0; + }); + } + for (double stepMv : {30.0, 20.0, 12.0}) { + char name[96]; + snprintf(name, sizeof name, "plug-in while discharging, small +%.0f mV step, +2 mV/min", stepMv); + latency(name, [stepMv](Sim &s) { + s.run(20, [](double m) { return 3.80 - 0.0006 * m; }); + const double t0 = s.now(); + s.clearSeen(); + s.run(40, [=](double m) { return 3.80 - 0.0006 * t0 + stepMv / 1000 + 0.002 * (m - t0); }); + return s.firstAt[CHG] < 0 ? -1 : s.firstAt[CHG] - t0; + }); + } + for (double stepMv : {80.0, 50.0, 25.0}) { + char name[96]; + snprintf(name, sizeof name, "unplug while charging, -%.0f mV step, then -0.6 mV/min", stepMv); + latency(name, [stepMv](Sim &s) { + s.run(30, [](double m) { return 3.90 + 0.0025 * m; }); + const double t0 = s.now(); + s.clearSeen(); + s.run(30, [=](double m) { return 3.90 + 0.0025 * t0 - stepMv / 1000 - 0.0006 * (m - t0); }); + return s.firstAt[DIS] < 0 ? -1 : s.firstAt[DIS] - t0; + }); + } + + printf("== latency: after boot (includes the 3 minute warm-up) ==\n"); + for (double r : {3.0, 2.0, 1.5, 1.0}) { + char name[96]; + snprintf(name, sizeof name, "booted while charging at +%.1f mV/min -> CHARGING", r); + latency(name, [r](Sim &s) { + s.run(40, [r](double m) { return 3.80 + r / 1000 * m; }); + return s.firstAt[CHG]; + }); + } + for (double r : {1.5, 1.0, 0.6, 0.4, 0.3}) { + char name[96]; + snprintf(name, sizeof name, "booted on battery at -%.1f mV/min -> DISCHARGING", r); + latency(name, [r](Sim &s) { + s.run(40, [r](double m) { return 3.85 - r / 1000 * m; }); + return s.firstAt[DIS]; + }); + } + for (double r : {0.20, 0.10, 0.05}) { // a light load in the middle of the curve: the slope is tiny + char name[96]; + snprintf(name, sizeof name, "booted on battery at -%.2f mV/min, light load -> DISCHARGING", r); + latency(name, [r](Sim &s) { + s.run(900, [r](double m) { return 3.85 - r / 1000 * m; }); + return s.firstAt[DIS]; + }); + } + for (double r : {0.15, 0.07}) { // booted on battery near the top: a flat-looking fall that may first read as FULL + char name[96]; + snprintf(name, sizeof name, "booted on battery at 4.17 V falling %.2f mV/min -> DISCHARGING", r); + latency(name, [r](Sim &s) { + s.run(900, [r](double m) { return 4.17 - r / 1000 * m; }); + return s.firstAt[DIS]; + }); + } + // (no charge was seen, so twelve flat minutes are not enough: the slow history has to show 160 minutes without a fall) + latency("booted at a flat 4.18 V (on a charger, full) -> FULL", [](Sim &s) { + s.run(300, [](double) { return 4.18; }); + return s.firstAt[FUL]; + }); + latency("booted at a flat 4.20 V (on a charger, full) -> FULL", [](Sim &s) { + s.run(300, [](double) { return 4.20; }); + return s.firstAt[FUL]; + }); + latency("booted on a charger, full, cell still relaxing (8 mV, tau 30 min) -> FULL", [](Sim &s) { + s.run(480, [](double m) { return 4.19 + 0.008 * exp(-m / 30.0); }); + return s.firstAt[FUL]; + }); + latency("charging reaches 4.2 V at minute 33 -> FULL (minutes after)", [](Sim &s) { + s.run(90, [](double m) { + const double x = 4.10 + 0.003 * m; + return x > 4.2 ? 4.2 : x; + }); + return s.firstAt[FUL] < 0 ? -1 : s.firstAt[FUL] - 33.0; + }); + + printf("== latency: unplugging a full battery (no step; plateau 4.19 V) ==\n"); + for (double r : {2.5, 1.7, 1.2, 0.8, 0.5}) { + char name[96]; + snprintf(name, sizeof name, "unplugged when full, falls at %.1f mV/min -> DISCHARGING", r); + latency(name, [r](Sim &s) { + chargeToFull(s); + const double t0 = s.now(); + s.clearSeen(); + s.run(80, [=](double m) { return 4.19 - r / 1000 * (m - t0); }); + return s.firstAt[DIS] < 0 ? -1 : s.firstAt[DIS] - t0; + }); + } + // A light load (the clock with the radio mostly off draws 10-45 mA from a 2500 mAh cell) makes the top of + // the curve fall by 0.07-0.3 mV a minute: the case that sat on FULL for hours with the first version. + for (double r : {0.40, 0.30, 0.20, 0.12, 0.07}) { + char name[96]; + snprintf(name, sizeof name, "unplugged when full, light load, falls at %.2f mV/min -> DISCHARGING", r); + latency(name, [r](Sim &s) { + chargeToFull(s); + const double t0 = s.now(); + s.clearSeen(); + s.run(600, [=](double m) { return 4.19 - r / 1000 * (m - t0); }); + return s.firstAt[DIS] < 0 ? -1 : s.firstAt[DIS] - t0; + }); + } + for (double r : {0.30, 0.12, 0.07}) { + char name[96]; + snprintf(name, sizeof name, "unplugged when full with a 7 mV IR step, light load, falls at %.2f mV/min", r); + latency(name, [r](Sim &s) { + chargeToFull(s); + const double t0 = s.now(); + s.clearSeen(); + s.run(600, [=](double m) { return 4.19 - 0.007 - r / 1000 * (m - t0); }); + return s.firstAt[DIS] < 0 ? -1 : s.firstAt[DIS] - t0; + }); + } + for (double drop : {40.0, 25.0}) { + char name[96]; + snprintf(name, sizeof name, "unplugged when full with a %.0f mV IR step, then -1 mV/min", drop); + latency(name, [drop](Sim &s) { + chargeToFull(s); + const double t0 = s.now(); + s.clearSeen(); + s.run(60, [=](double m) { return 4.19 - drop / 1000 - 0.001 * (m - t0); }); + return s.firstAt[DIS] < 0 ? -1 : s.firstAt[DIS] - t0; + }); + } + + printf("== false events over 8 hours ==\n"); + rate("on battery -0.5 mV/min: CHARGING or FULL claimed", "should be 0", [=](Sim &s) { + s.run(480, [](double m) { return 4.05 - 0.0005 * m; }); + return (s.seen & powerBits) ? 1 : 0; + }); + rate("on battery -0.3 mV/min: CHARGING or FULL claimed", "should be 0", [=](Sim &s) { + s.run(480, [](double m) { return 3.90 - 0.0003 * m; }); + return (s.seen & powerBits) ? 1 : 0; + }); + rate("on battery -0.07 mV/min, light load: CHARGING or FULL claimed", "should be 0", [=](Sim &s) { + s.run(480, [](double m) { return 3.95 - 0.00007 * m; }); + return (s.seen & powerBits) ? 1 : 0; + }); + rate("pure noise at 3.70 V: CHARGING or FULL claimed", "should be 0", [=](Sim &s) { + s.run(480, [](double) { return 3.70; }); + return (s.seen & powerBits) ? 1 : 0; + }); + rate("on battery with a random 15 mV level shift every ~40 min: CHARGING", "about 1 in 60 shifts", [&](Sim &s) { + double level = 0, next = 40; + s.run(480, [&](double m) { + if (m > next) { + level += (s.uniform() < 0.5 ? 1 : -1) * 0.015; + next += 20 + 40 * s.uniform(); + } + return 3.95 - 0.0005 * m + level; + }); + return (s.seen & (1u << CHG)) ? 1 : 0; + }); + rate("held full at 4.19 V: state changes after reaching FULL", "should be 0", [&](Sim &s) { + chargeToFull(s); + s.clearSeen(); + s.run(480, [](double) { return 4.19; }); + return s.changes; + }); + // After the charger ends the cell relaxes a few millivolts over some minutes: that is not a discharge. + for (double tau : {15.0, 30.0}) { + char name[96]; + snprintf(name, sizeof name, "held full after the charger ends, 8 mV tail (tau %.0f min): changes", tau); + rate(name, "should be 0", [tau](Sim &s) { + const double top = (4.198 - 4.10) / 0.003; // the minute the charge reaches its end and the cell starts to relax + auto f = [=](double m) { return m < top ? 4.10 + 0.003 * m : 4.19 + 0.008 * exp(-(m - top) / tau); }; + s.run(top + 3 * tau + 25, f); // (called FULL once the tail has flattened) + const bool full = s.d.state() == CS::FULL; + s.clearSeen(); + s.run(480, f); + return s.changes + (full ? 0 : 100); // 100 = it was not FULL to begin with + }); + } + // A clock started on its battery with the cell nearly full: flat for twelve minutes, and not held by anything. + for (double r : {0.12, 0.07, 0.05, 0.03}) { + char name[96]; + snprintf(name, sizeof name, "booted on battery at 4.19 V falling %.2f mV/min, 16 h: FULL or CHARGING", r); + rate(name, "should be 0", [r, powerBits](Sim &s) { + s.run(960, [r](double m) { return 4.19 - r / 1000 * m; }); + return (s.seen & powerBits) ? 1 : 0; + }); + } + rate("booted on battery at 4.19 V falling 0.02 mV/min (a month's drain), 16 h: FULL", "informational", [FUL](Sim &s) { + s.run(960, [](double m) { return 4.19 - 0.00002 * m; }); + return (s.seen & (1u << FUL)) ? 1 : 0; + }); + for (double dipMv : {13.0, 25.0}) { // a cable knocked loose and pushed back: four minutes low, every hour or so + char name[96]; + snprintf(name, sizeof name, "held full with a four minute dip of %.0f mV every hour or so: changes", dipMv); + rate(name, "informational: the twelve minute trend says discharging for a while", [dipMv](Sim &s) { + chargeToFull(s); + s.clearSeen(); + double dipAt = 20 + 50 * s.uniform(); + s.run(480, [&](double m) { + if (m > dipAt + 4) dipAt = m + 40 + 50 * s.uniform(); + return 4.19 - ((m >= dipAt && m < dipAt + 4) ? dipMv / 1000 : 0.0); + }); + return s.changes; + }); + } + rate("held full with random level shifts within +-4 mV every ~40 min: changes", "should be 0", [&](Sim &s) { + chargeToFull(s); + s.clearSeen(); + double level = 0, next = 40; + s.run(480, [&](double m) { + if (m > next) { + level = (s.uniform() < 0.5 ? 1 : -1) * 0.004 * s.uniform(); + next += 20 + 40 * s.uniform(); + } + return 4.19 + level; + }); + return s.changes; + }); + rate("held full with random level shifts within +-8 mV every ~40 min: changes", "informational: may read as a fall", [&](Sim &s) { + chargeToFull(s); + s.clearSeen(); + double level = 0, next = 40; + s.run(480, [&](double m) { + if (m > next) { + level = (s.uniform() < 0.5 ? 1 : -1) * 0.008 * s.uniform(); + next += 20 + 40 * s.uniform(); + } + return 4.19 + level; + }); + return s.changes + ((s.seen & (1u << CHG)) ? 1000 : 0); // 1000 = it also said CHARGING + }); + for (double sagMv : {30.0, 60.0, 100.0}) { + char name[96]; + snprintf(name, sizeof name, "boot on battery with a %.0f mV start-up sag: CHARGING", sagMv); + rate(name, "should be 0", [sagMv, powerBits](Sim &s) { + s.run(30, [sagMv](double m) { return 3.90 - 0.0006 * m - startupSag(m, sagMv) / 1000.0; }); + return (s.seen & (1u << CHG)) ? 1 : 0; + }); + } + for (double sagMv : {30.0, 60.0, 100.0}) { + char name[96]; + snprintf(name, sizeof name, "boot on USB (4.19 V full) with a %.0f mV start-up sag: DISCHARGING", sagMv); + rate(name, "should be 0", [sagMv, DIS](Sim &s) { + s.run(30, [sagMv](double m) { return 4.19 - startupSag(m, sagMv) / 1000.0; }); + return (s.seen & (1u << DIS)) ? 1 : 0; + }); + } + rate("plugged in when nearly full (+60 mV, capped 4.2 V): changes beyond DIS->CHG->FULL", "should be 0", [&](Sim &s) { + s.run(20, [](double m) { return 4.14 - 0.001 * m; }); + const double t0 = s.now(); + s.clearSeen(); + s.run(25, [t0](double m) { + const double x = 4.12 + 0.060 + 0.004 * (m - t0); + return x > 4.2 ? 4.2 : x; + }); + return s.changes > 2 ? 1 : 0; + }); + rate("charging +2 mV/min then flat 4.2 V: state changes beyond CHG->FULL", "should be 0", [&](Sim &s) { + s.run(30, [](double m) { return 4.12 + 0.002 * m; }); + s.clearSeen(); + s.run(480, [](double m) { + const double x = 4.12 + 0.002 * (m + 30); + return x > 4.2 ? 4.2 : x; + }); + return s.changes > 1 ? 1 : 0; + }); + return 0; +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/check.h b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/check.h new file mode 100644 index 00000000..a852ec0b --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/check.h @@ -0,0 +1,57 @@ +#pragma once + +// A very small test harness shared by the host-side tests: CHECK macros that count and +// print failures, and a section() heading. Each test program defines its own main(). + +#include +#include +#include +#include + +#include + +[[maybe_unused]] static int g_checks = 0, g_failed = 0; + +#define CHECK(cond) \ + do { \ + g_checks++; \ + if (!(cond)) { \ + g_failed++; \ + printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \ + } \ + } while (0) + +#define CHECK_STR(a, b) \ + do { \ + g_checks++; \ + if (strcmp((a), (b)) != 0) { \ + g_failed++; \ + printf(" FAIL %s:%d \"%s\" != \"%s\"\n", __FILE__, __LINE__, (a), (b)); \ + } \ + } while (0) + +#define CHECK_NEAR(a, b, tol) \ + do { \ + g_checks++; \ + if (fabs((double)(a) - (double)(b)) > (tol)) { \ + g_failed++; \ + printf(" FAIL %s:%d %g vs %g\n", __FILE__, __LINE__, (double)(a), (double)(b)); \ + } \ + } while (0) + +[[maybe_unused]] static std::string readFile(const char *path) { + FILE *f = fopen(path, "rb"); + if (!f) { + printf(" cannot open %s\n", path); + g_failed++; + return ""; + } + std::string s; + char buf[4096]; + size_t n; + while ((n = fread(buf, 1, sizeof buf, f)) > 0) s.append(buf, n); + fclose(f); + return s; +} + +[[maybe_unused]] static void section(const char *name) { printf("[%s]\n", name); } diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/esp32s3_app_head.bin b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/esp32s3_app_head.bin new file mode 100644 index 00000000..4c6b2b0e Binary files /dev/null and b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/esp32s3_app_head.bin differ diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/esp32s3_bootloader_head.bin b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/esp32s3_bootloader_head.bin new file mode 100644 index 00000000..86034b30 Binary files /dev/null and b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/esp32s3_bootloader_head.bin differ diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/open-meteo-forecast-perth.json b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/open-meteo-forecast-perth.json new file mode 100644 index 00000000..a53c5e83 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/open-meteo-forecast-perth.json @@ -0,0 +1 @@ +{"latitude":-31.95079,"longitude":115.807236,"generationtime_ms":0.5131959915161133,"utc_offset_seconds":28800,"timezone":"Australia/Perth","timezone_abbreviation":"GMT+8","elevation":23.0,"current_units":{"time":"iso8601","interval":"seconds","temperature_2m":"°C","relative_humidity_2m":"%","apparent_temperature":"°C","is_day":"","weather_code":"wmo code","wind_speed_10m":"km/h"},"current":{"time":"2026-10-05T13:45","interval":900,"temperature_2m":20.2,"relative_humidity_2m":57,"apparent_temperature":19.7,"is_day":1,"weather_code":2,"wind_speed_10m":12.3},"daily_units":{"time":"iso8601","weather_code":"wmo code","temperature_2m_max":"°C","temperature_2m_min":"°C","precipitation_probability_max":"%","sunrise":"iso8601","sunset":"iso8601","uv_index_max":""},"daily":{"time":["2026-10-05","2026-10-06","2026-10-07"],"weather_code":[3,3,51],"temperature_2m_max":[20.3,24.4,19.9],"temperature_2m_min":[12.0,11.6,15.2],"precipitation_probability_max":[2,2,45],"sunrise":["2026-10-05T05:49","2026-10-06T05:48","2026-10-07T05:47"],"sunset":["2026-10-05T18:21","2026-10-06T18:21","2026-10-07T18:22"],"uv_index_max":[7.05,7.35,7.70]}} \ No newline at end of file diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/open-meteo-geocode-perth.json b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/open-meteo-geocode-perth.json new file mode 100644 index 00000000..79ccefc8 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/open-meteo-geocode-perth.json @@ -0,0 +1 @@ +{"results":[{"id":2063523,"name":"Perth","latitude":-31.95224,"longitude":115.8614,"elevation":34.0,"feature_code":"PPLA","country_code":"AU","admin1_id":2058645,"admin2_id":2063522,"timezone":"Australia/Perth","population":2309338,"country_id":2077456,"country":"Australia","admin1":"Western Australia","admin2":"Perth"}],"generationtime_ms":0.6234646} \ No newline at end of file diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/rundown-2026-10.csv b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/rundown-2026-10.csv new file mode 100644 index 00000000..c503a67d --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/rundown-2026-10.csv @@ -0,0 +1,491 @@ +minute,volts +0,4.264 +10,4.225 +20,4.210 +30,4.201 +40,4.200 +50,4.199 +60,4.195 +70,4.193 +80,4.192 +90,4.192 +100,4.191 +110,4.191 +120,4.191 +130,4.189 +140,4.187 +150,4.186 +160,4.184 +170,4.182 +180,4.183 +190,4.182 +200,4.182 +210,4.180 +220,4.179 +230,4.180 +240,4.179 +250,4.178 +260,4.176 +270,4.177 +280,4.175 +290,4.173 +300,4.173 +310,4.171 +320,4.169 +330,4.165 +340,4.163 +350,4.161 +360,4.163 +370,4.160 +380,4.160 +390,4.158 +400,4.158 +410,4.157 +420,4.155 +430,4.154 +440,4.153 +450,4.152 +460,4.152 +470,4.151 +480,4.150 +490,4.149 +500,4.148 +510,4.148 +520,4.147 +530,4.144 +540,4.143 +550,4.141 +560,4.140 +570,4.139 +580,4.137 +590,4.135 +600,4.133 +610,4.131 +620,4.129 +630,4.127 +640,4.124 +650,4.120 +660,4.119 +670,4.117 +680,4.115 +690,4.115 +700,4.114 +710,4.114 +720,4.111 +730,4.108 +740,4.107 +750,4.104 +760,4.101 +770,4.099 +780,4.097 +790,4.094 +800,4.093 +810,4.092 +820,4.092 +830,4.091 +840,4.087 +850,4.084 +860,4.082 +870,4.079 +880,4.076 +890,4.073 +900,4.072 +910,4.068 +920,4.069 +930,4.066 +940,4.062 +950,4.060 +960,4.059 +970,4.057 +980,4.049 +990,4.049 +1000,4.043 +1010,4.035 +1020,4.033 +1030,4.031 +1040,4.025 +1050,4.022 +1060,4.021 +1070,4.021 +1080,4.021 +1090,4.017 +1100,4.014 +1110,4.009 +1120,4.009 +1130,4.008 +1140,3.999 +1150,3.998 +1160,3.997 +1170,3.997 +1180,3.995 +1190,3.993 +1200,3.990 +1210,3.990 +1220,3.987 +1230,3.987 +1240,3.984 +1250,3.982 +1260,3.981 +1270,3.978 +1280,3.979 +1290,3.978 +1300,3.976 +1310,3.976 +1320,3.974 +1330,3.971 +1340,3.971 +1350,3.967 +1360,3.967 +1370,3.966 +1380,3.961 +1390,3.960 +1400,3.956 +1410,3.955 +1420,3.954 +1430,3.952 +1440,3.950 +1450,3.949 +1460,3.948 +1470,3.947 +1480,3.946 +1490,3.945 +1500,3.945 +1510,3.944 +1520,3.943 +1530,3.940 +1540,3.940 +1550,3.938 +1560,3.937 +1570,3.936 +1580,3.934 +1590,3.932 +1600,3.932 +1610,3.930 +1620,3.925 +1630,3.924 +1640,3.922 +1650,3.921 +1660,3.921 +1670,3.920 +1680,3.918 +1690,3.915 +1700,3.913 +1710,3.911 +1720,3.908 +1730,3.908 +1740,3.907 +1750,3.907 +1760,3.906 +1770,3.905 +1780,3.905 +1790,3.903 +1800,3.899 +1810,3.900 +1820,3.900 +1830,3.898 +1840,3.897 +1850,3.897 +1860,3.897 +1870,3.896 +1880,3.894 +1890,3.892 +1900,3.887 +1910,3.881 +1920,3.875 +1930,3.870 +1940,3.872 +1950,3.870 +1960,3.869 +1970,3.869 +1980,3.869 +1990,3.868 +2000,3.866 +2010,3.861 +2020,3.864 +2030,3.862 +2040,3.861 +2050,3.860 +2060,3.860 +2070,3.857 +2080,3.854 +2090,3.852 +2100,3.851 +2110,3.851 +2120,3.851 +2130,3.851 +2140,3.849 +2150,3.845 +2160,3.842 +2170,3.841 +2180,3.837 +2190,3.837 +2200,3.833 +2210,3.831 +2220,3.827 +2230,3.826 +2240,3.825 +2250,3.825 +2260,3.824 +2270,3.823 +2280,3.820 +2290,3.818 +2300,3.816 +2310,3.816 +2320,3.814 +2330,3.815 +2340,3.812 +2350,3.802 +2360,3.810 +2370,3.809 +2380,3.807 +2390,3.806 +2400,3.802 +2410,3.802 +2420,3.794 +2430,3.794 +2440,3.793 +2450,3.790 +2460,3.783 +2470,3.788 +2480,3.784 +2490,3.782 +2500,3.781 +2510,3.781 +2520,3.780 +2530,3.777 +2540,3.775 +2550,3.772 +2560,3.772 +2570,3.771 +2580,3.770 +2590,3.768 +2600,3.767 +2610,3.764 +2620,3.764 +2630,3.762 +2640,3.762 +2650,3.760 +2660,3.760 +2670,3.758 +2680,3.756 +2690,3.753 +2700,3.752 +2710,3.750 +2720,3.748 +2730,3.748 +2740,3.745 +2750,3.742 +2760,3.740 +2770,3.740 +2780,3.739 +2790,3.738 +2800,3.735 +2810,3.733 +2820,3.732 +2830,3.731 +2840,3.731 +2850,3.726 +2860,3.729 +2870,3.727 +2880,3.718 +2890,3.720 +2900,3.714 +2910,3.710 +2920,3.708 +2930,3.708 +2940,3.707 +2950,3.706 +2960,3.705 +2970,3.703 +2980,3.704 +2990,3.702 +3000,3.698 +3010,3.698 +3020,3.696 +3030,3.695 +3040,3.695 +3050,3.688 +3060,3.692 +3070,3.690 +3080,3.690 +3090,3.689 +3100,3.687 +3110,3.686 +3120,3.685 +3130,3.684 +3140,3.683 +3150,3.682 +3160,3.680 +3170,3.679 +3180,3.678 +3190,3.676 +3200,3.672 +3210,3.670 +3220,3.668 +3230,3.667 +3240,3.666 +3250,3.666 +3260,3.664 +3270,3.664 +3280,3.662 +3290,3.662 +3300,3.662 +3310,3.661 +3320,3.660 +3330,3.659 +3340,3.659 +3350,3.658 +3360,3.657 +3370,3.657 +3380,3.656 +3390,3.654 +3400,3.651 +3410,3.648 +3420,3.646 +3430,3.646 +3440,3.644 +3450,3.643 +3460,3.644 +3470,3.640 +3480,3.640 +3490,3.639 +3500,3.637 +3510,3.637 +3520,3.638 +3530,3.636 +3540,3.635 +3550,3.634 +3560,3.633 +3570,3.630 +3580,3.631 +3590,3.628 +3600,3.628 +3610,3.627 +3620,3.625 +3630,3.625 +3640,3.625 +3650,3.624 +3660,3.622 +3670,3.621 +3680,3.619 +3690,3.619 +3700,3.617 +3710,3.617 +3720,3.614 +3730,3.613 +3740,3.612 +3750,3.610 +3760,3.608 +3770,3.607 +3780,3.606 +3790,3.606 +3800,3.605 +3810,3.602 +3820,3.600 +3830,3.600 +3840,3.598 +3850,3.597 +3860,3.597 +3870,3.597 +3880,3.596 +3890,3.595 +3900,3.591 +3910,3.590 +3920,3.587 +3930,3.584 +3940,3.584 +3950,3.580 +3960,3.579 +3970,3.578 +3980,3.578 +3990,3.576 +4000,3.573 +4010,3.571 +4020,3.569 +4030,3.568 +4040,3.568 +4050,3.567 +4060,3.567 +4070,3.566 +4080,3.563 +4090,3.561 +4100,3.559 +4110,3.552 +4120,3.542 +4130,3.544 +4140,3.542 +4150,3.540 +4160,3.538 +4170,3.535 +4180,3.533 +4190,3.533 +4200,3.530 +4210,3.532 +4220,3.531 +4230,3.529 +4240,3.530 +4250,3.529 +4260,3.528 +4270,3.528 +4280,3.525 +4290,3.525 +4300,3.523 +4310,3.522 +4320,3.521 +4330,3.518 +4340,3.517 +4350,3.517 +4360,3.515 +4370,3.512 +4380,3.507 +4390,3.507 +4400,3.502 +4410,3.502 +4420,3.502 +4430,3.499 +4440,3.496 +4450,3.497 +4460,3.494 +4470,3.493 +4480,3.493 +4490,3.492 +4500,3.492 +4510,3.491 +4520,3.492 +4530,3.490 +4540,3.488 +4550,3.485 +4560,3.484 +4570,3.482 +4580,3.475 +4590,3.480 +4600,3.476 +4610,3.476 +4620,3.473 +4630,3.460 +4640,3.464 +4650,3.457 +4660,3.458 +4670,3.455 +4680,3.454 +4690,3.452 +4700,3.450 +4710,3.448 +4720,3.444 +4730,3.438 +4740,3.435 +4750,3.429 +4760,3.422 +4770,3.415 +4780,3.411 +4790,3.405 +4800,3.398 +4810,3.383 +4820,3.373 +4830,3.365 +4840,3.357 +4850,3.352 +4860,3.334 +4870,3.327 +4880,3.319 +4890,3.307 diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/spotify-player-ad.json b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/spotify-player-ad.json new file mode 100644 index 00000000..8fdabaf1 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/spotify-player-ad.json @@ -0,0 +1,9 @@ +{ + "device": { "id": "abc123", "is_active": true, "name": "Phone", "type": "Smartphone", "volume_percent": 40 }, + "shuffle_state": false, + "timestamp": 1790000000000, + "progress_ms": 0, + "is_playing": true, + "item": null, + "currently_playing_type": "ad" +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/spotify-player-episode.json b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/spotify-player-episode.json new file mode 100644 index 00000000..a862abf3 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/spotify-player-episode.json @@ -0,0 +1,16 @@ +{ + "device": { "id": "abc123", "is_active": true, "name": "Desk PC", "type": "Computer", "volume_percent": null }, + "repeat_state": "off", + "shuffle_state": false, + "timestamp": 1790000000000, + "progress_ms": 1234567, + "is_playing": false, + "item": { + "name": "#412 - A very long episode title about many things", + "duration_ms": 5400000, + "show": { "name": "The Example Podcast", "media_type": "audio" }, + "type": "episode", + "uri": "spotify:episode:512ojhOuo1ktJprKbVcKyQ" + }, + "currently_playing_type": "episode" +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/spotify-player-track.json b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/spotify-player-track.json new file mode 100644 index 00000000..17501a5c --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/fixtures/spotify-player-track.json @@ -0,0 +1,48 @@ +{ + "device": { + "id": "0d1841b0976bae2a3a310dd74c0f3df354899bc8", + "is_active": true, + "is_private_session": false, + "is_restricted": false, + "name": "Kitchen speaker", + "type": "Speaker", + "volume_percent": 59, + "supports_volume": true + }, + "repeat_state": "off", + "shuffle_state": true, + "context": { + "type": "playlist", + "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DXcBWIGoYBM5M", + "external_urls": { "spotify": "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M" }, + "uri": "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M" + }, + "timestamp": 1790000000000, + "progress_ms": 83000, + "is_playing": true, + "item": { + "album": { + "album_type": "album", + "total_tracks": 14, + "name": "After Hours", + "release_date": "2020-03-20", + "images": [ + { "url": "https://i.scdn.co/image/ab67616d0000b2738863bc11d2aa12b54f5aeb36", "height": 640, "width": 640 } + ], + "artists": [ { "name": "The Weeknd", "type": "artist", "uri": "spotify:artist:1Xyo4u8uXC1ZmMpatF05PJ" } ] + }, + "artists": [ + { "name": "The Weeknd", "type": "artist", "uri": "spotify:artist:1Xyo4u8uXC1ZmMpatF05PJ" }, + { "name": "Daft Punk", "type": "artist", "uri": "spotify:artist:4tZwfgrHOc3mvqYlEYSvVi" } + ], + "disc_number": 1, + "duration_ms": 200040, + "explicit": false, + "name": "Blinding Lights", + "track_number": 9, + "type": "track", + "uri": "spotify:track:0VjIjW4GlUZAMYd2vXMi3b" + }, + "currently_playing_type": "track", + "actions": { "disallows": { "resuming": true } } +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/override_all.h b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/override_all.h new file mode 100644 index 00000000..79502855 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/override_all.h @@ -0,0 +1,41 @@ +#pragma once + +// What a secrets.h would say to pre-program every setting: one #define for each, with the values of +// all_settings.h. test_builddefaults.cpp (built with -DTEST_ALL -include override_all.h) checks +// that the settings it ends up with are exactly what the card file of all_settings.h gives, and +// that each macro is documented in the README and in secrets.example.h. +// +// A macro that config.h forgot to guard with #ifndef is a "redefined" error here (-Werror). + +#define WIFI_ENABLED 0 +#define WIFI_SSID "Cafe Net" +#define WIFI_PASSWORD "p@ss w0rd" +#define WIFI_SSID_BACKUP "Phone Hotspot" +#define WIFI_PASSWORD_BACKUP "h0tsp0t pw" +#define APP_HOSTNAME "my-clock" +#define NTP_SERVER "time.nist.gov" +#define WIFI_POWER_SAVE "max" +#define WIFI_MODE "sync" +#define USE_FAHRENHEIT 1 +#define TIME_FORMAT "12h" +#define DATE_FORMAT "mdy" +#define SHOW_WEEK 0 +#define LOCATION_LATITUDE 40.7128 +#define LOCATION_LONGITUDE (-74.0060) +#define LOCATION_LABEL "New York" +#define LOCATION_QUERY "New York" +#define TIMEZONE_POSIX_OVERRIDE "America/New_York" +#define SPOTIFY_ENABLED 0 +#define SPOTIFY_CLIENT_ID "0123456789abcdef0123456789abcdef" +#define SPOTIFY_LIVE 0 +#define INDOOR_TEMP_OFFSET_C (-2.5f) +#define BATTERY_MODE "none" +#define BATTERY_CAPACITY_MAH 2500 +#define BATTERY_CALIBRATION 1.0157f +#define LOW_BATTERY_SHUTDOWN 0 +#define BATTERY_CUTOFF_V 3.45f +#define CPU_MHZ 160 +#define CPU_IDLE_MHZ 40 +#define CONSOLE_MODE "auto" +#define POWER_LOG 1 +#define WEATHER_INTERVAL_MIN 30 diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/override_bad.h b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/override_bad.h new file mode 100644 index 00000000..d1cd4036 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/override_bad.h @@ -0,0 +1,19 @@ +#pragma once + +// A secrets.h with mistakes in it: values the SD card file would refuse too. test_builddefaults.cpp +// (-DTEST_BAD) checks that each one is left out and reported, and that the good ones beside them +// still count. (Seven bad values, in the order buildDefaults() looks at them: the report keeps three.) + +#define APP_HOSTNAME "my clock!" // letters, digits and '-' only +#define WIFI_POWER_SAVE "turbo" // normal or max +#define TIME_FORMAT "noon" // 24h or 12h +#define BATTERY_CUTOFF_V 2.0f // 3.10 to 3.60: lower would ruin the cell +#define CPU_MHZ 100 // 80, 160 or 240 +#define CPU_IDLE_MHZ 10 // 0, 80, 40 or 20: 10 is not on offer +#define CONSOLE_MODE "sometimes" // on, auto or off + +#define DATE_FORMAT "d-mon-y" // good ones +#define SHOW_WEEK 0 +#define BATTERY_CAPACITY_MAH 1800 +#define LOCATION_LATITUDE (-31.952240) // and coordinates that keep all their digits (a metre, like the card's) +#define LOCATION_LONGITUDE 115.861456 diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/override_edge.h b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/override_edge.h new file mode 100644 index 00000000..9a4f65eb --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/override_edge.h @@ -0,0 +1,20 @@ +#pragma once + +// A secrets.h with values right on the limits of what the settings accept, and the placeholder +// names that secrets.example.h comes with. test_builddefaults.cpp (-DTEST_EDGE) checks that every +// limit value is taken and that the placeholders count as "no network set". 3.10f is 3.0999999 as a +// double, below the limit of 3.10, which is why a float goes through the rules with six digits. + +#define BATTERY_CUTOFF_V 3.10f +#define BATTERY_CALIBRATION 0.80f +#define INDOOR_TEMP_OFFSET_C (-15.0f) +#define BATTERY_CAPACITY_MAH 20000 +#define WEATHER_INTERVAL_MIN 5 +#define LOCATION_LATITUDE (-90) +#define LOCATION_LONGITUDE 180 +#define CPU_MHZ 240 + +#define WIFI_SSID "YourWiFiName" +#define WIFI_PASSWORD "YourWiFiPassword" +#define WIFI_SSID_BACKUP "YourBackupWiFiName" +#define WIFI_PASSWORD_BACKUP "YourBackupWiFiPassword" diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/run_charge_stats.sh b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/run_charge_stats.sh new file mode 100644 index 00000000..fe25b6d4 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/run_charge_stats.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Detection latency and false-event statistics for charge.h over random voltage traces +# (see charge_stats.cpp). Run inside Linux or WSL. +# +# bash run_charge_stats.sh [sequences] all scenarios (300 sequences each by default) +# bash run_charge_stats.sh 300 trace scenario [n] on sequence +set -euo pipefail +cd "$(dirname "$0")" +mkdir -p build +g++ -std=c++17 -O2 -Wall -Wno-unused-lambda-capture -o build/charge_stats charge_stats.cpp -lm +./build/charge_stats "$@" diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/run_tests.sh b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/run_tests.sh new file mode 100644 index 00000000..dba6b453 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/run_tests.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Builds and runs the host-side logic tests. Run inside Linux or WSL. +# +# Needs the ArduinoJson headers (v7). By default they are looked for in the +# Arduino sketchbook; set ARDUINOJSON_SRC to point at ArduinoJson/src otherwise. +set -euo pipefail +cd "$(dirname "$0")" + +# AddressSanitizer binaries can spin forever at startup on recent Linux/WSL2 +# kernels with high ASLR entropy; run them with ASLR off where that is allowed. +run() { + if setarch "$(uname -m)" -R true 2>/dev/null; then + setarch "$(uname -m)" -R "$@" + else + "$@" + fi +} + +AJ="${ARDUINOJSON_SRC:-$HOME/Arduino/libraries/ArduinoJson/src}" +if [ ! -f "$AJ/ArduinoJson.h" ]; then + echo "ArduinoJson not found at $AJ - set ARDUINOJSON_SRC to its src directory" >&2 + exit 2 +fi + +FLAGS="-std=c++17 -O1 -g -Wall -Wextra -Wno-unused-parameter -Wno-maybe-uninitialized -fsanitize=address,undefined -fno-sanitize-recover=undefined" + +mkdir -p build +g++ $FLAGS -isystem "$AJ" -I../.. \ + test_logic.cpp ../../weather.cpp ../../spotify_parse.cpp ../../link_page.cpp \ + -o build/test_logic +g++ $FLAGS -I../.. \ + test_features.cpp ../../settings.cpp ../../tz_table.cpp \ + -o build/test_features +g++ $FLAGS -I../.. test_radio.cpp -o build/test_radio +# The defaults config.h gives (build_defaults.h), four ways: on its own, then with a secrets.h that sets +# everything, one with mistakes in it and one with values on the limits (the override_*.h files play +# the secrets.h). -Werror: a macro that config.h does not guard with #ifndef is a "redefined" error. +g++ $FLAGS -I../.. -c ../../settings.cpp -o build/settings.o +g++ $FLAGS -I../.. -c ../../tz_table.cpp -o build/tz_table.o +BD="$FLAGS -Werror -DCONFIG_NO_SECRETS -I../.." +g++ $BD test_builddefaults.cpp build/settings.o build/tz_table.o -o build/test_builddefaults_factory +g++ $BD -DTEST_ALL -include override_all.h test_builddefaults.cpp build/settings.o build/tz_table.o -o build/test_builddefaults_all +g++ $BD -DTEST_BAD -include override_bad.h test_builddefaults.cpp build/settings.o build/tz_table.o -o build/test_builddefaults_bad +g++ $BD -DTEST_EDGE -include override_edge.h test_builddefaults.cpp build/settings.o build/tz_table.o -o build/test_builddefaults_edge +# the battery simulation is a tool, not a test: build it so it cannot rot, and run one short trace +g++ -std=c++17 -O1 -Wall -Wextra -I../.. battery_sim.cpp -o build/battery_sim + +run ./build/test_logic +run ./build/test_features +run ./build/test_radio +for variant in factory all bad edge; do + echo "[build defaults: $variant]" + run ./build/test_builddefaults_$variant +done +./build/battery_sim trace 3 > /dev/null && echo "battery_sim: builds and runs" diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/test_builddefaults.cpp b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/test_builddefaults.cpp new file mode 100644 index 00000000..29a72834 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/test_builddefaults.cpp @@ -0,0 +1,243 @@ +// Host-side test of build_defaults.h: the defaults in config.h, as a secrets.h can replace them, become +// a Settings that follows the same rules as the SD card file, and every setting the card can set has +// one. Built four times by run_tests.sh, with CONFIG_NO_SECRETS (a secrets.h lying around must not +// get in) and a force-included header that plays the part of secrets.h: +// +// (nothing) the factory build, no secrets.h at all +// -DTEST_ALL override_all.h: every macro replaced, with the values of all_settings.h +// -DTEST_BAD override_bad.h: values the card would refuse as well +// -DTEST_EDGE override_edge.h: values on the limits, and the placeholders of secrets.example.h + +#include +#include +#include + +#include + +#include "all_settings.h" +#include "build_defaults.h" +#include "check.h" + +namespace { + +[[maybe_unused]] std::string fmt(const Settings &s, size_t i) { + char buf[128]; + return formatSetting(s, i, buf, sizeof buf) ? buf : ""; +} + +[[maybe_unused]] std::string dump(const Settings &s) { + std::string out; + for (size_t i = 0; i < settingCount(); i++) out += std::string(settingKey(i)) + "=" + fmt(s, i) + "\n"; + return out; +} + +// Says which settings differ, so a failure names them. +[[maybe_unused]] bool sameSettings(const Settings &a, const Settings &b, const char *what) { + bool same = true; + for (size_t i = 0; i < settingCount(); i++) { + if (fmt(a, i) == fmt(b, i)) continue; + printf(" %s: %s is '%s' here but '%s' there\n", what, settingKey(i), fmt(a, i).c_str(), fmt(b, i).c_str()); + same = false; + } + return same; +} + +// `name` as a whole word of `text` (WIFI_SSID is not found in WIFI_SSID_BACKUP). +[[maybe_unused]] bool hasWord(const std::string &text, const std::string &name) { + auto ident = [](char c) { return isalnum((unsigned char)c) || c == '_'; }; + for (size_t at = text.find(name); at != std::string::npos; at = text.find(name, at + 1)) { + const bool startsOk = at == 0 || !ident(text[at - 1]); + const size_t end = at + name.size(); + const bool endsOk = end >= text.size() || !ident(text[end]); + if (startsOk && endsOk) return true; + } + return false; +} + +#if defined(TEST_ALL) + +// A secrets.h that sets everything gives the very settings that the card file of all_settings.h +// gives: the two ways in agree on what each setting means. +void testEverythingReplaced() { + section("build defaults: every macro replaced"); + Settings card; + const std::string text(kAllSettings); + const ConfigReport r = applyConfigText(card, Settings(), text.data(), text.size()); + CHECK(r.problems() == 0); + CHECK(r.changed == (int)settingCount()); // what the rest of this test stands on: the file sets every setting to something new + + BuildReport rep; + const Settings b = buildDefaults(&rep); + CHECK(rep.problems == 0 && rep.kept == 0); + CHECK(b.userSet == 0); // defaults, not choices: the file can still "forget" back to them + CHECK(sameSettings(b, card, "build")); + CHECK(dump(b) == dump(card)); + + // setting by setting, so a setting that has no macro is named (the whole point of this program) + const Settings factory; + for (size_t i = 0; i < settingCount(); i++) { + const bool changed = fmt(b, i) != fmt(factory, i); + if (!changed) printf(" '%s' has no build-time default: nothing in buildDefaults() sets it\n", settingKey(i)); + CHECK(changed); + } +} + +// Each macro is described where people look for it. +void testMacrosDocumented() { + section("build defaults: documented"); + const std::string over = readFile("override_all.h"); + const std::string readme = readFile("../../README.md"); + const std::string example = readFile("../../secrets.example.h"); + const std::string config = readFile("../../config.h"); + int macros = 0; + size_t pos = 0; + while ((pos = over.find("\n#define ", pos)) != std::string::npos) { // (at the start of a line: the comments say "#define" too) + pos += 9; + const std::string name = over.substr(pos, over.find_first_of(" \t\n", pos) - pos); + macros++; + if (readme.find("`" + name + "`") == std::string::npos) printf(" README does not mention `%s`\n", name.c_str()); + CHECK(readme.find("`" + name + "`") != std::string::npos); + if (!hasWord(example, name)) printf(" secrets.example.h does not have %s\n", name.c_str()); + CHECK(hasWord(example, name)); + if (config.find("#ifndef " + name + "\n") == std::string::npos) printf(" config.h does not guard %s with #ifndef\n", name.c_str()); + CHECK(config.find("#ifndef " + name + "\n") != std::string::npos); + } + CHECK(macros >= (int)settingCount()); // at least one macro per setting (a few have more: the backup network) +} + +#elif defined(TEST_BAD) + +void testMistakes() { + section("build defaults: values the card would refuse"); + BuildReport rep; + const Settings b = buildDefaults(&rep); + const Settings factory; + + // the seven bad ones stay as they were ... + CHECK_STR(b.hostname, factory.hostname); + CHECK(b.wifiPowerSave == WIFISAVE_NORMAL); + CHECK(b.timeFormat == TIME_24H); + CHECK_NEAR(b.batteryCutoffV, factory.batteryCutoffV, 1e-6); // 2.0 V would ruin a LiPo + CHECK(b.cpuMhz() == 80); + CHECK(b.cpuIdle == CPUIDLE_OFF && b.cpuIdleMhz() == 0); // 10 MHz is not on offer + CHECK(b.console == CONSOLE_ON); + // ... the good ones beside them count + CHECK(b.dateFormat == DATE_D_MON_Y && !b.showWeek && b.batteryCapacityMah == 1800); + CHECK_NEAR(b.latitude, -31.952240, 1e-6); // coordinates keep their digits: about a metre + CHECK_NEAR(b.longitude, 115.861456, 1e-5); + CHECK(b.userSet == 0); + + // and they are reported, by the name of the macro and with the reason; the first three are kept + CHECK(rep.problems == 7); + CHECK(rep.kept == BuildReport::kKept); + const char *want[] = {"APP_HOSTNAME: ", "WIFI_POWER_SAVE: ", "TIME_FORMAT: "}; + for (int i = 0; i < 3; i++) { + if (strncmp(rep.text[i], want[i], strlen(want[i])) != 0) printf(" report %d is '%s'\n", i, rep.text[i]); + CHECK(strncmp(rep.text[i], want[i], strlen(want[i])) == 0); + CHECK(strlen(rep.text[i]) > strlen(want[i]) + 8); // with a reason after it + } + CHECK(strstr(rep.text[1], "normal") && strstr(rep.text[1], "max")); // says what would do + CHECK(strstr(rep.text[2], "24h") && strstr(rep.text[2], "12h")); + + // no report asked for: same settings, nobody to tell + const Settings quiet = buildDefaults(); + CHECK(dump(quiet) == dump(b)); +} + +#elif defined(TEST_EDGE) + +void testLimits() { + section("build defaults: values on the limits, placeholders"); + BuildReport rep; + const Settings b = buildDefaults(&rep); + if (rep.problems) { + for (int i = 0; i < rep.kept; i++) printf(" refused: %s\n", rep.text[i]); + } + CHECK(rep.problems == 0); + CHECK_NEAR(b.batteryCutoffV, 3.10, 1e-6); // 3.10f must not fall below "3.10 and up" + CHECK_NEAR(b.batteryCalibration, 0.80, 1e-6); + CHECK_NEAR(b.indoorOffsetC, -15.0, 1e-6); + CHECK(b.batteryCapacityMah == 20000 && b.weatherIntervalMin == 5); + CHECK_NEAR(b.latitude, -90.0, 1e-9); + CHECK_NEAR(b.longitude, 180.0, 1e-9); + CHECK(b.cpuMhz() == 240); + + // the names secrets.example.h comes with mean "no network yet", passwords included + CHECK(!b.hasMainWifi() && !b.hasBackupWifi()); + CHECK_STR(b.wifiSsid, ""); + CHECK_STR(b.wifiPassword, ""); + CHECK_STR(b.wifiBackupSsid, ""); + CHECK_STR(b.wifiBackupPassword, ""); +} + +#else + +// The factory build says what Settings() says: the defaults in config.h and the ones that the struct +// is initialised with cannot drift apart (the example file shows the second kind). +void testFactory() { + section("build defaults: the factory build"); + BuildReport rep; + const Settings b = buildDefaults(&rep); + CHECK(rep.problems == 0 && rep.kept == 0); + CHECK(b.userSet == 0); + CHECK(sameSettings(b, Settings(), "build")); + CHECK(dump(b) == dump(Settings())); + + // The name of the power log's copy on an SD card is said in three places: config.h (which the clock uses), the + // help of the power_log setting in the settings file, and the README. + static char example[16384]; + const bool rendered = renderExampleConfig(Settings(), "", example, sizeof example) > 0; + CHECK(rendered && strstr(example, POWER_LOG_FILE_NAME) != nullptr); + CHECK(readFile("../../README.md").find("`" POWER_LOG_FILE_NAME "`") != std::string::npos); + CHECK(rendered && strstr(example, "ESP32-S3-RLCD-PowerLog.txt") == nullptr); // (the check can fail: a name that is nowhere) +} + +// Does the README give `v` as the version: in its first lines, and as the first entry (the newest) of its +// list of versions? +bool readmeHasVersion(const std::string &readme, const std::string &v) { + const size_t top = readme.find("Version **" + v + "**"); + const size_t list = readme.find("\n## Versions\n"); + if (top == std::string::npos || list == std::string::npos || top > list) return false; + const size_t first = readme.find("\n**", list); + return first != std::string::npos && readme.compare(first + 1, v.size() + 4, "**" + v + "**") == 0; +} + +// APP_VERSION goes up by hand, and the README has to go with it: a number changed in one place and not +// the other is caught here. (Nothing can catch a number that was not changed at all.) +void testVersionDocumented() { + section("version: config.h and the README agree"); + const std::string readme = readFile("../../README.md"); + if (!readmeHasVersion(readme, APP_VERSION)) { + printf(" the README does not give %s as the version (at the top, and first under 'Versions')\n", APP_VERSION); + } + CHECK(readmeHasVersion(readme, APP_VERSION)); + // the check can fail: the number in one of the two places only, or not first in the list + const std::string top = "A clock. Version **7.1** (see below).\n", list = "\n## Versions\n\nBy hand.\n\n**7.1** (a day)\n\n* more\n\n**7.0**: less.\n"; + CHECK(readmeHasVersion(top + list, "7.1")); + CHECK(!readmeHasVersion(top + list, "7.0")); + CHECK(!readmeHasVersion(top + list, "7")); + CHECK(!readmeHasVersion(top, "7.1")); + CHECK(!readmeHasVersion(list, "7.1")); + CHECK(!readmeHasVersion("A clock. Version **7.0**.\n" + list, "7.0")); + CHECK(!readmeHasVersion(list + top, "7.1")); +} + +#endif + +} // namespace + +int main() { +#if defined(TEST_ALL) + testEverythingReplaced(); + testMacrosDocumented(); +#elif defined(TEST_BAD) + testMistakes(); +#elif defined(TEST_EDGE) + testLimits(); +#else + testFactory(); + testVersionDocumented(); +#endif + printf("%d checks, %d failed\n", g_checks, g_failed); + return g_failed ? 1 : 0; +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/test_features.cpp b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/test_features.cpp new file mode 100644 index 00000000..0d8005c7 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/test_features.cpp @@ -0,0 +1,4226 @@ +// Host-side tests for the settings layer and the other pure-logic modules added in 1.3 +// (moon phase, date formats, battery estimate, low-battery guard, clock drift). +// Built and run by run_tests.sh next to test_logic.cpp. + +#include +#include +#include + +#include +#include + +#include "all_settings.h" +#include "battery_est.h" +#include "calc.h" +#include "check.h" +#include "datefmt.h" +#include "drift.h" +#include "fw_logic.h" +#include "low_battery.h" +#include "moon.h" +#include "power_log.h" +#include "sd_layout.h" +#include "settings.h" +#include "tz_table.h" +#include "wifi_pick.h" + +// =========================================================================== +// Settings +// =========================================================================== +namespace { + +struct Applied { + Settings s; + ConfigReport r; +}; + +// Applies `text` to `base`, with a pristine Settings as the defaults. +Applied apply(const std::string &text, const Settings &base = Settings()) { + Applied a; + a.s = base; + const Settings defaults; + a.r = applyConfigText(a.s, defaults, text.data(), text.size()); + return a; +} + +std::string utf16(const std::string &ascii, bool bigEndian) { + std::string out = bigEndian ? std::string("\xFE\xFF", 2) : std::string("\xFF\xFE", 2); + for (char c : ascii) { + if (bigEndian) { + out += '\0'; + out += c; + } else { + out += c; + out += '\0'; + } + } + return out; +} + +std::string fmt(const Settings &s, size_t i) { + char buf[128]; + return formatSetting(s, i, buf, sizeof buf) ? buf : ""; +} + +// The table position of a setting, by the name it has in the file (the tests must not depend on the order). +size_t idx(const char *key) { + for (size_t i = 0; i < settingCount(); i++) + if (!strcmp(settingKey(i), key)) return i; + return (size_t)-1; +} + +// Every field of every setting as text, to compare two Settings. +std::string dump(const Settings &s) { + std::string out; + for (size_t i = 0; i < settingCount(); i++) { + out += settingKey(i); + out += '='; + out += fmt(s, i); + out += '\n'; + } + return out; +} + +// (kAllSettings, a file that sets every setting to something other than its default, is in all_settings.h) + +void testSettingsTable() { + section("settings table"); + const size_t n = settingCount(); + CHECK(n == 32); + CHECK(n <= 32); // userSet is a 32 bit mask, and with 32 settings it is full + CHECK_STR(settingKey(n), ""); + CHECK_STR(settingNvsKey(n), ""); + for (size_t i = 0; i < n; i++) { + CHECK(strlen(settingKey(i)) > 0); + CHECK(strlen(settingNvsKey(i)) > 0 && strlen(settingNvsKey(i)) <= 15); // NVS key limit + for (size_t j = i + 1; j < n; j++) { + CHECK(strcmp(settingKey(i), settingKey(j)) != 0); + CHECK(strcmp(settingNvsKey(i), settingNvsKey(j)) != 0); + } + } + int secrets = 0; + for (size_t i = 0; i < n; i++) secrets += settingIsSecret(i); + CHECK(secrets == 3); + CHECK(settingIsSecret(2) && !strcmp(settingKey(2), "wifi_password")); + CHECK(!settingIsSecret(3) && !strcmp(settingKey(3), "wifi_backup_ssid")); + CHECK(settingIsSecret(4) && !strcmp(settingKey(4), "wifi_backup_password")); + CHECK(settingIsSecret(idx("spotify_client_id"))); + + // defaults a clean build ships with + const Settings d; + CHECK(d.wifi && d.units == UNITS_METRIC && d.timeFormat == TIME_24H && d.dateFormat == DATE_ISO); + CHECK(d.cpuMhz() == 80 && d.lowBatteryShutdown && d.hasBattery() && !d.hasLatLon()); + CHECK_NEAR(d.batteryCutoffV, 3.30, 1e-6); + CHECK(d.userSet == 0); +} + +void testSettingsRoundTrip() { + section("settings flash round trip"); + const Settings d; + for (size_t i = 0; i < settingCount(); i++) { + Settings t; + CHECK(applyStoredSetting(t, i, fmt(d, i).c_str())); + CHECK(settingIsUserSet(t, i)); + CHECK(fmt(t, i) == fmt(d, i)); + } + + const Applied all = apply(kAllSettings); + CHECK(all.r.problems() == 0); + CHECK(all.r.changed == (int)settingCount()); + CHECK(all.s.userSet == (uint32_t)((1ull << settingCount()) - 1)); + Settings t; + for (size_t i = 0; i < settingCount(); i++) CHECK(applyStoredSetting(t, i, fmt(all.s, i).c_str())); + CHECK(dump(t) == dump(all.s)); + CHECK(t.userSet == all.s.userSet); + + // stored text that no longer validates is ignored, and leaves the setting alone + Settings u; + CHECK(!applyStoredSetting(u, 0, "maybe")); + CHECK(!applyStoredSetting(u, idx("latitude"), "500")); // out of range + CHECK(!applyStoredSetting(u, idx("units"), "kelvin")); + CHECK(!applyStoredSetting(u, idx("units"), "")); // an empty number or enum is no value + CHECK(!applyStoredSetting(u, 999, "x")); + CHECK(!applyStoredSetting(u, 0, nullptr)); + CHECK(u.userSet == 0 && u.wifi && u.units == UNITS_METRIC); + // but an empty text setting is a real choice (an open network, for one) + Settings v; + snprintf(v.wifiPassword, sizeof v.wifiPassword, "old"); + CHECK(applyStoredSetting(v, 2, "")); + CHECK_STR(v.wifiPassword, ""); + CHECK(settingIsUserSet(v, 2)); + // precision survives: coordinates keep 5 decimals, about a metre + Settings w; + w.latitude = -31.952240; + w.longitude = 115.861456; + Settings x; + CHECK(applyStoredSetting(x, idx("latitude"), fmt(w, idx("latitude")).c_str())); + CHECK(applyStoredSetting(x, idx("longitude"), fmt(w, idx("longitude")).c_str())); + CHECK_NEAR(x.latitude, -31.952240, 1e-6); + CHECK_NEAR(x.longitude, 115.861456, 1e-5); +} + +void testSettingsSyntax() { + section("settings file: what people type"); + // all of these say the same thing + const char *variants[] = { + "wifi_ssid = My Network", + "wifi_ssid=My Network", + "wifi_ssid = My Network ", + "\twifi_ssid\t=\tMy Network\t", + "wifi_ssid: My Network", + "wifi_ssid = \"My Network\"", + "wifi_ssid = 'My Network'", + "wifi_ssid=\"My Network\"", + "WIFI_SSID = My Network", + "WiFi SSID = My Network", + "Wi-Fi-SSID = \"My Network\"", + "wifi-ssid = My Network", + "wifissid = My Network", + "ssid = My Network", + "SSID: \"My Network\"", + "network = My Network", + "wifi_ssid = \xE2\x80\x9CMy Network\xE2\x80\x9D", // curly double quotes + "wifi_ssid = \xE2\x80\x98My Network\xE2\x80\x99", // curly single quotes + "wifi_ssid = \xC2\xABMy Network\xC2\xBB", // guillemets + "wifi_ssid = \"My Network\" # my home", + "wifi_ssid = My Network # my home", + "wifi_ssid = My Network ;", // no: ';' is no comment for bare values; checked below + }; + for (size_t i = 0; i + 1 < sizeof variants / sizeof variants[0]; i++) { + const Applied a = apply(variants[i]); + if (strcmp(a.s.wifiSsid, "My Network") != 0) printf(" variant %zu: [%s] gave [%s]\n", i, variants[i], a.s.wifiSsid); + CHECK_STR(a.s.wifiSsid, "My Network"); + CHECK(a.r.problems() == 0 && a.r.changed == 1); + CHECK(settingIsUserSet(a.s, 1)); + } + CHECK_STR(apply(variants[sizeof variants / sizeof variants[0] - 1]).s.wifiSsid, "My Network ;"); + + // quotes and the characters inside them + struct Case { + const char *line; + const char *ssid; + } cases[] = { + {"wifi_ssid = \"Bob's WiFi\"", "Bob's WiFi"}, + {"wifi_ssid = Bob's WiFi", "Bob's WiFi"}, + {"wifi_ssid = 'Bob\"s WiFi'", "Bob\"s WiFi"}, + {"wifi_ssid = \"Home\" # it's the \"good\" one", "Home"}, + {"wifi_ssid = \"Home\" ; the good one", "Home"}, + {"wifi_ssid = \"My \"Network\"\"", "My \"Network\""}, + {"wifi_ssid = \"Home#1\"", "Home#1"}, + {"wifi_ssid = Home#1", "Home#1"}, + {"wifi_ssid = Home #1", "Home"}, + {"wifi_ssid = \" padded \"", " padded "}, + {"wifi_ssid = \"unterminated", "unterminated"}, + {"wifi_ssid = 'tis", "'tis"}, + {"wifi_ssid = \"Caf\xC3\xA9 Net\"", "Caf\xC3\xA9 Net"}, + {"wifi_ssid = a=b:c", "a=b:c"}, + {"wifi_ssid : a=b:c", "a=b:c"}, + {"wifi_ssid = 1234 # 5678", "1234"}, + {"wifi_ssid = \"x\"y\"", "x\"y"}, + }; + for (const Case &c : cases) { + const Applied a = apply(c.line); + if (strcmp(a.s.wifiSsid, c.ssid) != 0) printf(" [%s] gave [%s], wanted [%s]\n", c.line, a.s.wifiSsid, c.ssid); + CHECK_STR(a.s.wifiSsid, c.ssid); + CHECK(a.r.problems() == 0); + } + + // the password: bare, spaces inside, empty on purpose, forgotten + CHECK_STR(apply("wifi_password = p@ss w0rd!").s.wifiPassword, "p@ss w0rd!"); + CHECK_STR(apply("password = \"p@ss w0rd!\"").s.wifiPassword, "p@ss w0rd!"); + CHECK_STR(apply("wifi_password = Pa$$ #1 word").s.wifiPassword, "Pa$$"); // a bare ' #' starts a comment + CHECK_STR(apply("wifi_password = \"Pa$$ #1 word\"").s.wifiPassword, "Pa$$ #1 word"); + Settings withPassword; + snprintf(withPassword.wifiPassword, sizeof withPassword.wifiPassword, "old"); + withPassword.userSet |= 1u << 2; + Applied open = apply("wifi_password = \"\"", withPassword); + CHECK_STR(open.s.wifiPassword, ""); + CHECK(settingIsUserSet(open.s, 2) && open.r.changed == 1); + Applied open2 = apply("wifi_password = ''", withPassword); + CHECK_STR(open2.s.wifiPassword, ""); + Applied forgot = apply("wifi_password =", withPassword); + CHECK_STR(forgot.s.wifiPassword, ""); // the default is no password + CHECK(!settingIsUserSet(forgot.s, 2) && forgot.r.cleared == 1 && forgot.r.changed == 0); + + // line ends and encodings + const std::string two = "wifi_ssid = Net\nwifi_password = pw\n"; + const char *ends[] = {"\n", "\r\n", "\r"}; + for (const char *e : ends) { + std::string t = std::string("wifi_ssid = Net") + e + "wifi_password = pw" + e; + Applied a = apply(t); + CHECK_STR(a.s.wifiSsid, "Net"); + CHECK_STR(a.s.wifiPassword, "pw"); + CHECK(a.r.lines == 2 && a.r.changed == 2); + } + Applied noEnd = apply("wifi_ssid = Net\nwifi_password = pw"); + CHECK_STR(noEnd.s.wifiPassword, "pw"); + CHECK(noEnd.r.lines == 2); + Applied bom = apply(std::string("\xEF\xBB\xBF") + two); + CHECK_STR(bom.s.wifiSsid, "Net"); + CHECK(bom.r.problems() == 0); + Applied le = apply(utf16(two, false)); + CHECK_STR(le.s.wifiSsid, "Net"); + CHECK_STR(le.s.wifiPassword, "pw"); + CHECK(le.r.problems() == 0); + Applied be = apply(utf16(two, true)); + CHECK_STR(be.s.wifiSsid, "Net"); + CHECK_STR(be.s.wifiPassword, "pw"); + Applied le16crlf = apply(utf16("wifi_ssid = Net\r\nunits = imperial\r\n", false)); + CHECK_STR(le16crlf.s.wifiSsid, "Net"); + CHECK(le16crlf.s.imperial()); + CHECK(apply("").r.lines == 0 && apply("").r.touched() == 0); + CHECK(apply("\n\n\n").r.lines == 3 && apply("\n\n\n").r.problems() == 0); + CHECK(apply("# only a comment\n; and another\n// and a third\n[wifi]\n").r.problems() == 0); + + // comments and section headings are skipped + Applied commented = apply("# wifi_ssid = Nope\n; units = imperial\n// time_format = 12h\n[Units]\nunits = imperial\n"); + CHECK_STR(commented.s.wifiSsid, ""); + CHECK(commented.s.imperial() && commented.s.time12h() == false); + CHECK(commented.r.changed == 1 && commented.r.problems() == 0); + // indented comments, headings and blank lines too + const Applied indented = apply(" # wifi_ssid = Nope\n\t; units = imperial\n [Units] \n \n\t\n"); + CHECK(indented.r.problems() == 0 && indented.r.touched() == 0 && indented.r.lines == 5); + CHECK_STR(indented.s.wifiSsid, ""); +} + +void testSettingsValues() { + section("settings file: values"); + // booleans + const char *yes[] = {"on", "ON", "Yes", "true", "1", "enabled", "y", "\"on\""}; + const char *no[] = {"off", "OFF", "No", "false", "0", "disabled", "n", "'off'"}; + for (const char *v : yes) CHECK(apply(std::string("show_week = ") + v, [] { Settings s; s.showWeek = false; return s; }()).s.showWeek); + for (const char *v : no) CHECK(!apply(std::string("wifi = ") + v).s.wifi); + + // enums and their spellings + CHECK(apply("units = imperial").s.imperial()); + CHECK(apply("units = Fahrenheit").s.imperial()); + CHECK(apply("units = F").s.imperial()); + CHECK(apply("temperature units = US").s.imperial()); + CHECK(!apply("units = metric", apply("units = imperial").s).s.imperial()); + CHECK(!apply("units = celsius", apply("units = imperial").s).s.imperial()); + CHECK(apply("time_format = 12h").s.time12h()); + CHECK(apply("time format = 12").s.time12h()); + CHECK(apply("hour format = 12 hour").s.time12h()); + CHECK(apply("time_format = AM/PM").s.time12h()); + CHECK(!apply("time_format = 24h", apply("time_format = 12h").s).s.time12h()); + struct { const char *text; int fmt; } dates[] = { + {"iso", DATE_ISO}, {"YYYY-MM-DD", DATE_ISO}, {"dmy", DATE_DMY}, {"DD/MM/YYYY", DATE_DMY}, + {"mdy", DATE_MDY}, {"mm/dd/yyyy", DATE_MDY}, {"dmy-dot", DATE_DMY_DOT}, {"dd.mm.yyyy", DATE_DMY_DOT}, + {"d-mon-y", DATE_D_MON_Y}, {"D Mon YYYY", DATE_D_MON_Y}, {"mon-d-y", DATE_MON_D_Y}, {"Mon D, YYYY", DATE_MON_D_Y}, + {"\"dmy\"", DATE_DMY}}; + for (const auto &d : dates) CHECK(apply(std::string("date_format = ") + d.text).s.dateFormat == d.fmt); + CHECK(apply("battery = none").s.battery == BATTERY_NONE && !apply("battery = none").s.hasBattery()); + CHECK(apply("battery = no").s.battery == BATTERY_NONE); + CHECK(apply("battery = USB").s.battery == BATTERY_NONE); + CHECK(apply("battery = auto", apply("battery = none").s).s.battery == BATTERY_AUTO); + CHECK(apply("cpu_mhz = 240").s.cpuMhz() == 240); + CHECK(apply("cpu_mhz = 160 MHz").s.cpuMhz() == 160); + CHECK(apply("CPU = 80").s.cpuMhz() == 80); + CHECK(apply("wifi_power_save = normal").s.wifiPowerSave == WIFISAVE_NORMAL); + CHECK(apply("wifi_power_save = max", apply("wifi_power_save = normal").s).s.wifiPowerSave == WIFISAVE_MAX); + + // numbers + CHECK_NEAR(apply("indoor_offset = -3.5").s.indoorOffsetC, -3.5, 1e-6); + CHECK_NEAR(apply("indoor offset = -3,5 C").s.indoorOffsetC, -3.5, 1e-6); + CHECK_NEAR(apply("indoor_offset = 0").s.indoorOffsetC, 0, 1e-6); + CHECK_NEAR(apply("indoor_offset = \"-2\"").s.indoorOffsetC, -2, 1e-6); + CHECK_NEAR(apply("indoor_offset = -4.0 \xC2\xB0" "C").s.indoorOffsetC, -4, 1e-6); + CHECK_NEAR(apply("battery_cutoff_v = 3.4 V").s.batteryCutoffV, 3.4, 1e-6); + CHECK_NEAR(apply("battery_cutoff_v = 3,45").s.batteryCutoffV, 3.45, 1e-6); + CHECK(apply("weather_interval_min = 30 min").s.weatherIntervalMin == 30); + CHECK(apply("weather_interval_min = 30.4").s.weatherIntervalMin == 30); + CHECK(apply("battery_capacity_mah = 2500 mAh").s.batteryCapacityMah == 2500); + CHECK(apply("battery_capacity_mah = 0").s.batteryCapacityMah == 0); + + // coordinates + CHECK_NEAR(apply("latitude = -31.952").s.latitude, -31.952, 1e-9); + CHECK_NEAR(apply("latitude = 31.952 S").s.latitude, -31.952, 1e-9); + CHECK_NEAR(apply("latitude = -31.952 S").s.latitude, -31.952, 1e-9); + CHECK_NEAR(apply("latitude = 31.952s").s.latitude, -31.952, 1e-9); + CHECK_NEAR(apply("latitude = 31.952 N").s.latitude, 31.952, 1e-9); + CHECK_NEAR(apply("latitude = 31.952\xC2\xB0 S").s.latitude, -31.952, 1e-9); + CHECK_NEAR(apply("lat = -31,952").s.latitude, -31.952, 1e-9); + CHECK_NEAR(apply("longitude = 115.861 E").s.longitude, 115.861, 1e-9); + CHECK_NEAR(apply("longitude = 115.861").s.longitude, 115.861, 1e-9); + CHECK_NEAR(apply("lon = 74.006 W").s.longitude, -74.006, 1e-9); + CHECK_NEAR(apply("Long = 74.006 w").s.longitude, -74.006, 1e-9); + CHECK_NEAR(apply("lng = -180").s.longitude, -180, 1e-9); + CHECK(apply("latitude = -31.952\nlongitude = 115.861").s.hasLatLon()); + + // strings + CHECK_STR(apply("hostname = my-clock").s.hostname, "my-clock"); + CHECK_STR(apply("ntp_server = time.google.com").s.ntpServer, "time.google.com"); + CHECK_STR(apply("location = \"Paris, France\"").s.location, "Paris, France"); + CHECK_STR(apply("location = Paris, France").s.location, "Paris, France"); + CHECK_STR(apply("city = Perth").s.location, "Perth"); + CHECK_STR(apply("location_label = Home sweet home").s.locationLabel, "Home sweet home"); + CHECK_STR(apply("label = \"\"").s.locationLabel, ""); + CHECK_STR(apply("spotify_client_id = 0123456789abcdef0123456789abcdef").s.spotifyClientId, + "0123456789abcdef0123456789abcdef"); + CHECK_STR(apply("client id = \"0123456789abcdef0123456789abcdef\"").s.spotifyClientId, + "0123456789abcdef0123456789abcdef"); +} + +void testSettingsRejects() { + section("settings file: bad values change nothing"); + struct { const char *line; } bad[] = { + {"wifi = maybe"}, {"wifi = 2"}, {"show_week = sometimes"}, + {"units = kelvin"}, {"units = 5"}, {"time_format = 36h"}, + {"date_format = yyyy"}, {"battery = lots"}, {"cpu_mhz = 100"}, + {"cpu_mhz = fast"}, {"wifi_power_save = loud"}, {"latitude = 95"}, + {"latitude = -91"}, {"latitude = north"}, {"latitude = 33 E"}, + {"longitude = 181"}, {"longitude = 100 N"}, {"longitude = abc"}, + {"latitude = nan"}, {"latitude = inf"}, {"latitude = 3.3.0"}, + {"indoor_offset = 20"}, {"indoor_offset = -16"}, {"indoor_offset = warm"}, + {"battery_cutoff_v = 2.5"}, {"battery_cutoff_v = 3.7"}, {"battery_cutoff_v = 33"}, + {"battery_capacity_mah = -5"}, {"battery_capacity_mah = 99999"}, + {"weather_interval_min = 1"}, {"weather_interval_min = 1000"}, {"weather_interval_min = soon"}, + {"hostname = my clock"}, {"hostname = -clock"}, {"hostname = clock-"}, + {"hostname = cl_ock"}, {"hostname = clock.local"}, {"ntp_server = http://x"}, + {"ntp_server = bad host"}, {"timezone = Perth"}, {"timezone = Mars/Olympus"}, + {"timezone = 12345"}, {"spotify_client_id = short"}, {"spotify_client_id = 0123456789abcdef0123456789abcde!"}, + {"spotify_client_id = 0123456789abcdef 0123456789abcdef"}, + }; + for (const auto &b : bad) { + const Applied a = apply(b.line); + if (!(a.r.bad == 1 && a.r.changed == 0)) printf(" accepted [%s]\n", b.line); + CHECK(a.r.bad == 1 && a.r.unknown == 0 && a.r.changed == 0 && a.r.unchanged == 0); + CHECK(a.s.userSet == 0); + CHECK(dump(a.s) == dump(Settings())); + CHECK(a.r.issueCount == 1 && a.r.issues[0].line == 1); + } + // too long + const std::string longSsid(33, 'x'); + CHECK(apply("wifi_ssid = " + longSsid).r.bad == 1); + CHECK(apply("wifi_ssid = " + std::string(32, 'x')).r.bad == 0); + CHECK(apply("wifi_password = " + std::string(65, 'x')).r.bad == 1); + CHECK(apply("wifi_password = " + std::string(64, 'x')).r.bad == 0); + CHECK(apply("location_label = " + std::string(40, 'x')).r.bad == 1); + CHECK(apply("wifi_ssid = tab\there").r.bad == 1); // control character + // a rejected value leaves what was there + Settings prev = apply("wifi_ssid = Keep\nunits = imperial\nlatitude = -31.952").s; + Applied rej = apply("wifi_ssid = " + longSsid + "\nunits = kelvin\nlatitude = 500", prev); + CHECK(rej.r.bad == 3 && rej.r.changed == 0); + CHECK(dump(rej.s) == dump(prev) && rej.s.userSet == prev.userSet); +} + +void testSettingsTimezone() { + section("settings file: time zone"); + CHECK_STR(apply("timezone = Australia/Perth").s.timezone, "Australia/Perth"); + CHECK_STR(apply("timezone = australia/perth").s.timezone, "Australia/Perth"); + CHECK_STR(apply("timezone = AUSTRALIA/PERTH").s.timezone, "Australia/Perth"); + CHECK_STR(apply("timezone = \"America/New York\"").s.timezone, "America/New_York"); + CHECK_STR(apply("tz = Europe/London").s.timezone, "Europe/London"); + CHECK_STR(apply("time zone = UTC").s.timezone, "UTC"); + CHECK_STR(apply("timezone = AWST-8").s.timezone, "AWST-8"); + CHECK_STR(apply("timezone = <+0530>-5:30").s.timezone, "<+0530>-5:30"); + CHECK_STR(apply("timezone = CET-1CEST,M3.5.0,M10.5.0/3").s.timezone, "CET-1CEST,M3.5.0,M10.5.0/3"); + CHECK(apply("timezone = Australia/Perth").r.changed == 1); + CHECK_STR(apply("timezone = \"\"", apply("timezone = AWST-8").s).s.timezone, ""); // empty on purpose = follow location + CHECK(settingIsUserSet(apply("timezone = \"\"").s, idx("timezone"))); + CHECK_STR(apply("timezone =", apply("timezone = AWST-8").s).s.timezone, ""); // forgotten = the default + CHECK(!settingIsUserSet(apply("timezone =", apply("timezone = AWST-8").s).s, idx("timezone"))); + // what the firmware does with it afterwards + const Applied perth = apply("timezone = australia/perth"); + CHECK_STR(tzPosixForIana(perth.s.timezone), "AWST-8"); + char canon[48]; + CHECK_STR(tzFindIanaIgnoreCase("america/new york", canon, sizeof canon), "EST5EDT,M3.2.0,M11.1.0"); + CHECK_STR(canon, "America/New_York"); + CHECK(tzFindIanaIgnoreCase("Nowhere/Land", canon, sizeof canon) == nullptr); + CHECK(tzFindIanaIgnoreCase("", canon, sizeof canon) == nullptr); + CHECK(tzFindIanaIgnoreCase(nullptr, canon, sizeof canon) == nullptr); + CHECK(tzFindIanaIgnoreCase("Australia/Perth", nullptr, 0) != nullptr); + CHECK(tzFindIanaIgnoreCase("Australia/Per", canon, sizeof canon) == nullptr); // prefix is not a match + CHECK(tzFindIanaIgnoreCase("Australia/Perth2", canon, sizeof canon) == nullptr); +} + +void testSettingsReport() { + section("settings file: report"); + const char *file = + "# comment\n" + "wifi = maybe\n" + "unknown_key = 1\n" + "wifi_ssid = Home\n" + "this line has no equals\n" + "latitude = 95\n" + "units = imperial\n" + "units = imperial\n"; + const Applied a = apply(file); + CHECK(a.r.lines == 8); + CHECK(a.r.changed == 2 && a.r.unchanged == 1); // the second "units" repeats the first + CHECK(a.r.unknown == 1 && a.r.bad == 3 && a.r.problems() == 4); + CHECK(a.r.issueCount == 4); + CHECK(a.r.issues[0].line == 2 && strstr(a.r.issues[0].text, "line 2") && strstr(a.r.issues[0].text, "wifi")); + CHECK(a.r.issues[1].line == 3 && strstr(a.r.issues[1].text, "unknown") && strstr(a.r.issues[1].text, "unknown_key")); + CHECK(a.r.issues[2].line == 5 && strstr(a.r.issues[2].text, "line 5")); + CHECK(a.r.issues[3].line == 6 && strstr(a.r.issues[3].text, "latitude")); + CHECK_STR(a.s.wifiSsid, "Home"); + CHECK(a.s.imperial() && !a.s.hasLatLon() && a.s.wifi); + char sum[96]; + formatConfigSummary(a.r, sum, sizeof sum); + CHECK_STR(sum, "2 changed, 1 unchanged, 4 problems"); + formatConfigSummary(apply("units = imperial").r, sum, sizeof sum); + CHECK_STR(sum, "1 changed"); + formatConfigSummary(apply("wifi = maybe").r, sum, sizeof sum); + CHECK_STR(sum, "1 problem"); + formatConfigSummary(apply("# nothing").r, sum, sizeof sum); + CHECK_STR(sum, "nothing to apply"); + formatConfigSummary(apply("reset_all = yes\nunits = imperial").r, sum, sizeof sum); + CHECK_STR(sum, "all forgotten, 1 changed"); + char tiny[8]; + formatConfigSummary(a.r, tiny, sizeof tiny); // must stay inside the buffer + CHECK(strlen(tiny) < sizeof tiny); + + // only the first few problems are kept, but all are counted + std::string many; + for (int i = 0; i < 20; i++) many += "wifi = maybe\n"; + const Applied m = apply(many); + CHECK(m.r.bad == 20 && m.r.issueCount == kConfigMaxIssues && m.r.lines == 20); + CHECK(m.r.issues[kConfigMaxIssues - 1].line == kConfigMaxIssues); + + // a line that is far too long is reported, not parsed + const Applied l = apply("wifi_ssid = " + std::string(500, 'x') + "\nunits = imperial\n"); + CHECK(l.r.bad == 1 && l.r.changed == 1 && l.s.imperial() && l.s.wifiSsid[0] == 0); + CHECK(strstr(l.r.issues[0].text, "too long")); + // text that is no settings file at all + static const char junkText[] = "\x01\x02\xFF\xFE garbage \x80\x81\n\0\0\0=\n"; + const Applied junk = apply(std::string(junkText, sizeof junkText - 1)); + CHECK(junk.r.touched() == 0 && junk.r.problems() >= 1); + static const char nulText[] = "wifi_ssid = a\0b\n"; // a NUL byte inside a value is replaced, not a terminator + CHECK_STR(apply(std::string(nulText, sizeof nulText - 1)).s.wifiSsid, "a?b"); +} + +// The banner after a start that read a settings file, and after one that could not say what it had done. +void testSdBanner() { + section("settings file: the banner"); + char b[40]; + bool warn = false; + auto banner = [&](const ConfigReport &r, bool saved, int carried, bool justInstalled) { + warn = formatSdBanner(r, saved, carried, justInstalled, b, sizeof b); + return std::string(b); + }; + + // As it went on the board: a card with four settings and a firmware file. The start that read it changed + // the four, saved them, installed the firmware and restarted before its banner; the next start read the + // same file, found nothing to change, and said "nothing new" of settings the owner had just added. + const std::string file = "wifi_mode = sync\nspotify_live = off\ncpu_idle_mhz = 20\nconsole = auto\n"; + const Applied first = apply(file); + CHECK(first.r.changed == 4 && first.r.touched() == 4 && first.r.unchanged == 0 && first.r.problems() == 0); + CHECK(banner(first.r, true, 0, false) == "SD settings: 4 changed" && !warn); // (what was never shown) + const Applied second = apply(file, first.s); + CHECK(second.r.touched() == 0 && second.r.unchanged == 4 && second.r.problems() == 0); + CHECK(dump(second.s) == dump(first.s)); + // with the count the first start left behind, the second says what the card did + CHECK(banner(second.r, false, first.r.touched(), true) == "SD settings: 4 changed" && !warn); + // without one (the firmware that ran first did not keep a count yet) it says what it knows, and no more + CHECK(banner(second.r, false, 0, true) == "SD settings: 4 in force" && !warn); + // an ordinary restart with the card left in + CHECK(banner(second.r, false, 0, false) == "SD settings: 4 in force, none new" && !warn); + // (nothing was saved at such a start because nothing changed: that is not "NOT saved") + CHECK(banner(second.r, false, 0, false).find("NOT") == std::string::npos); + + // the new firmware changes one more (a setting the old one did not know): the counts add up + const Applied third = apply("units = imperial\n" + file, first.s); + CHECK(third.r.changed == 1 && third.r.unchanged == 4); + CHECK(banner(third.r, true, 3, true) == "SD settings: 4 changed" && !warn); + CHECK(banner(third.r, true, 0, false) == "SD settings: 1 changed" && !warn); + // flash would not take them: a warning, and only what this start changed (what was carried had been saved) + CHECK(banner(third.r, false, 3, false) == "1 changed, NOT saved to flash" && warn); + + // problems are a warning, whatever else there is + const Applied bad = apply("wifi = maybe\n" + file, first.s); + CHECK(bad.r.problems() == 1 && bad.r.touched() == 0); + CHECK(banner(bad.r, false, 0, false) == "SD: 0 changed, 1 problem" && warn); + CHECK(banner(bad.r, false, 4, true) == "SD: 4 changed, 1 problem" && warn); + CHECK(banner(apply("wifi = maybe\ncolour = red\nunits = imperial\n").r, true, 0, false) == "SD: 1 changed, 2 problems" && warn); + + // a file that is all comments, as the clock writes it + const Applied comments = apply("# units = metric\r\n\r\n# wifi = on\r\n"); + CHECK(comments.r.touched() == 0 && comments.r.unchanged == 0 && comments.r.problems() == 0); + CHECK(banner(comments.r, false, 0, false) == "SD settings: the file sets nothing" && !warn); + CHECK(banner(comments.r, false, 0, true) == "SD settings: the file sets nothing" && !warn); + CHECK(banner(comments.r, false, 2, true) == "SD settings: 2 changed" && !warn); // (the card was swapped: still said) + // a setting that is forgotten, and reset_all, are changes + const Applied forgot = apply("units =\n", third.s); + CHECK(forgot.r.cleared == 1 && forgot.r.changed == 0); + CHECK(banner(forgot.r, true, 0, false) == "SD settings: 1 changed" && !warn); + CHECK(banner(apply("reset_all = yes\n", third.s).r, true, 0, false) == "SD settings: 1 changed" && !warn); + // no card at this start (an empty report): what the last start changed is still said + CHECK(banner(ConfigReport(), true, 4, false) == "SD settings: 4 changed" && !warn); + // a count that makes no sense is no count + CHECK(banner(second.r, false, -3, false) == "SD settings: 4 in force, none new" && !warn); + CHECK(banner(third.r, true, -3, false) == "SD settings: 1 changed" && !warn); + + // every text fits a banner of 39 characters, with counts beyond what a file of 32 KB can reach + int texts = 0, tooLong = 0; + for (int changed : {0, 1, 9999}) + for (int unchanged : {0, 1, 9999}) + for (int problems : {0, 1, 9999}) + for (int carried : {0, 9999}) + for (int flags = 0; flags < 4; flags++) { + ConfigReport r; + r.changed = changed; + r.unchanged = unchanged; + r.unknown = problems; + const std::string text = banner(r, (flags & 1) != 0, carried, (flags & 2) != 0); + texts++; + if (text.empty() || text.size() > 39) tooLong++; + } + CHECK(texts == 216 && tooLong == 0); + ConfigReport big; + big.changed = big.unchanged = big.unknown = 9999; + char tiny[8]; + volatile size_t tinyCap = sizeof tiny; // (volatile: the compiler would otherwise warn that this text is cut, which is the point) + formatSdBanner(big, true, 9999, false, tiny, tinyCap); // must stay inside the buffer + CHECK(strlen(tiny) < sizeof tiny); + + // the count that is carried lives in flash next to the settings: its name is no setting's, and fits (15 characters) + CHECK(strlen(kSdNoteNvsKey) >= 1 && strlen(kSdNoteNvsKey) <= 15); + int clashes = 0; + for (size_t i = 0; i < settingCount(); i++) clashes += strcmp(settingNvsKey(i), kSdNoteNvsKey) == 0; + CHECK(clashes == 0); +} + +void testSettingsForget() { + section("settings file: forgetting and reset_all"); + Settings defaults; + snprintf(defaults.hostname, sizeof defaults.hostname, "factory-name"); + defaults.latitude = -31.952; + defaults.longitude = 115.861; + // a clock with a few saved settings + Settings s = defaults; + const std::string saved = "hostname = mine\nunits = imperial\nlatitude = 10\nwifi_ssid = Home\n"; + ConfigReport r0 = applyConfigText(s, defaults, saved.data(), saved.size()); + CHECK(r0.changed == 4 && s.userSet != 0); + CHECK_STR(s.hostname, "mine"); + + // an empty value forgets one setting: the build's default is back + const std::string forget = "hostname =\nlatitude = \n"; + Settings s1 = s; + const ConfigReport r1 = applyConfigText(s1, defaults, forget.data(), forget.size()); + CHECK(r1.cleared == 2 && r1.changed == 0 && r1.problems() == 0); + CHECK_STR(s1.hostname, "factory-name"); + CHECK_NEAR(s1.latitude, -31.952, 1e-9); + CHECK(!settingIsUserSet(s1, idx("hostname")) && !settingIsUserSet(s1, idx("latitude"))); + CHECK(settingIsUserSet(s1, idx("units")) && settingIsUserSet(s1, idx("wifi_ssid"))); // the others stay + CHECK(s1.imperial() && !strcmp(s1.wifiSsid, "Home")); + + // forgetting something that was never saved is not an error + Settings s2 = defaults; + const ConfigReport r2 = applyConfigText(s2, defaults, forget.data(), forget.size()); + CHECK(r2.cleared == 0 && r2.unchanged == 2 && r2.problems() == 0 && r2.touched() == 0); + + // reset_all forgets everything first, even when it comes last in the file + const std::string reset = "reset_all = yes\n"; + Settings s3 = s; + const ConfigReport r3 = applyConfigText(s3, defaults, reset.data(), reset.size()); + CHECK(r3.resetAll && r3.touched() == 1 && r3.problems() == 0); + CHECK(s3.userSet == 0 && dump(s3) == dump(defaults)); + const std::string reset2 = "units = metric\nreset_all = yes\nspotify = off\n"; + Settings s4 = s; + const ConfigReport r4 = applyConfigText(s4, defaults, reset2.data(), reset2.size()); + CHECK(r4.resetAll && r4.problems() == 0); + CHECK(!s4.imperial() && !s4.spotify); // lines apply on top of the reset + CHECK_STR(s4.hostname, "factory-name"); // saved values are gone + CHECK(settingIsUserSet(s4, idx("units")) && settingIsUserSet(s4, idx("spotify"))); // units and spotify are saved again + CHECK(!settingIsUserSet(s4, idx("hostname")) && !settingIsUserSet(s4, idx("wifi_ssid"))); + CHECK(r4.changed == 1 && r4.unchanged == 1); // metric is the default again after the reset; spotify=off is new + for (const char *spelling : {"reset_all = on", "Reset All = TRUE", "reset-all: 1", "reset_all = \"yes\""}) { + Settings t = s; + const ConfigReport rr = applyConfigText(t, defaults, spelling, strlen(spelling)); + CHECK(rr.resetAll && t.userSet == 0); + } + for (const char *no : {"reset_all = no", "reset_all = off", "# reset_all = yes"}) { + Settings t = s; + const ConfigReport rr = applyConfigText(t, defaults, no, strlen(no)); + CHECK(!rr.resetAll && t.userSet == s.userSet && rr.problems() == 0); + } + Settings t5 = s; + const std::string badReset = "reset_all = please\n"; + const ConfigReport r5 = applyConfigText(t5, defaults, badReset.data(), badReset.size()); + CHECK(!r5.resetAll && r5.bad == 1 && t5.userSet == s.userSet); + + // single settings, the API a web page or a button handler could use + Settings t6 = defaults; + char err[96]; + CHECK(applySetting(t6, defaults, "units", "Imperial", err, sizeof err) == ApplyResult::Changed); + CHECK(applySetting(t6, defaults, "units", "imperial", err, sizeof err) == ApplyResult::Unchanged); + CHECK(applySetting(t6, defaults, "units", "kelvin", err, sizeof err) == ApplyResult::BadValue && strstr(err, "units")); + CHECK(applySetting(t6, defaults, "colour", "red", err, sizeof err) == ApplyResult::UnknownKey && strstr(err, "colour")); + CHECK(applySetting(t6, defaults, "units", "", err, sizeof err) == ApplyResult::Cleared); + CHECK(!t6.imperial() && t6.userSet == 0); + CHECK(applySetting(t6, defaults, "wifi_ssid", "\"Quoted Net\"") == ApplyResult::Changed && !strcmp(t6.wifiSsid, "Quoted Net")); + CHECK(applySetting(t6, defaults, "wifi_ssid", nullptr) == ApplyResult::Cleared); +} + +// Lines of an example file that look like "# key = value" for a known key. +std::vector splitLines(const std::string &text) { + std::vector lines; + size_t pos = 0; + while (pos < text.size()) { + size_t e = text.find('\n', pos); + if (e == std::string::npos) e = text.size(); + lines.push_back(text.substr(pos, e - pos)); + pos = e + 1; + } + return lines; +} + +bool isSettingLine(const std::string &line, size_t *index) { + if (line.compare(0, 2, "# ") != 0) return false; + for (size_t i = 0; i < settingCount(); i++) { + const std::string prefix = std::string("# ") + settingKey(i) + " = "; + if (line.compare(0, prefix.size(), prefix) == 0) { + *index = i; + return true; + } + } + return false; +} + +void checkExample(const Settings &cur, const char *label) { + printf(" (%s)\n", label); + static char buf[16384]; + const size_t n = renderExampleConfig(cur, "Measured drift: +1.2 s/day over 3 days", buf, sizeof buf); + CHECK(n > 2000 && n < 11000); + CHECK(n == strlen(buf)); + { // the clock writes it with Windows line ends into a buffer of 12 KB (kExampleCap in app_settings.cpp): it has to fit + static char onCard[12 * 1024]; + const size_t m = renderExampleConfig(cur, "Measured drift: +1.2 s/day over 3 days", onCard, sizeof onCard, true); + CHECK(m > n && m + 1024 < sizeof onCard); // ... with a kilobyte to spare for the settings still to come + CHECK(renderExampleConfig(cur, "", onCard, n, true) == 0); // (it can fail: a buffer that is too small gets nothing) + } + const std::string text(buf, n); + CHECK(text.back() == '\n'); + CHECK(text.find('\r') == std::string::npos); + CHECK(text.find("Measured drift: +1.2 s/day over 3 days") != std::string::npos); + CHECK(text.find("drift") != std::string::npos); + CHECK(text.find("reset_all") != std::string::npos); + CHECK(text.find("quotes") != std::string::npos); + + // nothing but comments and blank lines, none too wide for a small editor window + size_t settingLines = 0; + bool seen[32] = {}; + for (const std::string &line : splitLines(text)) { + CHECK(line.empty() || line[0] == '#'); + CHECK(line.size() <= 96); + size_t idx; + if (isSettingLine(line, &idx)) { + settingLines++; + CHECK(!seen[idx]); + seen[idx] = true; + } + } + CHECK(settingLines == settingCount()); // every setting is listed, once + + // so an unedited example changes nothing + Settings same = cur; + const Settings defaults; + const ConfigReport r = applyConfigText(same, defaults, text.data(), text.size()); + CHECK(r.problems() == 0 && r.touched() == 0 && r.changed == 0 && r.cleared == 0 && r.unchanged == 0); + CHECK(dump(same) == dump(cur)); + CHECK(same.userSet == cur.userSet); + + // and removing the # in front of each (non-secret) setting gives the same values back + std::string uncommented; + for (const std::string &line : splitLines(text)) { + size_t idx; + if (isSettingLine(line, &idx) && !settingIsSecret(idx)) { + uncommented += line.substr(2) + "\n"; + } else { + uncommented += line + "\n"; + } + } + Settings round; + const ConfigReport r2 = applyConfigText(round, defaults, uncommented.data(), uncommented.size()); + if (r2.problems()) { + for (int i = 0; i < r2.issueCount; i++) printf(" issue: %s\n", r2.issues[i].text); + } + CHECK(r2.problems() == 0); + for (size_t i = 0; i < settingCount(); i++) { + if (settingIsSecret(i)) continue; + if (fmt(round, i) != fmt(cur, i)) printf(" %s: [%s] vs [%s]\n", settingKey(i), fmt(round, i).c_str(), fmt(cur, i).c_str()); + CHECK(fmt(round, i) == fmt(cur, i)); + } + + // too small a buffer is reported, never overrun + char small[200]; + CHECK(renderExampleConfig(cur, "", small, sizeof small) == 0); + CHECK(renderExampleConfig(cur, "", buf, 0) == 0); +} + +void testSettingsExample() { + section("example settings file"); + checkExample(Settings(), "defaults"); + const Applied all = apply(kAllSettings); + checkExample(all.s, "everything changed"); + + // the password and the Client ID never reach the card + Settings secret = all.s; + snprintf(secret.wifiPassword, sizeof secret.wifiPassword, "hunter2-super-secret"); + static char buf[16384]; + const size_t n = renderExampleConfig(secret, "", buf, sizeof buf); + CHECK(n > 0); + CHECK(strstr(buf, "hunter2-super-secret") == nullptr); + CHECK(strstr(buf, "0123456789abcdef0123456789abcdef") == nullptr); + CHECK(strstr(buf, "p@ss") == nullptr); + CHECK(strstr(buf, "Cafe Net") != nullptr); // the network name is not secret, and helps the user + CHECK(strstr(buf, "# wifi_password = \"your password\"") != nullptr); + CHECK(strstr(buf, "# wifi_backup_password = \"your password\"") != nullptr); + CHECK(strstr(buf, "h0tsp0t") == nullptr); // (the backup password set by kAllSettings) + CHECK(strstr(buf, "# spotify_client_id = \"your client id\"") != nullptr); + + // without a measurement the generic drift note is still there + const size_t n2 = renderExampleConfig(Settings(), nullptr, buf, sizeof buf); + CHECK(n2 > 0 && strstr(buf, "drift") != nullptr && strstr(buf, "Measured") == nullptr); + // a name with a quote in it survives the round trip through the example text? (it is quoted as typed) + Settings quote; + snprintf(quote.wifiSsid, sizeof quote.wifiSsid, "Bob's WiFi"); + checkExample(quote, "apostrophe in the SSID"); + + // the card gets Windows line ends: the same text with every newline preceded by CR, which reads the same + static char crlfBuf[20000], lfBuf[20000]; + const size_t lfLen = renderExampleConfig(all.s, "Measured: +1.0 ppm", lfBuf, sizeof lfBuf, false); + const size_t crlfLen = renderExampleConfig(all.s, "Measured: +1.0 ppm", crlfBuf, sizeof crlfBuf, true); + CHECK(lfLen > 0 && crlfLen > lfLen); + std::string lfText(lfBuf, lfLen), crlfText(crlfBuf, crlfLen), stripped; + size_t newlines = 0, loneLf = 0; + for (size_t i = 0; i < crlfText.size(); i++) { + if (crlfText[i] == '\n') { + newlines++; + if (i == 0 || crlfText[i - 1] != '\r') loneLf++; + } + if (crlfText[i] != '\r') stripped += crlfText[i]; + } + CHECK(stripped == lfText); + CHECK(loneLf == 0 && crlfLen == lfLen + newlines); + Settings same = all.s; + const ConfigReport rr = applyConfigText(same, Settings(), crlfText.data(), crlfText.size()); + CHECK(rr.problems() == 0 && rr.touched() == 0 && rr.changed == 0); + // a buffer one byte short is refused, with or without CRLF + CHECK(renderExampleConfig(all.s, "", crlfBuf, 100, true) == 0); + static char probe[20000]; + const size_t exact = renderExampleConfig(all.s, "", probe, sizeof probe, true); + CHECK(exact > 0); + CHECK(renderExampleConfig(all.s, "", probe, exact, true) == 0); // no room for the terminator + CHECK(renderExampleConfig(all.s, "", probe, exact + 1, true) == exact); +} + +void testSettingsFuzz() { + section("settings file: garbage in, no crash"); + static char example[16384]; + const size_t exampleLen = renderExampleConfig(Settings(), "", example, sizeof example); + uint32_t rng = 12345; + auto next = [&rng]() { + rng = rng * 1664525u + 1013904223u; + return rng >> 8; + }; + int tested = 0; + for (int round = 0; round < 3000; round++) { + std::string text; + if (round % 3 == 0) { // random bytes + const size_t len = next() % 1500; + for (size_t i = 0; i < len; i++) text += (char)(next() & 0xFF); + } else if (round % 3 == 1) { // the example file with some bytes changed + text.assign(example, exampleLen); + std::string uncommented; + for (const std::string &line : splitLines(text)) { + size_t idx; + uncommented += (isSettingLine(line, &idx) ? line.substr(2) : line) + "\n"; + } + text = uncommented; + const int flips = 1 + (int)(next() % 20); + for (int i = 0; i < flips; i++) text[next() % text.size()] = (char)(next() & 0xFF); + } else { // settings-like lines with odd values + static const char *keys[] = {"wifi", "wifi_ssid", "latitude", "timezone", "units", "date_format", "reset_all", + "battery_cutoff_v", "hostname", "cpu_mhz", "", "=", ":"}; + static const char *values[] = {"", "\"", "'", "\"\"\"", "1e999", "-1e999", "nan", "0x1p3", "99999999999999999999", + "\xE2\x80", "\xC2", "\xE2\x80\x9C", "a b c d e f g h i j k l m n o p q r s t u v w x y z", + "# #", " ", "S", "-", "1,2,3", ",", "."}; + const int lines = 1 + (int)(next() % 12); + for (int i = 0; i < lines; i++) { + text += keys[next() % (sizeof keys / sizeof keys[0])]; + text += (next() & 1) ? " = " : (next() & 1) ? ":" : ""; + text += values[next() % (sizeof values / sizeof values[0])]; + text += (next() & 1) ? "\r\n" : "\n"; + } + } + Settings s; + const Settings defaults; + const ConfigReport r = applyConfigText(s, defaults, text.data(), text.size()); + tested++; + // whatever came in, the settings are still sane + bool sane = r.lines >= 0 && r.issueCount <= kConfigMaxIssues && s.latitude >= -90 && s.latitude <= 90 && + s.longitude >= -180 && s.longitude <= 180 && s.indoorOffsetC >= -15 && s.indoorOffsetC <= 15 && + s.batteryCutoffV >= 3.1f && s.batteryCutoffV <= 3.6f && s.weatherIntervalMin >= 5 && + s.weatherIntervalMin <= 240 && s.batteryCapacityMah >= 0 && s.batteryCapacityMah <= 20000 && + s.units <= UNITS_IMPERIAL && s.timeFormat <= TIME_12H && s.dateFormat < DATE_FORMAT_COUNT && + s.battery <= BATTERY_NONE && s.wifiPowerSave <= WIFISAVE_MAX && s.cpuSpeed <= CPU_240; + sane = sane && memchr(s.wifiSsid, 0, sizeof s.wifiSsid) && memchr(s.wifiPassword, 0, sizeof s.wifiPassword) && + memchr(s.hostname, 0, sizeof s.hostname) && memchr(s.ntpServer, 0, sizeof s.ntpServer) && + memchr(s.locationLabel, 0, sizeof s.locationLabel) && memchr(s.location, 0, sizeof s.location) && + memchr(s.timezone, 0, sizeof s.timezone) && memchr(s.spotifyClientId, 0, sizeof s.spotifyClientId); + if (!sane) printf(" insane result for round %d\n", round); + CHECK(sane); + // and the text of every setting still goes through flash unchanged + for (size_t i = 0; i < settingCount(); i++) { + Settings t; + if (!applyStoredSetting(t, i, fmt(s, i).c_str())) { + printf(" round %d: %s=[%s] does not reload\n", round, settingKey(i), fmt(s, i).c_str()); + CHECK(false); + break; + } + } + } + CHECK(tested == 3000); + // also: null text, zero length + Settings s; + const Settings defaults; + CHECK(applyConfigText(s, defaults, nullptr, 0).lines == 0); + CHECK(applyConfigText(s, defaults, "x", 0).lines == 0); + + // the worst case for the UTF-16 decoder: every unit grows to three bytes (the buffer is sized for it) + std::string euros = "\xFF\xFE"; + for (int i = 0; i < 4000; i++) { + euros += '\xAC'; + euros += '\x20'; + } + euros += '\n'; + euros += '\0'; + euros += '\0'; // an odd trailing byte pair of zeros + const ConfigReport er = applyConfigText(s, defaults, euros.data(), euros.size()); + CHECK(er.lines >= 1 && er.bad >= 1); // one very long line, reported as such + std::string be = "\xFE\xFF"; // and a lone BOM, or a BOM and half a unit + CHECK(applyConfigText(s, defaults, be.data(), be.size()).lines == 0); + be += 'x'; + CHECK(applyConfigText(s, defaults, be.data(), be.size()).lines == 0); +} + +// =========================================================================== +// Moon +// =========================================================================== +int64_t utc(int y, int mo, int d, int h, int mi) { return calc::epochFromUtc(y, mo, d, h, mi, 0); } + +void testMoonPhase() { + section("moon phase"); + struct Event { + int y, mo, d, h, mi; + }; + // new moons and full moons that came with eclipses, times from the published eclipse circumstances (UTC) + const Event newMoons[] = {{2000, 1, 6, 18, 14}, {2017, 8, 21, 18, 30}, {2019, 7, 2, 19, 16}, {2021, 12, 4, 7, 43}, + {2024, 4, 8, 18, 21}, {2024, 10, 2, 18, 49}, {2025, 3, 29, 10, 58}}; + const Event fullMoons[] = {{2000, 1, 21, 4, 40}, {2018, 7, 27, 20, 21}, {2019, 1, 21, 5, 16}, {2021, 5, 26, 11, 14}, + {2022, 11, 8, 11, 2}, {2024, 9, 18, 2, 34}, {2025, 3, 14, 6, 55}}; + for (const Event &e : newMoons) { + const moon::Phase p = moon::phaseAt(utc(e.y, e.mo, e.d, e.h, e.mi)); + if (!(p.lit < 0.003f)) printf(" new moon %d-%02d-%02d: lit %.5f\n", e.y, e.mo, e.d, p.lit); + CHECK(p.lit < 0.003f); + CHECK(p.age < 0.01f || p.age > 0.99f); + // a day before: a thin waning crescent; a day after: a thin waxing one + const moon::Phase before = moon::phaseAt(utc(e.y, e.mo, e.d, e.h, e.mi) - 86400); + const moon::Phase after = moon::phaseAt(utc(e.y, e.mo, e.d, e.h, e.mi) + 86400); + CHECK(!before.waxing && before.lit > 0.005f && before.lit < 0.05f); + CHECK(after.waxing && after.lit > 0.005f && after.lit < 0.05f); + } + for (const Event &e : fullMoons) { + const moon::Phase p = moon::phaseAt(utc(e.y, e.mo, e.d, e.h, e.mi)); + if (!(p.lit > 0.997f)) printf(" full moon %d-%02d-%02d: lit %.5f\n", e.y, e.mo, e.d, p.lit); + CHECK(p.lit > 0.997f); + CHECK(fabsf(p.age - 0.5f) < 0.01f); + const moon::Phase before = moon::phaseAt(utc(e.y, e.mo, e.d, e.h, e.mi) - 86400); + const moon::Phase after = moon::phaseAt(utc(e.y, e.mo, e.d, e.h, e.mi) + 86400); + CHECK(before.waxing && before.lit < 0.995f && before.lit > 0.95f); + CHECK(!after.waxing && after.lit < 0.995f && after.lit > 0.95f); + } + // quarters (UTC): half lit, growing at the first, shrinking at the last + const Event first[] = {{2024, 4, 15, 19, 13}, {2024, 10, 10, 18, 55}}; + const Event last[] = {{2024, 5, 1, 11, 27}, {2024, 10, 24, 8, 3}}; + for (const Event &e : first) { + const moon::Phase p = moon::phaseAt(utc(e.y, e.mo, e.d, e.h, e.mi)); + CHECK_NEAR(p.lit, 0.5, 0.02); + CHECK_NEAR(p.age, 0.25, 0.01); + CHECK(moon::phaseAt(utc(e.y, e.mo, e.d, e.h, e.mi) + 3 * 3600).waxing); + } + for (const Event &e : last) { + const moon::Phase p = moon::phaseAt(utc(e.y, e.mo, e.d, e.h, e.mi)); + CHECK_NEAR(p.lit, 0.5, 0.02); + CHECK_NEAR(p.age, 0.75, 0.01); + CHECK(!moon::phaseAt(utc(e.y, e.mo, e.d, e.h, e.mi) + 3 * 3600).waxing); + } + + // every new moon of 2024, found by scanning for the darkest minute near the published time + const Event year2024[] = {{2024, 1, 11, 11, 57}, {2024, 2, 9, 22, 59}, {2024, 3, 10, 9, 0}, {2024, 4, 8, 18, 21}, + {2024, 5, 8, 3, 22}, {2024, 6, 6, 12, 38}, {2024, 7, 5, 22, 57}, {2024, 8, 4, 11, 13}, + {2024, 9, 3, 1, 55}, {2024, 10, 2, 18, 49}, {2024, 11, 1, 12, 47}, {2024, 12, 1, 6, 21}}; + for (const Event &e : year2024) { + const int64_t expected = utc(e.y, e.mo, e.d, e.h, e.mi); + int64_t best = expected; + float bestLit = 2; + for (int64_t t = expected - 12 * 3600; t <= expected + 12 * 3600; t += 60) { + const float lit = moon::phaseAt(t).lit; + if (lit < bestLit) { + bestLit = lit; + best = t; + } + } + const double offMin = (double)(best - expected) / 60.0; + if (fabs(offMin) > 45) printf(" new moon %d-%02d-%02d: found %+.0f min from the published time\n", e.y, e.mo, e.d, offMin); + CHECK(fabs(offMin) <= 45); // measured worst case: 29 minutes (the darkest moment is not exactly the conjunction) + } + + // away from an eclipse the Moon passes up to five degrees from the Sun, so it is never quite dark + for (const Event &e : {Event{2024, 1, 11, 11, 57}, Event{2024, 2, 9, 22, 59}, Event{2024, 6, 6, 12, 38}}) { + float darkest = 2; + for (int64_t t = utc(e.y, e.mo, e.d, e.h, e.mi) - 6 * 3600; t <= utc(e.y, e.mo, e.d, e.h, e.mi) + 6 * 3600; t += 60) + darkest = fminf(darkest, moon::phaseAt(t).lit); + if (!(darkest > 0.0008f && darkest < 0.0025f)) printf(" darkest at %d-%02d-%02d: %.5f\n", e.y, e.mo, e.d, darkest); + CHECK(darkest > 0.0008f && darkest < 0.0025f); // (1 - cos 5 degrees) / 2 is 0.0019 + } + + // over one lunation the lit fraction rises and falls once, and the age advances steadily + const int64_t nm = utc(2024, 4, 8, 18, 21); + float prev = -1; + int ups = 0, downs = 0; + for (int h = 0; h <= 29 * 24 + 12; h++) { + const moon::Phase p = moon::phaseAt(nm + (int64_t)h * 3600); + CHECK(p.lit >= 0 && p.lit <= 1); + CHECK(p.age >= 0 && p.age < 1); + if (prev >= 0) { + if (p.lit > prev + 1e-6f) ups++; + else if (p.lit < prev - 1e-6f) downs++; + } + prev = p.lit; + } + CHECK(ups > 330 && ups < 380); // about 14.8 days of waxing + CHECK(downs > 330 && downs < 380); // about 14.8 days of waning + // the same instant a synodic month later is nearly the same phase + CHECK_NEAR(moon::phaseAt(nm + (int64_t)(29.530589 * 86400)).lit, moon::phaseAt(nm).lit, 0.02); + // dates a long way from today still work + CHECK(moon::phaseAt(0).lit >= 0 && moon::phaseAt(0).lit <= 1); // 1970 + CHECK(moon::phaseAt(utc(2099, 12, 31, 12, 0)).lit <= 1); // far future + CHECK(moon::phaseAt(-86400LL * 365 * 30).lit >= 0); // before 1970 +} + +// How many pixels of a disc are inside / lit. +void countDisc(int d, float lit, bool onRight, int *inside, int *litPx) { + *inside = *litPx = 0; + for (int r = 0; r < d; r++) + for (int c = 0; c < d; c++) { + const moon::DiscPixel p = moon::discPixel(c, r, d, lit, onRight); + *inside += p.inside; + *litPx += p.lit; + } +} + +void testMoonGlyph() { + section("moon glyph"); + for (int d = 8; d <= 14; d++) { + int inside, litPx; + countDisc(d, 0.0f, true, &inside, &litPx); + CHECK(inside >= d * d / 2 && inside <= d * d); + CHECK(litPx == 0); // new moon: nothing lit + countDisc(d, 1.0f, true, &inside, &litPx); + CHECK(litPx == inside); // full moon: all of it + countDisc(d, 0.003f, true, &inside, &litPx); + CHECK(litPx == 0); + countDisc(d, 0.997f, false, &inside, &litPx); + CHECK(litPx == inside); + + // quarter: one half (even sizes are exactly half; odd sizes keep the middle column dark) + int right, left; + countDisc(d, 0.5f, true, &inside, &right); + countDisc(d, 0.5f, false, &inside, &left); + CHECK(right == left); + if (d % 2 == 0) CHECK(right * 2 == inside); + CHECK(abs(right * 2 - inside) <= d); + + // the pixels lit grow with the lit fraction, and track it + int last = -1; + for (int k = 0; k <= 20; k++) { + const float lit = k / 20.0f; + int n; + countDisc(d, lit, true, &inside, &n); + CHECK(n >= last); + last = n; + if (fabs((double)n / inside - lit) > 0.12) printf(" d=%d lit=%.2f: %d of %d pixels\n", d, lit, n, inside); + CHECK(fabs((double)n / inside - lit) <= 0.12); + } + + // the terminator is an arc, not a straight line: on a crescent it starts nearer the middle in + // the rows towards the top and bottom than across the middle of the disc + if (d >= 11) { + auto firstLit = [&](int row) { + for (int c = 0; c < d; c++) + if (moon::discPixel(c, row, d, 0.25f, true).lit) return c; + return -1; + }; + const int mid = firstLit(d / 2), nearTop = firstLit(1), nearBottom = firstLit(d - 2); + CHECK(mid >= 0 && nearTop >= 0 && nearBottom >= 0); + CHECK(nearTop < mid && nearBottom < mid); + CHECK(nearTop == nearBottom); + // and a gibbous moon bulges the other way: the dark part is a crescent on the unlit side + auto firstDark = [&](int row) { + for (int c = d - 1; c >= 0; c--) { + const moon::DiscPixel p = moon::discPixel(c, row, d, 0.75f, true); + if (p.inside && !p.lit) return c; + } + return -1; + }; + CHECK(firstDark(1) > firstDark(d / 2)); // the dark crescent's inner edge sits nearer the middle at the top + } + + // mirrored when seen from the other hemisphere, and the lit part grows from the limb + for (int k = 1; k < 20; k++) { + const float lit = k / 20.0f; + for (int r = 0; r < d; r++) { + int firstIn = -1, lastIn = -1, litCount = 0, litFirst = -1, litLast = -1; + for (int c = 0; c < d; c++) { + const moon::DiscPixel a = moon::discPixel(c, r, d, lit, true); + const moon::DiscPixel b = moon::discPixel(d - 1 - c, r, d, lit, false); + CHECK(a.inside == b.inside && a.lit == b.lit); + if (a.inside) { + if (firstIn < 0) firstIn = c; + lastIn = c; + } + if (a.lit) { + if (litFirst < 0) litFirst = c; + litLast = c; + litCount++; + } + } + if (litCount) { + CHECK(litLast == lastIn); // touches the lit limb + CHECK(litLast - litFirst + 1 == litCount); // one run, no holes + } + CHECK(firstIn >= 0); // every row of the box crosses the disc + } + } + } +} + +// =========================================================================== +// Date and time text +// =========================================================================== +void testDateFormats() { + section("date formats"); + struct Case { + uint8_t format; + const char *oct4; // 2026-10-04 + const char *jan9; // 2026-01-09 + const char *dec31; // 1999-12-31 + } cases[] = { + {DATE_ISO, "2026-10-04", "2026-01-09", "1999-12-31"}, + {DATE_DMY, "04/10/2026", "09/01/2026", "31/12/1999"}, + {DATE_MDY, "10/04/2026", "01/09/2026", "12/31/1999"}, + {DATE_DMY_DOT, "04.10.2026", "09.01.2026", "31.12.1999"}, + {DATE_D_MON_Y, "4 Oct 2026", "9 Jan 2026", "31 Dec 1999"}, + {DATE_MON_D_Y, "Oct 4, 2026", "Jan 9, 2026", "Dec 31, 1999"}, + }; + char buf[32]; + for (const Case &c : cases) { + datefmt::formatDate(2026, 10, 4, c.format, buf, sizeof buf); + CHECK_STR(buf, c.oct4); + datefmt::formatDate(2026, 1, 9, c.format, buf, sizeof buf); + CHECK_STR(buf, c.jan9); + datefmt::formatDate(1999, 12, 31, c.format, buf, sizeof buf); + CHECK_STR(buf, c.dec31); + } + CHECK(DATE_FORMAT_COUNT == 6); + // unknown formats fall back to ISO, a short buffer is cut and terminated + datefmt::formatDate(2026, 10, 4, 200, buf, sizeof buf); + CHECK_STR(buf, "2026-10-04"); + char tiny[6]; + datefmt::formatDate(2026, 10, 4, DATE_ISO, tiny, sizeof tiny); + CHECK_STR(tiny, "2026-"); + // every date of 2026 in every format is short enough for the date line, and fixed width for the numeric ones + for (int f = 0; f < DATE_FORMAT_COUNT; f++) { + size_t shortest = 99, longest = 0; + for (int mo = 1; mo <= 12; mo++) + for (int d = 1; d <= 28; d++) { + datefmt::formatDate(2026, mo, d, (uint8_t)f, buf, sizeof buf); + const size_t n = strlen(buf); + shortest = n < shortest ? n : shortest; + longest = n > longest ? n : longest; + } + CHECK(longest <= 12); + if (f <= DATE_DMY_DOT) CHECK(shortest == longest && longest == 10); // numeric formats never change width + } + + // 12 hour clock face + bool pm = false; + CHECK(datefmt::hour12(0, &pm) == 12 && !pm); + CHECK(datefmt::hour12(1, &pm) == 1 && !pm); + CHECK(datefmt::hour12(11, &pm) == 11 && !pm); + CHECK(datefmt::hour12(12, &pm) == 12 && pm); + CHECK(datefmt::hour12(13, &pm) == 1 && pm); + CHECK(datefmt::hour12(23, &pm) == 11 && pm); + CHECK(datefmt::hour12(24, &pm) == 12 && !pm); // wraps like 0 + CHECK(datefmt::hour12(-1, &pm) == 11 && pm); // never crashes on odd input + CHECK(datefmt::hour12(5, nullptr) == 5); + CHECK_STR(datefmt::ampm(false), "AM"); + CHECK_STR(datefmt::ampm(true), "PM"); + for (int h = 0; h < 24; h++) { + const int h12 = datefmt::hour12(h, &pm); + CHECK(h12 >= 1 && h12 <= 12 && pm == (h >= 12)); + } +} + +// =========================================================================== +// Battery curve, estimate, shutdown guard +// =========================================================================== +void testBatteryCurve() { + section("battery curve (float and inverse)"); + // the integer gauge is exactly the rounded float one + for (int mv = 3000; mv <= 4400; mv++) { + const float v = mv / 1000.0f; + CHECK(calc::batteryPercent(v) == (int)lroundf(calc::batteryPercentF(v))); + } + CHECK_NEAR(calc::batteryPercentF(4.30f), 100, 1e-6); + CHECK_NEAR(calc::batteryPercentF(4.20f), 100, 1e-6); + CHECK_NEAR(calc::batteryPercentF(3.784f), 50, 1e-4); + CHECK_NEAR(calc::batteryPercentF(3.27f), 0, 1e-4); + CHECK_NEAR(calc::batteryPercentF(3.0f), 0, 1e-6); + CHECK_NEAR(calc::batteryPercentF(3.93f), 65 + 5 * (3.93 - 3.907) / (3.945 - 3.907), 0.01); // 68.0, between 3.907 (65) and 3.945 (70) + // monotonic, and the inverse really is one + float last = -1; + for (int mv = 3200; mv <= 4250; mv++) { + const float p = calc::batteryPercentF(mv / 1000.0f); + CHECK(p >= last); + last = p; + } + for (int i = 0; i <= 200; i++) { + const float pct = i * 0.5f; + const float v = calc::batteryVoltsForPercent(pct); + CHECK_NEAR(calc::batteryPercentF(v), pct, 0.01); + } + CHECK_NEAR(calc::batteryVoltsForPercent(150), 4.20, 1e-6); + CHECK_NEAR(calc::batteryVoltsForPercent(-5), 3.27, 1e-6); + // volts fall as percent falls + for (int i = 1; i <= 100; i++) CHECK(calc::batteryVoltsForPercent((float)i) > calc::batteryVoltsForPercent((float)i - 1)); + + // The curve carried on above its top, for the runtime estimate: the same up to 4.20 V, then 5.2 mV to the percent + // (seen on the board: 4.223 V on the charger, 4.210 V a minute after it), and it never stands still. + for (int mv = 3000; mv <= 4200; mv++) CHECK(calc::batteryPercentOpen(mv / 1000.0f) == calc::batteryPercentF(mv / 1000.0f)); + CHECK_NEAR(calc::batteryPercentOpen(4.21f), 101.92, 0.01); + CHECK_NEAR(calc::batteryPercentOpen(4.223f), 104.42, 0.01); + CHECK_NEAR(calc::batteryPercentOpen(4.30f), 119.23, 0.01); + float lastOpen = calc::batteryPercentOpen(4.150f); + for (int mv = 4151; mv <= 4300; mv++) { + const float p = calc::batteryPercentOpen(mv / 1000.0f); + CHECK(p > lastOpen && p - lastOpen < 0.2f); // rising all the way, with no step where the two halves meet + lastOpen = p; + } + { // a cell read as 4.215 V that falls 3 mV an hour: the rate is seen, and the time left is that of a full cell, no more + battest::Estimator est; + for (uint32_t t = 0; t <= 3 * 3600; t += 5) est.add(t, 4.215f - 0.003f * (float)t / 3600.0f); + const battest::Estimate e = est.estimate(3.30f, 0); + CHECK(e.state == battest::Estimate::READY && !e.unbounded); + CHECK_NEAR(e.pctPerHour, 0.577, 0.03); // (3 mV an hour at 5.2 mV to the percent) + CHECK_NEAR(e.levelPct, 101.15, 0.1); // 4.206 V after three hours: over the top of the curve still + CHECK_NEAR(e.hoursLeft, (100.0 - calc::batteryPercentF(3.30f)) / e.pctPerHour, 0.5); // (0.6 % more would be two hours more) + } +} + +// The run the battery curve was fitted to, replayed through the estimator: a 3400 mAh cell from a full charge until +// the clock shut itself down at 3.30 V, a reading every ten minutes (fixtures/rundown-2026-10.csv, from the clock's +// power log of 17 to 20 October 2026). Between two lines the voltage is taken as a straight line, read every 5 s +// as the clock does. Each estimate is held against the time the cell really had left. +void testBatteryEstimateReplay() { + section("battery estimate: the logged run down to the shutdown"); + std::vector> pts; + { + const std::string csv = readFile("fixtures/rundown-2026-10.csv"); + size_t at = csv.find('\n'); // (past the heading) + while (at != std::string::npos && at + 1 < csv.size()) { + const size_t next = csv.find('\n', at + 1); + int minute = 0; + float volts = 0; + if (sscanf(csv.c_str() + at + 1, "%d,%f", &minute, &volts) == 2) pts.push_back({minute, volts}); + at = next; + } + } + CHECK(pts.size() == 490 && pts.front().first == 0 && pts.back().first == 4890); + if (pts.size() < 2) return; + const double endMin = pts.back().first + 5; // (the last line, 3.307 V, then under 3.30 V for a minute: shut down) + battest::Estimator est; + // The first hours off the charger the cell sheds what is left of the charge faster than it will drain later, so + // the first figures are short: they may be, as long as they never promise more than the cell has. From the + // third hour on they are held to the truth. + const double settleH = 3; + int checked = 0, within15 = 0, within25 = 0, firstReadyMin = -1; + double worst = 0, worstAtH = 0, sumAbs = 0, settlingWorstLong = -1, settlingWorstShort = 0; + size_t seg = 0; + for (uint32_t t = 0; t <= (uint32_t)pts.back().first * 60; t += 5) { + while (seg + 2 < pts.size() && (double)t >= pts[seg + 1].first * 60.0) seg++; + const double m = t / 60.0, m0 = pts[seg].first, m1 = pts[seg + 1].first; + const double f = m1 > m0 ? (m - m0) / (m1 - m0) : 0; + est.add(t, (float)(pts[seg].second + f * (pts[seg + 1].second - pts[seg].second))); + if (t % 3600 != 0) continue; // judged once an hour + const battest::Estimate e = est.estimate(3.30f, 0); + if (e.state != battest::Estimate::READY) continue; + if (firstReadyMin < 0) firstReadyMin = (int)m; + const double trueH = (endMin - m) / 60.0; + if (trueH < 6) continue; // (the last hours are judged below, in hours rather than shares) + const double err = (e.unbounded ? 1e3 : e.hoursLeft) / trueH - 1.0; + if (m / 60.0 < settleH) { + settlingWorstLong = fmax(settlingWorstLong, err); + settlingWorstShort = fmin(settlingWorstShort, err); + continue; + } + checked++; + within15 += fabs(err) <= 0.15; + within25 += fabs(err) <= 0.25; + sumAbs += fabs(err); + if (fabs(err) > fabs(worst)) worst = err, worstAtH = m / 60.0; + } + printf(" (first figure after %d min; in the first %.0f hours %+.0f to %+.0f %%; then %d hourly figures up to six hours before the end: " + "%d within 15 %%, %d within 25 %%, mean error %.0f %%, worst %+.0f %% at %.0f h)\n", + firstReadyMin, settleH, 100 * settlingWorstShort, 100 * settlingWorstLong, checked, within15, within25, + 100 * sumAbs / (checked ? checked : 1), 100 * worst, worstAtH); + CHECK(firstReadyMin > 0 && firstReadyMin <= 60); + CHECK(settlingWorstLong <= 0.10); // settling: short is allowed, long is not + CHECK(checked >= 70); + CHECK(within15 >= checked * 9 / 10); // nine in ten within 15 % of the truth + CHECK(fabs(worst) <= 0.30); // none out by more than 30 % (the old curve said 6 days to a cell with 2.6 left) + CHECK(sumAbs / checked <= 0.10); + // near the end, in hours: three hours before the shutdown it must not promise more than five + { + battest::Estimator late; + size_t s = 0; + for (uint32_t t = 0; t <= (uint32_t)((endMin - 180) * 60); t += 5) { + while (s + 2 < pts.size() && (double)t >= pts[s + 1].first * 60.0) s++; + const double m = t / 60.0, m0 = pts[s].first, m1 = pts[s + 1].first; + late.add(t, (float)(pts[s].second + (m - m0) / (m1 - m0) * (pts[s + 1].second - pts[s].second))); + } + const battest::Estimate e = late.estimate(3.30f, 0); + CHECK(e.state == battest::Estimate::READY && !e.unbounded && e.hoursLeft <= 5.0f); + } +} + +// A deterministic reading source for the estimator tests. +struct Cell { + uint32_t rng = 12345; + double noiseV = 0; // standard deviation of the reading, volts + double dipRate = 0; // chance of a transmit dip per reading + double gaussian() { + auto u = [this]() { + rng = rng * 1664525u + 1013904223u; + return ((rng >> 8) & 0xFFFFFF) / 16777216.0 + 1e-9; + }; + return sqrt(-2.0 * log(u())) * cos(6.283185307179586 * u()); + } + double uniform() { + rng = rng * 1664525u + 1013904223u; + return ((rng >> 8) & 0xFFFFFF) / 16777216.0; + } + double read(double pct) { + double v = calc::batteryVoltsForPercent((float)pct); + if (noiseV > 0) v += gaussian() * noiseV; + if (dipRate > 0 && uniform() < dipRate) v -= 0.05 + 0.07 * uniform(); + return v; + } +}; + +// Feeds `hours` of discharge starting at second `t0`, one reading every 5 s; returns the end time. +uint32_t discharge(battest::Estimator &est, Cell &cell, double startPct, double pctPerHour, double hours, uint32_t t0) { + uint32_t t = t0; + const uint32_t end = t0 + (uint32_t)(hours * 3600); + for (; t < end; t += 5) { + const double pct = startPct - pctPerHour * (double)(t - t0) / 3600.0; + est.add(t, (float)cell.read(pct)); + } + return t; +} + +void testBatteryEstimate() { + section("battery estimate"); + char buf[32]; + battest::formatRemaining(0.0f, buf, sizeof buf); CHECK_STR(buf, "0 min"); + battest::formatRemaining(0.5f, buf, sizeof buf); CHECK_STR(buf, "30 min"); + battest::formatRemaining(0.999f, buf, sizeof buf); CHECK_STR(buf, "1 h 00 min"); // rounds up to the hour, not "60 min" + battest::formatRemaining(1.0f, buf, sizeof buf); CHECK_STR(buf, "1 h 00 min"); + battest::formatRemaining(5.0f + 40.0f / 60, buf, sizeof buf); CHECK_STR(buf, "5 h 40 min"); + battest::formatRemaining(47.9f, buf, sizeof buf); CHECK_STR(buf, "47 h 54 min"); + battest::formatRemaining(48.0f, buf, sizeof buf); CHECK_STR(buf, "2 d 0 h"); + battest::formatRemaining(100.0f, buf, sizeof buf); CHECK_STR(buf, "4 d 4 h"); + battest::formatRemaining(24.0f * 31, buf, sizeof buf); CHECK_STR(buf, ">30 d"); + battest::formatRemaining(-1.0f, buf, sizeof buf); CHECK_STR(buf, "--"); + battest::formatRemaining(NAN, buf, sizeof buf); CHECK_STR(buf, "--"); + + const float cutoff = 3.30f; + const double cutPct = calc::batteryPercentF(cutoff); + + // nothing yet + { + battest::Estimator est; + CHECK(est.estimate(cutoff, 0).state == battest::Estimate::OFF); + est.add(100, NAN); + est.add(100, 0.0f); + est.add(100, 9.0f); + CHECK(est.estimate(cutoff, 0).state == battest::Estimate::OFF); // junk readings are ignored + CHECK(est.binCount() == 0); + } + + // a steady, noiseless discharge at 3 % an hour + { + battest::Estimator est; + Cell cell; + // minute by minute: the state goes OFF -> LEARNING (counting down) -> READY + int lastLearn = 1000; + bool seenLearning = false, seenReady = false; + for (uint32_t sec = 0; sec <= 3 * 3600; sec += 5) { + const double pct = 90 - 3.0 * sec / 3600.0; + est.add(2000 + sec, (float)cell.read(pct)); + if (sec % 60 != 0) continue; + const battest::Estimate e = est.estimate(cutoff, 0); + if (e.state == battest::Estimate::LEARNING) { + seenLearning = true; + CHECK(!seenReady); + CHECK(e.learnMin <= lastLearn); // the countdown only goes down + CHECK(e.learnMin >= 1 && e.learnMin <= 51); + lastLearn = e.learnMin; + } else if (e.state == battest::Estimate::READY) { + if (!seenReady) CHECK(sec >= 45 * 60 && sec <= 56 * 60); // 15 min settling + 30 min of history (+ the bin that is still open) + seenReady = true; + } else { + CHECK(sec == 0); // OFF only before the first reading closes + } + } + CHECK(seenLearning && seenReady); + const battest::Estimate e = est.estimate(cutoff, 1000); + CHECK(e.state == battest::Estimate::READY); + CHECK_NEAR(e.pctPerHour, 3.0, 0.03); + CHECK_NEAR(e.avgMa, 30.0, 0.5); // 3 % of 1000 mAh an hour + CHECK(e.windowMin >= 100 && e.windowMin <= 180); // all of the history there is: just under 3 h + // the fitted level is that of the newest bin, at most one bin old + CHECK_NEAR(e.levelPct, 90 - 3.0 * 3.0 + 0.25, 0.4); + CHECK_NEAR(e.hoursLeft, (e.levelPct - cutPct) / 3.0, 0.05); + CHECK(!e.unbounded); + // without a capacity there is no current + CHECK(est.estimate(cutoff, 0).avgMa == 0); + } + + // the window follows the drain: a fast one is judged over about two hours, a slow one over many + { + battest::Estimator fast, slow; + Cell c1, c2; + discharge(fast, c1, 95, 12.0, 7.0, 0); + discharge(slow, c2, 95, 1.5, 12.0, 0); + const battest::Estimate f = fast.estimate(cutoff, 0), s = slow.estimate(cutoff, 0); + CHECK(f.state == battest::Estimate::READY && s.state == battest::Estimate::READY); + CHECK(f.windowMin >= 110 && f.windowMin <= 130); + CHECK(s.windowMin >= 440 && s.windowMin <= 480); + CHECK_NEAR(f.pctPerHour, 12.0, 0.15); + CHECK_NEAR(s.pctPerHour, 1.5, 0.03); + CHECK(slow.binCount() == battest::kMaxBins); // 12 h of 5 minute bins: the oldest were dropped + } + + // noise, WiFi dips: still close + { + battest::Estimator est; + Cell cell; + cell.noiseV = 0.003; + cell.dipRate = 0.01; + discharge(est, cell, 85, 3.0, 8.0, 500); + const battest::Estimate e = est.estimate(cutoff, 0); + CHECK(e.state == battest::Estimate::READY); + const double truth = (85 - 3.0 * 8 - cutPct) / 3.0; + if (fabs(e.pctPerHour - 3.0) > 0.45) printf(" noisy drain: %.2f %%/h, %.1f h left (truth %.1f h)\n", e.pctPerHour, e.hoursLeft, truth); + CHECK_NEAR(e.pctPerHour, 3.0, 0.45); + CHECK(fabs(e.hoursLeft - truth) / truth < 0.2); + } + + // no drain at all: "unbounded", not a division by zero + { + battest::Estimator est; + Cell cell; + for (uint32_t sec = 0; sec < 5 * 3600; sec += 5) est.add(sec, 3.90f); + const battest::Estimate e = est.estimate(cutoff, 1000); + CHECK(e.state == battest::Estimate::READY && e.unbounded && e.pctPerHour < 0.02f && e.avgMa < 0.2f); + } + + // a lot of transmit dips: each bin's median ignores them (a mean would sag by several percent) + { + battest::Estimator est; + Cell cell; + uint32_t rng = 777; + for (uint32_t sec = 0; sec < 4 * 3600; sec += 5) { + rng = rng * 1664525u + 1013904223u; + const bool dip = ((rng >> 8) & 0xFF) < 40; // about 16 % of the readings + est.add(sec, dip ? 3.78f : 3.90f); + } + const battest::Estimate e = est.estimate(cutoff, 0); + CHECK(e.state == battest::Estimate::READY); + CHECK_NEAR(e.levelPct, calc::batteryPercentF(3.90f), 0.3); + } + + // below the cut-off already: no time left + { + battest::Estimator est; + Cell cell; + discharge(est, cell, 6.0, 3.0, 2.0, 0); + const battest::Estimate e = est.estimate(3.50f, 0); // a cut-off above where the cell is + CHECK(e.state == battest::Estimate::READY && e.hoursLeft == 0 && !e.unbounded); + } + + // reset forgets everything + { + battest::Estimator est; + Cell cell; + discharge(est, cell, 90, 3.0, 2.0, 0); + CHECK(est.estimate(cutoff, 0).state == battest::Estimate::READY); + est.reset(); + CHECK(est.estimate(cutoff, 0).state == battest::Estimate::OFF && est.binCount() == 0); + est.add(99999, 3.9f); + CHECK(est.estimate(cutoff, 0).state == battest::Estimate::LEARNING); + } + + // a reset in the middle of a busy bin must not leave its time offsets behind: the first bin after it + // holds one reading and would be stamped hours into the future. The line fit throws that point out, so + // the slope and the level still come out right; what it spoils is the span and the window, and with them + // when the first figure appears. A fresh estimator is the reference, compared on everything the estimate + // reports, every minute of the run. + { + battest::Estimator used, fresh; + for (uint32_t t = 560; t < 600; t++) used.add(t, 3.95f); // 40 readings late in a bin + used.reset(); + Cell c1, c2; + const uint32_t t0 = 599; + int compared = 0, differing = 0, ready = 0; + for (uint32_t t = t0; t < t0 + 3 * 3600; t += 5) { + const double pct = 90.0 - 3.0 * (double)(t - t0) / 3600.0; + used.add(t, (float)c1.read(pct)); + fresh.add(t, (float)c2.read(pct)); + if ((t - t0) % 60 != 0) continue; + const battest::Estimate a = used.estimate(cutoff, 2000), b = fresh.estimate(cutoff, 2000); + compared++; + if (b.state == battest::Estimate::READY) ready++; + if (a.state != b.state || a.learnMin != b.learnMin || a.windowMin != b.windowMin || a.unbounded != b.unbounded || + fabs(a.pctPerHour - b.pctPerHour) > 1e-6 || fabs(a.levelPct - b.levelPct) > 1e-6 || + fabs(a.hoursLeft - b.hoursLeft) > 1e-6 || fabs(a.avgMa - b.avgMa) > 1e-6) + differing++; + } + if (differing != 0) printf(" after a reset: %d of %d estimates differ from a fresh estimator\n", differing, compared); + CHECK(compared == 180 && ready >= 100); + CHECK(differing == 0); + CHECK(used.binCount() == fresh.binCount()); + } + + // a charger appearing without being announced: the readings jump up, so start over + { + battest::Estimator est; + Cell cell; + uint32_t t = discharge(est, cell, 90, 3.0, 2.0, 0); + CHECK(est.estimate(cutoff, 0).state == battest::Estimate::READY); + for (uint32_t k = 0; k < 20 * 60; k += 5) est.add(t + k, 4.15f); // charging: far above the discharge line + const battest::Estimate e = est.estimate(cutoff, 0); + CHECK(e.state == battest::Estimate::LEARNING); + CHECK(est.binCount() <= 4); + } + + // the time base going backwards (a reboot of the counter) restarts the estimate + { + battest::Estimator est; + Cell cell; + discharge(est, cell, 90, 3.0, 2.0, 5000); + est.add(100, 3.9f); + CHECK(est.estimate(cutoff, 0).state == battest::Estimate::LEARNING); + CHECK(est.binCount() == 0); + } + + // the first minutes after leaving the charger fall faster than the cell drains: ignored + { + battest::Estimator est; + Cell cell; + for (uint32_t sec = 0; sec < 4 * 3600; sec += 5) { + const double pct = 90 - 3.0 * sec / 3600.0; + double v = cell.read(pct); + if (sec < 600) v += 0.04 * exp(-(double)sec / 200.0); // settling after the charger + est.add(sec, (float)v); + } + const battest::Estimate e = est.estimate(cutoff, 0); + CHECK(e.state == battest::Estimate::READY); + if (fabs(e.pctPerHour - 3.0) > 0.15) printf(" settling: %.2f %%/h\n", e.pctPerHour); + CHECK_NEAR(e.pctPerHour, 3.0, 0.15); + } + + // the constants in use + const battest::Params p; + CHECK(p.binSec == 300 && p.skipSec == 900 && p.readySec == 1800); + CHECK(p.minWindowSec == 7200 && p.maxWindowSec == 28800); + CHECK(p.maxWindowSec <= (uint32_t)battest::kMaxBins * p.binSec); // the bins cover the longest window +} + +void testLowBattery() { + section("low battery guard"); + using lowbat::BootAction; + // a clear sag, sustained: shuts down after a minute of readings + { + lowbat::Guard g; + g.configure(true, 3.30f); + CHECK_NEAR(g.cutoffV(), 3.30, 1e-6); + for (uint32_t t = 0; t < 60; t += 5) CHECK(!g.feed(t, 3.28f, true)); // 12 readings, but not yet a minute + CHECK(g.tripping()); + CHECK(g.feed(60, 3.28f, true)); + CHECK(g.feed(65, 3.29f, true)); // and it stays tripped + } + // sparse readings (the clock was busy): time alone is not enough, three readings are needed + { + lowbat::Guard g; + g.configure(true, 3.30f); + CHECK(!g.feed(0, 3.20f, true)); + CHECK(!g.feed(100, 3.20f, true)); // a minute and a half later, but only the second reading + CHECK(g.feed(110, 3.20f, true)); + } + // one dip is nothing + { + lowbat::Guard g; + g.configure(true, 3.30f); + uint32_t t = 0; + for (int i = 0; i < 100; i++, t += 5) { + const float v = (i % 20 == 7) ? 3.10f : 3.55f; + CHECK(!g.feed(t, v, true)); + } + CHECK(!g.tripping()); + } + // dips every 30 s, each followed by a good reading: never trips + { + lowbat::Guard g; + g.configure(true, 3.30f); + uint32_t t = 0; + for (int i = 0; i < 200; i++, t += 5) CHECK(!g.feed(t, (i % 6 == 0) ? 3.20f : 3.40f, true)); + } + // hovering within the hysteresis band keeps the countdown running + { + lowbat::Guard g; + g.configure(true, 3.30f); + CHECK(!g.feed(0, 3.29f, true)); + CHECK(!g.feed(5, 3.31f, true)); // above the cut-off but not by the margin: neither trips nor cancels + CHECK(g.tripping()); + CHECK(!g.feed(10, 3.29f, true)); + CHECK(g.feed(65, 3.29f, true)); + CHECK(!g.feed(70, 3.33f, true)); // clearly above: cancelled + CHECK(!g.tripping()); + CHECK(!g.feed(75, 3.29f, true)); // and a fresh minute is needed + } + // charging or USB: the guard stands down + { + lowbat::Guard g; + g.configure(true, 3.30f); + for (uint32_t t = 0; t < 120; t += 5) g.feed(t, 3.28f, true); + CHECK(g.tripping()); + CHECK(!g.feed(125, 3.28f, false)); + CHECK(!g.tripping()); + CHECK(!g.feed(130, 3.28f, true)); // restarts the minute + } + // switched off, and clamped to the limits the setting allows + { + lowbat::Guard g; + g.configure(false, 3.30f); + for (uint32_t t = 0; t < 300; t += 5) CHECK(!g.feed(t, 3.0f, true)); + g.configure(true, 2.0f); + CHECK_NEAR(g.cutoffV(), lowbat::kMinCutoffV, 1e-6); + g.configure(true, 5.0f); + CHECK_NEAR(g.cutoffV(), lowbat::kMaxCutoffV, 1e-6); + g.configure(true, 3.30f); + CHECK(!g.feed(0, 0.0f, true)); // no reading is no verdict + CHECK(!g.feed(5, NAN, true)); + CHECK(!g.tripping()); + } + + // what to do at power-up + CHECK(lowbat::bootDecision(true, 3.90f, 3.30f, false) == BootAction::RUN); + CHECK(lowbat::bootDecision(true, 3.29f, 3.30f, false) == BootAction::STAY_ASLEEP); // flat on a cold start + CHECK(lowbat::bootDecision(true, 3.30f, 3.30f, false) == BootAction::RUN); + // after a shutdown the cell has to come back well above the cut-off + CHECK(lowbat::bootDecision(true, 3.29f, 3.30f, true) == BootAction::STAY_ASLEEP); + CHECK(lowbat::bootDecision(true, 3.45f, 3.30f, true) == BootAction::STAY_ASLEEP); // rebounded, not charged + CHECK(lowbat::bootDecision(true, 3.69f, 3.30f, true) == BootAction::STAY_ASLEEP); + CHECK(lowbat::bootDecision(true, 3.70f, 3.30f, true) == BootAction::RUN); // charging got it there + CHECK(lowbat::bootDecision(true, 4.10f, 3.30f, true) == BootAction::RUN); + // switched off, or no reading + CHECK(lowbat::bootDecision(false, 3.0f, 3.30f, true) == BootAction::RUN); + CHECK(lowbat::bootDecision(true, 0.0f, 3.30f, true) == BootAction::RUN); + CHECK(lowbat::bootDecision(true, NAN, 3.30f, false) == BootAction::RUN); + // the restart level sits above anything the rebound of a just-unloaded cell can reach + CHECK(lowbat::kRestartV >= lowbat::kMaxCutoffV + 0.05f); +} + +// =========================================================================== +// Clock drift +// =========================================================================== +void testDrift() { + section("clock drift"); + const int64_t kHour = 3600LL * 1000000; + // the clock runs `ppm` fast (positive) or slow; syncs every `everySec` seconds, each with + // `jitterMs` of network error, from `startMono` + auto run = [&](double ppm, double jitterMs, int hours, int everySec, uint32_t seed, drift::Tracker *tr) { + uint32_t rng = seed; + auto noise = [&]() { + rng = rng * 1664525u + 1013904223u; + return (((rng >> 8) & 0xFFFFFF) / 16777216.0 - 0.5) * 2 * jitterMs; // uniform +-jitter + }; + const int64_t mono0 = 12345678; + const int64_t sys0 = 1760000000LL * 1000000; + for (int64_t s = 0; s <= (int64_t)hours * 3600; s += everySec) { + const int64_t mono = mono0 + s * 1000000; + // true time = sys0 + s; the crystal-driven timer reads s*(1 - ppm*1e-6) of it... a fast clock gains: its + // timer advances more than true time + const double trueElapsedUs = (double)s * 1e6 / (1.0 + ppm * 1e-6); + const int64_t sysNtp = sys0 + (int64_t)(trueElapsedUs + noise() * 1000.0); + tr->addSync(mono, sysNtp); + } + }; + + for (double ppm : {0.0, 2.0, -2.0, 10.0, -10.0, 25.0}) { + drift::Tracker tr; + run(ppm, 30, 24, 3600, 99, &tr); + const drift::Result r = tr.result(); + CHECK(r.valid && r.points >= 20); + if (fabs(r.ppm - ppm) > 1.0) printf(" true %+.1f ppm, measured %+.2f +- %.2f\n", ppm, r.ppm, r.errorPpm); + CHECK_NEAR(r.ppm, ppm, 1.0); + CHECK_NEAR(r.secPerDay, ppm * 0.0864, 0.1); + CHECK(r.errorPpm > 0 && r.errorPpm < 1.5); + CHECK(r.spanHours > 22 && r.spanHours < 25); + } + // a longer baseline is tighter + { + drift::Tracker a, b; + run(5.0, 40, 6, 3600, 7, &a); + run(5.0, 40, 40, 3600, 7, &b); + CHECK(a.result().valid && b.result().valid); + CHECK(b.result().errorPpm < a.result().errorPpm); + CHECK_NEAR(b.result().ppm, 5.0, 0.6); + } + // not enough history: no figure + { + drift::Tracker tr; + CHECK(!tr.result().valid); + run(5.0, 20, 2, 3600, 3, &tr); // 3 points over 2 hours + CHECK(!tr.result().valid); + drift::Tracker t2; + run(5.0, 20, 3, 3600, 3, &t2); // 4 points over 3 hours + CHECK(t2.result().valid); + } + // an outlier sync (a bad network path, 400 ms off) does not move the figure much, wherever it is + for (int badHour : {0, 1, 11, 23, 24}) { + drift::Tracker tr; + const int64_t mono0 = 1000000; + const int64_t sys0 = 1760000000LL * 1000000; + for (int h = 0; h <= 24; h++) { + int64_t sys = sys0 + (int64_t)h * kHour; + if (h == badHour) sys += 400000; // 400 ms late + tr.addSync(mono0 + (int64_t)h * kHour, sys); + } + const drift::Result r = tr.result(); + CHECK(r.valid); + if (fabs(r.ppm) > 0.5) printf(" outlier at hour %d: %+.2f ppm\n", badHour, r.ppm); + CHECK_NEAR(r.ppm, 0.0, 0.5); + } + // four points are not enough if they are close together + { + drift::Tracker tr; + for (int i = 0; i < 4; i++) tr.addSync(1000000LL + i * 30LL * 60 * 1000000, 1760000000LL * 1000000 + i * 30LL * 60 * 1000000); + CHECK(tr.points() == 4 && !tr.result().valid); // an hour and a half of history + } + // syncs closer than half an hour are not new information + { + drift::Tracker tr; + for (int i = 0; i < 100; i++) tr.addSync(1000000LL + i * 60LL * 1000000, 1760000000LL * 1000000 + i * 60LL * 1000000); + CHECK(tr.points() == 4); // 0, 30, 60, 90 minutes + } + // the timer restarting (a reboot) forgets the old points; the ring keeps the newest + { + drift::Tracker tr; + run(3.0, 10, 10, 3600, 5, &tr); + CHECK(tr.points() == 11); + tr.addSync(500, 1760000000LL * 1000000); // the timer is back near zero + CHECK(tr.points() == 1 && !tr.result().valid); + drift::Tracker many; + run(3.0, 10, 200, 3600, 5, &many); + CHECK(many.points() == drift::kMaxPoints); + CHECK_NEAR(many.result().ppm, 3.0, 0.5); + } +} + +// =========================================================================== +// Backup WiFi +// =========================================================================== +void testBackupWifiSettings() { + section("backup WiFi: settings"); + // the names people will try for the backup network and its password + const char *ssidNames[] = {"wifi_backup_ssid", "WiFi backup SSID", "Wi-Fi-Backup-SSID", "backup_ssid", "Backup SSID", + "backup-wifi", "backup_network", "ssid2", "SSID 2", "wifi_ssid2", "second_ssid", + "fallback_ssid", "alt_ssid"}; + for (const char *k : ssidNames) { + const Applied a = apply(std::string(k) + " = Phone Hotspot"); + if (strcmp(a.s.wifiBackupSsid, "Phone Hotspot") != 0) printf(" [%s] gave [%s]\n", k, a.s.wifiBackupSsid); + CHECK_STR(a.s.wifiBackupSsid, "Phone Hotspot"); + CHECK_STR(a.s.wifiSsid, ""); // the main network is left alone + CHECK(a.r.problems() == 0 && a.r.changed == 1); + CHECK(settingIsUserSet(a.s, idx("wifi_backup_ssid")) && !settingIsUserSet(a.s, idx("wifi_ssid"))); + } + const char *passNames[] = {"wifi_backup_password", "WiFi backup password", "backup_password", "backup_pass", + "backup_wifi_password", "password2", "pass2", "wifi_password2", "second_password", + "fallback_password", "alt_password", "backup_key"}; + for (const char *k : passNames) { + const Applied a = apply(std::string(k) + " = \"h0tsp0t pw\""); + if (strcmp(a.s.wifiBackupPassword, "h0tsp0t pw") != 0) printf(" [%s] gave [%s]\n", k, a.s.wifiBackupPassword); + CHECK_STR(a.s.wifiBackupPassword, "h0tsp0t pw"); + CHECK_STR(a.s.wifiPassword, ""); + CHECK(a.r.problems() == 0 && a.r.changed == 1); + } + // the plain names still mean the main network + const Applied both = apply("ssid = Home\npassword = homepw\nbackup_ssid = Phone\nbackup_password = phonepw\n"); + CHECK_STR(both.s.wifiSsid, "Home"); + CHECK_STR(both.s.wifiPassword, "homepw"); + CHECK_STR(both.s.wifiBackupSsid, "Phone"); + CHECK_STR(both.s.wifiBackupPassword, "phonepw"); + CHECK(both.s.hasMainWifi() && both.s.hasBackupWifi()); + + // quoting works as for the main network + CHECK_STR(apply("wifi_backup_ssid = \"Bob's Phone\"").s.wifiBackupSsid, "Bob's Phone"); + CHECK_STR(apply("wifi_backup_ssid = Home#1").s.wifiBackupSsid, "Home#1"); + CHECK_STR(apply("wifi_backup_ssid = Phone # my hotspot").s.wifiBackupSsid, "Phone"); + + // limits: 32 characters for a name, 64 for a password + CHECK(apply("wifi_backup_ssid = " + std::string(32, 'x')).r.bad == 0); + CHECK(apply("wifi_backup_ssid = " + std::string(33, 'x')).r.bad == 1); + CHECK(apply("wifi_backup_password = " + std::string(64, 'x')).r.bad == 0); + CHECK(apply("wifi_backup_password = " + std::string(65, 'x')).r.bad == 1); + + // forgetting the backup, and an open backup network + Settings has; + snprintf(has.wifiBackupSsid, sizeof has.wifiBackupSsid, "Phone"); + snprintf(has.wifiBackupPassword, sizeof has.wifiBackupPassword, "pw"); + has.userSet |= (1u << idx("wifi_backup_ssid")) | (1u << idx("wifi_backup_password")); + const Applied gone = apply("wifi_backup_ssid =\nwifi_backup_password =", has); + CHECK_STR(gone.s.wifiBackupSsid, ""); + CHECK_STR(gone.s.wifiBackupPassword, ""); + CHECK(gone.r.cleared == 2 && !gone.s.hasBackupWifi()); + const Applied openNet = apply("wifi_backup_password = \"\"", has); + CHECK_STR(openNet.s.wifiBackupPassword, ""); + CHECK_STR(openNet.s.wifiBackupSsid, "Phone"); + CHECK(settingIsUserSet(openNet.s, idx("wifi_backup_password"))); + + // what counts as having a backup + Settings s; + CHECK(!s.hasMainWifi() && !s.hasBackupWifi()); + snprintf(s.wifiBackupSsid, sizeof s.wifiBackupSsid, "Phone"); + CHECK(!s.hasMainWifi() && s.hasBackupWifi()); // a backup alone is still a network to join + snprintf(s.wifiSsid, sizeof s.wifiSsid, "Home"); + CHECK(s.hasMainWifi() && s.hasBackupWifi()); + snprintf(s.wifiBackupSsid, sizeof s.wifiBackupSsid, "Home"); + CHECK(!s.hasBackupWifi()); // the same network twice is no backup + snprintf(s.wifiBackupSsid, sizeof s.wifiBackupSsid, "home"); + CHECK(s.hasBackupWifi()); // names are case sensitive + + // flash round trip + Settings t; + CHECK(applyStoredSetting(t, idx("wifi_backup_ssid"), "Phone Hotspot")); + CHECK(applyStoredSetting(t, idx("wifi_backup_password"), "h0tsp0t pw")); + CHECK_STR(t.wifiBackupSsid, "Phone Hotspot"); + CHECK_STR(t.wifiBackupPassword, "h0tsp0t pw"); + CHECK(fmt(t, idx("wifi_backup_ssid")) == "Phone Hotspot" && fmt(t, idx("wifi_backup_password")) == "h0tsp0t pw"); + + // the password stays out of the example file and of the summary lines; the name may be shown + Settings withBoth = both.s; + snprintf(withBoth.wifiBackupPassword, sizeof withBoth.wifiBackupPassword, "backup-pw-123"); + static char buf[16384]; + const size_t n = renderExampleConfig(withBoth, "", buf, sizeof buf); + CHECK(n > 0 && strstr(buf, "backup-pw-123") == nullptr && strstr(buf, "homepw") == nullptr); + CHECK(strstr(buf, "# wifi_backup_password = \"your password\"") != nullptr); + CHECK(strstr(buf, "# wifi_backup_ssid = \"Phone\"") != nullptr); +} + +// =========================================================================== +// WiFi mode and the idle CPU clock +// =========================================================================== +void testPowerSettings() { + section("WiFi mode and idle clock settings"); + const Settings d; + CHECK(d.wifiMode == WIFIMODE_ALWAYS && !d.syncMode()); // nothing changes unless asked for + CHECK(d.cpuIdle == CPUIDLE_OFF && d.cpuIdleMhz() == 0); + CHECK_STR(settingNvsKey(idx("wifi_mode")), "wmode"); + CHECK_STR(settingNvsKey(idx("cpu_idle_mhz")), "cpuidle"); + + for (const char *v : {"sync", "Sync", "sync-only", "periodic", "saver", " SYNC "}) { + const Applied a = apply(std::string("wifi_mode = ") + v); + if (a.s.wifiMode != WIFIMODE_SYNC) printf(" [%s] was not sync\n", v); + CHECK(a.s.wifiMode == WIFIMODE_SYNC && a.r.problems() == 0 && a.s.syncMode()); + } + for (const char *v : {"always", "on", "connected", "stay"}) { + Settings sync; + sync.wifiMode = WIFIMODE_SYNC; + const Applied a = apply(std::string("wifi_mode = ") + v, sync); + CHECK(a.s.wifiMode == WIFIMODE_ALWAYS && a.r.problems() == 0); + } + for (const char *v : {"sometimes", "1", "wifi", "30"}) { + const Applied a = apply(std::string("wifi_mode = ") + v); + if (a.r.bad != 1) printf(" [%s] was not refused\n", v); + CHECK(a.r.bad == 1 && a.s.wifiMode == WIFIMODE_ALWAYS); + } + CHECK(apply("WiFi Mode: sync").s.syncMode()); + CHECK(apply("wifi_sync = sync").s.syncMode()); // an alias + CHECK(apply("radio-mode = sync").s.wifiMode == WIFIMODE_SYNC); + { // sync mode needs the radio: with WiFi off it is moot + const Applied a = apply("wifi_mode = sync\nwifi = off"); + CHECK(a.s.wifiMode == WIFIMODE_SYNC && !a.s.syncMode()); + } + + // the idle clock: a number, off, or the usual "MHz" after it + struct Case { + const char *text; + int mhz; // the value, 0 = off + }; + const Case cases[] = {{"off", 0}, {"same", 0}, {"none", 0}, {"no", 0}, {"0", 0}, {"80", 80}, {"80 MHz", 80}, {"80mhz", 80}, + {"40", 40}, {"40 MHz", 40}, {"20", 20}, {"20MHz", 20}}; + for (const Case &c : cases) { + Settings fast; + fast.cpuSpeed = CPU_240; // so that every idle value is below it + const Applied a = apply(std::string("cpu_idle_mhz = ") + c.text, fast); + if (a.r.problems() != 0 || a.s.cpuIdleMhz() != c.mhz) printf(" [%s] gave %d (problems %d)\n", c.text, a.s.cpuIdleMhz(), a.r.problems()); + CHECK(a.r.problems() == 0 && a.s.cpuIdleMhz() == c.mhz); + } + // 10 MHz is not on offer (a frame would take a quarter of a second to draw), nor is anything in between + for (const char *v : {"10", "10 MHz", "10mhz", "30", "5", "160", "240", "fast", "20.5"}) { + const Applied a = apply(std::string("cpu_idle_mhz = ") + v); + if (a.r.bad != 1) printf(" [%s] was not refused\n", v); + CHECK(a.r.bad == 1 && a.s.cpuIdle == CPUIDLE_OFF); + } + CHECK(apply("cpu idle = 40").s.cpuIdle == CPUIDLE_40); // spelling and aliases + CHECK(apply("idle_clock = 20").s.cpuIdle == CPUIDLE_20); + CHECK(apply("CPU-Idle-MHz: 20").s.cpuIdle == CPUIDLE_20); + { // a value an older firmware saved in flash and this one no longer has is ignored, not misread + Settings old; + char err[64] = ""; + CHECK(!applyStoredSetting(old, idx("cpu_idle_mhz"), "10", err, sizeof err) && old.cpuIdle == CPUIDLE_OFF && err[0]); + CHECK(applyStoredSetting(old, idx("cpu_idle_mhz"), "20") && old.cpuIdle == CPUIDLE_20); + } + + // an idle clock that is not below the working one means "no change": the working clock is the floor + Settings s; + s.cpuIdle = CPUIDLE_80; + s.cpuSpeed = CPU_80; + CHECK(s.cpuIdleMhz() == 0); + s.cpuSpeed = CPU_160; + CHECK(s.cpuIdleMhz() == 80); + s.cpuIdle = CPUIDLE_40; + s.cpuSpeed = CPU_80; + CHECK(s.cpuIdleMhz() == 40); + s.cpuIdle = CPUIDLE_20; + s.cpuSpeed = CPU_240; + CHECK(s.cpuIdleMhz() == 20); + s.cpuIdle = (uint8_t)(CPUIDLE_20 + 1); // a number that is no choice (it was 10 MHz once): no idle clock + CHECK(s.cpuIdleMhz() == 0); + + // the console: on by default; auto and off by the usual words + CHECK(d.console == CONSOLE_ON); + CHECK_STR(settingNvsKey(idx("console")), "console"); + struct ConsoleCase { + const char *text; + int mode; + }; + const ConsoleCase consoleCases[] = {{"on", CONSOLE_ON}, {"On", CONSOLE_ON}, {"yes", CONSOLE_ON}, {"always", CONSOLE_ON}, {"true", CONSOLE_ON}, + {"1", CONSOLE_ON}, {"auto", CONSOLE_AUTO}, {"AUTO", CONSOLE_AUTO}, {"usb", CONSOLE_AUTO}, + {"automatic", CONSOLE_AUTO}, {"off", CONSOLE_OFF}, {"no", CONSOLE_OFF}, {"never", CONSOLE_OFF}, + {"false", CONSOLE_OFF}, {"0", CONSOLE_OFF}, {" Off ", CONSOLE_OFF}}; + for (const ConsoleCase &c : consoleCases) { + Settings other; // start from a value that is not the one asked for + other.console = (uint8_t)(c.mode == CONSOLE_AUTO ? CONSOLE_OFF : CONSOLE_AUTO); + const Applied a = apply(std::string("console = ") + c.text, other); + if (a.r.problems() != 0 || a.s.console != c.mode) printf(" console [%s] gave %d (problems %d)\n", c.text, a.s.console, a.r.problems()); + CHECK(a.r.problems() == 0 && a.s.console == c.mode); + } + for (const char *v : {"maybe", "2", "quiet", "115200"}) { + const Applied a = apply(std::string("console = ") + v); + if (a.r.bad != 1) printf(" console [%s] was not refused\n", v); + CHECK(a.r.bad == 1 && a.s.console == CONSOLE_ON); + } + CHECK(apply("serial = off").s.console == CONSOLE_OFF); // the names people will try + CHECK(apply("Serial Console: auto").s.console == CONSOLE_AUTO); + CHECK(apply("usb_console = off").s.console == CONSOLE_OFF); + CHECK(apply("console-mode = auto").s.console == CONSOLE_AUTO); + CHECK(apply("log = off").s.console == CONSOLE_OFF); + + // Spotify while music plays, in sync mode: live by default, "off" lets the radio sleep + CHECK(d.spotifyLive); + CHECK_STR(settingNvsKey(idx("spotify_live")), "splive"); + CHECK(!apply("spotify_live = off").s.spotifyLive && apply("spotify_live = off").r.problems() == 0); + CHECK(!apply("Spotify Live: no").s.spotifyLive); + CHECK(!apply("spotify_follow = off").s.spotifyLive && !apply("spotify-stay-connected = 0").s.spotifyLive); + { + Settings off; + off.spotifyLive = false; + CHECK(apply("spotify_live = on", off).s.spotifyLive); + CHECK(!apply("spotify_live = sometimes", off).s.spotifyLive && apply("spotify_live = sometimes", off).r.bad == 1); + } + + // the power log: off unless it is asked for (it writes to the flash) + CHECK(!d.powerLog); + CHECK_STR(settingNvsKey(idx("power_log")), "plog"); + CHECK(apply("power_log = on").s.powerLog && apply("power_log = on").r.problems() == 0); + CHECK(apply("Power Log: yes").s.powerLog && apply("battery_log = on").s.powerLog && apply("log-power = 1").s.powerLog); + { + Settings on; + on.powerLog = true; + CHECK(!apply("power_log = off", on).s.powerLog && !apply("POWERLOG = no", on).s.powerLog); + CHECK(apply("power_log = hourly", on).s.powerLog && apply("power_log = hourly", on).r.bad == 1); // a value it does not know changes nothing + CHECK(apply("log = off", on).s.powerLog && apply("log = off", on).s.console == CONSOLE_OFF); // "log" alone is the console's name + CHECK(settingIsUserSet(apply("power_log = off", on).s, idx("power_log"))); // the 32nd bit of the mask is a bit like the others + CHECK(!settingIsUserSet(apply("power_log =", apply("power_log = on").s).s, idx("power_log"))); + } + + // they come out of the example file, with their help text + static char buf[16384]; + Settings cur; + cur.wifiMode = WIFIMODE_SYNC; + cur.cpuIdle = CPUIDLE_20; + cur.console = CONSOLE_AUTO; + cur.spotifyLive = false; + cur.powerLog = true; + CHECK(renderExampleConfig(cur, "", buf, sizeof buf) > 0); + CHECK(strstr(buf, "# wifi_mode = sync") != nullptr && strstr(buf, "# cpu_idle_mhz = 20") != nullptr); + CHECK(strstr(buf, "# console = auto") != nullptr && strstr(buf, "# spotify_live = off") != nullptr); + CHECK(strstr(buf, "# power_log = on") != nullptr && strstr(buf, "ESP32-S3-RLCD-PowerLog.csv") != nullptr); + CHECK(strstr(buf, "off, 80, 40 or 20:") != nullptr && strstr(buf, "20 or 10") == nullptr); // the help does not offer 10 MHz + const Applied back = apply(buf); // and read back, they give the same + CHECK(back.s.wifiMode == WIFIMODE_ALWAYS); // (everything commented out: nothing is applied) +} + +// =========================================================================== +// Battery capacity and voltage calibration +// =========================================================================== +void testBatteryCalibrationSetting() { + section("battery calibration and capacity settings"); + const Settings d; + CHECK_NEAR(d.batteryCalibration, 1.0, 1e-9); // no correction unless asked for + CHECK(d.batteryCapacityMah == 0); + CHECK(!settingIsSecret(idx("battery_calibration"))); + CHECK_STR(settingNvsKey(idx("battery_calibration")), "batcal"); + + // the names people will try + const char *names[] = {"battery_calibration", "Battery Calibration", "Battery-Calibration", "battery_cal", "adc_calibration", + "ADC calibration", "voltage_calibration", "battery_scale", "battery_gain", "adc_scale"}; + for (const char *k : names) { + const Applied a = apply(std::string(k) + " = 1.016"); + if (fabs(a.s.batteryCalibration - 1.016) > 1e-6) printf(" [%s] gave %g\n", k, (double)a.s.batteryCalibration); + CHECK_NEAR(a.s.batteryCalibration, 1.016, 1e-6); + CHECK(a.r.problems() == 0 && a.r.changed == 1); + CHECK(settingIsUserSet(a.s, idx("battery_calibration"))); + } + // spellings of the number: a decimal comma, four decimals + CHECK_NEAR(apply("battery_calibration = 1,016").s.batteryCalibration, 1.016, 1e-6); + CHECK_NEAR(apply("battery_calibration = \"1.0157\"").s.batteryCalibration, 1.0157, 1e-6); + CHECK_NEAR(apply("battery_calibration = 0.9847").s.batteryCalibration, 0.9847, 1e-6); + // the range is 0.80 to 1.25, inclusive; anything else is a mistake and changes nothing + CHECK_NEAR(apply("battery_calibration = 0.8").s.batteryCalibration, 0.8, 1e-6); + CHECK_NEAR(apply("battery_calibration = 1.25").s.batteryCalibration, 1.25, 1e-6); + for (const char *bad : {"0.79", "1.26", "0", "-1", "2", "abc", "1.0.1"}) { + const Applied a = apply(std::string("battery_calibration = ") + bad); + if (a.r.bad != 1) printf(" [%s] was not refused\n", bad); + CHECK(a.r.bad == 1 && a.r.changed == 0); + CHECK_NEAR(a.s.batteryCalibration, 1.0, 1e-9); + } + // forgetting it brings the default back (a build can have its own: here the pristine one) + Settings set; + set.batteryCalibration = 1.03f; + set.userSet |= 1u << idx("battery_calibration"); + const Applied gone = apply("battery_calibration =", set); + CHECK_NEAR(gone.s.batteryCalibration, 1.0, 1e-9); + CHECK(gone.r.cleared == 1 && !settingIsUserSet(gone.s, idx("battery_calibration"))); + Settings withDefault; + withDefault.batteryCalibration = 1.02f; // config.h or secrets.h said so + Settings fromFlash = withDefault; + const std::string text = "battery_calibration = 1.05\n"; + Settings defaults = withDefault; + applyConfigText(fromFlash, defaults, text.data(), text.size()); + CHECK_NEAR(fromFlash.batteryCalibration, 1.05, 1e-6); + const std::string forget = "battery_calibration =\n"; + applyConfigText(fromFlash, defaults, forget.data(), forget.size()); + CHECK_NEAR(fromFlash.batteryCalibration, 1.02, 1e-6); // back to the build's own value, not to 1 + + // the figure survives flash (it is stored as text) with its four decimals + Settings w; + w.batteryCalibration = 1.0157f; + Settings x; + CHECK(fmt(w, idx("battery_calibration")) == "1.0157"); + CHECK(applyStoredSetting(x, idx("battery_calibration"), fmt(w, idx("battery_calibration")).c_str())); + CHECK_NEAR(x.batteryCalibration, 1.0157, 1e-6); + CHECK(fmt(Settings(), idx("battery_calibration")) == "1"); + + // the capacity: more names, and a unit + CHECK(apply("battery_capacity = 2500").s.batteryCapacityMah == 2500); + CHECK(apply("capacity_mah = 3400").s.batteryCapacityMah == 3400); + CHECK(apply("Battery Capacity mAh = \"2600 mAh\"").s.batteryCapacityMah == 2600); + + // the example file explains the calibration and names the serial command + static char buf[16384]; + const size_t n = renderExampleConfig(Settings(), "", buf, sizeof buf); + CHECK(n > 0); + CHECK(strstr(buf, "# battery_calibration = 1\n") != nullptr); + CHECK(strstr(buf, "batcal 4.20") != nullptr); + CHECK(strstr(buf, "# battery_capacity_mah = 0\n") != nullptr); +} + +void testWifiPick() { + section("backup WiFi: which network next"); + using namespace wifipick; + const uint32_t kMin = 60UL * 1000UL; + + { // no names, one name + Picker none; + none.configure(false, false); + CHECK(none.nextAttempt() == NET_NONE && none.nextAttempt() == NET_NONE); + Picker onlyMain; + onlyMain.configure(true, false); + for (int i = 0; i < 6; i++) CHECK(onlyMain.nextAttempt() == NET_MAIN); + onlyMain.joined(NET_MAIN, 0); + CHECK(!onlyMain.timeToLookForMain(100 * 60 * kMin) && !onlyMain.onBackup()); + Picker onlyBackup; + onlyBackup.configure(false, true); + for (int i = 0; i < 6; i++) CHECK(onlyBackup.nextAttempt() == NET_BACKUP); + onlyBackup.joined(NET_BACKUP, 0); + CHECK(onlyBackup.onBackup() && !onlyBackup.timeToLookForMain(100 * 60 * kMin)); // nothing to go back to + } + + { // both: main first, then they alternate + Picker p; + p.configure(true, true); + const Net want[] = {NET_MAIN, NET_BACKUP, NET_MAIN, NET_BACKUP, NET_MAIN}; + for (Net w : want) CHECK(p.nextAttempt() == w); + } + + { // after a connection is lost the main network is tried first again, whatever was tried last + Picker p; + p.configure(true, true); + CHECK(p.nextAttempt() == NET_MAIN); + p.joined(NET_MAIN, 1000); + p.lost(); + CHECK(p.nextAttempt() == NET_MAIN); // not the backup, although the last attempt was the main one + CHECK(p.nextAttempt() == NET_BACKUP); + p.joined(NET_BACKUP, 5000); + CHECK(p.onBackup() && p.current() == NET_BACKUP); + p.lost(); + CHECK(p.current() == NET_NONE && !p.onBackup()); + CHECK(p.nextAttempt() == NET_MAIN); + } + + { // looking for the main network from the backup: not before 10 minutes, then every 10 + Picker p; + p.configure(true, true); + const uint32_t t0 = 123456; + p.joined(NET_BACKUP, t0); + CHECK(!p.timeToLookForMain(t0) && !p.timeToLookForMain(t0 + 10 * kMin - 1)); + CHECK(p.timeToLookForMain(t0 + 10 * kMin) && p.timeToLookForMain(t0 + 3 * 60 * kMin)); + p.lookedForMain(t0 + 10 * kMin, false); // it was not there + CHECK(!p.timeToLookForMain(t0 + 10 * kMin + 1) && !p.timeToLookForMain(t0 + 20 * kMin - 1)); + CHECK(p.timeToLookForMain(t0 + 20 * kMin)); + p.joined(NET_MAIN, t0 + 30 * kMin); + CHECK(!p.timeToLookForMain(t0 + 300 * kMin)); // on the main network there is nothing to look for + Picker q; + q.configure(true, true); + q.joined(NET_BACKUP, 0); + q.lost(); + CHECK(!q.timeToLookForMain(100 * 60 * kMin)); // not connected: no looking either + } + + { // the millisecond counter wraps after 49 days: the timing must not care + Picker p; + p.configure(true, true); + const uint32_t t0 = 0xFFFFFFFFu - 3 * kMin; + p.joined(NET_BACKUP, t0); + // the deadline wrapped past zero; the clock itself has not yet (for the first 3 minutes), and a plain + // "now >= deadline" would call that moment late already + CHECK(!p.timeToLookForMain(t0) && !p.timeToLookForMain(t0 + 1 * kMin) && !p.timeToLookForMain(t0 + 2 * kMin)); + CHECK(!p.timeToLookForMain(t0 + 5 * kMin)); // after the wrap, before the deadline + CHECK(!p.timeToLookForMain(t0 + 10 * kMin - 1)); + CHECK(p.timeToLookForMain(t0 + 10 * kMin)); + } + + { // going back fails again and again: the looks come further apart (20, 40, 60, 60 minutes) + Picker p; + p.configure(true, true); + uint32_t now = 1000; + p.joined(NET_BACKUP, now); + CHECK(p.lookInterval() == 10 * kMin); + const uint32_t gaps[] = {20 * kMin, 40 * kMin, 60 * kMin, 60 * kMin, 60 * kMin}; + for (uint32_t gap : gaps) { + now += p.lookInterval(); + CHECK(p.timeToLookForMain(now)); + p.lookedForMain(now, true); // the main network is in range: leave + p.lost(); + CHECK(p.nextAttempt() == NET_MAIN); // ... but it cannot be joined + CHECK(p.nextAttempt() == NET_BACKUP); // so back to the backup + now += 40 * 1000; + p.joined(NET_BACKUP, now); + CHECK(p.lookInterval() == gap); + CHECK(!p.timeToLookForMain(now + gap - 1) && p.timeToLookForMain(now + gap)); + } + // the main network is joined at last: the next stay on the backup starts over at 10 minutes + now += p.lookInterval(); + p.lookedForMain(now, true); + p.lost(); + CHECK(p.nextAttempt() == NET_MAIN); + p.joined(NET_MAIN, now + 5000); + CHECK(p.lookInterval() == 10 * kMin); + p.lost(); + p.joined(NET_BACKUP, now + 20 * kMin); + CHECK(p.lookInterval() == 10 * kMin && p.timeToLookForMain(now + 30 * kMin)); + } + + { // a backup that drops for its own reasons is not a failed return + Picker p; + p.configure(true, true); + p.joined(NET_BACKUP, 0); + p.lost(); + CHECK(p.nextAttempt() == NET_MAIN && p.nextAttempt() == NET_BACKUP); + p.joined(NET_BACKUP, 1000); + CHECK(p.lookInterval() == 10 * kMin); + // looking and finding nothing does not count either + p.lookedForMain(10 * kMin, false); + p.lookedForMain(20 * kMin, false); + CHECK(p.lookInterval() == 10 * kMin); + } + + // which of the two a connection is, by the name the stack reports + CHECK(classify("Home", "Home", "Phone") == NET_MAIN); + CHECK(classify("Phone", "Home", "Phone") == NET_BACKUP); + CHECK(classify("Elsewhere", "Home", "Phone") == NET_MAIN); // a name we do not know: the main one + CHECK(classify("Phone", "", "Phone") == NET_BACKUP); // only a backup is set + CHECK(classify("Elsewhere", "", "Phone") == NET_BACKUP); + CHECK(classify("Home", "Home", "") == NET_MAIN); + CHECK(classify("", "Home", "Phone") == NET_MAIN); + CHECK(classify("home", "Home", "Phone") == NET_MAIN && classify("PHONE", "Home", "Phone") == NET_MAIN); // case matters +} + +// A day in the life of the clock: a model of the radio around the Picker, one second at a time. An +// attempt takes 20 s to give up and 3 s to succeed, as in net_task.cpp. +void testWifiPickDay() { + section("backup WiFi: a simulated day"); + using namespace wifipick; + struct Sim { + Picker pick; + bool mainUp = true, backupUp = true; + Net on = NET_NONE; // connected to + uint32_t attemptAt = 0, joinAt = 0; + Net trying = NET_NONE; + int scans = 0, scansOnMain = 0, switches = 0; + uint32_t joinedMain = 0; + + void step(uint32_t nowMs) { + if (on != NET_NONE && ((on == NET_MAIN && !mainUp) || (on == NET_BACKUP && !backupUp))) { + on = NET_NONE; + pick.lost(); + attemptAt = nowMs ? nowMs : 1; // the stack's own reconnect gets 20 s, as in the sketch + trying = NET_NONE; + } + if (on == NET_NONE) { + if (trying != NET_NONE && nowMs >= joinAt) { + on = trying; + trying = NET_NONE; + pick.joined(on, nowMs); + if (on == NET_MAIN) joinedMain = nowMs; + } else if (attemptAt == 0 || nowMs - attemptAt > 20000) { + trying = pick.nextAttempt(); + const bool up = trying == NET_MAIN ? mainUp : backupUp; + attemptAt = nowMs ? nowMs : 1; + joinAt = up ? nowMs + 3000 : 0xFFFFFFFFu; + if (!up) trying = NET_NONE; + } + return; + } + if (pick.timeToLookForMain(nowMs)) { + scans++; + const bool seen = mainUp; + pick.lookedForMain(nowMs, seen); + if (seen) { + switches++; + on = NET_NONE; + pick.lost(); + attemptAt = 0; // start on the main network at once + trying = NET_NONE; + } + } else if (on == NET_MAIN) { + // nothing: the clock sits on the main network + } + } + }; + + Sim sim; + sim.pick.configure(true, true); + sim.mainUp = false; // away from home: only the phone is there + uint32_t t = 1000; + uint32_t onBackupAt = 0; + for (; t < 2 * 3600 * 1000UL; t += 1000) { + sim.step(t); + if (sim.on == NET_BACKUP && !onBackupAt) onBackupAt = t; + } + CHECK(onBackupAt > 0 && onBackupAt <= 1000 + 20000 + 3000 + 1000); // main fails (20 s), the backup works (3 s) + CHECK(sim.on == NET_BACKUP); + CHECK(sim.scans >= 11 && sim.scans <= 12); // a look every 10 minutes, nothing found + + const uint32_t homeAt = t; // the main network is up again + sim.mainUp = true; + for (; t < homeAt + 20 * 60 * 1000UL; t += 1000) sim.step(t); + CHECK(sim.on == NET_MAIN); + CHECK(sim.joinedMain > homeAt && sim.joinedMain <= homeAt + 10 * 60 * 1000UL + 5000); // back within one look interval + CHECK(sim.switches == 1); + + const int scansBefore = sim.scans; + for (; t < homeAt + 3 * 3600 * 1000UL; t += 1000) sim.step(t); + CHECK(sim.scans == scansBefore && sim.on == NET_MAIN); // no scanning while on the main network + + // the router dies: the clock ends up on the backup within a minute, and comes back when the router does + sim.mainUp = false; + const uint32_t deadAt = t; + uint32_t backAt = 0; + for (; t < deadAt + 3 * 60 * 1000UL; t += 1000) { + sim.step(t); + if (sim.on == NET_BACKUP && !backAt) backAt = t; + } + CHECK(backAt > deadAt && backAt <= deadAt + 20000 + 20000 + 3000 + 2000); // head start, main (20 s), backup + sim.mainUp = true; + const uint32_t routerAt = t; + for (; t < routerAt + 15 * 60 * 1000UL; t += 1000) sim.step(t); + CHECK(sim.on == NET_MAIN); + + // both gone, then the backup alone comes back: the clock finds it + sim.mainUp = false; + sim.backupUp = false; + for (int i = 0; i < 600; i++, t += 1000) sim.step(t); + CHECK(sim.on == NET_NONE); + sim.backupUp = true; + for (int i = 0; i < 90; i++, t += 1000) sim.step(t); + CHECK(sim.on == NET_BACKUP); +} + +// =========================================================================== +// Firmware update from the SD card +// =========================================================================== +void testFirmwareLogic() { + section("firmware update: which file is accepted"); + using namespace fwlogic; + + // the real thing: the first bytes of an exported build of this sketch, and of the bootloader the IDE exports with it + const std::string app = readFile("fixtures/esp32s3_app_head.bin"); + const std::string boot = readFile("fixtures/esp32s3_bootloader_head.bin"); + CHECK(app.size() == kHeadBytes && boot.size() == kHeadBytes); + const uint8_t *appHead = (const uint8_t *)app.data(), *bootHead = (const uint8_t *)boot.data(); + const uint32_t kRealSize = 1467536; // that build's .ino.bin + const uint32_t kSlot = 0x300000; // an app slot of the README's partition scheme + CHECK(inspectImage(appHead, app.size(), kRealSize, kSlot) == Problem::NONE); + CHECK(elfHash(appHead)[0] == 0xb0 && elfHash(appHead)[1] == 0x1e && elfHash(appHead)[2] == 0x2f && elfHash(appHead)[3] == 0x9c); + CHECK(le32(appHead + 32) == kAppDescMagic); // (the offsets above are the real ones) + // the other files the IDE exports next to it are refused, each for its own reason + CHECK(inspectImage(bootHead, boot.size(), 19984, kSlot) == Problem::TOO_SMALL); // ...bootloader.bin + CHECK(inspectImage(bootHead, boot.size(), 3072, kSlot) == Problem::TOO_SMALL); // ...partitions.bin + CHECK(inspectImage(bootHead, boot.size(), 16777216, kSlot) == Problem::TOO_BIG); // ...merged.bin: 16 MB, starts like a bootloader + CHECK(inspectImage(bootHead, boot.size(), 400 * 1024, kSlot) == Problem::NOT_AN_APP); // a bootloader of a plausible size + + // every header field the check reads, changed in turn on a copy of the real header + auto with = [&](size_t at, int value) { + std::string h = app; + h[at] = (char)value; + return h; + }; + auto problemIn = [&](const std::string &h, uint32_t size, uint32_t slot) { + return inspectImage((const uint8_t *)h.data(), h.size(), size, slot); + }; + auto problemOf = [&](const std::string &h, uint32_t size) { return problemIn(h, size, kSlot); }; + CHECK(problemOf(with(0, 0x00), kRealSize) == Problem::NOT_AN_IMAGE); + CHECK(problemOf(with(0, 0xAA), kRealSize) == Problem::NOT_AN_IMAGE); // a partition table + for (int chip : {0, 2, 5, 12, 13, 18}) CHECK(problemOf(with(12, chip), kRealSize) == Problem::WRONG_CHIP); // other ESP32s + CHECK(problemOf(with(13, 1), kRealSize) == Problem::WRONG_CHIP); // 0x0109 is not 9 either + CHECK(problemOf(with(32, 0x00), kRealSize) == Problem::NOT_AN_APP); + CHECK(problemOf(with(35, 0x00), kRealSize) == Problem::NOT_AN_APP); // the last byte of the magic word counts too + CHECK(problemOf(with(23, 0), kRealSize) == Problem::NO_DIGEST); + CHECK(problemOf(with(23, 2), kRealSize) == Problem::NO_DIGEST); + // sizes: the limits are inclusive + CHECK(problemOf(app, kMinImageBytes - 1) == Problem::TOO_SMALL); + CHECK(problemOf(app, kMinImageBytes) == Problem::NONE); + CHECK(problemOf(app, 0) == Problem::TOO_SMALL); + CHECK(problemOf(app, kSlot) == Problem::NONE); + CHECK(problemOf(app, kSlot + 1) == Problem::TOO_BIG); + CHECK(problemOf(app, 0xFFFFFFFFu) == Problem::TOO_BIG); + CHECK(problemIn(app, kSlot + 1, 0) == Problem::NONE); // slot size unknown: no upper limit here + CHECK(inspectImage(appHead, kHeadBytes - 1, kRealSize, kSlot) == Problem::TOO_SMALL); // a head that was not read in full + // size problems are reported before header problems + CHECK(problemOf(with(12, 5), 1000) == Problem::TOO_SMALL); + CHECK(problemOf(with(12, 5), kSlot + 1) == Problem::TOO_BIG); + + section("firmware update: what the clock decides"); + Inputs in; + memcpy(in.head, appHead, kHeadBytes); + in.files = 1; + in.fileSize = kRealSize; + in.slotSize = kSlot; + in.runningElf[0] = 0x42; // some other build + CHECK(decide(in).verdict == Verdict::INSTALL && decide(in).problem == Problem::NONE); + + Inputs none = in; + none.files = 0; + CHECK(decide(none).verdict == Verdict::NOTHING); + Inputs two = in; + two.files = 2; + CHECK(decide(two).verdict == Verdict::SEVERAL); + two.head[0] = 0; // several wins over a bad file: nothing is looked at + CHECK(decide(two).verdict == Verdict::SEVERAL); + Inputs noSlot = in; + noSlot.slotSize = 0; + CHECK(decide(noSlot).verdict == Verdict::NO_SLOT); + Inputs junk = in; + junk.head[0] = 0; + CHECK(decide(junk).verdict == Verdict::BAD_FILE && decide(junk).problem == Problem::NOT_AN_IMAGE); + Inputs small = in; + small.fileSize = 19984; + CHECK(decide(small).verdict == Verdict::BAD_FILE && decide(small).problem == Problem::TOO_SMALL); + + // the same build as the one that runs: nothing to do, whatever else is true + Inputs same = in; + memcpy(same.runningElf, elfHash(appHead), kDigestBytes); + CHECK(decide(same).verdict == Verdict::SAME_AS_RUNNING); + same.hasBattery = true; + same.batteryMv = 3000; + same.haveLast = true; + memcpy(same.lastElf, elfHash(appHead), kDigestBytes); + CHECK(decide(same).verdict == Verdict::SAME_AS_RUNNING); + // one byte of the ELF hash differs: a different build + Inputs near = in; + memcpy(near.runningElf, elfHash(appHead), kDigestBytes); + near.runningElf[kDigestBytes - 1] ^= 1; + CHECK(decide(near).verdict == Verdict::INSTALL); + near.runningElf[kDigestBytes - 1] ^= 1; + near.runningElf[0] ^= 0x80; + CHECK(decide(near).verdict == Verdict::INSTALL); + + // installed from the card before, and not what runs: not again + Inputs again = in; + again.haveLast = true; + memcpy(again.lastElf, elfHash(appHead), kDigestBytes); + CHECK(decide(again).verdict == Verdict::TRIED_BEFORE); + again.haveLast = false; // no memory of it: the stored bytes mean nothing + CHECK(decide(again).verdict == Verdict::INSTALL); + again.haveLast = true; + again.lastElf[10] ^= 4; // a different build was the last one + CHECK(decide(again).verdict == Verdict::INSTALL); + + // the battery: only when one is fitted and read, and only below 3.60 V + Inputs bat = in; + bat.hasBattery = true; + bat.batteryMv = kMinBatteryMv - 1; + CHECK(decide(bat).verdict == Verdict::LOW_BATTERY); + bat.batteryMv = kMinBatteryMv; + CHECK(decide(bat).verdict == Verdict::INSTALL); + bat.batteryMv = 0; // could not be read + CHECK(decide(bat).verdict == Verdict::INSTALL); + bat.hasBattery = false; // "battery = none": running from USB + bat.batteryMv = 2500; + CHECK(decide(bat).verdict == Verdict::INSTALL); + Inputs lowAndTried = in; // the cheaper reasons come first + lowAndTried.hasBattery = true; + lowAndTried.batteryMv = 3000; + lowAndTried.haveLast = true; + memcpy(lowAndTried.lastElf, elfHash(appHead), kDigestBytes); + CHECK(decide(lowAndTried).verdict == Verdict::TRIED_BEFORE); + Inputs lowAndJunk = in; + lowAndJunk.hasBattery = true; + lowAndJunk.batteryMv = 3000; + lowAndJunk.head[0] = 0; + CHECK(decide(lowAndJunk).verdict == Verdict::BAD_FILE); + + section("firmware update: what the screen says"); + char text[96]; + for (Verdict v : {Verdict::SEVERAL, Verdict::NO_SLOT, Verdict::TRIED_BEFORE, Verdict::LOW_BATTERY}) { + Decision d; + d.verdict = v; + describe(d, 3500, text, sizeof text); + CHECK(strlen(text) > 10 && strlen(text) <= 46); + } + for (Problem p : {Problem::TOO_SMALL, Problem::TOO_BIG, Problem::NOT_AN_IMAGE, Problem::WRONG_CHIP, Problem::NOT_AN_APP, Problem::NO_DIGEST}) { + Decision d; + d.verdict = Verdict::BAD_FILE; + d.problem = p; + describe(d, 0, text, sizeof text); + CHECK(strlen(text) > 10 && strlen(text) <= 46); + } + Decision low; + low.verdict = Verdict::LOW_BATTERY; + describe(low, 3504, text, sizeof text); + CHECK(strstr(text, "3.50 V") != nullptr); + describe(low, 3999, text, sizeof text); + CHECK(strstr(text, "3.99 V") != nullptr); + describe(low, 3000, text, sizeof text); + CHECK(strstr(text, "3.00 V") != nullptr); + Decision quiet; + quiet.verdict = Verdict::SAME_AS_RUNNING; + describe(quiet, 0, text, sizeof text); + CHECK_STR(text, ""); // a leftover copy of the running build gets no message + quiet.verdict = Verdict::NOTHING; + describe(quiet, 0, text, sizeof text); + CHECK_STR(text, ""); + // the merged-image hint is the one people need most + Decision big; + big.verdict = Verdict::BAD_FILE; + big.problem = Problem::TOO_BIG; + describe(big, 0, text, sizeof text); + CHECK(strstr(text, ".merged.bin") != nullptr && strstr(text, ".ino.bin") != nullptr); +} + +// The example settings file kept in docs/ for people who want to see the format before they have +// a card: it must be exactly what the clock writes to a card with none. Regenerate it with +// ./build/test_features --write-example +// =========================================================================== +// Firmware update: the two passes over the file (fwlogic::installImage), with a made-up file and flash +// =========================================================================== +namespace fwtest { + +// A file in memory. A read that reaches past its end fails, as it does on the card. +struct MemFile { + std::vector data; + long failAt = -1; // a read that covers this place in the file fails (a card error) + int failOnRead = 0; // ... or the read with this number does (1 = the first) + int reads = 0; + bool readAt(uint32_t offset, void *buf, uint32_t n) { + reads++; + if ((uint64_t)offset + n > data.size()) return false; + if (failOnRead && reads == failOnRead) return false; + if (failAt >= 0 && (uint32_t)failAt >= offset && (uint32_t)failAt < offset + n) return false; + memcpy(buf, data.data() + offset, n); + return true; + } +}; + +// The file as the first version of the updater read it: "whatever comes next", from wherever the last read +// left off. The place that is asked for is ignored. +struct NextBytesFile { + std::vector data; + size_t pos = 0; + bool readAt(uint32_t, void *buf, uint32_t n) { + if (pos + n > data.size()) return false; + memcpy(buf, data.data() + pos, n); + pos += n; + return true; + } +}; + +// A stand-in for SHA-256: 32 bytes that depend on every byte and on where it stands. +struct ToyHash { + uint64_t h[4] = {0x9E3779B97F4A7C15ull, 0xC2B2AE3D27D4EB4Full, 0x165667B19E3779F9ull, 0x27D4EB2F165667C5ull}; + uint64_t count = 0; + void update(const uint8_t *p, uint32_t n) { + for (uint32_t i = 0; i < n; i++) { + const uint64_t v = (uint64_t)p[i] + 1 + 257 * count++; + for (int k = 0; k < 4; k++) h[k] = (h[k] ^ (v * (uint64_t)(2 * k + 3))) * 0x100000001B3ull + (h[(k + 1) & 3] >> 7); + } + } + void finish(uint8_t *out) { + for (int k = 0; k < 4; k++) + for (int b = 0; b < 8; b++) out[8 * k + b] = (uint8_t)(h[k] >> (8 * b)); + } +}; + +// The other app slot. +struct FakeSlot { + std::vector written; + bool began = false, ended = false, aborted = false; + uint32_t beginSize = 0; + int readsBeforeBegin = -1; // how many reads the file had seen when the writing began + const int *fileReads = nullptr; + bool failBegin = false, failEnd = false; + long failWriteAt = -1; // the write that would take the slot past this many bytes fails + bool begin(uint32_t size) { + began = true; + beginSize = size; + if (fileReads) readsBeforeBegin = *fileReads; + return !failBegin; + } + bool write(uint8_t *p, uint32_t n) { + if (failWriteAt >= 0 && (long)(written.size() + n) > failWriteAt) return false; + written.insert(written.end(), p, p + n); + return true; + } + bool end() { + ended = true; + return !failEnd; + } + const char *error() { return "flash trouble"; } + void abort() { aborted = true; } +}; + +// A firmware file of `bodyBytes` and the 32 bytes of checksum that follow them. +std::vector makeImage(size_t bodyBytes, uint32_t seed) { + std::vector f(bodyBytes + fwlogic::kDigestBytes); + uint32_t x = seed * 2654435761u + 1; + for (size_t i = 0; i < bodyBytes; i++) { + x = x * 1664525u + 1013904223u; + f[i] = (uint8_t)(x >> 24); + } + ToyHash h; + h.update(f.data(), (uint32_t)bodyBytes); + h.finish(f.data() + bodyBytes); + return f; +} + +typedef std::vector> Progress; // (pass, percent), in the order reported + +template +const char *install(File &file, FakeSlot &slot, uint32_t size, uint32_t chunk, Progress *progress = nullptr) { + static std::vector buf(16384); + ToyHash hash; + return fwlogic::installImage(file, size, hash, slot, buf.data(), chunk, [&](fwlogic::Pass pass, int percent) { + if (progress) progress->push_back({(int)pass, percent}); + }); +} + +} // namespace fwtest + +void testFirmwareInstall() { + section("firmware update: the two passes over the file"); + using namespace fwlogic; + using namespace fwtest; + + // a good file goes into the slot whole, checksum and all, whatever its size and the size of the reads; + // and not a byte is written before every byte has been read and checked + int runs = 0, wrong = 0; + for (size_t body : {(size_t)1, (size_t)31, (size_t)32, (size_t)33, (size_t)4095, (size_t)4096, (size_t)4097, (size_t)150001}) { + for (uint32_t chunk : {7u, 512u, 4096u, 8192u}) { + MemFile file; + file.data = makeImage(body, (uint32_t)(body + chunk)); + FakeSlot slot; + slot.fileReads = &file.reads; + const uint32_t size = (uint32_t)file.data.size(); + Progress prog; + const char *err = install(file, slot, size, chunk, &prog); + runs++; + const int checkReads = (int)((body + chunk - 1) / chunk) + 1; // the body in pieces, and the checksum + bool ok = err == nullptr && slot.began && slot.ended && !slot.aborted && slot.beginSize == size && slot.written == file.data; + ok = ok && slot.readsBeforeBegin == checkReads; + // the progress: the check from 0 to 100, then the writing from 0 to 100, never backwards + ok = ok && prog.size() >= 4 && prog.front() == std::make_pair((int)Pass::CHECK, 0) && prog.back() == std::make_pair((int)Pass::WRITE, 100); + int lastPass = (int)Pass::CHECK, lastPct = 0, checkEnd = -1; + for (const auto &p : prog) { + if (p.first != lastPass) { + if (p.first != (int)Pass::WRITE || p.second != 0) ok = false; // (the writing starts at 0, and nothing goes back to checking) + checkEnd = lastPct; + lastPass = p.first; + lastPct = 0; + } + if (p.second < lastPct || p.second > 100) ok = false; + lastPct = p.second; + } + ok = ok && checkEnd == 100; + if (!ok) { + if (wrong++ < 3) printf(" body %u, reads of %u: err '%s', wrote %u of %u, %d reads before writing (want %d)\n", (unsigned)body, (unsigned)chunk, + err ? err : "none", (unsigned)slot.written.size(), (unsigned)size, slot.readsBeforeBegin, checkReads); + } + } + } + CHECK(runs == 32 && wrong == 0); + + // what the clock did on the day it was first tried on a real card: the caller had read the 208 bytes of the + // header for a look, and the check then read "what comes next". 208 bytes off, it came up short at the very + // end of every file: "Cannot read the file (card error)" at what looked like 100 %. Every read now names + // its place; a file that ignores the place (as that code in effect did) is seen to fail in just that way. + { + NextBytesFile old; + old.data = makeImage(150001, 7); + old.pos = kHeadBytes; + FakeSlot slot; + Progress prog; + const char *err = install(old, slot, (uint32_t)old.data.size(), 4096, &prog); + CHECK(err != nullptr && !strcmp(err, kReadError) && !slot.began && slot.written.empty()); + CHECK(!prog.empty() && prog.back().first == (int)Pass::CHECK && prog.back().second >= 96); // the bar was nearly full + // ... and reading "what comes next" from the start gets through the check and then has nothing left to write + NextBytesFile fromStart; + fromStart.data = makeImage(150001, 7); + FakeSlot slot2; + err = install(fromStart, slot2, (uint32_t)fromStart.data.size(), 4096); + CHECK(err != nullptr && !strcmp(err, kReadError) && slot2.began && slot2.aborted && !slot2.ended && slot2.written.empty()); + // the same file read by place is fine + MemFile good; + good.data = makeImage(150001, 7); + FakeSlot slot3; + CHECK(install(good, slot3, (uint32_t)good.data.size(), 4096) == nullptr && slot3.written == good.data); + } + + // a damaged copy: one bit anywhere in the file, the checksum included, and nothing is written + for (size_t at : {(size_t)0, (size_t)208, (size_t)75000, (size_t)150000, (size_t)150001, (size_t)150032}) { + MemFile file; + file.data = makeImage(150001, 3); + file.data[at] ^= 0x10; + FakeSlot slot; + const char *err = install(file, slot, (uint32_t)file.data.size(), 4096); + if (!err || strcmp(err, kDamaged) != 0) printf(" a flipped bit at %u gave '%s'\n", (unsigned)at, err ? err : "none"); + CHECK(err != nullptr && !strcmp(err, kDamaged) && !slot.began && slot.written.empty()); + } + + // a copy that was cut short (the card was pulled while Windows wrote it): shorter than its size says + for (size_t cut : {(size_t)1, (size_t)31, (size_t)32, (size_t)33, (size_t)5000}) { + MemFile file; + file.data = makeImage(150001, 4); + const uint32_t size = (uint32_t)file.data.size(); + file.data.resize(file.data.size() - cut); + FakeSlot slot; + const char *err = install(file, slot, size, 4096); + CHECK(err != nullptr && !strcmp(err, kReadError) && !slot.began); + } + + // a card error while checking, anywhere: nothing is written + for (long at : {0L, 4096L, 149999L, 150001L, 150032L}) { + MemFile file; + file.data = makeImage(150001, 5); + file.failAt = at; + FakeSlot slot; + const char *err = install(file, slot, (uint32_t)file.data.size(), 4096); + CHECK(err != nullptr && !strcmp(err, kReadError) && !slot.began && slot.written.empty()); + } + // a card error while writing (the check went through): the half-written slot is given up + { + MemFile file; + file.data = makeImage(150001, 6); + const int checkReads = (150001 + 4095) / 4096 + 1; + file.failOnRead = checkReads + 5; // the fifth read of the second pass + FakeSlot slot; + const char *err = install(file, slot, (uint32_t)file.data.size(), 4096); + CHECK(err != nullptr && !strcmp(err, kReadError) && slot.began && slot.aborted && !slot.ended); + CHECK(slot.written.size() == 4u * 4096u); + } + // the flash says no: at the start (nothing to give up), in the middle, at the end + { + MemFile file; + file.data = makeImage(150001, 8); + const uint32_t size = (uint32_t)file.data.size(); + FakeSlot a; + a.failBegin = true; + const char *err = install(file, a, size, 4096); + CHECK(err != nullptr && !strcmp(err, "flash trouble") && a.written.empty() && !a.aborted && !a.ended); + FakeSlot b; + b.failWriteAt = 20000; + err = install(file, b, size, 4096); + CHECK(err != nullptr && !strcmp(err, "flash trouble") && b.aborted && !b.ended && b.written.size() == 4u * 4096u); + FakeSlot c; + c.failEnd = true; + err = install(file, c, size, 4096); + CHECK(err != nullptr && !strcmp(err, "flash trouble") && c.aborted && c.written == file.data); + } + // no file to speak of, or reads of no size: refused before anything is read + { + MemFile file; + file.data = makeImage(1, 9); + FakeSlot slot; + const char *small = install(file, slot, 32, 4096); + CHECK(small != nullptr && !strcmp(small, kTooSmall) && file.reads == 0); + const char *zero = install(file, slot, 0, 4096); + CHECK(zero != nullptr && !strcmp(zero, kTooSmall)); + const char *noChunk = install(file, slot, (uint32_t)file.data.size(), 0); + CHECK(noChunk != nullptr && !strcmp(noChunk, kTooSmall) && !slot.began); + } +} + +// =========================================================================== +// What is on an SD card that would not mount (sd_layout.h) +// =========================================================================== +namespace sdtest { + +typedef std::vector Sector; + +Sector blank() { return Sector(512, 0); } + +void sign(Sector &s) { + s[510] = 0x55; + s[511] = 0xAA; +} + +// The boot sector of a volume as Windows writes it: the jump, the name of the formatter, 512 byte sectors. +Sector bootSector(const char *oem) { + Sector s = blank(); + s[0] = 0xEB; + s[1] = 0x58; + s[2] = 0x90; + memcpy(&s[3], oem, 8); + s[11] = 0x00; + s[12] = 0x02; // 512 bytes per sector + sign(s); + return s; +} + +Sector fat32() { + Sector s = bootSector("MSDOS5.0"); + memcpy(&s[82], "FAT32 ", 8); + return s; +} + +Sector fat16() { + Sector s = bootSector("MSDOS5.0"); + memcpy(&s[54], "FAT16 ", 8); + return s; +} + +Sector exfat() { return bootSector("EXFAT "); } +Sector ntfs() { return bootSector("NTFS "); } + +// A partition table with boot code in front of it (as every real one has). +Sector mbr() { + Sector s = blank(); + s[0] = 0x33; + s[1] = 0xC0; + s[2] = 0x8E; + sign(s); + return s; +} + +void entry(Sector &s, int slot, uint8_t type, uint32_t lba) { + uint8_t *e = &s[446 + 16 * slot]; + e[4] = type; + e[8] = (uint8_t)lba; + e[9] = (uint8_t)(lba >> 8); + e[10] = (uint8_t)(lba >> 16); + e[11] = (uint8_t)(lba >> 24); + e[12] = 0x00; + e[13] = 0x00; + e[14] = 0x10; // some size +} + +} // namespace sdtest + +void testSdLayout() { + section("SD card: what is on a card that will not mount"); + using namespace sdlayout; + using namespace sdtest; + + // nothing on it + CHECK(inspectFirst(blank().data()).kind == Kind::BLANK && !inspectFirst(blank().data()).lookAtPartition); + { + Sector noise(512, 0xA5); // no boot signature + CHECK(inspectFirst(noise.data()).kind == Kind::BLANK); + Sector emptyTable = mbr(); // a signature and a table with nothing in it (a wiped card) + CHECK(inspectFirst(emptyTable.data()).kind == Kind::BLANK); + } + + // a volume that fills the card, with no partition table + CHECK(inspectFirst(fat32().data()).kind == Kind::FAT && !inspectFirst(fat32().data()).lookAtPartition); + CHECK(inspectFirst(fat16().data()).kind == Kind::FAT); + CHECK(inspectFirst(exfat().data()).kind == Kind::EXFAT); + CHECK(inspectFirst(ntfs().data()).kind == Kind::NTFS); + + // the usual card: one FAT32 partition in a classic table. The answer is in the partition's own first sector. + { + Sector t = mbr(); + entry(t, 0, 0x0C, 8192); + const First f = inspectFirst(t.data()); + CHECK(f.kind == Kind::UNKNOWN && f.lookAtPartition && f.partitionLba == 8192 && f.partitionType == 0x0C); + CHECK(inspectPartition(fat32().data(), f.partitionType) == Kind::FAT); + CHECK(inspectPartition(blank().data(), f.partitionType) == Kind::FAT); // called FAT by the table, its start wiped: still "FAT, will not mount" + } + // a big card as it comes from the shop: one exFAT partition, type 0x07 (NTFS has the same type) + { + Sector t = mbr(); + entry(t, 0, 0x07, 32768); + const First f = inspectFirst(t.data()); + CHECK(f.lookAtPartition && f.partitionLba == 32768 && f.partitionType == 0x07); + CHECK(inspectPartition(exfat().data(), 0x07) == Kind::EXFAT); + CHECK(inspectPartition(ntfs().data(), 0x07) == Kind::NTFS); + CHECK(inspectPartition(blank().data(), 0x07) == Kind::OTHER); + CHECK(inspectPartition(fat32().data(), 0x07) == Kind::FAT); // what is there counts, not what the table calls it + } + // a GUID partition table: its first sector is a table with one entry of type 0xEE. Windows shows the FAT32 + // volume inside as "FAT32" all the same, and the clock's FAT library cannot find it. + { + Sector t = mbr(); + entry(t, 0, 0xEE, 1); + const First f = inspectFirst(t.data()); + CHECK(f.kind == Kind::GPT && !f.lookAtPartition && f.partitionType == 0xEE); + } + // the first entries empty: the first one that is used counts; a Linux partition is "other" + { + Sector t = mbr(); + entry(t, 2, 0x0B, 2048); + const First f = inspectFirst(t.data()); + CHECK(f.lookAtPartition && f.partitionLba == 2048 && f.partitionType == 0x0B); + Sector far = mbr(); // a partition that starts far into a big card: all four bytes of the sector number count + entry(far, 3, 0x0C, 0x81020304u); + CHECK(inspectFirst(far.data()).lookAtPartition && inspectFirst(far.data()).partitionLba == 0x81020304u); + Sector lin = mbr(); + entry(lin, 0, 0x83, 2048); + const First g = inspectFirst(lin.data()); + CHECK(g.lookAtPartition && inspectPartition(blank().data(), g.partitionType) == Kind::OTHER); + } + // an entry with a type and no start, or a start and no type, is not an entry + { + Sector t = mbr(); + entry(t, 0, 0x0C, 0); + entry(t, 1, 0x00, 4096); + CHECK(inspectFirst(t.data()).kind == Kind::BLANK); + } + // a FAT boot sector needs its signature, its jump, 512 byte sectors and its name: each one missing is no FAT + { + Sector s = fat32(); + s[510] = 0; + CHECK(!isFat(s.data()) && inspectFirst(s.data()).kind == Kind::BLANK); + s = fat32(); + s[0] = 0x00; + CHECK(!isFat(s.data())); + s = fat32(); + s[12] = 0x10; // 4096 byte sectors + CHECK(!isFat(s.data())); + s = fat32(); + memcpy(&s[82], "FAT64 ", 8); + CHECK(!isFat(s.data())); + for (uint8_t jump : {(uint8_t)0xEB, (uint8_t)0xE9, (uint8_t)0xE8}) { + s = fat32(); + s[0] = jump; + CHECK(isFat(s.data())); + } + Sector twelve = bootSector("MSDOS5.0"); + memcpy(&twelve[54], "FAT12 ", 8); + CHECK(isFat(twelve.data())); + Sector e = exfat(); + e[511] = 0; // exFAT and NTFS need the signature too + CHECK(!isExfat(e.data()) && inspectFirst(e.data()).kind == Kind::BLANK); + } + // the types a table uses for FAT + for (int t = 0; t < 256; t++) { + const bool want = t == 0x01 || t == 0x04 || t == 0x06 || t == 0x0B || t == 0x0C || t == 0x0E; + CHECK(isFatPartitionType((uint8_t)t) == want); + } + + // every answer has its own line for the screen, short enough for a banner (39 characters), and says "SD card" + std::vector seen; + for (Kind k : kAllKinds) { + const std::string line = text(k); + CHECK(line.size() >= 20 && line.size() <= 39); + CHECK(line.compare(0, 7, "SD card") == 0); + for (const std::string &other : seen) CHECK(other != line); + seen.push_back(line); + CHECK(strlen(name(k)) > 0); + } + CHECK(sizeof kAllKinds / sizeof kAllKinds[0] == 8); + CHECK(strstr(text(Kind::GPT), "GPT") && strstr(text(Kind::GPT), "MBR")); + CHECK(strstr(text(Kind::EXFAT), "exFAT") && strstr(text(Kind::EXFAT), "FAT32")); + CHECK(strstr(text(Kind::NTFS), "NTFS") && strstr(text(Kind::BLANK), "not formatted")); + CHECK(strstr(text(Kind::READ_ERROR), "cannot read") && strstr(text(Kind::FAT), "will not mount")); +} + +void testRepoExampleFile(bool write) { + section("example settings file in docs/"); + static char buf[16384]; + const size_t n = renderExampleConfig(Settings(), "", buf, sizeof buf); + const char *path = "../../docs/ESP32-S3-RLCD-Config.example.txt"; + if (write) { + FILE *f = fopen(path, "wb"); + if (!f || fwrite(buf, 1, n, f) != n) { + printf(" cannot write %s\n", path); + g_failed++; + } else { + printf(" wrote %s (%zu bytes)\n", path, n); + } + if (f) fclose(f); + return; + } + const std::string onDisk = readFile(path); + const bool same = onDisk == std::string(buf, n); + if (!same) printf(" %s is out of date: run ./build/test_features --write-example\n", path); + CHECK(same); +} + +// Every setting is described in the README, and the README says nothing about one that is gone. +void testReadmeLists(const char *path) { + section("README lists every setting"); + const std::string readme = readFile(path); + CHECK(!readme.empty()); + for (size_t i = 0; i < settingCount(); i++) { + const std::string key = std::string("`") + settingKey(i) + "`"; + if (readme.find(key) == std::string::npos) printf(" README does not mention %s\n", key.c_str()); + CHECK(readme.find(key) != std::string::npos); + } +} + +// --------------------------------------------------------------------------- +// docs/power-log.csv: the sheet for the readings of battery runs. It is filled in by hand (and by spreadsheets, +// which leave their marks), so it is read here the way it will be read later, and a line that is wrong is named. +// --------------------------------------------------------------------------- + +// One line of a CSV file as its fields, with quotes the way spreadsheets write them ("a, b" and "say ""hi"""). +std::vector csvFields(const std::string &line) { + std::vector out(1); + bool quoted = false; + for (size_t i = 0; i < line.size(); i++) { + const char c = line[i]; + if (quoted) { + if (c == '"' && i + 1 < line.size() && line[i + 1] == '"') { + out.back() += '"'; + i++; + } else if (c == '"') { + quoted = false; + } else { + out.back() += c; + } + } else if (c == '"') { + quoted = true; + } else if (c == ',') { + out.emplace_back(); + } else { + out.back() += c; + } + } + return out; +} + +// What is wrong with a power log: the number of problems, each said if `say`. The heading has to be made of +// plain names that the README explains, every line needs as many fields as the heading, and a column that is +// named after a setting may only hold what the settings file would take for it (or nothing). +int powerLogProblems(const std::string &csv, const std::string &readme, bool say) { + int problems = 0; + auto bad = [&](int line, const std::string &what) { + problems++; + if (say) printf(" power-log.csv line %d: %s\n", line, what.c_str()); + }; + const size_t start = csv.compare(0, 3, "\xEF\xBB\xBF") == 0 ? 3 : 0; // (a spreadsheet's byte-order mark) + std::vector head; + int lineNo = 0; + for (size_t pos = start; pos < csv.size();) { + size_t end = csv.find('\n', pos); + if (end == std::string::npos) end = csv.size(); + std::string line = csv.substr(pos, end - pos); + pos = end + 1; + lineNo++; + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (lineNo == 1) { + head = csvFields(line); + for (size_t i = 0; i < head.size(); i++) { + const std::string &name = head[i]; + bool plain = !name.empty(); + for (char c : name) plain = plain && ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'); + if (!plain) bad(1, "the column name '" + name + "' is not made of a-z, 0-9 and _"); + for (size_t k = 0; k < i; k++) + if (head[k] == name) bad(1, "the column '" + name + "' is there twice"); + if (plain && readme.find("`" + name + "`") == std::string::npos) bad(1, "the README does not explain the column `" + name + "`"); + } + continue; + } + if (line.empty()) continue; // (a blank line, as at the end of a file saved by a spreadsheet) + const std::vector f = csvFields(line); + if (f.size() != head.size()) { + bad(lineNo, std::to_string(f.size()) + " fields where the heading has " + std::to_string(head.size())); + continue; + } + for (size_t i = 0; i < head.size(); i++) { + if (f[i].empty() || idx(head[i].c_str()) == (size_t)-1) continue; // empty, or no setting's column + if (apply(head[i] + " = " + f[i] + "\n").r.problems() != 0) bad(lineNo, "'" + f[i] + "' is not a value of the setting " + head[i]); + } + } + if (head.empty()) bad(1, "no heading"); + return problems; +} + +void testPowerLog() { + section("docs/power-log.csv"); + const std::string csv = readFile("../../docs/power-log.csv"), readme = readFile("../../README.md"); + CHECK(!csv.empty() && !readme.empty()); + CHECK(powerLogProblems(csv, readme, true) == 0); + // the columns that say which settings a run was made with are settings, by the names the settings file uses + // (a setting that is renamed takes its column along), and the ones a rate is worked out from are there + const std::vector head = csvFields(csv.substr(0, csv.find_first_of("\r\n"))); + auto has = [&](const char *name) { + for (const std::string &h : head) + if (h == name) return true; + return false; + }; + for (const char *setting : {"wifi", "wifi_mode", "wifi_power_save", "cpu_idle_mhz", "spotify_live", "console", "battery_capacity_mah"}) { + if (!has(setting) || idx(setting) == (size_t)-1) printf(" power-log.csv: no column for the setting %s\n", setting); + CHECK(has(setting) && idx(setting) != (size_t)-1); + } + for (const char *reading : {"test", "firmware", "date", "time", "battery_v", "battery_pct", "left", "pct_per_hour", "over_min"}) CHECK(has(reading)); + + // the check can fail: a made-up sheet, right and then wrong in one way at a time + const std::string doc = "`test` `wifi_mode` `cpu_idle_mhz` `battery_pct` `notes`"; + const std::string h = "test,wifi_mode,cpu_idle_mhz,battery_pct,notes\n"; + CHECK(powerLogProblems(h + "a,sync,20,99.1,\n" + "a,always,off,,\n", doc, false) == 0); + CHECK(powerLogProblems("\xEF\xBB\xBF" + h + "a,sync,20,99.1,\"wet, cold, \"\"quoted\"\"\"\r\n\r\n", doc, false) == 0); // as a spreadsheet saves it + CHECK(csvFields("a,\"b, c\",,\"say \"\"hi\"\"\",").size() == 5 && csvFields("a,\"b, c\",,\"say \"\"hi\"\"\",")[3] == "say \"hi\""); + CHECK(powerLogProblems(h + "a,sync,20,99.1\n", doc, false) == 1); // a field short + CHECK(powerLogProblems(h + "a,sync,20,99.1,x,y\n", doc, false) == 1); // one too many (a comma in a note that is not quoted) + CHECK(powerLogProblems(h + "a,synch,20,99.1,\n", doc, false) == 1); // not a value of wifi_mode + CHECK(powerLogProblems(h + "a,sync,30,99.1,\n", doc, false) == 1); // 30 MHz is not on offer + CHECK(powerLogProblems(h + "a,sync,20,99.1,\n" + "b,sync,10,98.9,\n", doc, false) == 1); // ... and only the line that is wrong counts + CHECK(powerLogProblems(h + "a,sync,20,99.1,\n", "`test` `wifi_mode` `cpu_idle_mhz` `battery_pct`", false) == 1); // a column the README does not explain + CHECK(powerLogProblems("test,Wifi Mode,notes\na,sync,\n", doc, false) == 1); // a name a script could not use + CHECK(powerLogProblems("test,notes,test\na,,b\n", doc, false) == 1); // a column twice + CHECK(powerLogProblems("", doc, false) == 1 && powerLogProblems("\n", doc, false) >= 1); // nothing at all +} + +// --------------------------------------------------------------------------- +// The power log the clock writes by itself (power_log.h): the sheet's columns, filled in by the firmware +// --------------------------------------------------------------------------- + +// The settings and the readings of the README's own examples ("Logging a battery run"). +Settings sampleLogSettings() { + return apply("wifi_mode = sync\nwifi_power_save = max\ncpu_idle_mhz = 20\nspotify_live = off\nconsole = auto\nbattery_capacity_mah = 2500\n").s; +} + +powerlog::Reading sampleReading(const Settings &s) { + powerlog::Reading r; + r.version = "1.7"; + r.buildId = "3f9a12c"; + r.settings = &s; + r.spotify = SPOTIFY_PLAYING; + r.timeValid = true; + r.local.tm_year = 2026 - 1900; + r.local.tm_mon = 9; + r.local.tm_mday = 8; + r.local.tm_hour = 14; + r.local.tm_min = 30; + r.local.tm_sec = 59; + r.uptimeSec = 3 * 3600 + 12 * 60 + 47; + r.cpuMhz = 20; + r.clockReason = "idle"; + r.radioSync = true; + r.radioOnPermille = 8; + r.radioSessions = 96; + r.batteryPresent = true; + r.batteryVolts = 4.117f; + r.charge = CHARGE_DISCHARGING; + r.chargeReady = true; + r.estimate.state = battest::Estimate::READY; + r.estimate.pctPerHour = 1.2f; + r.estimate.hoursLeft = 72.0f; + r.estimate.windowMin = 300; + r.estimate.avgMa = 30.0f; + r.freeHeapBytes = 187 * 1024 + 500; + r.framesSent = 11520; + r.framesLate = 2; + r.framesWorstMs = 61; + return r; +} + +std::string logLine(const powerlog::Reading &r) { + char line[powerlog::kMaxLine]; + const size_t n = powerlog::formatLine(r, line, sizeof line); + return std::string(line, n); +} + +// The fields of a line as the clock wrote it (its line end taken off). +std::vector logFields(const std::string &line) { + std::string l = line; + while (!l.empty() && (l.back() == '\n' || l.back() == '\r')) l.pop_back(); + return csvFields(l); +} + +// One field of the line a reading makes, by its column. +std::string logField(const powerlog::Reading &r, powerlog::Column c) { + const std::vector f = logFields(logLine(r)); + return f.size() == (size_t)powerlog::kColumnCount ? f[c] : std::string("<") + std::to_string(f.size()) + " fields>"; +} + +void testPowerLogLine() { + section("power log: the heading and the line"); + using namespace powerlog; + const std::string readme = readFile("../../README.md"), sheet = readFile("../../docs/power-log.csv"); + + // the heading is the sheet's, column for column: a line pastes under the sheet's heading, one script reads both + char head[kMaxLine]; + const size_t headLen = formatHeader(head, sizeof head); + CHECK(headLen > 0 && headLen == strlen(head) && headLen < kMaxLine); + const std::string sheetHead = sheet.substr(0, sheet.find_first_of("\r\n")); + const std::vector names = csvFields(sheetHead); + CHECK(names.size() == (size_t)kColumnCount); + for (size_t i = 0; i < names.size() && i < (size_t)kColumnCount; i++) { + if (names[i] != columnName((int)i)) printf(" column %zu is '%s' in docs/power-log.csv and '%s' in power_log.h\n", i, names[i].c_str(), columnName((int)i)); + CHECK(names[i] == columnName((int)i)); + } + CHECK(std::string(head) == sheetHead + "\r\n"); + CHECK_STR(columnName(-1), ""); + CHECK_STR(columnName(kColumnCount), ""); + { // a buffer that is too small gets no heading rather than part of one + char small[kMaxLine]; + CHECK(formatHeader(small, headLen) == 0 && formatHeader(small, headLen + 1) == headLen && formatHeader(small, 0) == 0); + } + + // the README's own example of every column, as the one line the clock would write of it + const Settings s = sampleLogSettings(); + const Reading r = sampleReading(s); + const std::string line = logLine(r); + CHECK_STR(line.c_str(), ",1.7 3f9a12c,on,sync,max,20,off,auto,2500,,playing,2026-10-08,14:30,0d 3h 12m,20 idle,0.8,96,4.117,87.0,discharging," + "3 d 0 h,1.20,300,30.0,,187,11520,2,61,\r\n"); + CHECK(line.size() >= 140 && line.size() <= 170); // (the README's "3,000 to 7,000 lines" rests on a line of about this length) + for (const char *example : {"`0d 3h 12m`", "`20 idle`", "`0.8` and `96`", "`4.117` and `87.0`", "`3 d 0 h`, `1.20` and `300`", "`2026-10-08`", "`14:30`"}) { + if (readme.find(example) == std::string::npos) printf(" the README no longer gives %s as an example\n", example); + CHECK(readme.find(example) != std::string::npos); + } + // ... and the sheet's own checks take the clock's heading and line as they take the sheet + CHECK(powerLogProblems(std::string(head) + line, readme, true) == 0); + CHECK(powerLogProblems(std::string(head) + line.substr(1), readme, false) == 1); // (they can fail: the same line, a field short) + { // a buffer that is too small gets no line + char small[kMaxLine]; + CHECK(formatLine(r, small, line.size()) == 0 && formatLine(r, small, line.size() + 1) == line.size() && formatLine(r, small, 0) == 0); + for (size_t cap = 1; cap < line.size(); cap += 7) CHECK(formatLine(r, small, cap) == 0); + } + + // the columns of the settings hold what the settings file has, for every choice of every one of them: the + // text is the setting's own, and the settings parser takes it back + auto col = [](const char *name) { + for (int c = 0; c < kColumnCount; c++) + if (!strcmp(columnName(c), name)) return c; + return -1; + }; + int combos = 0; + std::string all = head; + for (int wifi = 0; wifi < 2; wifi++) + for (int mode : {WIFIMODE_ALWAYS, WIFIMODE_SYNC}) + for (int save : {WIFISAVE_NORMAL, WIFISAVE_MAX}) + for (int idle : {CPUIDLE_OFF, CPUIDLE_80, CPUIDLE_40, CPUIDLE_20}) + for (int live = 0; live < 2; live++) + for (int console : {CONSOLE_ON, CONSOLE_AUTO, CONSOLE_OFF}) { + Settings t; + t.wifi = wifi != 0; + t.wifiMode = (uint8_t)mode; + t.wifiPowerSave = (uint8_t)save; + t.cpuIdle = (uint8_t)idle; + t.spotifyLive = live != 0; + t.console = (uint8_t)console; + const std::string l = logLine(sampleReading(t)); + const std::vector f = logFields(l); + CHECK(f.size() == (size_t)kColumnCount); + if (f.size() != (size_t)kColumnCount) continue; + for (const char *name : {"wifi", "wifi_mode", "wifi_power_save", "cpu_idle_mhz", "spotify_live", "console"}) { + const std::string &text = f[(size_t)col(name)]; + CHECK(!text.empty() && text == fmt(t, idx(name))); + const Applied back = apply(std::string(name) + " = " + text + "\n", Settings()); + if (back.r.problems() != 0 || fmt(back.s, idx(name)) != fmt(t, idx(name))) printf(" %s: '%s' does not read back\n", name, text.c_str()); + CHECK(back.r.problems() == 0 && fmt(back.s, idx(name)) == fmt(t, idx(name))); + } + CHECK(f[C_BATTERY_CAPACITY_MAH].empty()); // (no capacity set in these) + all += l; + combos++; + } + CHECK(combos == 192); + CHECK(powerLogProblems(all, readme, true) == 0); + CHECK(col("wifi") == C_WIFI && col("console") == C_CONSOLE && col("cpu_idle_mhz") == C_CPU_IDLE_MHZ && col("notes") == C_NOTES); + { // settingText: by the setting's name, and nothing for a name that is no setting + char t[16] = "x"; + settingText(s, "wifi_mode", t, sizeof t); + CHECK_STR(t, "sync"); + settingText(s, "wifi_modes", t, sizeof t); + CHECK_STR(t, ""); + settingText(s, "wifi_ssid", t, 0); // no room at all: nothing is written + } + + // what the clock does not know stays empty: a reading with nothing in it + { + const std::vector f = logFields(logLine(Reading())); + CHECK(f.size() == (size_t)kColumnCount); + if (f.size() == (size_t)kColumnCount) { + for (int c : {C_TEST, C_FIRMWARE, C_WIFI, C_WIFI_MODE, C_WIFI_POWER_SAVE, C_CPU_IDLE_MHZ, C_SPOTIFY_LIVE, C_CONSOLE, C_BATTERY_CAPACITY_MAH, + C_CARD, C_MUSIC, C_DATE, C_TIME, C_CLOCK_SHOWN, C_RADIO_ON_PCT, C_RADIO_SESSIONS, C_BATTERY_V, C_BATTERY_PCT, C_LEFT, + C_PCT_PER_HOUR, C_OVER_MIN, C_CLOCK_MA, C_METER_MA, C_NOTES}) { + if (!f[(size_t)c].empty()) printf(" %s is '%s' for a reading that knows nothing\n", columnName(c), f[(size_t)c].c_str()); + CHECK(f[(size_t)c].empty()); + } + CHECK(f[C_UPTIME] == "0d 0h 0m" && f[C_BATTERY_STATE] == "none" && f[C_FREE_KB] == "0" && f[C_FRAMES_SENT] == "0" && f[C_FRAMES_LATE] == "0" && + f[C_FRAMES_WORST_MS] == "0"); + } + // the four columns only a person can fill are empty in the full reading too + for (Column c : {C_TEST, C_CARD, C_METER_MA}) CHECK(logField(r, c).empty()); + CHECK(logField(r, C_NOTES).empty()); + } + + // the date and the time only when the clock is trusted + { + Reading t = r; + t.timeValid = false; + CHECK(logField(t, C_DATE).empty() && logField(t, C_TIME).empty()); + CHECK(logField(r, C_DATE) == "2026-10-08" && logField(r, C_TIME) == "14:30"); + t = r; + t.local.tm_mon = 0; + t.local.tm_mday = 3; + t.local.tm_hour = 7; + t.local.tm_min = 5; + CHECK(logField(t, C_DATE) == "2026-01-03" && logField(t, C_TIME) == "07:05"); + } + // the uptime as the System info page has it + { + Reading t = r; + for (const auto &c : std::vector>{{0, "0d 0h 0m"}, {59, "0d 0h 0m"}, {60, "0d 0h 1m"}, {86399, "0d 23h 59m"}, + {90061, "1d 1h 1m"}, {4294967295u, "49710d 6h 28m"}}) { + t.uptimeSec = c.first; + if (logField(t, C_UPTIME) != c.second) printf(" uptime %u s gives '%s'\n", (unsigned)c.first, logField(t, C_UPTIME).c_str()); + CHECK(logField(t, C_UPTIME) == c.second); + } + } + // the CPU clock, with the reason when there is an idle clock to give one + { + Reading t = r; + CHECK(logField(t, C_CLOCK_SHOWN) == "20 idle"); + t.cpuMhz = 80; + t.clockReason = "USB attached"; + CHECK(logField(t, C_CLOCK_SHOWN) == "80 USB attached"); + t.clockReason = ""; + CHECK(logField(t, C_CLOCK_SHOWN) == "80"); + t.cpuMhz = 240; + CHECK(logField(t, C_CLOCK_SHOWN) == "240"); + } + // the radio's share only in sync mode + { + Reading t = r; + t.radioSync = false; + CHECK(logField(t, C_RADIO_ON_PCT).empty() && logField(t, C_RADIO_SESSIONS).empty()); + t.radioSync = true; + t.radioOnPermille = 1000; + t.radioSessions = 0; + CHECK(logField(t, C_RADIO_ON_PCT) == "100.0" && logField(t, C_RADIO_SESSIONS) == "0"); + t.radioOnPermille = 35; + t.radioSessions = 4294967295u; + CHECK(logField(t, C_RADIO_ON_PCT) == "3.5" && logField(t, C_RADIO_SESSIONS) == "4294967295"); + } + // music: what Spotify said, and nothing when no account is linked + { + Reading t = r; + const std::vector> cases = {{SPOTIFY_PLAYING, "playing"}, {SPOTIFY_PAUSED, "paused"}, {SPOTIFY_IDLE, "none"}, + {SPOTIFY_DISABLED, ""}, {SPOTIFY_NEEDS_LINK, ""}, {SPOTIFY_ERROR, ""}}; + for (const auto &c : cases) { + t.spotify = c.first; + CHECK(logField(t, C_MUSIC) == c.second); + } + } + // the battery: the voltage, the level on the curve with a decimal, and the state in the words of the System info page + { + Reading t = r; + CHECK(logField(t, C_BATTERY_V) == "4.117" && logField(t, C_BATTERY_PCT) == "87.0" && logField(t, C_BATTERY_STATE) == "discharging"); + t.batteryVolts = 3.3004f; + CHECK(logField(t, C_BATTERY_V) == "3.300" && logField(t, C_BATTERY_PCT) == "0.8"); + t.batteryVolts = 4.25f; + CHECK(logField(t, C_BATTERY_PCT) == "100.0"); + struct StateCase { + int charge; + bool warm, ready; + const char *text; + }; + const StateCase states[] = {{CHARGE_CHARGING, false, true, "charging"}, {CHARGE_FULL, false, true, "full"}, {CHARGE_DISCHARGING, false, true, "discharging"}, + {CHARGE_UNKNOWN, true, false, "starting up"}, {CHARGE_UNKNOWN, false, false, "learning"}, {CHARGE_UNKNOWN, false, true, "on battery?"}, + {CHARGE_CHARGING, true, false, "charging"}}; + for (const StateCase &c : states) { + t.charge = c.charge; + t.chargeWarmingUp = c.warm; + t.chargeReady = c.ready; + if (logField(t, C_BATTERY_STATE) != c.text) printf(" battery state %d/%d/%d gives '%s'\n", c.charge, c.warm, c.ready, logField(t, C_BATTERY_STATE).c_str()); + CHECK(logField(t, C_BATTERY_STATE) == c.text); + } + for (const char *word : {"`on battery?`", "`discharging`", "`charging`", "`full`"}) CHECK(readme.find(word) != std::string::npos); // the README names them + t = r; + t.batteryPresent = false; // no battery: nothing is measured or estimated + CHECK(logField(t, C_BATTERY_STATE) == "none"); + for (Column c : {C_BATTERY_V, C_BATTERY_PCT, C_LEFT, C_PCT_PER_HOUR, C_OVER_MIN, C_CLOCK_MA}) CHECK(logField(t, c).empty()); + } + // the runtime estimate: the three figures of the Left line and the one of the Current line, when there are any + { + Reading t = r; + CHECK(logField(t, C_LEFT) == "3 d 0 h" && logField(t, C_PCT_PER_HOUR) == "1.20" && logField(t, C_OVER_MIN) == "300" && logField(t, C_CLOCK_MA) == "30.0"); + for (int state : {(int)battest::Estimate::OFF, (int)battest::Estimate::LEARNING}) { // no figure yet + t = r; + t.estimate.state = (battest::Estimate::State)state; + for (Column c : {C_LEFT, C_PCT_PER_HOUR, C_OVER_MIN, C_CLOCK_MA}) CHECK(logField(t, c).empty()); + } + for (int charge : {CHARGE_CHARGING, CHARGE_FULL}) { // a charger shows: nothing is being drained + t = r; + t.charge = charge; + for (Column c : {C_LEFT, C_PCT_PER_HOUR, C_OVER_MIN, C_CLOCK_MA}) CHECK(logField(t, c).empty()); + } + t = r; + t.charge = CHARGE_UNKNOWN; // "on battery?": the estimate runs whenever no charger shows + CHECK(logField(t, C_LEFT) == "3 d 0 h" && logField(t, C_PCT_PER_HOUR) == "1.20"); + // the rate with two decimals under ten, as the page shows it, and one from there + struct RateCase { + float rate; + const char *text; + }; + for (const RateCase &c : {RateCase{0.31f, "0.31"}, RateCase{0.114f, "0.11"}, RateCase{9.99f, "9.99"}, RateCase{9.996f, "10.0"}, RateCase{12.34f, "12.3"}, + RateCase{0.02f, "0.02"}}) { + t = r; + t.estimate.pctPerHour = c.rate; + if (logField(t, C_PCT_PER_HOUR) != c.text) printf(" rate %g gives '%s'\n", (double)c.rate, logField(t, C_PCT_PER_HOUR).c_str()); + CHECK(logField(t, C_PCT_PER_HOUR) == c.text); + } + t = r; // a fall too slow to measure: the rate as it is, and "more than a month" + t.estimate.pctPerHour = 0.004f; + t.estimate.hoursLeft = 0; + t.estimate.unbounded = true; + CHECK(logField(t, C_LEFT) == ">30 d" && logField(t, C_PCT_PER_HOUR) == "0.00" && logField(t, C_OVER_MIN) == "300"); + t = r; // a rate that can be measured, and more than a month left at it + t.estimate.pctPerHour = 0.11f; + t.estimate.hoursLeft = 800.0f; + t.estimate.windowMin = 480; + CHECK(logField(t, C_LEFT) == ">30 d" && logField(t, C_PCT_PER_HOUR) == "0.11" && logField(t, C_OVER_MIN) == "480"); + t = r; + t.estimate.hoursLeft = 5.6667f; + CHECK(logField(t, C_LEFT) == "5 h 40 min"); + // the current: a decimal under a hundred, and only with the capacity that it is worked out from + t = r; + t.estimate.avgMa = 99.94f; + CHECK(logField(t, C_CLOCK_MA) == "99.9"); + t.estimate.avgMa = 99.96f; + CHECK(logField(t, C_CLOCK_MA) == "100"); + t.estimate.avgMa = 8.26f; + CHECK(logField(t, C_CLOCK_MA) == "8.3"); + Settings noCapacity = s; + noCapacity.batteryCapacityMah = 0; + t.settings = &noCapacity; + CHECK(logField(t, C_CLOCK_MA).empty() && logField(t, C_BATTERY_CAPACITY_MAH).empty() && logField(t, C_PCT_PER_HOUR) == "1.20"); + CHECK(logField(r, C_BATTERY_CAPACITY_MAH) == "2500"); + } + // memory and frames + { + Reading t = r; + t.freeHeapBytes = 1023; + t.framesSent = 4294967295u; + t.framesLate = 4294967295u; + t.framesWorstMs = -2147483647 - 1; + CHECK(logField(t, C_FREE_KB) == "0" && logField(t, C_FRAMES_SENT) == "4294967295" && logField(t, C_FRAMES_LATE) == "4294967295" && + logField(t, C_FRAMES_WORST_MS) == "-2147483648"); + CHECK(logField(r, C_FREE_KB) == "187" && logField(r, C_FRAMES_WORST_MS) == "61"); + } + // the firmware: the version, and the build id when there is one + { + Reading t = r; + t.buildId = ""; + CHECK(logField(t, C_FIRMWARE) == "1.7" && logField(r, C_FIRMWARE) == "1.7 3f9a12c"); + } + + // a note, and what CSV asks of a field with a comma or a quote in it + { + Reading t = r; + t.note = "start (power on)"; + CHECK(logField(t, C_NOTES) == "start (power on)" && logLine(t).find(",start (power on)\r\n") != std::string::npos); + t.note = "wet, cold, \"quoted\""; + const std::string l = logLine(t); + CHECK(l.find(",\"wet, cold, \"\"quoted\"\"\"\r\n") != std::string::npos); + CHECK(logFields(l).size() == (size_t)kColumnCount && logField(t, C_NOTES) == "wet, cold, \"quoted\""); + CHECK(powerLogProblems(std::string(head) + l, readme, false) == 0); + // (it can fail: the same note without the quotes is two fields too many for the sheet's checks) + std::string bare = l; + bare.replace(bare.find(",\"wet"), std::string::npos, ",wet, cold, quoted\r\n"); + CHECK(powerLogProblems(std::string(head) + bare, readme, false) == 1); + + char out[32]; + CHECK(csvField("plain text", out, sizeof out) == 10 && !strcmp(out, "plain text")); + CHECK(csvField("a,b", out, sizeof out) == 5 && !strcmp(out, "\"a,b\"")); + CHECK(csvField("say \"hi\"", out, sizeof out) == 12 && !strcmp(out, "\"say \"\"hi\"\"\"")); + CHECK(csvField("two\nlines", out, sizeof out) == 11 && !strcmp(out, "\"two\nlines\"")); + CHECK(csvField("cr\rhere", out, sizeof out) == 9 && out[0] == '"'); + CHECK(csvField("", out, sizeof out) == 0 && out[0] == 0); + CHECK(csvField("a,b", out, 5) == 0 && csvField("a,b", out, 6) == 5 && csvField("abc", out, 3) == 0 && csvField("abc", out, 4) == 3 && csvField("abc", out, 0) == 0); + } + + // no reading makes a line too long for the buffers the clock uses, and every one has the sheet's number of fields + { + Settings big = s; + big.batteryCapacityMah = 20000; + Reading t = sampleReading(big); + t.version = "12.34"; + t.note = "start (reset over USB) and a note that is longer than any"; + t.uptimeSec = 4294967295u; + t.cpuMhz = 240; + t.clockReason = "a reason that is far too long to be one"; + t.radioOnPermille = 1000; + t.radioSessions = 4294967295u; + t.batteryVolts = 4.2f; + t.estimate.pctPerHour = 99.9f; + t.estimate.hoursLeft = 47.99f; + t.estimate.windowMin = 480; + t.estimate.avgMa = 19980.0f; + t.freeHeapBytes = 4294967295u; + t.framesSent = t.framesLate = 4294967295u; + t.framesWorstMs = -2147483647 - 1; + const std::string l = logLine(t); + printf(" a line is %zu bytes for the README's example and %zu at the most (the buffers hold %zu)\n", line.size(), l.size(), kMaxLine); + CHECK(!l.empty() && l.size() < kMaxLine / 2 && logFields(l).size() == (size_t)kColumnCount); + CHECK(powerLogProblems(std::string(head) + l, readme, true) == 0); + CHECK(logField(t, C_CLOCK_SHOWN) == "240 a reason that is far" && logField(t, C_NOTES).size() == 31); // long texts are cut, the line stays whole + } +} + +// Files by name, each a string. (Not a std::map: brings in std::apply, which the calls of this file's own +// apply() with a std::string would then find as well.) +struct FakeFiles { + std::vector> all; + + const std::string *find(const std::string &name) const { + for (const auto &f : all) + if (f.first == name) return &f.second; + return nullptr; + } + size_t count(const std::string &name) const { return find(name) ? 1 : 0; } + size_t size() const { return all.size(); } + bool empty() const { return all.empty(); } + const std::string &at(const std::string &name) const { + static const std::string none; + const std::string *f = find(name); + return f ? *f : none; + } + std::string &operator[](const std::string &name) { // (makes the file if there is none, as a map would) + for (auto &f : all) + if (f.first == name) return f.second; + all.emplace_back(name, std::string()); + return all.back().second; + } + void erase(const std::string &name) { + for (size_t i = 0; i < all.size(); i++) { + if (all[i].first != name) continue; + all.erase(all.begin() + (long)i); + return; + } + } + bool operator==(const FakeFiles &other) const { // the same files with the same contents, in any order + if (all.size() != other.all.size()) return false; + for (const auto &f : all) { + const std::string *o = other.find(f.first); + if (!o || *o != f.second) return false; + } + return true; + } +}; + +// A file system made of strings, for the steps of power_log.h that deal with the files; it can be told to fail. +struct FakeFs { + FakeFiles files; + bool failRead = false, failAppend = false, failRemove = false, failRename = false; + int failReadFrom = -1; // reads fail from this one on, counted from 0 (-1: none) + uint32_t appendCut = 0; // with failAppend: this many bytes get into the file before the write fails + int reads = 0, appends = 0, removes = 0, renames = 0; + + long size(const char *name) { + const std::string *f = files.find(name); + return f ? (long)f->size() : -1; + } + long read(const char *name, uint32_t offset, char *buf, uint32_t n) { + const int index = reads++; + const std::string *f = files.find(name); + if (failRead || (failReadFrom >= 0 && index >= failReadFrom) || !f) return -1; + if (offset >= f->size()) return 0; + const size_t take = f->size() - offset < n ? f->size() - offset : n; + memcpy(buf, f->data() + offset, take); + return (long)take; + } + bool append(const char *name, const char *data, uint32_t n) { + appends++; + if (failAppend) { + if (appendCut > 0) files[name].append(data, appendCut < n ? appendCut : n); + return false; + } + files[name].append(data, n); + return true; + } + bool remove(const char *name) { + removes++; + if (failRemove) return false; + files.erase(name); + return true; + } + bool rename(const char *from, const char *to) { + renames++; + if (failRename || !files.count(from) || files.count(to)) return false; // like FAT: not onto a name that exists + const std::string content = files.at(from); // (a copy first: making the new name can move the old one in memory) + files.erase(from); + files[to] = content; + return true; + } +}; + +struct StringSink { + std::string text; + long failAfter = -1; // refuses what would take it past this many bytes + int writes = 0; + bool write(const char *data, uint32_t n) { + writes++; + if (failAfter >= 0 && (long)(text.size() + n) > failAfter) return false; + text.append(data, n); + return true; + } +}; + +// Line `i` of a made-up log: its number, then a filling whose length and letter follow from the number, so a +// line that is cut short, joined to its neighbour or out of place is seen. +std::string numberedLine(int i) { + const int fill = 10 + (int)(((uint32_t)i * 2654435761u) >> 24) % 190; // 10 to 199 characters + return "n=" + std::to_string(i) + "," + std::string((size_t)fill, (char)('a' + i % 26)) + "\r\n"; +} + +struct LogCheck { + int problems = 0; // lines that are not whole, not in order or not under a heading + int headings = 0; // how often `header` stands in it + int first = 0, last = 0, lines = 0; +}; + +// Reads an exported log of numbered lines back: the heading first, then the lines, whole and in order. +LogCheck checkLog(const std::string &text, const std::string &header) { + LogCheck c; + size_t pos = 0; + int lineNo = 0; + while (pos < text.size()) { + const size_t end = text.find("\r\n", pos); + if (end == std::string::npos) { // the last line has no end + c.problems++; + break; + } + const std::string line = text.substr(pos, end + 2 - pos); + pos = end + 2; + lineNo++; + if (line == header) { + c.headings++; + continue; + } + if (lineNo == 1) c.problems++; // lines before any heading + int n = 0; + if (sscanf(line.c_str(), "n=%d,", &n) != 1 || line != numberedLine(n)) { + c.problems++; + continue; + } + if (c.lines > 0 && n != c.last + 1) c.problems++; + if (c.lines == 0) c.first = n; + c.last = n; + c.lines++; + } + if (c.headings == 0) c.problems++; + return c; +} + +template +std::string exported(Fs &fs, const std::string &header, uint32_t cap = 4096, bool *ok = nullptr, uint32_t *bytes = nullptr) { + StringSink sink; + std::vector buf(cap ? cap : 1); + const powerlog::Exported e = powerlog::exportLog(fs, header.c_str(), sink, buf.data(), cap); + if (ok) *ok = e.ok; + if (bytes) *bytes = e.bytes; + return sink.text; +} + +void testPowerLogFiles() { + section("power log: the files (append, the cap, reading it back)"); + using namespace powerlog; + char head[kMaxLine]; + CHECK(formatHeader(head, sizeof head) > 0); + const std::string H = head; + + // the reader of made-up logs can tell a good one from a bad one + { + const std::string good = H + numberedLine(5) + numberedLine(6) + numberedLine(7); + const LogCheck g = checkLog(good, H); + CHECK(g.problems == 0 && g.headings == 1 && g.first == 5 && g.last == 7 && g.lines == 3); + CHECK(checkLog(H + numberedLine(5) + numberedLine(7), H).problems == 1); // a line missing + CHECK(checkLog(H + numberedLine(6) + numberedLine(5), H).problems == 1); // out of order + CHECK(checkLog(H + numberedLine(5).substr(0, 9) + "\r\n" + numberedLine(6), H).problems >= 1); // a line cut short + CHECK(checkLog(H + numberedLine(5).substr(0, 9) + numberedLine(6), H).problems >= 1); // ... and joined to the next + CHECK(checkLog(numberedLine(5) + numberedLine(6), H).problems >= 1); // no heading + CHECK(checkLog(good.substr(0, good.size() - 2), H).problems == 1); // the last line end missing + CHECK(checkLog(H + numberedLine(5) + H + numberedLine(6), H).headings == 2); // the heading twice + for (int i = 0; i < 300; i++) CHECK(numberedLine(i).size() >= 16 && numberedLine(i).size() <= 208); // (what the sizes below count on) + } + + // a new log: the heading, then the line; the next line is just the line + { + FakeFs fs; + CHECK(logBytes(fs) == 0); + const Appended a = appendLine(fs, head, numberedLine(1).c_str()); + CHECK(a.what == Append::OK && fs.files.size() == 1 && fs.files[kFile] == H + numberedLine(1)); + CHECK(a.logBytes == H.size() + numberedLine(1).size() && logBytes(fs) == a.logBytes); + const Appended b = appendLine(fs, head, numberedLine(2).c_str()); + CHECK(b.what == Append::OK && fs.files[kFile] == H + numberedLine(1) + numberedLine(2) && b.logBytes == fs.files[kFile].size()); + CHECK(fs.removes == 0 && fs.renames == 0 && fs.appends == 2); + CHECK(exported(fs, H) == H + numberedLine(1) + numberedLine(2)); + // an empty file that was left behind (made, and then the write failed) begins like a new one + FakeFs empty; + empty.files[kFile] = ""; + CHECK(appendLine(empty, head, numberedLine(1).c_str()).what == Append::OK && empty.files[kFile] == H + numberedLine(1)); + // nothing is written for a line or a heading that is empty or too long + FakeFs none; + const std::string tooLong(kMaxLine, 'x'); + CHECK(appendLine(none, head, "").what == Append::FAILED && appendLine(none, "", "x\r\n").what == Append::FAILED); + CHECK(appendLine(none, head, tooLong.c_str()).what == Append::FAILED && appendLine(none, tooLong.c_str(), "x\r\n").what == Append::FAILED); + CHECK(none.files.empty() && none.appends == 0); + } + + // the cap: the two files together never hold more, the newest lines are always there, whole and in order + // under one heading, and nothing is dropped before the log is full + for (uint32_t cap : {6000u, 20000u, 64u * 1024u}) { + FakeFs fs; + int rotations = 0, firstRotationAt = 0, bad = 0; + uint32_t smallestFull = 0xFFFFFFFFu; // the least the log held from its first rotation on + const int kLines = 2500; + for (int i = 1; i <= kLines; i++) { + const std::string oldBefore = fs.files.count(kOldFile) ? fs.files[kOldFile] : std::string(); + const Appended a = appendLine(fs, head, numberedLine(i).c_str(), cap); + if (a.what == Append::FAILED) bad++; + if (a.what == Append::ROTATED) { + rotations++; + if (!firstRotationAt) firstRotationAt = i; + } + if ((a.what == Append::ROTATED) != (fs.files.count(kOldFile) && fs.files[kOldFile] != oldBefore)) bad++; // it says so when lines were dropped + const long cur = fs.size(kFile), old = fs.size(kOldFile); + if (cur <= 0 || (uint32_t)cur > cap / 2 || old > (long)(cap / 2)) bad++; + if (logBytes(fs) > cap || a.logBytes != logBytes(fs)) bad++; + if (i % 7 == 0 || a.what == Append::ROTATED || i == kLines) { // (reading it back every time would take long) + bool ok = false; + uint32_t bytes = 0; + const std::string text = exported(fs, H, 4096, &ok, &bytes); + const LogCheck c = checkLog(text, H); + if (!ok || bytes != text.size() || c.problems != 0 || c.headings != 1 || c.last != i) bad++; + if (rotations <= 1 && c.first != 1) bad++; // nothing is dropped until the old file is replaced for the first time + if (rotations >= 1 && bytes < smallestFull) smallestFull = bytes; + if (text.size() > cap) bad++; + } + } + printf(" cap %u: %d lines written, the old half dropped %d times (first at line %d); the log never held less than %u bytes after that\n", (unsigned)cap, + kLines, rotations, firstRotationAt, (unsigned)smallestFull); + CHECK(bad == 0); + CHECK(rotations >= 2 && firstRotationAt > 1); + CHECK(smallestFull + 208 >= cap / 2 && smallestFull <= cap); // what is kept is at least half the cap, less a line + // (it can fail: with no cap to speak of the same lines pile up past it) + FakeFs loose; + for (int i = 1; i <= 200; i++) appendLine(loose, head, numberedLine(i).c_str(), 0xFFFFFFFFu); + CHECK(logBytes(loose) > 6000 && loose.files.count(kOldFile) == 0); + } + CHECK(kMaxBytes == 1024 * 1024 && kMaxBytes / 2 > 100 * kMaxLine); // the real cap: a megabyte + + // a file that another firmware began, under other columns: its lines keep their heading, in the old file, + // and the new lines get theirs + { + FakeFs fs; + const std::string otherHead = "test,firmware,a_column_that_is_gone\r\n", otherLines = "x,1.6 abc,1\r\ny,1.6 abc,2\r\n"; + fs.files[kFile] = otherHead + otherLines; + fs.files[kOldFile] = "older still\r\n"; + const Appended a = appendLine(fs, head, numberedLine(1).c_str()); + CHECK(a.what == Append::ROTATED && fs.files[kOldFile] == otherHead + otherLines && fs.files[kFile] == H + numberedLine(1)); + CHECK(appendLine(fs, head, numberedLine(2).c_str()).what == Append::OK); + CHECK(exported(fs, H) == otherHead + otherLines + H + numberedLine(1) + numberedLine(2)); // each lot under its own heading + // a heading that only begins like this firmware's is another heading, shorter or longer + for (const std::string &other : {H.substr(0, H.size() - 2) + ",one_more\r\n", H.substr(0, H.size() - 8) + "\r\n", std::string("t\r\n")}) { + FakeFs g; + g.files[kFile] = other + "1\r\n"; + CHECK(appendLine(g, head, numberedLine(1).c_str()).what == Append::ROTATED && g.files[kOldFile] == other + "1\r\n" && g.files[kFile] == H + numberedLine(1)); + } + // the same heading is no reason to start a new file (the control of the case above) + FakeFs same; + same.files[kFile] = H + numberedLine(1); + CHECK(appendLine(same, head, numberedLine(2).c_str()).what == Append::OK && same.files.count(kOldFile) == 0); + // a file that holds the heading and nothing else yet + FakeFs bare; + bare.files[kFile] = H; + CHECK(appendLine(bare, head, numberedLine(1).c_str()).what == Append::OK && bare.files[kFile] == H + numberedLine(1)); + } + + // a write that was cut short left part of a line: the next line starts on a line of its own + { + FakeFs fs; + fs.files[kFile] = H + numberedLine(1) + "n=2,bbb"; + CHECK(appendLine(fs, head, numberedLine(3).c_str()).what == Append::OK); + CHECK(fs.files[kFile] == H + numberedLine(1) + "n=2,bbb\r\n" + numberedLine(3)); + const LogCheck c = checkLog(exported(fs, H), H); + CHECK(c.problems == 2 && c.last == 3 && c.lines == 2); // the half line is lost (and so is its number), its neighbours are whole + // (the control: without that line end the two would be one line, and line 3 lost with it) + CHECK(checkLog(H + numberedLine(1) + "n=2,bbb" + numberedLine(3), H).lines == 1); + } + + // things that fail: nothing is half done, and the next line goes in as if nothing had happened + { + FakeFs fs; + for (int i = 1; i <= 3; i++) appendLine(fs, head, numberedLine(i).c_str()); + const FakeFiles before = fs.files; + fs.failRead = true; // the file cannot be read: nothing is touched + CHECK(appendLine(fs, head, numberedLine(4).c_str()).what == Append::FAILED && fs.files == before && fs.appends == 3); + fs.failRead = false; + fs.reads = 0; + fs.failReadFrom = 1; // ... or its heading can and its last byte cannot: the same + CHECK(appendLine(fs, head, numberedLine(4).c_str()).what == Append::FAILED && fs.files == before && fs.appends == 3 && fs.reads == 2); + fs.failReadFrom = -1; + fs.failAppend = true; // it cannot be written + CHECK(appendLine(fs, head, numberedLine(4).c_str()).what == Append::FAILED && fs.files == before); + fs.appendCut = 9; // ... or only the start of the line got in + CHECK(appendLine(fs, head, numberedLine(4).c_str()).what == Append::FAILED && fs.files[kFile].size() == before.at(kFile).size() + 9); + fs.failAppend = false; + CHECK(appendLine(fs, head, numberedLine(5).c_str()).what == Append::OK); + const LogCheck c = checkLog(exported(fs, H), H); + CHECK(c.first == 1 && c.last == 5 && c.lines == 4 && c.problems == 2); // lines 1 to 3 and 5 are whole; the half of line 4 is all that is wrong + + // when the log is full and the old file cannot be deleted or the new one renamed: no line, and nothing lost + // that was not going to be dropped anyway + FakeFs stuck; + int n = 0; + // (both loops end by a count as well: a cap that does not work must fail this test, not keep it running) + while (n < 1000 && stuck.files.count(kOldFile) == 0) appendLine(stuck, head, numberedLine(++n).c_str(), 6000); + while (n < 1000 && stuck.files[kFile].size() + numberedLine(n + 1).size() <= 3000) appendLine(stuck, head, numberedLine(++n).c_str(), 6000); + CHECK(n < 1000 && stuck.files.count(kOldFile) == 1); + const FakeFiles atFull = stuck.files; // (the next line is the one that makes room first) + stuck.failRemove = true; + CHECK(appendLine(stuck, head, numberedLine(n + 1).c_str(), 6000).what == Append::FAILED && stuck.files == atFull); + stuck.failRemove = false; + stuck.failRename = true; + CHECK(appendLine(stuck, head, numberedLine(n + 1).c_str(), 6000).what == Append::FAILED); + CHECK(stuck.files.count(kOldFile) == 0 && stuck.files[kFile] == atFull.at(kFile)); // the newest lines are all there + stuck.failRename = false; + CHECK(appendLine(stuck, head, numberedLine(n + 1).c_str(), 6000).what == Append::ROTATED); + const LogCheck after = checkLog(exported(stuck, H), H); + CHECK(after.problems == 0 && after.last == n + 1 && after.headings == 1); + } + + // reading the log back: one heading at the top, whatever the size of the pieces it is read in + { + FakeFs fs; + for (int i = 1; i <= 120; i++) appendLine(fs, head, numberedLine(i).c_str(), 12000); + CHECK(fs.files.count(kOldFile) == 1); + bool ok = false; + uint32_t bytes = 0; + const std::string whole = exported(fs, H, 4096, &ok, &bytes); + CHECK(ok && bytes == whole.size() && checkLog(whole, H).problems == 0 && checkLog(whole, H).headings == 1); + CHECK(whole == fs.files[kOldFile] + fs.files[kFile].substr(H.size())); // the old file, then the new one without its heading + for (uint32_t cap : {(uint32_t)kMaxLine, (uint32_t)kMaxLine + 1, 1000u, 100000u}) { + bool okCap = false; + CHECK(exported(fs, H, cap, &okCap) == whole && okCap); + } + for (uint32_t cap : {0u, 1u, (uint32_t)kMaxLine - 1}) { // a piece that could not hold a heading: nothing is read + bool okCap = true; + CHECK(exported(fs, H, cap, &okCap).empty() && !okCap); + } + // the sink gives up (a card that is full, a serial port nobody reads): it stops there and says so + for (long limit : {0L, 100L, (long)H.size(), (long)whole.size() - 1}) { + StringSink sink; + sink.failAfter = limit; + std::vector buf(4096); + const Exported e = exportLog(fs, head, sink, buf.data(), 4096); + CHECK(!e.ok && e.bytes == sink.text.size() && (long)sink.text.size() <= limit && whole.compare(0, sink.text.size(), sink.text) == 0); + } + { + StringSink sink; + sink.failAfter = (long)whole.size(); // (the control: room for exactly all of it) + std::vector buf(4096); + CHECK(exportLog(fs, head, sink, buf.data(), 4096).ok && sink.text == whole); + } + fs.failRead = true; // the log cannot be read + bool okRead = true; + exported(fs, H, 4096, &okRead); + CHECK(!okRead); + fs.failRead = false; + // ... or only so far. Wherever the reading stops, what went out is the start of the log and it is not + // passed off as the whole of it (a copy on the card that is short and says nothing would be the worst kind). + fs.reads = 0; + CHECK(exported(fs, H, 1000, &okRead) == whole && okRead); + const int readsOfAll = fs.reads; + CHECK(readsOfAll >= 8); // (both files' first lines, and several pieces of each) + for (int from = 0; from <= readsOfAll; from++) { + fs.reads = 0; + fs.failReadFrom = from; + bool okPart = true; + const std::string part = exported(fs, H, 1000, &okPart); + if (from < readsOfAll) { + if (okPart || part.size() >= whole.size() || whole.compare(0, part.size(), part) != 0) printf(" reads failing from number %d: not noticed\n", from); + CHECK(!okPart && part.size() < whole.size() && whole.compare(0, part.size(), part) == 0); + } else { + CHECK(okPart && part == whole); // (the control: every read it needs works) + } + } + fs.failReadFrom = -1; + + // an empty log is the heading alone + FakeFs none; + CHECK(exported(none, H, 4096, &ok, &bytes) == H && ok && bytes == H.size()); + none.files[kFile] = ""; + none.files[kOldFile] = ""; + CHECK(exported(none, H) == H); + // only the old file is there (the new one could not be begun after the old half was put aside) + FakeFs oldOnly; + oldOnly.files[kOldFile] = H + numberedLine(1) + numberedLine(2); + CHECK(exported(oldOnly, H) == H + numberedLine(1) + numberedLine(2)); + CHECK(appendLine(oldOnly, head, numberedLine(3).c_str()).what == Append::OK && checkLog(exported(oldOnly, H), H).problems == 0 && + checkLog(exported(oldOnly, H), H).lines == 3); + // the old file ends in the middle of a line: the new file's lines start on a line of their own + FakeFs torn; + torn.files[kOldFile] = H + numberedLine(1) + "n=2,bb"; + torn.files[kFile] = H + numberedLine(3); + CHECK(exported(torn, H) == H + numberedLine(1) + "n=2,bb\r\n" + numberedLine(3)); + // a file that does not begin with a line at all is put out as it is, and the next file's heading with it + FakeFs junk; + junk.files[kOldFile] = std::string(2 * kMaxLine, '#'); + junk.files[kFile] = H + numberedLine(1); + CHECK(exported(junk, H) == std::string(2 * kMaxLine, '#') + "\r\n" + H + numberedLine(1)); + } + + // forgetting the log + { + FakeFs fs; + for (int i = 1; i <= 120; i++) appendLine(fs, head, numberedLine(i).c_str(), 12000); + CHECK(fs.files.size() == 2 && clearLog(fs) && fs.files.empty() && logBytes(fs) == 0); + CHECK(clearLog(fs)); // nothing to forget is fine too + CHECK(appendLine(fs, head, numberedLine(1).c_str()).what == Append::OK && exported(fs, H) == H + numberedLine(1)); + fs.failRemove = true; + CHECK(!clearLog(fs) && fs.files.size() == 1); + } +} + +// A file of a folder as a string ("" if there is none), without the complaint readFile() makes. +std::string folderFile(const char *folder, const char *name) { + const std::string path = std::string(folder) + "/" + name; + FILE *f = fopen(path.c_str(), "rb"); + if (!f) return ""; + std::string text; + char buf[4096]; + size_t n; + while ((n = fread(buf, 1, sizeof buf, f)) > 0) text.append(buf, n); + fclose(f); + return text; +} + +// The same steps on the files of a real folder, through the very calls the clock makes on its flash partition +// (FolderFs). The made-up file system above is the reference: asked the same things, the two end up with the +// same files. (A folder of this PC is not the FAT file system of the clock; what is run here is the calls: the +// flags, the places in the file, what counts as an error.) +void testPowerLogFolder() { + section("power log: the files of a real folder"); + using namespace powerlog; + char head[kMaxLine]; + CHECK(formatHeader(head, sizeof head) > 0); + const std::string H = head; + // (in memory where the PC has /dev/shm: every line is followed by an fsync, which takes tens of milliseconds on a disk) + char inMemory[] = "/dev/shm/plog_test_XXXXXX", onDisk[] = "/tmp/plog_test_XXXXXX"; + const char *dir = mkdtemp(inMemory); + if (!dir) dir = mkdtemp(onDisk); + if (!dir) { + printf(" cannot make a folder in /dev/shm or /tmp\n"); + g_failed++; + return; + } + FolderFs real(dir); + FakeFs fake; + + // an empty folder: no file, nothing to read, nothing to rename; deleting what is not there is no error + char piece[64]; + CHECK(real.size(kFile) == -1 && real.size(kOldFile) == -1 && logBytes(real) == 0); + CHECK(real.read(kFile, 0, piece, sizeof piece) == -1); + CHECK(real.remove(kFile) && real.remove(kOldFile)); + CHECK(!real.rename(kFile, kOldFile)); + CHECK(exported(real, H) == H); + + // 400 lines with a small cap, to both + int differ = 0; + for (int i = 1; i <= 400; i++) { + const std::string l = numberedLine(i); + const Appended a = appendLine(real, head, l.c_str(), 20000), f = appendLine(fake, head, l.c_str(), 20000); + if (a.what != f.what || a.what == Append::FAILED || a.logBytes != f.logBytes || real.size(kFile) != fake.size(kFile) || + real.size(kOldFile) != fake.size(kOldFile)) { + if (differ++ < 3) printf(" line %d: the folder and the made-up files differ (%d/%d, %u/%u bytes)\n", i, (int)a.what, (int)f.what, (unsigned)a.logBytes, (unsigned)f.logBytes); + } + } + CHECK(differ == 0); + CHECK(fake.files.count(kOldFile) == 1 && fake.renames >= 2); // (the old half was dropped more than once on the way) + CHECK(folderFile(dir, kFile) == fake.files.at(kFile) && folderFile(dir, kOldFile) == fake.files.at(kOldFile)); // byte for byte + for (uint32_t cap : {(uint32_t)kMaxLine, 4096u}) { + bool okReal = false, okFake = false; + uint32_t bytes = 0; + const std::string text = exported(real, H, cap, &okReal, &bytes); + CHECK(okReal && bytes == text.size() && text == exported(fake, H, cap, &okFake) && okFake); + const LogCheck c = checkLog(text, H); + CHECK(c.problems == 0 && c.headings == 1 && c.last == 400); + } + + // reading: a piece from the middle, the last bytes (fewer than asked for), and past the end (nothing, which is no error) + const std::string cur = fake.files.at(kFile); + CHECK(cur.size() > 100); + CHECK(real.read(kFile, 10, piece, 20) == 20 && memcmp(piece, cur.data() + 10, 20) == 0); + CHECK(real.read(kFile, (uint32_t)cur.size() - 5, piece, 20) == 5 && memcmp(piece, cur.data() + cur.size() - 5, 5) == 0); + CHECK(real.read(kFile, (uint32_t)cur.size(), piece, 20) == 0 && real.read(kFile, (uint32_t)cur.size() + 1000, piece, 20) == 0); + CHECK(real.read(kFile, 0, piece, 0) == 0); + + // a file that ends in the middle of a line (cut short here by hand) is mended the same way + { + const std::string path = std::string(dir) + "/" + kFile; + CHECK(truncate(path.c_str(), (off_t)cur.size() - 7) == 0); + fake.files[kFile].resize(cur.size() - 7); + CHECK(appendLine(real, head, numberedLine(401).c_str(), 20000).what == appendLine(fake, head, numberedLine(401).c_str(), 20000).what); + const std::string mended = folderFile(dir, kFile), tail = "\r\n" + numberedLine(401); + CHECK(mended == fake.files.at(kFile)); + CHECK(mended.size() > tail.size() && mended.compare(mended.size() - tail.size(), tail.size(), tail) == 0); + } + + // another firmware's file: put aside under its own heading + { + CHECK(clearLog(real) && real.size(kFile) == -1 && real.size(kOldFile) == -1 && folderFile(dir, kFile).empty()); + const std::string other = "some,other,columns\r\n1,2,3\r\n"; + CHECK(real.append(kFile, other.data(), (uint32_t)other.size()) && folderFile(dir, kFile) == other); + CHECK(appendLine(real, head, numberedLine(1).c_str()).what == Append::ROTATED); + CHECK(folderFile(dir, kOldFile) == other && folderFile(dir, kFile) == H + numberedLine(1)); + CHECK(exported(real, H) == other + H + numberedLine(1)); + } + + // a name that is taken by something that is no file (here a folder): it has no size, and cannot be read, added to + // or deleted; the log says so instead of pretending + { + CHECK(clearLog(real)); + const std::string blocker = std::string(dir) + "/" + kOldFile; + CHECK(mkdir(blocker.c_str(), 0777) == 0); + CHECK(real.size(kOldFile) == -1 && !real.remove(kOldFile) && real.read(kOldFile, 0, piece, 8) == -1 && !real.append(kOldFile, "x", 1)); + CHECK(!clearLog(real)); + // lines go in until the file is full; then the old half cannot be dropped and every further line is refused + int written = 0, refused = 0; + for (int i = 1; i <= 60; i++) (appendLine(real, head, numberedLine(written + 1).c_str(), 6000).what == Append::FAILED ? refused : written)++; + CHECK(written > 10 && refused > 10 && written + refused == 60); + const LogCheck c = checkLog(exported(real, H), H); + CHECK(c.problems == 0 && c.first == 1 && c.last == written && real.size(kFile) <= 3000); + CHECK(rmdir(blocker.c_str()) == 0); + CHECK(appendLine(real, head, numberedLine(written + 1).c_str(), 6000).what == Append::ROTATED); // the way is clear: it carries on + CHECK(checkLog(exported(real, H), H).problems == 0 && checkLog(exported(real, H), H).last == written + 1); + } + + // a path too long to be a path is an error, not another file's name; and a folder that is not there takes nothing + { + const std::string longDir = "/tmp/" + std::string(200, 'x'); + FolderFs tooLong(longDir.c_str()); + CHECK(tooLong.size(kFile) == -1 && tooLong.read(kFile, 0, piece, 8) == -1 && !tooLong.append(kFile, "x", 1) && !tooLong.remove(kFile) && + !tooLong.rename(kFile, kOldFile)); + FolderFs gone("/tmp/plog_test_no_such_folder"); + CHECK(gone.size(kFile) == -1 && !gone.append(kFile, "x", 1) && gone.read(kFile, 0, piece, 8) == -1); + CHECK(appendLine(gone, head, numberedLine(1).c_str()).what == Append::FAILED); + bool ok = false; + CHECK(exported(gone, H, 4096, &ok) == H && ok); // (no files is an empty log) + } + + CHECK(clearLog(real) && real.size(kFile) == -1 && real.size(kOldFile) == -1); + CHECK(rmdir(dir) == 0); // nothing else was left in it +} + +void testPowerLogSchedule() { + section("power log: when a line is due"); + using namespace powerlog; + CHECK(kIntervalSec == 600 && kFirstSec > 0 && kFirstSec < 60 && kLatestStartUs > 0 && kLatestStartUs < 300000); + { + Schedule s; + CHECK(s.startLine() && !s.due(0) && !s.due(kFirstSec - 1) && s.due(kFirstSec) && s.due(100000)); + s.done(kFirstSec, true); + CHECK(!s.startLine() && s.lines() == 1 && s.lastSec() == kFirstSec); + CHECK(!s.due(kFirstSec) && !s.due(kFirstSec + kIntervalSec - 1) && s.due(kFirstSec + kIntervalSec)); + } + + // a day in which the loop gets round to it a little late every time: a line about every ten minutes, and + // never two closer together than that + { + Schedule s; + uint32_t rng = 4711, lastWrite = 0, minGap = 0xFFFFFFFFu, maxGap = 0; + int lines = 0; + for (uint32_t t = 0; t < 86400; t++) { + rng = rng * 1664525u + 1013904223u; + if (((rng >> 16) % 5) != 0) continue; // the loop is busy with a frame four seconds in five + if (!s.due(t)) continue; + if (lines > 0) { + const uint32_t gap = t - lastWrite; + if (gap < minGap) minGap = gap; + if (gap > maxGap) maxGap = gap; + } + lastWrite = t; + lines++; + s.done(t, true); + } + printf(" a simulated day: %d lines, %u to %u s apart\n", lines, (unsigned)minGap, (unsigned)maxGap); + CHECK(lines >= 140 && lines <= 144 && (uint32_t)lines == s.lines()); + CHECK(minGap >= kIntervalSec && maxGap < kIntervalSec + 60); + } + + // a write that fails is not tried again before the next line is due, and after three in a row no more at all + { + Schedule s; + s.done(10, true); + s.done(610, false); + CHECK(s.failures() == 1 && !s.stopped() && s.lines() == 1 && !s.due(611) && !s.due(1209) && s.due(1210)); + s.done(1210, false); + CHECK(s.failures() == 2 && !s.stopped()); + s.done(1810, true); // one that works: the count starts again + CHECK(s.failures() == 0 && s.lines() == 2); + for (int i = 0; i < kMaxFailures - 1; i++) s.done(2410 + (uint32_t)i * 600, false); + CHECK(!s.stopped() && s.due(100000)); + s.done(4000, false); + CHECK(s.stopped() && s.failures() == kMaxFailures && !s.due(4000 + kIntervalSec) && !s.due(4000000000u)); + Schedule first; // the line of the start fails: the next is a whole interval later, and is no longer "the start" + first.done(kFirstSec, false); + CHECK(!first.startLine() && !first.due(kFirstSec + kIntervalSec - 1) && first.due(kFirstSec + kIntervalSec) && first.lines() == 0); + Schedule off; // nothing to write to + off.stop(); + CHECK(off.stopped() && !off.due(kFirstSec) && !off.due(1000000)); + } + + // seconds that run over (136 years on): the interval is a difference, so it still holds + { + Schedule s; + s.done(4294967000u, true); + CHECK(!s.due(4294967295u) && !s.due(4294967000u + kIntervalSec - 1) && s.due(4294967000u + kIntervalSec)); + CHECK((uint32_t)(4294967000u + kIntervalSec) < 1000); // (that moment is past the wrap) + } +} + +void testPowerLogStatus() { + section("power log: the rows of the Power and settings page"); + using namespace powerlog; + const size_t kShown = 42; // an info page shows this much of a value + CHECK(kStatusRow == kShown + 1); + // A row is whole when it ends the way its text ends: a text cut off at the edge of the page does not. + auto whole = [](const char *row) { + for (const char *end : {" ago", " KB)", "in a moment", "FAILED", "write error", "read the log", "README)", "will not mount", "lines)"}) { + const size_t n = strlen(end), l = strlen(row); + if (l >= n && !strcmp(row + l - n, end)) return true; + } + return false; + }; + int rowsSeen = 0, bad = 0; + size_t longest = 0; + char longestText[kStatusRow] = ""; + for (int on = 0; on < 2; on++) + for (int problem : {(int)Status::FINE, (int)Status::NO_PARTITION, (int)Status::CANNOT_MOUNT}) + for (int stopped = 0; stopped < 2; stopped++) + for (int failures : {0, 1, kMaxFailures}) + for (uint32_t lines : {0u, 1u, 2u, 99999u}) + for (uint32_t bytes : {0u, 1u, 1024u, 1025u, kMaxBytes}) + for (uint32_t since : {0u, 89u, 90u, 5399u, 5400u, 36000u}) + for (int copy : {(int)Status::COPY_NONE, (int)Status::COPY_DONE, (int)Status::COPY_CANNOT_READ, (int)Status::COPY_CARD_ERROR}) { + Status s; + s.on = on != 0; + s.problem = (Status::Problem)problem; + s.stopped = stopped != 0; + s.failures = failures; + s.lines = lines; + s.logBytes = bytes; + s.sinceLineSec = since; + s.copy = (Status::Copy)copy; + s.copyBytes = bytes; + char rows[2][kStatusRow] = {"", ""}; + const int n = statusRows(s, rows); + if (n != on + (copy != Status::COPY_NONE ? 1 : 0)) bad++; + for (int i = 0; i < n; i++) { + rowsSeen++; + const size_t len = strlen(rows[i]); + if (len == 0 || len > kShown || !whole(rows[i])) { + if (bad++ < 5) printf(" a row that does not fit or is cut: '%s'\n", rows[i]); + } + if (len > longest) { + longest = len; + snprintf(longestText, sizeof longestText, "%.*s", (int)(sizeof longestText - 1), rows[i]); // (bounded: the compiler takes the two rows for one string) + } + } + if (!on && n == 1 && strncmp(rows[0], "off, ", 5) != 0) bad++; // the log is off, and the row about its copy says so + if (on && n == 2 && !strncmp(rows[1], "off", 3)) bad++; + } + printf(" %d rows, the longest takes %zu of %zu characters: '%s'\n", rowsSeen, longest, kShown, longestText); + CHECK(bad == 0 && rowsSeen > 1000 && longest <= kShown); + + // what they say + char rows[2][kStatusRow]; + Status s; + CHECK(statusRows(s, rows) == 0); // off, and no copy: nothing on the page + s.on = true; + CHECK(statusRows(s, rows) == 1 && !strcmp(rows[0], "on: first line in a moment")); + s.lines = 1; + s.logBytes = 460; + s.sinceLineSec = 45; + CHECK(statusRows(s, rows) == 1 && !strcmp(rows[0], "1 KB, 1 line this run, 45 s ago")); + s.lines = 37; + s.logBytes = 412 * 1024; + s.sinceLineSec = 240; + CHECK(statusRows(s, rows) == 1 && !strcmp(rows[0], "412 KB, 37 lines this run, 4 min ago")); + s.sinceLineSec = 89; // seconds up to a minute and a half, then minutes to the nearest, then hours + CHECK(statusRows(s, rows) == 1 && !strcmp(rows[0], "412 KB, 37 lines this run, 89 s ago")); + s.sinceLineSec = 90; + CHECK(statusRows(s, rows) == 1 && !strcmp(rows[0], "412 KB, 37 lines this run, 2 min ago")); + s.sinceLineSec = 209; + CHECK(statusRows(s, rows) == 1 && !strcmp(rows[0], "412 KB, 37 lines this run, 3 min ago")); + s.sinceLineSec = 210; + CHECK(statusRows(s, rows) == 1 && !strcmp(rows[0], "412 KB, 37 lines this run, 4 min ago")); + s.sinceLineSec = 5399; + CHECK(statusRows(s, rows) == 1 && !strcmp(rows[0], "412 KB, 37 lines this run, 90 min ago")); + s.sinceLineSec = 7200; + CHECK(statusRows(s, rows) == 1 && !strcmp(rows[0], "412 KB, 37 lines this run, 2 h ago")); + s.failures = 1; + CHECK(statusRows(s, rows) == 1 && !strcmp(rows[0], "37 lines this run, last write FAILED")); + s.failures = kMaxFailures; + s.stopped = true; + CHECK(statusRows(s, rows) == 1 && !strcmp(rows[0], "STOPPED after 3 write errors (37 lines)")); + s.problem = Status::NO_PARTITION; + CHECK(statusRows(s, rows) == 1 && !strcmp(rows[0], "OFF: no ffat partition (see the README)")); + s.problem = Status::CANNOT_MOUNT; + CHECK(statusRows(s, rows) == 1 && !strcmp(rows[0], "OFF: the flash partition will not mount")); + s = Status(); + s.on = true; + s.lines = 2; + s.logBytes = 2048; + s.copy = Status::COPY_DONE; + s.copyBytes = 1500; + CHECK(statusRows(s, rows) == 2 && !strcmp(rows[0], "2 KB, 2 lines this run, 0 s ago") && !strcmp(rows[1], "copied to SD card at start (2 KB)")); + s.copy = Status::COPY_CARD_ERROR; + CHECK(statusRows(s, rows) == 2 && !strcmp(rows[1], "NOT copied to SD: card write error")); + s.on = false; + s.copy = Status::COPY_CANNOT_READ; + CHECK(statusRows(s, rows) == 1 && !strcmp(rows[0], "off, NOT copied to SD: cannot read the log")); + s.copy = Status::COPY_DONE; + s.copyBytes = kMaxBytes; + CHECK(statusRows(s, rows) == 1 && !strcmp(rows[0], "off, copied to SD card at start (1024 KB)")); + + // numbers that no run reaches are kept to the digits there is room for, so the row is whole all the same + Status absurd; + absurd.on = true; + absurd.stopped = true; + absurd.failures = 2000000000; + absurd.lines = 4000000000u; + CHECK(statusRows(absurd, rows) == 1 && !strcmp(rows[0], "STOPPED after 9 write errors (99999 lines)") && strlen(rows[0]) == kShown); + absurd.stopped = false; + absurd.failures = 0; + absurd.logBytes = 4000000000u; + absurd.sinceLineSec = 4000000000u; + absurd.copy = Status::COPY_DONE; + absurd.copyBytes = 4000000000u; + CHECK(statusRows(absurd, rows) == 2 && !strcmp(rows[0], "9999 KB, 99999 lines this run, 999 h ago") && !strcmp(rows[1], "copied to SD card at start (9999 KB)")); + absurd.failures = -5; // (no such count: it reads as none) + absurd.stopped = true; + CHECK(statusRows(absurd, rows) == 2 && !strcmp(rows[0], "STOPPED after 0 write errors (99999 lines)")); + // (the check for a row that is cut can fail: the same text with its end off the page) + CHECK(whole("412 KB, 37 lines this run, 4 min ago") && !whole("412 KB, 37 lines this run, 4 min a")); +} + +} // namespace + +int main(int argc, char **argv) { + if (argc > 1 && !strcmp(argv[1], "--write-example")) { + testRepoExampleFile(true); + return g_failed ? 1 : 0; + } + testRepoExampleFile(false); + testReadmeLists("../../README.md"); + testPowerLog(); + testPowerLogLine(); + testPowerLogFiles(); + testPowerLogFolder(); + testPowerLogSchedule(); + testPowerLogStatus(); + testMoonPhase(); + testMoonGlyph(); + testDateFormats(); + testBatteryCurve(); + testBatteryEstimate(); + testBatteryEstimateReplay(); + testLowBattery(); + testDrift(); + testSettingsTable(); + testSettingsRoundTrip(); + testSettingsSyntax(); + testSettingsValues(); + testSettingsRejects(); + testSettingsTimezone(); + testSettingsReport(); + testSdBanner(); + testSettingsForget(); + testSettingsExample(); + testBackupWifiSettings(); + testPowerSettings(); + testBatteryCalibrationSetting(); + testWifiPick(); + testWifiPickDay(); + testFirmwareLogic(); + testFirmwareInstall(); + testSdLayout(); + testSettingsFuzz(); + + printf("\n%d checks, %d failed\n", g_checks, g_failed); + return g_failed ? 1 : 0; +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/test_logic.cpp b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/test_logic.cpp new file mode 100644 index 00000000..745fee9a --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/test_logic.cpp @@ -0,0 +1,2504 @@ +// Host-side tests for the portable parts of the firmware: calendar maths, +// sensor calculations, the time zone table, the Open-Meteo / Spotify parsers, +// URL helpers and the button gesture detector. +// +// ./run_tests.sh (Linux / WSL) + +#include +#include +#include +#include + +#include +#include + +#include "../../battery_est.h" +#include "../../buttons.h" +#include "../../calc.h" +#include "../../charge.h" +#include "../../frame_plan.h" +#include "../../infinity_cube.h" +#include "../../link_page.h" +#include "../../sand_cube.h" +#include "../../spotify_parse.h" +#include "../../timeutil.h" +#include "../../util.h" +#include "../../weather.h" +#include "../../weather_codes.h" + +// Pull the table in directly so the test can see inside it. +#include "../../tz_table.cpp" + +#include "check.h" + +// --------------------------------------------------------------------------- +static void testCalendar() { + section("calendar"); + CHECK(timeutil::weekday(2026, 10, 4) == 0); // Sunday + CHECK(timeutil::weekday(2026, 10, 5) == 1); // Monday + CHECK(timeutil::weekday(2000, 1, 1) == 6); // Saturday + CHECK(timeutil::weekday(2024, 2, 29) == 4); // Thursday + + auto week = [](int y, int m, int d) { + struct tm t = {}; + t.tm_year = y - 1900; + t.tm_mon = m - 1; + t.tm_mday = d; + t.tm_hour = 12; + timegm(&t); // normalises tm_yday / tm_wday + return timeutil::isoWeek(y, t.tm_yday, t.tm_wday); + }; + CHECK(week(2026, 10, 4) == 40); + CHECK(week(2026, 1, 1) == 1); + CHECK(week(2027, 1, 3) == 53); // 2026 has 53 ISO weeks + CHECK(week(2021, 1, 1) == 53); // Friday belonging to 2020's last week + CHECK(week(2020, 12, 31) == 53); + CHECK(week(2024, 12, 30) == 1); // Monday already in 2025's week 1 + CHECK(week(2018, 12, 31) == 1); + CHECK(week(2024, 2, 29) == 9); + + char buf[16]; + timeutil::formatUtcOffset(480, buf, sizeof buf); + CHECK_STR(buf, "+08:00"); + timeutil::formatUtcOffset(-210, buf, sizeof buf); + CHECK_STR(buf, "-03:30"); + timeutil::formatUtcOffset(0, buf, sizeof buf); + CHECK_STR(buf, "+00:00"); + timeutil::formatUtcOffset(345, buf, sizeof buf); + CHECK_STR(buf, "+05:45"); + + timeutil::formatDuration(83000, buf, sizeof buf); + CHECK_STR(buf, "1:23"); + timeutil::formatDuration(200040, buf, sizeof buf); + CHECK_STR(buf, "3:20"); + timeutil::formatDuration(3661000, buf, sizeof buf); + CHECK_STR(buf, "1:01:01"); + timeutil::formatDuration(0, buf, sizeof buf); + CHECK_STR(buf, "0:00"); + + CHECK_STR(timeutil::weekdayName(0), "Sunday"); + CHECK_STR(timeutil::weekdayShort(6), "Sat"); + CHECK_STR(timeutil::monthShort(9), "Oct"); + + // epoch conversion against the C library + CHECK(calc::epochFromUtc(2026, 10, 4, 0, 21, 7) == 1791073267); + CHECK(calc::epochFromUtc(1970, 1, 1, 0, 0, 0) == 0); + CHECK(calc::epochFromUtc(2000, 2, 29, 23, 59, 59) == 951868799); + for (int y = 2024; y <= 2040; y++) { + for (int m = 1; m <= 12; m++) { + struct tm t = {}; + t.tm_year = y - 1900; + t.tm_mon = m - 1; + t.tm_mday = 28; + t.tm_hour = 13; + t.tm_min = 7; + t.tm_sec = 9; + CHECK(calc::epochFromUtc(y, m, 28, 13, 7, 9) == (int64_t)timegm(&t)); + } + } +} + +static void testTimeZones() { + section("time zones"); + auto setTz = [](const char *tz) { + setenv("TZ", tz, 1); + tzset(); + }; + time_t jan = (time_t)calc::epochFromUtc(2026, 1, 15, 0, 0, 0); + time_t jul = (time_t)calc::epochFromUtc(2026, 7, 15, 0, 0, 0); + + setTz("AWST-8"); + CHECK(timeutil::utcOffsetMinutes(jan) == 480); + CHECK(timeutil::utcOffsetMinutes(jul) == 480); + + setTz("AEST-10AEDT,M10.1.0,M4.1.0/3"); // Sydney: DST in the southern summer + CHECK(timeutil::utcOffsetMinutes(jan) == 660); + CHECK(timeutil::utcOffsetMinutes(jul) == 600); + + setTz("EST5EDT,M3.2.0,M11.1.0"); // New York + CHECK(timeutil::utcOffsetMinutes(jan) == -300); + CHECK(timeutil::utcOffsetMinutes(jul) == -240); + + setTz("IST-1GMT0,M10.5.0,M3.5.0/1"); // Dublin uses "negative DST" in tzdata + CHECK(timeutil::utcOffsetMinutes(jan) == 0); + CHECK(timeutil::utcOffsetMinutes(jul) == 60); + + // offset spanning the date line / year boundary: local date differs from UTC date + setTz("<+13>-13"); + time_t nye = (time_t)calc::epochFromUtc(2026, 12, 31, 20, 0, 0); // already 2027 locally + CHECK(timeutil::utcOffsetMinutes(nye) == 780); + setTz("<-11>11"); + time_t ny = (time_t)calc::epochFromUtc(2027, 1, 1, 5, 0, 0); // still 2026 locally + CHECK(timeutil::utcOffsetMinutes(ny) == -660); + + // table lookups + CHECK_STR(tzPosixForIana("Australia/Perth"), "AWST-8"); + CHECK_STR(tzPosixForIana("America/New_York"), "EST5EDT,M3.2.0,M11.1.0"); + CHECK_STR(tzPosixForIana("Europe/Kyiv"), "EET-2EEST,M3.5.0/3,M10.5.0/4"); + CHECK_STR(tzPosixForIana("Asia/Kolkata"), "IST-5:30"); + CHECK_STR(tzPosixForIana("Pacific/Auckland"), "NZST-12NZDT,M9.5.0,M4.1.0/3"); + CHECK(tzPosixForIana("Mars/Olympus_Mons") == nullptr); + CHECK(tzPosixForIana("") == nullptr); + CHECK(tzPosixForIana(nullptr) == nullptr); + CHECK(tzPosixForIana("australia/perth") == nullptr); // names are case sensitive + + // binary search depends on sorted order; check it and round-trip every entry + size_t n = sizeof(kZones) / sizeof(kZones[0]); + CHECK(n > 500); + bool sorted = true; + for (size_t i = 1; i < n; i++) sorted &= strcmp(kZones[i - 1].name, kZones[i].name) < 0; + CHECK(sorted); + size_t bad = 0; + for (size_t i = 0; i < n; i++) { + const char *p = tzPosixForIana(kZones[i].name); + if (!p || strcmp(p, kZones[i].posix) != 0) bad++; + } + CHECK(bad == 0); + + // every rule in the table must be accepted by the C library's TZ parser and + // yield a plausible offset (guards against a typo in the generator) + size_t implausible = 0; + for (size_t i = 0; i < n; i++) { + setTz(kZones[i].posix); + int a = timeutil::utcOffsetMinutes(jan), b = timeutil::utcOffsetMinutes(jul); + if (a < -12 * 60 || a > 14 * 60 || b < -12 * 60 || b > 14 * 60) implausible++; + } + CHECK(implausible == 0); + + // synthesised fixed-offset rules + char buf[40]; + calc::posixFromUtcOffset(28800, buf, sizeof buf); + CHECK_STR(buf, "<+08>-8"); + calc::posixFromUtcOffset(19800, buf, sizeof buf); + CHECK_STR(buf, "<+0530>-5:30"); + calc::posixFromUtcOffset(-12600, buf, sizeof buf); + CHECK_STR(buf, "<-0330>3:30"); + calc::posixFromUtcOffset(0, buf, sizeof buf); + CHECK_STR(buf, "<+00>0"); + calc::posixFromUtcOffset(-18000, buf, sizeof buf); + CHECK_STR(buf, "<-05>5"); + int offsets[] = {28800, 19800, -12600, 0, -18000, 45900, 50400, -39600}; + for (int off : offsets) { + calc::posixFromUtcOffset(off, buf, sizeof buf); + setTz(buf); + CHECK(timeutil::utcOffsetMinutes(jan) == off / 60); + } + setTz("UTC0"); +} + +// The ADC reads a few percent low: one multiplier, what a tester says over what the clock measured. +static void testBatteryCalibration() { + section("battery voltage calibration"); + float f = 0; + // the case that started it: a full cell is 4.20 V on the tester and 4.135 V to the clock, which calls that 89 % + CHECK(calc::batteryPercent(4.135f) == 89); + CHECK(calc::calibrationFactor(4.20f, 4.135f, &f)); + CHECK_NEAR(f, 1.01572, 1e-4); + CHECK(calc::batteryPercent(4.135f * f) == 100); // corrected, it is full (and over the 4.16 V the charge detector wants) + CHECK(4.135f * f >= 4.16f); + // the direction: a reading that is too high gets a factor under 1 + CHECK(calc::calibrationFactor(4.20f, 4.00f, &f)); + CHECK_NEAR(f, 1.05, 1e-6); + CHECK(calc::calibrationFactor(4.00f, 4.20f, &f)); + CHECK_NEAR(f, 0.95238, 1e-4); + CHECK(calc::calibrationFactor(3.90f, 3.90f, &f)); + CHECK_NEAR(f, 1.0, 1e-9); + // the limits are the setting's (0.80 to 1.25), inclusive + CHECK(calc::kCalibrationMin == 0.80f && calc::kCalibrationMax == 1.25f); + CHECK(calc::calibrationFactor(3.2f, 4.0f, &f) && f == 0.8f); + f = 7.0f; + CHECK(!calc::calibrationFactor(3.19f, 4.0f, &f)); + CHECK(f == 7.0f); // a refused figure leaves the caller's factor alone + CHECK(calc::calibrationFactor(4.5f, 3.6f, &f) && f == 1.25f); + f = 7.0f; + CHECK(!calc::calibrationFactor(4.5f, 3.59f, &f)); + CHECK(f == 7.0f); + // a reading under 2.5 V is a floating pin, not a cell, even when it would give a factor in range + CHECK(!calc::calibrationFactor(2.6f, 2.4f, &f)); + CHECK(calc::calibrationFactor(2.6f, 2.5f, &f)); // (and 2.5 V itself is a reading) + f = 7.0f; + // a figure that cannot be one LiPo cell, or a pin that reads nothing, is refused and leaves the factor alone + f = 7.0f; + const float bad[] = {0.0f, 2.4f, 4.6f, -4.2f, NAN, INFINITY}; + for (float t : bad) CHECK(!calc::calibrationFactor(t, 4.1f, &f)); + const float badMeasured[] = {0.0f, 2.4f, 6.1f, -4.1f, NAN, INFINITY}; + for (float m : badMeasured) CHECK(!calc::calibrationFactor(4.2f, m, &f)); + CHECK(f == 7.0f); +} + +static void testSensorMaths() { + section("sensor maths"); + CHECK(calc::batteryPercent(4.25f) == 100); + CHECK(calc::batteryPercent(4.20f) == 100); + CHECK(calc::batteryPercent(3.784f) == 50); + CHECK(calc::batteryPercent(3.584f) == 20); + CHECK(calc::batteryPercent(3.27f) == 0); + CHECK(calc::batteryPercent(2.9f) == 0); + int mid = calc::batteryPercent(4.00f); + CHECK(mid >= 77 && mid <= 78); + int prev = -1; + bool monotonic = true; + for (int mv = 3000; mv <= 4300; mv++) { + int p = calc::batteryPercent(mv / 1000.0f); + monotonic &= p >= prev && p >= 0 && p <= 100; + prev = p; + } + CHECK(monotonic); + + const uint8_t beef[2] = {0xBE, 0xEF}; + CHECK(calc::crc8(beef, 2) == 0x92); // Sensirion datasheet check value + + // 25.0 C, 50 %RH + uint8_t raw[6] = {0x66, 0x66, 0, 0x80, 0x00, 0}; + raw[2] = calc::crc8(raw, 2); + raw[5] = calc::crc8(raw + 3, 2); + float t = 0, rh = 0; + CHECK(calc::shtc3Decode(raw, &t, &rh)); + CHECK_NEAR(t, 25.0, 0.01); + CHECK_NEAR(rh, 50.0, 0.01); + raw[2] ^= 0x01; // corrupt the temperature CRC + CHECK(!calc::shtc3Decode(raw, &t, &rh)); + raw[2] ^= 0x01; + raw[5] ^= 0x80; // corrupt the humidity CRC + CHECK(!calc::shtc3Decode(raw, &t, &rh)); + + CHECK_NEAR(calc::humidityAtTemperature(40, 22.0f, 22.0f), 40.0, 1e-4); + CHECK_NEAR(calc::humidityAtTemperature(40, 26.4f, 22.4f), 50.8, 0.6); // cooler air, higher RH + CHECK_NEAR(calc::humidityAtTemperature(90, 30.0f, 20.0f), 100.0, 1e-4); // clamped + CHECK(calc::humidityAtTemperature(0, 25.0f, 20.0f) == 0.0f); +} + +// --------------------------------------------------------------------------- +// Charge detection on simulated voltage traces. A reading every 5 s (+-0.5 s) +// with Gaussian noise (sigma 4 mV), one reading in ten pulled down 20..70 mV as +// if a WiFi transmit burst hit the ADC, and a slow 1.5 mV wander for load changes. +// Every scenario runs over 24 random sequences so one lucky draw cannot pass. +// --------------------------------------------------------------------------- +namespace { +using CS = ChargeDetector; + +struct Sim { + CS d; + uint32_t t = 0; // ms since the trace began + uint64_t rng; + double wander; + unsigned seen = 0; // bit per State seen since clearSeen() + double firstAt[4] = {-1, -1, -1, -1}; // minute each state was first seen since clearSeen() + int changes = 0; // state changes since clearSeen() + CS::State last = CS::UNKNOWN; + void (*tap)(void *ctx, float volts, uint32_t tMs) = nullptr; // is handed every reading after the detector had it + void *tapCtx = nullptr; + + explicit Sim(uint64_t seed) : rng(seed * 0x9E3779B97F4A7C15ull + 12345) { wander = uniform() * 6.2831853; } + double uniform() { + rng = rng * 6364136223846793005ull + 1442695040888963407ull; + return (double)(rng >> 11) / 9007199254740992.0; + } + double gauss() { + const double u1 = uniform() + 1e-12, u2 = uniform(); + return sqrt(-2.0 * log(u1)) * cos(6.283185307179586 * u2); + } + void clearSeen() { + seen = 0; + changes = 0; + last = d.state(); + for (double &f : firstAt) f = -1; + } + double now() const { return t / 60000.0; } + template + void run(double minutes, F truth) { + const uint32_t end = t + (uint32_t)(minutes * 60000.0 + 0.5); + while (t < end) { + const double m = t / 60000.0; + double v = truth(m) + 0.004 * gauss() + 0.0015 * sin(m * 1.26 + wander); + if (uniform() < 0.10) v -= 0.020 + 0.050 * uniform(); + d.addSample((float)v, t); + if (tap) tap(tapCtx, (float)v, t); + const CS::State s = d.state(); + seen |= 1u << s; + if (firstAt[s] < 0) firstAt[s] = m; + if (s != last) { + changes++; + last = s; + } + t += 4500 + (uint32_t)(uniform() * 1000.0); + } + } +}; + +struct Verdict { + bool ok = true; + const char *why = ""; + void need(bool cond, const char *what) { + if (ok && !cond) { + ok = false; + why = what; + } + } +}; + +// Runs a scenario over 24 random sequences. maxBad is how many may fail: zero for things the +// detector must always get right, a couple for the odd coincidence that no voltage-only method +// can exclude (a level shift that happens to be followed by a drift the same way). +template +void forSeeds(const char *name, F scenario, int maxBad = 0) { + int bad = 0; + uint64_t firstSeed = 0; + const char *firstWhy = ""; + for (uint64_t seed = 1; seed <= 24; seed++) { + Verdict v; + scenario(seed, v); + g_checks++; + if (!v.ok && bad++ == 0) { + firstSeed = seed; + firstWhy = v.why; + } + } + g_checks++; + if (bad > maxBad) { + g_failed++; + printf(" FAIL %s: %d of 24 sequences (at most %d allowed), first seed %llu: %s\n", name, bad, maxBad, + (unsigned long long)firstSeed, firstWhy); + } +} + +constexpr unsigned bit(CS::State s) { return 1u << s; } +const unsigned kClaimsPower = bit(CS::CHARGING) | bit(CS::FULL); // "USB is in" states + +// How a clock comes to FULL: a charge is seen, from 4.10 V at 3 mV a minute up to a plateau of 4.19 V that the +// charger then holds (an hour in all; FULL comes some twelve minutes after the voltage stops). A cell that is +// merely flat near the top when the clock starts is not called full for some three hours: see "started on a +// charger" and "started on its battery" below. +bool chargeToFull(Sim &s) { + const double t0 = s.now(); + s.run(60, [t0](double m) { + const double x = 4.10 + 0.003 * (m - t0); + return x > 4.19 ? 4.19 : x; + }); + return s.d.state() == CS::FULL; +} +} // namespace + +static void testCharge() { + section("charge detection"); + using S = CS; + + forSeeds("steady discharge on battery", [](uint64_t seed, Verdict &v) { + Sim s(seed); + auto f = [](double m) { return 3.95 - 0.0006 * m; }; + s.run(10.5, f); + v.need(s.d.state() == S::UNKNOWN, "claimed something before three minutes of warm-up and eight of data"); + s.run(9.5, f); + v.need(s.d.state() == S::DISCHARGING, "not discharging by minute 20"); + s.clearSeen(); + s.run(120, f); + v.need((s.seen & kClaimsPower) == 0, "called it charging or full"); + v.need(s.changes == 0, "flapped"); + v.need(fabs(s.d.slopeMvPerMin() + 0.6) < 0.6, "slope not near -0.6 mV/min"); + }); + + forSeeds("USB plugged in while on battery (+90 mV step)", [](uint64_t seed, Verdict &v) { + Sim s(seed); + s.run(25, [](double m) { return 3.83 - 0.0006 * m; }); + v.need(s.d.state() == S::DISCHARGING, "not discharging before the plug-in"); + const double t0 = s.now(); + s.clearSeen(); + s.run(45, [t0](double m) { return 3.83 - 0.0006 * t0 + 0.090 + 0.0025 * (m - t0); }); + v.need(s.firstAt[S::CHARGING] >= 0 && s.firstAt[S::CHARGING] - t0 <= 2.0, "charging not shown within 2 minutes"); + v.need(s.changes == 1, "flapped after the plug-in"); + v.need(s.d.state() == S::CHARGING, "not charging at the end"); + }); + + forSeeds("charged from the middle of the curve up to full", [](uint64_t seed, Verdict &v) { + Sim s(seed); + auto f = [](double m) { + const double x = 4.10 + 0.003 * m; // constant current, then the charger holds 4.2 V from minute 33 + return x > 4.2 ? 4.2 : x; + }; + s.run(17, f); + v.need(s.d.state() == S::CHARGING, "not charging by minute 17"); + s.clearSeen(); + s.run(37, f); + v.need(s.d.state() == S::FULL, "not full 20 minutes after the voltage stopped climbing"); + v.need((s.seen & bit(S::DISCHARGING)) == 0, "called it discharging"); + s.run(60, f); + v.need(s.d.state() == S::FULL, "left FULL while on the charger"); + }); + + // Unplugged from a full battery: the charger had stopped so no current flowed, nothing steps, + // and only the slow decline gives it away. + auto unplugFromFull = [](const char *name, double rate, double withinMin) { + forSeeds(name, [rate, withinMin](uint64_t seed, Verdict &v) { + Sim s(seed); + v.need(chargeToFull(s), "not full on the charger"); + const double t0 = s.now(); + s.clearSeen(); + s.run(withinMin + 5, [=](double m) { return 4.19 - rate * (m - t0); }); + v.need(s.firstAt[S::DISCHARGING] >= 0 && s.firstAt[S::DISCHARGING] - t0 <= withinMin, "discharging not shown in time"); + v.need((s.seen & bit(S::CHARGING)) == 0, "called it charging"); + }); + }; + unplugFromFull("unplugged when full, steep fall (1.7 mV/min)", 0.0017, 14); + unplugFromFull("unplugged when full, moderate fall (0.8 mV/min)", 0.0008, 30); + unplugFromFull("unplugged when full, gentle fall (0.5 mV/min)", 0.0005, 45); + + // A light load (the clock with its radio mostly off: 10..45 mA on a 2500 mAh cell) makes the top of the + // curve fall by 0.07..0.3 mV a minute, which twelve minutes cannot tell from flat: it sat on FULL for + // hours. The slow history ends FULL when the level is 12 mV under its plateau, so how long that takes + // depends on the fall; and then it must stay ended however flat the last twelve minutes look. + unplugFromFull("unplugged when full, light load (0.3 mV/min)", 0.0003, 75); + unplugFromFull("unplugged when full, lighter load (0.12 mV/min)", 0.00012, 150); + unplugFromFull("unplugged when full, light load (0.07 mV/min)", 0.00007, 240); + + { // 200 sequences of a 0.3 mV/min fall: the older rules sometimes say discharging, then flat again, before the slow history has spoken + int bad = 0; + for (uint64_t seed = 1; seed <= 200; seed++) { + Sim s(seed + 1100); + chargeToFull(s); + const double t0 = s.now(); + auto f = [=](double m) { return 4.19 - 0.0003 * (m - t0); }; + s.run(75, f); + s.clearSeen(); + s.run(180, f); + bad += s.changes != 0 ? 1 : 0; // 75 minutes after the unplug it must be settled on DISCHARGING for good + } + g_checks++; + if (bad > 0) { + g_failed++; + printf(" FAIL a 0.3 mV/min fall: the state still changed after 75 minutes in %d of 200 sequences\n", bad); + } + } + + for (double rate : {0.0003, 0.00007}) { + char name[96]; + snprintf(name, sizeof name, "after the fall is seen, FULL does not come back (%.2f mV/min)", rate * 1000); + forSeeds(name, [rate](uint64_t seed, Verdict &v) { + Sim s(seed); + v.need(chargeToFull(s) && !s.d.leftFull(), "not full on the charger, or falling already"); + const double t0 = s.now(); + auto f = [=](double m) { return 4.19 - rate * (m - t0); }; + s.run(rate > 0.0001 ? 90 : 240, f); + v.need(s.d.state() == S::DISCHARGING, "not discharging in time"); + v.need(s.d.leftFull(), "no sag found"); + s.clearSeen(); + s.run(360, f); // six more hours of the same slow fall: the twelve minute trend says "flat" the whole time + v.need((s.seen & kClaimsPower) == 0, "called it full or charging again"); + v.need(s.changes == 0, "flapped"); + v.need(s.d.longSlopeMvPerMin() < -rate * 1000 * 0.5 && s.d.longSlopeMvPerMin() > -rate * 1000 * 1.5, + "the slow trend does not show the fall"); + }); + } + + // What the charger itself does must not look like a discharge: its plateau wanders by a few millivolts, + // and the cell relaxes after the charger ends. + { // 300 sequences: a plateau that wanders is the one case where a single lucky draw (the highest level) would show + int bad = 0, notFull = 0; + for (uint64_t seed = 1; seed <= 300; seed++) { + Sim s(seed + 500); + if (!chargeToFull(s)) notFull++; + s.clearSeen(); + double level = 0, next = s.now() + 15; + s.run(480, [&](double m) { + if (m > next) { + level = (s.uniform() < 0.5 ? 1 : -1) * 0.004 * s.uniform(); + next += 20 + 40 * s.uniform(); + } + return 4.19 + level; + }); + bad += (s.changes != 0 || s.d.leftFull()) ? 1 : 0; + } + g_checks += 2; + if (notFull) { + g_failed++; + printf(" FAIL held full with plateau steps: %d of 300 sequences were not full on the charger\n", notFull); + } + if (bad) { + g_failed++; + printf(" FAIL held full with plateau steps of up to 4 mV every 20-60 minutes for 8 hours: left FULL in %d of 300 sequences\n", bad); + } + } + // A dip of a few minutes (a cable knocked loose and pushed back) takes the cell down and it comes back. The + // twelve minute trend or the slow history may call it a discharge for a while, and while the dips keep + // coming it may stay at that: a cell that was not seen charging is full only once the slow history holds no + // fall, and a dip in its newest 40 minutes is one. It must never be called charging, and once the dips are + // over FULL has to be back within the hour. + forSeeds("held full with a four minute dip of 13 mV every hour or so", [](uint64_t seed, Verdict &v) { + Sim s(seed + 550); + chargeToFull(s); + s.run(150, [](double) { return 4.19; }); // (in the first three hours after a start the slow history is too short to bring FULL back) + v.need(s.d.state() == S::FULL, "not full on the charger"); + s.clearSeen(); + double dipAt = s.now() + 20 + 50 * s.uniform(); + auto truth = [&](double m) { + if (m > dipAt + 4) dipAt = m + 40 + 50 * s.uniform(); + return 4.19 - ((m >= dipAt && m < dipAt + 4) ? 0.013 : 0.0); + }; + s.run(480, truth); + v.need((s.seen & bit(S::CHARGING)) == 0, "a dip was called charging"); + if (s.now() < dipAt + 4 && s.now() >= dipAt) s.run(4, truth); // (let a dip that is under way end) + dipAt = 1e9; // no more dips + s.run(60, truth); + v.need((s.seen & bit(S::CHARGING)) == 0, "a dip was called charging"); + v.need(s.d.state() == S::FULL, "FULL was not back an hour after the last dip"); + }); + for (double tau : {15.0, 30.0}) { + char name[96]; + snprintf(name, sizeof name, "held full after the charger ends, 8 mV tail (tau %.0f min)", tau); + forSeeds(name, [tau](uint64_t seed, Verdict &v) { + Sim s(seed + 600); + const double top = (4.198 - 4.10) / 0.003; // the minute the charge reaches its end, and the cell starts to relax + auto f = [=](double m) { return m < top ? 4.10 + 0.003 * m : 4.19 + 0.008 * exp(-(m - top) / tau); }; + s.run(top + 3 * tau + 25, f); // (it is called FULL once the tail has flattened) + v.need(s.d.state() == S::FULL, "not full on the charger"); + v.need((s.seen & bit(S::DISCHARGING)) == 0, "the cell relaxing was called a discharge"); + s.clearSeen(); + s.run(480, f); + v.need(s.changes == 0, "left FULL"); + }); + } + forSeeds("on battery at -0.07 mV/min, light load: never full or charging", [](uint64_t seed, Verdict &v) { + Sim s(seed + 700); + s.run(480, [](double m) { return 3.95 - 0.00007 * m; }); + v.need((s.seen & kClaimsPower) == 0, "called it full or charging"); + }); + + // Once FULL has ended, the level stepping up by 12 mV (a glitch) and staying there is not a plug-in: off a + // plateau the climb has to be clearer to count as charging, and that stays so while FULL is blocked. Run + // over 200 sequences, because a few cases of a glitch that lines up with the wander are unavoidable. + { + int bad = 0, setupFailed = 0; + for (uint64_t seed = 1; seed <= 200; seed++) { + Sim s(seed + 900); + chargeToFull(s); + const double t0 = s.now(); + s.run(150, [=](double m) { return 4.19 - 0.0003 * (m - t0); }); + if (!(s.d.state() == S::DISCHARGING && s.d.leftFull())) { + setupFailed++; + continue; + } + const double low = 4.19 - 0.0003 * (s.now() - t0); + s.clearSeen(); + s.run(60, [=](double) { return low + 0.012; }); + bad += (s.seen & bit(S::CHARGING)) ? 1 : 0; + } + g_checks += 2; + if (setupFailed) { + g_failed++; + printf(" FAIL glitch after FULL ended: %d of 200 sequences were not discharging with FULL blocked\n", setupFailed); + } + if (bad > 4) { + g_failed++; + printf(" FAIL a 12 mV step up after FULL ended was called charging in %d of 200 sequences (at most 4)\n", bad); + } + } + + // Plugged in again after the sag: charging is seen at once (a step) and, once the charger holds the cell, + // it is full again, with nothing of the sag left over. + forSeeds("plugged in again after a sag", [](uint64_t seed, Verdict &v) { + Sim s(seed + 800); + chargeToFull(s); + const double t0 = s.now(); + s.run(150, [=](double m) { return 4.19 - 0.0003 * (m - t0); }); + v.need(s.d.state() == S::DISCHARGING && s.d.leftFull(), "no sag found before the plug-in"); + const double t1 = s.now(); + const double low = 4.19 - 0.0003 * (t1 - t0); + s.clearSeen(); + s.run(60, [=](double m) { + const double x = low + 0.075 + 0.002 * (m - t1); // the charger takes over: a step up, held at 4.2 V + return x > 4.2 ? 4.2 : x; + }); + v.need(s.firstAt[S::CHARGING] >= 0 && s.firstAt[S::CHARGING] - t1 <= 3.0, "charging not shown within 3 minutes"); + v.need(s.d.state() == S::FULL, "not full again an hour after the plug-in"); + v.need(!s.d.leftFull(), "the sag was not forgotten"); + }); + + forSeeds("booted while charging (+2 mV/min)", [](uint64_t seed, Verdict &v) { + Sim s(seed); + s.run(17, [](double m) { return 3.90 + 0.002 * m; }); + v.need(s.d.state() == S::CHARGING, "not charging by minute 17"); + s.clearSeen(); + s.run(40, [](double m) { return 3.90 + 0.002 * (m + 17); }); + v.need(s.changes == 0, "flapped"); + }); + + forSeeds("booted while charging gently (+1 mV/min)", [](uint64_t seed, Verdict &v) { + Sim s(seed); + s.run(60, [](double m) { return 3.80 + 0.001 * m; }); + v.need(s.d.state() != S::DISCHARGING, "a rising voltage was called discharging"); + }); + + forSeeds("booted on battery (-1 mV/min)", [](uint64_t seed, Verdict &v) { + Sim s(seed); + s.run(18, [](double m) { return 3.85 - 0.001 * m; }); + v.need(s.d.state() == S::DISCHARGING, "not discharging by minute 18"); + s.clearSeen(); + s.run(60, [](double m) { return 3.85 - 0.001 * (m + 18); }); + v.need((s.seen & kClaimsPower) == 0, "called it charging or full"); + }); + + // A clock started on a charger that has finished. Nothing was seen of the charge, and twelve flat minutes at + // the top are just what a nearly full cell under a light load looks like too, so nothing is claimed until + // the slow history has shown some three hours without a fall. Then it is FULL, and stays. + forSeeds("started on a charger with the cell full (4.18 V)", [](uint64_t seed, Verdict &v) { + Sim s(seed); + s.run(120, [](double) { return 4.18; }); + v.need(s.seen == bit(S::UNKNOWN), "claimed something of a cell that is merely flat at the top"); + s.run(105, [](double) { return 4.18; }); + v.need(s.d.state() == S::FULL, "not full after 225 minutes without a fall"); + v.need((s.seen & (bit(S::CHARGING) | bit(S::DISCHARGING))) == 0, "called it charging or discharging"); + s.clearSeen(); + s.run(180, [](double) { return 4.18; }); + v.need(s.seen == bit(S::FULL), "wavered while sitting flat"); + }); + + // Found on the board: the clock was restarted on its battery with the cell at 99 %, the radio mostly off and + // the processor slow. The top of the curve then falls by some 0.05 mV a minute, which twelve minutes cannot + // tell from flat: it was called FULL eleven minutes after the start, and the runtime estimate (which waits + // for a clock that is on its own) was kept back for hours. Sixteen hours each, down to 4.07 .. 4.16 V. + for (double rate : {0.00012, 0.00007, 0.00005, 0.00003}) { + char name[96]; + snprintf(name, sizeof name, "started on its battery with the cell nearly full (-%.2f mV/min)", rate * 1000); + forSeeds(name, [rate](uint64_t seed, Verdict &v) { + Sim s(seed + 1300); + s.run(960, [rate](double m) { return 4.19 - rate * m; }); + v.need((s.seen & bit(S::FULL)) == 0, "a cell that carries the clock was called full"); + v.need((s.seen & bit(S::CHARGING)) == 0, "called it charging"); + }); + } + + // The board draws more while it joins WiFi and fetches over TLS in the first minutes after boot, the + // battery sags and then recovers. The recovery must not pass for a plug-in. + auto startupSag = [](double m, double sagMv) { return m < 1.0 ? sagMv : (m < 2.5 ? sagMv * (2.5 - m) / 1.5 : 0.0); }; + forSeeds("boot on battery with a 100 mV start-up sag", [startupSag](uint64_t seed, Verdict &v) { + Sim s(seed); + s.run(40, [startupSag](double m) { return 3.90 - 0.0006 * m - startupSag(m, 100) / 1000.0; }); + v.need((s.seen & kClaimsPower) == 0, "the start-up sag looked like charging"); + v.need(s.d.state() == S::DISCHARGING, "not discharging after 40 minutes"); + }); + forSeeds("boot on a charger (full) with a 100 mV start-up sag", [startupSag](uint64_t seed, Verdict &v) { + Sim s(seed); + auto f = [startupSag](double m) { return 4.19 - startupSag(m, 100) / 1000.0; }; + s.run(40, f); + v.need(s.seen == bit(S::UNKNOWN), "the start-up sag looked like discharging, or a flat cell was called something"); + s.run(185, f); + v.need(s.d.state() == S::FULL, "not full after 225 minutes"); + v.need((s.seen & (bit(S::CHARGING) | bit(S::DISCHARGING))) == 0, "called it charging or discharging"); + }); + { // the first three minutes are thrown away, and the Info page can say so + Sim s(5); + auto f = [](double m) { return 3.90 - 0.001 * m; }; + s.run(2.9, f); + CHECK(s.d.warmingUp() && s.d.minutesOfData() == 0.0f); + s.run(1.1, f); + CHECK(!s.d.warmingUp() && s.d.minutesOfData() > 0.0f && s.d.minutesOfData() < 1.5f); + } + + // A load burst (a WiFi reconnect, say) every three minutes pulling the battery down 40 mV for 20 s, + // long enough to fill a whole 15 s bin. + auto bursts = [](double m) { return fmod(m, 3.0) < 0.333 ? -0.040 : 0.0; }; + forSeeds("charging with regular WiFi bursts", [bursts](uint64_t seed, Verdict &v) { + Sim s(seed); + s.run(20, [bursts](double m) { return 3.90 + 0.002 * m + bursts(m); }); + v.need(s.d.state() == S::CHARGING, "not charging by minute 20"); + s.clearSeen(); + s.run(80, [bursts](double m) { return 3.90 + 0.002 * (m + 20) + bursts(m); }); + v.need((s.seen & bit(S::DISCHARGING)) == 0, "bursts looked like discharging"); + }); + forSeeds("discharging with regular WiFi bursts", [bursts](uint64_t seed, Verdict &v) { + Sim s(seed); + s.run(22, [bursts](double m) { return 3.95 - 0.0008 * m + bursts(m); }); + v.need(s.d.state() == S::DISCHARGING, "not discharging by minute 22"); + s.clearSeen(); + s.run(80, [bursts](double m) { return 3.95 - 0.0008 * (m + 22) + bursts(m); }); + v.need((s.seen & kClaimsPower) == 0, "bursts looked like charging"); + }); + + forSeeds("the flat middle of the discharge curve (-0.3 mV/min)", [](uint64_t seed, Verdict &v) { + Sim s(seed); + s.run(240, [](double m) { return 3.80 - 0.0003 * m; }); + v.need((s.seen & kClaimsPower) == 0, "called it charging or full"); + v.need(s.changes <= 2, "flapped"); + }); + + forSeeds("pure noise around 3.70 V for four hours", [](uint64_t seed, Verdict &v) { + Sim s(seed); + s.run(240, [](double) { return 3.70; }); + v.need((s.seen & kClaimsPower) == 0, "noise looked like charging or full"); + }); + + forSeeds("unplugged while charging (-80 mV step)", [](uint64_t seed, Verdict &v) { + Sim s(seed); + s.run(30, [](double m) { return 3.90 + 0.0025 * m; }); + v.need(s.d.state() == S::CHARGING, "not charging before the unplug"); + const double t0 = s.now(); + s.clearSeen(); + s.run(8, [t0](double m) { return 3.90 + 0.0025 * t0 - 0.080 - 0.0006 * (m - t0); }); + v.need(s.firstAt[S::DISCHARGING] >= 0 && s.firstAt[S::DISCHARGING] - t0 <= 2.5, "discharging not shown within 2.5 minutes"); + v.need((s.seen & kClaimsPower & ~bit(S::CHARGING)) == 0, "called it full"); + }); + + forSeeds("plugged in when nearly full (+60 mV, capped at 4.2 V)", [](uint64_t seed, Verdict &v) { + Sim s(seed); + s.run(24, [](double m) { return 4.14 - 0.001 * m; }); + v.need(s.d.state() == S::DISCHARGING, "not discharging before the plug-in"); + const double t0 = s.now(); + s.clearSeen(); + s.run(25, [t0](double m) { + const double x = 4.12 + 0.060 + 0.004 * (m - t0); + return x > 4.2 ? 4.2 : x; + }); + v.need(s.firstAt[S::CHARGING] >= 0 && s.firstAt[S::CHARGING] - t0 <= 2.5, "charging not shown within 2.5 minutes"); + v.need(s.d.state() == S::FULL, "not full 25 minutes later"); + v.need(s.changes == 2, "wrong sequence of states"); + }); + + forSeeds("plugged in with only a small step (+25 mV, then +2 mV/min)", [](uint64_t seed, Verdict &v) { + Sim s(seed); + s.run(20, [](double m) { return 3.80 - 0.0006 * m; }); + const double t0 = s.now(); + s.clearSeen(); + s.run(30, [t0](double m) { return 3.80 - 0.0006 * t0 + 0.025 + 0.002 * (m - t0); }); + v.need(s.firstAt[S::CHARGING] >= 0 && s.firstAt[S::CHARGING] - t0 <= 14.0, "charging not shown within 14 minutes"); + }); + + // A change in load moves the level for good; to a straight-line fit that looks like a ramp. + forSeeds("load drops by 15 mV while on battery", [](uint64_t seed, Verdict &v) { + Sim s(seed); + auto f = [](double m) { return 3.90 - 0.0006 * m + (m > 30 ? 0.015 : 0.0); }; + s.run(20, f); + s.clearSeen(); + s.run(60, f); + v.need((s.seen & kClaimsPower) == 0, "a level shift looked like charging"); + }, 2); + forSeeds("load drops by 25 mV in the middle of discharging", [](uint64_t seed, Verdict &v) { + Sim s(seed); + auto f = [](double m) { return 3.90 - 0.0006 * m + (m > 40 ? 0.025 : 0.0); }; + s.run(25, f); + s.clearSeen(); + s.run(60, f); + v.need((s.seen & kClaimsPower) == 0, "a level shift looked like charging"); + }, 2); + forSeeds("load rises by 8 mV while full on the charger", [](uint64_t seed, Verdict &v) { + Sim s(seed); + v.need(chargeToFull(s), "not full before the shift"); + const double t0 = s.now(); + s.clearSeen(); + s.run(60, [t0](double m) { return 4.19 - (m > t0 + 5 ? 0.008 : 0.0); }); + v.need(s.seen == bit(S::FULL), "a small level shift took it out of FULL"); + }); + // A bigger drop at the top of the curve is, to a voltage reading, a battery that has started + // supplying the load. It may say so for a while; it must never say charging, and it must settle once the + // level has sat still. That takes a few hours: once FULL has ended it waits until the last hour has not + // fallen, and a cell that was not seen charging is only called full when the slow history has no fall in it + // at all, which is what keeps a cell that carries the clock from reading as full. + forSeeds("load rises by 15 mV while full on the charger", [](uint64_t seed, Verdict &v) { + Sim s(seed); + v.need(chargeToFull(s), "not full before the shift"); + const double t0 = s.now(); + auto f = [t0](double m) { return 4.19 - (m > t0 + 5 ? 0.015 : 0.0); }; + s.clearSeen(); + s.run(45, f); + v.need((s.seen & bit(S::CHARGING)) == 0, "a drop was called charging"); + s.run(255, f); + v.need((s.seen & bit(S::CHARGING)) == 0, "a drop was called charging"); + v.need(s.d.state() == S::FULL, "did not settle back to FULL five hours on"); + }); + + { // the battery being removed, or readings stopping, starts it afresh + Sim s(7); + s.run(30, [](double m) { return 3.90 + 0.003 * m; }); + CHECK(s.d.state() == S::CHARGING && s.d.ready()); + s.d.reset(); + CHECK(s.d.state() == S::UNKNOWN && !s.d.ready() && s.d.minutesOfData() == 0.0f); + + ChargeDetector g; + uint32_t t = 0; + for (; t < 20 * 60000u; t += 5000) g.addSample(3.95f + 0.0025f * (t / 60000.0f), t); + CHECK(g.state() == S::CHARGING); + g.addSample(3.9f, t + 60000); // 60 s of silence breaks the time axis + CHECK(g.state() == S::UNKNOWN && g.minutesOfData() == 0.0f); + // and the millisecond counter wrapping around is no gap at all + ChargeDetector w; + uint32_t tw = 0xFFFFFFFFu - 5 * 60000u; + for (int i = 0; i < 12 * 60 / 5 + 40; i++, tw += 5000) w.addSample(3.95f - 0.001f * (i * 5 / 60.0f), tw); + CHECK(w.state() == S::DISCHARGING); + } +} + +// --------------------------------------------------------------------------- +// The charge detector, the gate and the runtime estimator wired together as the sketch wires them +// (readSlowSensors()), on traces of a clock that runs on its battery under a light load. +// +// Found on the board: restarted with the cell at 99 %, the radio mostly off and the processor at 20 MHz, the +// clock said "full" and gave no estimate. The voltage then falls too slowly for the detector to call it a +// discharge, the estimator was only fed once it had, and near the top a flat-looking cell was called full. +// --------------------------------------------------------------------------- +static void testEstimateUnderLightLoad() { + section("runtime estimate under a light load"); + struct Rig { + Sim s; + battest::Estimator est; + battest::Gate gate; + double firstReadyMin = -1; // the minute the estimator first had a figure + bool heldBack = false; // a reading was kept from the estimator + explicit Rig(uint64_t seed) : s(seed) { + s.tap = [](void *ctx, float volts, uint32_t tMs) { + Rig *r = (Rig *)ctx; + const CS::State st = r->s.d.state(); + if (!r->gate.feed(r->est, true, st == CS::CHARGING || st == CS::FULL, tMs / 1000, volts)) r->heldBack = true; + if (r->firstReadyMin < 0 && r->est.estimate(3.30f, 0).state == battest::Estimate::READY) r->firstReadyMin = tMs / 60000.0; + }; + s.tapCtx = this; + } + }; + + // Near the top of the curve 5 to 6 mV are one percent, so 0.05 mV a minute is about 0.55 % an hour (a week on a + // charge). The rate the estimator should find is the fall on the curve over the hours it fits (after the first 15 + // minutes, up to the fifth hour). + struct Case { + const char *name; + double startV, mvPerMin; + }; + const Case cases[] = { + {"started at 99 %, falling 0.05 mV/min", 4.195, 0.05}, + {"started at 99 %, falling 0.12 mV/min", 4.195, 0.12}, + {"started at 97 %, falling 0.03 mV/min", 4.184, 0.03}, + // as read on the board a minute after the charger: over the top of the curve, where a level that is cut + // off at 100 % stands still for the first hours + {"started at 4.215 V, over the top of the curve, falling 0.05 mV/min", 4.215, 0.05}, + }; + for (const Case &c : cases) { + forSeeds(c.name, [c](uint64_t seed, Verdict &v) { + const double pctPerHour = (calc::batteryPercentOpen((float)(c.startV - c.mvPerMin * 15 / 1000.0)) - + calc::batteryPercentOpen((float)(c.startV - c.mvPerMin * 300 / 1000.0))) / 4.75; + Rig r(seed + 1400); + r.s.run(300, [c](double m) { return c.startV - c.mvPerMin / 1000.0 * m; }); + v.need(!r.heldBack, "readings were kept from the estimator (something claimed a charger)"); + v.need(r.firstReadyMin > 0 && r.firstReadyMin <= 60, "no figure within the first hour"); + const battest::Estimate e = r.est.estimate(3.30f, 0); + v.need(e.state == battest::Estimate::READY && !e.unbounded, "no figure after five hours"); + v.need(fabs(e.pctPerHour - pctPerHour) <= 0.25 * pctPerHour, "the rate is more than a quarter off after five hours"); + v.need(e.windowMin >= 240, "the figure does not rest on the whole run"); + }); + } + + // Half way down, a fall of 0.01 mV a minute (under 0.1 % an hour): nothing the detector looks at moves; the + // estimator is fed all the same, and says how little it rests on. + forSeeds("started at 50 %, falling 0.01 mV/min", [](uint64_t seed, Verdict &v) { + Rig r(seed + 1500); + r.s.run(300, [](double m) { return 3.784 - 0.00001 * m; }); + v.need(!r.heldBack, "readings were kept from the estimator"); + v.need(r.firstReadyMin > 0 && r.firstReadyMin <= 60, "no figure within the first hour"); + }); + + // A charger shows: the history is dropped, nothing is fed while it is there, and it starts afresh after. + { + battest::Estimator est; + battest::Gate gate; + uint32_t t = 0; + auto feed = [&](int minutes, bool charger, float fromV, float perMin) { + int fed = 0; + for (int i = 0; i < minutes * 12; i++, t += 5) fed += gate.feed(est, true, charger, t, fromV + perMin * (float)(i / 12.0)) ? 1 : 0; + return fed; + }; + CHECK(!gate.onBattery()); + CHECK(feed(90, false, 4.00f, -0.0005f) == 90 * 12 && gate.onBattery()); + CHECK(est.estimate(3.30f, 0).state == battest::Estimate::READY && est.binCount() >= 16); + CHECK(feed(30, true, 4.00f, 0.002f) == 0 && !gate.onBattery()); + CHECK(est.estimate(3.30f, 0).state == battest::Estimate::OFF && est.binCount() == 0); // nothing of the discharge is left + CHECK(feed(20, false, 4.06f, -0.0005f) == 20 * 12 && gate.onBattery()); + CHECK(est.estimate(3.30f, 0).state == battest::Estimate::LEARNING); // ... and the new one starts from nothing + // no battery at all (battery = none, or none fitted): nothing is fed + CHECK(!gate.feed(est, false, false, t, 4.0f) && !gate.onBattery() && est.binCount() == 0); + } +} + +// --------------------------------------------------------------------------- +// Frame scheduling on a simulated UI loop. The clock advances by what each step +// costs (draw about 30 ms, send about 15 ms, naps of 4-5 ms) and by stalls injected +// at random moments (a flash write, the WiFi stack hogging the memory bus); every +// frame's finishing time is compared with the second it is for. The loop the +// firmware had before the planner (draw for the coming second `cost + 8 ms` ahead) +// is simulated alongside for comparison. +// --------------------------------------------------------------------------- +namespace { +struct LoopSim { + int64_t t; // wall clock, microseconds + uint64_t rng; + double stallEverySec = 0; // mean time between stalls; 0 = none + int stallMinMs = 0, stallMaxMs = 0; + double spikeProb = 0, spikeFactor = 4; // some draws take this many times longer + double forceEverySec = 0; // mean time between forced redraws (a key press, a toast); 0 = none + int64_t nextStallAt = INT64_MAX, nextForceAt = INT64_MAX, nextSlowAt = 0; + bool forcePending = false; + + struct Shown { + int64_t sec; + int64_t endUs; + bool scheduled; + }; + std::vector shown; + + explicit LoopSim(uint64_t seed) : t(1000000LL * 1000000LL + 123456), rng(seed * 0x9E3779B97F4A7C15ull + 99) {} + + double uniform() { + rng = rng * 6364136223846793005ull + 1442695040888963407ull; + return (double)(rng >> 11) / 9007199254740992.0; + } + double expo(double mean) { return -mean * log(1.0 - uniform() + 1e-12); } + void arm() { + if (stallEverySec > 0) nextStallAt = t + (int64_t)(expo(stallEverySec) * 1e6); + if (forceEverySec > 0) nextForceAt = t + (int64_t)(expo(forceEverySec) * 1e6); + } + + void advance(int64_t us) { + t += us; + while (t >= nextStallAt) { // the task is frozen for a while, asleep or not + t += (int64_t)((stallMinMs + uniform() * (stallMaxMs - stallMinMs)) * 1000.0); + nextStallAt = t + (int64_t)(expo(stallEverySec) * 1e6); + } + if (t >= nextForceAt) { + forcePending = true; + nextForceAt = t + (int64_t)(expo(forceEverySec) * 1e6); + } + } + int64_t drawCost() { return (int64_t)(30000 * (0.9 + 0.2 * uniform()) * (uniform() < spikeProb ? spikeFactor : 1.0)); } + int64_t sendCost() { return (int64_t)(15000 * (0.9 + 0.2 * uniform())); } + void nap(int ms) { advance(ms * 1000 + (int64_t)(uniform() * 1000)); } // timer tick granularity + void slowWork() { // the sensors, every 5 s when allowed + if (t >= nextSlowAt) { + advance(25000); + nextSlowAt = t + 5000000; + } + } + + // The planner-driven loop + void runNew(int seconds) { + FramePlanner p(20000); + arm(); + const int64_t end = t + (int64_t)seconds * 1000000; + auto draw = [&](time_t sec, bool scheduled) { + const int64_t c = drawCost(); + advance(c); + p.drew(sec, (uint32_t)c, scheduled); + }; + auto send = [&]() { + const int64_t c = sendCost(); + advance(c); + shown.push_back({(int64_t)p.preparedSec(), t, p.preparedScheduled()}); + p.sent((uint32_t)c); + }; + draw((time_t)(t / 1000000), false); // start-up + send(); + while (t < end) { + const bool force = forcePending; + forcePending = false; + const FramePlanner::Step st = p.next((time_t)(t / 1000000), (int32_t)(t % 1000000), force); + switch (st.action) { + case FramePlanner::DRAW_NOW: + draw(st.sec, false); + send(); + break; + case FramePlanner::PREPARE: + draw(st.sec, true); + break; + case FramePlanner::SEND: + advance(st.waitUs); + send(); + break; + case FramePlanner::SLEEP: + nap(st.waitUs > 12000 ? 4 : 1); + break; + default: + if (p.quiet((int32_t)(t % 1000000))) slowWork(); + nap(4); + break; + } + } + } + + // The loop as it was before: one step that draws and sends, started `cost + 8 ms` ahead. + void runOld(int seconds) { + arm(); + const int64_t end = t + (int64_t)seconds * 1000000; + uint32_t frameCost = 45000; + time_t lastDrawn = 0; + while (t < end) { + const int32_t usToNext = 1000000 - (int32_t)(t % 1000000); + bool draw = forcePending; + forcePending = false; + time_t sec = (time_t)(t / 1000000); + if (usToNext <= (int32_t)(frameCost + 8000)) sec += 1; + if (sec != lastDrawn) draw = true; + if (draw) { + const int64_t c = drawCost() + sendCost(); + advance(c); + frameCost = (uint32_t)((frameCost * 3 + c) / 4); + shown.push_back({(int64_t)sec, t, true}); + lastDrawn = sec; + } else { + if (usToNext > 300000) slowWork(); + nap(4); + } + } + } + + // Lateness of each scheduled frame once it is on the glass (sent + 20 ms of panel), in ms + // after its second began. 0 is perfect. + std::vector lateness(size_t skipFrames = 5) const { + std::vector v; + for (size_t i = skipFrames; i < shown.size(); i++) + if (shown[i].scheduled) v.push_back(((shown[i].endUs + 20000) - shown[i].sec * 1000000LL) / 1000.0); + return v; + } + static int countAbove(const std::vector &v, double ms) { + int n = 0; + for (double x : v) n += x > ms; + return n; + } + // Every second between the 5th and the one 5 s before the end appears at least once, in order. + bool everySecondShown(int fromSec = 5, int untilSecFromEnd = 5) const { + if (shown.size() < 10) return false; + const int64_t first = shown.front().sec + fromSec, last = shown.back().sec - untilSecFromEnd; + int64_t want = first; + int64_t prev = -1; + for (const Shown &s : shown) { + if (prev >= 0 && s.sec < prev) return false; // never backwards + prev = s.sec; + if (s.sec == want) want++; + else if (s.sec > want && want <= last) return false; // a second was skipped + } + return want > last; + } +}; +} // namespace + +static void testFramePlan() { + section("frame scheduling"); + using P = FramePlanner; + + { // quiet(): slow work only with plenty of time before the tick and nothing waiting to be sent + P p(20000); + CHECK(p.quiet(100000)); + CHECK(!p.quiet(800000)); // 200 ms to the tick + p.drew(100, 30000, true); + CHECK(!p.quiet(100000)); + p.sent(15000); + CHECK(p.quiet(100000)); + } + { // the decisions at a few moments, with the default costs (send lead 35 ms, draw lead 125 ms) + P p(20000); + p.drew(500, 30000, false); + p.sent(15000); // the frame for second 500 is on the glass + P::Step s = p.next(500, 400000, false); + CHECK(s.action == P::IDLE); + s = p.next(500, 880000, false); // 120 ms to the tick: time to draw the next second + CHECK(s.action == P::PREPARE && s.sec == 501); + p.drew(501, 30000, true); + s = p.next(500, 890000, false); // 110 ms left, due in 75 ms: sleep + CHECK(s.action == P::SLEEP); + s = p.next(500, 960000, false); // 40 ms left, due in 5 ms: send after a short wait + CHECK(s.action == P::SEND && s.waitUs > 0 && s.waitUs <= 5000); + p.sent(15000); + s = p.next(500, 990000, false); + CHECK(s.action == P::IDLE); + s = p.next(501, 100000, false); // the second has begun and it was shown: nothing to do + CHECK(s.action == P::IDLE); + } + { // a forced redraw is immediate, and in the last ~110 ms before a tick it shows the second about to start + P p(20000); + p.drew(500, 30000, false); + p.sent(15000); + P::Step s = p.next(500, 300000, true); + CHECK(s.action == P::DRAW_NOW && s.sec == 500); + s = p.next(500, 850000, true); // 150 ms left: still the current second + CHECK(s.action == P::DRAW_NOW && s.sec == 500); + s = p.next(500, 920000, true); // 80 ms left: not enough to draw this one and still prepare the next + CHECK(s.action == P::DRAW_NOW && s.sec == 501); + s = p.next(500, 980000, true); + CHECK(s.action == P::DRAW_NOW && s.sec == 501); + } + { // far behind (start-up or a long stall): draw the current second at once + P p(20000); + CHECK(p.next(500, 100000, false).action == P::DRAW_NOW); + p.drew(500, 30000, false); + p.sent(15000); + P::Step s = p.next(503, 100000, false); + CHECK(s.action == P::DRAW_NOW && s.sec == 503); + } + { // a prepared frame whose second has begun is sent without waiting; one from the future is dropped + P p(20000); + p.drew(500, 30000, false); + p.sent(15000); + p.drew(501, 30000, true); + P::Step s = p.next(501, 20000, false); + CHECK(s.action == P::SEND && s.waitUs == 0); + P q(20000); + q.drew(500, 30000, false); + q.sent(15000); + q.drew(501, 30000, true); + s = q.next(450, 100000, false); // the clock jumped back by 50 seconds + CHECK(!q.prepared()); + CHECK(s.action == P::DRAW_NOW && s.sec == 450); + } + { // one freak cost does not move the schedule much + P p(20000); + p.drew(500, 30000, false); + p.sent(15000); + p.drew(501, 5000000, true); // a draw that "took" five seconds + CHECK(p.drawCostUs() <= 60000); + } + + // --- simulated loops ------------------------------------------------------------------- + { // no stalls, no spikes: every frame lands within a few ms of plan + LoopSim s(1); + s.runNew(300); + CHECK(s.everySecondShown()); + const std::vector late = s.lateness(); + double lo = 1e9, hi = -1e9; + for (double x : late) lo = fmin(lo, x), hi = fmax(hi, x); + CHECK(late.size() > 280); + CHECK(lo > -12.0 && hi < 12.0); // the nap granularity is the only error + } + { // one draw in ten takes four times as long (120 ms): the old loop shows those frames late, the new one never does + int newLate = 0, oldLate = 0, frames = 0; + for (uint64_t seed = 1; seed <= 8; seed++) { + LoopSim a(seed), b(seed); + a.spikeProb = b.spikeProb = 0.10; + a.runNew(200); + b.runOld(200); + CHECK(a.everySecondShown() && b.everySecondShown()); + newLate += LoopSim::countAbove(a.lateness(), 50.0); + oldLate += LoopSim::countAbove(b.lateness(), 50.0); + frames += (int)a.lateness().size(); + } + printf(" draw spikes: frames more than 50 ms late: new %d, old %d of %d\n", newLate, oldLate, frames); + CHECK(newLate == 0); + CHECK(oldLate > frames / 40); // the comparison would prove nothing if the old loop coped + } + { // stalls of 20-300 ms about every 8 s, plus the draw spikes: fewer late frames than before at every threshold + int newL[2] = {0, 0}, oldL[2] = {0, 0}, frames = 0; + for (uint64_t seed = 1; seed <= 12; seed++) { + LoopSim a(seed), b(seed); + for (LoopSim *s : {&a, &b}) { + s->stallEverySec = 8; + s->stallMinMs = 20; + s->stallMaxMs = 300; + s->spikeProb = 0.05; + } + a.runNew(300); + b.runOld(300); + CHECK(a.everySecondShown() && b.everySecondShown()); + const double thr[2] = {50.0, 150.0}; + for (int k = 0; k < 2; k++) { + newL[k] += LoopSim::countAbove(a.lateness(), thr[k]); + oldL[k] += LoopSim::countAbove(b.lateness(), thr[k]); + } + frames += (int)a.lateness().size(); + } + printf(" stalls: frames more than 50 ms late: new %d, old %d; more than 150 ms: new %d, old %d (of %d)\n", newL[0], + oldL[0], newL[1], oldL[1], frames); + CHECK(newL[0] <= oldL[0]); + CHECK(newL[1] <= oldL[1]); + CHECK(newL[0] < frames / 12); // and they are rare in absolute terms + } + { // forced redraws (key presses, toasts) at random moments: nothing skipped or sent backwards, the schedule recovers + for (uint64_t seed = 1; seed <= 6; seed++) { + LoopSim s(seed); + s.forceEverySec = 3.0; + s.runNew(200); + CHECK(s.everySecondShown()); + const std::vector late = s.lateness(20); + CHECK(late.size() > 120); + CHECK(LoopSim::countAbove(late, 50.0) == 0); + } + } + { // the clock jumps (an NTP correction) forwards and backwards: no second is repeated or lost for long, nothing sticks + LoopSim s(3); + FramePlanner p(20000); + int64_t jumps[][2] = {{40, 420000}, {80, -380000}, {120, 1300000}, {160, -1600000}, {200, 5000000}, {240, -3000000}}; + size_t nextJump = 0; + const int64_t t0 = s.t; + auto draw = [&](time_t sec, bool scheduled) { + const int64_t c = s.drawCost(); + s.advance(c); + p.drew(sec, (uint32_t)c, scheduled); + }; + auto send = [&]() { + const int64_t c = s.sendCost(); + s.advance(c); + s.shown.push_back({(int64_t)p.preparedSec(), s.t, p.preparedScheduled()}); + p.sent((uint32_t)c); + }; + draw((time_t)(s.t / 1000000), false); + send(); + int64_t lastJumpAt = 0; + int wrongAfterRecovery = 0, framesAfterRecovery = 0; + while (s.t < t0 + 300 * 1000000LL) { + if (nextJump < sizeof jumps / sizeof *jumps && s.t - t0 >= jumps[nextJump][0] * 1000000LL) { + s.t += jumps[nextJump][1]; + lastJumpAt = s.t; + nextJump++; + } + const FramePlanner::Step st = p.next((time_t)(s.t / 1000000), (int32_t)(s.t % 1000000), false); + switch (st.action) { + case FramePlanner::DRAW_NOW: draw(st.sec, false); send(); break; + case FramePlanner::PREPARE: draw(st.sec, true); break; + case FramePlanner::SEND: s.advance(st.waitUs); send(); break; + case FramePlanner::SLEEP: s.nap(st.waitUs > 12000 ? 4 : 1); break; + default: s.nap(4); break; + } + // once 3 s past a jump the frames must be back on schedule + if (!s.shown.empty() && s.t - lastJumpAt > 3000000 && s.shown.back().scheduled) { + const double late = ((s.shown.back().endUs + 20000) - s.shown.back().sec * 1000000LL) / 1000.0; + framesAfterRecovery++; + if (late > 15.0 || late < -15.0) wrongAfterRecovery++; + s.shown.back().scheduled = false; // count each frame once + } + } + CHECK(framesAfterRecovery > 100); + CHECK(wrongAfterRecovery == 0); + } +} + +// --------------------------------------------------------------------------- +static void testWeather() { + section("weather"); + std::string body = readFile("fixtures/open-meteo-forecast-perth.json"); + WeatherData w; + ZoneInfo z; + CHECK(parseOpenMeteoForecast(body.c_str(), body.size(), &w, &z)); + CHECK(w.valid); + CHECK_NEAR(w.temp, 20.2, 1e-4); + CHECK_NEAR(w.feels, 19.7, 1e-4); + CHECK(w.humidity == 57); + CHECK(w.code == 2); + CHECK(w.isDay); + CHECK_NEAR(w.windKmh, 12.3, 1e-4); + CHECK(w.day[0].code == 3 && w.day[1].code == 3 && w.day[2].code == 51); + CHECK_NEAR(w.day[0].tmax, 20.3, 1e-4); + CHECK_NEAR(w.day[0].tmin, 12.0, 1e-4); + CHECK_NEAR(w.day[1].tmax, 24.4, 1e-4); + CHECK_NEAR(w.day[1].tmin, 11.6, 1e-4); + CHECK_NEAR(w.day[2].tmax, 19.9, 1e-4); + CHECK_NEAR(w.day[2].tmin, 15.2, 1e-4); + CHECK(w.day[0].rainPct == 2 && w.day[1].rainPct == 2 && w.day[2].rainPct == 45); + CHECK(w.day[0].weekday == 1 && w.day[1].weekday == 2 && w.day[2].weekday == 3); // Mon, Tue, Wed + CHECK_STR(w.sunrise, "05:49"); + CHECK_STR(w.sunset, "18:21"); + CHECK_NEAR(w.uvMax, 7.05, 1e-4); + CHECK_STR(z.iana, "Australia/Perth"); + CHECK(z.utcOffsetSec == 28800); + CHECK_STR(tzPosixForIana(z.iana), "AWST-8"); + + // icon / text mapping for the codes in the fixture + CHECK(wmoIcon(0) == WX_CLEAR && wmoIcon(2) == WX_PARTLY && wmoIcon(3) == WX_CLOUDY && wmoIcon(51) == WX_DRIZZLE); + CHECK(wmoIcon(95) == WX_THUNDER && wmoIcon(73) == WX_SNOW && wmoIcon(65) == WX_RAIN); + CHECK_STR(wmoText(0), "Clear"); + CHECK_STR(wmoText(1234), "Unknown"); + + // malformed input must fail cleanly + WeatherData bad; + CHECK(!parseOpenMeteoForecast("", 0, &bad, &z)); + CHECK(!parseOpenMeteoForecast("{}", 2, &bad, &z)); + CHECK(!parseOpenMeteoForecast("{\"current\":{},\"daily\":{}}", 25, &bad, &z)); + CHECK(!parseOpenMeteoForecast(body.c_str(), body.size() / 2, &bad, &z)); // truncated + std::string two = body; + size_t p = two.find("\"2026-10-07\""); // drop the third day's date + if (p != std::string::npos) two.replace(p, 12, "null"); + CHECK(!parseOpenMeteoForecast(two.c_str(), two.size(), &bad, &z)); + + // Everything shown as a fact is required: with one field missing (null, or not in the reply at all) + // the reply is refused rather than shown with a plausible default (a clear sky, 0 % humidity). + auto replaced = [&](const std::string &from, const std::string &to) { + std::string s = body; + const size_t at = s.find(from); + CHECK(at != std::string::npos); // the text this test edits is in the fixture + if (at != std::string::npos) s.replace(at, from.size(), to); + return s; + }; + auto parses = [&](const std::string &s, WeatherData *out = nullptr, ZoneInfo *zone = nullptr) { + WeatherData tmp; + ZoneInfo tmpZone; + return parseOpenMeteoForecast(s.c_str(), s.size(), out ? out : &tmp, zone ? zone : &tmpZone); + }; + const char *currentFields[][2] = {{"temperature_2m", "20.2"}, {"apparent_temperature", "19.7"}, {"relative_humidity_2m", "57"}, + {"weather_code", "2"}, {"wind_speed_10m", "12.3"}, {"is_day", "1"}}; + for (const auto &f : currentFields) { + const std::string lit = std::string("\"") + f[0] + "\":" + f[1]; + CHECK(parses(body)); + CHECK(!parses(replaced(lit, std::string("\"") + f[0] + "\":null"))); // null + CHECK(!parses(replaced(lit, std::string("\"x_") + f[0] + "\":" + f[1]))); // not there + } + struct Column { + const char *key; + const char *v[3]; + }; + const Column columns[] = {{"weather_code", {"3", "3", "51"}}, + {"temperature_2m_max", {"20.3", "24.4", "19.9"}}, + {"temperature_2m_min", {"12.0", "11.6", "15.2"}}}; + for (const Column &c : columns) { + const std::string lit = std::string("\"") + c.key + "\":[" + c.v[0] + "," + c.v[1] + "," + c.v[2] + "]"; + for (int hole = 0; hole < 3; hole++) { // any one of the three days missing + std::string v[3] = {c.v[0], c.v[1], c.v[2]}; + v[hole] = "null"; + CHECK(!parses(replaced(lit, std::string("\"") + c.key + "\":[" + v[0] + "," + v[1] + "," + v[2] + "]"))); + } + CHECK(!parses(replaced(lit, std::string("\"x_") + c.key + "\":[" + c.v[0] + "," + c.v[1] + "," + c.v[2] + "]"))); // the column missing + CHECK(!parses(replaced(lit, std::string("\"") + c.key + "\":[" + c.v[0] + "," + c.v[1] + "]"))); // two days only + } + { // what the service leaves null where it has no figure is optional: 0 %, "--:--" and 0 + WeatherData o; + CHECK(parses(replaced("\"precipitation_probability_max\":[2,2,45]", "\"precipitation_probability_max\":[null,null,null]"), &o)); + CHECK(o.day[0].rainPct == 0 && o.day[1].rainPct == 0 && o.day[2].rainPct == 0 && o.day[2].code == 51); + CHECK(parses(replaced("\"precipitation_probability_max\":[", "\"x_precipitation_probability_max\":["), &o)); + CHECK(o.day[2].rainPct == 0); + CHECK(parses(replaced("\"sunrise\":[", "\"x_sunrise\":["), &o)); + CHECK_STR(o.sunrise, "--:--"); + CHECK_STR(o.sunset, "18:21"); // the other one is still read + CHECK(parses(replaced("\"sunset\":[", "\"x_sunset\":["), &o)); + CHECK_STR(o.sunset, "--:--"); + CHECK_STR(o.sunrise, "05:49"); + CHECK(parses(replaced("\"uv_index_max\":[", "\"x_uv_index_max\":["), &o)); + CHECK_NEAR(o.uvMax, 0.0, 1e-9); + CHECK_NEAR(o.temp, 20.2, 1e-4); // and the rest of the reply is intact + } + { // a zone name needs an offset to go with it (a name the clock does not know becomes a fixed offset) + WeatherData o; + ZoneInfo zi; + CHECK(parses(body, &o, &zi)); + CHECK_STR(zi.iana, "Australia/Perth"); + ZoneInfo noName; + CHECK(parses(replaced("\"timezone\":\"Australia/Perth\"", "\"x_timezone\":\"Australia/Perth\""), &o, &noName)); + CHECK_STR(noName.iana, ""); + ZoneInfo noOffset; + CHECK(parses(replaced("\"utc_offset_seconds\":28800", "\"x_utc_offset_seconds\":28800"), &o, &noOffset)); + CHECK_STR(noOffset.iana, ""); // not "UTC": the clock leaves its zone alone + CHECK(o.valid); + CHECK(parseOpenMeteoForecast(body.c_str(), body.size(), &o, nullptr)); // no zone wanted + } + + // geocoder + std::string geo = readFile("fixtures/open-meteo-geocode-perth.json"); + GeoResult g; + CHECK(parseOpenMeteoGeocode(geo.c_str(), geo.size(), &g)); + CHECK_NEAR(g.lat, -31.95224, 1e-5); + CHECK_NEAR(g.lon, 115.8614, 1e-5); + CHECK_STR(g.name, "Perth"); + CHECK_STR(g.admin1, "Western Australia"); + CHECK_STR(g.country, "AU"); + CHECK_STR(g.iana, "Australia/Perth"); + CHECK(!parseOpenMeteoGeocode("{\"generationtime_ms\":0.1}", 24, &g)); // no results key + CHECK(!parseOpenMeteoGeocode("{\"results\":[]}", 14, &g)); +} + +// --------------------------------------------------------------------------- +static void testSpotify() { + section("spotify parsing"); + char devId[48]; + SpotifyInfo s; + s.status = SPOTIFY_IDLE; + copyStr(s.message, sizeof s.message, "keep me"); + + std::string track = readFile("fixtures/spotify-player-track.json"); + CHECK(parsePlayerState(track.c_str(), track.size(), &s, devId, sizeof devId)); + CHECK(s.status == SPOTIFY_PLAYING); + CHECK_STR(s.title, "Blinding Lights"); + CHECK_STR(s.artist, "The Weeknd, Daft Punk"); + CHECK_STR(s.album, "After Hours"); + CHECK_STR(s.device, "Kitchen speaker"); + CHECK(s.durationMs == 200040 && s.progressMs == 83000); + CHECK(s.volume == 59); + CHECK(s.shuffle); + CHECK_STR(devId, "0d1841b0976bae2a3a310dd74c0f3df354899bc8"); + CHECK_STR(s.message, "keep me"); // caller-owned fields are preserved + + std::string ep = readFile("fixtures/spotify-player-episode.json"); + CHECK(parsePlayerState(ep.c_str(), ep.size(), &s, devId, sizeof devId)); + CHECK(s.status == SPOTIFY_PAUSED); + CHECK_STR(s.title, "#412 - A very long episode title about many things"); + CHECK_STR(s.artist, "The Example Podcast"); + CHECK_STR(s.album, ""); + CHECK(s.volume == -1); // null volume + CHECK(s.progressMs == 1234567 && s.durationMs == 5400000); + CHECK(!s.shuffle); + + std::string ad = readFile("fixtures/spotify-player-ad.json"); + CHECK(parsePlayerState(ad.c_str(), ad.size(), &s, devId, sizeof devId)); + CHECK(s.status == SPOTIFY_PLAYING); + CHECK_STR(s.title, "Advertisement"); + CHECK_STR(s.artist, ""); + CHECK(s.durationMs == 0); + + // oversized strings are cut, never overflow + std::string longName(400, 'x'); + std::string big = "{\"is_playing\":true,\"progress_ms\":1,\"item\":{\"name\":\"" + longName + + "\",\"duration_ms\":10,\"artists\":["; + for (int i = 0; i < 40; i++) big += std::string(i ? "," : "") + "{\"name\":\"Artist Number " + std::to_string(i) + "\"}"; + big += "],\"album\":{\"name\":\"" + longName + "\"}},\"device\":{\"name\":\"" + longName + "\"}}"; + CHECK(parsePlayerState(big.c_str(), big.size(), &s, devId, sizeof devId)); + CHECK(strlen(s.title) == sizeof(s.title) - 1); + CHECK(strlen(s.artist) < sizeof(s.artist)); + CHECK(strncmp(s.artist, "Artist Number 0, Artist Number 1", 32) == 0); + CHECK(s.artist[strlen(s.artist) - 1] != ' ' && s.artist[strlen(s.artist) - 1] != ','); + CHECK(strlen(s.device) == sizeof(s.device) - 1); + + CHECK(!parsePlayerState("not json", 8, &s, devId, sizeof devId)); + CHECK(!parsePlayerState("", 0, &s, devId, sizeof devId)); + + // token replies + SpotifyTokenReply tr; + const char *tok = + "{\"access_token\":\"BQDabc\",\"token_type\":\"Bearer\",\"expires_in\":3600," + "\"refresh_token\":\"AQBxyz\",\"scope\":\"user-read-playback-state\"}"; + CHECK(parseTokenReply(tok, strlen(tok), &tr)); + CHECK_STR(tr.accessToken, "BQDabc"); + CHECK_STR(tr.refreshToken, "AQBxyz"); + CHECK(tr.expiresInSec == 3600); + const char *tok2 = "{\"access_token\":\"BQDdef\",\"token_type\":\"Bearer\",\"expires_in\":3600}"; + CHECK(parseTokenReply(tok2, strlen(tok2), &tr)); + CHECK_STR(tr.refreshToken, ""); // not rotated + CHECK(!parseTokenReply("{\"error\":\"invalid_grant\"}", 25, &tr)); + CHECK(!parseTokenReply("{}", 2, &tr)); + + // authorize URL: exact expected text, produced independently with Python's urllib.parse.quote + char authUrl[600]; + size_t authLen = buildAuthorizeUrl("abc123", "http://127.0.0.1:8888/callback", + "user-read-playback-state user-modify-playback-state", + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", "Zx9", authUrl, sizeof authUrl); + CHECK(authLen == strlen(authUrl) && authLen > 0); + CHECK_STR(authUrl, + "https://accounts.spotify.com/authorize?client_id=abc123&response_type=code" + "&redirect_uri=http%3A%2F%2F127.0.0.1%3A8888%2Fcallback" + "&scope=user-read-playback-state%20user-modify-playback-state" + "&code_challenge_method=S256&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&state=Zx9"); + char tinyUrl[40]; + CHECK(buildAuthorizeUrl("abc123", "http://127.0.0.1:8888/callback", "x", "y", "z", tinyUrl, sizeof tinyUrl) == 0); + + // error bodies + char reason[40], msg[80]; + const char *e1 = "{\"error\":\"invalid_grant\",\"error_description\":\"Refresh token revoked\"}"; + parseSpotifyError(e1, strlen(e1), reason, sizeof reason, msg, sizeof msg); + CHECK_STR(reason, "invalid_grant"); + CHECK_STR(msg, "Refresh token revoked"); + const char *e2 = + "{\"error\":{\"status\":404,\"message\":\"Player command failed: No active device found\"," + "\"reason\":\"NO_ACTIVE_DEVICE\"}}"; + parseSpotifyError(e2, strlen(e2), reason, sizeof reason, msg, sizeof msg); + CHECK_STR(reason, "NO_ACTIVE_DEVICE"); + CHECK_STR(msg, "Player command failed: No active device found"); + parseSpotifyError("garbage", 7, reason, sizeof reason, msg, sizeof msg); + CHECK_STR(reason, ""); + parseSpotifyError(nullptr, 0, reason, sizeof reason, msg, sizeof msg); + CHECK_STR(reason, ""); +} + +// --------------------------------------------------------------------------- +static void testLinkPage() { + section("setup page"); + LinkPageInfo info; + info.appName = "RLCD Clock"; + info.redirectUri = "http://127.0.0.1:8888/callback"; + info.clockAddress = "192.168.1.50"; + info.authorizeUrl = "https://accounts.spotify.com/authorize?client_id=abc&response_type=code&state=Zx9"; + + static char body[6000], doc[8000]; + size_t n = renderLinkPageBody(info, body, sizeof body); + CHECK(n > 500 && n == strlen(body)); + std::string b(body); + CHECK(b.find("not linked") != std::string::npos); + CHECK(b.find("value='http://127.0.0.1:8888/callback'") != std::string::npos); // the URI to register + CHECK(b.find("python spotify_link.py 192.168.1.50") != std::string::npos); // the clock's own address + CHECK(b.find("href='/spotify_link.py'") != std::string::npos); + CHECK(b.find("action=/link") != std::string::npos); + CHECK(b.find("client_id=abc&response_type=code&state=Zx9") != std::string::npos); // & escaped in the href + CHECK(b.find("client_id=abc&response_type") == std::string::npos); + CHECK(b.find("class=err") == std::string::npos && b.find("class=ok") == std::string::npos); // no message yet + CHECK(b.find("expected: nothing there is listening") != std::string::npos); // the dead end is explained + + info.linked = true; + info.daysLeft = 9; + info.message = "Linked! done & dusted"; + info.messageIsError = false; + n = renderLinkPageBody(info, body, sizeof body); + b = body; + CHECK(b.find("linked (Spotify expires the link in 9 days") != std::string::npos); + CHECK(b.find("

Linked! <b>done</b> & dusted

") != std::string::npos); // escaped + info.messageIsError = true; + renderLinkPageBody(info, body, sizeof body); + CHECK(std::string(body).find("

") != std::string::npos); + + char tiny[100]; + CHECK(renderLinkPageBody(info, tiny, sizeof tiny) == 0); // too small is reported, not truncated silently + + info.linked = false; + info.message = ""; + renderLinkPageBody(info, body, sizeof body); + size_t dn = renderPageDocument("RLCD Clock", body, doc, sizeof doc); + CHECK(dn == strlen(doc) && dn > strlen(body)); + CHECK(strncmp(doc, "", 15) == 0); + CHECK(strcmp(doc + dn - 14, "") == 0); + CHECK(strstr(doc, "RLCD Clock - Spotify") != nullptr); + CHECK(renderPageDocument("RLCD Clock", body, tiny, sizeof tiny) == 0); + + FILE *f = fopen("build/link_page.html", "wb"); // for looking at in a browser + if (f) { + fwrite(doc, 1, dn, f); + fclose(f); + } +} + +static void testUtil() { + section("util"); + char buf[128]; + CHECK(urlEncode("a b&c=d/\xC3\xA9", buf, sizeof buf) > 0); + CHECK_STR(buf, "a%20b%26c%3Dd%2F%C3%A9"); + CHECK(urlEncode("http://127.0.0.1:8888/callback", buf, sizeof buf) > 0); + CHECK_STR(buf, "http%3A%2F%2F127.0.0.1%3A8888%2Fcallback"); + CHECK(urlEncode("user-read-playback-state user-modify-playback-state", buf, sizeof buf) > 0); + CHECK_STR(buf, "user-read-playback-state%20user-modify-playback-state"); + char small[8]; + CHECK(urlEncode("this is far too long", small, sizeof small) == 0); + + char out[256]; + const char *url = "http://127.0.0.1:8888/callback?code=AQD%2Bxy%2Fz_1-2&state=abc123#frag"; + CHECK(queryParam(url, "code", out, sizeof out)); + CHECK_STR(out, "AQD+xy/z_1-2"); + CHECK(queryParam(url, "state", out, sizeof out)); + CHECK_STR(out, "abc123"); + CHECK(!queryParam(url, "error", out, sizeof out)); + CHECK(!queryParam(url, "cod", out, sizeof out)); // prefix must not match + CHECK(queryParam("error=access_denied&state=s", "error", out, sizeof out)); + CHECK_STR(out, "access_denied"); + CHECK(!queryParam("", "code", out, sizeof out)); + char tiny[4]; + CHECK(queryParam("?code=abcdefgh", "code", tiny, sizeof tiny)); + CHECK_STR(tiny, "abc"); // truncated, still terminated + + copyStr(tiny, sizeof tiny, "abcdefgh"); + CHECK_STR(tiny, "abc"); + copyStr(tiny, sizeof tiny, nullptr); + CHECK_STR(tiny, ""); + + // PKCE (RFC 7636 appendix B): verifier dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk + const uint8_t digest[32] = {0x13, 0xd3, 0x1e, 0x96, 0x1a, 0x1a, 0xd8, 0xec, 0x2f, 0x16, 0xb1, + 0x0c, 0x4c, 0x98, 0x2e, 0x08, 0x76, 0xa8, 0x78, 0xad, 0x6d, 0xf1, + 0x44, 0x56, 0x6e, 0xe1, 0x89, 0x4a, 0xcb, 0x70, 0xf9, 0xc3}; + CHECK(base64UrlEncode(digest, 32, out, sizeof out) == 43); + CHECK_STR(out, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + const uint8_t a[1] = {0xFB}, b[2] = {0xFB, 0xFF}, c[3] = {0xFB, 0xFF, 0xBF}; + CHECK(base64UrlEncode(a, 1, out, sizeof out) == 2); + CHECK_STR(out, "-w"); + CHECK(base64UrlEncode(b, 2, out, sizeof out) == 3); + CHECK_STR(out, "-_8"); + CHECK(base64UrlEncode(c, 3, out, sizeof out) == 4); + CHECK_STR(out, "-_-_"); + CHECK(base64UrlEncode(digest, 32, small, sizeof small) == 0); +} + +// --------------------------------------------------------------------------- +struct Press { + uint32_t down, up; +}; + +static std::vector> runButton(const std::vector &presses, + uint32_t endMs, + bool bounce = false) { + ClickDetector d(320, 800, 25); + std::vector> events; + for (uint32_t t = 0; t <= endMs; t += 5) { + bool down = false; + for (auto &p : presses) down |= (t >= p.down && t < p.up); + if (bounce) { // contact chatter for 12 ms after each edge + for (auto &p : presses) { + if ((t >= p.down && t < p.down + 12) || (t >= p.up && t < p.up + 12)) down = (t / 5) % 2; + } + } + ClickEvent e = d.update(down, t); + if (e != CLICK_NONE) events.push_back({t, e}); + } + return events; +} + +static void testButtons() { + section("buttons"); + auto one = runButton({{100, 180}}, 2000); + CHECK(one.size() == 1 && one[0].second == CLICK_1); + CHECK(one.size() == 1 && one[0].first >= 180 + 320 && one[0].first <= 180 + 320 + 40); // after the gap + + auto two = runButton({{100, 160}, {260, 320}}, 2000); + CHECK(two.size() == 1 && two[0].second == CLICK_2); + + auto three = runButton({{100, 160}, {260, 320}, {420, 480}}, 2000); + CHECK(three.size() == 1 && three[0].second == CLICK_3); + + auto four = runButton({{100, 160}, {260, 320}, {420, 480}, {580, 640}}, 2000); + CHECK(four.size() == 1 && four[0].second == CLICK_3); // 3+ clicks all map to CLICK_3 + + // clicks slower than the gap are separate gestures + auto slow = runButton({{100, 160}, {900, 960}}, 3000); + CHECK(slow.size() == 2 && slow[0].second == CLICK_1 && slow[1].second == CLICK_1); + + // long press: fires once, while held, with no click afterwards + auto lng = runButton({{100, 1500}}, 3000); + CHECK(lng.size() == 1 && lng[0].second == CLICK_LONG); + CHECK(lng.size() == 1 && lng[0].first >= 100 + 800 && lng[0].first <= 100 + 800 + 40); + + // a click followed by a long press reports only the long press + auto mixed = runButton({{100, 160}, {260, 1400}}, 3000); + CHECK(mixed.size() == 1 && mixed[0].second == CLICK_LONG); + + // contact bounce must not create phantom clicks + auto bouncy = runButton({{100, 200}}, 2000, true); + CHECK(bouncy.size() == 1 && bouncy[0].second == CLICK_1); + + // a glitch shorter than the debounce time is ignored + auto glitch = runButton({{100, 110}}, 2000); + CHECK(glitch.empty()); + + // millis() wrap-around + ClickDetector d(320, 800, 25); + uint32_t base = 0xFFFFFF00u; + ClickEvent e = CLICK_NONE; + for (uint32_t i = 0; i < 700; i += 5) { // base + i wraps past 2^32 around i = 256 + bool down = i >= 20 && i < 100; + ClickEvent r = d.update(down, base + i); + if (r != CLICK_NONE) e = r; + } + CHECK(e == CLICK_1); +} + +// A loop that is busy part of the time, as the UI loop is while it draws a frame: `busyMs` out of every second, +// from `phaseMs` on, it does not look at the pin; otherwise every 5 ms. With `edges` every change of the pin is +// also recorded with its time, as the interrupt of button_edges.cpp does (16 places, the rest is dropped), and +// the loop replays the record before it looks itself. The pin is modelled to the millisecond. +static std::vector> runBusyButton(const std::vector &presses, uint32_t endMs, uint32_t busyMs, + uint32_t phaseMs, bool edges, uint32_t bounceMs = 0, uint32_t startMs = 0) { + ClickDetector d(320, 800, 25); + std::vector> events; + struct Edge { + uint32_t at; + bool down; + }; + std::vector log; + auto level = [&](uint32_t t) { + bool down = false; + for (auto &p : presses) down |= (t >= p.down && t < p.up); + for (auto &p : presses) { // contact chatter after each change: the level flips every millisecond + if ((t >= p.down && t < p.down + bounceMs) || (t >= p.up && t < p.up + bounceMs)) down = (t & 1) != 0; + } + return down; + }; + bool last = false; + for (uint32_t t = 0; t <= endMs; t++) { + const bool now = level(t); + if (edges && now != last && log.size() < 15) log.push_back({startMs + t, now}); // (a ring of 16 holds 15) + last = now; + const uint32_t inSecond = (t + 1000 - phaseMs % 1000) % 1000; + if (t % 5 != 0 || inSecond < busyMs) continue; // the loop is not looking + for (const Edge &ed : log) { + const ClickEvent e = d.edge(ed.down, ed.at); + if (e != CLICK_NONE) events.push_back({t, e}); + } + log.clear(); + // (with the record, the loop's own look is one more change at "now": if the record was full and the end + // of a press is missing from it, the press still gets the time it lasted) + const ClickEvent e = edges ? d.edge(now, startMs + t) : d.update(now, startMs + t); + if (e != CLICK_NONE) events.push_back({t, e}); + } + return events; +} + +static void testButtonEdges() { + section("buttons: a loop that is busy drawing"); + // A 100 ms tap that begins and ends inside a 120 ms frame (20 MHz): a loop that only looks never sees it ... + CHECK(runBusyButton({{1010, 1110}}, 3000, 120, 0, false).empty()); + // ... and with the changes recorded it counts, as one click, reported once the gap after it has passed + { + const auto got = runBusyButton({{1010, 1110}}, 3000, 120, 0, true); + CHECK(got.size() == 1 && got[0].second == CLICK_1); + CHECK(got.size() == 1 && got[0].first >= 1110 + 320 && got[0].first <= 1110 + 320 + 130); + } + // Taps of 40 to 150 ms at every moment of the second, frames of 60, 120 and 240 ms: none is lost with the + // record, and without it some are (which is what the record is for: the check of the check) + for (uint32_t busy : {60u, 120u, 240u}) { + int lostPlain = 0, lostEdges = 0, wrong = 0, taps = 0; + for (uint32_t len : {40u, 60u, 100u, 150u}) { + for (uint32_t start = 1000; start < 2000; start += 7) { + taps++; + const auto plain = runBusyButton({{start, start + len}}, start + 1500, busy, 0, false); + const auto withEdges = runBusyButton({{start, start + len}}, start + 1500, busy, 0, true); + if (plain.empty()) lostPlain++; + if (withEdges.empty()) lostEdges++; + if (withEdges.size() > 1 || (withEdges.size() == 1 && withEdges[0].second != CLICK_1)) wrong++; + if (plain.size() > 1 || (plain.size() == 1 && plain[0].second != CLICK_1)) wrong++; + } + } + printf(" (frames of %u ms: %d of %d taps lost by looking alone, %d with the changes recorded)\n", (unsigned)busy, lostPlain, taps, lostEdges); + CHECK(lostEdges == 0 && wrong == 0); + CHECK(lostPlain > 0); + } + // two and three clicks, with a frame somewhere in them, at every phase + { + int wrong = 0, runs = 0; + for (uint32_t phase = 0; phase < 1000; phase += 13) { + runs += 2; + const auto two = runBusyButton({{1000, 1070}, {1170, 1240}}, 3000, 120, phase, true); + const auto three = runBusyButton({{1000, 1070}, {1170, 1240}, {1340, 1410}}, 3000, 120, phase, true); + if (two.size() != 1 || two[0].second != CLICK_2) wrong++; + if (three.size() != 1 || three[0].second != CLICK_3) wrong++; + } + if (wrong) printf(" %d of %d multi-clicks came out wrong\n", wrong, runs); + CHECK(wrong == 0); + } + // a long press is one long press, whenever the frames fall + { + int wrong = 0; + for (uint32_t phase = 0; phase < 1000; phase += 37) { + const auto lng = runBusyButton({{1000, 2400}}, 4000, 240, phase, true); + if (lng.size() != 1 || lng[0].second != CLICK_LONG) wrong++; + } + CHECK(wrong == 0); + } + // contact chatter: still one click, also when the chatter fills the record (12 ms of it at each end is 24 changes) + { + int wrong = 0; + for (uint32_t phase = 0; phase < 1000; phase += 37) { + const auto bouncy = runBusyButton({{1000, 1100}}, 3000, 120, phase, true, 6); + const auto storm = runBusyButton({{1000, 1100}}, 3000, 240, phase, true, 12); + if (bouncy.size() != 1 || bouncy[0].second != CLICK_1) wrong++; + if (storm.size() != 1 || storm[0].second != CLICK_1) wrong++; // (the record overflows: still the one click, no phantom) + } + if (wrong) printf(" %d runs with contact chatter came out wrong\n", wrong); + CHECK(wrong == 0); + } + // a glitch shorter than the debounce time stays a glitch, recorded or not + CHECK(runBusyButton({{1010, 1020}}, 3000, 120, 0, true).empty()); + CHECK(runBusyButton({{1500, 1512}}, 3000, 120, 0, true).empty()); + // across the wrap of millis() + { + const auto got = runBusyButton({{1010, 1110}}, 3000, 120, 0, true, 0, 0xFFFFFA00u); // the tap straddles 2^32 + CHECK(got.size() == 1 && got[0].second == CLICK_1); + } + // time never runs backwards for the detector: a look stamped earlier than a change already replayed (the + // loop took its time before the interrupt came) is taken as "now", so nothing is accepted undebounced + { + ClickDetector det(320, 800, 25); + CHECK(det.update(false, 1000) == CLICK_NONE); + CHECK(det.edge(true, 1010) == CLICK_NONE); // pressed at 1010 ... + CHECK(det.update(true, 1005) == CLICK_NONE); // ... and a look that says 1005: still 0 ms of "pressed" + CHECK(det.edge(false, 1020) == CLICK_NONE); // released after 10 ms: a glitch + ClickEvent e = CLICK_NONE; + for (uint32_t t = 1025; t < 2000 && e == CLICK_NONE; t += 5) e = det.update(false, t); + CHECK(e == CLICK_NONE); + } +} + +// KEY held for five seconds and then let go restarts the clock (HoldRelease, fed from a ClickDetector). +struct HoldRun { + int armed = 0, fired = 0, longs = 0, clicks = 0; + int64_t armedAt = -1, firedAt = -1; +}; + +// `level(t)` is the pin; the loop looks every 5 ms, starting at `startMs` on the clock's own millis(). +template +static HoldRun runHold(Level level, uint32_t endMs, uint32_t holdMs = 5000, uint32_t startMs = 0) { + ClickDetector det(320, 800, 25); + HoldRelease hold(holdMs); + HoldRun r; + for (uint32_t t = 0; t <= endMs; t += 5) { + const ClickEvent e = det.update(level(t), startMs + t); + if (e == CLICK_LONG) r.longs++; + else if (e != CLICK_NONE) r.clicks++; + switch (hold.update(det.isDown(), det.heldMs())) { + case HoldRelease::ARMED: + r.armed++; + r.armedAt = t; + break; + case HoldRelease::FIRED: + r.fired++; + r.firedAt = t; + break; + default: + break; + } + } + return r; +} + +static void testHoldRelease() { + section("buttons: a long hold that acts when the button is let go"); + { // held from 1.0 s to 7.0 s: armed once at five seconds of holding, fired once when it comes up; the + // ordinary long press (0.8 s) is reported as always, and no click + const HoldRun r = runHold([](uint32_t t) { return t >= 1000 && t < 7000; }, 9000); + CHECK(r.armed == 1 && r.armedAt >= 6000 && r.armedAt <= 6000 + 40); + CHECK(r.fired == 1 && r.firedAt >= 7000 && r.firedAt <= 7000 + 40); + CHECK(r.longs == 1 && r.clicks == 0); + } + { // let go a moment too early (4.9 s): nothing, however long one waits + const HoldRun r = runHold([](uint32_t t) { return t >= 1000 && t < 5900; }, 20000); + CHECK(r.armed == 0 && r.fired == 0 && r.longs == 1); + } + { // clicks and the ordinary hold: nothing + const HoldRun one = runHold([](uint32_t t) { return t >= 1000 && t < 1100; }, 5000); + const HoldRun two = runHold([](uint32_t t) { return (t >= 1000 && t < 1080) || (t >= 1200 && t < 1280); }, 5000); + const HoldRun lng = runHold([](uint32_t t) { return t >= 1000 && t < 2500; }, 9000); + CHECK(one.armed == 0 && one.fired == 0 && one.clicks == 1); + CHECK(two.armed == 0 && two.fired == 0 && two.clicks == 1); + CHECK(lng.armed == 0 && lng.fired == 0 && lng.longs == 1); + } + { // several short holds do not add up + const HoldRun r = runHold([](uint32_t t) { return t >= 1000 && t < 20000 && (t - 1000) % 4000 < 3000; }, 22000); + CHECK(r.armed == 0 && r.fired == 0); + } + { // held for half a minute: armed once, nothing more while it is down, fired once at the end + const HoldRun r = runHold([](uint32_t t) { return t >= 1000 && t < 31000; }, 33000); + CHECK(r.armed == 1 && r.fired == 1 && r.firedAt >= 31000 && r.firedAt <= 31040); + } + { // still down at the end: armed, not fired (the clock does not restart under a finger) + const HoldRun r = runHold([](uint32_t t) { return t >= 1000; }, 20000); + CHECK(r.armed == 1 && r.fired == 0); + } + { // a contact that chatters for 10 ms in the middle of the hold is not "let go" (the debounced state is used) + const HoldRun r = runHold([](uint32_t t) { return t >= 1000 && t < 8000 && !(t >= 6500 && t < 6510); }, 10000); + CHECK(r.armed == 1 && r.fired == 1 && r.firedAt >= 8000); + // ... and one that chatters before the five seconds are up does not start the count again + const HoldRun early = runHold([](uint32_t t) { return t >= 1000 && t < 8000 && !(t >= 3000 && t < 3010); }, 10000); + CHECK(early.armed == 1 && early.armedAt >= 6000 && early.armedAt <= 6040); + } + { // twice in a row works twice + const HoldRun r = runHold([](uint32_t t) { return (t >= 1000 && t < 7000) || (t >= 9000 && t < 15000); }, 17000); + CHECK(r.armed == 2 && r.fired == 2); + } + { // across the wrap of millis() + const HoldRun r = runHold([](uint32_t t) { return t >= 1000 && t < 7000; }, 9000, 5000, 0xFFFFF000u); + CHECK(r.armed == 1 && r.fired == 1 && r.armedAt >= 6000 && r.armedAt <= 6040); + } + { // the detector's own view: up means 0 ms, down counts from the moment the press was accepted + ClickDetector det(320, 800, 25); + CHECK(!det.isDown() && det.heldMs() == 0); + for (uint32_t t = 0; t <= 1000; t += 5) det.update(false, t); + CHECK(!det.isDown() && det.heldMs() == 0); + for (uint32_t t = 1005; t <= 3000; t += 5) det.update(true, t); + CHECK(det.isDown() && det.heldMs() >= 1960 && det.heldMs() <= 1995); // 2 s less the 25 ms of debounce + for (uint32_t t = 3005; t <= 3100; t += 5) det.update(false, t); + CHECK(!det.isDown() && det.heldMs() == 0); + // fed from recorded changes (the idle clock): a press recorded at 5000, first looked at after a slow frame + // (120 ms later, which is when it is accepted and the count starts), then every 5 ms + det.edge(true, 5000); + det.edge(true, 5120); + for (uint32_t t = 5125; t <= 11000; t += 5) det.edge(true, t); + CHECK(det.isDown() && det.heldMs() == 11000 - 5120); + HoldRelease hold(5000); + CHECK(hold.update(det.isDown(), det.heldMs()) == HoldRelease::ARMED && hold.armed()); + CHECK(hold.update(det.isDown(), det.heldMs()) == HoldRelease::NONE); + det.edge(false, 11500); + det.edge(false, 11600); + CHECK(hold.update(det.isDown(), det.heldMs()) == HoldRelease::FIRED && !hold.armed()); + CHECK(hold.update(det.isDown(), det.heldMs()) == HoldRelease::NONE); + } +} + +// --------------------------------------------------------------------------- +// The two buttons held down together for ten seconds, wired as the sketch wires them (pollButtons(), onKey(), +// onBoot(), cubeButton()): two click detectors, the hold that restarts the clock, and the chord. What matters most is what must NOT +// happen while two buttons are held: KEY alone restarts the clock after five seconds, BOOT alone inverts the +// screen after one. +// --------------------------------------------------------------------------- +namespace { +struct Pad { + ClickDetector key{320, 800}, boot{320, 1000}; + HoldRelease restart{5000}; + ChordHold both{10000, 520}; + bool cubeOn = false; + int keyClicks = 0, keyLongs = 0, bootClicks = 0, bootLongs = 0; // what handleKey() / handleBoot() would be given + int armed = 0, restarts = 0, cubes = 0, ends = 0; + int shoved = 0, switched = 0; // what the cube was given while it was up: KEY's clicks, BOOT's clicks + uint32_t cubeAt = 0; + + // A gesture of KEY or BOOT: to the cube while it is up (a long press ends it), else to the button's own handler. + void gesture(bool isKey, ClickEvent e) { + if (!cubeOn) { + if (isKey) { + (e == CLICK_LONG ? keyLongs : keyClicks)++; + } else { + (e == CLICK_LONG ? bootLongs : bootClicks)++; + } + } else if (e == CLICK_LONG) { + cubeOn = false; + ends++; + } else { + (isKey ? shoved : switched)++; + } + } + + void poll(bool k, bool b, uint32_t now) { + const bool muted = both.muted(); + const ClickEvent ek = key.update(k, now), eb = boot.update(b, now); + if (ek != CLICK_NONE && !muted) gesture(true, ek); + if (eb != CLICK_NONE && !muted) gesture(false, eb); + if (both.update(key.isDown(), boot.isDown(), now) == ChordHold::FIRED && !cubeOn) { + cubeOn = true; + cubes++; + cubeAt = now; + } + if (both.muted()) { + restart.cancel(); + return; + } + switch (restart.update(key.isDown(), key.heldMs())) { + case HoldRelease::ARMED: armed++; break; + case HoldRelease::FIRED: restarts++; break; + default: break; + } + } + // Runs `ms` of it, polled every 4 ms, from `*t`; the two functions say whether each button is down at a time + // counted from `t0`. + template + void run(uint32_t *t, uint32_t t0, uint32_t ms, K keyDown, B bootDown) { + for (const uint32_t end = *t + ms; (int32_t)(end - *t) > 0; *t += 4) poll(keyDown(*t - t0), bootDown(*t - t0), *t); + } + int events() const { return keyClicks + keyLongs + bootClicks + bootLongs; } +}; +} // namespace + +static void testChord() { + section("buttons: two held down together"); + auto never = [](uint32_t) { return false; }; + for (uint32_t t0 : {0u, 0xFFFFE000u}) { // (the second: across the wrap of millis()) + Pad p; + uint32_t t = t0; + // both down within 30 ms of each other, for 10.4 s; KEY comes up 80 ms before BOOT + auto key = [](uint32_t s) { return s >= 1000 && s < 11400; }; + auto boot = [](uint32_t s) { return s >= 1030 && s < 11480; }; + p.run(&t, t0, 10900, key, boot); + CHECK(p.cubes == 0); // 9.9 s of the two together + p.run(&t, t0, 400, key, boot); + CHECK(p.cubes == 1 && p.cubeOn && p.cubeAt - t0 >= 11055 && p.cubeAt - t0 <= 11100); // ten seconds after both were down + p.run(&t, t0, 3000, key, boot); // let go, and wait + CHECK(p.cubes == 1 && p.cubeOn && p.ends == 0); + CHECK(p.events() == 0); // no long press of either (0.8 s, 1 s), no click + CHECK(p.armed == 0 && p.restarts == 0); // and KEY's five seconds did not count: the clock does not restart + + // while it is up a tap of BOOT is "the other cube", not "next page", and a tap of KEY a shove, not "play" + const uint32_t t1 = t; + p.run(&t, t1, 2000, never, [](uint32_t s) { return s >= 200 && s < 300; }); + CHECK(p.cubeOn && p.switched == 1 && p.shoved == 0 && p.events() == 0); + const uint32_t t1b = t; + p.run(&t, t1b, 3000, [](uint32_t s) { return (s >= 200 && s < 300) || (s >= 1500 && s < 1600); }, never); + CHECK(p.cubeOn && p.switched == 1 && p.shoved == 2 && p.events() == 0 && p.ends == 0); + // a long press of either button ends it, and is neither "refresh" nor "invert"; held on, it is still nothing + const uint32_t t1c = t; + p.run(&t, t1c, 4000, never, [](uint32_t s) { return s >= 200 && s < 3200; }); + CHECK(!p.cubeOn && p.ends == 1 && p.events() == 0 && p.cubes == 1); + // ... and after that the buttons are themselves again + const uint32_t t2 = t; + p.run(&t, t2, 2000, [](uint32_t s) { return s >= 200 && s < 300; }, [](uint32_t s) { return s >= 900 && s < 1000; }); + CHECK(p.keyClicks == 1 && p.bootClicks == 1 && p.events() == 2); + const uint32_t t3 = t; + p.run(&t, t3, 8000, [](uint32_t s) { return s >= 200 && s < 5700; }, never); + CHECK(p.keyLongs == 1 && p.armed == 1 && p.restarts == 1); // KEY alone for 5.5 s still restarts + } + { // let go a little too early (9.6 s): nothing at all, and nothing afterwards + Pad p; + uint32_t t = 0; + p.run(&t, 0, 14000, [](uint32_t s) { return s >= 1000 && s < 10650; }, [](uint32_t s) { return s >= 1020 && s < 10620; }); + CHECK(p.cubes == 0 && p.events() == 0 && p.armed == 0 && p.restarts == 0); + } + { // KEY alone long enough to restart (the banner is up), then BOOT joins: letting go no longer restarts + Pad p; + uint32_t t = 0; + p.run(&t, 0, 12000, [](uint32_t s) { return s >= 1000 && s < 9000; }, [](uint32_t s) { return s >= 7000 && s < 8000; }); + CHECK(p.armed == 1 && p.restarts == 0 && p.cubes == 0); + CHECK(p.keyLongs == 1 && p.bootLongs == 0 && p.keyClicks == 0 && p.bootClicks == 0); // (KEY's own long press came before BOOT did) + } + { // BOOT lets go for a moment in the middle: the ten seconds start again, and KEY alone meanwhile is nothing + Pad p; + uint32_t t = 0; + auto key = [](uint32_t s) { return s >= 1000 && s < 19000; }; + auto boot = [](uint32_t s) { return (s >= 1000 && s < 7000) || (s >= 7300 && s < 19000); }; + p.run(&t, 0, 16000, key, boot); + CHECK(p.cubes == 0 && p.armed == 0); // 15 s of KEY, but never ten of the two together + p.run(&t, 0, 1400, key, boot); + CHECK(p.cubes == 1 && p.cubeAt >= 17325 && p.cubeAt <= 17370); // ten seconds after BOOT came back + p.run(&t, 0, 4000, key, boot); + CHECK(p.events() == 0 && p.armed == 0 && p.restarts == 0); + } + { // one after the other: KEY's long press is its own (nobody was holding BOOT yet), then the two count from BOOT + Pad p; + uint32_t t = 0; + auto key = [](uint32_t s) { return s >= 1000 && s < 15000; }; + auto boot = [](uint32_t s) { return s >= 4000 && s < 15000; }; + p.run(&t, 0, 13900, key, boot); + CHECK(p.keyLongs == 1 && p.cubes == 0 && p.armed == 0); // (KEY has been down for 12.9 s: no restart banner) + p.run(&t, 0, 3000, key, boot); + CHECK(p.cubes == 1 && p.cubeAt >= 14025 && p.cubeAt <= 14070 && p.restarts == 0 && p.events() == 1); + } + { // KEY held to end the cube, and let go after two seconds: the clock is back, nothing is refreshed, nothing restarts + Pad p; + uint32_t t = 0; + p.run(&t, 0, 13000, [](uint32_t s) { return s >= 1000 && s < 11500; }, [](uint32_t s) { return s >= 1000 && s < 11500; }); + CHECK(p.cubes == 1 && p.cubeOn); + const uint32_t t1 = t; + p.run(&t, t1, 5000, [](uint32_t s) { return s >= 200 && s < 2200; }, [](uint32_t) { return false; }); + CHECK(!p.cubeOn && p.ends == 1 && p.events() == 0 && p.armed == 0 && p.restarts == 0); + } + { // held on and on: it comes up once + Pad p; + uint32_t t = 0; + p.run(&t, 0, 45000, [](uint32_t s) { return s >= 1000; }, [](uint32_t s) { return s >= 1000; }); + CHECK(p.cubes == 1 && p.cubeOn && p.ends == 0 && p.events() == 0); + } + { // the pieces: not muted at rest; muted from the moment both are down until both are up and quiet again + ChordHold c(10000, 520); + CHECK(!c.muted()); + CHECK(c.update(true, false, 100) == ChordHold::NONE && !c.muted()); + CHECK(c.update(true, true, 200) == ChordHold::NONE && c.muted()); + CHECK(c.update(true, false, 300) == ChordHold::NONE && c.muted()); // one is still down + CHECK(c.update(false, false, 400) == ChordHold::NONE && c.muted()); // both up: quiet for 520 ms + CHECK(c.update(false, false, 919) == ChordHold::NONE && c.muted()); + CHECK(c.update(false, false, 920) == ChordHold::NONE && !c.muted()); + ChordHold d(10000, 520); // held for half a minute: fired once, at ten seconds + int fired = 0; + uint32_t firedAt = 0; + for (uint32_t t = 0; t <= 30000; t += 4) { + if (d.update(true, true, t) == ChordHold::FIRED) { + fired++; + firedAt = t; + } + } + CHECK(fired == 1 && firedAt == 10000); + HoldRelease h(5000); + CHECK(h.update(true, 5000) == HoldRelease::ARMED && h.armed()); + h.cancel(); + CHECK(!h.armed() && h.update(false, 0) == HoldRelease::NONE); // let go after a cancel: nothing fires + } +} + +// --------------------------------------------------------------------------- +// The cube those two buttons bring up (infinity_cube.h): the geometry only. That it stays on the screen is +// measured on the rendered frames by the UI preview. +// --------------------------------------------------------------------------- +static void testInfinityCube() { + section("infinity cube"); + using namespace infcube; + struct Count { + int w, h, margin; + int lines = 0, bold = 0, dots = 0, outside = 0, unknown = 0, afterBold = 0, byStyle[kStyles] = {}; + int top = 1 << 20, bottom = -1; + void see(int x, int y) { + if (x < margin || x > w - 1 - margin || y < margin || y > h - 1 - margin) outside++; + if (y < top) top = y; + if (y > bottom) bottom = y; + } + void line(int x0, int y0, int x1, int y1, Style style) { + lines++; + if (style >= kStyles) { + unknown++; + } else { + byStyle[style]++; + } + if (style == BOLD) { + bold++; + } else if (bold > 0) { + afterBold++; // the cube's own edges come last, over the ends of what is behind them + } + see(x0, y0); + see(x1, y1); + } + void dot(int x, int y) { + dots++; + see(x, y); + } + }; + int frames = 0, bad = 0, offScreen = 0, empty = 0, small = 0, faces[4] = {}, least = 1 << 30, most = 0; + for (uint32_t ms = 0; ms < 600000; ms += 173, frames++) { + Count c{400, 300, 8}; + V3 ax[3]; + tumble((float)ms, 5, ax); + draw(ax, ms, 400, 300, c); + const int seen = c.bold / 4; + if (c.bold % 4 != 0 || seen < 1 || seen > 3 || c.dots != c.bold) { + bad++; + } else { + faces[seen]++; + } + if (c.outside || c.unknown || c.afterBold) bad++, offScreen++; + if (c.lines - c.bold < 20 || c.byStyle[SOLID] < 1) bad++, empty++; // behind the faces there is something, some of the cube's far side at least + if (c.bottom - c.top < 150) bad++, small++; // and the cube is of a size to be seen + if (c.lines < least) least = c.lines; + if (c.lines > most) most = c.lines; + } + printf(" (%d frames: one face seen in %d, two in %d, three in %d; %d to %d lines a frame)\n", frames, faces[1], faces[2], faces[3], least, most); + if (bad) printf(" %d frames are wrong: %d off the screen or out of order, %d with nothing behind the faces, %d too small\n", bad, offScreen, empty, small); + CHECK(bad == 0); + CHECK(faces[2] > 0 && faces[3] > 0 && faces[1] + faces[2] + faces[3] == frames); + { // seen square on: one face, its four edges, its four corners + const V3 square[3] = {{1, 0, 0}, {0, 1, 0}, {0, 0, 1}}; + Count c{400, 300, 8}; + draw(square, 0, 400, 300, c); + CHECK(c.bold == 4 && c.dots == 4 && c.outside == 0); + } + { // a small screen gets a small cube, not a cut one + int outside = 0; + for (uint32_t ms = 0; ms < 60000; ms += 211) { + Count c{128, 64, 0}; + V3 ax[3]; + tumble((float)ms, 9, ax); + draw(ax, ms, 128, 64, c); + outside += c.outside; + } + CHECK(outside == 0); + } + { // the tumble: three unit axes at right angles, always; never a jerk; every side of the cube comes to the front; + // another seed, another way; and back along the same way, it is where it was + int skewed = 0, jerks = 0; + float least[3] = {9, 9, 9}, most[3] = {-9, -9, -9}; + V3 was[3]; + tumble(0.0f, 5, was); + for (uint32_t ms = 16; ms < 900000; ms += 16) { + V3 ax[3]; + tumble((float)ms, 5, ax); + for (int i = 0; i < 3; i++) { + if (fabsf(dot(ax[i], ax[i]) - 1.0f) > 1e-4f || fabsf(dot(ax[i], ax[(i + 1) % 3])) > 1e-4f) skewed++; + const V3 moved = sub(ax[i], was[i]); + if (dot(moved, moved) > 0.04f * 0.04f) jerks++; // (a frame of 16 ms: under two and a half degrees) + if (ax[i].z < least[i]) least[i] = ax[i].z; + if (ax[i].z > most[i]) most[i] = ax[i].z; + was[i] = ax[i]; + } + } + CHECK(skewed == 0 && jerks == 0); + for (int i = 0; i < 3; i++) CHECK(least[i] < -0.9f && most[i] > 0.9f); + V3 a[3], b[3], c[3]; + tumble(30000.0f, 5, a); + tumble(30000.0f, 5, b); + tumble(30000.0f, 6, c); + CHECK(a[0].x == b[0].x && a[1].y == b[1].y && a[2].z == b[2].z); + CHECK(fabsf(a[0].x - c[0].x) + fabsf(a[1].y - c[1].y) + fabsf(a[2].z - c[2].z) > 0.05f); + tumble(0.0f, 5, a); + tumble(0.0f, 6, c); + CHECK(fabsf(a[0].x - c[0].x) + fabsf(a[1].y - c[1].y) + fabsf(a[2].z - c[2].z) > 0.05f); // (they do not even start alike) + // it is a slow thing: a quarter of a second moves no axis by more than a quarter of the way to its neighbour + tumble(60000.0f, 5, a); + tumble(60250.0f, 5, b); + for (int i = 0; i < 3; i++) CHECK(dot(a[i], b[i]) > 0.92f); + } + { // a shove (KEY, while a cube is up): off at a set speed in some direction, dying away; the tumbling goes on under it + Shove a; + a.bump(0x12345678u); + CHECK_NEAR(sqrtf(a.rate[0] * a.rate[0] + a.rate[1] * a.rate[1] + a.rate[2] * a.rate[2]), kShoveRate, 1e-6); + CHECK(a.angle[0] == 0.0f && a.angle[1] == 0.0f && a.angle[2] == 0.0f); // (it has not moved yet) + Shove b = a, first = a; + a.advance(3000.0f); + for (int i = 0; i < 300; i++) b.advance(10.0f); // counted frame by frame it comes to the same + for (int i = 0; i < 3; i++) CHECK_NEAR(a.angle[i], b.angle[i], 2e-3); + // by then it has all but stopped, having turned the cube by its speed times the time it takes to fade + CHECK(sqrtf(a.rate[0] * a.rate[0] + a.rate[1] * a.rate[1] + a.rate[2] * a.rate[2]) < 0.01f * kShoveRate); + CHECK_NEAR(sqrtf(a.angle[0] * a.angle[0] + a.angle[1] * a.angle[1] + a.angle[2] * a.angle[2]), kShoveRate * kShoveFadeMs, 0.03); + for (int i = 0; i < 3; i++) CHECK(a.angle[i] * first.rate[i] >= 0.0f); // ... the way it was shoved + first.advance(kShoveFadeMs); // one such stretch on, a third of the speed is left + CHECK_NEAR(sqrtf(first.rate[0] * first.rate[0] + first.rate[1] * first.rate[1] + first.rate[2] * first.rate[2]), kShoveRate * 0.3679, 1e-5); + // it is something to see and no more than that: a good part of a turn, less than a whole one; still going + // after a sixth of a second, over within two + CHECK(kShoveRate * kShoveFadeMs > 1.0f && kShoveRate * kShoveFadeMs < 6.0f); + Shove seen; + seen.bump(0x12345678u); + seen.advance(150.0f); + CHECK(sqrtf(seen.rate[0] * seen.rate[0] + seen.rate[1] * seen.rate[1] + seen.rate[2] * seen.rate[2]) > 0.5f * kShoveRate); + seen.advance(1850.0f); + CHECK(sqrtf(seen.rate[0] * seen.rate[0] + seen.rate[1] * seen.rate[1] + seen.rate[2] * seen.rate[2]) < 0.05f * kShoveRate); + // the direction comes with the bits, and over many shoves it is every direction + float sum[3] = {0, 0, 0}; + int alike = 0; + uint32_t r = 2463534242u; + Shove before; + before.bump(r); + for (int n = 0; n < 4000; n++) { + r ^= r << 13; + r ^= r >> 17; + r ^= r << 5; + Shove s; + s.bump(r); + for (int i = 0; i < 3; i++) sum[i] += s.rate[i] / kShoveRate; + if (s.rate[0] == before.rate[0] && s.rate[1] == before.rate[1]) alike++; + before = s; + } + CHECK(alike == 0 && fabsf(sum[0]) < 200 && fabsf(sum[1]) < 200 && fabsf(sum[2]) < 200); + Shove twice; // two shoves the same way are twice the shove + twice.bump(77); + twice.bump(77); + CHECK_NEAR(sqrtf(twice.rate[0] * twice.rate[0] + twice.rate[1] * twice.rate[1] + twice.rate[2] * twice.rate[2]), 2 * kShoveRate, 1e-6); + Shove odd; // (bits that are all nought are a direction too) + odd.bump(0); + CHECK_NEAR(sqrtf(odd.rate[0] * odd.rate[0] + odd.rate[1] * odd.rate[1] + odd.rate[2] * odd.rate[2]), kShoveRate, 1e-6); + // the cube with a shove in it: still three unit axes at right angles, and somewhere else than without + V3 plain[3], same[3], moved[3]; + const float none[3] = {0, 0, 0}, some[3] = {0.7f, -1.1f, 2.0f}; + tumble(1234.0f, 5, plain); + tumble(1234.0f, 5, none, same); + tumble(1234.0f, 5, some, moved); + CHECK(plain[0].x == same[0].x && plain[1].y == same[1].y && plain[2].z == same[2].z); + CHECK(fabsf(plain[0].x - moved[0].x) + fabsf(plain[1].y - moved[1].y) + fabsf(plain[2].z - moved[2].z) > 0.1f); + for (int i = 0; i < 3; i++) CHECK(fabsf(dot(moved[i], moved[i]) - 1.0f) < 1e-4f && fabsf(dot(moved[i], moved[(i + 1) % 3])) < 1e-4f); + } + // the further a reflection, the fainter, down to nothing + CHECK(fade(1) == SOLID && fade(2) == FADE1 && fade(1000) == NONE); + for (int b = 1; b < 40; b++) CHECK(fade(b + 1) >= fade(b)); + bool everyStep = true; + for (int style = SOLID; style <= FADE4; style++) { + bool found = false; + for (int b = 1; b < 40; b++) found = found || fade(b) == style; + everyStep = everyStep && found; + } + CHECK(everyStep); + // the pulse: from the depth to the front, one cube a step, then a rest + CHECK(pulseAt(0) == kDepth && pulseAt(kPulseStepMs - 1) == kDepth && pulseAt(kPulseStepMs) == kDepth - 1); + CHECK(pulseAt((kDepth - 1) * kPulseStepMs) == 1 && pulseAt(kDepth * kPulseStepMs) == 0); + CHECK(pulseAt((kDepth + kPulseRest) * kPulseStepMs) == kDepth && pulseAt((kDepth + kPulseRest) * kPulseStepMs - 1) == 1 - kPulseRest); + // the window: what is seen from the eye through the square from (-1, -1) to (1, 1), one in front of it + Window win; + win.eye = {0, 0, -1}; + const V3 sq[4] = {{1, 1, 0}, {-1, 1, 0}, {-1, -1, 0}, {1, -1, 0}}; + for (int i = 0; i < 4; i++) { + V3 m = cross(sub(sq[i], win.eye), sub(sq[(i + 1) & 3], win.eye)); + if (dot(m, sub(V3{0, 0, 0}, win.eye)) < 0) m = mul(m, -1.0f); + win.normal[i] = m; + } + float t0 = -1, t1 = -1; + CHECK(win.clip({-0.5f, 0, 0}, {1, 0, 0}, &t0, &t1) && t0 == 0.0f && t1 == 1.0f); // inside from end to end + CHECK(win.clip({-3, 0, 0}, {6, 0, 0}, &t0, &t1)); // across the square: the middle third + CHECK_NEAR(t0, 1.0 / 3.0, 1e-5); + CHECK_NEAR(t1, 2.0 / 3.0, 1e-5); + CHECK(win.clip({-10, 0, 1}, {20, 0, 0}, &t0, &t1)); // one further away, the window is twice as wide: -2 to 2 + CHECK_NEAR(t0, 0.4, 1e-5); + CHECK_NEAR(t1, 0.6, 1e-5); + CHECK(win.clip({0, 0, 0}, {0, 9, 0}, &t0, &t1) && t0 == 0.0f); // from the middle out through the top edge + CHECK_NEAR(t1, 1.0 / 9.0, 1e-5); + CHECK(win.clip({0, 0, 0}, {0, 9, 9}, &t0, &t1) && t0 == 0.0f && t1 == 1.0f); // up and away as steeply as the window widens: never out + CHECK(!win.clip({2, 2, 0}, {5, 0, 0}, &t0, &t1)); // beside it + CHECK(!win.clip({-3, 1.5f, 0}, {6, 0, 0}, &t0, &t1)); // past it, above + CHECK(!win.clip({1.5f, -3, 0}, {0, 6, 0}, &t0, &t1)); // alongside an edge, outside it +} + +// --------------------------------------------------------------------------- +// The other cube those buttons bring up (sand_cube.h): sand on the walls of a cube that tumbles. +// --------------------------------------------------------------------------- +static void testSandCube() { + section("sand cube"); + using namespace sandcube; + const int want = kCells * kCells * kFillPercent / 100; + auto same = [](const Sand &a, const Sand &b) { + for (int f = 0; f < 6; f++) + for (int j = 0; j < kCells; j++) + for (int i = 0; i < kCells; i++) + if (a.has(f, i, j) != b.has(f, i, j)) return false; + return true; + }; + { // the same amount of sand on every face; the same seed gives the same sand, another seed other sand + Sand a, b, c; + a.reset(7); + b.reset(7); + c.reset(8); + for (int f = 0; f < 6; f++) CHECK(a.grains(f) == want && c.grains(f) == want); + CHECK(same(a, b) && !same(a, c)); + V3 ax[3]; + infcube::tumble(1000.0f, 3, ax); + a.step(ax); + b.step(ax); + CHECK(same(a, b)); // ... and runs the same way + } + { // gravity down one side: the sand ends up there, packed, and none of it is lost on the way + Sand s; + s.reset(3); + float a0, b0, a1, b1; + s.centre(2, &a0, &b0); + CHECK(fabsf(a0) < 0.15f && fabsf(b0) < 0.15f); // scattered evenly to begin with + for (int k = 0; k < 600; k++) s.stepFace(2, 0.0f, -1.0f); + s.centre(2, &a1, &b1); + CHECK(s.grains(2) == want); + CHECK(b1 < -0.55f && fabsf(a1) < 0.1f); + int fullRows = 0, strays = 0; + for (int j = 0; j < kCells; j++) { + int n = 0; + for (int i = 0; i < kCells; i++) n += s.has(2, i, j) ? 1 : 0; + if (n == kCells && j == fullRows) fullRows++; + if (j >= want / kCells + 3) strays += n; + } + CHECK(fullRows >= want / kCells - 2 && strays == 0); // a level bed, but for a grain or two on top + // the other faces were not touched + Sand fresh; + fresh.reset(3); + for (int f = 0; f < 6; f++) { + if (f == 2) continue; + float fa, fb, ga, gb; + s.centre(f, &fa, &fb); + fresh.centre(f, &ga, &gb); + CHECK(fa == ga && fb == gb); + } + // then the other way: it pours back, all of it + for (int k = 0; k < 600; k++) s.stepFace(2, 0.0f, 1.0f); + s.centre(2, &a1, &b1); + CHECK(s.grains(2) == want && b1 > 0.55f); + // along the other side, and into a corner + for (int k = 0; k < 600; k++) s.stepFace(2, 1.0f, 0.0f); + s.centre(2, &a1, &b1); + CHECK(s.grains(2) == want && a1 > 0.55f && fabsf(b1) < 0.25f); + for (int k = 0; k < 600; k++) s.stepFace(2, -0.7f, -0.7f); + s.centre(2, &a1, &b1); + CHECK(s.grains(2) == want && a1 < -0.3f && b1 < -0.3f); + } + { // a face that lies flat keeps its sand where it is; on a gentle slope it creeps + Sand s, before; + s.reset(5); + before.reset(5); + for (int k = 0; k < 200; k++) s.stepFace(0, kFlat * 0.6f, -kFlat * 0.6f); + CHECK(same(s, before)); + float a0, b0, a1, b1, a2, b2; + before.centre(0, &a0, &b0); + for (int k = 0; k < 10; k++) s.stepFace(0, 0.0f, -0.2f); + s.centre(0, &a1, &b1); + Sand steep; + steep.reset(5); + for (int k = 0; k < 10; k++) steep.stepFace(0, 0.0f, -1.0f); + steep.centre(0, &a2, &b2); + CHECK(b1 < b0 - 0.01f && b2 < b1 - 0.05f); // ten steps: a little way on the slope, a long way straight down + } + { // the cube tumbles for ten minutes: every face keeps its sand, and every face has had it at more than one side + Sand s; + s.reset(11); + float lowA[6], highA[6], lowB[6], highB[6]; + for (int f = 0; f < 6; f++) lowA[f] = lowB[f] = 9, highA[f] = highB[f] = -9; + for (uint32_t ms = 0; ms < 600000; ms += kStepMs) { + V3 ax[3]; + infcube::tumble((float)ms, 11, ax); + s.advance(ms, ax); + if (ms % (kStepMs * 20) != 0) continue; + for (int f = 0; f < 6; f++) { + float a, b; + s.centre(f, &a, &b); + if (a < lowA[f]) lowA[f] = a; + if (a > highA[f]) highA[f] = a; + if (b < lowB[f]) lowB[f] = b; + if (b > highB[f]) highB[f] = b; + } + } + int kept = 0, sloshed = 0; + for (int f = 0; f < 6; f++) { + kept += s.grains(f) == want ? 1 : 0; + sloshed += (highA[f] - lowA[f] > 0.6f || highB[f] - lowB[f] > 0.6f) ? 1 : 0; + } + CHECK(kept == 6 && sloshed == 6); + } + { // "down" is the bottom of the screen: with the cube held as it starts, the sand of the face in front ends up low on it + struct Mean { + long sumY = 0, n = 0; + void grain(int, int y, int) { + sumY += y; + n++; + } + void line(int, int, int, int, infcube::Style) {} + void dot(int, int) {} + }; + const V3 square[3] = {{1, 0, 0}, {0, 1, 0}, {0, 0, 1}}; + Sand s; + s.reset(6); + Mean before, after; + s.draw(square, 400, 300, before); + for (int k = 0; k < 600; k++) s.step(square); + s.draw(square, 400, 300, after); + CHECK(before.n == want && after.n == want); // one face is seen, square on + CHECK(labs(before.sumY / before.n - 150) < 12 && after.sumY / after.n > 200); + } + { // advance(): a step every kStepMs, a late call is not made up for, time that starts again is taken as it comes + V3 ax[3]; + infcube::tumble(5000.0f, 2, ax); // (tipped, so that the sand has somewhere to run) + Sand a, b, start; + a.reset(9); + b.reset(9); + start.reset(9); + a.advance(0, ax); + CHECK(same(a, b)); // nothing is due at the start + a.advance(kStepMs - 1, ax); + CHECK(same(a, b)); + a.advance(kStepMs, ax); + b.step(ax); + CHECK(same(a, b) && !same(a, start)); + a.advance(kStepMs * 3, ax); // two more + b.step(ax); + b.step(ax); + CHECK(same(a, b)); + a.advance(kStepMs * 1000, ax); // very late: only a few steps are taken, not a thousand + for (int k = 0; k < kMaxStepsAtOnce; k++) b.step(ax); + CHECK(same(a, b)); + a.advance(kStepMs * 2, ax); // the time started again: nothing is taken, and it goes on from there + CHECK(same(a, b)); + a.advance(kStepMs * 3, ax); + b.step(ax); + CHECK(same(a, b)); + } + { // the picture: the sand of the faces that are seen, their edges after it, all of it on the screen + struct Count { + int grains = 0, bold = 0, dots = 0, outside = 0, lateGrains = 0, tiny = 0, huge = 0; + void see(int x, int y, int r) { + if (x - r < 8 || x + r > 391 || y - r < 8 || y + r > 291) outside++; + } + void grain(int x, int y, int size) { + grains++; + if (bold) lateGrains++; + if (size < 1) tiny++; + if (size > 9) huge++; + see(x, y, size / 2); + } + void line(int x0, int y0, int x1, int y1, infcube::Style style) { + if (style == infcube::BOLD) bold++; + see(x0, y0, 1); + see(x1, y1, 1); + } + void dot(int x, int y) { + dots++; + see(x, y, 3); + } + }; + Sand s; + s.reset(4); + int bad = 0, faces[4] = {}; + for (uint32_t ms = 0; ms < 300000; ms += 149) { + V3 ax[3]; + infcube::tumble((float)ms, 4, ax); + s.advance(ms, ax); + Count c; + s.draw(ax, 400, 300, c); + const int seen = c.bold / 4; + if (c.bold % 4 != 0 || seen < 1 || seen > 3 || c.dots != c.bold || c.grains != seen * want) { + bad++; + } else { + faces[seen]++; + } + if (c.outside || c.lateGrains || c.tiny || c.huge) bad++; + } + CHECK(bad == 0 && faces[2] > 0 && faces[3] > 0); + } +} + +int main() { + testCalendar(); + testTimeZones(); + testSensorMaths(); + testBatteryCalibration(); + testCharge(); + testEstimateUnderLightLoad(); + testFramePlan(); + testWeather(); + testSpotify(); + testUtil(); + testLinkPage(); + testButtons(); + testButtonEdges(); + testHoldRelease(); + testChord(); + testInfinityCube(); + testSandCube(); + printf("\n%d checks, %d failed\n", g_checks, g_failed); + return g_failed ? 1 : 0; +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/test_radio.cpp b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/test_radio.cpp new file mode 100644 index 00000000..aa099685 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/test_radio.cpp @@ -0,0 +1,1229 @@ +// Host-side tests of the power logic that is not the settings: when the radio is on in "wifi_mode = sync" +// (radio_plan.h), which CPU clock the clock runs at (clock_policy.h), when the console is shut down +// (console_policy.h), the picker across radio sessions (wifi_pick.h) and the frame planner after a clock change +// (frame_plan.h). Built and run by run_tests.sh. + +#include +#include +#include + +#include "check.h" +#include "clock_policy.h" +#include "console_policy.h" +#include "frame_plan.h" +#include "radio_plan.h" +#include "settings.h" +#include "wifi_pick.h" + +using namespace radioplan; + +namespace { + +// A planner driven by hand: `in` is what the network task would report. +struct Drive { + Planner p; + Inputs in; + uint32_t t = 100000; + explicit Drive(bool sync = true, uint32_t timeout = kConnectTimeoutMs) : p(sync, timeout) {} + bool step(uint32_t ms) { + t += ms; + return p.update(t, in); + } + // steps of `ms` until the radio is on (true) or `limitMs` has passed + bool untilOn(uint32_t limitMs, uint32_t ms = 1000) { + const uint32_t t0 = t; + bool on = false; + while (!on && t - t0 < limitMs) on = step(ms); + return on; + } + bool untilOff(uint32_t limitMs, uint32_t ms = 1000) { + const uint32_t t0 = t; + bool on = true; + while (on && t - t0 < limitMs) on = step(ms); + return !on; + } +}; + +void testSteps() { + section("radio plan: one session at a time"); + { // nothing to do: the radio stays off + Drive d; + for (int i = 0; i < 100; i++) CHECK(!d.step(1000)); + CHECK(d.p.state() == Planner::OFF && d.p.sessions() == 0 && d.p.radioOnMs(d.t) == 0); + CHECK(d.p.lastOutcome() == Planner::NONE); + } + { // always mode: the radio is on whatever it is asked + Drive d(false); + CHECK(d.step(10) && d.p.state() == Planner::CONNECTING); + d.in.online = true; + CHECK(d.step(10) && d.p.state() == Planner::ONLINE); + d.in.online = false; + CHECK(d.step(10) && d.p.state() == Planner::CONNECTING); + d.in.absent = true; // ... and "no such network" does not switch it off: that is sync mode's business + for (int i = 0; i < 100; i++) CHECK(d.step(1000)); + CHECK(!d.p.takeGaveUp() && d.p.lastOutcome() == Planner::NONE); + d.p.lookNow(); + CHECK(d.step(10)); + } + { // a session: starts at once, goes online, ends 2.5 s after the work is done + Drive d; + d.in.workDue = true; + CHECK(d.step(100) && d.p.state() == Planner::CONNECTING); + for (int i = 0; i < 30; i++) CHECK(d.step(100)); // 3 s of association + d.in.online = true; + CHECK(d.step(100) && d.p.state() == Planner::ONLINE); + for (int i = 0; i < 50; i++) CHECK(d.step(100)); // 5 s of work + d.in.workDue = false; + const uint32_t tDone = d.t; + bool on = true; + while (on && d.t - tDone < 10000) on = d.step(100); + CHECK(!on && d.p.state() == Planner::OFF); + CHECK(d.t - tDone >= kSettleMs && d.t - tDone <= kSettleMs + 200); // the settle time, no more, no less + CHECK(d.p.sessions() == 1 && d.p.failures() == 0 && d.p.retryAtMs() == 0); + CHECK(d.p.lastOutcome() == Planner::DONE && !d.p.takeGaveUp()); + CHECK(d.p.radioOnMs(d.t) > 8000 && d.p.radioOnMs(d.t) < 12000); + } + { // a session holds while Spotify holds it, then settles + Drive d; + d.in.holdSpotify = true; + d.step(100); + d.in.online = true; + for (int i = 0; i < 6000; i++) CHECK(d.step(100)); // ten minutes of music + d.in.holdSpotify = false; + const uint32_t t0 = d.t; + bool on = true; + while (on && d.t - t0 < 10000) on = d.step(100); + CHECK(!on && d.t - t0 >= kSettleMs && d.t - t0 <= kSettleMs + 200); + } + { // a dropped connection is joined again, with a fresh allowance of time + Drive d; + d.in.workDue = true; + d.step(100); + d.in.online = true; + d.step(100); + d.in.online = false; + CHECK(d.step(100) && d.p.state() == Planner::CONNECTING); + for (uint32_t waited = 0; waited < kConnectTimeoutMs - 1000; waited += 1000) CHECK(d.step(1000)); + CHECK(d.p.state() == Planner::CONNECTING && !d.p.takeGaveUp()); + bool on = true; + for (int i = 0; i < 3 && on; i++) on = d.step(1000); + CHECK(!on && d.p.takeGaveUp() && d.p.failures() == 1 && d.p.lastOutcome() == Planner::FAILED); + } +} + +void testFailures() { + section("radio plan: a network that cannot be joined"); + Drive d; + d.in.workDue = true; // the weather is due and stays due: nothing ever gets done + const uint32_t expectBackoff[] = {60000, 120000, 300000, 600000, 900000, 900000, 900000}; + for (int round = 0; round < 7; round++) { + bool on = false; + const uint32_t t0 = d.t; + while (!on && d.t - t0 < 2000000) on = d.step(1000); // (the first session starts at once, the others after the back-off) + CHECK(on); + const uint32_t tOn = d.t; + while (on && d.t - tOn < 120000) on = d.step(1000); + CHECK(!on && d.p.takeGaveUp() && d.p.lastOutcome() == Planner::FAILED); + CHECK(d.t - tOn >= kConnectTimeoutMs - 1000 && d.t - tOn <= kConnectTimeoutMs + 1000); + CHECK(d.p.failures() == round + 1 && d.p.awayLooks() == 0); + CHECK(d.p.retryAtMs() - d.t >= expectBackoff[round] - 1000 && d.p.retryAtMs() - d.t <= expectBackoff[round] + 1000); + const uint32_t retryAt = d.p.retryAtMs(); + while ((int32_t)(retryAt - d.t) > 1500) CHECK(!d.step(1000)); // it stays off until then + } + { // a key press starts a session in the middle of the back-off (the person wants an answer), and the count goes on + Drive e; + e.in.workDue = true; + e.step(1000); + bool on = true; + while (on) on = e.step(1000); // the first session failed + CHECK(e.p.failures() == 1); + e.step(5000); + CHECK(!e.step(100)); // 5 s into a minute's back-off + e.in.demand = true; + CHECK(e.step(100) && e.p.state() == Planner::CONNECTING); + e.in.demand = false; + on = true; + while (on) on = e.step(1000); // it fails too: two in a row, so the back-off is two minutes + CHECK(e.p.failures() == 2); + CHECK(e.p.retryAtMs() - e.t >= 119000 && e.p.retryAtMs() - e.t <= 121000); + CHECK(!e.step(30000)); // and it is off again, with the work still due + } + { // one press buys one attempt: when that is given up, the rest of the 45 seconds does not start another + // (a clock with one network gives up after 25 s; the press would otherwise run the radio twice) + Drive e(true, 25000); + e.in.workDue = true; + e.step(1000); + CHECK(e.untilOff(60000) && e.p.failures() == 1); + e.step(5000); + e.in.demand = true; + e.in.demandSeq = 7; + CHECK(e.step(100) && e.p.state() == Planner::CONNECTING); + CHECK(e.untilOff(60000, 100) && e.p.failures() == 2); // given up after 25 s, the press still "active" for 20 more + for (int i = 0; i < 190; i++) CHECK(!e.step(100)); // ... and nothing starts in those + e.in.demandSeq = 8; // a new press does + CHECK(e.step(100) && e.p.state() == Planner::CONNECTING); + } + { // joining resets the count + Drive e; + e.in.workDue = true; + e.step(1000); + bool on = true; + while (on) on = e.step(1000); + CHECK(e.p.failures() == 1); + while (!e.step(1000)) { + } + e.in.online = true; + e.step(100); + CHECK(e.p.failures() == 0); + } + { // work that never gets done, with a joined radio, is a failure after three minutes, not a radio that stays on for ever + Drive e; + e.in.workDue = true; + e.step(100); + e.in.online = true; + bool on = true; + const uint32_t t0 = e.t; + while (on && e.t - t0 < 400000) on = e.step(1000); + CHECK(!on && e.p.takeGaveUp() && e.p.lastOutcome() == Planner::FAILED); + CHECK(e.t - t0 >= kSessionMaxMs - 1000 && e.t - t0 <= kSessionMaxMs + 1000); + } + { // ... but not while music holds it + Drive e; + e.in.workDue = true; + e.in.holdSpotify = true; + e.step(100); + e.in.online = true; + for (uint32_t i = 0; i < 400; i++) CHECK(e.step(1000)); + } +} + +// One session's search for the networks: what "no such network" from the WiFi stack adds up to. +void testSearch() { + section("radio plan: is the network there at all?"); + { // one network, coming from a session that worked: two answers before it is believed + Search s; + s.begin(true, false, kAwayScansFirst); + CHECK(!s.noneInRange()); + CHECK(!s.note(0, 0, 0) && !s.noneInRange()); + CHECK(!s.note(0, 1, 0) && !s.noneInRange()); // one scan can miss a router that is there + CHECK(s.note(0, 2, 0) && s.noneInRange()); + CHECK(!s.note(0, 3, 0) && s.noneInRange()); // said once: the caller moves on once, not every time round + } + { // away already: the first answer will do + Search s; + s.begin(true, false, kAwayScansAgain); + CHECK(s.note(0, 1, 0) && s.noneInRange()); + } + { // two networks: both have to be missing + Search s; + s.begin(true, true, kAwayScansAgain); + CHECK(s.note(0, 1, 0) && !s.noneInRange()); // the main one is not there: on to the backup at once + CHECK(!s.note(1, 0, 0) && !s.noneInRange()); + CHECK(s.note(1, 1, 0) && s.noneInRange()); + } + { // a network that answers anything else (a wrong password) is there: the session is not "away" + Search s; + s.begin(true, true, kAwayScansAgain); + CHECK(!s.note(0, 0, 1) && !s.noneInRange()); + CHECK(s.note(1, 1, 0)); // the backup is missing ... + CHECK(!s.noneInRange()); // ... but the main one refused: that is a failure, with its back-off + CHECK(!s.note(0, 5, 0) || !s.noneInRange()); // and it stays one for this session + CHECK(!s.noneInRange()); + } + { // a mixed bag on one network (not found, then a refusal): not away + Search s; + s.begin(true, false, kAwayScansFirst); + CHECK(!s.note(0, 1, 1) && !s.noneInRange()); + s.note(0, 2, 1); + CHECK(!s.noneInRange()); + } + { // a connection came up or broke: start over + Search s; + s.begin(true, false, kAwayScansAgain); + s.note(0, 0, 3); + s.forget(); + CHECK(s.note(0, 1, 0) && s.noneInRange()); + s.forget(); + CHECK(!s.noneInRange()); + } + { // no network set at all is not "away" (the network task never gets this far), and a bad index is ignored + Search s; + s.begin(false, false, kAwayScansAgain); + CHECK(!s.noneInRange()); + CHECK(!s.note(-1, 9, 0) && !s.note(2, 9, 0) && !s.noneInRange()); + s.begin(false, true, 0); // (a count below one is one) + CHECK(s.note(1, 1, 0) && s.noneInRange()); + } + CHECK(kAwayScansFirst == 2 && kAwayScansAgain == 1 && kMinScanMs >= 500 && kMinScanMs < 1300); // (a scan of 11 channels takes 1.3 s at the least) +} + +void testAway() { + section("radio plan: none of the networks in range"); + { // not a failure: the session ends as soon as the answer is in, and the looks come every 2 minutes for the + // first five, then every 5 minutes, for as long as it takes + Drive d; + d.in.workDue = true; + const uint32_t expectWait[] = {120000, 120000, 120000, 120000, 120000, 300000, 300000, 300000, 300000}; + for (int look = 0; look < 9; look++) { + CHECK(d.untilOn(400000, 100)); + const uint32_t tOn = d.t; + for (int i = 0; i < 24; i++) CHECK(d.step(100)); // 2.4 s: the scan + d.in.absent = true; + CHECK(!d.step(100) && d.p.takeGaveUp() && d.p.lastOutcome() == Planner::AWAY); + d.in.absent = false; + CHECK(d.t - tOn <= 2600); + CHECK(d.p.failures() == 0 && d.p.awayLooks() == look + 1); // the failure count is not touched + const uint32_t wait = d.p.retryAtMs() - d.t; + if (wait != expectWait[look]) printf(" look %d: wait %u s\n", look + 1, (unsigned)(wait / 1000)); + CHECK(wait == expectWait[look]); + const uint32_t retryAt = d.p.retryAtMs(); + while ((int32_t)(retryAt - d.t) > 150) CHECK(!d.step(100)); // off until then + } + // the network is back: joined at the next look, and the counts start over + CHECK(d.untilOn(400000, 100)); + d.in.online = true; + d.step(100); + CHECK(d.p.state() == Planner::ONLINE && d.p.awayLooks() == 0); + d.in.workDue = false; + CHECK(d.untilOff(10000, 100) && d.p.lastOutcome() == Planner::DONE && d.p.retryAtMs() == 0); + } + CHECK(awayAfterMs(1) == kAwayQuickMs && awayAfterMs(kAwayQuickLooks) == kAwayQuickMs && awayAfterMs(kAwayQuickLooks + 1) == kAwaySlowMs && + awayAfterMs(1000) == kAwaySlowMs); + CHECK(kAwaySlowMs < kBackoffMs[kBackoffSteps - 1]); // the point of it: a network that is not there is looked for more often than one that refuses + + // (helper: one look that finds nothing; the radio is on for 2.5 s) + auto lookAndMiss = [](Drive &d) { + for (int i = 0; i < 24; i++) d.step(100); + d.in.absent = true; + const bool off = !d.step(100); + d.in.absent = false; + return off && d.p.takeGaveUp() && d.p.lastOutcome() == Planner::AWAY; + }; + { // a key press while away: one look at once, and only one for that press; the next press looks again + Drive d; + d.in.workDue = true; + CHECK(d.step(100) && lookAndMiss(d)); + CHECK(!d.step(10000)); // ten seconds into the two minutes + d.in.demand = true; + d.in.demandSeq = 2; + CHECK(d.step(100) && d.p.state() == Planner::CONNECTING); + CHECK(lookAndMiss(d)); + for (int i = 0; i < 400; i++) CHECK(!d.step(100)); // the press is still "active" for 40 s: no loop of looks + CHECK(d.p.awayLooks() == 1); // a look someone asked for is extra: the quick ones are not used up + d.in.demandSeq = 3; + CHECK(d.step(100) && lookAndMiss(d)); + CHECK(d.p.awayLooks() == 1); + d.in.demand = false; + // ... and when the hotspot is on at last, the press that finds it holds the radio as a press does + d.in.demand = true; + d.in.demandSeq = 4; + CHECK(d.step(100)); + d.in.online = true; + d.in.workDue = false; + for (int i = 0; i < 300; i++) CHECK(d.step(100)); // 30 s on, held by the press + d.in.demand = false; + CHECK(d.untilOff(5000, 100) && d.p.lastOutcome() == Planner::DONE); + } + { // the first look of an absence counts even when a key press started it (the next looks then take one answer) + Drive d; + d.in.demand = true; + d.in.demandSeq = 2; + CHECK(d.step(100) && lookAndMiss(d) && d.p.awayLooks() == 1); + } + { // any button (BOOT has no business with the network, but it means someone is there): look now + Drive d; + d.in.workDue = true; + CHECK(d.step(100) && lookAndMiss(d)); + CHECK(!d.step(30000)); + d.p.lookNow(); + CHECK(d.step(100) && d.p.state() == Planner::CONNECTING); + d.p.lookNow(); // while a look is under way: nothing + CHECK(lookAndMiss(d) && d.p.awayLooks() == 1); + CHECK(d.p.retryAtMs() - d.t == kAwayQuickMs); // and the looks by the clock go on from there + CHECK(!d.step(60000)); + } + { // ... but only then: it does not cut a back-off short (the network is there and refuses), and it starts + // nothing when all is well + Drive d; + d.in.workDue = true; + d.step(100); + CHECK(d.untilOff(60000) && d.p.lastOutcome() == Planner::FAILED); + const uint32_t retryAt = d.p.retryAtMs(); + d.p.lookNow(); + CHECK(d.p.retryAtMs() == retryAt && !d.step(1000)); + Drive fine; + fine.p.lookNow(); + for (int i = 0; i < 50; i++) CHECK(!fine.step(1000)); + } + { // away and failures keep separate books: a refusal after five looks waits a minute, not a quarter of an hour + Drive d; + d.in.workDue = true; + for (int i = 0; i < 6; i++) { + CHECK(d.untilOn(400000, 100)); + CHECK(lookAndMiss(d)); + } + CHECK(d.p.awayLooks() == 6 && d.p.failures() == 0); + CHECK(d.untilOn(400000, 100)); + CHECK(d.untilOff(60000, 100) && d.p.lastOutcome() == Planner::FAILED && d.p.failures() == 1); + CHECK(d.p.retryAtMs() - d.t == kBackoffMs[0]); + } + { // a connection that breaks and then finds the network gone (the router was switched off): away, in the same session + Drive d; + d.in.workDue = true; + d.step(100); + d.in.online = true; + d.step(100); + d.in.online = false; + CHECK(d.step(100) && d.p.state() == Planner::CONNECTING); + CHECK(lookAndMiss(d) && d.p.failures() == 0); + } +} + +// 32 bits of milliseconds wrap after 49.7 days, and a time more than 24.8 days back looks like one still to come. +void testWrap() { + section("radio plan: after weeks of running"); + CHECK(!pending(1000, 0) && pending(1000, 1001) && !pending(1000, 1000) && !pending(1000, 999)); + CHECK(!pending(0x90000000u, 0) && !pending(0xFFFFFFFFu, 0)); // 0 is "none" at every age, not a time 25 days ahead + CHECK(pending(0xFFFFFFF0u, 0x10u)); // across the wrap: 32 ms to go + CHECK(pending(1000, 1000 + 0x7FFFFFFFu)); // (the far end of what can be told) + CHECK(demandUntil(5) != 0 && demandUntil(0u - kDemandMs) != 0); // never the "none" value + CHECK(demandActive(5, demandUntil(5)) && !demandActive(5 + kDemandMs + 2, demandUntil(5))); + CHECK(due(1000, 1000) && due(1001, 1000) && !due(999, 1000) && due(5, 0xFFFFFFF0u)); + { // keepDue: work that is due stays due, however long ago that was + uint32_t at = 1000; + uint32_t now = 1000; + int flips = 0; + for (uint64_t step = 0; step < 70ULL * 24 * 3600 * 1000; step += 3600000ULL) { // 70 days, looked at every hour + now = (uint32_t)(1000 + step); + keepDue(now, &at); + if (!due(now, at)) flips++; + } + CHECK(flips == 0); + uint32_t bare = 1000; // without it: "not due" for 24.8 of those days + int bareFlips = 0; + for (uint64_t step = 0; step < 70ULL * 24 * 3600 * 1000; step += 3600000ULL) { + if (!due((uint32_t)(1000 + step), bare)) bareFlips++; + } + CHECK(bareFlips > 24 * 20); + uint32_t ahead = now + 60000; // a time still to come is left alone + keepDue(now, &ahead); + CHECK(ahead == now + 60000); + } + // the planner starts sessions at every age: at the start, past 2^31 ms (24.8 days) and across 2^32 (49.7 days) + for (uint32_t start : {100000u, 0x7FFFF000u, 0x80000100u, 0xC0000000u, 0xFFFF0000u}) { + Drive d; + d.t = start; + int sessions = 0; + for (int n = 0; n < 12; n++) { // twelve sessions ten minutes apart: two hours, which crosses the boundary in question + d.in.workDue = true; + const bool on = d.step(100); + if (!on) printf(" no session at t = 0x%08x (start 0x%08x)\n", (unsigned)d.t, (unsigned)start); + CHECK(on); + d.in.online = true; + d.step(3000); + d.in.workDue = false; + d.in.online = true; + if (d.untilOff(10000, 100)) sessions++; + d.in.online = false; + for (int i = 0; i < 600; i++) d.step(1000); + } + CHECK(sessions == 12 && d.p.sessions() == 12); + } + { // a wait that ran out long ago does not come round again: a give-up, then nothing due for 30 days, then work + Drive d; + d.in.workDue = true; + d.step(100); + CHECK(d.untilOff(60000)); + d.in.workDue = false; + for (int day = 0; day < 30; day++) { + for (int i = 0; i < 24; i++) CHECK(!d.step(3600000)); + } + CHECK(d.p.retryAtMs() == 0); + d.in.workDue = true; + CHECK(d.step(100)); + } +} + +void testSpotifyRules() { + section("radio plan: what Spotify needs of the radio"); + SpotifyNeeds n; + CHECK(!spotifyHolds(n)); + n.playing = true; + CHECK(spotifyHolds(n)); // live (the default): music holds the radio + n.live = false; + CHECK(!spotifyHolds(n)); // spotify_live = off: it does not + n.pausedRecently = true; + CHECK(!spotifyHolds(n)); + n.live = true; + n.playing = false; + CHECK(spotifyHolds(n)); // paused a moment ago, live + for (bool live : {false, true}) { // a command that waits and the Now Playing page hold it either way + SpotifyNeeds c; + c.live = live; + c.commandWaiting = true; + CHECK(spotifyHolds(c)); + c.commandWaiting = false; + c.pageOpen = true; + CHECK(spotifyHolds(c)); + } + // the next look: when the track should be over, and a moment more + CHECK(trackLookAfterMs(210000, 0) == 210000 + kTrackEndSlackMs); + CHECK(trackLookAfterMs(210000, 200000) == 10000 + kTrackEndSlackMs); + CHECK(trackLookAfterMs(210000, 210000) == kTrackEndSlackMs); // over already by Spotify's own figures: just the moment + CHECK(trackLookAfterMs(210000, 250000) == kTrackEndSlackMs); + CHECK(kTrackEndSlackMs < kSettleMs); // a look that finds the track on its last second is repeated within the same session +} + +// A model of the network task's rules in sync mode (net_task.cpp): what is due, how long each thing takes, +// what the WiFi stack answers and what a joined radio does about it. One pass every 100 ms. +enum NetState { NET_NOT_SET, NET_UP, NET_ABSENT, NET_REFUSING }; + +struct NetDay { + Planner p; + wifipick::Picker pick; + Search search; + NetState net[2] = {NET_UP, NET_NOT_SET}; // the main network and the backup + uint32_t connectMs = 3000; // a network that is up is joined this long after the attempt on it began + uint32_t scanMs = 2400; // one that is not answers "no such network" (or refuses) this long after, and again every so long + bool internetUp = true; + bool spotify = false; // configured and linked + bool live = true; // spotify_live + bool playing = false; // the phone is playing + uint32_t trackMs = 210000; + uint32_t t = 1000; + // the session + bool radio = false, online = false; + uint32_t sessionStart = 0, lastBegin = 0, answerAt = 0, busyUntil = 0; + int attemptNet = -1, joinedNet = -1; + unsigned notFound = 0, other = 0; + // the network task's timers + bool ntpEver = false; + uint32_t ntpAt = 0, weatherAt = 0, ntpRetryAt = 0; + int weatherFails = 0; + bool peekDone = false; + uint32_t demandUntilMs = 0, demandSeq = 1; + // the player, and what the clock believes of it + uint32_t trackStart = 0, trackNo = 0; + bool seenPlaying = false; + uint32_t seenTrackNo = 0, lookAt = 0, nextPollAt = 0; + // what happened + uint32_t weatherFetches = 0, ntpSyncs = 0, peeks = 0, looks = 0, awayEnds = 0, failedEnds = 0; + uint32_t lastWeather = 0, lastNtp = 0, maxWeatherGap = 0, maxNtpGap = 0; + uint32_t longestSession = 0, staleMs = 0, staleRun = 0, maxStaleRun = 0; + explicit NetDay(uint32_t timeout = 25000) : p(true, timeout) { pick.configure(true, false); } + + void setNets(NetState mainNet, NetState backup) { + net[0] = mainNet; + net[1] = backup; + pick.configure(mainNet != NET_NOT_SET, backup != NET_NOT_SET); + } + void press() { + demandSeq++; + demandUntilMs = demandUntil(t); + } + void play(bool on) { + playing = on; + trackStart = t; + trackNo++; + } + void skip() { + trackStart = t; + trackNo++; + } + // wakes the radio on its own only when it is overdue by the grace time + bool ntpDue() const { return (!ntpEver || due(t, ntpAt + kNtpGraceMs)) && due(t, ntpRetryAt); } + // asked in a session that is going on anyway: due within ten minutes counts + bool ntpNear() const { return (!ntpEver || due(t, ntpAt - kNtpEarlyMs)) && due(t, ntpRetryAt); } + bool weatherDue() const { return due(t, weatherAt); } + bool peekDue() const { return spotify && !peekDone; } + bool lookDue() const { return spotify && !live && seenPlaying && lookAt != 0 && due(t, lookAt); } + + void poll() { // a look at the player + seenPlaying = playing; + seenTrackNo = trackNo; + lookAt = playing ? ((t + trackLookAfterMs(trackMs, t - trackStart)) | 1u) : 0; + nextPollAt = t + 10000; + } + + void tick() { + t += 100; + if (playing && (uint32_t)(t - trackStart) >= trackMs) { // the next track begins + trackStart += trackMs; + trackNo++; + } + if (seenPlaying && (seenTrackNo != trackNo || !playing)) { // the screen shows something that is no longer so + staleMs += 100; + staleRun += 100; + if (staleRun > maxStaleRun) maxStaleRun = staleRun; + } else { + staleRun = 0; + } + + // what the WiFi stack does with the attempt under way + if (radio && !online && attemptNet >= 0) { + const NetState s = net[attemptNet]; + if (s == NET_UP && (uint32_t)(t - lastBegin) >= connectMs) { + online = true; + joinedNet = attemptNet; + pick.joined(attemptNet ? wifipick::NET_BACKUP : wifipick::NET_MAIN, t); + search.forget(); + } else if (s != NET_UP && due(t, answerAt)) { + if (s == NET_ABSENT) notFound++; + else other++; + answerAt = t + scanMs; + } + } + if (radio && online && net[joinedNet] != NET_UP) { // the connection broke: the stack tries the same network again + online = false; + pick.lost(); + search.forget(); + lastBegin = t; + notFound = other = 0; + answerAt = t + scanMs; + } + + Inputs in; + in.online = radio && online; + SpotifyNeeds needs; + needs.playing = seenPlaying; + needs.live = live; + in.workDue = ntpDue() || weatherDue() || peekDue() || lookDue(); + in.holdSpotify = spotify && spotifyHolds(needs); + in.demand = demandActive(t, demandUntilMs); + in.demandSeq = demandSeq; + in.absent = radio && search.noneInRange(); + const bool was = radio; + radio = p.update(t, in); + if (radio && !was) { + sessionStart = t; + peekDone = false; + nextPollAt = t; + search.begin(net[0] != NET_NOT_SET, net[1] != NET_NOT_SET, p.awayLooks() == 0 ? kAwayScansFirst : kAwayScansAgain); + attemptNet = -1; + lastBegin = 0; + online = false; + } + if (!radio && was) { + if ((uint32_t)(t - sessionStart) > longestSession) longestSession = t - sessionStart; + pick.radioOff(); + online = false; + if (p.takeGaveUp()) { + if (p.lastOutcome() == Planner::AWAY) awayEnds++; + else failedEnds++; + seenPlaying = false; // the network is gone: a track nobody can follow is taken off the screen + } + } + if (!radio) return; + + if (!online) { // the attempts: one network at a time, the other one at once when this one is not there + const bool moveOn = attemptNet >= 0 && search.note(attemptNet, notFound, other) && !search.noneInRange(); + if (lastBegin == 0 || (uint32_t)(t - lastBegin) > 20000 || moveOn) { + attemptNet = pick.nextAttempt() == wifipick::NET_BACKUP ? 1 : 0; + lastBegin = t ? t : 1; + notFound = other = 0; + answerAt = t + scanMs; + } + return; + } + // what the task does once joined: the work of one loop, in the order of net_task.cpp + if (t >= busyUntil) { + if (ntpNear()) { + busyUntil = t + 400; + if (internetUp) { + ntpEver = true; + ntpAt = t + 3600000UL; + if (lastNtp && t - lastNtp > maxNtpGap) maxNtpGap = t - lastNtp; + lastNtp = t; + ntpSyncs++; + } else { // no reply: wait twenty seconds, try again in five minutes + busyUntil = t + 20000; + ntpRetryAt = t + 20000 + 300000; + } + } else if (weatherDue()) { + busyUntil = t + 2500; + if (internetUp) { + weatherAt = t + 15 * 60000UL; + weatherFails = 0; + if (lastWeather && t - lastWeather > maxWeatherGap) maxWeatherGap = t - lastWeather; + lastWeather = t; + weatherFetches++; + } else { + weatherAt = t + retryAfterMs(++weatherFails); // gently: the radio is dear + } + } else if (peekDue()) { // the session's first look at the player (TLS and all: three seconds) + busyUntil = t + 3000; + peekDone = true; + peeks++; + poll(); + } else if (spotify && due(t, nextPollAt) && (seenPlaying || lookDue())) { // the ten second poll while it plays and the radio is on + busyUntil = t + 500; + looks++; + poll(); + } + } + } + void run(uint32_t ms) { + const uint32_t end = t + ms; + while ((int32_t)(end - t) > 0) tick(); + } +}; + +void testDay() { + section("radio plan: a simulated day"); + { // a quiet day with a good network: a session every quarter of an hour, the radio on for a sliver of it + NetDay d(kConnectTimeoutMs); + d.run(24 * 3600000UL); + const double duty = d.p.radioOnMs(d.t) / (24.0 * 3600000.0); + if (duty > 0.015) printf(" radio on %.2f %% of the day\n", duty * 100); + printf(" (a quiet day: radio on %.2f %%, %u sessions, longest %.1f s)\n", duty * 100, (unsigned)d.p.sessions(), d.longestSession / 1000.0); + CHECK(duty < 0.015); + CHECK(d.weatherFetches >= 94 && d.weatherFetches <= 98); + CHECK(d.ntpSyncs >= 23 && d.ntpSyncs <= 26); // about every hour, in a session that is going on anyway + CHECK(d.p.sessions() >= 94 && d.p.sessions() <= 104); // the time sync does not need sessions of its own + CHECK(d.maxWeatherGap <= 15 * 60000UL + 15000); // never later than a quarter of an hour and a few seconds + CHECK(d.maxNtpGap <= 3600000UL + kNtpGraceMs + 30000); // an hour, and a few minutes when no session comes by + CHECK(d.longestSession <= 20000); // a session is seconds, not minutes + CHECK(d.p.failures() == 0 && d.awayEnds == 0 && d.failedEnds == 0); + } + { // with Spotify linked, each session also looks at it once: a few more seconds, nothing more + NetDay d(kConnectTimeoutMs); + d.spotify = true; + d.run(24 * 3600000UL); + const double duty = d.p.radioOnMs(d.t) / (24.0 * 3600000.0); + if (duty >= 0.025 || d.peeks < 94 || d.peeks > 125) printf(" radio on %.2f %%, %u peeks, %u sessions\n", duty * 100, (unsigned)d.peeks, (unsigned)d.p.sessions()); + printf(" (with Spotify linked: radio on %.2f %%, %u sessions)\n", duty * 100, (unsigned)d.p.sessions()); + CHECK(duty < 0.025 && d.peeks >= 94 && d.peeks <= 125); // (a few more sessions than weather fetches: the hourly time sync on its own) + CHECK(d.longestSession <= 25000); + } + { // three hours with a router that is there and refuses the clock (a changed password): a try after 1, 2, 5, + // 10 minutes and then every quarter of an hour, the radio on for the time it takes to give up each time; + // and the first good session within a quarter of an hour of the password being put right + NetDay d; // (a clock with one network: 25 s is long enough to give up) + d.run(2 * 3600000UL); + d.net[0] = NET_REFUSING; + const uint32_t onBefore = d.p.radioOnMs(d.t); + d.run(3 * 3600000UL); + const uint32_t onOutage = d.p.radioOnMs(d.t) - onBefore; + const uint32_t tries = (uint32_t)d.p.failures(); + printf(" (three hours with a router that refuses: %u failed sessions, radio on %.1f %% of the time)\n", (unsigned)tries, onOutage / (3 * 36000.0)); + if (tries < 14 || tries > 18) printf(" %u failed sessions in three hours\n", (unsigned)tries); + CHECK(tries >= 14 && tries <= 18); + CHECK(d.awayEnds == 0 && d.failedEnds == tries); + CHECK(onOutage <= (tries + 1) * 26000UL); + CHECK(onOutage < 0.04 * 3 * 3600000.0); + const uint32_t weatherBefore = d.weatherFetches; + d.net[0] = NET_UP; + const uint32_t back = d.t; + while (d.weatherFetches == weatherBefore && d.t - back < 20 * 60000UL) d.tick(); + CHECK(d.weatherFetches > weatherBefore && d.t - back <= 15 * 60000UL + 30000); + CHECK(d.p.failures() == 0); + } + { // the router is there, the internet is not: the weather and the time are retried gently, nothing stays on + NetDay d(kConnectTimeoutMs); + d.run(3600000UL); + d.internetUp = false; + const uint32_t onBefore = d.p.radioOnMs(d.t); + d.run(2 * 3600000UL); + const double on = (double)(d.p.radioOnMs(d.t) - onBefore) / (2 * 3600000.0); + if (on > 0.15) printf(" radio on %.1f %% with no internet\n", on * 100); + printf(" (router up, no internet: radio on %.1f %%)\n", on * 100); + CHECK(on < 0.05); // retried after 1, 2, 5, 10 minutes and then every 15 (and the time every 5) + CHECK(d.longestSession <= 60000); + } + { // key presses: each gets a joined radio in seconds, and the radio goes off again some 45 s after the last + NetDay d(kConnectTimeoutMs); + d.run(3600000UL); + for (int press = 0; press < 12; press++) { + d.run(7 * 60000UL + press * 13000UL); // (some of them close to the quarter of an hour sessions) + const uint32_t t0 = d.t; + d.press(); + bool joined = false; + while (!joined && d.t - t0 < 20000) { + d.tick(); + joined = d.online; + } + CHECK(joined && d.t - t0 <= d.connectMs + 800); + d.run(kDemandMs - 3000 - (d.t - t0)); + CHECK(d.online); // still held + d.run(3000 + kSettleMs + 2000); + CHECK(!d.online || d.t - t0 < kDemandMs + 6000); + } + } + { // music: the radio stays on for as long as it plays and goes off a few seconds after + NetDay d(kConnectTimeoutMs); + d.spotify = true; + d.run(3600000UL); + d.play(true); + d.run(16 * 60000UL); // (the next session finds it playing) + d.run(2 * 3600000UL); + CHECK(d.radio && d.online); + CHECK(d.maxStaleRun <= 12000); // the strip is never more than a poll behind + d.play(false); + const uint32_t t0 = d.t; + while (d.radio && d.t - t0 < 30000) d.tick(); + CHECK(!d.radio && d.t - t0 <= 10000 + kSettleMs + 3500); // (the next ten second poll sees that it stopped) + const uint32_t onAfterMusic = d.p.radioOnMs(d.t); + d.run(4 * 3600000UL); + CHECK(d.p.radioOnMs(d.t) - onAfterMusic < 0.03 * 4 * 3600000.0); + } +} + +// What the new rule is for: a clock that is somewhere its network is not. +void testAwayDays() { + section("radio plan: a clock away from its network"); + { // taken to work for eight hours, one network: a look every 2 minutes at first, then every 5, each a scan long; + // the radio is on for a fraction of what the old "45 seconds, then back off" cost, and home is found within + // 5 minutes of coming back + NetDay d; + d.run(2 * 3600000UL); + d.net[0] = NET_ABSENT; + const uint32_t onBefore = d.p.radioOnMs(d.t); + d.run(8 * 3600000UL); + const uint32_t onAway = d.p.radioOnMs(d.t) - onBefore; + printf(" (eight hours away, one network: %u looks, radio on %.2f %% of the time, longest session %.1f s)\n", (unsigned)d.awayEnds, + onAway / (8 * 36000.0), d.longestSession / 1000.0); + CHECK(d.failedEnds == 0 && d.p.failures() == 0); + CHECK(d.awayEnds >= 95 && d.awayEnds <= 104); // 5 quick ones, then one every 5 minutes (and the 2.5 s each takes) + CHECK(onAway < 0.012 * 8 * 3600000.0); // about one percent + CHECK(d.longestSession <= 20000); // (the longest was a good session before it left) + const uint32_t weatherBefore = d.weatherFetches; + d.net[0] = NET_UP; + const uint32_t back = d.t; + while (d.weatherFetches == weatherBefore && d.t - back < 20 * 60000UL) d.tick(); + CHECK(d.weatherFetches > weatherBefore && d.t - back <= kAwaySlowMs + 10000); + CHECK(d.p.awayLooks() == 0); + } + { // the first look of an absence asks twice (4.9 s), the later ones once (2.5 s) + NetDay d; + d.run(3600000UL); + d.net[0] = NET_ABSENT; + uint32_t lengths[3] = {0, 0, 0}; + for (int i = 0; i < 3; i++) { + while (!d.radio) d.tick(); + const uint32_t t0 = d.t; + while (d.radio) d.tick(); + lengths[i] = d.t - t0; + } + CHECK(lengths[0] >= 4700 && lengths[0] <= 5200); + CHECK(lengths[1] >= 2300 && lengths[1] <= 2800 && lengths[2] >= 2300 && lengths[2] <= 2800); + } + { // with a backup network set, both are asked for, one after the other: twice the scan, still seconds + NetDay d(kConnectTimeoutMs); + d.setNets(NET_UP, NET_ABSENT); + d.run(3600000UL); + d.net[0] = NET_ABSENT; + const uint32_t onBefore = d.p.radioOnMs(d.t); + d.run(8 * 3600000UL); + const uint32_t onAway = d.p.radioOnMs(d.t) - onBefore; + printf(" (eight hours away, two networks: %u looks, radio on %.2f %% of the time)\n", (unsigned)d.awayEnds, onAway / (8 * 36000.0)); + CHECK(d.failedEnds == 0 && d.awayEnds >= 93 && d.awayEnds <= 104); + CHECK(onAway < 0.022 * 8 * 3600000.0); + // at work the phone's hotspot (the backup) is switched on: found at the next look, no button needed + d.net[1] = NET_UP; + const uint32_t on = d.t; + while (!d.online && d.t - on < 20 * 60000UL) d.tick(); + CHECK(d.online && d.joinedNet == 1 && d.t - on <= kAwaySlowMs + 2 * d.scanMs + d.connectMs + 1000); + } + { // ... and with a button, at once: KEY, or any button (the nudge), a few seconds after the hotspot is up + for (int how = 0; how < 2; how++) { + NetDay d(kConnectTimeoutMs); + d.setNets(NET_ABSENT, NET_ABSENT); + d.run(3600000UL); // an hour away already: the looks are 5 minutes apart + CHECK(d.awayEnds >= 10 && !d.radio); + while (d.radio || d.p.retryAtMs() - d.t < 200000) d.tick(); // just after a look: the next is minutes off + d.net[1] = NET_UP; + const uint32_t on = d.t; + if (how == 0) d.press(); + else d.p.lookNow(); + while (!d.online && d.t - on < 60000) d.tick(); + if (!d.online) printf(" not joined after a %s\n", how == 0 ? "key press" : "nudge"); + CHECK(d.online && d.joinedNet == 1 && d.t - on <= d.scanMs + d.connectMs + 1500); // the main one is asked for first, then the hotspot + } + } + { // a key press where there is no network: one look of a few seconds, not 45 seconds of radio + NetDay d; + d.net[0] = NET_ABSENT; + d.run(3600000UL); + while (d.radio) d.tick(); + const uint32_t onBefore = d.p.radioOnMs(d.t); + const uint32_t looksBefore = d.awayEnds; + d.press(); + d.run(60000); + const uint32_t cost = d.p.radioOnMs(d.t) - onBefore; + CHECK(d.awayEnds == looksBefore + 1 || d.awayEnds == looksBefore + 2); // (the press's look, and perhaps the clock's own, due in that minute) + CHECK(cost <= 2 * 2800); + } + { // the router restarts (a session finds it gone, and it comes back three minutes later): no failure is + // counted, and the clock is on it again within two minutes of its return + NetDay d; + d.run(3600000UL); + d.net[0] = NET_ABSENT; + while (d.awayEnds == 0 && d.t < 3 * 3600000UL) d.tick(); // (the next session notices) + CHECK(d.awayEnds == 1 && d.p.failures() == 0); + d.run(3 * 60000UL); + d.net[0] = NET_UP; + const uint32_t back = d.t; + while (!d.online && d.t - back < 10 * 60000UL) d.tick(); + CHECK(d.online && d.t - back <= kAwayQuickMs + d.connectMs + 500); + CHECK(d.failedEnds == 0); + } + { // gone in the middle of a session (the router loses power while the weather is fetched): the stack's own + // reconnect hears "no such network" and the session ends as away, not after 25 seconds as a failure + NetDay d; + d.run(3600000UL); + while (!d.online) d.tick(); + d.net[0] = NET_ABSENT; + const uint32_t t0 = d.t; + while (d.radio && d.t - t0 < 60000) d.tick(); + CHECK(!d.radio && d.t - t0 <= 2 * d.scanMs + 600 && d.awayEnds == 1 && d.failedEnds == 0); + } +} + +// spotify_live = off: the radio does not stay on for the music. +void testTrackMode() { + section("radio plan: music with spotify_live = off"); + uint32_t onLive = 0, onTrack = 0; + for (int mode = 0; mode < 2; mode++) { + NetDay d(kConnectTimeoutMs); + d.spotify = true; + d.live = mode == 0; + d.run(3600000UL); + d.play(true); + d.run(16 * 60000UL); // (a session comes by and finds it playing) + CHECK(d.seenPlaying); + const uint32_t onBefore = d.p.radioOnMs(d.t); + const uint32_t sessionsBefore = d.p.sessions(); + const uint32_t staleBefore = d.staleMs; + d.maxStaleRun = 0; + d.run(2 * 3600000UL); // two hours of music, tracks of three and a half minutes + const uint32_t on = d.p.radioOnMs(d.t) - onBefore; + const uint32_t sessions = d.p.sessions() - sessionsBefore; + if (mode == 0) { + onLive = on; + printf(" (two hours of music, live: radio on %.0f %%, the strip at most %.1f s behind)\n", on / 72000.0, d.maxStaleRun / 1000.0); + CHECK(on > 0.99 * 7200000.0 && d.maxStaleRun <= 12000); + } else { + onTrack = on; + printf(" (two hours of music, spotify_live = off: radio on %.1f %%, %u sessions, the strip at most %.1f s behind, %.1f %% of the time)\n", + on / 72000.0, (unsigned)sessions, d.maxStaleRun / 1000.0, (d.staleMs - staleBefore) / 72000.0); + CHECK(sessions >= 34 && sessions <= 46); // one a track (34 of them), and the weather's when it does not fall together with one + CHECK(on < 0.07 * 7200000.0); + CHECK(d.maxStaleRun <= kTrackEndSlackMs + d.connectMs + 3000 + 1000); // the next title a few seconds after the track changed + CHECK(d.longestSession <= 25000); + // the music stops (the phone is paused): the look at the end of the track sees it, and the looks stop + d.play(false); + d.run(d.trackMs + 20000); + CHECK(!d.seenPlaying); + const uint32_t s2 = d.p.sessions(); + d.run(3600000UL); + CHECK(d.p.sessions() - s2 <= 6); // back to the weather's four an hour (and the hourly time sync) + } + } + CHECK(onTrack * 10 < onLive); // what it is for + + { // a track skipped on the phone shows late, and not later than the old one would have run + NetDay d(kConnectTimeoutMs); + d.spotify = true; + d.live = false; + d.run(3600000UL); + d.play(true); + d.run(16 * 60000UL); + // 20 s into a track, the radio off, and no weather or time session in the next minutes (it would look too) + while ((uint32_t)(d.t - d.trackStart) < 20000 || (uint32_t)(d.t - d.trackStart) > 30000 || d.radio || + (int32_t)(d.weatherAt - d.t) < 400000 || (int32_t)(d.ntpAt - d.t) < 400000) { + d.tick(); + } + d.maxStaleRun = 0; + d.skip(); + d.run(d.trackMs + 30000); + CHECK(d.maxStaleRun >= 150000); // (it does show late: the rest of the track that was skipped. That is the price.) + CHECK(d.maxStaleRun <= d.trackMs + 10000); + CHECK(d.seenTrackNo == d.trackNo || d.radio); + } + { // ... and never later than the next session that comes by for the weather: every session looks at the player + NetDay d(kConnectTimeoutMs); + d.spotify = true; + d.live = false; + d.trackMs = 3600000; // an hour-long episode + d.run(3600000UL); + d.play(true); + d.run(16 * 60000UL); + while (d.radio) d.tick(); + d.maxStaleRun = 0; + d.skip(); + d.run(40 * 60000UL); + CHECK(d.maxStaleRun <= 15 * 60000UL + 15000); + } + { // KEY is live either way: a press brings the radio up and the player is looked at straight away + NetDay d(kConnectTimeoutMs); + d.spotify = true; + d.live = false; + d.run(3600000UL); + d.play(true); + d.run(16 * 60000UL); + while (d.radio) d.tick(); + while ((uint32_t)(d.t - d.trackStart) < 20000 || (uint32_t)(d.t - d.trackStart) > 30000 || d.radio) d.tick(); + d.skip(); + d.press(); + const uint32_t t0 = d.t; + while (d.seenTrackNo != d.trackNo && d.t - t0 < 30000) d.tick(); + CHECK(d.seenTrackNo == d.trackNo && d.t - t0 <= d.connectMs + 3000 + 1000); + } + { // the network goes away while music plays: the track is taken off the screen at the first look that fails + NetDay d; + d.spotify = true; + d.live = false; + d.run(3600000UL); + d.play(true); + d.run(16 * 60000UL); + while (d.radio) d.tick(); + CHECK(d.seenPlaying); + d.net[0] = NET_ABSENT; + d.run(d.trackMs + 30000); + CHECK(!d.seenPlaying && d.awayEnds >= 1); + } +} + +void testClockPolicy() { + section("CPU clock policy"); + using namespace clockpolicy; + Needs none, radio, usb, poke; + radio.radio = true; + usb.usbHost = true; + poke.poked = true; + CHECK(target(none, 80, 20) == 20); + CHECK(target(radio, 80, 20) == 80 && target(usb, 80, 20) == 80 && target(poke, 80, 20) == 80); + Needs all; + all.radio = all.usbHost = all.poked = true; + CHECK(target(all, 80, 20) == 80); + // no idle clock, or one that is not below the working clock: always the working clock + CHECK(target(none, 80, 0) == 80 && target(none, 80, 80) == 80 && target(none, 80, 160) == 80); + CHECK(target(none, 240, 80) == 80 && target(none, 160, 40) == 40); + // up at once, down only when quiet, nothing to do when it is there already + CHECK(mayChange(20, 80, false) && mayChange(20, 80, true)); + CHECK(!mayChange(80, 20, false) && mayChange(80, 20, true)); + CHECK(!mayChange(80, 80, true) && !mayChange(20, 20, false)); + + // the settings give the idle clock: never above the working one, and 20 MHz is the lowest on offer + Settings st; + st.cpuIdle = CPUIDLE_20; + CHECK(target(none, st.cpuMhz(), st.cpuIdleMhz()) == 20); + st.cpuIdle = CPUIDLE_40; + CHECK(target(none, st.cpuMhz(), st.cpuIdleMhz()) == 40); + st.cpuIdle = CPUIDLE_80; // = cpu_mhz = 80: nothing to do + CHECK(target(none, st.cpuMhz(), st.cpuIdleMhz()) == 80); + int lowest = 1000; + for (int v = 0; v < 8; v++) { + Settings any; + any.cpuSpeed = CPU_240; + any.cpuIdle = (uint8_t)v; + if (any.cpuIdleMhz() > 0 && any.cpuIdleMhz() < lowest) lowest = any.cpuIdleMhz(); + } + CHECK(lowest == 20); +} + +void testConsolePolicy() { + section("console policy"); + using namespace consolepolicy; + CHECK((int)CONSOLE_ON == (int)ON && (int)CONSOLE_AUTO == (int)AUTO && (int)CONSOLE_OFF == (int)OFF); // the setting's numbers are the policy's + { // on: never, with or without a computer + Watch w; + for (uint32_t t = 0; t < 3600000; t += 1000) CHECK(!w.shouldStop(ON, false, (t / 60000) % 2 == 0, t)); + } + { // off: at once, a computer on the port or not + Watch w; + CHECK(w.shouldStop(OFF, false, true, 3000)); + Watch v; + CHECK(v.shouldStop(OFF, false, false, 3000)); + } + { // auto, no computer: ten seconds after the first call, not before + Watch w; + uint32_t t = 2500; // (the loop starts a few seconds after power-on) + bool stop = false; + while (!stop && t < 60000) { + stop = w.shouldStop(AUTO, false, false, t); + if (!stop) t += 4; + } + CHECK(stop && t - 2500 >= kNoHostMs && t - 2500 <= kNoHostMs + 8); + } + { // auto, on a computer: it stays for as long as the computer is seen, and goes ten seconds after the cable is pulled + Watch w; + uint32_t t = 2500; + for (; t < 3600000; t += 100) CHECK(!w.shouldStop(AUTO, false, true, t)); + const uint32_t pulled = t - 100; // the last time the computer was seen + bool stop = false; + while (!stop && t - pulled < 60000) { + t += 100; + stop = w.shouldStop(AUTO, false, false, t); + } + CHECK(stop && t - pulled >= kNoHostMs && t - pulled <= kNoHostMs + 200); + } + { // auto: a computer that shows up within the ten seconds (the port takes a moment after power-on) keeps it, and + // short gaps (the port being reset by an upload) do not count up + Watch w; + uint32_t t = 1000; + for (; t < 9000; t += 100) CHECK(!w.shouldStop(AUTO, false, false, t)); + for (; t < 20000; t += 100) CHECK(!w.shouldStop(AUTO, false, true, t)); + for (int round = 0; round < 20; round++) { + for (int i = 0; i < 80; i++, t += 100) CHECK(!w.shouldStop(AUTO, false, false, t)); // 8 s without + for (int i = 0; i < 10; i++, t += 100) CHECK(!w.shouldStop(AUTO, false, true, t)); // 1 s with + } + } + { // KEY held at start-up keeps the console whatever the setting, for the whole run + for (int mode : {(int)ON, (int)AUTO, (int)OFF}) { + Watch w; + for (uint32_t t = 0; t < 600000; t += 500) CHECK(!w.shouldStop(mode, true, false, t)); + } + } + { // across the wrap of millis() + Watch w; + uint32_t t = 0xFFFFF000u; + CHECK(!w.shouldStop(AUTO, false, true, t)); + bool stop = false; + uint32_t waited = 0; + while (!stop && waited < 60000) { + waited += 100; + stop = w.shouldStop(AUTO, false, false, t + waited); + } + CHECK(stop && waited >= kNoHostMs && waited <= kNoHostMs + 100); + } +} + +void testPicker() { + section("radio plan: the picker across sessions"); + using namespace wifipick; + Picker pk; + pk.configure(true, true); + CHECK(pk.nextAttempt() == NET_MAIN); + pk.joined(NET_MAIN, 1000); + pk.radioOff(); + CHECK(pk.nextAttempt() == NET_MAIN && pk.current() == NET_MAIN); // on the main network: back to it + // on the backup: the next session starts there, not with a failed attempt on the main one + Picker bk; + bk.configure(true, true); + bk.nextAttempt(); + bk.nextAttempt(); + bk.joined(NET_BACKUP, 5000); + CHECK(bk.onBackup()); + bk.radioOff(); + CHECK(bk.nextAttempt() == NET_BACKUP && bk.onBackup()); + CHECK(bk.nextAttempt() == NET_MAIN); // if that fails, the other one, as always + // ... and the look for the main network goes on by the clock + bk.joined(NET_BACKUP, 10000); + bk.radioOff(); + CHECK(!bk.timeToLookForMain(10000 + 5 * 60000UL) && bk.timeToLookForMain(10000 + 10 * 60000UL)); + // no backup: nothing changes + Picker one; + one.configure(true, false); + one.nextAttempt(); + one.joined(NET_MAIN, 1); + one.radioOff(); + CHECK(one.nextAttempt() == NET_MAIN); +} + +// After the clock changes the frame planner expects the costs of the new clock. A model of the UI loop at +// 80 MHz and at 20, where a frame takes four times as long, and a switch between them in the middle of the run. +void testFramePlanClock() { + section("frame plan: after a clock change"); + FramePlanner p(20000); + CHECK(p.mhz() == 80 && p.drawCostUs() == 30000 && p.sendCostUs() == 15000); + p.clockChanged(80, 20); + CHECK(p.mhz() == 20 && p.drawCostUs() == 120000 && p.sendCostUs() == 60000); + p.clockChanged(20, 80); + CHECK(p.drawCostUs() == 30000 && p.sendCostUs() == 15000); + p.clockChanged(80, 80); // no change + CHECK(p.drawCostUs() == 30000); + p.clockChanged(0, 20); // nonsense is ignored + p.clockChanged(80, 0); + CHECK(p.drawCostUs() == 30000 && p.mhz() == 80); + p.clockChanged(80, 10); + CHECK(p.drawCostUs() == 240000 && p.sendCostUs() == 120000); // more than the 150 ms ceiling of the fast clocks + + // a long stall must not stretch the plan, at 20 MHz as at 80: a measurement is capped at 150 ms * 80 / MHz + FramePlanner q(20000); + q.drew(100, 400000, true); + CHECK(q.drawCostUs() == (30000u * 3 + 150000u) / 4); + FramePlanner slow(20000); + slow.clockChanged(80, 20); + for (int i = 0; i < 40; i++) slow.drew(100 + i, 5000000, true); // always stalled: the ceiling is 600 ms here + CHECK(slow.drawCostUs() > 590000u && slow.drawCostUs() <= 600000u); + + // the first frame after a switch is planned with the new costs: a simulated second-by-second loop, the + // clock dropping from 80 to 20 MHz at second 50 and coming back at second 150, with draws and sends that + // take 4 times as long while it is low; a frame is late if it goes out after its second has begun + FramePlanner pl(20000); + int64_t t = 0; + int lateFrames = 0, frames = 0; + int mhz = 80; + auto cost = [&](int64_t us) { return mhz == 80 ? us : us * 4; }; + time_t lastSent = 0; + time_t prepared = 0; + bool havePrepared = false; + const int64_t end = 300 * 1000000LL; + while (t < end) { + const time_t sec = (time_t)(t / 1000000); + const int sec2 = (int)sec; + if ((sec2 == 50 && mhz == 80 && !pl.prepared()) || (sec2 == 150 && mhz == 20 && !pl.prepared())) { + const int to = mhz == 80 ? 20 : 80; + pl.clockChanged((uint32_t)mhz, (uint32_t)to); + mhz = to; + } + const FramePlanner::Step st = pl.next(sec, (int32_t)(t % 1000000), false); + switch (st.action) { + case FramePlanner::DRAW_NOW: + t += cost(30000); + pl.drew(st.sec, (uint32_t)cost(30000), false); + t += cost(15000); + pl.sent((uint32_t)cost(15000)); + lastSent = st.sec; + break; + case FramePlanner::PREPARE: + t += cost(30000); + pl.drew(st.sec, (uint32_t)cost(30000), true); + prepared = st.sec; + havePrepared = true; + break; + case FramePlanner::SEND: + t += st.waitUs; + t += cost(15000); + if (havePrepared && sec2 > 5) { + frames++; + // on the glass 20 ms (the panel) after it was sent: late if that is after its second began + if ((t + 20000) > (int64_t)prepared * 1000000LL + 30000) lateFrames++; // 30 ms of grace + } + pl.sent((uint32_t)cost(15000)); + lastSent = prepared; + break; + case FramePlanner::SLEEP: + t += 4000; + break; + default: + t += 4000; + break; + } + } + (void)lastSent; + printf(" (%d of %d frames late around the two clock changes)\n", lateFrames, frames); + CHECK(frames > 250); + CHECK(lateFrames <= 2); // a frame or two at the moment of a switch is what it costs; the rest is on time +} + +} // namespace + +int main() { + testSteps(); + testFailures(); + testSearch(); + testAway(); + testWrap(); + testSpotifyRules(); + testDay(); + testAwayDays(); + testTrackMode(); + testClockPolicy(); + testConsolePolicy(); + testPicker(); + testFramePlanClock(); + printf("%d checks, %d failed\n", g_checks, g_failed); + return g_failed ? 1 : 0; +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/tests/test_spotify_link.py b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/test_spotify_link.py new file mode 100644 index 00000000..32aa2d20 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/tests/test_spotify_link.py @@ -0,0 +1,201 @@ +"""Tests for tools/spotify_link.py against a fake clock and a simulated browser. + + python -m unittest tools/tests/test_spotify_link.py (from the sketch folder) + python tools/tests/test_spotify_link.py +""" +import pathlib +import socket +import subprocess +import sys +import threading +import time +import unittest +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +TOOLS = pathlib.Path(__file__).resolve().parent.parent +HELPER = TOOLS / "spotify_link.py" + + +def free_port(): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class FakeClock: + """Stands in for the clock's link page: /, /go and /callback.""" + + def __init__(self, callback_status=200, callback_body=b"

Linked!

"): + self.received = [] # request paths seen + outer = self + + class H(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + outer.received.append(self.path) + if self.path == "/": + self._send(200, b"setup page") + elif self.path == "/go": + self.send_response(302) + self.send_header("Location", "https://accounts.spotify.com/authorize?client_id=fake") + self.send_header("Content-Length", "0") + self.end_headers() + elif self.path.startswith("/callback"): + self._send(callback_status, callback_body) + else: + self._send(404, b"nope") + + def _send(self, status, body): + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), H) + self.address = "127.0.0.1:%d" % self.server.server_address[1] + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + def close(self): + self.server.shutdown() + self.server.server_close() + + +def run_helper(address, port, timeout=20, *extra): + return subprocess.Popen( + [sys.executable, str(HELPER), address, "--port", str(port), "--no-browser", "--timeout", str(timeout), *extra], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + + +def wait_listening(port, limit=10.0): + end = time.time() + limit + while time.time() < end: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.5): + return True + except OSError: + time.sleep(0.05) + return False + + +class HelperTests(unittest.TestCase): + def test_forwards_redirect_to_clock_and_relays_answer(self): + clock, port = FakeClock(), free_port() + try: + proc = run_helper(clock.address, port) + self.assertTrue(wait_listening(port), "helper never started listening") + # the "browser" arrives after Spotify's redirect + with urllib.request.urlopen("http://127.0.0.1:%d/callback?code=AQD%%2Bxyz&state=abc123" % port, timeout=10) as r: + self.assertEqual(r.status, 200) + self.assertIn(b"Linked!", r.read()) + out, _ = proc.communicate(timeout=15) + self.assertEqual(proc.returncode, 0, out) + self.assertIn("Done", out) + self.assertIn("/callback?code=AQD%2Bxyz&state=abc123", clock.received) # forwarded untouched + self.assertIn("/", clock.received) # it checked the clock first + self.assertIn("/go", out) # printed the link for the user + finally: + clock.close() + + def test_error_from_clock_is_relayed_and_reported(self): + clock, port = FakeClock(callback_status=400, callback_body=b"

Spotify refused the code

"), free_port() + try: + proc = run_helper(clock.address, port) + self.assertTrue(wait_listening(port)) + with self.assertRaises(urllib.error.HTTPError) as ctx: + urllib.request.urlopen("http://127.0.0.1:%d/callback?code=bad" % port, timeout=10) + self.assertEqual(ctx.exception.code, 400) + self.assertIn(b"refused", ctx.exception.read()) + out, _ = proc.communicate(timeout=15) + self.assertEqual(proc.returncode, 1, out) + finally: + clock.close() + + def test_unrelated_requests_do_not_finish_it(self): + clock, port = FakeClock(), free_port() + try: + proc = run_helper(clock.address, port) + self.assertTrue(wait_listening(port)) + with self.assertRaises(urllib.error.HTTPError) as ctx: # e.g. the browser asking for a favicon + urllib.request.urlopen("http://127.0.0.1:%d/favicon.ico" % port, timeout=10) + self.assertEqual(ctx.exception.code, 404) + self.assertIsNone(proc.poll(), "helper quit on an unrelated request") + urllib.request.urlopen("http://127.0.0.1:%d/callback?code=ok" % port, timeout=10).read() + out, _ = proc.communicate(timeout=15) + self.assertEqual(proc.returncode, 0, out) + finally: + clock.close() + + def test_idle_speculative_connection_does_not_block_the_real_request(self): + clock, port = FakeClock(), free_port() + idle = None + try: + proc = run_helper(clock.address, port) + self.assertTrue(wait_listening(port)) + idle = socket.create_connection(("127.0.0.1", port)) # browsers open spare, silent connections + started = time.time() + urllib.request.urlopen("http://127.0.0.1:%d/callback?code=ok" % port, timeout=10).read() + self.assertLess(time.time() - started, 5, "served only after the idle connection timed out") + proc.communicate(timeout=15) + self.assertEqual(proc.returncode, 0) + finally: + if idle: + idle.close() + clock.close() + + def test_clock_unreachable(self): + port = free_port() + proc = run_helper("127.0.0.1:%d" % free_port(), port) # nothing listens on the "clock" port + out, _ = proc.communicate(timeout=30) + self.assertEqual(proc.returncode, 1) + self.assertIn("Cannot reach the clock", out) + + def test_port_already_in_use(self): + clock = FakeClock() + busy = socket.socket() + busy.bind(("127.0.0.1", 0)) + busy.listen(1) + try: + proc = run_helper(clock.address, busy.getsockname()[1]) + out, _ = proc.communicate(timeout=30) + self.assertEqual(proc.returncode, 1) + self.assertIn("Cannot listen on 127.0.0.1", out) + finally: + busy.close() + clock.close() + + def test_times_out_without_a_redirect(self): + clock, port = FakeClock(), free_port() + try: + proc = run_helper(clock.address, port, 1) + out, _ = proc.communicate(timeout=30) + self.assertEqual(proc.returncode, 1) + self.assertIn("Timed out", out) + finally: + clock.close() + + def test_accepts_http_prefix_and_trailing_slash(self): + clock, port = FakeClock(), free_port() + try: + proc = run_helper("http://" + clock.address + "/", port) + self.assertTrue(wait_listening(port)) + urllib.request.urlopen("http://127.0.0.1:%d/callback?code=ok" % port, timeout=10).read() + out, _ = proc.communicate(timeout=15) + self.assertEqual(proc.returncode, 0, out) + finally: + clock.close() + + +class EmbeddedCopyTests(unittest.TestCase): + def test_header_served_by_the_clock_matches_the_script(self): + result = subprocess.run([sys.executable, str(TOOLS / "gen_helper_header.py"), "--check"], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/build_u8g2.sh b/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/build_u8g2.sh new file mode 100644 index 00000000..5bce565a --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/build_u8g2.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Compiles the U8g2 C sources once into build/u8g2/*.o (used by the preview tool). +# Run inside Linux or WSL: ./build_u8g2.sh [path/to/U8g2/src] +set -euo pipefail +cd "$(dirname "$0")" +SRC="${1:-../../../../../01_Arduino_Libraries/U8g2/src}" +OUT=build/u8g2 +mkdir -p "$OUT" +ls "$SRC"/clib/*.c | xargs -P "$(nproc)" -I{} sh -c ' + o="'"$OUT"'/$(basename {} .c).o" + [ "$o" -nt {} ] || gcc -O1 -w -c {} -I"'"$SRC"'"/clib -o "$o"' +echo "u8g2 objects: $(ls "$OUT" | wc -l)" diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/fuzz.cpp b/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/fuzz.cpp new file mode 100644 index 00000000..dda4c356 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/fuzz.cpp @@ -0,0 +1,198 @@ +// Robustness test for ui.cpp: renders many frames built from adversarial data +// (invalid UTF-8, emoji, CJK, control characters, strings that fill their +// buffers, out-of-range numbers) and relies on AddressSanitizer / UBSan to catch +// any overflow or undefined behaviour in the drawing code. +// +// ./run_fuzz.sh [iterations] [seed] + +#include "host_display.h" + +#include "../../ui.cpp" + +#include +#include +#include +#include + +static UiModel *g_current = nullptr; +static int g_iteration = 0; + +// A frame must render in milliseconds; if one takes seconds the drawing code is +// stuck in a loop. Report the model that did it. +static void onHang(int) { + fprintf(stderr, "HANG in frame %d: page=%d spotify=%d\n", g_iteration, g_current->page, + g_current->spotify.status); + fprintf(stderr, " title=[%s]\n artist=[%s]\n album=[%s]\n device=[%s]\n", g_current->spotify.title, + g_current->spotify.artist, g_current->spotify.album, g_current->spotify.device); + fprintf(stderr, " message=[%s]\n linkUrl=[%s]\n location=[%s]\n status=[%s]\n toast=[%s]\n", + g_current->spotify.message, g_current->spotify.linkUrl, g_current->location, g_current->status, + g_current->toast); + _exit(2); +} + +static uint64_t g_rng = 0x9E3779B97F4A7C15ull; +static uint32_t rnd() { + g_rng ^= g_rng << 13; + g_rng ^= g_rng >> 7; + g_rng ^= g_rng << 17; + return (uint32_t)(g_rng >> 16); +} +static int rndRange(int lo, int hi) { return lo + (int)(rnd() % (uint32_t)(hi - lo + 1)); } +static float rndFloat(float lo, float hi) { return lo + (hi - lo) * (rnd() / 4294967296.0f) * 65536.0f / 65536.0f; } + +// Fragments a track title might plausibly (or implausibly) contain. +static const char *kFragments[] = { + "Blinding Lights", "Don\xE2\x80\x99t Stop", " - ", " (Remastered 2011)", "Caf\xC3\xA9 del Mar", + "\xE5\xA4\x9C\xE3\x81\xAB\xE9\xA7\x86\xE3\x81\x91\xE3\x82\x8B", // CJK + "\xF0\x9F\x8E\xB5", // emoji (4-byte) + "\xD0\x9C\xD0\xB8\xD1\x80", // Cyrillic + "\xE2\x80\x94", "\xE2\x80\xA6", "\xC2\xA0", "\xC2\xB7", + "\x80", "\xC0\xAF", "\xFF", "\xE2\x80", "\xF0\x9F", // invalid / truncated + "\t", "\n", "\x01", " ", "WWWWWWWWWWWWWWWWWWWW", "iiiiiiiiiiiiiiiiiiii", "%s%d%n", "a", +}; + +static void fillString(char *dst, size_t cap) { + size_t n = 0; + int mode = rndRange(0, 9); + size_t target = mode == 0 ? 0 : (mode <= 2 ? cap - 1 : (size_t)rndRange(1, (int)cap - 1)); + while (n < target) { + const char *f = kFragments[rnd() % (sizeof(kFragments) / sizeof(kFragments[0]))]; + size_t l = strlen(f); + if (n + l >= cap) { // may cut a multi-byte sequence in half on purpose + l = cap - 1 - n; + } + memcpy(dst + n, f, l); + n += l; + if (rndRange(0, 5) == 0) break; + } + if (mode == 1) { // exactly full buffer, no terminator inside + memset(dst, 'x', cap - 1); + n = cap - 1; + } + dst[n] = 0; +} + +static float wild(float lo, float hi) { + switch (rnd() % 12) { + case 0: return 0; + case 1: return -0.0f; + case 2: return 1e9f; + case 3: return -1e9f; + default: return lo + (hi - lo) * ((rnd() & 0xFFFF) / 65535.0f); + } +} + +static void randomModel(UiModel &m) { + m = UiModel(); + m.timeValid = rndRange(0, 7) != 0; + m.local.tm_year = rndRange(100, 200); + m.local.tm_mon = rndRange(0, 11); + m.local.tm_mday = rndRange(1, 31); + m.local.tm_hour = rndRange(0, 23); + m.local.tm_min = rndRange(0, 59); + m.local.tm_sec = rndRange(0, 60); // leap second + m.local.tm_wday = rndRange(0, 6); + m.local.tm_yday = rndRange(0, 365); + m.secFrac = rndFloat(0, 1); + m.utcOffsetMin = rndRange(-14 * 60, 14 * 60); + fillString(m.tzAbbrev, sizeof m.tzAbbrev); + + m.indoorValid = rndRange(0, 5) != 0; + m.indoorC = wild(-40, 80); + m.indoorRh = wild(-5, 120); + m.batPresent = rndRange(0, 3) != 0; + m.batVolts = wild(0, 5); + m.batPercent = rndRange(-20, 130); + m.batLow = rndRange(0, 1); + m.batBlinkOn = rndRange(0, 1); + m.batCharge = rndRange(-2, 6); // includes values that are not a UiCharge + m.wifiUp = rndRange(0, 1); + m.rssi = rndRange(-130, 10); + fillString(m.location, sizeof m.location); + fillString(m.status, sizeof m.status); + + WeatherData &w = m.weather; + w.valid = rndRange(0, 5) != 0; + w.ageSec = rnd(); + w.temp = wild(-60, 70); + w.feels = wild(-60, 70); + w.windKmh = wild(0, 300); + w.humidity = (uint8_t)rnd(); + w.code = (uint8_t)rnd(); + w.isDay = rndRange(0, 1); + for (auto &d : w.day) { + d.code = (uint8_t)rnd(); + d.tmax = wild(-60, 70); + d.tmin = wild(-60, 70); + d.rainPct = (uint8_t)rnd(); + d.weekday = (uint8_t)rnd(); // deliberately out of range too + } + fillString(w.sunrise, sizeof w.sunrise); + fillString(w.sunset, sizeof w.sunset); + w.uvMax = wild(0, 20); + + SpotifyInfo &s = m.spotify; + s.status = (SpotifyStatus)rndRange(0, 5); + fillString(s.title, sizeof s.title); + fillString(s.artist, sizeof s.artist); + fillString(s.album, sizeof s.album); + fillString(s.device, sizeof s.device); + fillString(s.message, sizeof s.message); + fillString(s.linkUrl, sizeof s.linkUrl); + s.durationMs = rndRange(0, 3) == 0 ? 0 : rnd(); + s.progressMs = rndRange(0, 3) == 0 ? rnd() : s.durationMs / 3; // sometimes beyond the end + s.volume = (int8_t)rndRange(-1, 100); + s.linkDaysLeft = (int16_t)rndRange(-1, 200); + + m.useFahrenheit = rndRange(0, 1); + m.time12h = rndRange(0, 1); + m.dateFormat = (uint8_t)rndRange(0, 9); // two past the last format on purpose + m.showWeek = rndRange(0, 1); + m.wifiOff = rndRange(0, 4) == 0; + m.moonValid = rndRange(0, 4) != 0; + m.southern = rndRange(0, 1); + for (int i = 0; i < 3; i++) { + m.moonLit[i] = (uint8_t)rnd(); // up to 255 %: must be clamped + m.moonWaxing[i] = rndRange(0, 1); + } + m.page = (UiPage)rndRange(0, PAGE_COUNT - 1); + if (rndRange(0, 3) == 0) { + fillString(m.toast, sizeof m.toast); + m.toastIcon = rndRange(0, 6); + } + m.infoCount = rndRange(0, UI_INFO_LINES); + for (int i = 0; i < UI_INFO_LINES; i++) fillString(m.info[i], UI_INFO_LINE_LEN); +} + +int main(int argc, char **argv) { + int iterations = argc > 1 ? atoi(argv[1]) : 3000; + if (argc > 2) g_rng ^= (uint64_t)strtoull(argv[2], nullptr, 10) * 0xD1B54A32D192ED03ull; // seed + u8g2_t *u = hostDisplayInit(); + UiModel m; + g_current = &m; + signal(SIGALRM, onHang); + for (int i = 0; i < iterations; i++) { + g_iteration = i; + randomModel(m); + alarm(5); + u8g2_ClearBuffer(u); + uiDraw(u, m); + if (i % 50 == 0) { // the shutdown screen, with any voltage + u8g2_ClearBuffer(u); + uiDrawBatteryEmpty(u, wild(-1, 6), wild(0, 6), rndRange(0, 1)); + } + if (i % 50 == 7) { // the firmware-update screen, with anything in its fields + UiFwScreen f; + f.kind = (UiFwKind)rndRange(0, 4); // two past the last kind on purpose + fillString(f.what, sizeof f.what); + fillString(f.detail, sizeof f.detail); + f.percent = rndRange(-300, 300); + f.keepPowered = rndRange(0, 1); + u8g2_ClearBuffer(u); + uiDrawFirmwareUpdate(u, f); + } + } + alarm(0); + printf("fuzz: %d frames rendered, no faults\n", iterations); + return 0; +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/host_display.h b/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/host_display.h new file mode 100644 index 00000000..2d7a1f56 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/host_display.h @@ -0,0 +1,65 @@ +#pragma once + +// Host-side stand-in for the ST7305 panel: a 300x400 1-bit page buffer set up +// exactly like firmware (full-buffer mode, U8G2_R1 -> 400x300 landscape). +// Lets ui.cpp be rendered on a PC and dumped to an image. + +#include +#include +#include +#include + +static const int kPhysW = 300; // panel native width +static const int kPhysH = 400; // panel native height +static const int kTileW = 38; // buffer width in 8-px tiles (304 px) +static const int kTileH = 50; // buffer height in 8-px tiles (400 px) + +static uint8_t hostDisplayCb(u8x8_t *u8x8, uint8_t msg, uint8_t, void *) { + static const u8x8_display_info_t info = { + /* chip_enable_level */ 0, /* chip_disable_level */ 1, + /* post_chip_enable_wait_ns */ 0, /* pre_chip_disable_wait_ns */ 0, + /* reset_pulse_width_ms */ 0, /* post_reset_wait_ms */ 0, + /* sda_setup_time_ns */ 0, /* sck_pulse_width_ns */ 0, + /* sck_clock_hz */ 0, /* spi_mode */ 0, /* i2c_bus_clock_100kHz */ 4, + /* data_setup_time_ns */ 0, /* write_pulse_width_ns */ 0, + /* tile_width */ kTileW, /* tile_height */ kTileH, + /* default_x_offset */ 0, /* flip_mode_x_offset */ 0, + /* pixel_width */ kPhysW, /* pixel_height */ kPhysH}; + if (msg == U8X8_MSG_DISPLAY_SETUP_MEMORY) { + u8x8_d_helper_display_setup_memory(u8x8, &info); + } + return 1; +} + +static uint8_t hostByteCb(u8x8_t *, uint8_t, uint8_t, void *) { return 1; } +static uint8_t hostGpioCb(u8x8_t *, uint8_t, uint8_t, void *) { return 1; } + +static uint8_t g_hostBuf[kTileW * 8 * kTileH]; + +static u8g2_t *hostDisplayInit() { + static u8g2_t u8g2; + u8g2_SetupDisplay(&u8g2, hostDisplayCb, u8x8_cad_empty, hostByteCb, hostGpioCb); + u8g2_SetupBuffer(&u8g2, g_hostBuf, kTileH, u8g2_ll_hvline_vertical_top_lsb, U8G2_R1); + u8g2_InitDisplay(&u8g2); + u8g2_SetPowerSave(&u8g2, 0); + return &u8g2; +} + +// Writes the *logical* 400x300 image as a binary PGM-like file: "P5\n400 300\n255\n" +// with 0 = black ink and 255 = white paper. `invert` = 1 treats set pixels as ink. +static void hostDumpPgm(const char *path) { + // Physical pixel (px,py): byte [(py/8)*304 + px], bit (py%8), LSB = top. + // U8G2_R1 maps logical (lx,ly) to physical (px = kPhysW-1-ly, py = lx). + FILE *f = fopen(path, "wb"); + if (!f) return; + fprintf(f, "P5\n400 300\n255\n"); + for (int ly = 0; ly < 300; ly++) { + for (int lx = 0; lx < 400; lx++) { + int px = kPhysW - 1 - ly; + int py = lx; + int on = (g_hostBuf[(py / 8) * (kTileW * 8) + px] >> (py % 8)) & 1; + fputc(on ? 0 : 255, f); + } + } + fclose(f); +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/preview.cpp b/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/preview.cpp new file mode 100644 index 00000000..642882ec --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/preview.cpp @@ -0,0 +1,1557 @@ +// Host-side UI preview. Renders ui.cpp for a set of scenarios and writes +// out/.pgm (400x300). Convert to PNG with to_png.py. +// +// ./run_preview.sh (Linux / WSL) +// +// ui.cpp is #included so the gallery can reach its internal helpers. + +#include "host_display.h" + +#include "../../ui.cpp" + +#include "../../calc.h" +#include "../../sd_layout.h" +#include "../../settings.h" + +#include +#include +#include +#include + +static u8g2_t *g_u = nullptr; + +static void snapshot(const char *name, const UiModel &m) { + u8g2_ClearBuffer(g_u); + uiDraw(g_u, m); + char path[128]; + snprintf(path, sizeof path, "out/%s.pgm", name); + hostDumpPgm(path); + printf("rendered %s\n", path); +} + +static void setStr(char *dst, size_t cap, const char *s) { snprintf(dst, cap, "%s", s); } + +// Sunday 2026-10-04 08:21:07 in Perth, WA, with sample weather numbers. +static UiModel baseModel() { + UiModel m; + m.timeValid = true; + memset(&m.local, 0, sizeof m.local); + m.local.tm_year = 2026 - 1900; + m.local.tm_mon = 9; + m.local.tm_mday = 4; + m.local.tm_hour = 8; + m.local.tm_min = 21; + m.local.tm_sec = 7; + m.local.tm_wday = 0; + m.local.tm_yday = 276; + m.utcOffsetMin = 480; + setStr(m.tzAbbrev, sizeof m.tzAbbrev, "AWST"); + + m.indoorValid = true; + m.indoorC = 22.4f; + m.indoorRh = 45; + + m.batPresent = true; + m.batVolts = 4.05f; + m.batPercent = 87; + + m.wifiUp = true; + m.rssi = -58; + setStr(m.location, sizeof m.location, "Perth"); + + WeatherData &w = m.weather; + w.valid = true; + w.temp = 15.3f; + w.feels = 15.1f; + w.humidity = 78; + w.code = 0; + w.isDay = true; + w.windKmh = 3.8f; + w.day[0] = {3, 18.8f, 9.4f, 4, 0}; + w.day[1] = {51, 19.5f, 11.5f, 35, 1}; + w.day[2] = {3, 20.5f, 9.1f, 0, 2}; + setStr(w.sunrise, sizeof w.sunrise, "05:52"); + setStr(w.sunset, sizeof w.sunset, "18:23"); + w.uvMax = 7.2f; + + // the moon for this date (2026-10-04 08:21 AWST = 00:21 UTC), today and the next two days + m.moonValid = true; + m.southern = true; // Perth + const int64_t base = calc::epochFromUtc(2026, 10, 4, 0, 21, 7); + for (int i = 0; i < 3; i++) { + const moon::Phase p = moon::phaseAt(base + (int64_t)i * 86400); + m.moonLit[i] = (uint8_t)lroundf(p.lit * 100); + m.moonWaxing[i] = p.waxing; + } + + SpotifyInfo &s = m.spotify; + s.status = SPOTIFY_PLAYING; + setStr(s.title, sizeof s.title, "Blinding Lights"); + setStr(s.artist, sizeof s.artist, "The Weeknd"); + setStr(s.album, sizeof s.album, "After Hours"); + setStr(s.device, sizeof s.device, "Kitchen speaker"); + s.durationMs = 200040; + s.progressMs = 83000; + s.volume = 59; + return m; +} + +static void setTime(UiModel &m, int h, int mi, int s) { + m.local.tm_hour = h; + m.local.tm_min = mi; + m.local.tm_sec = s; +} + + +// --------------------------------------------------------------------------- +// Layout stability: numbers that change every second/day must not wobble. +// U8g2's own string width is ink based, so centring on it moved the time by up +// to 5 px depending on its digits; ui.cpp lays digits out on fixed cells. +// +// Method: for a string like "00:00:00" work out the glyph cells exactly as the +// renderer does, and collect the pixels inside the cells of the punctuation +// (':' and '-'). Those cells must hold identical pixels in every frame. If the +// text shifted, they would not. +// --------------------------------------------------------------------------- +static bool inkAt(int lx, int ly) { + int px = kPhysW - 1 - ly, py = lx; + return (g_hostBuf[(py / 8) * (kTileW * 8) + px] >> (py % 8)) & 1; +} + +static std::string punctuationCells(const uint8_t *font, int cx, const char *layout, int y0, int y1) { + u8g2_SetFont(g_u, font); + const int pitch = digitPitch(g_u); + int x = cx - pitchWidth(g_u, layout) / 2; + std::string pattern; + for (const char *c = layout; *c; ++c) { + const bool digit = *c >= '0' && *c <= '9'; + const int adv = digit ? pitch : u8g2_GetGlyphWidth(g_u, (unsigned char)*c); + if (!digit) { + for (int xx = x; xx < x + adv; xx++) + for (int y = y0; y <= y1; y++) pattern += inkAt(xx, y) ? '#' : '.'; + } + x += adv; + } + return pattern; +} + +static int checkLayoutStability() { + const int cx = (kRightX0 + kRightX1) / 2; + int timeBad = 0, dateBad = 0, barBad = 0, oldWayBad = 0, frames = 0; + std::string refTime, refDate, refBar, refOld; + + const int hours[] = {0, 1, 7, 8, 10, 11, 12, 17, 19, 21, 22, 23}; + const int mins[] = {0, 1, 7, 10, 11, 21, 34, 59}; + const int secs[] = {0, 1, 7, 10, 11, 21, 59}; + for (int h : hours) + for (int mi : mins) + for (int sc : secs) { + UiModel m = baseModel(); + setTime(m, h, mi, sc); + m.spotify.status = SPOTIFY_IDLE; + + u8g2_ClearBuffer(g_u); // dashboard: the big time + uiDraw(g_u, m); + std::string t = punctuationCells(F_TIME, cx, "00:00:00", 57, 98); + if (refTime.empty()) refTime = t; + if (t != refTime && timeBad++ < 3) printf(" TIME MOVED at %02d:%02d:%02d\n", h, mi, sc); + + m.page = PAGE_INFO; // secondary pages: the clock in the status bar + u8g2_ClearBuffer(g_u); + uiDraw(g_u, m); + std::string b = punctuationCells(F_BOLD, 200, "00:00:00", 2, 16); + if (refBar.empty()) refBar = b; + if (b != refBar && barBad++ < 3) printf(" STATUS-BAR CLOCK MOVED at %02d:%02d:%02d\n", h, mi, sc); + + // negative control: the previous method (centre on U8g2's own width) must be caught by the same check + char buf[16]; + snprintf(buf, sizeof buf, "%02d:%02d:%02d", h, mi, sc); + u8g2_ClearBuffer(g_u); + u8g2_SetFontMode(g_u, 1); + u8g2_SetFont(g_u, F_TIME); + ink(g_u); + txtC(g_u, cx, 99, buf); + std::string o = punctuationCells(F_TIME, cx, "00:00:00", 57, 98); + if (refOld.empty()) refOld = o; + if (o != refOld) oldWayBad++; + frames += 3; + } + + const int dates[][3] = {{2026, 10, 4}, {2026, 11, 11}, {2027, 1, 7}, {2026, 12, 31}, {2026, 7, 17}, {2031, 3, 1}, {2029, 9, 27}}; + for (auto &d : dates) { + UiModel m = baseModel(); + m.local.tm_year = d[0] - 1900; + m.local.tm_mon = d[1] - 1; + m.local.tm_mday = d[2]; + m.spotify.status = SPOTIFY_IDLE; + u8g2_ClearBuffer(g_u); + uiDraw(g_u, m); + std::string p = punctuationCells(F_DATE, cx, "0000-00-00", 25, 50); + if (refDate.empty()) refDate = p; + if (p != refDate && dateBad++ < 3) printf(" DATE MOVED for %04d-%02d-%02d\n", d[0], d[1], d[2]); + frames++; + } + + int bad = timeBad + dateBad + barBad; + printf("layout stability: %d frames, %d problems (time %d, date %d, status-bar clock %d)\n", frames, bad, timeBad, + dateBad, barBad); + if (oldWayBad == 0) { // the check itself must be able to see the old wobble, or it proves nothing + printf(" CHECK IS BLIND: the old centring method showed no movement\n"); + bad++; + } else { + printf(" negative control OK: the old centring method moved the time in %d of %d frames\n", oldWayBad, frames / 3); + } + return bad; +} + +// --------------------------------------------------------------------------- +// Label plates: white capitals on a black plate need the same black above and +// below them, and left and right (the INDOOR label once had 1 px above and 3 px +// below). Measured on the rendered pixels: the plate is the run of solid rows +// just inside its left edge, the text is whatever is white inside it. +// --------------------------------------------------------------------------- +struct PlateMargins { + bool found = false; + int above = 0, below = 0, left = 0, right = 0; +}; + +static PlateMargins measurePlate(int plateX) { + PlateMargins r; + auto solidRow = [&](int y) { return inkAt(plateX, y) && inkAt(plateX + 1, y) && inkAt(plateX + 2, y); }; + int mid = -1; + for (int y = 120; y < 170 && mid < 0; y++) + if (solidRow(y)) mid = y; + if (mid < 0) return r; + int top = mid, bot = mid, left = plateX, right = plateX; + while (solidRow(top - 1)) top--; + while (solidRow(bot + 1)) bot++; + while (inkAt(left - 1, top)) left--; // the plate's own top row is solid all the way across + while (inkAt(right + 1, top)) right++; + int tTop = -1, tBot = -1, tLeft = 1 << 20, tRight = -1; + for (int y = top; y <= bot; y++) + for (int x = left; x <= right; x++) + if (!inkAt(x, y)) { + if (tTop < 0) tTop = y; + tBot = y; + if (x < tLeft) tLeft = x; + if (x > tRight) tRight = x; + } + if (tTop < 0) return r; + r.found = true; + r.above = tTop - top; + r.below = bot - tBot; + r.left = tLeft - left; + r.right = right - tRight; + return r; +} + +static int checkLabelPlates() { + int bad = 0; + UiModel m = baseModel(); + m.spotify.status = SPOTIFY_IDLE; + u8g2_ClearBuffer(g_u); + uiDraw(g_u, m); + const int plateX = kRightX0 + 8; + PlateMargins p = measurePlate(plateX); + printf("INDOOR label plate: %d px of black above, %d below, %d left, %d right\n", p.above, p.below, p.left, p.right); + if (!p.found || p.above < 2 || p.above != p.below || abs(p.left - p.right) > 1) { + printf(" PROBLEM: the label text is not centred on its plate\n"); + bad++; + } + + // negative control: the previous geometry (plate rows y0-6..y0+5, baseline y0+3) must be caught by the same check + const int x0 = kRightX0, y0 = 144; + u8g2_ClearBuffer(g_u); + u8g2_SetFontMode(g_u, 1); + ink(g_u); + u8g2_SetFont(g_u, F_SMALLB); + u8g2_DrawBox(g_u, x0 + 8, y0 - 6, tw(g_u, "INDOOR") + 8, 12); + paper(g_u); + txt(g_u, x0 + 12, y0 + 3, "INDOOR"); + ink(g_u); + PlateMargins o = measurePlate(plateX); + if (!o.found || o.above == o.below) { + printf(" CHECK IS BLIND: the old label geometry (%d above, %d below) was not flagged\n", o.above, o.below); + bad++; + } else { + printf(" negative control OK: the old geometry measures %d above and %d below\n", o.above, o.below); + } + return bad; +} + +// --------------------------------------------------------------------------- +// Battery gauge: it must not move when the percentage gains or loses a digit, the +// charge icon has to be there, and a long status message must stop short of it. +// --------------------------------------------------------------------------- +static int leftmostInk(int x0, int x1, int y0, int y1) { + for (int x = x0; x < x1; x++) + for (int y = y0; y <= y1; y++) + if (inkAt(x, y)) return x; + return -1; +} + +static int checkBatteryGauge() { + int bad = 0; + UiModel m = baseModel(); + m.spotify.status = SPOTIFY_IDLE; + + int ref = -2; + for (int pct : {100, 99, 87, 50, 10, 9, 0}) { + m.batPercent = pct; + u8g2_ClearBuffer(g_u); + uiDraw(g_u, m); + const int x = leftmostInk(300, 396, 4, 15); // the body's frame + if (ref == -2) ref = x; + if (x != ref) { + printf(" GAUGE MOVED at %d%%: left edge x=%d, expected %d\n", pct, x, ref); + bad++; + } + } + + // What the battery is doing is shown by a bolt (charging) or a tick (full) INSIDE the body: the pixels that + // differ from the same bar on the battery lie inside the frame, there are some, and the gauge itself never + // moves with the state or collides with the status message. + const char *names[] = {"unknown", "discharging", "charging", "full"}; + struct Look { + int pct; + bool low, blinkOn; + }; + const Look looks[] = {{0, false, false}, {17, false, false}, {62, false, false}, {100, false, false}, {17, true, true}, {17, true, false}}; + static bool plain[20][100]; // the right end of the status bar on the battery, without art + int gaugeFrames = 0, artFrames = 0; + auto render = [&](const Look &lk, int st) { + UiModel s = baseModel(); + s.spotify.status = SPOTIFY_IDLE; + s.batPercent = lk.pct; + s.batLow = lk.low; + s.batBlinkOn = lk.blinkOn; + s.batCharge = st; + setStr(s.status, sizeof s.status, "Weather: could not reach api.open-meteo.com, retrying in 60 s ..."); + u8g2_ClearBuffer(g_u); + uiDraw(g_u, s); + return s; + }; + // pixels that differ from `plain`, inside and outside the body's frame + auto differences = [&](int bodyL, int *inside, int *outside) { + *inside = *outside = 0; + for (int y = 0; y < 20; y++) + for (int x = 300; x < 400; x++) + if (inkAt(x, y) != plain[y][x - 300]) { + const bool in = x > bodyL && x < bodyL + kBatBodyW - 1 && y > 4 && y < 4 + kBatBodyH - 1; + (in ? *inside : *outside)++; + } + }; + for (const Look &lk : looks) { + const UiModel ref = render(lk, CHARGE_DISCHARGING); + for (int y = 0; y < 20; y++) + for (int x = 300; x < 400; x++) plain[y][x - 300] = inkAt(x, y); + u8g2_SetFont(g_u, F_BOLD); + const int bodyL = 396 - batteryLayout(g_u, ref).width; + for (int st = CHARGE_UNKNOWN; st <= CHARGE_FULL; st++) { + render(lk, st); + int inside, outside, intrusions = 0; + differences(bodyL, &inside, &outside); + const bool plate = lk.low && lk.blinkOn; // the blink's black plate reaches 4 px left of the gauge + for (int x = bodyL - 8; x < bodyL - (plate ? 4 : 0); x++) + for (int y = 2; y <= 17; y++) intrusions += inkAt(x, y); + // the bolt sits in a window and shows at every level; the tick is cut out of the fill, so it needs a full + // one (a "full" gauge below 100 % may show less of it, or none) + const bool wantsArt = st == CHARGE_CHARGING || (st == CHARGE_FULL && lk.pct == 100); + const bool optionalArt = st == CHARGE_FULL && !wantsArt; + // the body's frame starts the group, in every state (white on the black plate while it blinks) + const bool stays = plate ? (!inkAt(bodyL, 8) && inkAt(bodyL - 1, 8)) : (inkAt(bodyL, 8) && !inkAt(bodyL - 1, 8)); + gaugeFrames++; + artFrames += wantsArt; + if (!(wantsArt ? inside >= 8 : (optionalArt || inside == 0)) || outside || intrusions || !stays) { + printf(" GAUGE PROBLEM (%s, %d%%%s): %d art pixels inside the body, %d outside it, %d message pixels within 8 px, " + "frame %s\n", + names[st], lk.pct, lk.low ? (lk.blinkOn ? ", low, blink on" : ", low, blink off") : "", inside, outside, intrusions, + stays ? "in place" : "MOVED"); + bad++; + } + } + } + printf("battery gauge states: %d frames (%d with a bolt or a tick), the gauge never moves, nothing leaves the body\n", gaugeFrames, artFrames); + { // negative controls: art on the frame, and art that is not there, must both be seen + const Look lk = looks[2]; + render(lk, CHARGE_DISCHARGING); + for (int y = 0; y < 20; y++) + for (int x = 300; x < 400; x++) plain[y][x - 300] = inkAt(x, y); + const UiModel s = render(lk, CHARGE_CHARGING); + u8g2_SetFont(g_u, F_BOLD); + const int bodyL = 396 - batteryLayout(g_u, s).width; + int inside, outside; + paper(g_u); + u8g2_DrawPixel(g_u, bodyL + 10, 4); // a bolt that reached the top of the frame + ink(g_u); + differences(bodyL, &inside, &outside); + const bool trampled = outside > 0; + render(lk, CHARGE_DISCHARGING); // art that is missing + differences(bodyL, &inside, &outside); + const bool missing = inside < 8; + if (!trampled || !missing) { + printf(" CHECK IS BLIND: art on the frame was %s, missing art was %s\n", trampled ? "seen" : "missed", missing ? "seen" : "missed"); + bad++; + } else { + printf(" negative control OK: a pixel off the body's frame and a gauge without its bolt are both seen\n"); + } + } + printf("battery gauge: %d problems\n", bad); + return bad; +} + +// --------------------------------------------------------------------------- +// Weather band. Today's big number starts where the condition text under it does (their +// *ink*, which is not where the glyph cells start: a "1" has 5 blank columns in front of it, a +// "0" has 2); nothing may touch the separators; the moon and rain stacks of the two forecast +// days sit at the same place in both columns. +// --------------------------------------------------------------------------- +static bool anyInk(int x0, int x1, int y0, int y1) { return leftmostInk(x0, x1, y0, y1) >= 0; } + +static void renderModel(const UiModel &m) { + u8g2_ClearBuffer(g_u); + uiDraw(g_u, m); +} + +static int checkWeatherBand() { + int bad = 0; + const int temps[] = {-25, -12, -3, 0, 1, 8, 11, 15, 19, 21, 29, 99}; + const int codes[] = {0, 1, 2, 3, 45, 48, 51, 61, 63, 71, 80, 95, 96, 77}; + const int numRows0 = kWxTop + 7, numRows1 = kWxTop + 30, textRows0 = kWxTop + 36, textRows1 = kWxTop + 46; + int frames = 0, misaligned = 0, oldMisaligned = 0; + for (int t : temps) + for (int c : codes) { + UiModel m = baseModel(); + m.spotify.status = SPOTIFY_IDLE; + m.weather.temp = (float)t; + m.weather.code = (uint8_t)c; + renderModel(m); + // the icon ends at x=47; the text and the number start at 54 or so + const int numL = leftmostInk(50, 75, numRows0, numRows1); + const int textL = leftmostInk(50, 75, textRows0, textRows1); + frames++; + if (numL != textL) { + if (misaligned++ < 3) printf(" TODAY MISALIGNED: %d deg, %s: number ink at x=%d, text ink at x=%d\n", t, wmoText(c), numL, textL); + } + + // negative control: the number drawn from the same x as the text, as before + u8g2_ClearBuffer(g_u); + u8g2_SetFontMode(g_u, 1); + ink(g_u); + char num[8]; + snprintf(num, sizeof num, "%d", t); + u8g2_SetFont(g_u, F_BIG); + txt(g_u, 54, kWxTop + 31, num); + u8g2_SetFont(g_u, F_BODY); + txt(g_u, 54, kWxTop + 47, wmoText(c)); + if (leftmostInk(50, 75, numRows0, numRows1) != leftmostInk(50, 75, textRows0, textRows1)) oldMisaligned++; + } + printf("weather band, today: %d frames, %d with the number off the text's left edge\n", frames, misaligned); + bad += misaligned; + if (oldMisaligned == 0) { + printf(" CHECK IS BLIND: drawing both from the same x was not flagged\n"); + bad++; + } else { + printf(" negative control OK: drawing both from the same x misaligns %d of %d\n", oldMisaligned, frames); + } + + // nothing within 3 px of a separator, whatever the numbers + struct Extreme { + float temp, hi, lo; + uint8_t rain; + bool f; + }; + const Extreme extremes[] = {{-30, -12, -35, 100, false}, {-9, 5, -12, 100, false}, {44, 49, 38, 100, false}, + {100, 110, 95, 100, true}, {-5, 19, -4, 100, true}, {15.3f, 18.8f, 9.4f, 0, false}}; + int touching = 0; + for (const Extreme &e : extremes) { + UiModel m = baseModel(); + m.spotify.status = SPOTIFY_IDLE; + m.weather.temp = e.temp; + m.weather.day[0].tmax = e.hi; + m.weather.day[0].tmin = e.lo; + m.weather.day[0].rainPct = e.rain; + m.weather.day[1].tmax = e.hi; + m.weather.day[1].tmin = e.lo; + m.weather.day[1].rainPct = e.rain; + m.weather.day[2].tmax = e.hi; + m.weather.day[2].tmin = e.lo; + m.weather.day[2].rainPct = e.rain; + m.moonLit[0] = m.moonLit[1] = m.moonLit[2] = 100; + m.useFahrenheit = e.f; + renderModel(m); + const int y0 = kWxTop + 4, y1 = kWxBottom - 4; + const int seps[] = {kColA, kColB, UI_WIDTH}; + for (int s : seps) + if (anyInk(s - 3, s, y0, y1)) { + if (touching++ < 3) printf(" INK WITHIN 3 PX OF A SEPARATOR at x=%d (temp %.0f hi %.0f lo %.0f)\n", s, e.temp, e.hi, e.lo); + } + } + printf("weather band, margins: %d separators touched\n", touching); + bad += touching; + { // negative control: ink put where the check looks must be seen + UiModel m = baseModel(); + renderModel(m); + ink(g_u); + u8g2_DrawBox(g_u, kColA - 3, kWxTop + 10, 2, 2); + if (!anyInk(kColA - 3, kColA, kWxTop + 4, kWxBottom - 4)) { + printf(" CHECK IS BLIND: ink 2 px from a separator was not seen\n"); + bad++; + } else { + printf(" negative control OK: ink 2 px from a separator is seen\n"); + } + } + + // the stack of moon and rain starts at the same x in both forecast days + UiModel m = baseModel(); + m.spotify.status = SPOTIFY_IDLE; + renderModel(m); + const int a = leftmostInk(kColA + 68, kColB - 4, kWxTop + 22, kWxTop + 31); + const int b = leftmostInk(kColB + 68, UI_WIDTH - 4, kWxTop + 22, kWxTop + 31); + const int ra = leftmostInk(kColA + 68, kColB - 4, kWxTop + 36, kWxTop + 44); + const int rb = leftmostInk(kColB + 68, UI_WIDTH - 4, kWxTop + 36, kWxTop + 44); + printf("weather band, stacks: moon at +%d / +%d, rain at +%d / +%d px from the column start\n", a - kColA, b - kColB, + ra - kColA, rb - kColB); + if (a - kColA != b - kColB || ra - kColA != rb - kColB || a < 0 || ra < 0) { + printf(" PROBLEM: the moon and rain stacks are not in the same place in both columns\n"); + bad++; + } + { // negative control: wide temperatures in one column change what the same measurement reads there + UiModel w = baseModel(); + w.spotify.status = SPOTIFY_IDLE; + w.weather.day[1].tmin = -17; + w.weather.day[1].tmax = -12; + renderModel(w); + const int moved = leftmostInk(kColA + 68, kColB - 4, kWxTop + 22, kWxTop + 31) - kColA; + if (moved == b - kColB) { + printf(" CHECK IS BLIND: wide temperatures changed nothing (still +%d)\n", moved); + bad++; + } else { + printf(" negative control OK: with -12 / -17 the same measurement reads +%d instead of +%d\n", moved, b - kColB); + } + } + return bad; +} + +// --------------------------------------------------------------------------- +// The 12 hour clock and the date formats. The colon of "9:05" must not move as the digits +// change (that is what shows when centring goes wrong), the digits and AM / PM stay in the right +// column, and every date, in every format, fits the space under the status bar. +// --------------------------------------------------------------------------- +static int checkClockFormats() { + int bad = 0; + const int cx = (kRightX0 + kRightX1) / 2; + + // geometry as drawTime12() lays it out + u8g2_SetFont(g_u, F_TIME); + const int pitch = digitPitch(g_u), colon = u8g2_GetGlyphWidth(g_u, ':'); + const int bigW = 4 * pitch + colon; + u8g2_SetFont(g_u, F_BIG); + const int secW = 2 * digitPitch(g_u), gap = 8; + const int x0 = cx - (bigW + gap + secW) / 2; + const int colonX = x0 + 2 * pitch; + + std::string ref; + int moved = 0, frames = 0, secBad = 0; + for (int h = 0; h < 24; h++) + for (int mi : {0, 5, 11, 20, 45, 59}) + for (int sc : {0, 1, 9, 10, 31, 59}) { + UiModel m = baseModel(); + m.time12h = true; + m.spotify.status = SPOTIFY_IDLE; + setTime(m, h, mi, sc); + renderModel(m); + std::string cell; + for (int xx = colonX; xx < colonX + colon; xx++) + for (int y = 57; y <= 98; y++) cell += inkAt(xx, y) ? '#' : '.'; + if (ref.empty()) ref = cell; + if (cell != ref && moved++ < 3) printf(" 12 H COLON MOVED at %02d:%02d:%02d\n", h, mi, sc); + // the seconds live in their own column, right of the big digits + if (!anyInk(x0 + bigW + gap, x0 + bigW + gap + secW, 75, 98)) secBad++; + // nothing of the big time reaches into the seconds column + if (anyInk(x0 + bigW, x0 + bigW + gap - 1, 57, 98)) secBad++; + frames++; + } + // negative control: the plain 24 hour layout, centred on U8g2's own width, is seen to move + int oldMoved = 0; + std::string oldRef; + for (int h = 1; h <= 12; h++) { + char buf[8]; + snprintf(buf, sizeof buf, "%d:05", h); + u8g2_ClearBuffer(g_u); + u8g2_SetFontMode(g_u, 1); + ink(g_u); + u8g2_SetFont(g_u, F_TIME); + txtC(g_u, cx, 99, buf); + std::string cell; + for (int xx = colonX; xx < colonX + colon; xx++) + for (int y = 57; y <= 98; y++) cell += inkAt(xx, y) ? '#' : '.'; + if (oldRef.empty()) oldRef = cell; + if (cell != oldRef) oldMoved++; + } + printf("12 hour clock: %d frames, %d with a moving colon, %d with digits out of place\n", frames, moved, secBad); + bad += moved + secBad; + if (oldMoved == 0) { + printf(" CHECK IS BLIND: centring \"9:05\" on its ink width did not move the colon\n"); + bad++; + } else { + printf(" negative control OK: centring on the ink width moves the colon in %d of 12 hours\n", oldMoved); + } + + // every date fits the date line (rows 22..52, x 186..396), in every format + int overflow = 0, dates = 0, widest = 0; + for (int fmt = 0; fmt < DATE_FORMAT_COUNT; fmt++) + for (int mo = 0; mo < 12; mo++) + for (int d : {1, 9, 10, 28, 30}) { + UiModel m = baseModel(); + m.dateFormat = (uint8_t)fmt; + m.local.tm_mon = mo; + m.local.tm_mday = d; + m.spotify.status = SPOTIFY_IDLE; + renderModel(m); + dates++; + const int left = leftmostInk(kRightX0 - 10, kRightX1 + 4, 22, 52); + int right = -1; + for (int x = kRightX1 + 3; x >= kRightX0 - 10; x--) + if (anyInk(x, x + 1, 22, 52)) { + right = x; + break; + } + if (right - left + 1 > widest) widest = right - left + 1; + if (left < kRightX0 || right > kRightX1) { + if (overflow++ < 3) printf(" DATE OUT OF BOUNDS: format %d month %d day %d: ink %d..%d\n", fmt, mo + 1, d, left, right); + } + } + printf("date formats: %d dates, widest %d px of %d, %d out of bounds\n", dates, widest, kRightX1 - kRightX0 + 1, overflow); + bad += overflow; + { // negative control: a date in the big time font is too wide, and the same measurement must say so + u8g2_ClearBuffer(g_u); + u8g2_SetFontMode(g_u, 1); + ink(g_u); + u8g2_SetFont(g_u, F_TIME); + txtPitchC(g_u, cx, 51, "2026-10-04"); + const int left = leftmostInk(kRightX0 - 60, kRightX1 + 4, 22, 52); + if (left >= kRightX0) { + printf(" CHECK IS BLIND: a date 270 px wide did not leave the date line (ink starts at x=%d)\n", left); + bad++; + } else { + printf(" negative control OK: an oversized date starts at x=%d, left of the %d it must not pass\n", left, kRightX0); + } + } + return bad; +} + +// --------------------------------------------------------------------------- +// The other pages: nothing may run off the right or bottom edge (the legend is dense). +// --------------------------------------------------------------------------- +static int checkPageEdges() { + int bad = 0; + // ink in the columns x0..x1 below the status bar, apart from the rows of the full-width rules + auto edgeInk = [&](int x0, int x1) { + for (int y = 22; y <= 296; y++) { + if (y == 196 || y == 199 || y == 223 || y == 256) continue; + if (anyInk(x0, x1, y, y)) return true; + } + return false; + }; + const UiPage pages[] = {PAGE_INFO, PAGE_POWER, PAGE_LEGEND, PAGE_NOW_PLAYING, PAGE_DASHBOARD}; + for (UiPage p : pages) { + UiModel m = baseModel(); + m.page = p; + m.infoCount = UI_INFO_LINES; + for (int i = 0; i < UI_INFO_LINES; i++) { + snprintf(m.info[i], UI_INFO_LINE_LEN, "%-9s %.*s", "Label", UI_INFO_LINE_LEN - 12, + "012345678901234567890123456789012345678901234567890123456789"); + } + renderModel(m); + const bool right = edgeInk(UI_WIDTH - 3, UI_WIDTH), bottom = anyInk(0, UI_WIDTH, 298, 300); // (the progress bar ends on row 297) + const bool left = edgeInk(0, 4); + if (right || bottom || (left && p != PAGE_DASHBOARD)) { + printf(" PAGE %d: ink at the edge (right %d, bottom %d, left %d)\n", (int)p, right, bottom, left); + bad++; + } + } + // the battery-empty screen too + u8g2_ClearBuffer(g_u); + uiDrawBatteryEmpty(g_u, 3.28f, 3.70f, true); + if (anyInk(UI_WIDTH - 3, UI_WIDTH, 0, 299) || anyInk(0, UI_WIDTH, 297, 300) || anyInk(0, 3, 0, 299)) { + printf(" BATTERY-EMPTY SCREEN: ink at the edge\n"); + bad++; + } + { // negative control: a pixel in the last column, and one in the last row, must be seen + UiModel m = baseModel(); + m.page = PAGE_INFO; + renderModel(m); + u8g2_DrawPixel(g_u, UI_WIDTH - 1, 120); + const bool right = edgeInk(UI_WIDTH - 3, UI_WIDTH); + renderModel(m); + u8g2_DrawPixel(g_u, 120, 299); + const bool bottom = anyInk(0, UI_WIDTH, 298, 300); + if (!right || !bottom) { + printf(" CHECK IS BLIND: an edge pixel was not seen (right %d, bottom %d)\n", right, bottom); + bad++; + } else { + printf(" negative control OK: edge pixels are seen\n"); + } + } + printf("page edges: %d problems\n", bad); + return bad; +} + +// --------------------------------------------------------------------------- +// The moon glyph as drawn in the UI: an outline that fills with the lit part, mirrored in the +// south, and nothing outside its 10 x 10 box. +// --------------------------------------------------------------------------- +static int inkIn(int x, int y, int w, int h) { + int n = 0; + for (int yy = y; yy < y + h; yy++) + for (int xx = x; xx < x + w; xx++) n += inkAt(xx, yy); + return n; +} + +static int checkMoonGlyph() { + int bad = 0; + int last = -1; + for (int pct = 0; pct <= 100; pct += 5) { + u8g2_ClearBuffer(g_u); + ink(g_u); + iconMoon(g_u, 50, 50, kMoonD, pct, true, false); + const int n = inkIn(50, 50, kMoonD, kMoonD); + const int outside = inkIn(40, 40, 30, 30) - n; + if (n < last || outside != 0) { + printf(" MOON %d%%: %d ink pixels (previous %d), %d outside the box\n", pct, n, last, outside); + bad++; + } + last = n; + // northern growing moon: lit part on the right; southern: on the left + if (pct == 50) { + const int leftN = inkIn(50, 50, 5, kMoonD), rightN = inkIn(55, 50, 5, kMoonD); + u8g2_ClearBuffer(g_u); + iconMoon(g_u, 50, 50, kMoonD, pct, true, true); + const int leftS = inkIn(50, 50, 5, kMoonD), rightS = inkIn(55, 50, 5, kMoonD); + if (!(rightN > leftN && leftS > rightS && leftS == rightN && rightS == leftN)) { + printf(" MOON: the picture is not mirrored for the south (N %d/%d, S %d/%d)\n", leftN, rightN, leftS, rightS); + bad++; + } + } + } + // new moon: a ring; full moon: a solid disc + u8g2_ClearBuffer(g_u); + iconMoon(g_u, 50, 50, kMoonD, 0, true, false); + const int ring = inkIn(50, 50, kMoonD, kMoonD); + u8g2_ClearBuffer(g_u); + iconMoon(g_u, 50, 50, kMoonD, 100, true, false); + const int disc = inkIn(50, 50, kMoonD, kMoonD); + printf("moon glyph: a new moon is a ring of %d pixels, a full moon a disc of %d, %d problems\n", ring, disc, bad); + if (!(ring > 20 && ring < disc && disc > 60)) bad++; + return bad; +} + +// --------------------------------------------------------------------------- +// Rain glyph. It has to read as a drop and not as text: mirror-symmetric (three slashes are not) and of +// the size ui.cpp says. Wherever it is drawn (today's row, both forecast days, the legend) it must be +// there intact exactly once, end on the same row as the digits beside it, and keep 2 to 5 blank columns +// from them (the moon's own gap is 2, plus the side bearing of the first digit; the forecast days add 1 +// for the centring). The glyph is found by matching the pixels iconRain() itself draws. +// --------------------------------------------------------------------------- +static int lowestInk(int x0, int x1, int y0, int y1) { + for (int y = y1; y >= y0; y--) + for (int x = x0; x < x1; x++) + if (inkAt(x, y)) return y; + return -1; +} + +// size of the ink in the window, and how many of its pixels have no partner mirrored about its vertical axis +static int asymmetry(int x0, int y0, int w, int h, int &bw, int &bh) { + int minX = 1 << 20, maxX = -1, minY = 1 << 20, maxY = -1; + for (int y = y0; y < y0 + h; y++) + for (int x = x0; x < x0 + w; x++) + if (inkAt(x, y)) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + bw = maxX - minX + 1; + bh = maxY - minY + 1; + int odd = 0; + for (int y = minY; y <= maxY; y++) + for (int x = minX; x <= maxX; x++) + if (inkAt(x, y) != inkAt(maxX - (x - minX), y)) odd++; + return odd; +} + +static bool g_drop[kRainH][kRainW]; + +static void captureDrop() { + u8g2_ClearBuffer(g_u); + ink(g_u); + iconRain(g_u, 50, 50); + for (int r = 0; r < kRainH; r++) + for (int c = 0; c < kRainW; c++) g_drop[r][c] = inkAt(50 + c, 50 + r); +} + +// How often the glyph appears intact inside the window (x0 <= x < x1, y0 <= y <= y1), and where first. +static int findDrop(int x0, int x1, int y0, int y1, int &fx, int &fy) { + int n = 0; + for (int y = y0; y + kRainH - 1 <= y1; y++) + for (int x = x0; x + kRainW <= x1; x++) { + bool same = true; + for (int r = 0; r < kRainH && same; r++) + for (int c = 0; c < kRainW; c++) + if (inkAt(x + c, y + r) != g_drop[r][c]) { + same = false; + break; + } + if (same && n++ == 0) { + fx = x; + fy = y; + } + } + return n; +} + +// glyph bottom row minus the bottom row of the text to its right, and the blank columns between them +// (-1 for both when there is no text: the narrowest form shows the glyph alone) +static bool measureDrop(int fx, int fy, int &bottomDelta, int &gap) { + const int textLeft = leftmostInk(fx + kRainW, fx + kRainW + 12, fy, fy + kRainH - 1); + if (textLeft < 0) { + bottomDelta = gap = -1; + return false; + } + bottomDelta = (fy + kRainH - 1) - lowestInk(fx + kRainW + 1, fx + kRainW + 26, fy - 1, fy + kRainH + 2); + gap = textLeft - (fx + kRainW); + return true; +} + +static int checkRainGlyph() { + int bad = 0; + + // the shape on its own + u8g2_ClearBuffer(g_u); + ink(g_u); + iconRain(g_u, 50, 50); + int bw, bh; + const int odd = asymmetry(45, 45, 20, 20, bw, bh); + u8g2_ClearBuffer(g_u); // negative control: the three slanted strokes it replaced + ink(g_u); + for (int i = 0; i < 3; i++) u8g2_DrawLine(g_u, 50 + i * 3, 50 + 8, 50 + i * 3 + 3, 50); + int ow, oh; + const int oldOdd = asymmetry(45, 45, 20, 20, ow, oh); + printf("rain glyph: %d x %d px, %d pixels without a mirror partner\n", bw, bh, odd); + if (odd != 0 || bw != kRainW || bh != kRainH) { + printf(" PROBLEM: the glyph is not a symmetric %d x %d drop\n", kRainW, kRainH); + bad++; + } + if (oldOdd == 0) { + printf(" CHECK IS BLIND: the old slanted strokes passed as symmetric\n"); + bad++; + } else { + printf(" negative control OK: the old strokes have %d pixels without a partner\n", oldOdd); + } + + // in place: today, both forecast days, for short and wide numbers, both units, and the legend + captureDrop(); + int placements = 0, missing = 0, offBaseline = 0, badGap = 0; + auto inspect = [&](const char *where, int n, int fx, int fy) { + placements++; + if (n != 1) { + if (missing++ < 3) printf(" %s: the drop is there %d times (expected once)\n", where, n); + return; + } + int delta, gap; + if (!measureDrop(fx, fy, delta, gap)) return; + if (delta != 0 && offBaseline++ < 3) printf(" %s: the drop ends %d rows below the digits\n", where, delta); + if ((gap < 2 || gap > 5) && badGap++ < 3) printf(" %s: %d blank columns between the drop and its number\n", where, gap); + }; + const float temps[] = {-25, 5, 99}; + const int rains[] = {0, 35, 100}; + for (float t : temps) + for (int rain : rains) + for (int f = 0; f < 2; f++) { + UiModel m = baseModel(); + m.spotify.status = SPOTIFY_IDLE; + m.useFahrenheit = f == 1; + m.weather.temp = t; + for (int d = 0; d < 3; d++) m.weather.day[d].rainPct = (uint8_t)rain; + renderModel(m); + int fx = 0, fy = 0, n; + char tag[48]; + snprintf(tag, sizeof tag, "today (%.0f deg%s, rain %d%%)", t, f ? " F" : "", rain); + n = findDrop(60, kColA - 4, kWxTop + 4, kWxTop + 22, fx, fy); + inspect(tag, n, fx, fy); + snprintf(tag, sizeof tag, "tomorrow (rain %d%%)", rain); + n = findDrop(kColA + 60, kColB - 4, kWxTop + 30, kWxTop + 52, fx, fy); + inspect(tag, n, fx, fy); + snprintf(tag, sizeof tag, "day after (rain %d%%)", rain); + n = findDrop(kColB + 60, UI_WIDTH - 4, kWxTop + 30, kWxTop + 52, fx, fy); + inspect(tag, n, fx, fy); + } + { + UiModel m = baseModel(); + m.page = PAGE_LEGEND; + renderModel(m); + int fx = 0, fy = 0; + const int n = findDrop(0, 60, 80, 106, fx, fy); + inspect("legend", n, fx, fy); + } + printf("rain glyph in place: %d placements, %d missing or doubled, %d off the digits' bottom row, %d badly spaced\n", + placements, missing, offBaseline, badGap); + bad += missing + offBaseline + badGap; + + { // negative controls: one row too low, and a number two pixels too close + int fx = 0, fy = 0, lowDelta = 0, closeGap = 0, unused = 0; + u8g2_ClearBuffer(g_u); + ink(g_u); + u8g2_SetFont(g_u, F_SMALL); + iconRain(g_u, 60, 61); // rows 61..69 + pctText(g_u, 60 + kRainW + 2, 69, 35, 0); // digits on rows 61..68 + const bool lowSeen = findDrop(40, 120, 40, 90, fx, fy) == 1 && measureDrop(fx, fy, lowDelta, unused) && lowDelta == 1; + u8g2_ClearBuffer(g_u); + ink(g_u); + iconRain(g_u, 60, 60); // rows 60..68, the glyph still whole + pctText(g_u, 60 + kRainW, 69, 35, 0); // ... with its number 2 px closer than it is drawn + const bool closeSeen = findDrop(40, 120, 40, 90, fx, fy) == 1 && measureDrop(fx, fy, unused, closeGap) && closeGap < 2; + if (!lowSeen || !closeSeen) { + printf(" CHECK IS BLIND: a drop one row too low was %s, a number 2 px too close was %s\n", lowSeen ? "seen" : "missed", + closeSeen ? "seen" : "missed"); + bad++; + } else { + printf(" negative control OK: a drop one row too low reads +%d, a number 2 px too close leaves %d blank columns\n", lowDelta, + closeGap); + } + } + return bad; +} + +// --------------------------------------------------------------------------- +// Firmware-update screen. The progress bar must fill in proportion (a 2 px frame, a 1 px gap, then +// (width - 6) * percent / 100 pixels), and nothing, with the longest texts the fields hold, may come near +// the edge of the screen or run into the next line. +// --------------------------------------------------------------------------- +static void fwScreen(UiFwKind kind, const char *what, const char *detail, int percent, bool keep) { + UiFwScreen s; + s.kind = kind; + setStr(s.what, sizeof s.what, what); + setStr(s.detail, sizeof s.detail, detail); + s.percent = percent; + s.keepPowered = keep; + u8g2_ClearBuffer(g_u); + uiDrawFirmwareUpdate(g_u, s); +} + +// the run of ink along the middle row of the bar, starting where the fill starts +static int fwFillWidth() { + const int y = kFwBarY + kFwBarH / 2; + int n = 0; + while (kFwBarX + 3 + n < kFwBarX + kFwBarW && inkAt(kFwBarX + 3 + n, y)) n++; + return n; +} + +static int checkFirmwareScreen() { + int bad = 0; + int wrong = 0, last = -1, frames = 0; + for (int p = 0; p <= 100; p += (p < 5 || p > 95 ? 1 : 5)) { + fwScreen(FW_BUSY, "Installing the new firmware", "ESP32-S3-RLCD-Firmware.bin, 1.40 MB", p, true); + const int want = (kFwBarW - 6) * p / 100; + const int got = fwFillWidth(); + frames++; + if (got != want || got < last) { + if (wrong++ < 3) printf(" BAR AT %d%%: filled %d px, expected %d (previous %d)\n", p, got, want, last); + } + last = got; + // the frame is whole: both ends and a gap between it and the fill + const int y = kFwBarY + kFwBarH / 2; + if (!inkAt(kFwBarX, y) || !inkAt(kFwBarX + kFwBarW - 1, y) || inkAt(kFwBarX + 2, y) || inkAt(kFwBarX + kFwBarW - 3, y)) { + if (wrong++ < 3) printf(" BAR AT %d%%: the frame or its gap is wrong\n", p); + } + } + printf("firmware screen, progress bar: %d fills, %d wrong\n", frames, wrong); + bad += wrong; + { // negative control: a bar scaled by the outer width (no room for the frame) overruns the gap at 100 % + u8g2_ClearBuffer(g_u); + ink(g_u); + u8g2_DrawFrame(g_u, kFwBarX, kFwBarY, kFwBarW, kFwBarH); + u8g2_DrawFrame(g_u, kFwBarX + 1, kFwBarY + 1, kFwBarW - 2, kFwBarH - 2); + u8g2_DrawBox(g_u, kFwBarX + 3, kFwBarY + 3, kFwBarW * 100 / 100, kFwBarH - 6); + if (fwFillWidth() == (kFwBarW - 6)) { + printf(" CHECK IS BLIND: a bar drawn too wide measured as right\n"); + bad++; + } else { + printf(" negative control OK: a bar scaled by the outer width reads %d px instead of %d\n", fwFillWidth(), kFwBarW - 6); + } + } + + // the longest texts, every kind of screen: nothing within 3 px of an edge + const std::string longWhat(63, 'W'), longDetail(63, 'W'); + int edge = 0; + for (int kind = FW_BUSY; kind <= FW_PROBLEM; kind++) + for (int keep = 0; keep <= 1; keep++) + for (int pct : {-1, 0, 100}) { + fwScreen((UiFwKind)kind, longWhat.c_str(), longDetail.c_str(), pct, keep); + if (anyInk(UI_WIDTH - 3, UI_WIDTH, 0, 299) || anyInk(0, 3, 0, 299) || anyInk(0, UI_WIDTH, 0, 2) || anyInk(0, UI_WIDTH, 297, 300)) { + if (edge++ < 3) printf(" FIRMWARE SCREEN: ink at the edge (kind %d, keep %d, %d%%)\n", kind, keep, pct); + } + } + // the texts the clock really shows + const char *whats[] = {"Checking the file", "Installing the new firmware", "Restarting", "The file is damaged: copy it again", + "Too big (the .merged.bin? use the .ino.bin)", "No second app slot: pick the 3MB APP scheme", + "This build was installed from the card before", "Battery 3.50 V: plug in USB, then restart", + "Built for another chip (need ESP32-S3)", "Cannot read the file (card error)"}; + for (const char *w : whats) { + fwScreen(FW_PROBLEM, w, "11_Clock_Dashboard.ino.bin, 1.40 MB", -1, false); + if (anyInk(UI_WIDTH - 3, UI_WIDTH, 0, 299) || anyInk(0, 3, 0, 299)) { + if (edge++ < 3) printf(" FIRMWARE SCREEN: \"%s\" touches the edge\n", w); + } + } + printf("firmware screen, edges: %d problems\n", edge); + bad += edge; + { // negative control: ink in the last column is seen + fwScreen(FW_BUSY, "x", "y", 10, true); + u8g2_DrawPixel(g_u, UI_WIDTH - 1, 120); + if (!anyInk(UI_WIDTH - 3, UI_WIDTH, 0, 299)) { + printf(" CHECK IS BLIND: a pixel in the last column was not seen\n"); + bad++; + } else { + printf(" negative control OK: edge pixels are seen\n"); + } + } + return bad; +} + +// --------------------------------------------------------------------------- +// The help for the buttons on the legend page, and the longest banner. KEY's four lines must stop +// short of the BOOT column beside them, and the banner's text must fit its band with room at both ends. +// --------------------------------------------------------------------------- +static int checkButtonHelp() { + int bad = 0; + const int kBootX = 208, kGap = 10, kRow0 = 241, kRow1 = 297; // BOOT's column, the least gap before it, the rows of the four lines + auto keyRightEdge = [&]() { // the last column with ink left of the BOOT column + for (int x = kBootX - 1; x >= 8; x--) + if (anyInk(x, x + 1, kRow0, kRow1)) return x; + return -1; + }; + UiModel m = baseModel(); + m.page = PAGE_LEGEND; + renderModel(m); + const int right = keyRightEdge(); + int lines = 0; + for (int i = 0; i < 4; i++) lines += anyInk(8, 150, 252 + i * 13 - 9, 252 + i * 13) ? 1 : 0; // each of KEY's lines is there + const bool bootThere = anyInk(kBootX, kBootX + 60, kRow0, kRow1); + printf("legend buttons: KEY's lines end at x=%d, %d px short of the BOOT column (%d wanted), %d of 4 lines drawn\n", right, + kBootX - 1 - right, kGap, lines); + if (right < 0 || right > kBootX - 1 - kGap || lines != 4 || !bootThere) { + printf(" LEGEND: KEY's lines run into the BOOT column, or a line is missing\n"); + bad++; + } + { // negative control: a line that is too long is seen to reach the BOOT column + renderModel(m); + u8g2_SetFont(g_u, F_BODY); + ink(g_u); + txt(g_u, 8, 291, "hold: refresh now; hold 5 s: restart the clock"); + const int r = keyRightEdge(); + if (r <= kBootX - 1 - kGap) { + printf(" CHECK IS BLIND: a line of 46 characters did not reach the BOOT column (ends at x=%d)\n", r); + bad++; + } else { + printf(" negative control OK: a line that is too long ends at x=%d, inside the gap\n", r); + } + } + + // The banner: a black band with white text, centred. The columns its text takes up: + auto bannerText = [&](const char *text, int icon, int *left, int *rightEnd) { + UiModel t = baseModel(); + setStr(t.toast, sizeof t.toast, text); + t.toastIcon = icon; + renderModel(t); + *left = *rightEnd = -1; + for (int x = 0; x < UI_WIDTH; x++) { + bool paperHere = false; + for (int y = kBottomTop + 2; y <= UI_HEIGHT - 3 && !paperHere; y++) paperHere = !inkAt(x, y); + if (paperHere) { + if (*left < 0) *left = x; + *rightEnd = x; + } + } + }; + const int kMargin = 12; + int l = -1, r = -1; + bannerText(UI_TOAST_RESTART_HOLD, TOAST_NONE, &l, &r); + printf("restart banner: \"%s\" takes x=%d..%d, margins %d and %d (%d wanted)\n", UI_TOAST_RESTART_HOLD, l, r, l, UI_WIDTH - 1 - r, kMargin); + if (l < kMargin || r > UI_WIDTH - 1 - kMargin || strlen(UI_TOAST_RESTART_HOLD) >= sizeof m.toast) { + printf(" BANNER: the restart text does not fit\n"); + bad++; + } + bannerText(UI_TOAST_RESTARTING, TOAST_NONE, &l, &r); + if (l < kMargin || r > UI_WIDTH - 1 - kMargin) { + printf(" BANNER: \"%s\" does not fit\n", UI_TOAST_RESTARTING); + bad++; + } + // what the clock says about an SD card that will not mount (sd_layout.h), each with the warning mark it is shown with + int widest = 0, sdBad = 0, sdTexts = 0; + for (sdlayout::Kind k : sdlayout::kAllKinds) { + const char *text = sdlayout::text(k); + bannerText(text, TOAST_WARN, &l, &r); + sdTexts++; + if (r - l + 1 > widest) widest = r - l + 1; + if (l < kMargin || r > UI_WIDTH - 1 - kMargin || strlen(text) >= sizeof m.toast) { + printf(" BANNER: \"%s\" does not fit (x=%d..%d)\n", text, l, r); + sdBad++; + } + } + printf("SD card banners: %d texts, the widest takes %d px of %d, %d do not fit\n", sdTexts, widest, UI_WIDTH - 2 * kMargin, sdBad); + bad += sdBad; + // what the clock says about the settings file of a card (settings.h), with counts beyond what a file can hold + { + int cfgTexts = 0, cfgBad = 0, cfgWidest = 0; + char widestText[sizeof m.toast] = ""; + for (int changed : {0, 8888}) + for (int unchanged : {0, 8888}) + for (int problems : {0, 8888}) + for (int carried : {0, 8888}) + for (int flags = 0; flags < 4; flags++) { + ConfigReport rep; + rep.changed = changed; + rep.unchanged = unchanged; + rep.unknown = problems; + char text[sizeof m.toast]; + const bool warn = formatSdBanner(rep, (flags & 1) != 0, carried, (flags & 2) != 0, text, sizeof text); + bannerText(text, warn ? TOAST_WARN : TOAST_NONE, &l, &r); + cfgTexts++; + if (r - l + 1 > cfgWidest) { + cfgWidest = r - l + 1; + setStr(widestText, sizeof widestText, text); + } + if (l < kMargin || r > UI_WIDTH - 1 - kMargin) { + if (cfgBad++ < 3) printf(" BANNER: \"%s\" does not fit (x=%d..%d)\n", text, l, r); + } + } + printf("settings banners: %d texts, the widest (\"%s\") takes %d px of %d, %d do not fit\n", cfgTexts, widestText, cfgWidest, + UI_WIDTH - 2 * kMargin, cfgBad); + bad += cfgBad; + } + { // negative control: the longest text a banner can be given does not fit, and the same measurement says so + const std::string longest(sizeof m.toast - 1, 'W'); + bannerText(longest.c_str(), TOAST_NONE, &l, &r); + if (l >= kMargin && r <= UI_WIDTH - 1 - kMargin) { + printf(" CHECK IS BLIND: %d wide letters measured as fitting (x=%d..%d)\n", (int)longest.size(), l, r); + bad++; + } else { + printf(" negative control OK: %d wide letters take x=%d..%d, past the margins\n", (int)longest.size(), l, r); + } + } + return bad; +} + +// --------------------------------------------------------------------------- +// The outline of the analogue clock: a ring two pixels wide with no pixel missing. It used to be two circles, +// one inside the other, and wherever the two rounded differently a single pixel between them stayed white: +// seen on the panel as holes in the outline. +// --------------------------------------------------------------------------- +static int checkClockRing() { + int bad = 0; + // white pixels well inside the band of the ring, and black ones just outside it + auto count = [&](int *holes, int *spill) { + *holes = *spill = 0; + for (int y = kClockCy - kClockR - 4; y <= kClockCy + kClockR + 4; y++) { + for (int x = kClockCx - kClockR - 4; x <= kClockCx + kClockR + 4; x++) { + const float d = sqrtf((float)((x - kClockCx) * (x - kClockCx) + (y - kClockCy) * (y - kClockCy))); + if (d >= (float)kClockR - 1.2f && d <= (float)kClockR + 0.2f && !inkAt(x, y)) (*holes)++; + if (d >= (float)kClockR + 1.2f && d <= (float)kClockR + 3.0f && inkAt(x, y)) (*spill)++; + } + } + }; + int holes = 0, spill = 0, frames = 0; + for (int hour = 0; hour < 12; hour++) { // (the hands never reach the ring; a few times of day all the same) + UiModel m = baseModel(); + m.local.tm_hour = hour; + m.local.tm_min = hour * 5; + m.local.tm_sec = (hour * 7) % 60; + renderModel(m); + int h, s; + count(&h, &s); + holes += h; + spill += s; + frames++; + } + printf("clock outline: %d frames, %d pixels missing from the ring, %d outside it\n", frames, holes, spill); + if (holes || spill) { + printf(" CLOCK: the outline has holes in it, or ink beside it\n"); + bad++; + } + { // negative control: the two circles it used to be + u8g2_ClearBuffer(g_u); + ink(g_u); + u8g2_DrawCircle(g_u, kClockCx, kClockCy, kClockR, U8G2_DRAW_ALL); + u8g2_DrawCircle(g_u, kClockCx, kClockCy, kClockR - 1, U8G2_DRAW_ALL); + int oldHoles, oldSpill; + count(&oldHoles, &oldSpill); + if (oldHoles == 0) { + printf(" CHECK IS BLIND: drawn as two circles the outline shows no holes\n"); + bad++; + } else { + printf(" negative control OK: drawn as two circles, %d pixels of the ring are missing\n", oldHoles); + } + } + return bad; +} + +// --------------------------------------------------------------------------- +// The turning cubes that both buttons bring up (ui.cpp; infinity_cube.h, sand_cube.h): white on black. Whichever +// way a cube has turned, it has to stay clear of the edges of the screen, and it has to be a picture: neither +// nothing nor a white screen. +// --------------------------------------------------------------------------- +static uint32_t g_cubeSeed = 1; +static void drawCube(bool mirrors, float turnMs, uint32_t elapsedMs, const float *shove = nullptr) { + UiCube c; + c.mirrors = mirrors; + c.seed = g_cubeSeed; + c.turnMs = turnMs; + c.elapsedMs = elapsedMs; + for (int i = 0; i < 3; i++) c.shove[i] = shove ? shove[i] : 0.0f; + uiDrawCube(g_u, c); +} +static void drawMirrorCube(u8g2_t *, uint32_t ms) { drawCube(true, (float)ms, ms); } +static void drawSandCube(u8g2_t *, uint32_t ms) { drawCube(false, (float)ms, ms); } + +static int checkCube(const char *name, void (*drawFn)(u8g2_t *, uint32_t), int leastWanted) { + int bad = 0; + const int kMargin = 8; + auto litOutside = [&]() { // white pixels in the band of kMargin along the four edges + int n = 0; + for (int y = 0; y < UI_HEIGHT; y++) + for (int x = 0; x < UI_WIDTH; x++) { + const bool band = x < kMargin || x >= UI_WIDTH - kMargin || y < kMargin || y >= UI_HEIGHT - kMargin; + if (band && !inkAt(x, y)) n++; + } + return n; + }; + int frames = 0, least = 1 << 30, most = 0, touching = 0, top = UI_HEIGHT, bottom = 0; + for (uint32_t ms = 0; ms < 180000; ms += 131, frames++) { // three minutes of it: every turn of the cube, every stage of the fall + u8g2_ClearBuffer(g_u); + drawFn(g_u, ms); + if (litOutside() > 0) touching++; + int lit = 0; + for (int y = 0; y < UI_HEIGHT; y++) { + bool row = false; + for (int x = 0; x < UI_WIDTH; x++) + if (!inkAt(x, y)) { + lit++; + row = true; + } + if (row && y < top) top = y; + if (row && y > bottom) bottom = y; + } + if (lit < least) least = lit; + if (lit > most) most = lit; + } + printf("%s: %d frames, %d to %d white pixels of %d, rows %d to %d, %d touch the %d px margin\n", name, frames, least, most, + UI_WIDTH * UI_HEIGHT, top, bottom, touching, kMargin); + if (touching || least < leastWanted || most > UI_WIDTH * UI_HEIGHT / 4 || top > 40 || bottom < UI_HEIGHT - 41) { + printf(" CUBE: it leaves the screen, fills it, or is too small to be seen\n"); + bad++; + } + { // negative control: a pixel in the margin is seen, and so is a frame with nothing in it + u8g2_ClearBuffer(g_u); + drawFn(g_u, 0); + u8g2_SetDrawColor(g_u, 0); + u8g2_DrawPixel(g_u, kMargin - 1, UI_HEIGHT / 2); + u8g2_SetDrawColor(g_u, 1); + const int seen = litOutside(); + u8g2_ClearBuffer(g_u); + u8g2_DrawBox(g_u, 0, 0, UI_WIDTH, UI_HEIGHT); + int lit = 0; + for (int y = 0; y < UI_HEIGHT; y++) + for (int x = 0; x < UI_WIDTH; x++) lit += !inkAt(x, y); + if (seen != 1 || lit != 0) { + printf(" CHECK IS BLIND: a white pixel in the margin counted %d, a black screen %d white pixels\n", seen, lit); + bad++; + } else { + printf(" negative control OK: a pixel in the margin is seen, a black screen has none\n"); + } + } + return bad; +} + +int main() { + mkdir("out", 0755); + g_u = hostDisplayInit(); + + // 1. normal dashboard, Spotify playing, running on the battery + UiModel m = baseModel(); + m.batCharge = CHARGE_DISCHARGING; + snapshot("dash_playing", m); + + // 2. nothing playing -> extras strip + m = baseModel(); + m.spotify.status = SPOTIFY_IDLE; + snapshot("dash_idle", m); + + // 3. low battery, both blink phases + m = baseModel(); + m.spotify.status = SPOTIFY_IDLE; + m.batPercent = 17; + m.batVolts = 3.66f; + m.batLow = true; + m.batCharge = CHARGE_DISCHARGING; + m.batBlinkOn = true; + snapshot("dash_lowbat_on", m); + m.batBlinkOn = false; + snapshot("dash_lowbat_off", m); + + // 3b. what the battery is doing: running on it, charging, full; and low while charging (no blink) + m = baseModel(); + m.spotify.status = SPOTIFY_IDLE; + m.batCharge = CHARGE_DISCHARGING; + snapshot("dash_bat_discharging", m); + m.batCharge = CHARGE_CHARGING; + m.batPercent = 62; + snapshot("dash_bat_charging", m); + m.batCharge = CHARGE_FULL; + m.batPercent = 100; + snapshot("dash_bat_full", m); + m.batCharge = CHARGE_CHARGING; + m.batPercent = 17; + m.batVolts = 3.66f; + m.batLow = false; // the sketch holds the warning back while charging + snapshot("dash_lowbat_charging", m); + m.batCharge = CHARGE_DISCHARGING; + m.batLow = true; + m.batBlinkOn = true; + snapshot("dash_lowbat_discharging_on", m); + + // 4. just booted: no time, no weather, no wifi + m = baseModel(); + m.timeValid = false; + m.weather = WeatherData(); + m.wifiUp = false; + m.indoorValid = false; + m.batPresent = false; + m.spotify = SpotifyInfo(); + setStr(m.status, sizeof m.status, "Connecting to WiFi..."); + snapshot("dash_boot", m); + + // 5. long / unicode title + m = baseModel(); + setStr(m.spotify.title, sizeof m.spotify.title, + "Don\xE2\x80\x99t Stop Me Now \xE2\x80\x93 Remastered 2011 (Caf\xC3\xA9 del Mar Extended Mix) \xE5\xA4\x9C\xE3\x81\xAB\xE9\xA7\x86\xE3\x81\x91\xE3\x82\x8B"); + setStr(m.spotify.artist, sizeof m.spotify.artist, "Queen, David Bowie, Freddie Mercury & Friends"); + snapshot("dash_longtitle", m); + + // 6. toast + paused + m = baseModel(); + m.spotify.status = SPOTIFY_PAUSED; + setStr(m.toast, sizeof m.toast, "Next track"); + m.toastIcon = TOAST_NEXT; + snapshot("dash_toast_next", m); + { // the banner while KEY is held for a restart (the longest one) + UiModel hold = m; + setStr(hold.toast, sizeof hold.toast, UI_TOAST_RESTART_HOLD); + hold.toastIcon = TOAST_NONE; + snapshot("dash_toast_restart", hold); + } + + // 7. Fahrenheit, rainy, stale weather + m = baseModel(); + m.useFahrenheit = true; + m.weather.code = 63; + m.weather.ageSec = 4000; + m.weather.day[1].code = 95; + m.weather.day[2].code = 71; + m.spotify.status = SPOTIFY_NEEDS_LINK; + setStr(m.spotify.linkUrl, sizeof m.spotify.linkUrl, "http://192.168.1.50"); + snapshot("dash_fahrenheit", m); + + // 7b. link page not up yet / link about to expire + m = baseModel(); + m.spotify.status = SPOTIFY_NEEDS_LINK; + snapshot("dash_link_wait", m); + m = baseModel(); + m.spotify.status = SPOTIFY_IDLE; + m.spotify.linkDaysLeft = 9; + setStr(m.spotify.linkUrl, sizeof m.spotify.linkUrl, "http://192.168.1.50"); + snapshot("dash_link_renew", m); + + // 8. Now Playing pages + m = baseModel(); + m.page = PAGE_NOW_PLAYING; + snapshot("np_playing", m); + setStr(m.spotify.title, sizeof m.spotify.title, + "Bohemian Rhapsody - Remastered 2011 (Live at Wembley Stadium 1986 Extended Version)"); + setStr(m.spotify.artist, sizeof m.spotify.artist, "Queen"); + m.spotify.status = SPOTIFY_PAUSED; + snapshot("np_long_paused", m); + m.spotify = SpotifyInfo(); + m.spotify.status = SPOTIFY_IDLE; + snapshot("np_idle", m); + + // 9. Info page + m = baseModel(); + m.page = PAGE_INFO; + m.batCharge = CHARGE_DISCHARGING; + const char *lines[] = { + "Firmware RLCD Clock 1.4, 2026-10-07, build 3f9a12c", + "WiFi HomeNet -58 dBm", + "Address 192.168.1.50 rlcd-clock.local", + "Time NTP synced 12 min ago, last step -38 ms", + "Zone Australia/Perth AWST-8", + "Location -31.952, 115.861", + "Weather 2 min ago - Open-Meteo.com", + "Battery 4.12 V 87% discharging -0.1 mV/min", + "Left 3 d 0 h (1.20 %/h over 300 min)", + "Indoor 22.4 C 45% (sensor 26.4 C)", + "Spotify linked, playing (link: 152 d left)", + "Uptime 0d 3h 12m, 187 KB free", + "Frames 11520 sent, 2 late, worst 61 ms", + }; + m.infoCount = 13; + for (int i = 0; i < 13; i++) setStr(m.info[i], UI_INFO_LINE_LEN, lines[i]); + snapshot("info", m); + + // 9b. the other pages + m = baseModel(); + m.page = PAGE_LEGEND; + snapshot("legend", m); + m.page = PAGE_POWER; + m.batCharge = CHARGE_DISCHARGING; + { + const char *power[] = { + // (the same clock as on the Info page: 87 % of a 2500 mAh cell, drawing 30 mA with WiFi always on) + "Power 80 MHz, WiFi saver normal", + "Battery 4.117 V = 87.0 % on the curve", + "Left 3 d 0 h (1.20 %/h over 300 min)", + "Current about 30.0 mA (of 2500 mAh)", + "Shutdown at 3.30 V, back on at 3.70 V", + "Drift +1.8 ppm = +0.16 s/day over 18 h", + "Config SD: 7 changed, 1 unchanged, 2 problems", + " line 5: 'wifi': expected on or off", + " line 9: unknown setting 'colour'", + "Units metric, 24 h, ISO date, week numbers", + }; + m.infoCount = 10; + for (int i = 0; i < 10; i++) setStr(m.info[i], UI_INFO_LINE_LEN, power[i]); + } + snapshot("power", m); + u8g2_ClearBuffer(g_u); + uiDrawBatteryEmpty(g_u, 3.28f, 3.70f, true); + hostDumpPgm("out/battery_empty.pgm"); + printf("rendered out/battery_empty.pgm\n"); + fwScreen(FW_BUSY, "Installing the new firmware", "ESP32-S3-RLCD-Firmware.bin, 1.40 MB", 42, true); + hostDumpPgm("out/fw_installing.pgm"); + fwScreen(FW_PROBLEM, "Too big (the .merged.bin? use the .ino.bin)", "11_Clock_Dashboard.ino.merged.bin, 16.00 MB", -1, false); + hostDumpPgm("out/fw_problem.pgm"); + fwScreen(FW_DONE, "Restarting", "ESP32-S3-RLCD-Firmware.bin, 1.40 MB", 100, false); + hostDumpPgm("out/fw_done.pgm"); + printf("rendered out/fw_*.pgm\n"); + + // 9b2. wide numbers squeeze the weather band: a cold day, a hot day in Fahrenheit + m = baseModel(); + m.spotify.status = SPOTIFY_IDLE; + m.weather.temp = -12.4f; + m.weather.code = 71; + m.weather.day[0].tmax = -8; + m.weather.day[0].tmin = -15; + m.weather.day[0].rainPct = 100; + m.weather.day[1] = {73, -9.0f, -17.0f, 100, 1}; + m.weather.day[2] = {75, -12.0f, -21.0f, 85, 2}; + m.moonLit[0] = 100; + m.moonLit[1] = 99; + snapshot("dash_cold", m); + m.useFahrenheit = true; + m.weather.temp = 40.0f; + m.weather.day[0].tmax = 43; + m.weather.day[0].tmin = 38; + m.weather.day[1] = {1, 41.0f, 36.0f, 100, 1}; + snapshot("dash_hot_f", m); + + // 9c. settings: 12 hour clock, other date formats, WiFi off + m = baseModel(); + m.time12h = true; + setTime(m, 21, 5, 9); + m.dateFormat = DATE_D_MON_Y; + m.showWeek = false; + m.spotify.status = SPOTIFY_IDLE; + snapshot("dash_12h_pm", m); + setTime(m, 0, 7, 59); + m.dateFormat = DATE_MDY; + snapshot("dash_12h_midnight", m); + m = baseModel(); + m.wifiOff = true; + m.weather = WeatherData(); + m.spotify = SpotifyInfo(); + snapshot("dash_wifi_off", m); + m.timeValid = false; + snapshot("dash_wifi_off_no_time", m); + + // 10. clock face gallery: four times at a smaller radius + { + u8g2_ClearBuffer(g_u); + u8g2_SetFontMode(g_u, 1); + ink(g_u); + const int times[4][3] = {{10, 9, 35}, {3, 0, 0}, {12, 0, 0}, {18, 45, 59}}; + for (int i = 0; i < 4; i++) { + UiModel c = baseModel(); + setTime(c, times[i][0], times[i][1], times[i][2]); + c.secFrac = 0; + drawAnalogClock(g_u, 100 + (i % 2) * 200, 75 + (i / 2) * 150, 70, c); + } + hostDumpPgm("out/clocks.pgm"); + printf("rendered out/clocks.pgm\n"); + } + + // 11. weather icon gallery (day), then night + for (int night = 0; night < 2; night++) { + u8g2_ClearBuffer(g_u); + u8g2_SetFontMode(g_u, 1); + ink(g_u); + const WxIcon icons[8] = {WX_CLEAR, WX_PARTLY, WX_CLOUDY, WX_FOG, WX_DRIZZLE, WX_RAIN, WX_SNOW, WX_THUNDER}; + for (int i = 0; i < 8; i++) { + drawWxIcon(g_u, icons[i], !night, 6 + i * 48, 10, 42); + drawWxIcon(g_u, icons[i], !night, 10 + i * 48, 70, 32); + drawWxIcon(g_u, icons[i], !night, 12 + i * 48, 120, 24); + drawWxIcon(g_u, icons[i], !night, 12 + i * 48, 160, 64); + } + hostDumpPgm(night ? "out/icons_night.pgm" : "out/icons_day.pgm"); + printf("rendered icons (%s)\n", night ? "night" : "day"); + } + int bad = checkLayoutStability(); // run_preview.sh fails if any number wobbles... + bad += checkLabelPlates(); // ...or a label is off-centre on its plate... + bad += checkBatteryGauge(); // ...or the battery gauge moves or collides with the status message... + bad += checkWeatherBand(); // ...or the weather band is misaligned or touches its separators... + bad += checkClockFormats(); // ...or the 12 hour clock or a date format misbehaves... + bad += checkPageEdges(); // ...or a page runs off the screen... + bad += checkMoonGlyph(); // ...or the moon is drawn wrongly... + bad += checkRainGlyph(); // ...or the rain drop is not a drop, or not where its number is... + bad += checkFirmwareScreen(); // ...or the firmware-update screen's bar or text is off... + bad += checkButtonHelp(); // ...or the legend's button help or the restart banner does not fit... + bad += checkClockRing(); // ...or the clock's outline has a hole in it... + for (uint32_t seed : {20261008u, 7u}) { // ...or a cube leaves the screen, whichever way it tumbles + g_cubeSeed = seed; + bad += checkCube("mirror cube", drawMirrorCube, 2000); + bad += checkCube("sand cube", drawSandCube, 2000); + } + + // the cube as a strip of frames (to_png.py, or any tool that reads PGM, makes a film of them) + infcube::Shove shove; // (a shove now and then, as KEY gives it) + for (int i = 0; i < 72; i++) { + if (i == 30 || i == 52) shove.bump(0x2545F491u * (uint32_t)i); + shove.advance(85.0f); + u8g2_ClearBuffer(g_u); + drawCube(true, (float)i * 85.0f, (uint32_t)i * 85, shove.angle); + char path[64]; + snprintf(path, sizeof path, "out/cube_%02d.pgm", i); + hostDumpPgm(path); + } + shove = infcube::Shove(); + for (int i = 0; i < 160; i++) { + if (i == 60 || i == 110) shove.bump(0x9E3779B9u * (uint32_t)i); + shove.advance(70.0f); + u8g2_ClearBuffer(g_u); + drawCube(false, (float)i * 70.0f, (uint32_t)i * 70, shove.angle); + char path[64]; + snprintf(path, sizeof path, "out/sand_%03d.pgm", i); + hostDumpPgm(path); + } + printf("rendered out/cube_00.pgm .. cube_71.pgm\n"); + return bad ? 1 : 0; +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/run_fuzz.sh b/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/run_fuzz.sh new file mode 100644 index 00000000..aa5c89b0 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/run_fuzz.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Renders thousands of randomised, adversarial frames under AddressSanitizer and +# UBSan to prove ui.cpp cannot overflow a buffer. Run inside Linux or WSL. +set -euo pipefail +cd "$(dirname "$0")" + +# AddressSanitizer binaries can spin forever at startup on recent Linux/WSL2 +# kernels with high ASLR entropy; run them with ASLR off where that is allowed. +run() { + if setarch "$(uname -m)" -R true 2>/dev/null; then + setarch "$(uname -m)" -R "$@" + else + "$@" + fi +} +SRC="${U8G2_SRC:-../../../../../01_Arduino_Libraries/U8g2/src}" +./build_u8g2.sh "$SRC" > /dev/null +mkdir -p build +g++ -std=c++17 -O1 -g -Wall -Wno-unused-function -Wno-unused-variable \ + -fsanitize=address,undefined -fno-sanitize-recover=undefined \ + -I"$SRC" -I. -I../.. \ + fuzz.cpp build/u8g2/*.o -o build/fuzz -lm +run ./build/fuzz "${1:-3000}" "${2:-0}" diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/run_preview.sh b/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/run_preview.sh new file mode 100644 index 00000000..accf37de --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/run_preview.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Builds and runs the host-side UI preview, writing out/*.pgm. +# Run inside Linux or WSL, then convert with: python to_png.py out/*.pgm +set -euo pipefail +cd "$(dirname "$0")" +SRC="${U8G2_SRC:-../../../../../01_Arduino_Libraries/U8g2/src}" +./build_u8g2.sh "$SRC" > /dev/null +g++ -std=c++17 -O1 -Wall -Wno-unused-function -Wno-unused-variable \ + -I"$SRC" -I. -I../.. \ + preview.cpp build/u8g2/*.o -o build/preview -lm +./build/preview diff --git a/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/to_png.py b/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/to_png.py new file mode 100644 index 00000000..edcf2a59 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tools/ui_preview/to_png.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Convert the 400x300 binary PGM files written by the preview tool to PNG. + + python to_png.py [--scale N] [--crop x0,y0,x1,y1] [--stack out.png] file.pgm [file2.pgm ...] + +Each input becomes .png next to it. --crop keeps only that rectangle of every +input (before scaling); --stack puts all the inputs one above the other in a single +out.png instead (handy for comparing a detail across scenarios). Needs Pillow +(pip install pillow). +""" +import sys +from PIL import Image + + +def read_pgm(path): + with open(path, "rb") as f: + data = f.read() + # header: P5\n \n255\n + parts = data.split(b"\n", 3) + assert parts[0] == b"P5", "not a binary PGM" + w, h = (int(v) for v in parts[1].split()) + return Image.frombytes("L", (w, h), parts[3][: w * h]) + + +def main(): + args = sys.argv[1:] + scale = 2 + crop = None + stack = None + while args and args[0].startswith("--"): + opt = args.pop(0) + if opt == "--scale": + scale = int(args.pop(0)) + elif opt == "--crop": + crop = tuple(int(v) for v in args.pop(0).split(",")) + elif opt == "--stack": + stack = args.pop(0) + else: + sys.exit("unknown option " + opt) + + images = [] + for path in args: + img = read_pgm(path).convert("1") + if crop: + img = img.crop(crop) + images.append((path, img)) + + if stack: + gap = 2 + width = max(i.width for _, i in images) + height = sum(i.height for _, i in images) + gap * (len(images) - 1) + sheet = Image.new("1", (width, height), 1) + y = 0 + for _, img in images: + sheet.paste(img, (0, y)) + y += img.height + gap + if scale != 1: + sheet = sheet.resize((sheet.width * scale, sheet.height * scale), Image.NEAREST) + sheet.save(stack) + print("wrote", stack) + return + + for path, img in images: + if scale != 1: + img = img.resize((img.width * scale, img.height * scale), Image.NEAREST) + out = path.rsplit(".", 1)[0] + ".png" + img.save(out) + print("wrote", out) + + +if __name__ == "__main__": + main() diff --git a/02_Example/Arduino/11_Clock_Dashboard/tz_table.cpp b/02_Example/Arduino/11_Clock_Dashboard/tz_table.cpp new file mode 100644 index 00000000..6dc50ca7 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tz_table.cpp @@ -0,0 +1,649 @@ +// GENERATED FILE - do not edit. Regenerate with tools/gen_tz_table.py +// Source: IANA tz database, footer line of each compiled TZif file. +#include "tz_table.h" + +#include + +namespace { +struct TzEntry { + const char *name; + const char *posix; +}; + +// Sorted by name (strcmp order) for binary search. +const TzEntry kZones[] = { + {"Africa/Abidjan", "GMT0"}, + {"Africa/Accra", "GMT0"}, + {"Africa/Addis_Ababa", "EAT-3"}, + {"Africa/Algiers", "CET-1"}, + {"Africa/Asmara", "EAT-3"}, + {"Africa/Asmera", "EAT-3"}, + {"Africa/Bamako", "GMT0"}, + {"Africa/Bangui", "WAT-1"}, + {"Africa/Banjul", "GMT0"}, + {"Africa/Bissau", "GMT0"}, + {"Africa/Blantyre", "CAT-2"}, + {"Africa/Brazzaville", "WAT-1"}, + {"Africa/Bujumbura", "CAT-2"}, + {"Africa/Cairo", "EET-2EEST,M4.5.5/0,M10.5.4/24"}, + {"Africa/Casablanca", "<+01>-1"}, + {"Africa/Ceuta", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Africa/Conakry", "GMT0"}, + {"Africa/Dakar", "GMT0"}, + {"Africa/Dar_es_Salaam", "EAT-3"}, + {"Africa/Djibouti", "EAT-3"}, + {"Africa/Douala", "WAT-1"}, + {"Africa/El_Aaiun", "<+01>-1"}, + {"Africa/Freetown", "GMT0"}, + {"Africa/Gaborone", "CAT-2"}, + {"Africa/Harare", "CAT-2"}, + {"Africa/Johannesburg", "SAST-2"}, + {"Africa/Juba", "CAT-2"}, + {"Africa/Kampala", "EAT-3"}, + {"Africa/Khartoum", "CAT-2"}, + {"Africa/Kigali", "CAT-2"}, + {"Africa/Kinshasa", "WAT-1"}, + {"Africa/Lagos", "WAT-1"}, + {"Africa/Libreville", "WAT-1"}, + {"Africa/Lome", "GMT0"}, + {"Africa/Luanda", "WAT-1"}, + {"Africa/Lubumbashi", "CAT-2"}, + {"Africa/Lusaka", "CAT-2"}, + {"Africa/Malabo", "WAT-1"}, + {"Africa/Maputo", "CAT-2"}, + {"Africa/Maseru", "SAST-2"}, + {"Africa/Mbabane", "SAST-2"}, + {"Africa/Mogadishu", "EAT-3"}, + {"Africa/Monrovia", "GMT0"}, + {"Africa/Nairobi", "EAT-3"}, + {"Africa/Ndjamena", "WAT-1"}, + {"Africa/Niamey", "WAT-1"}, + {"Africa/Nouakchott", "GMT0"}, + {"Africa/Ouagadougou", "GMT0"}, + {"Africa/Porto-Novo", "WAT-1"}, + {"Africa/Sao_Tome", "GMT0"}, + {"Africa/Timbuktu", "GMT0"}, + {"Africa/Tripoli", "EET-2"}, + {"Africa/Tunis", "CET-1"}, + {"Africa/Windhoek", "CAT-2"}, + {"America/Adak", "HST10HDT,M3.2.0,M11.1.0"}, + {"America/Anchorage", "AKST9AKDT,M3.2.0,M11.1.0"}, + {"America/Anguilla", "AST4"}, + {"America/Antigua", "AST4"}, + {"America/Araguaina", "<-03>3"}, + {"America/Argentina/Buenos_Aires", "<-03>3"}, + {"America/Argentina/Catamarca", "<-03>3"}, + {"America/Argentina/ComodRivadavia", "<-03>3"}, + {"America/Argentina/Cordoba", "<-03>3"}, + {"America/Argentina/Jujuy", "<-03>3"}, + {"America/Argentina/La_Rioja", "<-03>3"}, + {"America/Argentina/Mendoza", "<-03>3"}, + {"America/Argentina/Rio_Gallegos", "<-03>3"}, + {"America/Argentina/Salta", "<-03>3"}, + {"America/Argentina/San_Juan", "<-03>3"}, + {"America/Argentina/San_Luis", "<-03>3"}, + {"America/Argentina/Tucuman", "<-03>3"}, + {"America/Argentina/Ushuaia", "<-03>3"}, + {"America/Aruba", "AST4"}, + {"America/Asuncion", "<-03>3"}, + {"America/Atikokan", "EST5"}, + {"America/Atka", "HST10HDT,M3.2.0,M11.1.0"}, + {"America/Bahia", "<-03>3"}, + {"America/Bahia_Banderas", "CST6"}, + {"America/Barbados", "AST4"}, + {"America/Belem", "<-03>3"}, + {"America/Belize", "CST6"}, + {"America/Blanc-Sablon", "AST4"}, + {"America/Boa_Vista", "<-04>4"}, + {"America/Bogota", "<-05>5"}, + {"America/Boise", "MST7MDT,M3.2.0,M11.1.0"}, + {"America/Buenos_Aires", "<-03>3"}, + {"America/Cambridge_Bay", "MST7MDT,M3.2.0,M11.1.0"}, + {"America/Campo_Grande", "<-04>4"}, + {"America/Cancun", "EST5"}, + {"America/Caracas", "<-04>4"}, + {"America/Catamarca", "<-03>3"}, + {"America/Cayenne", "<-03>3"}, + {"America/Cayman", "EST5"}, + {"America/Chicago", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Chihuahua", "CST6"}, + {"America/Ciudad_Juarez", "MST7MDT,M3.2.0,M11.1.0"}, + {"America/Coral_Harbour", "EST5"}, + {"America/Cordoba", "<-03>3"}, + {"America/Costa_Rica", "CST6"}, + {"America/Coyhaique", "<-03>3"}, + {"America/Creston", "MST7"}, + {"America/Cuiaba", "<-04>4"}, + {"America/Curacao", "AST4"}, + {"America/Danmarkshavn", "GMT0"}, + {"America/Dawson", "MST7"}, + {"America/Dawson_Creek", "MST7"}, + {"America/Denver", "MST7MDT,M3.2.0,M11.1.0"}, + {"America/Detroit", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Dominica", "AST4"}, + {"America/Edmonton", "MST7MDT,M3.2.0,M11.1.0"}, + {"America/Eirunepe", "<-05>5"}, + {"America/El_Salvador", "CST6"}, + {"America/Ensenada", "PST8PDT,M3.2.0,M11.1.0"}, + {"America/Fort_Nelson", "MST7"}, + {"America/Fort_Wayne", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Fortaleza", "<-03>3"}, + {"America/Glace_Bay", "AST4ADT,M3.2.0,M11.1.0"}, + {"America/Godthab", "<-02>2<-01>,M3.5.0/-1,M10.5.0/0"}, + {"America/Goose_Bay", "AST4ADT,M3.2.0,M11.1.0"}, + {"America/Grand_Turk", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Grenada", "AST4"}, + {"America/Guadeloupe", "AST4"}, + {"America/Guatemala", "CST6"}, + {"America/Guayaquil", "<-05>5"}, + {"America/Guyana", "<-04>4"}, + {"America/Halifax", "AST4ADT,M3.2.0,M11.1.0"}, + {"America/Havana", "CST5CDT,M3.2.0/0,M11.1.0/1"}, + {"America/Hermosillo", "MST7"}, + {"America/Indiana/Indianapolis", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Indiana/Knox", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Indiana/Marengo", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Indiana/Petersburg", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Indiana/Tell_City", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Indiana/Vevay", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Indiana/Vincennes", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Indiana/Winamac", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Indianapolis", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Inuvik", "MST7MDT,M3.2.0,M11.1.0"}, + {"America/Iqaluit", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Jamaica", "EST5"}, + {"America/Jujuy", "<-03>3"}, + {"America/Juneau", "AKST9AKDT,M3.2.0,M11.1.0"}, + {"America/Kentucky/Louisville", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Kentucky/Monticello", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Knox_IN", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Kralendijk", "AST4"}, + {"America/La_Paz", "<-04>4"}, + {"America/Lima", "<-05>5"}, + {"America/Los_Angeles", "PST8PDT,M3.2.0,M11.1.0"}, + {"America/Louisville", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Lower_Princes", "AST4"}, + {"America/Maceio", "<-03>3"}, + {"America/Managua", "CST6"}, + {"America/Manaus", "<-04>4"}, + {"America/Marigot", "AST4"}, + {"America/Martinique", "AST4"}, + {"America/Matamoros", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Mazatlan", "MST7"}, + {"America/Mendoza", "<-03>3"}, + {"America/Menominee", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Merida", "CST6"}, + {"America/Metlakatla", "AKST9AKDT,M3.2.0,M11.1.0"}, + {"America/Mexico_City", "CST6"}, + {"America/Miquelon", "<-03>3<-02>,M3.2.0,M11.1.0"}, + {"America/Moncton", "AST4ADT,M3.2.0,M11.1.0"}, + {"America/Monterrey", "CST6"}, + {"America/Montevideo", "<-03>3"}, + {"America/Montreal", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Montserrat", "AST4"}, + {"America/Nassau", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/New_York", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Nipigon", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Nome", "AKST9AKDT,M3.2.0,M11.1.0"}, + {"America/Noronha", "<-02>2"}, + {"America/North_Dakota/Beulah", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/North_Dakota/Center", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/North_Dakota/New_Salem", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Nuuk", "<-02>2<-01>,M3.5.0/-1,M10.5.0/0"}, + {"America/Ojinaga", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Panama", "EST5"}, + {"America/Pangnirtung", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Paramaribo", "<-03>3"}, + {"America/Phoenix", "MST7"}, + {"America/Port-au-Prince", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Port_of_Spain", "AST4"}, + {"America/Porto_Acre", "<-05>5"}, + {"America/Porto_Velho", "<-04>4"}, + {"America/Puerto_Rico", "AST4"}, + {"America/Punta_Arenas", "<-03>3"}, + {"America/Rainy_River", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Rankin_Inlet", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Recife", "<-03>3"}, + {"America/Regina", "CST6"}, + {"America/Resolute", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Rio_Branco", "<-05>5"}, + {"America/Rosario", "<-03>3"}, + {"America/Santa_Isabel", "PST8PDT,M3.2.0,M11.1.0"}, + {"America/Santarem", "<-03>3"}, + {"America/Santiago", "<-04>4<-03>,M9.1.6/24,M4.1.6/24"}, + {"America/Santo_Domingo", "AST4"}, + {"America/Sao_Paulo", "<-03>3"}, + {"America/Scoresbysund", "<-02>2<-01>,M3.5.0/-1,M10.5.0/0"}, + {"America/Shiprock", "MST7MDT,M3.2.0,M11.1.0"}, + {"America/Sitka", "AKST9AKDT,M3.2.0,M11.1.0"}, + {"America/St_Barthelemy", "AST4"}, + {"America/St_Johns", "NST3:30NDT,M3.2.0,M11.1.0"}, + {"America/St_Kitts", "AST4"}, + {"America/St_Lucia", "AST4"}, + {"America/St_Thomas", "AST4"}, + {"America/St_Vincent", "AST4"}, + {"America/Swift_Current", "CST6"}, + {"America/Tegucigalpa", "CST6"}, + {"America/Thule", "AST4ADT,M3.2.0,M11.1.0"}, + {"America/Thunder_Bay", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Tijuana", "PST8PDT,M3.2.0,M11.1.0"}, + {"America/Toronto", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Tortola", "AST4"}, + {"America/Vancouver", "PST8PDT,M3.2.0,M11.1.0"}, + {"America/Virgin", "AST4"}, + {"America/Whitehorse", "MST7"}, + {"America/Winnipeg", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Yakutat", "AKST9AKDT,M3.2.0,M11.1.0"}, + {"America/Yellowknife", "MST7MDT,M3.2.0,M11.1.0"}, + {"Antarctica/Casey", "<+08>-8"}, + {"Antarctica/Davis", "<+07>-7"}, + {"Antarctica/DumontDUrville", "<+10>-10"}, + {"Antarctica/Macquarie", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {"Antarctica/Mawson", "<+05>-5"}, + {"Antarctica/McMurdo", "NZST-12NZDT,M9.5.0,M4.1.0/3"}, + {"Antarctica/Palmer", "<-03>3"}, + {"Antarctica/Rothera", "<-03>3"}, + {"Antarctica/South_Pole", "NZST-12NZDT,M9.5.0,M4.1.0/3"}, + {"Antarctica/Syowa", "<+03>-3"}, + {"Antarctica/Troll", "<+00>0<+02>-2,M3.5.0/1,M10.5.0/3"}, + {"Antarctica/Vostok", "<+05>-5"}, + {"Arctic/Longyearbyen", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Asia/Aden", "<+03>-3"}, + {"Asia/Almaty", "<+05>-5"}, + {"Asia/Amman", "<+03>-3"}, + {"Asia/Anadyr", "<+12>-12"}, + {"Asia/Aqtau", "<+05>-5"}, + {"Asia/Aqtobe", "<+05>-5"}, + {"Asia/Ashgabat", "<+05>-5"}, + {"Asia/Ashkhabad", "<+05>-5"}, + {"Asia/Atyrau", "<+05>-5"}, + {"Asia/Baghdad", "<+03>-3"}, + {"Asia/Bahrain", "<+03>-3"}, + {"Asia/Baku", "<+04>-4"}, + {"Asia/Bangkok", "<+07>-7"}, + {"Asia/Barnaul", "<+07>-7"}, + {"Asia/Beirut", "EET-2EEST,M3.5.0/0,M10.5.0/0"}, + {"Asia/Bishkek", "<+06>-6"}, + {"Asia/Brunei", "<+08>-8"}, + {"Asia/Calcutta", "IST-5:30"}, + {"Asia/Chita", "<+09>-9"}, + {"Asia/Choibalsan", "<+08>-8"}, + {"Asia/Chongqing", "CST-8"}, + {"Asia/Chungking", "CST-8"}, + {"Asia/Colombo", "<+0530>-5:30"}, + {"Asia/Dacca", "<+06>-6"}, + {"Asia/Damascus", "<+03>-3"}, + {"Asia/Dhaka", "<+06>-6"}, + {"Asia/Dili", "<+09>-9"}, + {"Asia/Dubai", "<+04>-4"}, + {"Asia/Dushanbe", "<+05>-5"}, + {"Asia/Famagusta", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Asia/Gaza", "EET-2EEST,M3.4.4/50,M10.4.4/50"}, + {"Asia/Harbin", "CST-8"}, + {"Asia/Hebron", "EET-2EEST,M3.4.4/50,M10.4.4/50"}, + {"Asia/Ho_Chi_Minh", "<+07>-7"}, + {"Asia/Hong_Kong", "HKT-8"}, + {"Asia/Hovd", "<+07>-7"}, + {"Asia/Irkutsk", "<+08>-8"}, + {"Asia/Istanbul", "<+03>-3"}, + {"Asia/Jakarta", "WIB-7"}, + {"Asia/Jayapura", "WIT-9"}, + {"Asia/Jerusalem", "IST-2IDT,M3.4.4/26,M10.5.0"}, + {"Asia/Kabul", "<+0430>-4:30"}, + {"Asia/Kamchatka", "<+12>-12"}, + {"Asia/Karachi", "PKT-5"}, + {"Asia/Kashgar", "<+06>-6"}, + {"Asia/Kathmandu", "<+0545>-5:45"}, + {"Asia/Katmandu", "<+0545>-5:45"}, + {"Asia/Khandyga", "<+09>-9"}, + {"Asia/Kolkata", "IST-5:30"}, + {"Asia/Krasnoyarsk", "<+07>-7"}, + {"Asia/Kuala_Lumpur", "<+08>-8"}, + {"Asia/Kuching", "<+08>-8"}, + {"Asia/Kuwait", "<+03>-3"}, + {"Asia/Macao", "CST-8"}, + {"Asia/Macau", "CST-8"}, + {"Asia/Magadan", "<+11>-11"}, + {"Asia/Makassar", "WITA-8"}, + {"Asia/Manila", "PST-8"}, + {"Asia/Muscat", "<+04>-4"}, + {"Asia/Nicosia", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Asia/Novokuznetsk", "<+07>-7"}, + {"Asia/Novosibirsk", "<+07>-7"}, + {"Asia/Omsk", "<+06>-6"}, + {"Asia/Oral", "<+05>-5"}, + {"Asia/Phnom_Penh", "<+07>-7"}, + {"Asia/Pontianak", "WIB-7"}, + {"Asia/Pyongyang", "KST-9"}, + {"Asia/Qatar", "<+03>-3"}, + {"Asia/Qostanay", "<+05>-5"}, + {"Asia/Qyzylorda", "<+05>-5"}, + {"Asia/Rangoon", "<+0630>-6:30"}, + {"Asia/Riyadh", "<+03>-3"}, + {"Asia/Saigon", "<+07>-7"}, + {"Asia/Sakhalin", "<+11>-11"}, + {"Asia/Samarkand", "<+05>-5"}, + {"Asia/Seoul", "KST-9"}, + {"Asia/Shanghai", "CST-8"}, + {"Asia/Singapore", "<+08>-8"}, + {"Asia/Srednekolymsk", "<+11>-11"}, + {"Asia/Taipei", "CST-8"}, + {"Asia/Tashkent", "<+05>-5"}, + {"Asia/Tbilisi", "<+04>-4"}, + {"Asia/Tehran", "<+0330>-3:30"}, + {"Asia/Tel_Aviv", "IST-2IDT,M3.4.4/26,M10.5.0"}, + {"Asia/Thimbu", "<+06>-6"}, + {"Asia/Thimphu", "<+06>-6"}, + {"Asia/Tokyo", "JST-9"}, + {"Asia/Tomsk", "<+07>-7"}, + {"Asia/Ujung_Pandang", "WITA-8"}, + {"Asia/Ulaanbaatar", "<+08>-8"}, + {"Asia/Ulan_Bator", "<+08>-8"}, + {"Asia/Urumqi", "<+06>-6"}, + {"Asia/Ust-Nera", "<+10>-10"}, + {"Asia/Vientiane", "<+07>-7"}, + {"Asia/Vladivostok", "<+10>-10"}, + {"Asia/Yakutsk", "<+09>-9"}, + {"Asia/Yangon", "<+0630>-6:30"}, + {"Asia/Yekaterinburg", "<+05>-5"}, + {"Asia/Yerevan", "<+04>-4"}, + {"Atlantic/Azores", "<-01>1<+00>,M3.5.0/0,M10.5.0/1"}, + {"Atlantic/Bermuda", "AST4ADT,M3.2.0,M11.1.0"}, + {"Atlantic/Canary", "WET0WEST,M3.5.0/1,M10.5.0"}, + {"Atlantic/Cape_Verde", "<-01>1"}, + {"Atlantic/Faeroe", "WET0WEST,M3.5.0/1,M10.5.0"}, + {"Atlantic/Faroe", "WET0WEST,M3.5.0/1,M10.5.0"}, + {"Atlantic/Jan_Mayen", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Atlantic/Madeira", "WET0WEST,M3.5.0/1,M10.5.0"}, + {"Atlantic/Reykjavik", "GMT0"}, + {"Atlantic/South_Georgia", "<-02>2"}, + {"Atlantic/St_Helena", "GMT0"}, + {"Atlantic/Stanley", "<-03>3"}, + {"Australia/ACT", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {"Australia/Adelaide", "ACST-9:30ACDT,M10.1.0,M4.1.0/3"}, + {"Australia/Brisbane", "AEST-10"}, + {"Australia/Broken_Hill", "ACST-9:30ACDT,M10.1.0,M4.1.0/3"}, + {"Australia/Canberra", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {"Australia/Currie", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {"Australia/Darwin", "ACST-9:30"}, + {"Australia/Eucla", "<+0845>-8:45"}, + {"Australia/Hobart", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {"Australia/LHI", "<+1030>-10:30<+11>-11,M10.1.0,M4.1.0"}, + {"Australia/Lindeman", "AEST-10"}, + {"Australia/Lord_Howe", "<+1030>-10:30<+11>-11,M10.1.0,M4.1.0"}, + {"Australia/Melbourne", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {"Australia/NSW", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {"Australia/North", "ACST-9:30"}, + {"Australia/Perth", "AWST-8"}, + {"Australia/Queensland", "AEST-10"}, + {"Australia/South", "ACST-9:30ACDT,M10.1.0,M4.1.0/3"}, + {"Australia/Sydney", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {"Australia/Tasmania", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {"Australia/Victoria", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {"Australia/West", "AWST-8"}, + {"Australia/Yancowinna", "ACST-9:30ACDT,M10.1.0,M4.1.0/3"}, + {"Brazil/Acre", "<-05>5"}, + {"Brazil/DeNoronha", "<-02>2"}, + {"Brazil/East", "<-03>3"}, + {"Brazil/West", "<-04>4"}, + {"CET", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"CST6CDT", "CST6CDT,M3.2.0,M11.1.0"}, + {"Canada/Atlantic", "AST4ADT,M3.2.0,M11.1.0"}, + {"Canada/Central", "CST6CDT,M3.2.0,M11.1.0"}, + {"Canada/Eastern", "EST5EDT,M3.2.0,M11.1.0"}, + {"Canada/Mountain", "MST7MDT,M3.2.0,M11.1.0"}, + {"Canada/Newfoundland", "NST3:30NDT,M3.2.0,M11.1.0"}, + {"Canada/Pacific", "PST8PDT,M3.2.0,M11.1.0"}, + {"Canada/Saskatchewan", "CST6"}, + {"Canada/Yukon", "MST7"}, + {"Chile/Continental", "<-04>4<-03>,M9.1.6/24,M4.1.6/24"}, + {"Chile/EasterIsland", "<-06>6<-05>,M9.1.6/22,M4.1.6/22"}, + {"Cuba", "CST5CDT,M3.2.0/0,M11.1.0/1"}, + {"EET", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"EST", "EST5"}, + {"EST5EDT", "EST5EDT,M3.2.0,M11.1.0"}, + {"Egypt", "EET-2EEST,M4.5.5/0,M10.5.4/24"}, + {"Eire", "IST-1GMT0,M10.5.0,M3.5.0/1"}, + {"Etc/GMT", "GMT0"}, + {"Etc/GMT+0", "GMT0"}, + {"Etc/GMT+1", "<-01>1"}, + {"Etc/GMT+10", "<-10>10"}, + {"Etc/GMT+11", "<-11>11"}, + {"Etc/GMT+12", "<-12>12"}, + {"Etc/GMT+2", "<-02>2"}, + {"Etc/GMT+3", "<-03>3"}, + {"Etc/GMT+4", "<-04>4"}, + {"Etc/GMT+5", "<-05>5"}, + {"Etc/GMT+6", "<-06>6"}, + {"Etc/GMT+7", "<-07>7"}, + {"Etc/GMT+8", "<-08>8"}, + {"Etc/GMT+9", "<-09>9"}, + {"Etc/GMT-0", "GMT0"}, + {"Etc/GMT-1", "<+01>-1"}, + {"Etc/GMT-10", "<+10>-10"}, + {"Etc/GMT-11", "<+11>-11"}, + {"Etc/GMT-12", "<+12>-12"}, + {"Etc/GMT-13", "<+13>-13"}, + {"Etc/GMT-14", "<+14>-14"}, + {"Etc/GMT-2", "<+02>-2"}, + {"Etc/GMT-3", "<+03>-3"}, + {"Etc/GMT-4", "<+04>-4"}, + {"Etc/GMT-5", "<+05>-5"}, + {"Etc/GMT-6", "<+06>-6"}, + {"Etc/GMT-7", "<+07>-7"}, + {"Etc/GMT-8", "<+08>-8"}, + {"Etc/GMT-9", "<+09>-9"}, + {"Etc/GMT0", "GMT0"}, + {"Etc/Greenwich", "GMT0"}, + {"Etc/UCT", "UTC0"}, + {"Etc/UTC", "UTC0"}, + {"Etc/Universal", "UTC0"}, + {"Etc/Zulu", "UTC0"}, + {"Europe/Amsterdam", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Andorra", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Astrakhan", "<+04>-4"}, + {"Europe/Athens", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Belfast", "GMT0BST,M3.5.0/1,M10.5.0"}, + {"Europe/Belgrade", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Berlin", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Bratislava", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Brussels", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Bucharest", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Budapest", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Busingen", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Chisinau", "EET-2EEST,M3.5.0,M10.5.0/3"}, + {"Europe/Copenhagen", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Dublin", "IST-1GMT0,M10.5.0,M3.5.0/1"}, + {"Europe/Gibraltar", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Guernsey", "GMT0BST,M3.5.0/1,M10.5.0"}, + {"Europe/Helsinki", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Isle_of_Man", "GMT0BST,M3.5.0/1,M10.5.0"}, + {"Europe/Istanbul", "<+03>-3"}, + {"Europe/Jersey", "GMT0BST,M3.5.0/1,M10.5.0"}, + {"Europe/Kaliningrad", "EET-2"}, + {"Europe/Kiev", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Kirov", "MSK-3"}, + {"Europe/Kyiv", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Lisbon", "WET0WEST,M3.5.0/1,M10.5.0"}, + {"Europe/Ljubljana", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/London", "GMT0BST,M3.5.0/1,M10.5.0"}, + {"Europe/Luxembourg", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Madrid", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Malta", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Mariehamn", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Minsk", "<+03>-3"}, + {"Europe/Monaco", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Moscow", "MSK-3"}, + {"Europe/Nicosia", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Oslo", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Paris", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Podgorica", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Prague", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Riga", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Rome", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Samara", "<+04>-4"}, + {"Europe/San_Marino", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Sarajevo", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Saratov", "<+04>-4"}, + {"Europe/Simferopol", "MSK-3"}, + {"Europe/Skopje", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Sofia", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Stockholm", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Tallinn", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Tirane", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Tiraspol", "EET-2EEST,M3.5.0,M10.5.0/3"}, + {"Europe/Ulyanovsk", "<+04>-4"}, + {"Europe/Uzhgorod", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Vaduz", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Vatican", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Vienna", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Vilnius", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Volgograd", "MSK-3"}, + {"Europe/Warsaw", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Zagreb", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Zaporozhye", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Zurich", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"GB", "GMT0BST,M3.5.0/1,M10.5.0"}, + {"GB-Eire", "GMT0BST,M3.5.0/1,M10.5.0"}, + {"GMT", "GMT0"}, + {"GMT+0", "GMT0"}, + {"GMT-0", "GMT0"}, + {"GMT0", "GMT0"}, + {"Greenwich", "GMT0"}, + {"HST", "HST10"}, + {"Hongkong", "HKT-8"}, + {"Iceland", "GMT0"}, + {"Indian/Antananarivo", "EAT-3"}, + {"Indian/Chagos", "<+06>-6"}, + {"Indian/Christmas", "<+07>-7"}, + {"Indian/Cocos", "<+0630>-6:30"}, + {"Indian/Comoro", "EAT-3"}, + {"Indian/Kerguelen", "<+05>-5"}, + {"Indian/Mahe", "<+04>-4"}, + {"Indian/Maldives", "<+05>-5"}, + {"Indian/Mauritius", "<+04>-4"}, + {"Indian/Mayotte", "EAT-3"}, + {"Indian/Reunion", "<+04>-4"}, + {"Iran", "<+0330>-3:30"}, + {"Israel", "IST-2IDT,M3.4.4/26,M10.5.0"}, + {"Jamaica", "EST5"}, + {"Japan", "JST-9"}, + {"Kwajalein", "<+12>-12"}, + {"Libya", "EET-2"}, + {"MET", "MET-1MEST,M3.5.0,M10.5.0/3"}, + {"MST", "MST7"}, + {"MST7MDT", "MST7MDT,M3.2.0,M11.1.0"}, + {"Mexico/BajaNorte", "PST8PDT,M3.2.0,M11.1.0"}, + {"Mexico/BajaSur", "MST7"}, + {"Mexico/General", "CST6"}, + {"NZ", "NZST-12NZDT,M9.5.0,M4.1.0/3"}, + {"NZ-CHAT", "<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45"}, + {"Navajo", "MST7MDT,M3.2.0,M11.1.0"}, + {"PRC", "CST-8"}, + {"PST8PDT", "PST8PDT,M3.2.0,M11.1.0"}, + {"Pacific/Apia", "<+13>-13"}, + {"Pacific/Auckland", "NZST-12NZDT,M9.5.0,M4.1.0/3"}, + {"Pacific/Bougainville", "<+11>-11"}, + {"Pacific/Chatham", "<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45"}, + {"Pacific/Chuuk", "<+10>-10"}, + {"Pacific/Easter", "<-06>6<-05>,M9.1.6/22,M4.1.6/22"}, + {"Pacific/Efate", "<+11>-11"}, + {"Pacific/Enderbury", "<+13>-13"}, + {"Pacific/Fakaofo", "<+13>-13"}, + {"Pacific/Fiji", "<+12>-12"}, + {"Pacific/Funafuti", "<+12>-12"}, + {"Pacific/Galapagos", "<-06>6"}, + {"Pacific/Gambier", "<-09>9"}, + {"Pacific/Guadalcanal", "<+11>-11"}, + {"Pacific/Guam", "ChST-10"}, + {"Pacific/Honolulu", "HST10"}, + {"Pacific/Johnston", "HST10"}, + {"Pacific/Kanton", "<+13>-13"}, + {"Pacific/Kiritimati", "<+14>-14"}, + {"Pacific/Kosrae", "<+11>-11"}, + {"Pacific/Kwajalein", "<+12>-12"}, + {"Pacific/Majuro", "<+12>-12"}, + {"Pacific/Marquesas", "<-0930>9:30"}, + {"Pacific/Midway", "SST11"}, + {"Pacific/Nauru", "<+12>-12"}, + {"Pacific/Niue", "<-11>11"}, + {"Pacific/Norfolk", "<+11>-11<+12>,M10.1.0,M4.1.0/3"}, + {"Pacific/Noumea", "<+11>-11"}, + {"Pacific/Pago_Pago", "SST11"}, + {"Pacific/Palau", "<+09>-9"}, + {"Pacific/Pitcairn", "<-08>8"}, + {"Pacific/Pohnpei", "<+11>-11"}, + {"Pacific/Ponape", "<+11>-11"}, + {"Pacific/Port_Moresby", "<+10>-10"}, + {"Pacific/Rarotonga", "<-10>10"}, + {"Pacific/Saipan", "ChST-10"}, + {"Pacific/Samoa", "SST11"}, + {"Pacific/Tahiti", "<-10>10"}, + {"Pacific/Tarawa", "<+12>-12"}, + {"Pacific/Tongatapu", "<+13>-13"}, + {"Pacific/Truk", "<+10>-10"}, + {"Pacific/Wake", "<+12>-12"}, + {"Pacific/Wallis", "<+12>-12"}, + {"Pacific/Yap", "<+10>-10"}, + {"Poland", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Portugal", "WET0WEST,M3.5.0/1,M10.5.0"}, + {"ROC", "CST-8"}, + {"ROK", "KST-9"}, + {"Singapore", "<+08>-8"}, + {"Turkey", "<+03>-3"}, + {"UCT", "UTC0"}, + {"US/Alaska", "AKST9AKDT,M3.2.0,M11.1.0"}, + {"US/Aleutian", "HST10HDT,M3.2.0,M11.1.0"}, + {"US/Arizona", "MST7"}, + {"US/Central", "CST6CDT,M3.2.0,M11.1.0"}, + {"US/East-Indiana", "EST5EDT,M3.2.0,M11.1.0"}, + {"US/Eastern", "EST5EDT,M3.2.0,M11.1.0"}, + {"US/Hawaii", "HST10"}, + {"US/Indiana-Starke", "CST6CDT,M3.2.0,M11.1.0"}, + {"US/Michigan", "EST5EDT,M3.2.0,M11.1.0"}, + {"US/Mountain", "MST7MDT,M3.2.0,M11.1.0"}, + {"US/Pacific", "PST8PDT,M3.2.0,M11.1.0"}, + {"US/Samoa", "SST11"}, + {"UTC", "UTC0"}, + {"Universal", "UTC0"}, + {"W-SU", "MSK-3"}, + {"WET", "WET0WEST,M3.5.0/1,M10.5.0"}, + {"Zulu", "UTC0"}, +}; +} // namespace + +const char *tzPosixForIana(const char *iana) { + if (!iana || !*iana) return nullptr; + size_t lo = 0, hi = sizeof(kZones) / sizeof(kZones[0]); + while (lo < hi) { + size_t mid = (lo + hi) / 2; + int c = strcmp(iana, kZones[mid].name); + if (c == 0) return kZones[mid].posix; + if (c < 0) hi = mid; else lo = mid + 1; + } + return nullptr; +} + +const char *tzFindIanaIgnoreCase(const char *name, char *canonOut, size_t cap) { + if (!name || !*name) return nullptr; + for (size_t i = 0; i < sizeof(kZones) / sizeof(kZones[0]); i++) { + const char *a = name, *b = kZones[i].name; + while (*a && *b) { + char ca = *a, cb = *b; + if (ca >= 'A' && ca <= 'Z') ca = (char)(ca + 32); + if (cb >= 'A' && cb <= 'Z') cb = (char)(cb + 32); + if (ca == ' ') ca = '_'; + if (ca != cb) break; + ++a; + ++b; + } + if (!*a && !*b) { + if (canonOut && cap) { + strncpy(canonOut, kZones[i].name, cap - 1); + canonOut[cap - 1] = 0; + } + return kZones[i].posix; + } + } + return nullptr; +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/tz_table.h b/02_Example/Arduino/11_Clock_Dashboard/tz_table.h new file mode 100644 index 00000000..050090e0 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/tz_table.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +// IANA time zone name -> POSIX TZ string (for tzset()). +// tzPosixForIana("Australia/Perth") -> "AWST-8" +// tzPosixForIana("Europe/London") -> "GMT0BST,M3.5.0/1,M10.5.0" +// Returns nullptr for unknown names. The table lives in tz_table.cpp, which is +// generated by tools/gen_tz_table.py. +const char *tzPosixForIana(const char *iana); + +// The same lookup for a name typed by hand: case does not matter and a space counts as +// an underscore ("australia/perth", "America/New York"). Copies the canonical spelling +// to canonOut (if given) and returns the POSIX rule, or nullptr for an unknown name. +// Scans the whole table, so use it for configuration, not in a loop. +const char *tzFindIanaIgnoreCase(const char *name, char *canonOut, size_t cap); diff --git a/02_Example/Arduino/11_Clock_Dashboard/ui.cpp b/02_Example/Arduino/11_Clock_Dashboard/ui.cpp new file mode 100644 index 00000000..57f4858d --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/ui.cpp @@ -0,0 +1,1580 @@ +#include "ui.h" + +#include +#include +#include +#include + +#include "datefmt.h" +#include "infinity_cube.h" +#include "sand_cube.h" +#include "moon.h" +#include "timeutil.h" +#include "weather_codes.h" + +#define DEG "\xC2\xB0" // UTF-8 degree sign + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +// --------------------------------------------------------------------------- +// Fonts +// --------------------------------------------------------------------------- +#define F_TIME u8g2_font_logisoso42_tn +#define F_DATE u8g2_font_logisoso26_tr // letters too, for "4 Oct 2026"; the digits are the same as _tn's +#define F_BIG u8g2_font_logisoso24_tr +#define F_H1 u8g2_font_helvB14_tf +#define F_H2 u8g2_font_helvB12_tf +#define F_BOLD u8g2_font_helvB10_tf +#define F_BODY u8g2_font_helvR10_tf +#define F_SMALL u8g2_font_helvR08_tf +#define F_SMALLB u8g2_font_helvB08_tf +#define F_MONO u8g2_font_profont12_tf + +namespace { + +// --------------------------------------------------------------------------- +// Layout (all coordinates are logical pixels, origin top-left) +// --------------------------------------------------------------------------- +const int kBarH = 20; // status bar; separator on its last row +const int kClockCx = 92; +const int kClockCy = 108; +const int kClockR = 80; +const int kRightX0 = 186; // right-hand column of the dashboard +const int kRightX1 = 396; +const int kWxTop = 199; // weather band top (separator row) +const int kWxBottom = 255; +const int kBottomTop = 256; // spotify / extras strip (to the bottom edge) +const int kColA = 176; // weather column boundaries: today, then two days of 112 px +const int kColB = 288; + +// --------------------------------------------------------------------------- +// Tiny helpers +// --------------------------------------------------------------------------- +inline int iround(float v) { return (int)lroundf(v); } +inline void ink(u8g2_t *u) { u8g2_SetDrawColor(u, 1); } +inline void paper(u8g2_t *u) { u8g2_SetDrawColor(u, 0); } + +inline int tw(u8g2_t *u, const char *s) { return (int)u8g2_GetUTF8Width(u, s); } +inline void txt(u8g2_t *u, int x, int y, const char *s) { u8g2_DrawUTF8(u, x, y, s); } +inline void txtR(u8g2_t *u, int xr, int y, const char *s) { txt(u, xr - tw(u, s), y, s); } +inline void txtC(u8g2_t *u, int xc, int y, const char *s) { txt(u, xc - tw(u, s) / 2, y, s); } + +inline int tempOf(float c, bool fahrenheit) { + return iround(fahrenheit ? c * 9.0f / 5.0f + 32.0f : c); +} + +// --------------------------------------------------------------------------- +// Numbers that change must not wobble. U8g2 measures a multi-glyph string by its +// *ink* ("balanced" width: the last glyph's ink width plus the first glyph's left +// bearing), so centring or right-aligning on that moves the whole string by a few +// pixels depending on which digits it starts and ends with (a "1" is narrower than +// a "0"). These helpers lay the glyphs out on their advances instead, giving every +// digit the cell of the widest digit, so a given string length always occupies the +// same pixels. ASCII only: digits, ':' '-' '.' '/' ' ' and the like. +// --------------------------------------------------------------------------- +int digitPitch(u8g2_t *u) { + int w = 0; + for (int c = '0'; c <= '9'; c++) { + int a = u8g2_GetGlyphWidth(u, (uint16_t)c); + if (a > w) w = a; + } + return w; +} + +int pitchWidth(u8g2_t *u, const char *s) { + const int pitch = digitPitch(u); + int w = 0; + for (; *s; ++s) { + unsigned char c = (unsigned char)*s; + w += (c >= '0' && c <= '9') ? pitch : u8g2_GetGlyphWidth(u, c); + } + return w; +} + +void txtPitch(u8g2_t *u, int x, int y, const char *s) { + const int pitch = digitPitch(u); + for (; *s; ++s) { + unsigned char c = (unsigned char)*s; + int adv = u8g2_GetGlyphWidth(u, c); + if (c >= '0' && c <= '9') { + u8g2_DrawGlyph(u, x + (pitch - adv) / 2, y, c); // narrow digits sit centred in the cell + x += pitch; + } else { + u8g2_DrawGlyph(u, x, y, c); + x += adv; + } + } +} +inline void txtPitchR(u8g2_t *u, int xr, int y, const char *s) { txtPitch(u, xr - pitchWidth(u, s), y, s); } +inline void txtPitchC(u8g2_t *u, int xc, int y, const char *s) { txtPitch(u, xc - pitchWidth(u, s) / 2, y, s); } + +// Draws a degree sign as a small ring; the numeric fonts have no glyph for it. +void degreeRing(u8g2_t *u, int x, int y, int r) { + u8g2_DrawCircle(u, x, y, r, U8G2_DRAW_ALL); + if (r >= 3) u8g2_DrawCircle(u, x, y, r - 1, U8G2_DRAW_ALL); +} + +// --------------------------------------------------------------------------- +// UTF-8 handling. Track names are arbitrary Unicode; the bundled fonts cover +// Latin-1. Common typographic punctuation is mapped to ASCII, anything else +// the font lacks becomes '?'. +// --------------------------------------------------------------------------- +int utf8Decode(const unsigned char *s, uint32_t *cp) { + if (s[0] < 0x80) { + *cp = s[0]; + return 1; + } + if ((s[0] & 0xE0) == 0xC0 && (s[1] & 0xC0) == 0x80) { + *cp = ((uint32_t)(s[0] & 0x1F) << 6) | (s[1] & 0x3F); + return 2; + } + if ((s[0] & 0xF0) == 0xE0 && (s[1] & 0xC0) == 0x80 && (s[2] & 0xC0) == 0x80) { + *cp = ((uint32_t)(s[0] & 0x0F) << 12) | ((uint32_t)(s[1] & 0x3F) << 6) | (s[2] & 0x3F); + return 3; + } + if ((s[0] & 0xF8) == 0xF0 && (s[1] & 0xC0) == 0x80 && (s[2] & 0xC0) == 0x80 && + (s[3] & 0xC0) == 0x80) { + *cp = ((uint32_t)(s[0] & 0x07) << 18) | ((uint32_t)(s[1] & 0x3F) << 12) | + ((uint32_t)(s[2] & 0x3F) << 6) | (s[3] & 0x3F); + return 4; + } + *cp = 0xFFFD; // invalid byte + return 1; +} + +// Copies src to dst (always valid UTF-8, NUL terminated) using only glyphs the +// currently selected font can draw. +void sanitizeForFont(u8g2_t *u, char *dst, size_t cap, const char *src) { + size_t n = 0; + bool lastUnknown = false; + auto put = [&](const char *s) { + size_t l = strlen(s); + if (n + l + 1 > cap) return false; + memcpy(dst + n, s, l); + n += l; + return true; + }; + const unsigned char *p = (const unsigned char *)src; + while (*p) { + uint32_t cp; + int len = utf8Decode(p, &cp); + const char *mapped = nullptr; + switch (cp) { + case 0x2018: case 0x2019: case 0x201A: case 0x201B: case 0x2032: mapped = "'"; break; + case 0x201C: case 0x201D: case 0x201E: case 0x2033: mapped = "\""; break; + case 0x2010: case 0x2011: case 0x2012: case 0x2013: case 0x2014: case 0x2015: + case 0x2212: mapped = "-"; break; + case 0x2026: mapped = "..."; break; + case 0x2022: case 0x00B7: case 0x2219: mapped = "-"; break; + case 0x00A0: case 0x2002: case 0x2003: case 0x2009: mapped = " "; break; + default: break; + } + if (cp < 0x20 || cp == 0x7F) mapped = " "; + if (mapped) { + if (!put(mapped)) break; + lastUnknown = false; + } else if (cp < 0x10000 && u8g2_IsGlyph(u, (uint16_t)cp)) { + if (n + len + 1 > cap) break; + memcpy(dst + n, p, len); + n += len; + lastUnknown = false; + } else if (!lastUnknown) { + if (!put("?")) break; + lastUnknown = true; + } + p += len; + } + dst[n] = 0; +} + +// Draws text clipped to maxW pixels, ending with "..." when it had to be cut. +// Returns the drawn width. +int txtFit(u8g2_t *u, int x, int y, int maxW, const char *src) { + char buf[160]; + sanitizeForFont(u, buf, sizeof buf, src); + int w = tw(u, buf); + if (w <= maxW) { + txt(u, x, y, buf); + return w; + } + size_t n = strlen(buf); + while (n > 0) { + --n; + while (n > 0 && (buf[n] & 0xC0) == 0x80) --n; // stay on a code point boundary + buf[n] = 0; + char tmp[170]; + snprintf(tmp, sizeof tmp, "%s...", buf); + int tmpW = tw(u, tmp); + if (tmpW <= maxW) { + txt(u, x, y, tmp); + return tmpW; + } + } + return 0; +} + +// Greedy word wrap into at most maxLines lines of at most maxW pixels; the last +// line is ellipsised if text remains. Returns the number of lines produced. +int wrapText(u8g2_t *u, const char *src, int maxW, char lines[][96], int maxLines) { + char clean[200]; + sanitizeForFont(u, clean, sizeof clean, src); + int count = 0; + const char *p = clean; + while (*p && count < maxLines) { + while (*p == ' ') ++p; + if (!*p) break; + char line[96] = ""; + size_t lineLen = 0; + const char *lastBreak = nullptr; // pointer just after the last space that fits + size_t lastBreakLen = 0; + const char *q = p; + while (*q) { + // advance one code point + uint32_t cp; + int cl = utf8Decode((const unsigned char *)q, &cp); + if (lineLen + cl + 1 > sizeof line) break; + memcpy(line + lineLen, q, cl); + line[lineLen + cl] = 0; + if (tw(u, line) > maxW) { + line[lineLen] = 0; + break; + } + lineLen += cl; + q += cl; + if (cp == ' ') { + lastBreak = q; + lastBreakLen = lineLen; + } + } + if (*q && lastBreak) { // wrapped mid-text: back up to the last space + lineLen = lastBreakLen; + q = lastBreak; + } + line[lineLen] = 0; + while (lineLen > 0 && line[lineLen - 1] == ' ') line[--lineLen] = 0; + if (lineLen == 0) break; // a single glyph wider than maxW; give up + p = q; + while (*p == ' ') ++p; + if (count == maxLines - 1 && *p) { // out of lines: ellipsise + char tmp[96]; + snprintf(tmp, sizeof tmp, "%.90s", line); // leave room for "..." + size_t n = strlen(tmp); + if (strlen(line) > n) { // truncated: don't leave half a UTF-8 sequence behind + while (n > 0 && (line[n] & 0xC0) == 0x80) --n; + tmp[n] = 0; + } + for (;;) { + char with[104]; + snprintf(with, sizeof with, "%s...", tmp); + if (tw(u, with) <= maxW || n == 0) { + snprintf(lines[count], 96, "%.95s", with); + break; + } + --n; + while (n > 0 && (tmp[n] & 0xC0) == 0x80) --n; + tmp[n] = 0; + } + } else { + snprintf(lines[count], 96, "%.95s", line); + } + ++count; + } + return count; +} + +// --------------------------------------------------------------------------- +// Shapes +// --------------------------------------------------------------------------- +void disc(u8g2_t *u, float cx, float cy, float r) { + u8g2_DrawDisc(u, (int16_t)iround(cx), (int16_t)iround(cy), (uint16_t)iround(r), U8G2_DRAW_ALL); +} + +// A line segment with a width tapering from w0 (at x0,y0) to w1 (at x1,y1), +// drawn as two filled triangles. Widths <= 1 fall back to a hairline. +void taper(u8g2_t *u, float x0, float y0, float x1, float y1, float w0, float w1) { + float dx = x1 - x0, dy = y1 - y0; + float len = sqrtf(dx * dx + dy * dy); + if (len < 0.5f) return; + if (w0 <= 1.2f && w1 <= 1.2f) { + u8g2_DrawLine(u, iround(x0), iround(y0), iround(x1), iround(y1)); + return; + } + float nx = -dy / len, ny = dx / len; + float ax = x0 + nx * w0 / 2, ay = y0 + ny * w0 / 2; + float bx = x0 - nx * w0 / 2, by = y0 - ny * w0 / 2; + float cx = x1 - nx * w1 / 2, cy = y1 - ny * w1 / 2; + float ex = x1 + nx * w1 / 2, ey = y1 + ny * w1 / 2; + u8g2_DrawTriangle(u, iround(ax), iround(ay), iround(bx), iround(by), iround(cx), iround(cy)); + u8g2_DrawTriangle(u, iround(ax), iround(ay), iround(cx), iround(cy), iround(ex), iround(ey)); +} + +// Media glyphs, drawn inside an s x s box whose top-left is (x, y). +void iconPlay(u8g2_t *u, int x, int y, int s) { + u8g2_DrawTriangle(u, x + s / 6, y, x + s / 6, y + s - 1, x + s - 1, y + s / 2); +} +void iconPause(u8g2_t *u, int x, int y, int s) { + int bw = (s + 2) / 4; + if (bw < 2) bw = 2; + int gap = s / 5; + if (gap < 2) gap = 2; + int x0 = x + (s - (2 * bw + gap)) / 2; + u8g2_DrawBox(u, x0, y, bw, s); + u8g2_DrawBox(u, x0 + bw + gap, y, bw, s); +} +void iconNext(u8g2_t *u, int x, int y, int s) { + int bw = s / 5 > 1 ? s / 5 : 1; + u8g2_DrawTriangle(u, x, y, x, y + s - 1, x + s - bw - 2, y + s / 2); + u8g2_DrawBox(u, x + s - bw, y, bw, s); +} +void iconPrev(u8g2_t *u, int x, int y, int s) { + int bw = s / 5 > 1 ? s / 5 : 1; + u8g2_DrawBox(u, x, y, bw, s); + u8g2_DrawTriangle(u, x + s - 1, y, x + s - 1, y + s - 1, x + bw + 2, y + s / 2); +} +void iconNote(u8g2_t *u, int x, int y, int s) { // musical note + int r = (s + 3) / 6; // note head radius + if (r < 2) r = 2; + int stemX = x + 2 * r - 1; + u8g2_DrawDisc(u, x + r, y + s - r - 1, r, U8G2_DRAW_ALL); + u8g2_DrawBox(u, stemX, y, 2, s - r); + taper(u, stemX + 2, y, x + s - 1, y + s / 2, 3, 2); // flag +} + +// Chance of rain: a drop, outlined like the moon. kRainW x kRainH with its top-left at (x, y); its +// bottom row is the bottom row of the digits beside it. (Three slanted strokes, "///", read as text.) +const int kRainW = 7, kRainH = 9; +void iconRain(u8g2_t *u, int x, int y) { + static const char *const kDrop[kRainH] = { + "...#...", // + "..#.#..", // + "..#.#..", // + ".#...#.", // + ".#...#.", // + "#.....#", // + "#.....#", // + ".#...#.", // + "..###..", // + }; + for (int r = 0; r < kRainH; r++) + for (int c = 0; c < kRainW; c++) + if (kDrop[r][c] == '#') u8g2_DrawPixel(u, x + c, y + r); +} + +// The moon as a disc of `d` pixels: an outline, with the lit part filled (moon.h decides which +// pixels). `litPct` 0..100; a growing moon is lit on the right in the northern hemisphere and on +// the left in the southern. +const int kMoonD = 10; +// Where the moon and the drop share a column (the forecast days, the legend) the drop is centred in a +// cell as wide as the moon, so the two percentages start at the same x. +const int kRainInset = (kMoonD - kRainW + 1) / 2; +void iconMoon(u8g2_t *u, int x, int y, int d, int litPct, bool waxing, bool southern) { + const float lit = (litPct < 0 ? 0 : (litPct > 100 ? 100 : litPct)) / 100.0f; + const bool onRight = waxing != southern; + auto inside = [&](int c, int r) { return moon::discPixel(c, r, d, lit, onRight).inside; }; + for (int r = 0; r < d; r++) + for (int c = 0; c < d; c++) { + const moon::DiscPixel p = moon::discPixel(c, r, d, lit, onRight); + if (!p.inside) continue; + const bool rim = !(inside(c - 1, r) && inside(c + 1, r) && inside(c, r - 1) && inside(c, r + 1)); + if (p.lit || rim) u8g2_DrawPixel(u, x + c, y + r); + } +} + +// --------------------------------------------------------------------------- +// Weather icons. Drawn in a 32-unit design box and scaled to `size` pixels. +// --------------------------------------------------------------------------- +struct Box { + float x, y, k; // top-left and scale + float X(float v) const { return x + v * k; } + float Y(float v) const { return y + v * k; } +}; + +void sunShape(u8g2_t *u, const Box &b, float cx, float cy, float r, bool rays) { + disc(u, b.X(cx), b.Y(cy), r * b.k); + if (!rays) return; + for (int i = 0; i < 8; i++) { + float a = i * (float)M_PI / 4.0f; + float s = sinf(a), c = cosf(a); + taper(u, b.X(cx + s * (r + 3)), b.Y(cy - c * (r + 3)), b.X(cx + s * (r + 6.5f)), + b.Y(cy - c * (r + 6.5f)), 2.2f * b.k, 2.2f * b.k); + } +} + +void moonShape(u8g2_t *u, const Box &b, float cx, float cy, float r) { + disc(u, b.X(cx), b.Y(cy), r * b.k); + paper(u); + disc(u, b.X(cx + r * 0.55f), b.Y(cy - r * 0.35f), r * 0.85f * b.k); + ink(u); +} + +// Cloud occupying roughly x 3..30, y 7..27 of the design box, shifted by (dx, dy). +void cloudShape(u8g2_t *u, const Box &b, float dx, float dy, bool halo) { + auto draw = [&](float grow) { + disc(u, b.X(9 + dx), b.Y(21 + dy), (6 + grow) * b.k); + disc(u, b.X(24 + dx), b.Y(21 + dy), (6 + grow) * b.k); + disc(u, b.X(16.5f + dx), b.Y(15 + dy), (8 + grow) * b.k); + float x0 = b.X(9 + dx), x1 = b.X(24 + dx); + float y0 = b.Y(15 + dy - grow), y1 = b.Y(27 + dy + grow); + u8g2_DrawBox(u, iround(x0), iround(y0), iround(x1 - x0) + 1, iround(y1 - y0) + 1); + }; + if (halo) { + paper(u); + draw(2.0f); + ink(u); + } + draw(0.0f); +} + +void rainStreaks(u8g2_t *u, const Box &b, float y0, int n, float w) { + for (int i = 0; i < n; i++) { + float x = 10 + i * (n == 2 ? 10 : 6.5f); + taper(u, b.X(x + 2), b.Y(y0), b.X(x - 1), b.Y(y0 + 5.5f), w * b.k, w * b.k); + } +} + +void snowFlakes(u8g2_t *u, const Box &b, float y0) { + for (int i = 0; i < 3; i++) { + float cx = 9.5f + i * 7; + float cy = y0 + (i == 1 ? 3.5f : 0.5f); + u8g2_DrawPixel(u, iround(b.X(cx)), iround(b.Y(cy))); + u8g2_DrawBox(u, iround(b.X(cx - 1)), iround(b.Y(cy - 1)), iround(2.2f * b.k) + 1, iround(2.2f * b.k) + 1); + } +} + +void boltShape(u8g2_t *u, const Box &b) { + // zig-zag lightning bolt below the cloud + u8g2_DrawTriangle(u, iround(b.X(18)), iround(b.Y(21.5f)), iround(b.X(11)), iround(b.Y(29)), + iround(b.X(17)), iround(b.Y(29))); + u8g2_DrawTriangle(u, iround(b.X(15)), iround(b.Y(26)), iround(b.X(21.5f)), iround(b.Y(26)), + iround(b.X(14)), iround(b.Y(32))); +} + +void drawWxIcon(u8g2_t *u, WxIcon icon, bool day, int x, int y, int size) { + Box b = {(float)x, (float)y, size / 32.0f}; + ink(u); + switch (icon) { + case WX_CLEAR: + if (day) { + sunShape(u, b, 16, 16, 6.5f, true); + } else { + moonShape(u, b, 15, 16, 10); + } + break; + case WX_PARTLY: + if (day) { + sunShape(u, b, 11, 11, 5, true); + } else { + moonShape(u, b, 10, 11, 7); + } + cloudShape(u, b, 2, 3, true); + break; + case WX_CLOUDY: + cloudShape(u, b, 0, 1, false); + break; + case WX_FOG: + cloudShape(u, b, 0, -5, false); + for (int i = 0; i < 3; i++) { + float yy = 23 + i * 3.6f; + u8g2_DrawBox(u, iround(b.X(4 + (i % 2) * 3)), iround(b.Y(yy)), iround(24 * b.k), iround(1.6f * b.k) > 0 ? iround(1.6f * b.k) : 1); + } + break; + case WX_DRIZZLE: + cloudShape(u, b, 0, -4, false); + rainStreaks(u, b, 24, 3, 1.0f); + break; + case WX_RAIN: + cloudShape(u, b, 0, -4, false); + rainStreaks(u, b, 24, 3, 1.8f); + break; + case WX_SNOW: + cloudShape(u, b, 0, -4, false); + snowFlakes(u, b, 26); + break; + case WX_THUNDER: + cloudShape(u, b, 0, -4, false); + boltShape(u, b); + break; + } + ink(u); +} + +// --------------------------------------------------------------------------- +// Status bar widgets +// --------------------------------------------------------------------------- +int wifiLevel(int rssi) { + if (rssi >= -55) return 4; + if (rssi >= -65) return 3; + if (rssi >= -75) return 2; + if (rssi >= -85) return 1; + return 0; +} + +void drawWifi(u8g2_t *u, int x, int yb, bool up, int rssi) { + int level = up ? wifiLevel(rssi) : 0; + for (int i = 0; i < 4; i++) { + int h = 3 + i * 3; + int bx = x + i * 4; + if (i < level) { + u8g2_DrawBox(u, bx, yb - h + 1, 3, h); + } else { + u8g2_DrawHLine(u, bx, yb, 3); + } + } + if (!up) { // cross + u8g2_DrawLine(u, x + 3, yb - 9, x + 12, yb - 1); + u8g2_DrawLine(u, x + 3, yb - 8, x + 11, yb - 1); + u8g2_DrawLine(u, x + 12, yb - 9, x + 3, yb - 1); + u8g2_DrawLine(u, x + 11, yb - 9, x + 3, yb - 2); + } +} + +// What the battery is doing, as pixel art ('#' = ink) drawn inside the battery body: a bolt while +// charging, a tick when it is full, nothing when it runs on the battery or the clock does not know +// (the percentage and the fill say the rest). The bolt sits in a window cleared in the fill, so it is +// crisp at every level; the tick is cut out of the solid fill of a full battery. +const char *const kArtBolt[] = { // charging, 6 x 8 + "....##", "...##.", "..##..", ".#####", "#####.", "..##..", ".##...", "##....", +}; +const char *const kArtTick[] = { // full, 7 x 6 + ".....##", "....##.", "#..##..", "##.##..", ".###...", "..#....", +}; + +// Picks the art for a UiCharge value; returns its height in rows (0 = none). +int chargeArt(int state, const char *const **rows) { + switch (state) { + case CHARGE_CHARGING: *rows = kArtBolt; return (int)(sizeof kArtBolt / sizeof *kArtBolt); + case CHARGE_FULL: *rows = kArtTick; return (int)(sizeof kArtTick / sizeof *kArtTick); + default: *rows = nullptr; return 0; + } +} + +// Where the pieces of the battery gauge go. The percentage sits in a cell as wide as "100%" +// so the gauge does not shift when the number gains or loses a digit. +struct BatteryLayout { + char label[8]; + int width; +}; +const int kBatBodyW = 24, kBatBodyH = 12, kBatNub = 2, kBatGap = 4; + +// The art in the middle of the body at (bx, by). `window`: first clear the art's columns, a pixel wider on +// each side and as tall as the inside of the frame, in `bg`, then draw the art in `fg`. Otherwise the art is +// cut out of whatever is there, in `bg`. +void drawBatteryArt(u8g2_t *u, int bx, int by, const char *const *rows, int h, int fg, int bg, bool window) { + const int w = (int)strlen(rows[0]); + const int ax = bx + 2 + (kBatBodyW - 4 - w) / 2, ay = by + 2 + (kBatBodyH - 4 - h) / 2; + if (window) { + u8g2_SetDrawColor(u, bg); + u8g2_DrawBox(u, ax - 1, by + 1, w + 2, kBatBodyH - 2); + } + u8g2_SetDrawColor(u, window ? fg : bg); + for (int r = 0; r < h; r++) + for (int c = 0; c < w; c++) + if (rows[r][c] == '#') u8g2_DrawPixel(u, ax + c, ay + r); +} + +BatteryLayout batteryLayout(u8g2_t *u, const UiModel &m) { + BatteryLayout b; + if (m.batPresent) { + snprintf(b.label, sizeof b.label, "%d%%", m.batPercent); + } else { + snprintf(b.label, sizeof b.label, "USB"); + } + u8g2_SetFont(u, F_BOLD); + const int labelW = m.batPresent ? pitchWidth(u, "100%") : tw(u, b.label); + b.width = (m.batPresent ? kBatBodyW + kBatNub + kBatGap : 0) + labelW; + return b; +} + +// Right-aligned battery gauge ending at xr, `dy` rows below where it sits in the status bar (0 +// there; the legend page draws samples further down). Inverts (black plate, white artwork) +// during the "on" phase of the blink when the level is low. +void drawBatteryAt(u8g2_t *u, int xr, int dy, const UiModel &m) { + const BatteryLayout b = batteryLayout(u, m); // leaves F_BOLD selected + const int x0 = xr - b.width; + bool flash = m.batPresent && m.batLow && m.batBlinkOn; + + if (flash) { + ink(u); + u8g2_DrawRBox(u, x0 - 4, 1 + dy, b.width + 8, kBarH - 3, 3); + paper(u); + } else { + ink(u); + } + int ty = 15 + dy; + if (m.batPresent) { + const int bx = x0, by = 4 + dy; + u8g2_DrawFrame(u, bx, by, kBatBodyW, kBatBodyH); + u8g2_DrawBox(u, bx + kBatBodyW, by + 3, kBatNub, kBatBodyH - 6); + int inner = kBatBodyW - 4; + int fill = (inner * m.batPercent + 50) / 100; + if (m.batPercent > 0 && fill < 1) fill = 1; + if (fill > inner) fill = inner; + u8g2_DrawBox(u, bx + 2, by + 2, fill, kBatBodyH - 4); + const char *const *rows; + const int h = chargeArt(m.batCharge, &rows); + if (h > 0) drawBatteryArt(u, bx, by, rows, h, flash ? 0 : 1, flash ? 1 : 0, m.batCharge == CHARGE_CHARGING); // (the plate is black while it blinks) + if (flash) paper(u); else ink(u); + txtPitch(u, bx + kBatBodyW + kBatNub + kBatGap, ty, b.label); + } else { + txt(u, x0, ty, b.label); + } + ink(u); +} + +void drawBattery(u8g2_t *u, int xr, const UiModel &m) { drawBatteryAt(u, xr, 0, m); } + +void drawStatusClock(u8g2_t *u, int cx, int y, const UiModel &m); // below, with the time drawing + +void drawStatusBar(u8g2_t *u, const UiModel &m) { + ink(u); + drawWifi(u, 4, 16, m.wifiUp, m.rssi); + + const int messageEnd = 396 - batteryLayout(u, m).width - 8; // the status message stops short of the gauge + u8g2_SetFont(u, F_BODY); + int leftEnd = 24; + if (m.location[0]) { + leftEnd += txtFit(u, 24, 15, 120, m.location); + } + + // centre: clock on the secondary pages, otherwise the latest status message + if (m.page != PAGE_DASHBOARD && m.timeValid) { + drawStatusClock(u, 200, 15, m); + } else if (m.status[0]) { + u8g2_SetFont(u, F_SMALL); + int avail = messageEnd - (leftEnd + 10); + int w = tw(u, m.status); + int x = leftEnd + 10; + if (w < avail) x += (avail - w) / 2; + txtFit(u, x, 14, avail, m.status); + } + + drawBattery(u, 396, m); + u8g2_DrawHLine(u, 0, kBarH - 1, UI_WIDTH); +} + +// --------------------------------------------------------------------------- +// Analog clock +// --------------------------------------------------------------------------- +// A ring two pixels wide whose outside is the circle of radius r: every pixel whose middle lies between r - 1.5 +// and r + 0.5 from the centre. (Two circles drawn one inside the other, r and r - 1, cover the same band but +// for single pixels here and there, where the two happen to round differently: on the panel they showed as +// holes in the clock's outline.) +void ring(u8g2_t *u, int cx, int cy, int r) { + const float outer2 = ((float)r + 0.5f) * ((float)r + 0.5f), inner2 = ((float)r - 1.5f) * ((float)r - 1.5f); + for (int dy = -r; dy <= r; dy++) { + const float d2 = (float)(dy * dy); + const int xo = (int)floorf(sqrtf(outer2 - d2)); // the last column of this row that is inside the outer edge + if (d2 >= inner2) { // above or below the hole: one run from side to side + u8g2_DrawHLine(u, cx - xo, cy + dy, 2 * xo + 1); + continue; + } + const int xi = (int)ceilf(sqrtf(inner2 - d2)); // the first column that is outside the inner edge + if (xi > xo) continue; + u8g2_DrawHLine(u, cx - xo, cy + dy, xo - xi + 1); + u8g2_DrawHLine(u, cx + xi, cy + dy, xo - xi + 1); + } +} + +void drawAnalogClock(u8g2_t *u, int cx, int cy, int R, const UiModel &m) { + ink(u); + ring(u, cx, cy, R); + + // minute / hour ticks + for (int i = 0; i < 60; i++) { + float a = i * 6.0f * (float)M_PI / 180.0f; + float s = sinf(a), c = -cosf(a); + if (i % 5 == 0) { + taper(u, cx + s * (R - 10), cy + c * (R - 10), cx + s * (R - 3), cy + c * (R - 3), 3, 3); + } else { + u8g2_DrawPixel(u, iround(cx + s * (R - 4)), iround(cy + c * (R - 4))); + u8g2_DrawPixel(u, iround(cx + s * (R - 5)), iround(cy + c * (R - 5))); + } + } + + // numerals + u8g2_SetFont(u, F_BOLD); + for (int h = 1; h <= 12; h++) { + float a = h * 30.0f * (float)M_PI / 180.0f; + char n[12]; + snprintf(n, sizeof n, "%d", h); + int x = iround(cx + sinf(a) * (R - 22)); + int y = iround(cy - cosf(a) * (R - 22)); + txtC(u, x, y + 4, n); + } + + if (!m.timeValid) { + u8g2_SetFont(u, F_SMALL); + txtC(u, cx, cy + 28, "waiting for time"); + disc(u, cx, cy, 4); + return; + } + + float sec = m.local.tm_sec + m.secFrac; + float min = m.local.tm_min + sec / 60.0f; + float hr = (m.local.tm_hour % 12) + min / 60.0f; + float aH = hr * 30.0f * (float)M_PI / 180.0f; + float aM = min * 6.0f * (float)M_PI / 180.0f; + float aS = sec * 6.0f * (float)M_PI / 180.0f; + + auto hand = [&](float ang, float len, float tail, float wBase, float wTip) { + float s = sinf(ang), c = -cosf(ang); + float x0 = cx - s * tail, y0 = cy - c * tail; + float x1 = cx + s * len, y1 = cy + c * len; + paper(u); // thin paper-coloured halo so hands stay readable over ticks/numerals + taper(u, x0, y0, x1, y1, wBase + 3, wTip + 3); + ink(u); + taper(u, x0, y0, x1, y1, wBase, wTip); + }; + hand(aH, R * 0.50f, 7, 7, 3); + hand(aM, R * 0.78f, 9, 5, 2); + + // second hand: slim wedge with a short counterweight tail and a ring hub + float s = sinf(aS), c = -cosf(aS); + ink(u); + taper(u, cx - s * 6, cy - c * 6, cx + s * (R * 0.90f), cy + c * (R * 0.90f), 2.6f, 1.3f); + taper(u, cx - s * 18, cy - c * 18, cx - s * 6, cy - c * 6, 3.2f, 3.2f); + disc(u, cx, cy, 6); + paper(u); + disc(u, cx, cy, 2); + ink(u); +} + +// The time on a 12 hour clock: HH:MM big, with the seconds and AM / PM small to its right. +// +// 9:45 07 the seconds sit on the baseline of the big digits, +// PM AM / PM level with their top. +// +// A one digit hour leaves its cell empty rather than shifting everything, so the picture only +// changes where the digits do. +void drawTime12(u8g2_t *u, int cx, int yTime, const struct tm &t) { + bool pm; + const int h = datefmt::hour12(t.tm_hour, &pm); + u8g2_SetFont(u, F_TIME); + const int pitch = digitPitch(u); + const int bigW = 4 * pitch + u8g2_GetGlyphWidth(u, ':'); + const int top = yTime - (int)u8g2_GetAscent(u); + u8g2_SetFont(u, F_BIG); + const int secW = 2 * digitPitch(u); + const int gap = 8; + const int x0 = cx - (bigW + gap + secW) / 2; + + char big[8]; + snprintf(big, sizeof big, "%d:%02d", h, t.tm_min); + u8g2_SetFont(u, F_TIME); + txtPitch(u, x0 + (h < 10 ? pitch : 0), yTime, big); + + char sec[8]; + snprintf(sec, sizeof sec, "%02d", t.tm_sec); + u8g2_SetFont(u, F_BIG); + const int sx = x0 + bigW + gap; + txtPitch(u, sx, yTime, sec); + + u8g2_SetFont(u, F_BOLD); + txt(u, sx + 1, top + (int)u8g2_GetAscent(u), datefmt::ampm(pm)); +} + +// The clock in the status bar of the other pages: 08:21:07, or 8:21:07 PM, on fixed digit cells. +void drawStatusClock(u8g2_t *u, int cx, int y, const UiModel &m) { + u8g2_SetFont(u, F_BOLD); + char t[16]; + if (!m.time12h) { + snprintf(t, sizeof t, "%02d:%02d:%02d", m.local.tm_hour, m.local.tm_min, m.local.tm_sec); + txtPitchC(u, cx, y, t); + return; + } + bool pm; + const int h = datefmt::hour12(m.local.tm_hour, &pm); + snprintf(t, sizeof t, "%d:%02d:%02d %s", h, m.local.tm_min, m.local.tm_sec, datefmt::ampm(pm)); + const int width = pitchWidth(u, "00:00:00 PM"); + txtPitch(u, cx - width / 2 + (h < 10 ? digitPitch(u) : 0), y, t); +} + +// --------------------------------------------------------------------------- +// Dashboard blocks +// --------------------------------------------------------------------------- +void drawDateTime(u8g2_t *u, const UiModel &m) { + const int cx = (kRightX0 + kRightX1) / 2; + // Baselines chosen so the glyph rows leave clear gaps (measured on the host + // renderer): bar->date 5 px, date->time 6, time->weekday 6, weekday->zone 4. + const int yDate = 51, yTime = 99, yWeek = 116, yZone = 134; + char buf[48]; + if (!m.timeValid) { + u8g2_SetFont(u, F_DATE); + txtC(u, cx, yDate, "----------"); + u8g2_SetFont(u, F_TIME); + txtC(u, cx, yTime, "--:--:--"); + u8g2_SetFont(u, F_BODY); + txtC(u, cx, yWeek + 7, m.wifiOff ? "Time not set: turn WiFi on once" : "Waiting for network time"); + return; + } + const struct tm &t = m.local; + datefmt::formatDate(t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, m.dateFormat, buf, sizeof buf); + u8g2_SetFont(u, F_DATE); + txtPitchC(u, cx, yDate, buf); + + if (m.time12h) { + drawTime12(u, cx, yTime, t); + } else { + snprintf(buf, sizeof buf, "%02d:%02d:%02d", t.tm_hour, t.tm_min, t.tm_sec); + u8g2_SetFont(u, F_TIME); + txtPitchC(u, cx, yTime, buf); + } + + // weekday, and with "show_week" the ISO week number and day of year + if (m.showWeek) { + int week = timeutil::isoWeek(t.tm_year + 1900, t.tm_yday, t.tm_wday); + snprintf(buf, sizeof buf, "%s \xC2\xB7 W%02d \xC2\xB7 day %d", timeutil::weekdayName(t.tm_wday), week, t.tm_yday + 1); + } else { + snprintf(buf, sizeof buf, "%s", timeutil::weekdayName(t.tm_wday)); + } + u8g2_SetFont(u, F_BOLD); + txtC(u, cx, yWeek, buf); + + // time zone + char off[16]; + timeutil::formatUtcOffset(m.utcOffsetMin, off, sizeof off); + if (m.tzAbbrev[0]) { + snprintf(buf, sizeof buf, "%s UTC%s", m.tzAbbrev, off); + } else { + snprintf(buf, sizeof buf, "UTC%s", off); + } + u8g2_SetFont(u, F_BODY); + txtC(u, cx, yZone, buf); +} + +void drawIndoor(u8g2_t *u, const UiModel &m) { + const int x0 = kRightX0, x1 = kRightX1, y0 = 144, y1 = 194; + u8g2_DrawRFrame(u, x0, y0, x1 - x0, y1 - y0, 4); + u8g2_SetFont(u, F_SMALLB); + // Label plate: 2 px of black above and below the capitals (the glyph rows run from + // baseline - cap to baseline - 1), straddling the frame line. + const int cap = u8g2_GetAscent(u), plateTop = y0 - 6; + u8g2_DrawBox(u, x0 + 8, plateTop, tw(u, "INDOOR") + 8, cap + 4); + paper(u); + txt(u, x0 + 12, plateTop + 2 + cap, "INDOOR"); + ink(u); + + if (!m.indoorValid) { + u8g2_SetFont(u, F_BODY); + txtC(u, (x0 + x1) / 2, y0 + 30, "sensor unavailable"); + return; + } + char num[16]; + int base = y0 + 37; // digits centred in the (now 50 px) box: 12 px above and below + float t = m.useFahrenheit ? m.indoorC * 9.0f / 5.0f + 32.0f : m.indoorC; + snprintf(num, sizeof num, "%.1f", t); + u8g2_SetFont(u, F_BIG); + const char unit = m.useFahrenheit ? 'F' : 'C'; + int w1 = pitchWidth(u, num); + int w2 = u8g2_GetGlyphWidth(u, (uint16_t)unit); + const int unitGap = 12, sectionGap = 22; + char hum[8]; + snprintf(hum, sizeof hum, "%d", iround(m.indoorRh)); + int w3 = pitchWidth(u, hum); + int w4 = u8g2_GetGlyphWidth(u, (uint16_t)'%'); + int total = w1 + unitGap + w2 + sectionGap + w3 + w4; + int x = (x0 + x1) / 2 - total / 2; + txtPitch(u, x, base, num); + degreeRing(u, x + w1 + 6, base - 18, 3); + u8g2_DrawGlyph(u, x + w1 + unitGap, base, (uint16_t)unit); + x += w1 + unitGap + w2 + sectionGap; + txtPitch(u, x, base, hum); + u8g2_DrawGlyph(u, x + w3, base, (uint16_t)'%'); +} + +// Blank columns before the ink of a glyph, measured on the rendered fonts (the preview checks them), +// so that things can be lined up by their ink rather than by where the glyph's cell starts. +int bearingBig(char c) { return c == '1' ? 5 : (c == '-' ? 3 : 2); } // logisoso24: every digit advances 15 +int inkRightBig(char c) { return c == '1' ? 10 : (c == '-' ? 11 : 13); } // last inked column in that cell +int bearingBody(char c) { // helvR10 capitals + static const int8_t kCap[26] = {0, 1, 1, 1, 1, 1, 1, 1, 2, 0, 1, 2, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 1}; + return (c >= 'A' && c <= 'Z') ? kCap[c - 'A'] : 1; +} + +// "35%" in the small font on fixed digit cells. form 0: with the percent sign, 1: without it, +// 2: nothing at all (for when there is no room). +int pctWidth(u8g2_t *u, int pct, int form) { + if (form >= 2) return 0; + u8g2_SetFont(u, F_SMALL); + char s[8]; + snprintf(s, sizeof s, form == 0 ? "%d%%" : "%d", pct); + return pitchWidth(u, s); +} + +int pctText(u8g2_t *u, int x, int baseline, int pct, int form) { + if (form >= 2) return x; + u8g2_SetFont(u, F_SMALL); + char s[8]; + snprintf(s, sizeof s, form == 0 ? "%d%%" : "%d", pct); + txtPitch(u, x, baseline, s); + return x + pitchWidth(u, s); +} + +int clampPct(int pct) { return pct < 0 ? 0 : (pct > 100 ? 100 : pct); } + +// A glyph followed by its percentage (gap 2): how wide it is in each form. +int groupWidth(u8g2_t *u, int glyphW, int pct, int form) { return glyphW + (form < 2 ? 2 + pctWidth(u, pct, form) : 0); } + +// The widest form of a glyph-and-percentage that fits in `room` px. +int fittingForm(u8g2_t *u, int glyphW, int pct, int room) { + for (int form = 0; form < 2; form++) + if (groupWidth(u, glyphW, pct, form) <= room) return form; + return 2; +} + +// Today's block, 0 .. kColA wide: +// +// icon 15 deg (moon) 52% (drop) 35% <- the first row shares its top with the big number +// Overcast H 19 deg L 9 deg <- the second its bottom +// +// The big number starts where the condition text below it does (their ink, not their cells). +// Wide numbers (-12, 104) squeeze the rows to the right of them: the moon loses its percentage, +// then the moon goes, and the high and low shrink their gaps and then their type. +void drawWeatherCurrent(u8g2_t *u, const UiModel &m) { + const WeatherData &w = m.weather; + if (!w.valid) { + u8g2_SetFont(u, F_BODY); + txtC(u, kColA / 2, kWxTop + 32, m.wifiOff ? "WiFi is off" : "Weather: waiting..."); + return; + } + drawWxIcon(u, wmoIcon(w.code), w.isDay, 6, kWxTop + 8, 42); + const int tx = 54; // where the condition text starts + const int limit = kColA - 4; // nothing reaches the separator + char buf[40]; + + u8g2_SetFont(u, F_BODY); + if (w.ageSec > 3600) { // stale data: say so rather than silently showing old numbers + snprintf(buf, sizeof buf, "%s (old)", wmoText(w.code)); + } else { + snprintf(buf, sizeof buf, "%s", wmoText(w.code)); + } + txtFit(u, tx, kWxTop + 47, limit - tx, buf); + const int textInk = tx + bearingBody(buf[0]); + + u8g2_SetFont(u, F_BIG); + char num[12]; + snprintf(num, sizeof num, "%d", tempOf(w.temp, m.useFahrenheit)); + const int numX = textInk - bearingBig(num[0]); + txtPitch(u, numX, kWxTop + 31, num); + const int lastCell = numX + pitchWidth(u, num) - digitPitch(u); + const int inkEnd = lastCell + inkRightBig(num[strlen(num) - 1]); + degreeRing(u, inkEnd + 5, kWxTop + 13, 3); + const int hx = inkEnd + 5 + 3 + 7; // right of the ring + const int room = limit - hx; + + // moon and chance of rain, side by side + const int top = kWxTop + 7; + const int rain = clampPct(w.day[0].rainPct), moonPct = clampPct(m.moonLit[0]); + bool moonShown = m.moonValid; + int moonForm = 0; + if (moonShown && groupWidth(u, kMoonD, moonPct, 0) + 6 + groupWidth(u, kRainW, rain, 0) > room) moonForm = 2; + if (moonShown && groupWidth(u, kMoonD, moonPct, moonForm) + 6 + groupWidth(u, kRainW, rain, 0) > room) moonShown = false; + const int rainForm = fittingForm(u, kRainW, rain, room); + int x = hx; + if (moonShown) { + iconMoon(u, x, top, kMoonD, moonPct, m.moonWaxing[0], m.southern); + x = pctText(u, x + kMoonD + 2, top + 9, moonPct, moonForm) + (moonForm < 2 ? 6 : 4); + } + iconRain(u, x, top + 9 - kRainH); // same rule: bottom row = bottom of the digits (baseline top + 9, minus 1) + pctText(u, x + kRainW + 2, top + 9, rain, rainForm); + + // high and low on one line: spaced, then tight, then in the small bold type + char hi[8], lo[8]; + snprintf(hi, sizeof hi, "%d", tempOf(w.day[0].tmax, m.useFahrenheit)); + snprintf(lo, sizeof lo, "%d", tempOf(w.day[0].tmin, m.useFahrenheit)); + for (int variant = 0; variant < 3; variant++) { + u8g2_SetFont(u, variant < 2 ? F_BOLD : F_SMALLB); + const bool spaced = variant == 0; + const int gap = variant == 0 ? 7 : (variant == 1 ? 5 : 4); + char a[12], b[12]; + snprintf(a, sizeof a, spaced ? "H %s" : "H%s", hi); + snprintf(b, sizeof b, spaced ? "L %s" : "L%s", lo); + const int wa = tw(u, a), wb = tw(u, b); + const int end = hx + wa + 3 + 2 + gap + wb + 3 + 2; // each followed by its degree ring + if (end > limit && variant < 2) continue; + const int y = kWxTop + 31; + txt(u, hx, y, a); + degreeRing(u, hx + wa + 3, y - (variant < 2 ? 8 : 6), 2); + const int bx = hx + wa + 3 + 2 + gap; + txt(u, bx, y, b); + degreeRing(u, bx + wb + 3, y - (variant < 2 ? 8 : 6), 2); + break; + } +} + +// A day of the forecast, 112 px wide: +// +// Mon +// icon 20 deg (moon) 52% +// 12 deg (drop) 35% +// The stack on the right is at the same place in every column; a wide temperature pushes it right +// and, if that leaves too little room, each row drops its percent sign and then its number. +void drawWeatherDay(u8g2_t *u, const UiModel &m, int idx, int x0, int x1) { + const WeatherData &w = m.weather; + if (!w.valid) return; + const WeatherDay &d = w.day[idx]; + char buf[24]; + u8g2_SetFont(u, F_BOLD); + txt(u, x0 + 8, kWxTop + 15, timeutil::weekdayShort(d.weekday)); + + drawWxIcon(u, wmoIcon(d.code), true, x0 + 6, kWxTop + 19, 32); + + const int tx = x0 + 44; + u8g2_SetFont(u, F_H2); + snprintf(buf, sizeof buf, "%d", tempOf(d.tmax, m.useFahrenheit)); + const int hiW = tw(u, buf); + txt(u, tx, kWxTop + 33, buf); + degreeRing(u, tx + hiW + 3, kWxTop + 25, 2); + u8g2_SetFont(u, F_BODY); + snprintf(buf, sizeof buf, "%d", tempOf(d.tmin, m.useFahrenheit)); + const int loW = tw(u, buf); + txt(u, tx, kWxTop + 47, buf); + degreeRing(u, tx + loW + 3, kWxTop + 40, 2); + + int sx = tx + (hiW > loW ? hiW : loW) + 3 + 2 + 4; + if (sx < x0 + 72) sx = x0 + 72; + const int room = x1 - 3 - sx; + if (m.moonValid) { + const int pct = clampPct(m.moonLit[idx]); + iconMoon(u, sx, kWxTop + 22, kMoonD, pct, m.moonWaxing[idx], m.southern); + pctText(u, sx + kMoonD + 2, kWxTop + 31, pct, fittingForm(u, kMoonD, pct, room)); + } + const int rain = clampPct(d.rainPct); + iconRain(u, sx + kRainInset, kWxTop + 45 - kRainH); // its bottom row is the bottom of the digits (baseline - 1) + pctText(u, sx + kMoonD + 2, kWxTop + 45, rain, fittingForm(u, kMoonD, rain, room)); +} + +void drawWeatherBand(u8g2_t *u, const UiModel &m) { + ink(u); + u8g2_DrawHLine(u, 0, kWxTop, UI_WIDTH); + u8g2_DrawVLine(u, kColA, kWxTop + 4, kWxBottom - kWxTop - 8); + u8g2_DrawVLine(u, kColB, kWxTop + 4, kWxBottom - kWxTop - 8); + drawWeatherCurrent(u, m); + drawWeatherDay(u, m, 1, kColA, kColB); + drawWeatherDay(u, m, 2, kColB, UI_WIDTH); +} + +void drawProgress(u8g2_t *u, int x, int y, int w, int h, uint32_t pos, uint32_t dur) { + u8g2_DrawFrame(u, x, y, w, h); + if (dur > 0) { + uint64_t f = (uint64_t)(w - 4) * (pos > dur ? dur : pos) / dur; + if (f > 0) u8g2_DrawBox(u, x + 2, y + 2, (uint16_t)f, h - 4); + } +} + +void drawBottomBand(u8g2_t *u, const UiModel &m) { + ink(u); + u8g2_DrawHLine(u, 0, kBottomTop, UI_WIDTH); + const SpotifyInfo &s = m.spotify; + const int top = kBottomTop; + + bool active = (s.status == SPOTIFY_PLAYING || s.status == SPOTIFY_PAUSED) && s.title[0]; + if (active) { + iconNote(u, 8, top + 7, 15); + // title row + u8g2_SetFont(u, F_BOLD); + txtFit(u, 32, top + 18, 336, s.title); + if (s.status == SPOTIFY_PLAYING) { + iconPlay(u, 379, top + 7, 13); + } else { + iconPause(u, 379, top + 7, 13); + } + // artist row + elapsed / total + char t1[12], t2[12], tm[28]; + timeutil::formatDuration(s.progressMs, t1, sizeof t1); + timeutil::formatDuration(s.durationMs, t2, sizeof t2); + snprintf(tm, sizeof tm, "%s / %s", t1, t2); + u8g2_SetFont(u, F_SMALL); + int tmW = pitchWidth(u, tm); + u8g2_SetFont(u, F_BODY); + txtFit(u, 32, top + 32, 396 - 32 - tmW - 12, s.artist); + u8g2_SetFont(u, F_SMALL); + txtPitchR(u, 394, top + 32, tm); + drawProgress(u, 8, top + 36, 384, 6, s.progressMs, s.durationMs); + return; + } + + // nothing playing: setup hint, error, or weather extras + u8g2_SetFont(u, F_BODY); + char buf[112]; + const int base = top + 26; + if (s.status == SPOTIFY_NEEDS_LINK && s.linkUrl[0]) { + snprintf(buf, sizeof buf, "Spotify: open %s to link", s.linkUrl); + txtC(u, 200, base, buf); + return; + } + if (s.linkDaysLeft >= 0 && s.linkDaysLeft <= 14 && s.linkUrl[0] && s.status != SPOTIFY_NEEDS_LINK) { + // Spotify expires the link after six months: say so while there is time to renew + snprintf(buf, sizeof buf, "Spotify link ends in %d d - renew at %s", s.linkDaysLeft, s.linkUrl); + txtC(u, 200, base, buf); + return; + } + if (s.status == SPOTIFY_ERROR && s.message[0]) { + snprintf(buf, sizeof buf, "Spotify: %s", s.message); + txtFit(u, 8, base, 384, buf); + return; + } + if (m.weather.valid) { + const WeatherData &w = m.weather; + int wind = iround(m.useFahrenheit ? w.windKmh * 0.621371f : w.windKmh); + snprintf(buf, sizeof buf, "Sunrise %s Sunset %s UV %.0f Wind %d %s", w.sunrise, w.sunset, + w.uvMax, wind, m.useFahrenheit ? "mph" : "km/h"); + txtC(u, 200, base, buf); + } else if (m.wifiOff) { + txtC(u, 200, base, "WiFi is off: clock and indoor sensor only"); + } else if (s.status == SPOTIFY_IDLE) { + txtC(u, 200, base, "Spotify idle"); + } +} + +// Feedback overlay for key presses: replaces the bottom strip with a black band. +void drawToast(u8g2_t *u, const UiModel &m) { + u8g2_SetFont(u, F_H1); + int iconW = m.toastIcon == TOAST_NONE ? 0 : 26; + int x = (UI_WIDTH - (tw(u, m.toast) + iconW)) / 2; + int cy = kBottomTop + (UI_HEIGHT - kBottomTop) / 2; + ink(u); + u8g2_DrawBox(u, 0, kBottomTop, UI_WIDTH, UI_HEIGHT - kBottomTop); + paper(u); + int iy = cy - 8; + switch (m.toastIcon) { + case TOAST_PLAY: iconPlay(u, x, iy, 16); break; + case TOAST_PAUSE: iconPause(u, x, iy, 16); break; + case TOAST_NEXT: iconNext(u, x, iy, 16); break; + case TOAST_PREV: iconPrev(u, x, iy, 16); break; + case TOAST_WARN: txt(u, x + 4, cy + 6, "!"); break; + default: break; + } + txt(u, x + iconW, cy + 6, m.toast); + ink(u); +} + +void drawDashboard(u8g2_t *u, const UiModel &m) { + drawAnalogClock(u, kClockCx, kClockCy, kClockR, m); + drawDateTime(u, m); + drawIndoor(u, m); + drawWeatherBand(u, m); + drawBottomBand(u, m); +} + +// --------------------------------------------------------------------------- +// Now Playing page +// --------------------------------------------------------------------------- +void drawNowPlayingPage(u8g2_t *u, const UiModel &m) { + const SpotifyInfo &s = m.spotify; + ink(u); + bool active = (s.status == SPOTIFY_PLAYING || s.status == SPOTIFY_PAUSED) && s.title[0]; + bool playing = s.status == SPOTIFY_PLAYING; + + u8g2_SetFont(u, F_SMALLB); + txt(u, 8, 36, !active ? "SPOTIFY" : (playing ? "NOW PLAYING" : "PAUSED")); + if (active && s.device[0]) { + u8g2_SetFont(u, F_SMALL); + char dev[72]; + if (s.volume >= 0) { + snprintf(dev, sizeof dev, "on %s \xC2\xB7 vol %d%%", s.device, s.volume); + } else { + snprintf(dev, sizeof dev, "on %s", s.device); + } + int w = tw(u, dev); + txtFit(u, 392 - (w > 240 ? 240 : w), 36, 240, dev); + } + + if (!active) { + u8g2_SetFont(u, F_H1); + const char *msg = "Nothing playing"; + char buf[96]; + const char *hint = "Start something in Spotify and it will show up here"; + if (m.wifiOff) { + msg = "WiFi is off"; + hint = "Set wifi = on in the SD card settings"; + } else if (s.status == SPOTIFY_DISABLED) { + msg = "Spotify is not set up"; + hint = "Add a Client ID: secrets.h or SD card settings"; + } else if (s.status == SPOTIFY_NEEDS_LINK) { + msg = "Link your account"; + if (s.linkUrl[0]) { + snprintf(buf, sizeof buf, "Open %s in a browser", s.linkUrl); + hint = buf; + } else { + hint = "Waiting for the network"; + } + } else if (s.status == SPOTIFY_ERROR && s.message[0]) { + msg = s.message; + hint = "Will retry automatically"; + } + txtC(u, 200, 100, msg); + u8g2_SetFont(u, F_BODY); + txtC(u, 200, 126, hint); + } else { + // title: one line at 18 px if it fits, otherwise up to two lines at 14 px + char lines[2][96]; + u8g2_SetFont(u, u8g2_font_helvB18_tf); + int n = wrapText(u, s.title, 384, lines, 1); + int y = 64; + bool single = n == 1 && strstr(lines[0], "...") == nullptr; + if (single) { + txt(u, 8, y, lines[0]); + } else { + u8g2_SetFont(u, F_H1); + n = wrapText(u, s.title, 384, lines, 2); + y = 60; + for (int i = 0; i < n; i++) { + txt(u, 8, y, lines[i]); + if (i + 1 < n) y += 18; + } + } + u8g2_SetFont(u, F_H2); + y += 24; + txtFit(u, 8, y, 384, s.artist); + if (s.album[0]) { + u8g2_SetFont(u, F_BODY); + y += 19; + txtFit(u, 8, y, 384, s.album); + } + } + + // progress + if (active) { + const int by = 158; + drawProgress(u, 8, by, 384, 10, s.progressMs, s.durationMs); + char t1[12], t2[12]; + timeutil::formatDuration(s.progressMs, t1, sizeof t1); + timeutil::formatDuration(s.durationMs, t2, sizeof t2); + u8g2_SetFont(u, F_BODY); + txtPitch(u, 8, by + 25, t1); + txtPitchR(u, 392, by + 25, t2); + } + + // key legend: the three click patterns of the KEY button + u8g2_DrawHLine(u, 0, 196, UI_WIDTH); + const int iy = 208, isz = 24; + const int cxs[3] = {100, 200, 300}; + iconPrev(u, cxs[0] - isz / 2, iy, isz); + if (playing) { + iconPause(u, cxs[1] - isz / 2, iy, isz); + } else { + iconPlay(u, cxs[1] - isz / 2, iy, isz); + } + iconNext(u, cxs[2] - isz / 2, iy, isz); + u8g2_SetFont(u, F_SMALLB); + txtC(u, cxs[0], iy + 42, "3 clicks"); + txtC(u, cxs[1], iy + 42, "1 click"); + txtC(u, cxs[2], iy + 42, "2 clicks"); + u8g2_SetFont(u, F_SMALL); + txtC(u, cxs[0], iy + 54, "previous"); + txtC(u, cxs[1], iy + 54, playing ? "pause" : "play"); + txtC(u, cxs[2], iy + 54, "next"); + txtC(u, 200, 292, "KEY button | BOOT button: next page"); +} + +// --------------------------------------------------------------------------- +// Info pages: SYSTEM INFO and POWER AND SETTINGS share this; the sketch fills the lines +// --------------------------------------------------------------------------- +void drawInfoPage(u8g2_t *u, const UiModel &m) { + ink(u); + u8g2_SetFont(u, F_SMALLB); + txt(u, 8, 36, m.page == PAGE_POWER ? "POWER AND SETTINGS" : "SYSTEM INFO"); + u8g2_SetFont(u, F_MONO); + int y = 54; + for (int i = 0; i < m.infoCount && i < UI_INFO_LINES; i++) { + txt(u, 8, y, m.info[i]); + y += 17; + } +} + +// --------------------------------------------------------------------------- +// Legend page: what the symbols and the button clicks mean, drawn with the same functions the +// other pages use so the two cannot drift apart. +// --------------------------------------------------------------------------- +void drawLegendPage(u8g2_t *u, const UiModel &) { + ink(u); + u8g2_SetFontMode(u, 1); + u8g2_SetFont(u, F_SMALLB); + txt(u, 8, 36, "LEGEND"); + + // weather: day and night, each kind + const WxIcon icons[9] = {WX_CLEAR, WX_CLEAR, WX_PARTLY, WX_CLOUDY, WX_FOG, WX_DRIZZLE, WX_RAIN, WX_SNOW, WX_THUNDER}; + const char *const names[9] = {"clear", "night", "partly", "cloudy", "fog", "drizzle", "rain", "snow", "storm"}; + for (int i = 0; i < 9; i++) { + drawWxIcon(u, icons[i], i != 1, 8 + i * 44, 44, 24); + u8g2_SetFont(u, F_SMALL); + txtC(u, 8 + i * 44 + 12, 80, names[i]); + } + + // chance of rain, and the moon + u8g2_SetFont(u, F_SMALL); + iconRain(u, 8 + kRainInset, 100 - kRainH); + txt(u, 8 + kMoonD + 2, 100, "35%"); + txt(u, 56, 100, "chance of rain: today and the next two days"); + const int phases[5] = {0, 25, 50, 75, 100}; + for (int i = 0; i < 5; i++) { + const int x = 8 + i * 48; + iconMoon(u, x, 108, kMoonD, phases[i], i < 3, false); + char s[8]; + snprintf(s, sizeof s, "%d%%", phases[i]); + txtPitch(u, x + kMoonD + 2, 117, s); + } + txt(u, 256, 117, "moon: how much is lit"); + txt(u, 256, 128, "(the lit side flips in the south)"); + + // WiFi + for (int i = 0; i < 5; i++) { + const int rssi[5] = {-50, -60, -70, -80, -100}; + drawWifi(u, 8 + i * 22, 148, i < 4, rssi[i]); + } + u8g2_SetFont(u, F_SMALL); + txt(u, 124, 142, "WiFi signal, strong to weak;"); + txt(u, 124, 153, "crossed out: not connected"); + + // the battery gauge, in the states it shows + struct Gauge { + int percent, charge; + bool low; + const char *what; + }; + const Gauge gauges[4] = {{54, CHARGE_DISCHARGING, false, "on battery"}, + {62, CHARGE_CHARGING, false, "charging"}, + {100, CHARGE_FULL, false, "full"}, + {17, CHARGE_DISCHARGING, true, "low: it blinks"}}; + for (int i = 0; i < 4; i++) { + UiModel g; + g.batPresent = true; + g.batPercent = gauges[i].percent; + g.batCharge = gauges[i].charge; + g.batLow = gauges[i].low; + g.batBlinkOn = gauges[i].low; // the inverted phase of the blink + const int gx = 12 + i * 98; // left edge of the gauge and of its caption + drawBatteryAt(u, gx + batteryLayout(u, g).width, 160, g); + u8g2_SetFont(u, F_SMALL); + txt(u, gx, 194, gauges[i].what); + } + + // Spotify + iconNote(u, 8, 203, 15); + iconPlay(u, 168, 204, 13); + iconPause(u, 288, 204, 13); + u8g2_SetFont(u, F_SMALL); + txt(u, 28, 215, "Spotify: now playing"); + txt(u, 186, 215, "playing"); + txt(u, 306, 215, "paused"); + + // the buttons + u8g2_DrawHLine(u, 0, 223, UI_WIDTH); + u8g2_SetFont(u, F_SMALLB); + txt(u, 8, 237, "KEY BUTTON"); + txt(u, 208, 237, "BOOT BUTTON"); + u8g2_SetFont(u, F_BODY); + // (KEY held for five seconds and let go restarts the clock, which is when it reads the SD card) + const char *const key[4] = {"1 click: play / pause", "2 clicks: next track", "3 clicks: previous track", + "hold: refresh; 5 s: restart"}; + const char *const boot[4] = {"1 click: next page", "2 clicks: dashboard", "3 clicks: system info", + "hold: invert the screen"}; + for (int i = 0; i < 4; i++) { + txt(u, 8, 252 + i * 13, key[i]); + txt(u, 208, 252 + i * 13, boot[i]); + } +} + +} // namespace + +void uiDraw(u8g2_t *u, const UiModel &m) { + u8g2_SetFontMode(u, 1); // transparent glyph backgrounds + u8g2_SetFontDirection(u, 0); + u8g2_SetFontPosBaseline(u); + ink(u); + + drawStatusBar(u, m); + switch (m.page) { + case PAGE_NOW_PLAYING: drawNowPlayingPage(u, m); break; + case PAGE_INFO: + case PAGE_POWER: drawInfoPage(u, m); break; + case PAGE_LEGEND: drawLegendPage(u, m); break; + default: drawDashboard(u, m); break; + } + if (m.toast[0]) drawToast(u, m); +} + +// The screen the clock leaves behind when it switches itself off to protect the battery. Static: +// the panel keeps showing it while the chip sleeps. +void uiDrawBatteryEmpty(u8g2_t *u, float volts, float restartVolts, bool offerOverride) { + u8g2_SetFontMode(u, 1); + u8g2_SetFontDirection(u, 0); + u8g2_SetFontPosBaseline(u); + ink(u); + const int w = 132, h = 64, x = (UI_WIDTH - w - 10) / 2, y = 34; + for (int i = 0; i < 3; i++) u8g2_DrawFrame(u, x + i, y + i, w - 2 * i, h - 2 * i); // a thick outline + u8g2_DrawBox(u, x + w, y + 18, 10, h - 36); // the contact + u8g2_DrawBox(u, x + 9, y + 9, 7, h - 18); // a sliver of charge + char s[64]; + u8g2_SetFont(u, F_BIG); + txtC(u, UI_WIDTH / 2, 146, "Battery empty"); + u8g2_SetFont(u, F_H2); + snprintf(s, sizeof s, "Charge it: the clock restarts at %.2f V", restartVolts); + txtC(u, UI_WIDTH / 2, 178, s); + u8g2_SetFont(u, F_BODY); + snprintf(s, sizeof s, "Battery now %.2f V (looked at every 5 minutes)", volts); + txtC(u, UI_WIDTH / 2, 202, s); + txtC(u, UI_WIDTH / 2, 222, "It was shut down to protect the cell."); + if (offerOverride) { + u8g2_SetFont(u, F_SMALL); + txtC(u, UI_WIDTH / 2, 280, "Hold the KEY button for 3 seconds to start anyway"); + } +} + +// --------------------------------------------------------------------------- +// The firmware-update screen (start-up, while the SD card's firmware is checked and written) +// --------------------------------------------------------------------------- +const int kFwBarX = 50, kFwBarY = 168, kFwBarW = 300, kFwBarH = 16; // outer size of the progress bar +const int kFwTextW = 380; // the widest line of text + +// A 2 px frame, a 1 px gap, and a fill of (width - 6) * percent / 100 pixels. +static void fwBar(u8g2_t *u, int percent) { + const int p = percent < 0 ? 0 : (percent > 100 ? 100 : percent); + ink(u); + u8g2_DrawFrame(u, kFwBarX, kFwBarY, kFwBarW, kFwBarH); + u8g2_DrawFrame(u, kFwBarX + 1, kFwBarY + 1, kFwBarW - 2, kFwBarH - 2); + u8g2_DrawBox(u, kFwBarX + 3, kFwBarY + 3, (kFwBarW - 6) * p / 100, kFwBarH - 6); +} + +// A chip with four pins on each side. +static void fwChip(u8g2_t *u, int cx, int y) { + const int s = 44, x = cx - s / 2; + ink(u); + for (int i = 0; i < 3; i++) u8g2_DrawFrame(u, x + i, y + i, s - 2 * i, s - 2 * i); + u8g2_DrawBox(u, x + 14, y + 14, s - 28, s - 28); + for (int i = 0; i < 4; i++) { + const int p = 7 + i * 10; + u8g2_DrawBox(u, x - 8, y + p, 8, 3); + u8g2_DrawBox(u, x + s, y + p, 8, 3); + u8g2_DrawBox(u, x + p, y - 8, 3, 8); + u8g2_DrawBox(u, x + p, y + s, 3, 8); + } +} + +// Centred in the larger type if it fits, else in the body type, else cut short. +static void fwLine(u8g2_t *u, int y, const char *s, const uint8_t *big, const uint8_t *small) { + u8g2_SetFont(u, big); + if (tw(u, s) > kFwTextW) u8g2_SetFont(u, small); + if (tw(u, s) <= kFwTextW) { + txtC(u, UI_WIDTH / 2, y, s); + } else { + txtFit(u, (UI_WIDTH - kFwTextW) / 2, y, kFwTextW, s); + } +} + +void uiDrawFirmwareUpdate(u8g2_t *u, const UiFwScreen &s) { + u8g2_SetFontMode(u, 1); + u8g2_SetFontDirection(u, 0); + u8g2_SetFontPosBaseline(u); + ink(u); + fwChip(u, UI_WIDTH / 2, 26); + const char *heading = s.kind == FW_DONE ? "Firmware updated" : (s.kind == FW_PROBLEM ? "Not updated" : "Firmware update"); + u8g2_SetFont(u, F_BIG); + txtC(u, UI_WIDTH / 2, 118, heading); + if (s.what[0]) fwLine(u, 152, s.what, F_H2, F_BODY); + if (s.percent >= 0) { + fwBar(u, s.percent); + char pct[16]; + snprintf(pct, sizeof pct, "%d %%", s.percent < 0 ? 0 : (s.percent > 100 ? 100 : s.percent)); + u8g2_SetFont(u, F_BODY); + txtC(u, UI_WIDTH / 2, 206, pct); + } + if (s.detail[0]) fwLine(u, 232, s.detail, F_BODY, F_SMALL); + u8g2_SetFont(u, F_SMALL); + if (s.keepPowered) { + txtC(u, UI_WIDTH / 2, 282, "Do not switch off or take the card out"); + } else if (s.kind == FW_PROBLEM) { + txtC(u, UI_WIDTH / 2, 282, "The clock carries on with the firmware it has"); + } else if (s.kind == FW_DONE) { + txtC(u, UI_WIDTH / 2, 282, "On trial for its first minute: if it fails, the old one returns"); + } +} + +// An infinity-mirror cube, in white on black: a cube of mirrors that turns, each face a window on the lattice of +// its own lit frame, reflected without end (infinity_cube.h has the geometry, and how bright each line is). It +// is no page of the clock: the sketch shows it to whoever holds both buttons down for long enough. + +// A line of which `on` pixels are drawn and `off` are not, over and over: the fainter reflections. +static void cubeDashes(u8g2_t *u, int x0, int y0, int x1, int y1, int on, int off) { + const int dx = abs(x1 - x0), dy = abs(y1 - y0), sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1; + int err = dx - dy, i = 0; + for (;;) { + if (i < on) u8g2_DrawPixel(u, x0, y0); + if (++i == on + off) i = 0; + if (x0 == x1 && y0 == y1) break; + const int e2 = 2 * err; + if (e2 > -dy) { + err -= dy; + x0 += sx; + } + if (e2 < dx) { + err += dx; + y0 += sy; + } + } +} + +// An edge of the cube itself, three pixels wide. +static void cubeEdge(u8g2_t *u, int x0, int y0, int x1, int y1) { + u8g2_DrawLine(u, x0, y0, x1, y1); + u8g2_DrawLine(u, x0 + 1, y0, x1 + 1, y1); + u8g2_DrawLine(u, x0 - 1, y0, x1 - 1, y1); + u8g2_DrawLine(u, x0, y0 + 1, x1, y1 + 1); + u8g2_DrawLine(u, x0, y0 - 1, x1, y1 - 1); +} + +namespace { +struct CubePen { + u8g2_t *u; + void line(int x0, int y0, int x1, int y1, infcube::Style style) { + switch (style) { + case infcube::BOLD: cubeEdge(u, x0, y0, x1, y1); break; + case infcube::SOLID: u8g2_DrawLine(u, x0, y0, x1, y1); break; + case infcube::FADE1: cubeDashes(u, x0, y0, x1, y1, 3, 1); break; + case infcube::FADE2: cubeDashes(u, x0, y0, x1, y1, 1, 1); break; + case infcube::FADE3: cubeDashes(u, x0, y0, x1, y1, 1, 3); break; + case infcube::FADE4: cubeDashes(u, x0, y0, x1, y1, 1, 7); break; + default: break; + } + } + void dot(int x, int y) { u8g2_DrawDisc(u, x, y, 3, U8G2_DRAW_ALL); } // a glint on a corner +}; +} // namespace + +// ... and the other cube: sand on its walls, which runs to whatever side of each face is down (sand_cube.h). +namespace { +struct SandPen { + u8g2_t *u; + void grain(int x, int y, int size) { u8g2_DrawBox(u, x - size / 2, y - size / 2, size, size); } + void line(int x0, int y0, int x1, int y1, infcube::Style) { cubeEdge(u, x0, y0, x1, y1); } + void dot(int x, int y) { u8g2_DrawDisc(u, x, y, 3, U8G2_DRAW_ALL); } +}; +} // namespace + + +// One of the two cubes, tumbling (infcube::tumble()). The sand keeps its own state from call to call: it starts +// afresh when the time does, or with another seed. +void uiDrawCube(u8g2_t *u, const UiCube &cube) { + static sandcube::Sand sand; + static uint32_t lastMs = 0xFFFFFFFFu, lastSeed = 0; + if (cube.elapsedMs < lastMs || cube.seed != lastSeed) sand.reset(cube.seed); // a new showing + lastMs = cube.elapsedMs; + lastSeed = cube.seed; + infcube::V3 axis[3]; + infcube::tumble(cube.turnMs, cube.seed, cube.shove, axis); + sand.advance(cube.elapsedMs, axis); // (the sand runs on while the mirrors are shown: it is the same cube) + ink(u); + u8g2_DrawBox(u, 0, 0, UI_WIDTH, UI_HEIGHT); + paper(u); + if (cube.mirrors) { + CubePen pen{u}; + infcube::draw(axis, cube.elapsedMs, UI_WIDTH, UI_HEIGHT, pen); + } else { + SandPen pen{u}; + sand.draw(axis, UI_WIDTH, UI_HEIGHT, pen); + } + ink(u); +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/ui.h b/02_Example/Arduino/11_Clock_Dashboard/ui.h new file mode 100644 index 00000000..9f86293d --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/ui.h @@ -0,0 +1,36 @@ +#pragma once + +// Screen renderer. Pure drawing code: it only uses the U8g2 C API and libc, +// so the same file runs on the ESP32 and in the host-side preview tool +// (tools/ui_preview) that renders frames to PNG for layout work. + +#include "app_model.h" + +#include + +// Logical drawing surface: 400 x 300, 1 = ink (black), 0 = paper (white). +#define UI_WIDTH 400 +#define UI_HEIGHT 300 + +// Draws one complete frame described by `model`. The caller clears the buffer +// beforehand and sends it to the panel afterwards. +void uiDraw(u8g2_t *u8g2, const UiModel &model); + +// The "battery empty" screen shown when the clock switches itself off to protect the cell: how +// far the battery is, the level it restarts at, and (offerOverride) the hold-KEY way round it. +void uiDrawBatteryEmpty(u8g2_t *u8g2, float volts, float restartVolts, bool offerOverride); + +// The firmware-update screen: a chip, a heading, what is going on, a progress bar and the file. +void uiDrawFirmwareUpdate(u8g2_t *u8g2, const UiFwScreen &screen); + +// A tumbling cube, white on black, over the whole screen: one of mirrors, each face a window on the reflections +// of its own lit edges, or one with sand on its walls that runs to whichever side is down. The caller sends the +// buffer. It is no page of the clock. +struct UiCube { + bool mirrors = false; // which of the two + uint32_t seed = 1; // of the way it tumbles (and of where the sand lies to begin with): another one every showing + float turnMs = 0; // how far along its tumbling it is, in milliseconds + float shove[3] = {0, 0, 0}; // what it has been shoved round by on top of that (infcube::Shove::angle) + uint32_t elapsedMs = 0; // since it came up; has to go up from call to call (the sand runs by it) +}; +void uiDrawCube(u8g2_t *u8g2, const UiCube &cube); diff --git a/02_Example/Arduino/11_Clock_Dashboard/util.h b/02_Example/Arduino/11_Clock_Dashboard/util.h new file mode 100644 index 00000000..9bba66fe --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/util.h @@ -0,0 +1,111 @@ +#pragma once + +// Small string helpers (no Arduino dependency). + +#include +#include +#include +#include + +// 204 / 205 / 304 replies carry no body and no Content-Length. Arduino's +// HTTPClient::getString() would wait for the connection to close on those, so +// callers must check this first. +inline bool httpStatusHasBody(int code) { return code >= 200 && code != 204 && code != 205 && code != 304; } + +// Bounded copy that always NUL-terminates. +inline void copyStr(char *dst, size_t cap, const char *src) { + if (cap == 0) return; + size_t n = src ? strlen(src) : 0; + if (n >= cap) n = cap - 1; + if (n) memcpy(dst, src, n); + dst[n] = 0; +} + +// base64url without padding (RFC 4648 section 5), as PKCE requires. +// Returns the encoded length, or 0 if `cap` was too small. +inline size_t base64UrlEncode(const uint8_t *in, size_t len, char *out, size_t cap) { + static const char *tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + size_t need = (len * 4 + 2) / 3; + if (cap < need + 1) return 0; + size_t o = 0; + for (size_t i = 0; i < len; i += 3) { + uint32_t v = (uint32_t)in[i] << 16; + if (i + 1 < len) v |= (uint32_t)in[i + 1] << 8; + if (i + 2 < len) v |= in[i + 2]; + out[o++] = tbl[(v >> 18) & 63]; + out[o++] = tbl[(v >> 12) & 63]; + if (i + 1 < len) out[o++] = tbl[(v >> 6) & 63]; + if (i + 2 < len) out[o++] = tbl[v & 63]; + } + out[o] = 0; + return o; +} + +// Percent-encodes everything except RFC 3986 unreserved characters. +// Returns the encoded length, or 0 if `cap` was too small. +inline size_t urlEncode(const char *src, char *dst, size_t cap) { + static const char *hex = "0123456789ABCDEF"; + size_t n = 0; + for (const unsigned char *p = (const unsigned char *)src; *p; ++p) { + bool keep = (*p >= 'A' && *p <= 'Z') || (*p >= 'a' && *p <= 'z') || (*p >= '0' && *p <= '9') || + *p == '-' || *p == '_' || *p == '.' || *p == '~'; + if (keep) { + if (n + 1 >= cap) return 0; + dst[n++] = (char)*p; + } else { + if (n + 3 >= cap) return 0; + dst[n++] = '%'; + dst[n++] = hex[*p >> 4]; + dst[n++] = hex[*p & 15]; + } + } + if (n >= cap) return 0; + dst[n] = 0; + return n; +} + +// Decodes %XX and '+' in place. +inline void urlDecodeInPlace(char *s) { + char *w = s; + for (const char *r = s; *r; ++r) { + if (*r == '%' && r[1] && r[2]) { + auto hv = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + int hi = hv(r[1]), lo = hv(r[2]); + if (hi >= 0 && lo >= 0) { + *w++ = (char)(hi * 16 + lo); + r += 2; + continue; + } + } + *w++ = (*r == '+') ? ' ' : *r; + } + *w = 0; +} + +// Finds `key` in a query string ("a=1&b=2" or a full URL containing one) and +// copies the decoded value into out. Returns false when absent. +inline bool queryParam(const char *text, const char *key, char *out, size_t cap) { + const char *q = strchr(text, '?'); + const char *p = q ? q + 1 : text; + size_t klen = strlen(key); + while (*p) { + const char *end = p; + while (*end && *end != '&' && *end != '#') ++end; + if ((size_t)(end - p) > klen && strncmp(p, key, klen) == 0 && p[klen] == '=') { + size_t vlen = (size_t)(end - (p + klen + 1)); + if (vlen >= cap) vlen = cap - 1; + memcpy(out, p + klen + 1, vlen); + out[vlen] = 0; + urlDecodeInPlace(out); + return true; + } + if (*end == '#') break; + p = *end ? end + 1 : end; + } + return false; +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/weather.cpp b/02_Example/Arduino/11_Clock_Dashboard/weather.cpp new file mode 100644 index 00000000..beeb592a --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/weather.cpp @@ -0,0 +1,111 @@ +#include "weather.h" + +#include +#include +#include + +#include "timeutil.h" +#include "util.h" + +namespace { + +// "2026-10-04" -> weekday 0..6 (0 = Sunday); false if malformed. +bool weekdayOfIsoDate(const char *date, uint8_t *wday) { + int y, m, d; + if (!date || sscanf(date, "%d-%d-%d", &y, &m, &d) != 3) return false; + if (m < 1 || m > 12 || d < 1 || d > 31) return false; + *wday = (uint8_t)timeutil::weekday(y, m, d); + return true; +} + +// "2026-10-04T05:52" -> "05:52" +void timeOfIsoDateTime(const char *s, char *out, size_t cap) { + const char *t = s ? strchr(s, 'T') : nullptr; + if (t && strlen(t + 1) >= 5) { + snprintf(out, cap, "%.5s", t + 1); + } else { + copyStr(out, cap, "--:--"); + } +} + +uint8_t clampPct(int v) { return (uint8_t)(v < 0 ? 0 : (v > 100 ? 100 : v)); } + +} // namespace + +bool parseOpenMeteoForecast(const char *json, size_t len, WeatherData *out, ZoneInfo *zone) { + JsonDocument doc; + if (deserializeJson(doc, json, len)) return false; + + JsonObjectConst cur = doc["current"]; + JsonObjectConst daily = doc["daily"]; + if (cur.isNull() || daily.isNull()) return false; + // Everything the clock shows as a fact about now has to be in the reply. A missing field would + // otherwise become a plausible default (a clear sky, 0 % humidity, no wind) that is simply wrong. + static const char *const kCurrent[] = {"temperature_2m", "apparent_temperature", "relative_humidity_2m", + "weather_code", "wind_speed_10m", "is_day"}; + for (const char *key : kCurrent) + if (cur[key].isNull()) return false; + + WeatherData w; + w.valid = true; + w.temp = cur["temperature_2m"] | 0.0f; + w.feels = cur["apparent_temperature"] | w.temp; + w.windKmh = cur["wind_speed_10m"] | 0.0f; + w.humidity = clampPct(cur["relative_humidity_2m"] | 0); + w.code = (uint8_t)(cur["weather_code"] | 0); + w.isDay = (cur["is_day"] | 1) != 0; + + JsonArrayConst times = daily["time"]; + JsonArrayConst codes = daily["weather_code"]; + JsonArrayConst tmax = daily["temperature_2m_max"]; + JsonArrayConst tmin = daily["temperature_2m_min"]; + JsonArrayConst pop = daily["precipitation_probability_max"]; + JsonArrayConst rise = daily["sunrise"]; + JsonArrayConst set = daily["sunset"]; + JsonArrayConst uv = daily["uv_index_max"]; + // The dates, conditions and temperatures of all three days are needed. The rain chance, sunrise, + // sunset and UV are not: the service leaves them null where it has no figure (no probability model for + // a region, a polar day), and the clock then shows 0 %, "--:--" and 0. + if (times.size() < 3 || codes.size() < 3 || tmax.size() < 3 || tmin.size() < 3) return false; + + for (int i = 0; i < 3; i++) { + WeatherDay &d = w.day[i]; + if (codes[i].isNull() || tmax[i].isNull() || tmin[i].isNull()) return false; + d.code = (uint8_t)(codes[i] | 0); + d.tmax = tmax[i] | 0.0f; + d.tmin = tmin[i] | 0.0f; + d.rainPct = clampPct(pop[i] | 0); + if (!weekdayOfIsoDate(times[i] | "", &d.weekday)) return false; + } + timeOfIsoDateTime(rise[0] | "", w.sunrise, sizeof w.sunrise); + timeOfIsoDateTime(set[0] | "", w.sunset, sizeof w.sunset); + w.uvMax = uv[0] | 0.0f; + + *out = w; + if (zone) { + copyStr(zone->iana, sizeof zone->iana, doc["timezone"] | ""); + zone->utcOffsetSec = doc["utc_offset_seconds"] | 0; + // A zone name that is not in the clock's table is turned into a fixed offset, so a reply that names + // a zone but gives no offset must not count as one: it would mean UTC. (No zone at all leaves the + // clock's zone alone.) + if (doc["utc_offset_seconds"].isNull()) zone->iana[0] = 0; + } + return true; +} + +bool parseOpenMeteoGeocode(const char *json, size_t len, GeoResult *out) { + JsonDocument doc; + if (deserializeJson(doc, json, len)) return false; + JsonObjectConst r = doc["results"][0]; + if (r.isNull() || r["latitude"].isNull() || r["longitude"].isNull()) return false; + + GeoResult g; + g.lat = r["latitude"] | 0.0; + g.lon = r["longitude"] | 0.0; + copyStr(g.name, sizeof g.name, r["name"] | ""); + copyStr(g.admin1, sizeof g.admin1, r["admin1"] | ""); + copyStr(g.country, sizeof g.country, r["country_code"] | ""); + copyStr(g.iana, sizeof g.iana, r["timezone"] | ""); + *out = g; + return true; +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/weather.h b/02_Example/Arduino/11_Clock_Dashboard/weather.h new file mode 100644 index 00000000..211be571 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/weather.h @@ -0,0 +1,33 @@ +#pragma once + +// Parsers for Open-Meteo replies. Pure functions (ArduinoJson only), so they +// are covered by the host-side tests in tools/tests. + +#include + +#include "app_model.h" + +struct ZoneInfo { + char iana[48] = ""; // e.g. "Australia/Perth" + int utcOffsetSec = 0; // offset at the time of the request +}; + +// Parses a /v1/forecast reply requested with: +// current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m +// daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max, +// sunrise,sunset,uv_index_max +// timezone=auto&forecast_days=3 +// Returns false if the reply is malformed or incomplete. +bool parseOpenMeteoForecast(const char *json, size_t len, WeatherData *out, ZoneInfo *zone); + +struct GeoResult { + double lat = 0; + double lon = 0; + char name[40] = ""; + char admin1[40] = ""; + char country[8] = ""; + char iana[48] = ""; +}; + +// Parses the first hit of a geocoding-api.open-meteo.com/v1/search reply. +bool parseOpenMeteoGeocode(const char *json, size_t len, GeoResult *out); diff --git a/02_Example/Arduino/11_Clock_Dashboard/weather_codes.h b/02_Example/Arduino/11_Clock_Dashboard/weather_codes.h new file mode 100644 index 00000000..576fee7c --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/weather_codes.h @@ -0,0 +1,85 @@ +#pragma once + +// WMO weather interpretation codes as used by Open-Meteo +// (https://open-meteo.com/en/docs#weathervariables). + +#include + +enum WxIcon : uint8_t { + WX_CLEAR = 0, // sun by day, moon by night + WX_PARTLY, // sun + cloud + WX_CLOUDY, + WX_FOG, + WX_DRIZZLE, + WX_RAIN, + WX_SNOW, + WX_THUNDER +}; + +inline WxIcon wmoIcon(int code) { + switch (code) { + case 0: return WX_CLEAR; + case 1: + case 2: return WX_PARTLY; + case 3: return WX_CLOUDY; + case 45: + case 48: return WX_FOG; + case 51: + case 53: + case 55: + case 56: + case 57: return WX_DRIZZLE; + case 61: + case 63: + case 65: + case 66: + case 67: + case 80: + case 81: + case 82: return WX_RAIN; + case 71: + case 73: + case 75: + case 77: + case 85: + case 86: return WX_SNOW; + case 95: + case 96: + case 99: return WX_THUNDER; + default: return WX_CLOUDY; + } +} + +inline const char *wmoText(int code) { + switch (code) { + case 0: return "Clear"; + case 1: return "Mostly clear"; + case 2: return "Partly cloudy"; + case 3: return "Overcast"; + case 45: return "Fog"; + case 48: return "Rime fog"; + case 51: return "Light drizzle"; + case 53: return "Drizzle"; + case 55: return "Heavy drizzle"; + case 56: + case 57: return "Icy drizzle"; + case 61: return "Light rain"; + case 63: return "Rain"; + case 65: return "Heavy rain"; + case 66: + case 67: return "Freezing rain"; + case 71: return "Light snow"; + case 73: return "Snow"; + case 75: return "Heavy snow"; + case 77: return "Snow grains"; + case 80: return "Light showers"; + case 81: return "Showers"; + case 82: return "Heavy showers"; + case 85: + case 86: return "Snow showers"; + case 95: return "Thunderstorm"; + case 96: + case 99: return "Storm + hail"; + default: return "Unknown"; + } +} diff --git a/02_Example/Arduino/11_Clock_Dashboard/wifi_pick.h b/02_Example/Arduino/11_Clock_Dashboard/wifi_pick.h new file mode 100644 index 00000000..4e78e4b7 --- /dev/null +++ b/02_Example/Arduino/11_Clock_Dashboard/wifi_pick.h @@ -0,0 +1,115 @@ +#pragma once + +// Which WiFi network the clock tries next, when it has a backup. Pure logic (no WiFi calls; the +// times are passed in), so tools/tests covers it on a PC. +// +// * The main network always comes first: at start-up, and after a connection was lost. +// * When an attempt runs out of time the other network is tried, and so on, alternating. +// * While the clock sits on the backup it looks for the main network every 10 minutes (the sketch +// scans for it) and goes back as soon as it is in range, so it does not stay on, say, a phone's +// hotspot for days. If going back does not work out (the main network is in range but cannot be +// joined) the clock ends up on the backup again and the looks come further apart: 20 minutes, +// 40, then every hour. Joining the main network starts over at 10 minutes. + +#include +#include + +namespace wifipick { + +enum Net : int8_t { NET_NONE = -1, NET_MAIN = 0, NET_BACKUP = 1 }; + +const uint32_t kLookFirstMs = 10UL * 60UL * 1000UL; +const uint32_t kLookMaxMs = 60UL * 60UL * 1000UL; + +// Which of the two a connection is, going by the name the WiFi stack reports (it may have +// reconnected by itself): the main network if the name matches, the backup if that one does. +inline Net classify(const char *joined, const char *mainSsid, const char *backupSsid) { + if (mainSsid[0] && !strcmp(joined, mainSsid)) return NET_MAIN; + if (backupSsid[0] && !strcmp(joined, backupSsid)) return NET_BACKUP; + return mainSsid[0] ? NET_MAIN : NET_BACKUP; // a name we do not know: say what we have +} + +class Picker { + public: + // Which names are set; a backup equal to the main network counts as none (Settings::hasBackupWifi). + void configure(bool hasMain, bool hasBackup) { + hasMain_ = hasMain; + hasBackup_ = hasBackup; + current_ = NET_NONE; + lastTried_ = NET_NONE; + lookAtMs_ = 0; + failedReturns_ = 0; + returning_ = false; + } + + // The network for the next association attempt; NET_NONE if no name is set. + Net nextAttempt() { + Net n = NET_NONE; + if (hasMain_ && hasBackup_) { + n = lastTried_ == NET_MAIN ? NET_BACKUP : NET_MAIN; + } else if (hasMain_) { + n = NET_MAIN; + } else if (hasBackup_) { + n = NET_BACKUP; + } + lastTried_ = n; + return n; + } + + // The connection is up on `net`. + void joined(Net net, uint32_t nowMs) { + current_ = net; + if (net == NET_BACKUP) { + if (returning_ && failedReturns_ < 8) failedReturns_++; // we set out for the main network and are back here + lookAtMs_ = nowMs + lookInterval(); + } else if (net == NET_MAIN) { + failedReturns_ = 0; + } + returning_ = false; + } + + // The connection dropped (or was dropped on purpose): the next attempt starts with the main network. + void lost() { + current_ = NET_NONE; + lastTried_ = NET_NONE; + } + + // The radio was switched off on purpose (wifi_mode = sync): the network joined is still the network to + // go back to, so the next attempt starts with it, not with the main one when the clock is on the backup + // (that would cost a failed attempt of 20 seconds at every session). The backup's looks for the main + // network go on by the clock. + void radioOff() { + lastTried_ = current_ == NET_BACKUP ? NET_MAIN : NET_NONE; + } + + Net current() const { return current_; } + bool onBackup() const { return current_ == NET_BACKUP; } + + // On the backup, and long enough since it was joined (or since the last look)? + bool timeToLookForMain(uint32_t nowMs) const { + return hasMain_ && current_ == NET_BACKUP && (int32_t)(nowMs - lookAtMs_) >= 0; + } + + // The sketch looked: `mainSeen` says the main network is in range. Then the clock leaves the backup + // and the next attempt is the main network. + void lookedForMain(uint32_t nowMs, bool mainSeen) { + lookAtMs_ = nowMs + kLookFirstMs; // (until the next join sets the real interval) + if (mainSeen) returning_ = true; + } + + uint32_t lookInterval() const { + uint32_t t = kLookFirstMs; + for (int i = 0; i < failedReturns_ && t < kLookMaxMs; i++) t *= 2; + return t > kLookMaxMs ? kLookMaxMs : t; + } + + private: + bool hasMain_ = false, hasBackup_ = false; + Net current_ = NET_NONE; + Net lastTried_ = NET_NONE; + uint32_t lookAtMs_ = 0; + int failedReturns_ = 0; + bool returning_ = false; +}; + +} // namespace wifipick