Keep UI startup and rescue windows closed after observed expiry

This commit is contained in:
mikecarper committed 2026-10-16 19:00:45 -07:00
1 parent b7c2636aa9
commit 4348fdcc56
24 files changed
+554 -15

No files matched your search

+1
View File
@@ -801,6 +801,7 @@ jobs:
python3 -B test/test_companion_pairing_display.py
python3 -B test/test_companion_identity_startup.py
python3 -B test/test_startup_screen.py
python3 -B test/test_ui_startup_windows.py -v
node test/test_notification_builder.js
python3 -B test/test_notification_settings.py
python3 -B test/test_notification_led_ownership.py -v
+6 -1
View File
@@ -1968,6 +1968,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
#endif
ui_started_at = millis();
_startup_complete = false;
next_batt_chck = _next_refresh = ui_started_at;
_alert[0] = 0;
_alert_expiry = 0;
@@ -2275,6 +2276,10 @@ bool UITask::isButtonGesturePending() const {
}
void UITask::loop() {
// Observe startup expiry even while display/Morse/input defers rendering.
if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) >= 8000u) {
_startup_complete = true;
}
const bool morse_timing = mesh::morseTimingActive(&radio_driver);
serviceWiFiToggleButton();
servicePairingState();
@@ -2624,7 +2629,7 @@ char UITask::handleLongPress(char c) {
return c;
}
#endif
if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue
if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) < 8000u) { // early-boot CLI/rescue only
the_mesh.enterCLIRescue();
c = 0; // consume event
}
+1
View File
@@ -42,6 +42,7 @@ class UITask : public AbstractUITask {
uint32_t _next_refresh;
unsigned long _pairing_screen_until;
bool _deferred_msg_preview;
bool _startup_complete = false;
CompanionNodePrefs* _node_prefs;
char _alert[80];
uint32_t _alert_expiry;
+10 -4
View File
@@ -159,6 +159,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
_userButtonAnalog->onAnyPress([this]() { handleButtonAnyPress(); });
#endif
ui_started_at = millis();
_startup_complete = false;
_next_refresh = ui_started_at;
}
@@ -352,7 +353,8 @@ void UITask::renderCurrScreen() {
_display->print(tmp);
#endif
_display->setColor(UIColor::secondary_txt); // last color will be kept on T114
} else if (!pairing_screen_active && (millis() - ui_started_at) < BOOT_SCREEN_MILLIS) { // boot screen
} else if (!pairing_screen_active && !_startup_complete
&& static_cast<uint32_t>(millis() - ui_started_at) < BOOT_SCREEN_MILLIS) { // boot screen
// meshcore logo
_display->setColor(UIColor::corp_blue);
int logoWidth = 128;
@@ -716,6 +718,10 @@ void UITask::servicePairingState() {
}
void UITask::loop() {
// Observe startup expiry before button callbacks and optional rendering.
if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) >= 8000u) {
_startup_complete = true;
}
const bool morse_timing = mesh::morseTimingActive(&radio_driver);
#ifdef PIN_VIBRATION
vibration.loop();
@@ -750,11 +756,11 @@ void UITask::loop() {
if (!morse_timing && _display != NULL && _display->isOn()) {
if (!isPairingScreenActive() && !_alert[0] && !(_origin[0] && _msg[0])
&& (millis() - ui_started_at) >= BOOT_SCREEN_MILLIS) {
&& (_startup_complete || static_cast<uint32_t>(millis() - ui_started_at) >= BOOT_SCREEN_MILLIS)) {
serviceRadioProfileDisplayPage();
}
static bool _firstBoot = true;
if(_firstBoot && (millis() - ui_started_at) >= BOOT_SCREEN_MILLIS) {
if(_firstBoot && (_startup_complete || static_cast<uint32_t>(millis() - ui_started_at) >= BOOT_SCREEN_MILLIS)) {
_need_refresh = true;
_firstBoot = false;
}
@@ -995,7 +1001,7 @@ void UITask::handleButtonLongPress() {
_button_notification_cleared = false;
#endif
MESH_DEBUG_PRINTLN("UITask: long press triggered");
if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue
if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) < 8000u) { // early-boot CLI/rescue only
the_mesh.enterCLIRescue();
#if defined(PIN_BUZZER) && MESH_GPS_VOICE
// enterCLIRescue is supported independently of the framed USB interface.
@@ -56,6 +56,7 @@ class UITask : public AbstractUITask {
unsigned long ui_started_at;
uint32_t _radio_profile_page_started_at = 0;
bool _dual_radio_enabled_seen = false;
bool _startup_complete = false;
uint8_t _radio_profile_display_page = 0;
// Button handlers
+6 -1
View File
@@ -669,6 +669,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
#endif
ui_started_at = millis();
_startup_complete = false;
next_batt_chck = _next_refresh = ui_started_at;
_alert[0] = 0;
_alert_expiry = 0;
@@ -866,6 +867,10 @@ bool UITask::isButtonPressed() const {
}
void UITask::loop() {
// Observe startup expiry even while display/Morse/input defers rendering.
if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) >= 8000u) {
_startup_complete = true;
}
const bool morse_timing = mesh::morseTimingActive(&radio_driver);
servicePairingState();
@@ -1071,7 +1076,7 @@ char UITask::checkDisplayOn(char c) {
}
char UITask::handleLongPress(char c) {
if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue
if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) < 8000u) { // early-boot CLI/rescue only
the_mesh.enterCLIRescue();
c = 0; // consume event
}
@@ -49,6 +49,7 @@ class UITask : public AbstractUITask {
uint32_t next_batt_chck;
uint32_t last_backlight_btn_check = 0;
uint16_t _cached_batt_mv;
bool _startup_complete = false;
#ifdef PIN_STATUS_LED
int led_state = 0;
uint32_t last_led_change = 0;
+7 -2
View File
@@ -332,6 +332,7 @@ static const uint8_t meshcore_logo [] PROGMEM = {
void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) {
_prevBtnState = HIGH;
_started_at = millis();
_startup_complete = false;
_node_prefs = node_prefs;
resetRadioProfileDisplayPage();
#ifdef DISPLAY_ACTIVITY_DASHBOARD
@@ -418,7 +419,7 @@ void UITask::renderCurrScreen() {
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
if ((uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) { // boot screen
if (!_startup_complete && (uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) { // boot screen
// meshcore logo
_display->setColor(UIColor::corp_blue);
int logoWidth = 128;
@@ -635,7 +636,7 @@ uint32_t UITask::getFrameSignature() {
uint32_t signature = DisplayFrameSignature::INITIAL;
char tmp[80];
if ((uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) {
if (!_startup_complete && (uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) {
signature = DisplayFrameSignature::append(signature, "boot");
return DisplayFrameSignature::append(signature, _version_info);
}
@@ -845,6 +846,10 @@ void UITask::toggleDisplay(const char* source) {
#endif
void UITask::loop() {
// Observe expiry outside display/Morse/input gates; startup is one-shot.
if (!_startup_complete && (uint32_t)(millis() - _started_at) >= BOOT_SCREEN_MILLIS) {
_startup_complete = true;
}
const bool morse_timing = mesh::morseTimingActive(the_mesh.getProfileRadio());
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
if (_display->isOn()) resetRadioProfileDisplayPage();
+1
View File
@@ -27,6 +27,7 @@ class UITask {
unsigned long _started_at = 0;
uint8_t _radio_profile_display_page = 0;
bool _dual_radio_enabled_seen = false;
bool _startup_complete = false;
void resetRadioProfileDisplayPage();
void advanceRadioProfileDisplayPage();
+7 -2
View File
@@ -131,6 +131,7 @@ static const uint8_t meshcore_logo [] PROGMEM = {
void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) {
_prevBtnState = HIGH;
_started_at = millis();
_startup_complete = false;
_node_prefs = node_prefs;
resetRadioProfileDisplayPage();
#ifdef DISPLAY_ACTIVITY_DASHBOARD
@@ -188,7 +189,7 @@ void UITask::renderCurrScreen() {
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
if ((uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) { // boot screen
if (!_startup_complete && (uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) { // boot screen
// meshcore logo
_display->setColor(UIColor::corp_blue);
int logoWidth = 128;
@@ -341,7 +342,7 @@ uint32_t UITask::getFrameSignature() {
uint32_t signature = DisplayFrameSignature::INITIAL;
char tmp[80];
if ((uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) {
if (!_startup_complete && (uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) {
signature = DisplayFrameSignature::append(signature, "boot");
return DisplayFrameSignature::append(signature, _version_info);
}
@@ -496,6 +497,10 @@ void UITask::toggleDisplay(const char* source) {
#endif
void UITask::loop() {
// Observe expiry outside display/Morse/input gates; startup is one-shot.
if (!_startup_complete && (uint32_t)(millis() - _started_at) >= BOOT_SCREEN_MILLIS) {
_startup_complete = true;
}
const bool morse_timing = mesh::morseTimingActive(the_mesh.getProfileRadio());
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
resetRadioProfileDisplayPage();
+1
View File
@@ -28,6 +28,7 @@ class UITask {
unsigned long _started_at = 0;
uint32_t _radio_profile_page_started_at = 0;
bool _dual_radio_enabled_seen = false;
bool _startup_complete = false;
#ifdef DISPLAY_TOUCH_TOGGLE
unsigned long _powering_off_at = 0;
#endif
+6 -1
View File
@@ -105,6 +105,7 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi
const uint32_t now = millis();
_last_read = now - 200u;
_last_refresh = now - 1000u;
_startup_complete = now >= BOOT_SCREEN_MILLIS;
}
void UITask::resetRadioProfileDisplayPage() {
@@ -128,7 +129,7 @@ bool UITask::showingSecondaryRadioProfilePage() {
void UITask::renderCurrScreen() {
char tmp[80];
if (millis() < BOOT_SCREEN_MILLIS) { // boot screen
if (!_startup_complete && millis() < BOOT_SCREEN_MILLIS) { // boot screen
// meshcore logo
_display->setColor(UIColor::corp_blue);
int logoWidth = 128;
@@ -210,6 +211,10 @@ void UITask::renderCurrScreen() {
}
void UITask::loop() {
// This UI's splash is relative to boot, not to begin(). Retire it once.
if (!_startup_complete && millis() >= BOOT_SCREEN_MILLIS) {
_startup_complete = true;
}
const bool morse_timing = mesh::morseTimingActive(activeSensorMesh().getProfileRadio());
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
resetRadioProfileDisplayPage();
+1
View File
@@ -12,6 +12,7 @@ class UITask {
char _version_info[32];
uint32_t _radio_profile_page_started_at = 0;
bool _dual_radio_enabled_seen = false;
bool _startup_complete = false;
void resetRadioProfileDisplayPage();
bool showingSecondaryRadioProfilePage();
+80 -2
View File
@@ -5,12 +5,15 @@
#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 }; }
@@ -52,6 +55,8 @@ struct UITask {
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;
@@ -59,10 +64,11 @@ struct UITask {
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;
unsigned battery_reads = 0, shutdowns = 0, profile_services = 0;
char _alert[80] = {}, _origin[80] = {}, _msg[80] = {};
void servicePairingState() {}
void serviceWiFiToggleButton() {}
@@ -73,13 +79,15 @@ struct UITask {
bool isBluetoothEnabled() const { return false; }
void userLedHandler() {}
void renderPairingBanner() {}
void serviceRadioProfileDisplayPage() {}
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@
@@ -100,6 +108,76 @@ int main(int argc, char** argv) {
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;
+1
View File
@@ -5,6 +5,7 @@
#include <string>
static uint32_t now_ms;
static constexpr uint32_t BOOT_SCREEN_MILLIS = 4000;
static uint32_t millis() { return now_ms; }
static constexpr int HIGH = 1;
static bool deny_copy;
+1
View File
@@ -8,6 +8,7 @@ static uint32_t now_ms = 0, read_delay = 0, render_delay = 0, end_delay = 0;
static unsigned reads = 0, renders = 0;
static constexpr int HIGH = 1, LOW = 0, USER_BTN_PRESSED = LOW;
static constexpr int BUTTON_EVENT_NONE = 0;
static constexpr uint32_t BOOT_SCREEN_MILLIS = 4000;
static int button_level = HIGH;
static uint32_t millis() { return now_ms; }
[[maybe_unused]] static int digitalRead(int pin) {
+201
View File
@@ -0,0 +1,201 @@
// Actual Sensor UI methods and header; infrastructure init/prefix/metadata only.
// Hardware, optional status-page and radio dependencies are explicit adapters.
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
static uint32_t now_ms = 0;
static uint32_t millis() { return now_ms; }
static constexpr int HIGH = 1;
enum class UIColor { corp_blue, primary_txt, secondary_txt, warning_txt };
namespace mesh {
struct Params { float freq = 915, bw = 125; uint8_t sf = 7, cr = 5; };
struct RadioProfiles {
bool enabled() const { return false; }
bool secondary_temporary = false, primary_temporary = false;
const Params& params(int) const { static Params value; return value; }
};
struct MainBoard {
bool isExternalPowered() const { return true; }
bool isUsbHostConnected() const { return true; }
};
namespace ui {
enum class DisplayWake { Button };
struct RadioProfileSystemStatus {};
[[maybe_unused]] static bool showSecondaryRadioProfilePage(bool, uint8_t, uint32_t) {
return false;
}
[[maybe_unused]] static bool showRadioProfileSystemStatusPage(bool, uint8_t, uint32_t,
uint8_t*) { return false; }
[[maybe_unused]] static const char* radioProfileDisplayTag(int, bool) { return ""; }
}
}
struct DisplayDriver {
bool on = true, changed = false;
unsigned frames = 0, logos = 0, homes = 0;
bool servicePower(bool) { bool result = changed; changed = false; return result; }
bool isOn() const { return on; }
void wake(mesh::ui::DisplayWake) { on = true; }
void startFrame() { ++frames; }
void endFrame() {}
void setColor(UIColor) {}
void setTextSize(float) {}
void setCursor(int, int) {}
int width() const { return 128; }
unsigned getTextWidth(const char* text) const { return std::strlen(text) * 6; }
void drawXbm(int, int, const uint8_t*, int, int) { ++logos; }
void print(const char* text) { if (!std::strcmp(text, "normal-node")) ++homes; }
};
namespace mesh { namespace ui {
[[maybe_unused]] static void drawRadioProfileSystemStatusPage(
DisplayDriver&, const RadioProfileSystemStatus&, uint8_t) {}
}}
static mesh::MainBoard board;
struct Radio { bool timing = false; } radio;
namespace mesh {
[[maybe_unused]] static bool morseTimingActive(const Radio* value) { return value->timing; }
}
struct SensorMesh {
Radio* getProfileRadio() { return &radio; }
bool isDualRadioActive() const { return false; }
} sensor_mesh;
[[maybe_unused]] static SensorMesh& activeSensorMesh() { return sensor_mesh; }
static SensorMesh& the_mesh = sensor_mesh;
struct NodePrefs {
char node_name[32] = "normal-node";
float freq = 915, bw = 125;
uint8_t sf = 7, cr = 5;
};
[[maybe_unused]] static const mesh::RadioProfiles* configuredRadioProfiles() { return nullptr; }
[[maybe_unused]] static mesh::ui::RadioProfileSystemStatus radioProfileSystemStatus(
const NodePrefs&) { return {}; }
[[maybe_unused]] static uint8_t radioProfileStatusPageCount(DisplayDriver&, bool) { return 0; }
[[maybe_unused]] static const uint8_t meshcore_logo[] = {0};
#define BOOT_SCREEN_MILLIS 4000
#include "UITask.h"
@METHODS@
static const char* scenario;
static unsigned checks = 0;
#define CHECK(expression) do { ++checks; if (!(expression)) { \
std::fprintf(stderr, "FAIL %s: %s (line %d)\n", scenario, #expression, __LINE__); \
return 1; } } while (0)
int main(int argc, char** argv) {
if (argc != 2) return 2;
scenario = argv[1];
DisplayDriver display;
NodePrefs prefs;
#if TEST_REPEATER
UITask ui(board, display);
#else
UITask ui(display);
#endif
#if TEST_SENSOR
const bool late = !std::strcmp(scenario, "late-begin");
const bool upper = !std::strcmp(scenario, "upper-begin");
const bool maximum = !std::strcmp(scenario, "max-begin");
const bool off_first = !std::strcmp(scenario, "off-first");
const bool morse_first = !std::strcmp(scenario, "morse-first");
now_ms = late ? 4000u : upper ? 0x80001000u : maximum ? UINT32_MAX : 0u;
display.on = !off_first;
radio.timing = morse_first;
ui.begin(&prefs, "16 Oct 2026", "v1.2.3-test");
ui.loop();
CHECK(display.logos == unsigned(!late && !upper && !maximum && !off_first && !morse_first));
CHECK(display.homes == unsigned(late || upper || maximum));
if (!std::strcmp(scenario, "boundary")) {
now_ms = 3999;
display.changed = true;
ui.loop();
CHECK(display.logos == 2 && display.homes == 0);
now_ms = 4000;
display.changed = true;
ui.loop();
CHECK(display.logos == 2 && display.homes == 1);
} else if (late || upper || maximum) {
// Re-entering the low boot clock range must not undo begin's late cutoff.
now_ms = 0;
display.changed = true;
ui.loop();
CHECK(display.logos == 0 && display.homes == 2);
} else if (!std::strcmp(scenario, "live-wrap")) {
now_ms = 5000;
ui.loop();
CHECK(display.logos == 1 && display.homes == 1);
for (uint64_t day = 1; day <= 49; ++day) {
now_ms = static_cast<uint32_t>(day * 86400000ULL);
ui.loop();
CHECK(display.logos == 1);
}
const unsigned homes = display.homes;
now_ms = 0; // absolute 2^32, reached after the live checkpoints above
ui.loop();
CHECK(display.logos == 1 && display.homes == homes + 1);
} else if (!std::strcmp(scenario, "off-retirement")
|| !std::strcmp(scenario, "morse-retirement") || off_first || morse_first) {
const bool off = off_first || !std::strcmp(scenario, "off-retirement");
display.on = !off;
radio.timing = !off;
const unsigned frames = display.frames, logos = display.logos;
now_ms = 4000;
ui.loop();
CHECK(display.frames == frames);
now_ms = 0; // full-wrap alias, after observed expiry with no rendered frame
display.on = true;
display.changed = true;
radio.timing = false;
ui.loop();
CHECK(display.logos == logos && display.homes == 1);
} else if (!std::strcmp(scenario, "wake")) {
now_ms = 4000;
ui.loop();
CHECK(display.logos == 1 && display.homes == 1);
display.on = false;
now_ms = 12000;
ui.loop();
now_ms = 0;
display.wake(mesh::ui::DisplayWake::Button);
display.changed = true;
ui.loop();
CHECK(display.logos == 1 && display.homes == 2);
} else if (!std::strcmp(scenario, "rebegin")) {
now_ms = 4000;
ui.loop();
CHECK(display.logos == 1 && display.homes == 1);
now_ms = 100;
ui.begin(&prefs, "16 Oct 2026", "v1.2.3-test");
ui.loop();
CHECK(display.logos == 2 && display.homes == 1);
now_ms = 4100;
ui.loop();
CHECK(display.logos == 2 && display.homes == 2);
now_ms = 5000;
ui.begin(&prefs, "16 Oct 2026", "v1.2.3-test");
ui.loop();
CHECK(display.logos == 2 && display.homes == 3);
} else return 3;
#else
const bool wrapping = !std::strcmp(scenario, "infra-wrap-start");
const uint32_t start = wrapping ? UINT32_MAX - 1000u : 1000u;
now_ms = start;
ui.begin(&prefs, "16 Oct 2026", "v1.2.3-test");
CHECK(ui.testRenderBoot() && ui.testSignatureBoot());
now_ms = start + 3999u;
ui.loop();
CHECK(ui.testRenderBoot() && ui.testSignatureBoot());
now_ms = start + 4000u;
ui.loop();
CHECK(!ui.testRenderBoot() && !ui.testSignatureBoot());
now_ms = start; // full-wrap alias, same retained boot timestamp
ui.loop();
CHECK(!ui.testRenderBoot() && !ui.testSignatureBoot());
if (!std::strcmp(scenario, "infra-rebegin")) {
ui.begin(&prefs, "16 Oct 2026", "v1.2.3-test");
CHECK(ui.testRenderBoot() && ui.testSignatureBoot());
} else if (std::strcmp(scenario, "infra-boundary") && !wrapping) return 3;
#endif
std::printf("PASS %s (%u checks)\n", scenario, checks);
}
+1
View File
@@ -293,6 +293,7 @@ struct UITask {
char _alert[80]={},_origin[80]={},_msg[80]={};
bool _need_refresh=false,_displayWasOn=false,_button_notification_cleared=false;
bool _shutdown_pending=false,_shutdown_restart=false;
bool _startup_complete=false;
uint32_t _shutdown_started_at=0,ui_started_at=0;
uint8_t _radio_profile_display_page=0,_notification_outputs=mesh::notify::Sound;
bool guard_ok=true,guard_reject_drain=false;unsigned guards=0,preview_clears=0;
+53 -2
View File
@@ -30,27 +30,58 @@ class CompanionUiDeadlineTests(unittest.TestCase):
cls.addClassCleanup(cls.directory.cleanup)
work = Path(cls.directory.name)
cls.binaries = {}
cls.startup_mutants = {}
cls.first_paint_mutant = None
for variant in BUILD_VARIANTS:
source_variant = 'ui-tiny' if variant == 'ui-tiny-cache' else variant
source = (ROOT / f'examples/companion_radio/{source_variant}/UITask.cpp').read_text(encoding='ascii')
generated = FIXTURE.read_text(encoding='ascii').replace('@LOOP@', extract_braced(source, 'void UITask::loop()'))
handler = ('void UITask::handleButtonLongPress()' if source_variant == 'ui-orig'
else 'char UITask::handleLongPress(char c)')
generated += '\n' + extract_braced(source, handler) + '\n'
generated = generated.replace('@ALERT@', '' if variant == 'ui-orig' else
extract_braced(source, 'void UITask::showAlert('))
generated = generated.replace('@SCREEN@', '' if variant == 'ui-orig' else
extract_braced(source, 'void UITask::setCurrScreen('))
cpp, binary = work / (variant + '.cpp'), work / variant
cpp.write_text(generated, encoding='ascii')
built = subprocess.run([
command = [
compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror', '-Wno-unused-function',
'-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie',
'-DUI_ORIG=' + str(int(variant == 'ui-orig')), '-DUI_HAS_JOYSTICK=0',
'-DUI_TINY_CACHE=' + str(int(variant == 'ui-tiny-cache')),
'-DCOMPANION_FEATURE_READER=0', '-DAUTO_OFF_MILLIS=0',
*([] if variant in ('ui-orig', 'ui-tiny-cache') else ['-DAUTO_SHUTDOWN_MILLIVOLTS=3400']),
str(cpp), '-o', str(binary)], capture_output=True, text=True, timeout=60)
str(cpp), '-o', str(binary)]
built = subprocess.run(command, capture_output=True, text=True, timeout=60)
if built.returncode:
raise AssertionError(built.stdout + built.stderr)
cls.binaries[variant] = binary
latch = extract_braced(source,
'if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) >= 8000u)')
if generated.count(latch) != 1:
raise AssertionError('startup retirement must appear once in the complete foreground loop')
mutant_cpp, mutant_binary = work / (variant + '-startup.cpp'), work / (variant + '-startup')
mutant_cpp.write_text(generated.replace(latch, '', 1), encoding='ascii')
mutant_command = [str(mutant_cpp) if arg == str(cpp)
else str(mutant_binary) if arg == str(binary) else arg for arg in command]
built = subprocess.run(mutant_command, capture_output=True, text=True, timeout=60)
if built.returncode:
raise AssertionError(built.stdout + built.stderr)
cls.startup_mutants[variant] = mutant_binary
if variant == 'ui-orig':
first_paint = extract_braced(source, 'if(_firstBoot &&')
if first_paint.count('_startup_complete || ') != 1:
raise AssertionError('first paint must recognize completed startup')
mutant_cpp, mutant_binary = work / 'orig-first-paint.cpp', work / 'orig-first-paint'
mutant_cpp.write_text(generated.replace(first_paint,
first_paint.replace('_startup_complete || ', '', 1), 1), encoding='ascii')
mutant_command = [str(mutant_cpp) if arg == str(cpp)
else str(mutant_binary) if arg == str(binary) else arg for arg in command]
built = subprocess.run(mutant_command, capture_output=True, text=True, timeout=60)
if built.returncode:
raise AssertionError(built.stdout + built.stderr)
cls.first_paint_mutant = mutant_binary
def check_scenario(self, scenario, variants=VARIANTS):
for variant in variants:
@@ -64,6 +95,24 @@ class CompanionUiDeadlineTests(unittest.TestCase):
def test_healthy_refresh_schedule(self):
self.check_scenario('healthy')
def test_startup_rescue_retires_outside_gates_and_stays_closed_after_full_wrap(self):
self.check_scenario('startup-one-shot', BUILD_VARIANTS)
def test_missing_foreground_startup_retirement_is_rejected(self):
for variant, binary in self.startup_mutants.items():
with self.subTest(variant=variant):
checked = subprocess.run([str(binary), 'startup-one-shot'],
capture_output=True, text=True, timeout=10)
self.assertNotEqual(checked.returncode, 0)
self.assertIn('expired startup rescue must never reopen after full wrap', checked.stderr)
def test_orig_first_paint_can_be_deferred_past_startup_and_wrap(self):
self.check_scenario('startup-first-draw', ('ui-orig',))
checked = subprocess.run([str(self.first_paint_mutant), 'startup-first-draw'],
capture_output=True, text=True, timeout=10)
self.assertNotEqual(checked.returncode, 0)
self.assertIn('deferred first paint must remain post-startup after full wrap', checked.stderr)
def test_upper_half_refresh_schedule(self):
self.check_scenario('upper-half')
@@ -140,6 +189,8 @@ class CompanionUiDeadlineWiringContract(unittest.TestCase):
# every actual begin/re-begin must re-arm from its fresh clock.
self.assertRegex(constructor, r'\b_next_refresh\s*=\s*0\s*;')
self.assertIn('ui_started_at = millis();', begin)
self.assertIn('_startup_complete = false;', begin)
self.assertIn('bool _startup_complete = false;', header)
self.assertLess(begin.index('ui_started_at = millis();'),
begin.index('_next_refresh = ui_started_at;'))
without_constructor = header.replace(constructor, '', 1)
+3
View File
@@ -388,8 +388,11 @@ struct TestDisplay {
bool isOn() const { return policy.on(); }
void wake(mesh::ui::DisplayWake reason) { policy.wake(reason, millis()); }
} display;
static constexpr uint32_t BOOT_SCREEN_MILLIS = 4000;
struct UITask {
TestDisplay* _display = &display;
bool _startup_complete = false;
uint32_t _started_at = 0;
uint32_t _next_refresh = 0, _powering_off_at = 0, _next_read = 0;
uint32_t _last_refresh = 0, _last_read = 0; // Sensor uses elapsed-time anchors.
int _prevBtnState = HIGH;
+2
View File
@@ -244,6 +244,7 @@ int main() {
source = r'''
#include <cassert>
#define UI_WIFI_SETUP_HOME_PAGE 1
#include <cstdint>
unsigned long now = 4000;
unsigned long millis() { return now; }
struct Screen {};
@@ -260,6 +261,7 @@ struct UITask {
Screen* home;
Screen* msg_preview;
unsigned long ui_started_at = 0;
bool _startup_complete = false;
char handleLongPress(char c);
};
''' + ui[start:end] + r'''
+1
View File
@@ -54,6 +54,7 @@ struct UITask {
CompanionNodePrefs* _node_prefs = nullptr;
unsigned clears = 0, resets = 0;
unsigned long ui_started_at = 0, _next_refresh = 0;
bool _startup_complete = false;
explicit UITask(char (&label)[@CAPACITY@]) : _version_info(label) {}
void clearMsgPreview() { ++clears; }
void resetRadioProfileDisplayPage() { ++resets; }
+1
View File
@@ -70,6 +70,7 @@ struct UI {
uint32_t _last_refresh=0;
uint16_t _cached_batt_mv=4000,voltage=3350;
bool _need_refresh=true;
bool _startup_complete=false;
char _alert[4]={},_origin[4]={},_msg[4]={};
bool isButtonGesturePending() { return false; }
bool isPairingScreenActive() { return false; }
+161
View File
@@ -0,0 +1,161 @@
"""Check one-shot infrastructure startup windows with a real uint32 clock.
Sensor coverage compiles its actual header, complete begin, page methods,
renderer and foreground loop. Repeater/Room coverage compiles their headers,
begin/reset methods, exact renderer/signature boot expressions and actual loop
prefix only. Board, radio, display and optional page helpers are host adapters;
this does not qualify complete infrastructure rendering or physical hardware.
"""
from pathlib import Path
import os
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / 'test/fixtures/ui_startup_windows.cpp'
ROLES = ('simple_sensor', 'simple_repeater', 'simple_room_server')
def startup_retirement(loop):
"""Extract the real first retirement branch, never a handwritten predicate."""
return extract_braced(loop, 'if (!_startup_complete &&')
def boot_expression(method):
matched = re.search(r'\bif\s*\(([^\n]*_startup_complete[^\n]*)\)\s*\{', method)
if not matched:
raise AssertionError('expected a guarded single-line startup expression')
return matched.group(1)
class UiStartupWindowsTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++')
if not compiler:
raise unittest.SkipTest('a host C++17 compiler is required')
cls.temp = tempfile.TemporaryDirectory(prefix='ui-startup-windows-')
cls.addClassCleanup(cls.temp.cleanup)
work = Path(cls.temp.name)
(work / 'helpers/ui').mkdir(parents=True)
for stub in ('helpers/ui/DisplayDriver.h', 'helpers/CommonCLI.h'):
(work / stub).write_text('#pragma once\n', encoding='ascii')
sanitize = [] if not sys.platform.startswith('linux') else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-omit-frame-pointer', '-fno-pie', '-no-pie']
cls.binaries = {}
fixture = FIXTURE.read_text(encoding='ascii')
for role in ROLES:
folder = ROOT / 'examples' / role
source = (folder / 'UITask.cpp').read_text(encoding='ascii')
header = (folder / 'UITask.h').read_text(encoding='ascii')
if header.count('class UITask {') != 1:
raise AssertionError('expected one real UI class')
header = header.replace('class UITask {', 'struct UITask {')
# Only expose observations; no extra state or changed declarations.
header = header.replace('public:',
'bool testRenderBoot();\n bool testSignatureBoot();\npublic:', 1)
directory = work / role
directory.mkdir()
(directory / 'UITask.h').write_text(header, encoding='ascii')
begin = extract_braced(source, 'void UITask::begin(')
reset = extract_braced(source, 'void UITask::resetRadioProfileDisplayPage(')
loop = extract_braced(source, 'void UITask::loop()')
if role == 'simple_sensor':
methods = '\n'.join((begin, reset,
extract_braced(source, 'bool UITask::showingSecondaryRadioProfilePage('),
extract_braced(source, 'void UITask::renderCurrScreen()'), loop))
else:
prefix = loop[:loop.index(' const bool morse_timing')]
render = extract_braced(source, 'void UITask::renderCurrScreen()')
signature = extract_braced(source, 'uint32_t UITask::getFrameSignature()')
methods = '\n'.join((begin, reset, prefix + '}\n',
'void UITask::applyDisplayFlip() {}',
'bool UITask::testRenderBoot() { return ' + boot_expression(render) + '; }',
'bool UITask::testSignatureBoot() { return ' + boot_expression(signature) + '; }'))
variants = [('production', methods)]
if role == 'simple_sensor':
retirement = startup_retirement(loop)
if methods.count(retirement) != 1:
raise AssertionError('expected exactly one actual retirement branch')
variants.append(('no-retirement', methods.replace(retirement, '', 1)))
for kind, generated_methods in variants:
cpp = directory / (kind + '.cpp')
cpp.write_text(fixture.replace('@METHODS@', generated_methods), encoding='ascii')
binary = directory / (kind + ('.exe' if os.name == 'nt' else ''))
built = subprocess.run([
compiler, '-std=c++17', '-O1', '-g', '-Wall', '-Wextra', '-Werror',
*sanitize, '-DTEST_SENSOR=' + str(int(role == 'simple_sensor')),
'-DTEST_REPEATER=' + str(int(role == 'simple_repeater')),
'-I' + str(work), str(cpp), '-o', str(binary)],
capture_output=True, text=True, timeout=60)
if built.returncode:
raise AssertionError(built.stdout + built.stderr)
cls.binaries[role, kind] = binary
def check_case(self, case, roles=('simple_sensor',), kind='production', fails=False):
for role in roles:
with self.subTest(role=role, case=case, kind=kind):
checked = subprocess.run([str(self.binaries[role, kind]), case],
capture_output=True, text=True, timeout=10)
if fails:
self.assertNotEqual(checked.returncode, 0, checked.stdout)
self.assertIn('FAIL ' + case, checked.stderr)
self.assertNotIn('runtime error:', checked.stderr)
self.assertNotIn('AddressSanitizer:', checked.stderr)
else:
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn('PASS ' + case, checked.stdout)
def test_sensor_exact_boot_boundary_and_absolute_late_begin(self):
for case in ('boundary', 'late-begin', 'upper-begin', 'max-begin'):
self.check_case(case)
def test_sensor_live_loops_do_not_replay_after_full_clock_wrap(self):
self.check_case('live-wrap')
def test_sensor_retires_without_display_or_morse_rendering(self):
for case in ('off-retirement', 'morse-retirement',
'off-first', 'morse-first'):
self.check_case(case)
def test_sensor_normal_wake_and_rebegin_preserve_startup_policy(self):
for case in ('wake', 'rebegin'):
self.check_case(case)
def test_removing_actual_sensor_retirement_reproduces_semantic_failure(self):
for case in ('live-wrap', 'off-retirement', 'morse-retirement',
'off-first', 'morse-first'):
self.check_case(case, kind='no-retirement', fails=True)
def test_infrastructure_actual_init_prefix_and_render_metadata(self):
for case in ('infra-boundary', 'infra-wrap-start', 'infra-rebegin'):
self.check_case(case, ('simple_repeater', 'simple_room_server'))
def test_retirement_is_before_hardware_and_rendering_gates(self):
for role in ROLES:
with self.subTest(role=role):
folder = ROOT / 'examples' / role
source = (folder / 'UITask.cpp').read_text(encoding='ascii')
header = (folder / 'UITask.h').read_text(encoding='ascii')
self.assertRegex(header, r'bool _startup_complete\s*=\s*false;')
loop = extract_braced(source, 'void UITask::loop()')
retired = startup_retirement(loop)
self.assertIn('_startup_complete = true;', retired)
self.assertLess(loop.index(retired), loop.index('const bool morse_timing'))
self.assertLess(loop.index(retired), loop.index('servicePower('))
main = (folder / 'main.cpp').read_text(encoding='ascii')
self.assertIn('ui_task.begin(', extract_braced(main, 'void setup()'))
# Room's alternative preprocessor branches have unmatched
# braces before preprocessing; do not parse them as one block.
self.assertIn('ui_task.loop();', main[main.index('\nvoid loop()'):])
if __name__ == '__main__':
unittest.main()