Keep Companion UI deadlines working across rollover and long idle
This commit is contained in:
1 parent
d01b63f0c3
commit
4c3a5691e1
16 files changed
+819
-57
No files matched your search
@@ -782,6 +782,8 @@ jobs:
|
||||
run: |
|
||||
python3 -B test/test_rtttl_rollover.py -v
|
||||
python3 -B test/test_rtttl_rollover_recipes.py -v
|
||||
python3 -B test/test_companion_ui_deadlines.py -v
|
||||
python3 -B test/test_scrolling_status_bar_rollover.py -v
|
||||
python3 -B test/test_gps_voice_buttons.py -v
|
||||
python3 -B test/test_gps_voice_generator.py -v
|
||||
python3 -B test/test_button_voice_actions.py -v
|
||||
|
||||
@@ -1964,6 +1964,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
|
||||
#endif
|
||||
|
||||
ui_started_at = millis();
|
||||
next_batt_chck = _next_refresh = ui_started_at;
|
||||
_alert[0] = 0;
|
||||
_alert_expiry = 0;
|
||||
|
||||
splash = new SplashScreen(this);
|
||||
@@ -1977,7 +1979,7 @@ void UITask::serviceWiFiToggleButton() {
|
||||
if (wifi_btn.check() == BUTTON_EVENT_CLICK) {
|
||||
const bool enabled = toggleCompanionWiFi();
|
||||
showAlert(enabled ? "WiFi: ON" : "WiFi: OFF", 1200);
|
||||
_next_refresh = 0;
|
||||
_next_refresh = millis();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -2054,7 +2056,7 @@ if (_notification_outputs & mesh::notify::Sound) switch(t){
|
||||
|
||||
void UITask::msgRead(int msgcount) {
|
||||
_msgcount = msgcount;
|
||||
_next_refresh = 0;
|
||||
_next_refresh = millis();
|
||||
}
|
||||
|
||||
void UITask::syncMessageQueue(int msgcount, int removed_index) {
|
||||
@@ -2093,7 +2095,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
|
||||
if (shouldWakeDisplayForMessage()) {
|
||||
_display->wake(mesh::ui::DisplayWake::Message);
|
||||
}
|
||||
if (_display->isOn()) _next_refresh = 0;
|
||||
if (_display->isOn()) _next_refresh = millis();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2127,7 +2129,7 @@ void UITask::setCurrScreen(UIScreen* c) {
|
||||
}
|
||||
#endif
|
||||
curr = c;
|
||||
_next_refresh = 100;
|
||||
_next_refresh = millis();
|
||||
}
|
||||
|
||||
bool UITask::isPairingScreenActive() const {
|
||||
@@ -2169,7 +2171,7 @@ void UITask::showPairingPin(bool from_button) {
|
||||
setCurrScreen(home);
|
||||
if (_display != NULL) {
|
||||
_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection(), true);
|
||||
_next_refresh = 0;
|
||||
_next_refresh = millis();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2187,7 +2189,7 @@ void UITask::finishPairingScreen(bool timed_out) {
|
||||
if (timed_out && _display != NULL) {
|
||||
_display->dismiss();
|
||||
} else {
|
||||
_next_refresh = 0;
|
||||
_next_refresh = millis();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2207,7 +2209,7 @@ void UITask::servicePairingState() {
|
||||
if (_display != NULL && _display->servicePower(
|
||||
_board->isExternalPowered() || _board->isUsbHostConnected(),
|
||||
hasConnection(), isPairingScreenActive())) {
|
||||
_next_refresh = 0;
|
||||
_next_refresh = millis();
|
||||
#if COMPANION_FEATURE_READER
|
||||
if (!_display->isOn() && isReaderActive())
|
||||
static_cast<ReaderScreen*>(reader)->flush();
|
||||
@@ -2272,6 +2274,20 @@ void UITask::loop() {
|
||||
serviceWiFiToggleButton();
|
||||
servicePairingState();
|
||||
|
||||
// Keep an already-due refresh due while drawing is deferred, without
|
||||
// moving a future throttle or letting it age past half the clock range.
|
||||
const uint32_t refresh_now = millis();
|
||||
if (static_cast<int32_t>(refresh_now - _next_refresh) >= 0) {
|
||||
_next_refresh = refresh_now;
|
||||
}
|
||||
|
||||
// Retire the popup even while sleep, Morse or input prevents drawing.
|
||||
// Its deadline may be zero after wrapping; empty text means inactive.
|
||||
if (_alert[0] && static_cast<int32_t>(millis() - _alert_expiry) >= 0) {
|
||||
_alert[0] = 0;
|
||||
_next_refresh = millis();
|
||||
}
|
||||
|
||||
char c = 0;
|
||||
#if UI_HAS_JOYSTICK
|
||||
int ev = user_btn.check();
|
||||
@@ -2384,7 +2400,7 @@ void UITask::loop() {
|
||||
&& touch_input.hasSeparateVerticalSwipes(),
|
||||
split_transport_selector, reader_touch_bar);
|
||||
const int debug_area = mesh::ui::touchDebugAreaAt(debug_areas, _touch_debug_x, _touch_debug_y);
|
||||
if (debug_area != _touch_debug_area || curr != _touch_debug_screen) _next_refresh = 0;
|
||||
if (debug_area != _touch_debug_area || curr != _touch_debug_screen) _next_refresh = millis();
|
||||
_touch_debug_area = debug_area;
|
||||
_touch_debug_screen = curr;
|
||||
}
|
||||
@@ -2449,7 +2465,7 @@ void UITask::loop() {
|
||||
if (c != 0 && curr) {
|
||||
_display->wake(mesh::ui::DisplayWake::Button);
|
||||
curr->handleInput(c);
|
||||
_next_refresh = 100; // trigger refresh
|
||||
_next_refresh = millis(); // trigger refresh
|
||||
}
|
||||
|
||||
userLedHandler();
|
||||
@@ -2464,11 +2480,11 @@ void UITask::loop() {
|
||||
#endif
|
||||
|
||||
if (_display != NULL && _display->isOn()) {
|
||||
if (!morse_timing && millis() >= _next_refresh && curr && !isButtonGesturePending()) {
|
||||
if (!morse_timing && static_cast<int32_t>(millis() - _next_refresh) >= 0 && curr && !isButtonGesturePending()) {
|
||||
_display->startFrame();
|
||||
int delay_millis = curr->render(*_display);
|
||||
renderPairingBanner();
|
||||
if (millis() < _alert_expiry) { // render alert popup
|
||||
if (_alert[0] && static_cast<int32_t>(millis() - _alert_expiry) < 0) { // render alert popup
|
||||
_display->setTextSize(1);
|
||||
int y = _display->height() / 3;
|
||||
int p = _display->height() / 32;
|
||||
@@ -2510,7 +2526,7 @@ void UITask::loop() {
|
||||
#endif
|
||||
|
||||
#ifdef AUTO_SHUTDOWN_MILLIVOLTS
|
||||
if (millis() > next_batt_chck) {
|
||||
if (static_cast<int32_t>(millis() - next_batt_chck) >= 0) {
|
||||
uint16_t milliVolts = getBattMilliVolts();
|
||||
if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) {
|
||||
if(!board.isExternalPowered()) {
|
||||
@@ -2574,7 +2590,7 @@ char UITask::checkDisplayOn(char c) {
|
||||
}
|
||||
c = 0;
|
||||
}
|
||||
_next_refresh = 0; // trigger refresh
|
||||
_next_refresh = millis(); // trigger refresh
|
||||
}
|
||||
return c;
|
||||
}
|
||||
@@ -2655,7 +2671,7 @@ void UITask::toggleGPS() {
|
||||
}
|
||||
the_mesh.savePrefs();
|
||||
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
|
||||
_next_refresh = 0;
|
||||
_next_refresh = millis();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -2675,7 +2691,7 @@ void UITask::toggleBuzzer() {
|
||||
the_mesh.setNotificationOutputMute(mesh::notify::Sound, _node_prefs->buzzer_quiet);
|
||||
the_mesh.savePrefs();
|
||||
showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800);
|
||||
_next_refresh = 0; // trigger refresh
|
||||
_next_refresh = millis(); // trigger refresh
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -2701,7 +2717,7 @@ void UITask::notificationMelody(const char* text) {
|
||||
void UITask::notificationScreen(int8_t mode) {
|
||||
if (!_display) return;
|
||||
_display->notificationPower(mode);
|
||||
_next_refresh = 0;
|
||||
_next_refresh = millis();
|
||||
}
|
||||
void UITask::notificationVibration(bool on) {
|
||||
#ifdef HAS_DRV2605
|
||||
|
||||
@@ -39,14 +39,15 @@ class UITask : public AbstractUITask {
|
||||
#elif defined(PIN_VIBRATION)
|
||||
GenericVibration vibration;
|
||||
#endif
|
||||
unsigned long _next_refresh;
|
||||
uint32_t _next_refresh;
|
||||
unsigned long _pairing_screen_until;
|
||||
bool _deferred_msg_preview;
|
||||
CompanionNodePrefs* _node_prefs;
|
||||
char _alert[80];
|
||||
unsigned long _alert_expiry;
|
||||
uint32_t _alert_expiry;
|
||||
int _msgcount;
|
||||
unsigned long ui_started_at, next_batt_chck;
|
||||
unsigned long ui_started_at;
|
||||
uint32_t next_batt_chck;
|
||||
int next_backlight_btn_check = 0;
|
||||
#ifdef HAS_TOUCH
|
||||
#ifndef TOUCH_CENTER_ZONE_PERCENT
|
||||
@@ -155,7 +156,7 @@ public:
|
||||
_touch_debug_enabled = enabled;
|
||||
_touch_debug_x = _touch_debug_y = _touch_debug_area = -1;
|
||||
_touch_debug_screen = nullptr;
|
||||
_next_refresh = 0; // Also removes already-drawn outlines when disabling.
|
||||
_next_refresh = millis(); // Also removes already-drawn outlines when disabling.
|
||||
if (enabled) checkDisplayOn(0); // Wake without selecting or navigating.
|
||||
return true;
|
||||
}
|
||||
@@ -185,7 +186,7 @@ public:
|
||||
void msgRead(int msgcount) override;
|
||||
void syncMessageQueue(int msgcount, int removed_index = -1) override;
|
||||
bool supportsInboxModes() const override { return _display != nullptr; }
|
||||
void inboxModeChanged() override { _next_refresh = 0; }
|
||||
void inboxModeChanged() override { _next_refresh = millis(); }
|
||||
const char* inboxTitle() const;
|
||||
void newMsg(uint8_t path_len, const char* from_name, const char* text,
|
||||
int msgcount, int channel_idx = -1,
|
||||
|
||||
@@ -159,6 +159,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
|
||||
_userButtonAnalog->onAnyPress([this]() { handleButtonAnyPress(); });
|
||||
#endif
|
||||
ui_started_at = millis();
|
||||
_next_refresh = ui_started_at;
|
||||
}
|
||||
|
||||
void UITask::resetRadioProfileDisplayPage() {
|
||||
@@ -666,7 +667,7 @@ void UITask::showPairingPin() {
|
||||
const unsigned long now = millis();
|
||||
_pairing_screen_until = now + BLE_PAIRING_DISPLAY_MILLIS;
|
||||
_need_refresh = true;
|
||||
_next_refresh = 0;
|
||||
_next_refresh = millis();
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection(), true);
|
||||
}
|
||||
@@ -675,7 +676,7 @@ void UITask::showPairingPin() {
|
||||
void UITask::finishPairingScreen(bool timed_out) {
|
||||
_pairing_screen_until = 0;
|
||||
_need_refresh = true;
|
||||
_next_refresh = 0;
|
||||
_next_refresh = millis();
|
||||
if (_display == NULL) return;
|
||||
|
||||
if (timed_out) {
|
||||
@@ -698,7 +699,7 @@ void UITask::servicePairingState() {
|
||||
if (_display != NULL && _display->servicePower(
|
||||
_board->isExternalPowered() || _board->isUsbHostConnected(),
|
||||
hasConnection(), isPairingScreenActive())) {
|
||||
_next_refresh = 0;
|
||||
_next_refresh = millis();
|
||||
_need_refresh = true;
|
||||
}
|
||||
}
|
||||
@@ -710,6 +711,13 @@ void UITask::loop() {
|
||||
#endif
|
||||
servicePairingState();
|
||||
|
||||
// Keep an already-due refresh due while clean or asleep, without moving
|
||||
// a future throttle. It must not age past the signed clock's half range.
|
||||
const uint32_t refresh_now = millis();
|
||||
if (static_cast<int32_t>(refresh_now - _next_refresh) >= 0) {
|
||||
_next_refresh = refresh_now;
|
||||
}
|
||||
|
||||
#ifdef PIN_USER_BTN
|
||||
if (_userButton) {
|
||||
_userButton->update();
|
||||
@@ -739,7 +747,7 @@ void UITask::loop() {
|
||||
_need_refresh = true;
|
||||
_firstBoot = false;
|
||||
}
|
||||
if (millis() >= _next_refresh && _need_refresh) {
|
||||
if (static_cast<int32_t>(millis() - _next_refresh) >= 0 && _need_refresh) {
|
||||
_display->startFrame();
|
||||
renderCurrScreen();
|
||||
_display->endFrame();
|
||||
|
||||
@@ -31,7 +31,7 @@ class UITask : public AbstractUITask {
|
||||
#elif defined(PIN_VIBRATION)
|
||||
GenericVibration vibration;
|
||||
#endif
|
||||
unsigned long _next_refresh;
|
||||
uint32_t _next_refresh;
|
||||
CompanionNodePrefs* _node_prefs;
|
||||
char _version_info[32];
|
||||
char _origin[62];
|
||||
|
||||
@@ -20,7 +20,7 @@ class ScrollingStatusBar {
|
||||
int _text_width;
|
||||
int _scroll_x;
|
||||
int _display_width;
|
||||
unsigned long _next_scroll;
|
||||
uint32_t _next_scroll;
|
||||
unsigned long _next_update;
|
||||
bool _needs_redraw;
|
||||
|
||||
@@ -43,8 +43,9 @@ public:
|
||||
void begin(int display_width) {
|
||||
_display_width = display_width;
|
||||
_scroll_x = 0;
|
||||
_next_scroll = 0;
|
||||
_next_scroll = millis();
|
||||
_next_update = 0;
|
||||
_needs_redraw = true;
|
||||
}
|
||||
|
||||
// Call periodically to update the status string content.
|
||||
@@ -92,8 +93,9 @@ public:
|
||||
|
||||
// Returns true if the status bar needs a redraw this frame.
|
||||
bool needsRedraw() {
|
||||
if (_text_width <= _display_width) return _needs_redraw; // static, no scrolling
|
||||
return millis() >= _next_scroll;
|
||||
if (_needs_redraw) return true;
|
||||
if (_text_width <= _display_width) return false; // static, no scrolling
|
||||
return static_cast<int32_t>(millis() - _next_scroll) >= 0;
|
||||
}
|
||||
|
||||
// Render the status bar via DisplayDriver.
|
||||
@@ -134,4 +136,4 @@ public:
|
||||
_needs_redraw = false;
|
||||
}
|
||||
|
||||
};
|
||||
};
|
||||
@@ -665,6 +665,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
|
||||
#endif
|
||||
|
||||
ui_started_at = millis();
|
||||
next_batt_chck = _next_refresh = ui_started_at;
|
||||
_alert[0] = 0;
|
||||
_alert_expiry = 0;
|
||||
|
||||
splash = new SplashScreen(this);
|
||||
@@ -739,7 +741,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
|
||||
if (shouldWakeDisplayForMessage()) {
|
||||
_display->wake(mesh::ui::DisplayWake::Message);
|
||||
}
|
||||
if (_display->isOn()) _next_refresh = 0;
|
||||
if (_display->isOn()) _next_refresh = millis();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,7 +769,7 @@ void UITask::userLedHandler() {
|
||||
|
||||
void UITask::setCurrScreen(UIScreen* c) {
|
||||
curr = c;
|
||||
_next_refresh = 100;
|
||||
_next_refresh = millis();
|
||||
}
|
||||
|
||||
bool UITask::isPairingScreenActive() const {
|
||||
@@ -786,13 +788,13 @@ void UITask::showPairingPin() {
|
||||
setCurrScreen(home);
|
||||
if (_display != NULL) {
|
||||
_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection(), true);
|
||||
_next_refresh = 0;
|
||||
_next_refresh = millis();
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::finishPairingScreen(bool timed_out) {
|
||||
_pairing_screen_until = 0;
|
||||
_next_refresh = 0;
|
||||
_next_refresh = millis();
|
||||
#if UI_SMALL_MESSAGE_FONT == 1
|
||||
if (_deferred_msg_preview) {
|
||||
_deferred_msg_preview = false;
|
||||
@@ -820,7 +822,7 @@ void UITask::servicePairingState() {
|
||||
}
|
||||
if (_display != NULL && _display->servicePower(
|
||||
_board->isExternalPowered() || _board->isUsbHostConnected(),
|
||||
hasConnection(), isPairingScreenActive())) _next_refresh = 0;
|
||||
hasConnection(), isPairingScreenActive())) _next_refresh = millis();
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -862,6 +864,25 @@ void UITask::loop() {
|
||||
const bool morse_timing = mesh::morseTimingActive(&radio_driver);
|
||||
servicePairingState();
|
||||
|
||||
// Keep already-due work due across long display-off/Morse intervals,
|
||||
// without moving a future refresh or triggering any display/ADC work.
|
||||
const uint32_t refresh_now = millis();
|
||||
if (static_cast<int32_t>(refresh_now - _next_refresh) >= 0) {
|
||||
_next_refresh = refresh_now;
|
||||
}
|
||||
#ifndef AUTO_SHUTDOWN_MILLIVOLTS
|
||||
if (static_cast<int32_t>(refresh_now - next_batt_chck) >= 0) {
|
||||
next_batt_chck = refresh_now;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Retire the popup even while sleep, Morse or input prevents drawing.
|
||||
// Its deadline may be zero after wrapping; empty text means inactive.
|
||||
if (_alert[0] && static_cast<int32_t>(millis() - _alert_expiry) >= 0) {
|
||||
_alert[0] = 0;
|
||||
_next_refresh = millis();
|
||||
}
|
||||
|
||||
char c = 0;
|
||||
#if UI_HAS_JOYSTICK
|
||||
int ev = user_btn.check();
|
||||
@@ -942,7 +963,7 @@ void UITask::loop() {
|
||||
if (c != 0 && curr) {
|
||||
_display->wake(mesh::ui::DisplayWake::Button);
|
||||
curr->handleInput(c);
|
||||
_next_refresh = 100; // trigger refresh
|
||||
_next_refresh = millis(); // trigger refresh
|
||||
}
|
||||
|
||||
userLedHandler();
|
||||
@@ -962,7 +983,7 @@ void UITask::loop() {
|
||||
isBluetoothEnabled());
|
||||
|
||||
bool status_dirty = _statusBar.needsRedraw();
|
||||
bool content_dirty = (millis() >= _next_refresh && curr);
|
||||
bool content_dirty = (static_cast<int32_t>(millis() - _next_refresh) >= 0 && curr);
|
||||
|
||||
if (status_dirty || content_dirty) {
|
||||
_display->startFrame();
|
||||
@@ -975,7 +996,7 @@ void UITask::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPairingScreenActive() && millis() < _alert_expiry) { // render alert popup
|
||||
if (!isPairingScreenActive() && _alert[0] && static_cast<int32_t>(millis() - _alert_expiry) < 0) { // render alert popup
|
||||
_display->setTextSize(1);
|
||||
int y = _display->height() / 3;
|
||||
int p = _display->height() / 32;
|
||||
@@ -1001,7 +1022,7 @@ void UITask::loop() {
|
||||
#endif
|
||||
|
||||
#ifdef AUTO_SHUTDOWN_MILLIVOLTS
|
||||
if (millis() > next_batt_chck) {
|
||||
if (static_cast<int32_t>(millis() - next_batt_chck) >= 0) {
|
||||
_cached_batt_mv = getBattMilliVolts();
|
||||
if (_cached_batt_mv > 0 && _cached_batt_mv < AUTO_SHUTDOWN_MILLIVOLTS) {
|
||||
if(!board.isExternalPowered()) {
|
||||
@@ -1020,7 +1041,7 @@ void UITask::loop() {
|
||||
next_batt_chck = millis() + 8000;
|
||||
}
|
||||
#else
|
||||
if (!morse_timing && _display != NULL && _display->isOn() && millis() >= next_batt_chck) {
|
||||
if (!morse_timing && _display != NULL && _display->isOn() && static_cast<int32_t>(millis() - next_batt_chck) >= 0) {
|
||||
_cached_batt_mv = getBattMilliVolts();
|
||||
next_batt_chck = millis() + 8000;
|
||||
}
|
||||
@@ -1035,7 +1056,7 @@ char UITask::checkDisplayOn(char c) {
|
||||
if (!was_on) {
|
||||
c = 0;
|
||||
}
|
||||
_next_refresh = 0; // trigger refresh
|
||||
_next_refresh = millis(); // trigger refresh
|
||||
}
|
||||
return c;
|
||||
}
|
||||
@@ -1091,7 +1112,7 @@ void UITask::toggleGPS() {
|
||||
}
|
||||
the_mesh.savePrefs();
|
||||
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
|
||||
_next_refresh = 0;
|
||||
_next_refresh = millis();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1111,7 +1132,7 @@ void UITask::toggleBuzzer() {
|
||||
the_mesh.setNotificationOutputMute(mesh::notify::Sound, _node_prefs->buzzer_quiet);
|
||||
the_mesh.savePrefs();
|
||||
showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800);
|
||||
_next_refresh = 0; // trigger refresh
|
||||
_next_refresh = millis(); // trigger refresh
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1137,7 +1158,7 @@ void UITask::notificationMelody(const char* text) {
|
||||
void UITask::notificationScreen(int8_t mode) {
|
||||
if (!_display) return;
|
||||
_display->notificationPower(mode);
|
||||
_next_refresh = 0;
|
||||
_next_refresh = millis();
|
||||
}
|
||||
void UITask::notificationVibration(bool on) {
|
||||
#ifdef HAS_DRV2605
|
||||
|
||||
@@ -39,13 +39,14 @@ class UITask : public AbstractUITask {
|
||||
#elif defined(PIN_VIBRATION)
|
||||
GenericVibration vibration;
|
||||
#endif
|
||||
unsigned long _next_refresh;
|
||||
uint32_t _next_refresh;
|
||||
CompanionNodePrefs* _node_prefs;
|
||||
char _alert[80];
|
||||
unsigned long _alert_expiry;
|
||||
uint32_t _alert_expiry;
|
||||
unsigned long _pairing_screen_until;
|
||||
int _msgcount;
|
||||
unsigned long ui_started_at, next_batt_chck;
|
||||
unsigned long ui_started_at;
|
||||
uint32_t next_batt_chck;
|
||||
int next_backlight_btn_check = 0;
|
||||
uint16_t _cached_batt_mv;
|
||||
#ifdef PIN_STATUS_LED
|
||||
|
||||
+398
@@ -0,0 +1,398 @@
|
||||
// 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>
|
||||
|
||||
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;
|
||||
struct Radio { bool timing = false; } radio_driver;
|
||||
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;
|
||||
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;
|
||||
uint16_t _cached_batt_mv = 4000, voltage = 4000;
|
||||
bool gesture_pending = false;
|
||||
bool _need_refresh = true;
|
||||
unsigned battery_reads = 0, shutdowns = 0;
|
||||
char _alert[80] = {}, _origin[80] = {}, _msg[80] = {};
|
||||
void servicePairingState() {}
|
||||
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() {}
|
||||
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();
|
||||
};
|
||||
@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, "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);
|
||||
}
|
||||
@@ -66,7 +66,8 @@ struct UITask {
|
||||
UIScreen* home = &home_screen;
|
||||
UIScreen* msg_preview = &preview_screen;
|
||||
UIScreen* curr = home;
|
||||
unsigned long _pairing_screen_until = 0, _next_refresh = 0;
|
||||
unsigned long _pairing_screen_until = 0;
|
||||
uint32_t _next_refresh = 0;
|
||||
bool _pairing_from_button = false, _deferred_msg_preview = false;
|
||||
int _msgcount = 0;
|
||||
UITask(TestDisplay* display, Board* board, Interfaces* interfaces)
|
||||
@@ -77,7 +78,7 @@ struct UITask {
|
||||
const char* connectedClientLabel() const {
|
||||
return _interfaceManager->ble ? "BLUETOOTH" : _interfaceManager->usb ? "USB" : nullptr;
|
||||
}
|
||||
void setCurrScreen(UIScreen* screen) { curr = screen; _next_refresh = 100; }
|
||||
void setCurrScreen(UIScreen* screen);
|
||||
void gotoHomeScreen() { setCurrScreen(home); }
|
||||
bool isPairingPromptActive() const { return isPairingScreenActive(); }
|
||||
bool isPairingScreenActive() const;
|
||||
@@ -163,6 +164,7 @@ class CompanionPairingDisplayTests(unittest.TestCase):
|
||||
def test_real_pairing_methods_and_usb_oled_selection(self):
|
||||
source = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text()
|
||||
signatures = (
|
||||
"void UITask::setCurrScreen(",
|
||||
"bool UITask::isPairingScreenActive() const",
|
||||
"void UITask::renderPairingBanner()",
|
||||
"void UITask::showPairingPin(bool from_button)",
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run complete actual UI loops against explicit 32-bit clocks and fields.
|
||||
|
||||
The test adapts display/screen/radio/input dependencies, not scheduling or popup
|
||||
conditions. It excludes optional button, touch, buzzer, reader and vibration
|
||||
branches. Startup begin()/hardware renderers are not compiled by this boundary.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
import tempfile
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURE = Path(__file__).resolve().parent / 'fixtures/companion_ui_deadlines.cpp'
|
||||
VARIANTS = ('ui-orig', 'ui-tiny', 'ui-new')
|
||||
BUILD_VARIANTS = VARIANTS + ('ui-tiny-cache',)
|
||||
|
||||
|
||||
class CompanionUiDeadlineTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
compiler = shutil.which('g++') or shutil.which('clang++')
|
||||
if compiler is None:
|
||||
raise unittest.SkipTest('a host C++17 compiler is required')
|
||||
cls.directory = tempfile.TemporaryDirectory(prefix='companion-ui-deadlines-')
|
||||
cls.addClassCleanup(cls.directory.cleanup)
|
||||
work = Path(cls.directory.name)
|
||||
cls.binaries = {}
|
||||
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()'))
|
||||
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([
|
||||
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)
|
||||
if built.returncode:
|
||||
raise AssertionError(built.stdout + built.stderr)
|
||||
cls.binaries[variant] = binary
|
||||
|
||||
def check_scenario(self, scenario, variants=VARIANTS):
|
||||
for variant in variants:
|
||||
with self.subTest(variant=variant, scenario=scenario):
|
||||
checked = subprocess.run([str(self.binaries[variant]), scenario],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
print(f'\n[{variant}; {scenario}]\n' + checked.stdout + checked.stderr, flush=True)
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
self.assertIn('PASS: ' + scenario, checked.stdout)
|
||||
|
||||
def test_healthy_refresh_schedule(self):
|
||||
self.check_scenario('healthy')
|
||||
|
||||
def test_upper_half_refresh_schedule(self):
|
||||
self.check_scenario('upper-half')
|
||||
|
||||
def test_actual_forced_screen_change_in_upper_half(self):
|
||||
self.check_scenario('force-upper-half', VARIANTS[1:])
|
||||
|
||||
def test_morse_deferred_refresh_survives_wrap(self):
|
||||
self.check_scenario('deferred-across-wrap')
|
||||
|
||||
def test_wrapped_future_deadline_is_not_force_zero(self):
|
||||
self.check_scenario('wrapped-future-deadline')
|
||||
|
||||
def test_alert_remains_active_across_wrap(self):
|
||||
self.check_scenario('alert-across-wrap', VARIANTS[1:])
|
||||
|
||||
def test_alert_with_zero_expiry_is_active(self):
|
||||
self.check_scenario('alert-zero-deadline', VARIANTS[1:])
|
||||
|
||||
def test_expired_alert_never_returns_after_wrap(self):
|
||||
self.check_scenario('expired-after-wrap', VARIANTS[1:])
|
||||
|
||||
def test_expired_alert_never_returns_after_halfwrap(self):
|
||||
self.check_scenario('expired-after-halfwrap', VARIANTS[1:])
|
||||
|
||||
def test_protective_battery_poll_survives_wrap(self):
|
||||
self.check_scenario('battery-deferred-wrap', VARIANTS[1:])
|
||||
|
||||
def test_healthy_and_upper_half_battery_schedule(self):
|
||||
for scenario in ('battery-healthy', 'battery-upper-half'):
|
||||
self.check_scenario(scenario, VARIANTS[1:])
|
||||
|
||||
def test_upper_half_alert_schedule(self):
|
||||
self.check_scenario('alert-upper-half', VARIANTS[1:])
|
||||
|
||||
def test_future_battery_poll_does_not_run_early(self):
|
||||
self.check_scenario('battery-wrapped-future', VARIANTS[1:])
|
||||
|
||||
def test_alert_expiry_is_consumed_outside_display_gate(self):
|
||||
self.check_scenario('expire-with-display-off', VARIANTS[1:])
|
||||
|
||||
def test_alert_expiry_is_consumed_outside_morse_gate(self):
|
||||
self.check_scenario('expire-during-morse', VARIANTS[1:])
|
||||
|
||||
def test_alert_expiry_is_consumed_outside_gesture_gate(self):
|
||||
self.check_scenario('expire-during-gesture', ('ui-new',))
|
||||
|
||||
def test_clean_orig_preview_keeps_due_refresh_after_26_days(self):
|
||||
self.check_scenario('orig-long-clean-idle', ('ui-orig',))
|
||||
|
||||
def test_display_off_live_loops_keep_refresh_due_after_26_days(self):
|
||||
self.check_scenario('display-off-long-idle', ('ui-tiny', 'ui-new', 'ui-tiny-cache'))
|
||||
|
||||
def test_fresh_orig_dirty_event_preserves_one_second_throttle(self):
|
||||
self.check_scenario('fresh-dirty-throttle', ('ui-orig',))
|
||||
|
||||
def test_tiny_cached_adc_waits_for_safe_wake_after_26_days(self):
|
||||
self.check_scenario('cache-battery-long-idle', ('ui-tiny-cache',))
|
||||
|
||||
def test_tiny_cached_adc_future_zero_and_off_morse_gates(self):
|
||||
self.check_scenario('cache-battery-wrapped-future', ('ui-tiny-cache',))
|
||||
|
||||
|
||||
class CompanionUiDeadlineWiringContract(unittest.TestCase):
|
||||
def test_begin_types_force_paths_and_ci_keep_deadlines_armed(self):
|
||||
for variant in ('ui-orig', 'ui-tiny', 'ui-new'):
|
||||
with self.subTest(variant=variant):
|
||||
directory = ROOT / 'examples/companion_radio' / variant
|
||||
source = (directory / 'UITask.cpp').read_text(encoding='ascii')
|
||||
header = (directory / 'UITask.h').read_text(encoding='ascii')
|
||||
begin = extract_braced(source, 'void UITask::begin(')
|
||||
constructor = extract_braced(header, 'UITask(mesh::MainBoard*')
|
||||
self.assertRegex(header, r'\buint32_t\s+_next_refresh\s*;')
|
||||
# Construction precedes Arduino begin. It may use zero, but
|
||||
# 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.assertLess(begin.index('ui_started_at = millis();'),
|
||||
begin.index('_next_refresh = ui_started_at;'))
|
||||
without_constructor = header.replace(constructor, '', 1)
|
||||
old_force = r'\b_next_refresh\s*=\s*(?:0|100)[uUlL]*\s*;'
|
||||
self.assertNotRegex(source, old_force)
|
||||
self.assertNotRegex(without_constructor, old_force)
|
||||
if variant == 'ui-orig':
|
||||
# Re-begin does not rely on a constructor-only dirty flag.
|
||||
self.assertIn('clearMsgPreview();', begin)
|
||||
clear = extract_braced(source, 'void UITask::clearMsgPreview(')
|
||||
self.assertIn('_need_refresh = true;', clear)
|
||||
else:
|
||||
self.assertRegex(header, r'\buint32_t\s+_alert_expiry\s*;')
|
||||
self.assertRegex(header, r'\buint32_t\s+next_batt_chck\s*;')
|
||||
self.assertIn('next_batt_chck = _next_refresh = ui_started_at;', begin)
|
||||
self.assertIn('_alert[0] = 0;', begin)
|
||||
self.assertIn('_alert_expiry = 0;', begin)
|
||||
for initialization in ('next_batt_chck = _next_refresh = ui_started_at;',
|
||||
'_alert[0] = 0;', '_alert_expiry = 0;'):
|
||||
self.assertLess(begin.index(initialization),
|
||||
begin.index('setCurrScreen(splash);'))
|
||||
screen = extract_braced(source, 'void UITask::setCurrScreen(')
|
||||
self.assertIn('_next_refresh = millis();', screen)
|
||||
if variant == 'ui-new':
|
||||
for signature in ('bool setTouchDebugEnabled(bool enabled)',
|
||||
'void inboxModeChanged()'):
|
||||
inline = extract_braced(header, signature)
|
||||
self.assertIn('_next_refresh = millis();', inline)
|
||||
main = (ROOT / 'examples/companion_radio/main.cpp').read_text(encoding='ascii')
|
||||
self.assertIn('ui_task.begin(', extract_braced(main, 'void setup()'))
|
||||
self.assertIn('ui_task.loop();', extract_braced(main, '\nvoid loop()'))
|
||||
workflow = (ROOT / '.github/workflows/run-unit-tests.yml').read_text(encoding='ascii')
|
||||
for module in ('test_companion_ui_deadlines', 'test_scrolling_status_bar_rollover'):
|
||||
self.assertIn('python3 -B test/' + module + '.py -v', workflow)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -408,7 +408,7 @@ class MessageNavigationTest(unittest.TestCase):
|
||||
implementation = "\n".join(extract_braced(source, signature) for signature in (
|
||||
"char UITask::checkDisplayOn(", "char UITask::handleDoubleClick(",
|
||||
"char UITask::handleMultiClick(", "bool UITask::isButtonGesturePending("))
|
||||
self.assertIn("millis() >= _next_refresh && curr && !isButtonGesturePending()", source)
|
||||
self.assertIn("static_cast<int32_t>(millis() - _next_refresh) >= 0 && curr && !isButtonGesturePending()", source)
|
||||
implementation += "\n" + extract_braced(source, "class MsgPreviewScreen :") + ";\n"
|
||||
implementation += "void UITask::pollButton() { char c=0; int ev=user_btn.check();\n"
|
||||
implementation += button_route + "\nif(c && curr) curr->handleInput(c);\n}\n"
|
||||
|
||||
@@ -207,7 +207,7 @@ int main() {
|
||||
def test_actual_all_six_ui_redraw_blocks_defer_without_consuming_deadlines(self):
|
||||
specs = (
|
||||
("examples/companion_radio/ui-new/UITask.cpp",
|
||||
"if (!morse_timing && millis() >= _next_refresh"),
|
||||
"if (!morse_timing && static_cast<int32_t>(millis() - _next_refresh) >= 0"),
|
||||
("examples/companion_radio/ui-tiny/UITask.cpp",
|
||||
"if (!morse_timing && _display != NULL && _display->isOn())"),
|
||||
("examples/companion_radio/ui-orig/UITask.cpp",
|
||||
@@ -247,7 +247,7 @@ int main() {
|
||||
with self.subTest(ui=variant):
|
||||
source = (ROOT / f"examples/companion_radio/{variant}/UITask.cpp").read_text(encoding="ascii")
|
||||
loop = method(source, "void UITask::loop()")
|
||||
cutoff = method(loop, "if (millis() > next_batt_chck)")
|
||||
cutoff = method(loop, "if (static_cast<int32_t>(millis() - next_batt_chck) >= 0)")
|
||||
self.assertNotIn("morse_timing", cutoff)
|
||||
code = FIXTURE.replace("@REDRAW@", "").replace("@CUTOFF@", cutoff) + r'''
|
||||
int main() {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Exercise the actual tiny UI status-bar header across clock rollover."""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CXX = shutil.which("g++")
|
||||
|
||||
DRIVER = r'''#pragma once
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
extern uint32_t now_ms;
|
||||
inline uint32_t millis() { return now_ms; }
|
||||
namespace UIColor { constexpr unsigned primary_txt = 1; }
|
||||
struct DisplayDriver {
|
||||
int width = 200;
|
||||
unsigned prints = 0;
|
||||
int last_x = 0;
|
||||
void setTextSize(int) {}
|
||||
int getTextWidth(const char*) { return width; }
|
||||
void setColor(unsigned) {}
|
||||
void setCursor(int x, int) { last_x = x; }
|
||||
void print(const char*) { ++prints; }
|
||||
};
|
||||
'''
|
||||
|
||||
FIXTURE = r'''#include <ScrollingStatusBar.h>
|
||||
#include <cstdlib>
|
||||
uint32_t now_ms = 0;
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 2);
|
||||
const int mode = std::atoi(argv[1]);
|
||||
ScrollingStatusBar bar;
|
||||
DisplayDriver display;
|
||||
auto update = [&](const char* name) { bar.update(display, name, 4000, false, false, true); };
|
||||
if (mode == 1) {
|
||||
now_ms = 0xfffffff0U;
|
||||
bar.begin(72); update("wrapped-next");
|
||||
assert(bar.needsRedraw());
|
||||
bar.render(display);
|
||||
assert(!bar.needsRedraw());
|
||||
now_ms += 79; assert(!bar.needsRedraw());
|
||||
++now_ms; assert(bar.needsRedraw());
|
||||
bar.render(display); assert(!bar.needsRedraw());
|
||||
} else if (mode == 2) {
|
||||
now_ms = 0xffffffffU - 100;
|
||||
bar.begin(72); update("overdue-high");
|
||||
assert(bar.needsRedraw()); bar.render(display);
|
||||
now_ms = 5; assert(bar.needsRedraw());
|
||||
bar.render(display); assert(!bar.needsRedraw());
|
||||
} else if (mode == 3) {
|
||||
now_ms = 0x80000000U;
|
||||
bar.begin(72); update("upper-half");
|
||||
assert(bar.needsRedraw()); bar.render(display);
|
||||
assert(!bar.needsRedraw());
|
||||
// Fresh content must redraw immediately, not inherit the old deadline.
|
||||
update("changed-with-future-scroll"); assert(bar.needsRedraw());
|
||||
bar.render(display); assert(!bar.needsRedraw());
|
||||
} else if (mode == 4) {
|
||||
now_ms = 0x80000000U;
|
||||
display.width = 50;
|
||||
bar.begin(72); update("static");
|
||||
assert(bar.needsRedraw()); bar.render(display);
|
||||
assert(!bar.needsRedraw());
|
||||
now_ms += 1000; assert(!bar.needsRedraw());
|
||||
update("static-changed"); assert(bar.needsRedraw());
|
||||
bar.render(display); assert(!bar.needsRedraw());
|
||||
} else if (mode == 5) {
|
||||
now_ms = 1000;
|
||||
display.width = 50;
|
||||
bar.begin(72); update("static");
|
||||
assert(bar.needsRedraw()); bar.render(display);
|
||||
assert(!bar.needsRedraw());
|
||||
bar.begin(72); assert(bar.needsRedraw());
|
||||
} else assert(false);
|
||||
assert(display.prints);
|
||||
}
|
||||
'''
|
||||
|
||||
@unittest.skipUnless(CXX, "Native C++ compiler is required")
|
||||
class ScrollingStatusBarRolloverTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.temporary = tempfile.TemporaryDirectory(prefix="statusbar-rollover-")
|
||||
cls.addClassCleanup(cls.temporary.cleanup)
|
||||
directory = Path(cls.temporary.name)
|
||||
stub = directory / "helpers/ui/DisplayDriver.h"
|
||||
stub.parent.mkdir(parents=True)
|
||||
stub.write_text(DRIVER, encoding="ascii")
|
||||
source = directory / "fixture.cpp"
|
||||
source.write_text(FIXTURE, encoding="ascii")
|
||||
cls.binary = directory / "probe"
|
||||
subprocess.run([
|
||||
CXX, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-omit-frame-pointer", "-fno-pie", "-no-pie",
|
||||
"-I" + str(ROOT / "examples/companion_radio/ui-tiny"),
|
||||
"-I" + str(directory), str(source), "-o", str(cls.binary),
|
||||
], check=True, capture_output=True, timeout=30)
|
||||
|
||||
def run_mode(self, mode):
|
||||
result = subprocess.run([str(self.binary), str(mode)], capture_output=True,
|
||||
text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_future_wrapped_scroll_deadline_waits_exactly_eighty_ms(self):
|
||||
self.run_mode(1)
|
||||
|
||||
def test_high_deadline_expired_after_wrap_is_immediately_due(self):
|
||||
self.run_mode(2)
|
||||
|
||||
def test_begin_upper_half_and_changed_scrolling_content_are_immediate(self):
|
||||
self.run_mode(3)
|
||||
|
||||
def test_static_content_updates_only_when_dirty(self):
|
||||
self.run_mode(4)
|
||||
|
||||
def test_begin_rearms_static_content_for_redraw(self):
|
||||
self.run_mode(5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -30,10 +30,13 @@ class TouchDebugCliTest(unittest.TestCase):
|
||||
setter = extract_braced(mesh, 'if (strncmp(command, "set display.touch", 17) == 0')
|
||||
source = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <initializer_list>
|
||||
static uint32_t mock_now = 0x80001000u;
|
||||
static uint32_t millis() { return mock_now; }
|
||||
struct UIScreen {};
|
||||
struct Display {};
|
||||
struct AbstractUITask {
|
||||
@@ -41,7 +44,7 @@ BASE_HOOKS
|
||||
};
|
||||
struct UITask : AbstractUITask {
|
||||
Display* _display=nullptr;
|
||||
unsigned long _next_refresh=999;
|
||||
uint32_t _next_refresh=999;
|
||||
int wakes=0;
|
||||
char checkDisplayOn(char c) { assert(c==0); ++wakes; return 0; }
|
||||
FIELDS
|
||||
@@ -76,7 +79,7 @@ int main() {
|
||||
assert(ui.supportsTouchDebug());
|
||||
assert(command("get display.touch")=="display.touch off (off after reboot)");
|
||||
assert(command("set display.touch on")=="OK - display.touch on (off after reboot)");
|
||||
assert(ui.isTouchDebugEnabled() && ui.wakes==1 && ui._next_refresh==0);
|
||||
assert(ui.isTouchDebugEnabled() && ui.wakes==1 && ui._next_refresh==millis());
|
||||
assert(command("get display.touch")=="display.touch on (off after reboot)");
|
||||
// Invalid inputs must not mutate state or accidentally accept a suffix.
|
||||
for(const char* input : {"set display.touch", "set display.touch ",
|
||||
@@ -90,7 +93,7 @@ int main() {
|
||||
ui._touch_debug_area=4; ui._touch_debug_screen=&screen;
|
||||
ui._next_refresh=999;
|
||||
assert(command("set display.touch\t off")=="OK - display.touch off (off after reboot)");
|
||||
assert(!ui.isTouchDebugEnabled() && ui.wakes==1 && ui._next_refresh==0);
|
||||
assert(!ui.isTouchDebugEnabled() && ui.wakes==1 && ui._next_refresh==millis());
|
||||
assert(ui._touch_debug_x==-1 && ui._touch_debug_y==-1 && ui._touch_debug_area==-1);
|
||||
assert(ui._touch_debug_screen==nullptr);
|
||||
assert(command("set display.touch on").find("OK")==0);
|
||||
|
||||
@@ -159,7 +159,7 @@ class TouchDebugOverlayTest(unittest.TestCase):
|
||||
profile = (ROOT / "variants/sensecap_indicator-espnow/platformio.ini").read_text()
|
||||
self.assertNotIn("UI_TOUCH_DEBUG_OUTLINES", profile)
|
||||
self.assertIn("TOUCH_MIRROR_TAP_X_ENABLED ? _display->width() - 1 - touch_x : touch_x", loop)
|
||||
self.assertIn("if (debug_area != _touch_debug_area || curr != _touch_debug_screen) _next_refresh = 0;", loop)
|
||||
self.assertIn("if (debug_area != _touch_debug_area || curr != _touch_debug_screen) _next_refresh = millis();", loop)
|
||||
self.assertIn("_touch_debug_screen == curr ? _touch_debug_x : -1", loop)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user