Default fresh settings to sound off and vibration on, and keep existing saved choices. Apply changes immediately across all three UIs and make custom notification patterns respect the same mute controls. Keep the Wio Mini Companion within its existing flash boundary by storing its LED switch in a reserved preference byte and shortening CLI help without removing commands. Add coverage for live output changes, persistence, and storage failures. Adapted from https://github.com/meshcore-dev/MeshCore/pull/3603. Co-authored-by: R0ck <develop@hectospark.co.uk>
478 lines
18 KiB
C++
478 lines
18 KiB
C++
// Compile the complete production UITask::loop() with a true 32-bit clock.
|
|
// Screen rendering, radio timing, display and unrelated services are adapters;
|
|
// deadline, popup, polling and scheduling decisions remain production source.
|
|
#include <cassert>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <initializer_list>
|
|
|
|
static uint32_t now_ms = 0;
|
|
static uint32_t millis() { return now_ms; }
|
|
static void delay(uint32_t duration) { now_ms += duration; }
|
|
static constexpr uint32_t BOOT_SCREEN_MILLIS = 3000;
|
|
#define MESH_DEBUG_PRINTLN(...) do {} while (0)
|
|
struct Radio { bool timing = false; } radio_driver;
|
|
struct TestMesh { unsigned rescues = 0; void enterCLIRescue() { ++rescues; } } the_mesh;
|
|
namespace mesh {
|
|
static bool morseTimingActive(const Radio* radio) { return radio && radio->timing; }
|
|
namespace ui { enum class DisplayWake { Button }; }
|
|
}
|
|
namespace UIColor { constexpr int popup_bkg = 0, popup_txt = 1, warning_txt = 2; }
|
|
struct Board { bool isExternalPowered() const { return false; } } board;
|
|
struct Display {
|
|
bool on = true;
|
|
unsigned frames = 0, popups = 0;
|
|
bool isOn() const { return on; }
|
|
bool isEink() const { return false; }
|
|
int height() const { return 64; }
|
|
int width() const { return 128; }
|
|
void wake(mesh::ui::DisplayWake) { on = true; }
|
|
void startFrame() { ++frames; }
|
|
void endFrame() {}
|
|
void setTextSize(int) {}
|
|
void setColor(int) {}
|
|
void fillRect(int, int, int, int) {}
|
|
void drawRect(int, int, int, int) {}
|
|
void drawTextCentered(int, int, const char* text) { if (!std::strcmp(text, "ALERT")) ++popups; }
|
|
};
|
|
struct UIScreen {
|
|
unsigned renders = 0, polls = 0;
|
|
int delay_ms = 1000;
|
|
int render(Display&) { ++renders; return delay_ms; }
|
|
void poll() { ++polls; }
|
|
void handleInput(char) {}
|
|
};
|
|
struct HomeScreen : UIScreen { void showFirstPage() {} };
|
|
struct StatusBar {
|
|
void update(Display&, const char*, uint16_t, bool, bool, bool) {}
|
|
bool needsRedraw() const { return false; }
|
|
void render(Display&) {}
|
|
};
|
|
struct UITask {
|
|
Display display;
|
|
Display* _display = &display;
|
|
HomeScreen screen;
|
|
UIScreen* curr = &screen;
|
|
UIScreen* home = &screen;
|
|
UIScreen preview;
|
|
UIScreen* msg_preview = &preview;
|
|
StatusBar _statusBar;
|
|
struct Prefs { const char* node_name = "test"; } prefs;
|
|
Prefs* _node_prefs = &prefs;
|
|
// Arduino unsigned long is 32 bits; the host unsigned long is NOT used.
|
|
uint32_t _next_refresh = 0, _alert_expiry = 0, ui_started_at = 0, next_batt_chck = 0;
|
|
uint32_t _pairing_screen_until = 0;
|
|
bool _pairing_from_button = false;
|
|
bool _startup_complete = false;
|
|
uint16_t _cached_batt_mv = 4000, voltage = 4000;
|
|
bool gesture_pending = false;
|
|
bool _need_refresh = true;
|
|
unsigned battery_reads = 0, shutdowns = 0, profile_services = 0;
|
|
char _alert[80] = {}, _origin[80] = {}, _msg[80] = {};
|
|
void servicePairingState() {}
|
|
void syncAlertPreferences() {}
|
|
void serviceWiFiToggleButton() {}
|
|
bool isPairingScreenActive() const { return false; }
|
|
bool isButtonGesturePending() const { return gesture_pending; }
|
|
bool isBuzzerQuiet() const { return true; }
|
|
bool getGPSState() const { return false; }
|
|
bool isBluetoothEnabled() const { return false; }
|
|
void userLedHandler() {}
|
|
void renderPairingBanner() {}
|
|
void serviceRadioProfileDisplayPage() { ++profile_services; }
|
|
void renderCurrScreen() { ++screen.renders; _need_refresh = false; }
|
|
uint16_t getBattMilliVolts() { ++battery_reads; return voltage; }
|
|
void shutdown() { ++shutdowns; }
|
|
void showAlert(const char* text, int duration_millis);
|
|
void setCurrScreen(UIScreen* screen);
|
|
void loop();
|
|
void handleButtonLongPress();
|
|
char handleLongPress(char);
|
|
};
|
|
@LOOP@
|
|
@ALERT@
|
|
@SCREEN@
|
|
|
|
static void forceCurrentScreen(UITask& ui) {
|
|
#if UI_ORIG
|
|
// Orig has no screen selector. Dirty is a real incoming-event condition;
|
|
// retain its already-due refresh so the test never invents a comparator.
|
|
ui._need_refresh = true;
|
|
#else
|
|
ui.setCurrScreen(ui.home); // Actual source's force-refresh operation.
|
|
#endif
|
|
}
|
|
|
|
int main(int argc, char** argv) {
|
|
setbuf(stdout, nullptr);
|
|
assert(argc == 2);
|
|
const char* scenario = argv[1];
|
|
UITask ui;
|
|
if (!std::strcmp(scenario, "startup-first-draw")) {
|
|
#if UI_ORIG
|
|
ui.ui_started_at = now_ms = 1000;
|
|
ui._need_refresh = false;
|
|
ui.display.on = false;
|
|
ui.loop();
|
|
now_ms = 9000; ui.loop();
|
|
for (uint64_t day = 1; day <= 49; ++day) {
|
|
now_ms = uint32_t(1000u + day * 86400000ULL);
|
|
ui.loop();
|
|
}
|
|
now_ms = 2000; ui.loop(); // Full-wrap alias, startup already observed closed.
|
|
ui.display.on = true; ui.loop();
|
|
assert(ui.display.frames == 1 && "deferred first paint must remain post-startup after full wrap");
|
|
#endif
|
|
std::puts("PASS: startup-first-draw");
|
|
return 0;
|
|
}
|
|
if (!std::strcmp(scenario, "startup-one-shot")) {
|
|
for (uint32_t started : {0u, 1000u, UINT32_MAX - 4000u}) {
|
|
for (unsigned gate = 0; gate < 4; ++gate) {
|
|
UITask window;
|
|
window.ui_started_at = started;
|
|
window._next_refresh = now_ms = started;
|
|
window.display.on = (gate & 1) == 0;
|
|
radio_driver.timing = (gate & 2) != 0;
|
|
window.loop();
|
|
now_ms = started + 7999u; window.loop();
|
|
const auto before = the_mesh.rescues;
|
|
#if UI_ORIG
|
|
window.handleButtonLongPress();
|
|
#else
|
|
assert(window.handleLongPress('x') == 0);
|
|
#endif
|
|
assert(the_mesh.rescues == before + 1 && !window._startup_complete);
|
|
now_ms = started + 8000u; window.loop();
|
|
#if UI_ORIG
|
|
window.handleButtonLongPress();
|
|
assert(window.shutdowns == 1);
|
|
#else
|
|
assert(window.handleLongPress('x') == 'x');
|
|
#endif
|
|
assert(the_mesh.rescues == before + 1); // Exact 8-second cutoff.
|
|
for (uint64_t day = 1; day <= 49; ++day) {
|
|
now_ms = uint32_t(started + day * 86400000ULL);
|
|
window.loop();
|
|
}
|
|
// Live observation of expiry precedes the complete-counter alias.
|
|
now_ms = started + 1000u; window.loop();
|
|
window.display.on = true; radio_driver.timing = false;
|
|
const auto profiles = window.profile_services;
|
|
window.loop();
|
|
#if UI_ORIG
|
|
window.handleButtonLongPress();
|
|
#else
|
|
(void)profiles;
|
|
const char returned = window.handleLongPress('x');
|
|
#endif
|
|
assert(the_mesh.rescues == before + 1 && window._startup_complete
|
|
&& "expired startup rescue must never reopen after full wrap");
|
|
#if UI_ORIG
|
|
assert(window.shutdowns == 2 && window.profile_services == profiles + 1);
|
|
#else
|
|
assert(returned == 'x');
|
|
#endif
|
|
}
|
|
}
|
|
std::puts("PASS: startup-one-shot");
|
|
return 0;
|
|
}
|
|
if (!std::strcmp(scenario, "orig-long-clean-idle")) {
|
|
#if UI_ORIG
|
|
now_ms = 0x90001000u;
|
|
const uint32_t origin = now_ms;
|
|
ui.ui_started_at = now_ms - BOOT_SCREEN_MILLIS;
|
|
ui._next_refresh = now_ms;
|
|
// A persistent message preview disables automatic radio-page dirtiness.
|
|
std::strcpy(ui._origin, "sender");
|
|
std::strcpy(ui._msg, "retained preview");
|
|
ui.loop();
|
|
assert(ui.display.frames == 1 && !ui._need_refresh);
|
|
for (uint32_t day = 1; day <= 26; ++day) {
|
|
now_ms = origin + day * 86400000u;
|
|
ui.loop(); // Every individual elapsed step is far below half a wrap.
|
|
assert(ui.display.frames == 1 && !ui._need_refresh);
|
|
}
|
|
ui._need_refresh = true; // A fresh message finally makes the view dirty.
|
|
ui.loop();
|
|
std::printf("orig after 26 clean days: frames=%u next=%u now=%u\n",
|
|
ui.display.frames, ui._next_refresh, now_ms);
|
|
assert(ui.display.frames == 2 && "a clean interval cannot age a due deadline back into the future");
|
|
#else
|
|
assert(false && "orig-only scenario");
|
|
#endif
|
|
} else if (!std::strcmp(scenario, "display-off-long-idle")) {
|
|
#if !UI_ORIG
|
|
now_ms = 0x90001000u;
|
|
const uint32_t origin = now_ms;
|
|
ui.ui_started_at = now_ms - BOOT_SCREEN_MILLIS;
|
|
ui._next_refresh = now_ms;
|
|
ui.next_batt_chck = now_ms;
|
|
ui.display.on = false;
|
|
ui.loop();
|
|
for (uint32_t day = 1; day <= 26; ++day) {
|
|
now_ms = origin + day * 86400000u;
|
|
ui.loop();
|
|
assert(ui.display.frames == 0);
|
|
}
|
|
ui.display.on = true; // Do not inject a force-refresh assignment on wake.
|
|
ui.loop();
|
|
std::printf("wake after 26 display-off days: frames=%u next=%u now=%u\n",
|
|
ui.display.frames, ui._next_refresh, now_ms);
|
|
assert(ui.display.frames == 1 && "live loops must preserve due refresh status while display is off");
|
|
#else
|
|
assert(false && "screened layouts only");
|
|
#endif
|
|
} else if (!std::strcmp(scenario, "fresh-dirty-throttle")) {
|
|
#if UI_ORIG
|
|
now_ms = 0x90001000u;
|
|
ui.ui_started_at = now_ms - BOOT_SCREEN_MILLIS;
|
|
ui._next_refresh = now_ms;
|
|
std::strcpy(ui._origin, "sender");
|
|
std::strcpy(ui._msg, "preview");
|
|
ui.loop();
|
|
assert(ui.display.frames == 1 && !ui._need_refresh);
|
|
++now_ms;
|
|
ui._need_refresh = true; // Fresh incoming dirtiness, not a force operation.
|
|
ui.loop();
|
|
assert(ui.display.frames == 1 && "normal fresh dirtiness still respects the one-second throttle");
|
|
now_ms += 998u;
|
|
ui.loop();
|
|
assert(ui.display.frames == 1);
|
|
++now_ms;
|
|
ui.loop();
|
|
assert(ui.display.frames == 2);
|
|
#else
|
|
assert(false && "orig-only dirty-flag scenario");
|
|
#endif
|
|
} else if (!std::strcmp(scenario, "cache-battery-long-idle")) {
|
|
#if UI_TINY_CACHE
|
|
now_ms = 0x90001000u;
|
|
const uint32_t origin = now_ms;
|
|
ui.ui_started_at = now_ms - BOOT_SCREEN_MILLIS;
|
|
ui._next_refresh = now_ms;
|
|
ui.next_batt_chck = now_ms;
|
|
ui.display.on = false;
|
|
ui.loop();
|
|
for (uint32_t day = 1; day <= 26; ++day) {
|
|
now_ms = origin + day * 86400000u;
|
|
ui.loop();
|
|
assert(ui.battery_reads == 0 && ui.display.frames == 0);
|
|
}
|
|
ui.display.on = true;
|
|
radio_driver.timing = true;
|
|
ui.loop();
|
|
assert(ui.battery_reads == 0 && ui.display.frames == 0);
|
|
radio_driver.timing = false;
|
|
++now_ms;
|
|
ui.loop();
|
|
std::printf("cache ADC after 26 off days and Morse release: reads=%u next=%u now=%u\n",
|
|
ui.battery_reads, ui.next_batt_chck, now_ms);
|
|
assert(ui.battery_reads == 1 && "cached battery poll stays due while off but samples only on safe wake");
|
|
assert(ui.display.frames == 1);
|
|
#else
|
|
assert(false && "tiny no-auto-shutdown cache-only scenario");
|
|
#endif
|
|
} else if (!std::strcmp(scenario, "cache-battery-wrapped-future")) {
|
|
#if UI_TINY_CACHE
|
|
now_ms = UINT32_MAX - 7999u;
|
|
ui.next_batt_chck = now_ms;
|
|
ui.loop();
|
|
assert(ui.battery_reads == 1 && ui.next_batt_chck == 0);
|
|
++now_ms;
|
|
ui.loop();
|
|
assert(ui.battery_reads == 1 && "future wrapped zero cannot be clamped into an immediate cache poll");
|
|
ui.display.on = false;
|
|
now_ms = 1u;
|
|
ui.loop();
|
|
assert(ui.battery_reads == 1);
|
|
ui.display.on = true;
|
|
radio_driver.timing = true;
|
|
ui.loop();
|
|
assert(ui.battery_reads == 1);
|
|
radio_driver.timing = false;
|
|
ui.loop();
|
|
assert(ui.battery_reads == 2 && "due cached ADC waits for both display and Morse gates");
|
|
#else
|
|
assert(false && "tiny cache-only scenario");
|
|
#endif
|
|
} else if (!std::strcmp(scenario, "healthy") || !std::strcmp(scenario, "upper-half")) {
|
|
now_ms = !std::strcmp(scenario, "healthy") ? 10000u : 0x80001000u;
|
|
ui.ui_started_at = now_ms - BOOT_SCREEN_MILLIS;
|
|
ui._next_refresh = now_ms + 1000u;
|
|
ui.loop();
|
|
assert(ui.display.frames == 0);
|
|
now_ms += 999u;
|
|
ui.loop();
|
|
assert(ui.display.frames == 0);
|
|
++now_ms;
|
|
ui.loop();
|
|
assert(ui.display.frames == 1 && ui.screen.renders == 1);
|
|
} else if (!std::strcmp(scenario, "force-upper-half")) {
|
|
#if UI_ORIG
|
|
assert(false && "orig has no setCurrScreen operation");
|
|
#else
|
|
now_ms = 0x80001000u;
|
|
ui._next_refresh = now_ms + 1000u;
|
|
forceCurrentScreen(ui);
|
|
ui.loop();
|
|
assert(ui.display.frames == 1 && "screen change must redraw immediately in upper half");
|
|
#endif
|
|
} else if (!std::strcmp(scenario, "deferred-across-wrap")) {
|
|
now_ms = UINT32_MAX - 1500u;
|
|
ui.ui_started_at = now_ms - BOOT_SCREEN_MILLIS;
|
|
ui._next_refresh = UINT32_MAX - 1000u;
|
|
ui.loop();
|
|
assert(ui.display.frames == 0);
|
|
radio_driver.timing = true;
|
|
now_ms = UINT32_MAX - 500u;
|
|
ui.loop();
|
|
now_ms = 100u;
|
|
ui.loop();
|
|
assert(ui.display.frames == 0);
|
|
radio_driver.timing = false;
|
|
ui.loop();
|
|
std::printf("after wrapped Morse deferral: frames=%u next=%u now=%u\n",
|
|
ui.display.frames, ui._next_refresh, now_ms);
|
|
assert(ui.display.frames == 1 && "overdue refresh must run after Morse releases across wrap");
|
|
} else if (!std::strcmp(scenario, "wrapped-future-deadline")) {
|
|
now_ms = UINT32_MAX - 999u;
|
|
ui.ui_started_at = now_ms - BOOT_SCREEN_MILLIS;
|
|
ui._next_refresh = now_ms;
|
|
ui.loop();
|
|
assert(ui.display.frames == 1 && ui._next_refresh == 0);
|
|
++now_ms;
|
|
// Orig correctly clears dirty after render; model a fresh incoming event.
|
|
// It still must respect the real loop's one-second redraw throttle.
|
|
ui._need_refresh = true;
|
|
ui.loop();
|
|
std::printf("future deadline wrapped to zero: frames=%u now=%u\n", ui.display.frames, now_ms);
|
|
assert(ui.display.frames == 1 && "a future wrapped deadline is not an immediate force sentinel");
|
|
now_ms = 0;
|
|
ui.loop();
|
|
assert(ui.display.frames == 2);
|
|
}
|
|
#if !UI_ORIG
|
|
else if (!std::strcmp(scenario, "battery-healthy") || !std::strcmp(scenario, "battery-upper-half")) {
|
|
ui.display.on = false;
|
|
now_ms = !std::strcmp(scenario, "battery-healthy") ? 10000u : 0x80001000u;
|
|
const uint32_t due = now_ms + 1000u;
|
|
ui.next_batt_chck = due;
|
|
ui.loop();
|
|
assert(ui.battery_reads == 0 && ui.shutdowns == 0);
|
|
now_ms = due + 1u;
|
|
ui.loop();
|
|
assert(ui.battery_reads == 1 && ui.shutdowns == 0);
|
|
now_ms += 7999u;
|
|
ui.loop();
|
|
assert(ui.battery_reads == 1);
|
|
now_ms += 2u;
|
|
ui.loop();
|
|
assert(ui.battery_reads == 2 && ui.shutdowns == 0);
|
|
} else if (!std::strcmp(scenario, "alert-upper-half")) {
|
|
now_ms = 0x80001000u;
|
|
ui.showAlert("ALERT", 1000);
|
|
forceCurrentScreen(ui);
|
|
ui.loop();
|
|
assert(ui.display.popups == 1);
|
|
now_ms += 999u;
|
|
forceCurrentScreen(ui);
|
|
ui.loop();
|
|
assert(ui.display.popups == 2);
|
|
now_ms += 2u;
|
|
forceCurrentScreen(ui);
|
|
ui.loop();
|
|
assert(ui.display.popups == 2 && "healthy upper-half alert must expire normally");
|
|
} else if (!std::strcmp(scenario, "battery-deferred-wrap")) {
|
|
ui.display.on = false;
|
|
radio_driver.timing = true;
|
|
ui.voltage = 3350;
|
|
now_ms = UINT32_MAX - 1500u;
|
|
ui.next_batt_chck = UINT32_MAX - 1000u;
|
|
ui.loop();
|
|
assert(ui.battery_reads == 0 && ui.shutdowns == 0);
|
|
now_ms = 100u; // The main loop crossed wrap before the protective poll ran.
|
|
ui.loop();
|
|
std::printf("low-battery poll across wrap: reads=%u shutdowns=%u next=%u\n",
|
|
ui.battery_reads, ui.shutdowns, ui.next_batt_chck);
|
|
assert(ui.battery_reads == 1 && ui.shutdowns == 1
|
|
&& "overdue protective battery sample must not be postponed another full wrap");
|
|
} else if (!std::strcmp(scenario, "battery-wrapped-future")) {
|
|
ui.display.on = false;
|
|
now_ms = UINT32_MAX - 7999u;
|
|
ui.next_batt_chck = now_ms - 1u;
|
|
ui.loop();
|
|
assert(ui.battery_reads == 1 && ui.next_batt_chck == 0);
|
|
++now_ms;
|
|
ui.loop();
|
|
std::printf("future battery poll wrapped to zero: reads=%u next=%u\n",
|
|
ui.battery_reads, ui.next_batt_chck);
|
|
assert(ui.battery_reads == 1 && "eight-second battery poll must not run again one ms later");
|
|
now_ms = 1u;
|
|
ui.loop();
|
|
assert(ui.battery_reads == 2 && ui.shutdowns == 0);
|
|
} else if (!std::strcmp(scenario, "expire-with-display-off")
|
|
|| !std::strcmp(scenario, "expire-during-morse")
|
|
|| !std::strcmp(scenario, "expire-during-gesture")) {
|
|
now_ms = 10000u;
|
|
ui.showAlert("ALERT", 1000);
|
|
forceCurrentScreen(ui);
|
|
ui.loop();
|
|
assert(ui.display.popups == 1);
|
|
const unsigned before_frames = ui.display.frames;
|
|
now_ms = 11001u;
|
|
if (!std::strcmp(scenario, "expire-with-display-off")) ui.display.on = false;
|
|
if (!std::strcmp(scenario, "expire-during-morse")) radio_driver.timing = true;
|
|
if (!std::strcmp(scenario, "expire-during-gesture")) ui.gesture_pending = true;
|
|
ui.loop();
|
|
assert(ui.display.frames == before_frames);
|
|
std::printf("popup expiry while redraw gated: text_active=%u frames=%u\n",
|
|
unsigned(ui._alert[0] != 0), ui.display.frames);
|
|
assert(!ui._alert[0] && "expired popup text must be consumed even when redraw is suppressed");
|
|
ui.display.on = true;
|
|
radio_driver.timing = false;
|
|
ui.gesture_pending = false;
|
|
now_ms = 0x80002af9u;
|
|
forceCurrentScreen(ui);
|
|
ui.loop();
|
|
assert(ui.display.popups == 1 && "expired popup cannot revive after half a wrap");
|
|
}
|
|
else if (!std::strcmp(scenario, "alert-across-wrap") || !std::strcmp(scenario, "alert-zero-deadline")) {
|
|
const bool zero_deadline = !std::strcmp(scenario, "alert-zero-deadline");
|
|
now_ms = zero_deadline ? uint32_t(0u - 1000u) : UINT32_MAX - 500u;
|
|
ui.ui_started_at = now_ms - BOOT_SCREEN_MILLIS;
|
|
ui.showAlert("ALERT", 1000);
|
|
forceCurrentScreen(ui);
|
|
ui.loop();
|
|
std::printf("new wrapped alert: popups=%u expiry=%u now=%u\n",
|
|
ui.display.popups, ui._alert_expiry, now_ms);
|
|
assert(ui.display.popups == 1 && "new popup must display before a wrapped expiry, including zero");
|
|
now_ms = ui._alert_expiry;
|
|
forceCurrentScreen(ui);
|
|
ui.loop();
|
|
assert(ui.display.popups == 1 && "popup expires at its exact deadline");
|
|
} else if (!std::strcmp(scenario, "expired-after-wrap") || !std::strcmp(scenario, "expired-after-halfwrap")) {
|
|
now_ms = 10000u;
|
|
ui.showAlert("ALERT", 1000);
|
|
forceCurrentScreen(ui);
|
|
ui.loop();
|
|
assert(ui.display.popups == 1);
|
|
now_ms = 11001u;
|
|
forceCurrentScreen(ui);
|
|
ui.loop();
|
|
assert(ui.display.popups == 1);
|
|
// It was observed expired. A stale expiry must never reactivate it later.
|
|
now_ms = !std::strcmp(scenario, "expired-after-wrap") ? 100u : 0x80002af9u;
|
|
forceCurrentScreen(ui);
|
|
ui.loop();
|
|
std::printf("previously expired alert: popups=%u expiry=%u now=%u\n",
|
|
ui.display.popups, ui._alert_expiry, now_ms);
|
|
assert(ui.display.popups == 1 && "observed expired popup must never reactivate");
|
|
}
|
|
#endif
|
|
else assert(false && "unknown scenario");
|
|
std::printf("PASS: %s\n", scenario);
|
|
}
|