diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index e503b2218..01d918f08 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 2e1d3d1dd..a2075426e 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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(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(millis() - ui_started_at) < 8000u) { // early-boot CLI/rescue only the_mesh.enterCLIRescue(); c = 0; // consume event } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index ad0bab61c..ba80054dd 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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; diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index 9ef7432aa..fd66182bc 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -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(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(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(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(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(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. diff --git a/examples/companion_radio/ui-orig/UITask.h b/examples/companion_radio/ui-orig/UITask.h index 0c8fa855e..b74a92dda 100644 --- a/examples/companion_radio/ui-orig/UITask.h +++ b/examples/companion_radio/ui-orig/UITask.h @@ -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 diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index 5ef36b6fa..489c2652b 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -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(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(millis() - ui_started_at) < 8000u) { // early-boot CLI/rescue only the_mesh.enterCLIRescue(); c = 0; // consume event } diff --git a/examples/companion_radio/ui-tiny/UITask.h b/examples/companion_radio/ui-tiny/UITask.h index e92705413..a8dee7305 100644 --- a/examples/companion_radio/ui-tiny/UITask.h +++ b/examples/companion_radio/ui-tiny/UITask.h @@ -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; diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index ccfbc3bdd..dc63ca00b 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -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(); diff --git a/examples/simple_repeater/UITask.h b/examples/simple_repeater/UITask.h index 4c965f9b6..bd9fde913 100644 --- a/examples/simple_repeater/UITask.h +++ b/examples/simple_repeater/UITask.h @@ -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(); diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index 69c34f5f8..c80a0827d 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -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(); diff --git a/examples/simple_room_server/UITask.h b/examples/simple_room_server/UITask.h index a407ec2bd..8af1c82a9 100644 --- a/examples/simple_room_server/UITask.h +++ b/examples/simple_room_server/UITask.h @@ -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 diff --git a/examples/simple_sensor/UITask.cpp b/examples/simple_sensor/UITask.cpp index 03fc01c67..3aaff8b85 100644 --- a/examples/simple_sensor/UITask.cpp +++ b/examples/simple_sensor/UITask.cpp @@ -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(); diff --git a/examples/simple_sensor/UITask.h b/examples/simple_sensor/UITask.h index b10f3a799..f7b03e7af 100644 --- a/examples/simple_sensor/UITask.h +++ b/examples/simple_sensor/UITask.h @@ -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(); diff --git a/test/fixtures/companion_ui_deadlines.cpp b/test/fixtures/companion_ui_deadlines.cpp index 245ae5759..74738666a 100644 --- a/test/fixtures/companion_ui_deadlines.cpp +++ b/test/fixtures/companion_ui_deadlines.cpp @@ -5,12 +5,15 @@ #include #include #include +#include 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; diff --git a/test/fixtures/infrastructure_ui_version.cpp b/test/fixtures/infrastructure_ui_version.cpp index c036168ba..6fda8f45b 100644 --- a/test/fixtures/infrastructure_ui_version.cpp +++ b/test/fixtures/infrastructure_ui_version.cpp @@ -5,6 +5,7 @@ #include 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; diff --git a/test/fixtures/sensor_ui_polling.cpp b/test/fixtures/sensor_ui_polling.cpp index 6679c624d..9584176a3 100644 --- a/test/fixtures/sensor_ui_polling.cpp +++ b/test/fixtures/sensor_ui_polling.cpp @@ -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) { diff --git a/test/fixtures/ui_startup_windows.cpp b/test/fixtures/ui_startup_windows.cpp new file mode 100644 index 000000000..e1a0e2823 --- /dev/null +++ b/test/fixtures/ui_startup_windows.cpp @@ -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 +#include +#include +#include + +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(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); +} diff --git a/test/test_button_voice_actions.py b/test/test_button_voice_actions.py index 081cd4c5d..e64b55157 100644 --- a/test/test_button_voice_actions.py +++ b/test/test_button_voice_actions.py @@ -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; diff --git a/test/test_companion_ui_deadlines.py b/test/test_companion_ui_deadlines.py index b383a0c73..5d6573a5b 100644 --- a/test/test_companion_ui_deadlines.py +++ b/test/test_companion_ui_deadlines.py @@ -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(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) diff --git a/test/test_esp32_usb_sleep.py b/test/test_esp32_usb_sleep.py index 9215e8ee9..16258e86e 100644 --- a/test/test_esp32_usb_sleep.py +++ b/test/test_esp32_usb_sleep.py @@ -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; diff --git a/test/test_heltec_v4_wifi_setup_page.py b/test/test_heltec_v4_wifi_setup_page.py index 059a06211..e0c745d48 100644 --- a/test/test_heltec_v4_wifi_setup_page.py +++ b/test/test_heltec_v4_wifi_setup_page.py @@ -244,6 +244,7 @@ int main() { source = r''' #include #define UI_WIFI_SETUP_HOME_PAGE 1 +#include 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''' diff --git a/test/test_legacy_ui_version_bounds.py b/test/test_legacy_ui_version_bounds.py index 2e92cea15..dabed78ef 100644 --- a/test/test_legacy_ui_version_bounds.py +++ b/test/test_legacy_ui_version_bounds.py @@ -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; } diff --git a/test/test_morse_loop_policy.py b/test/test_morse_loop_policy.py index 6503c1e27..66cf2ab68 100644 --- a/test/test_morse_loop_policy.py +++ b/test/test_morse_loop_policy.py @@ -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; } diff --git a/test/test_ui_startup_windows.py b/test/test_ui_startup_windows.py new file mode 100644 index 000000000..323baf2ff --- /dev/null +++ b/test/test_ui_startup_windows.py @@ -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()