Keep sensor UI polling and refresh working across uptime rollover

This commit is contained in:
mikecarper committed 2026-10-16 17:56:45 -07:00
1 parent 01d9b7c376
commit a24f9d5ef8
6 files changed
+381 -10

No files matched your search

+1
View File
@@ -783,6 +783,7 @@ jobs:
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_sensor_ui_polling.py -v
python3 -B test/test_home_sensor_refresh.py -v
python3 -B test/test_scrolling_status_bar_rollover.py -v
python3 -B test/test_gps_voice_buttons.py -v
+11 -6
View File
@@ -100,6 +100,11 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi
// v1.2.3 (1 Jan 2025)
snprintf(_version_info, sizeof(_version_info), "%s (%s)", version, build_date);
free(version);
// Make the first read/frame immediately due, including begin() after wrap.
const uint32_t now = millis();
_last_read = now - 200u;
_last_refresh = now - 1000u;
}
void UITask::resetRadioProfileDisplayPage() {
@@ -208,7 +213,7 @@ void UITask::loop() {
const bool morse_timing = mesh::morseTimingActive(activeSensorMesh().getProfileRadio());
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
resetRadioProfileDisplayPage();
_next_refresh = 0;
_last_refresh = millis() - 1000u;
}
#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) \
&& defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \
@@ -218,12 +223,12 @@ void UITask::loop() {
if (!_display->isOn()) {
_display->wake(mesh::ui::DisplayWake::Button);
resetRadioProfileDisplayPage();
_next_refresh = 0;
_last_refresh = millis() - 1000u;
}
_display->wake(mesh::ui::DisplayWake::Button);
}
#elif defined(PIN_USER_BTN)
if (millis() >= _next_read) {
if (static_cast<uint32_t>(millis() - _last_read) >= 200u) {
int btnState = digitalRead(PIN_USER_BTN);
if (btnState != _prevBtnState) {
if (btnState == USER_BTN_PRESSED) { // pressed?
@@ -237,17 +242,17 @@ void UITask::loop() {
}
_prevBtnState = btnState;
}
_next_read = millis() + 200; // 5 reads per second
_last_read = millis(); // 5 reads per second, timed from completed I/O
}
#endif
if (!morse_timing && _display->isOn()) {
if (millis() >= _next_refresh) {
if (static_cast<uint32_t>(millis() - _last_refresh) >= 1000u) {
_display->startFrame();
renderCurrScreen();
_display->endFrame();
_next_refresh = millis() + 1000; // refresh every second
_last_refresh = millis(); // refresh every second, after the frame ends
}
#if MOMENTARY_BUTTON_WAKE_HOLD_MS > 0 && defined(PIN_USER_BTN)
if (user_btn.isWakeHoldActive()) _display->wake(mesh::ui::DisplayWake::Button);
+3 -2
View File
@@ -5,7 +5,8 @@
class UITask {
DisplayDriver* _display;
unsigned long _next_read, _next_refresh;
// Completion timestamps keep periodic work due across millis() rollover.
uint32_t _last_read, _last_refresh;
int _prevBtnState;
NodePrefs* _node_prefs;
char _version_info[32];
@@ -16,7 +17,7 @@ class UITask {
bool showingSecondaryRadioProfilePage();
void renderCurrScreen();
public:
UITask(DisplayDriver& display) : _display(&display) { _next_read = _next_refresh = 0; }
UITask(DisplayDriver& display) : _display(&display) { _last_read = _last_refresh = 0; }
void begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version);
void loop();
+247
View File
@@ -0,0 +1,247 @@
// Production header, begin and loop; only hardware/render boundaries are fake.
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
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 int button_level = HIGH;
static uint32_t millis() { return now_ms; }
[[maybe_unused]] static int digitalRead(int pin) {
if (pin != 7) std::abort();
++reads;
now_ms += read_delay;
return button_level;
}
struct Radio { bool timing = false; } radio;
namespace mesh {
static bool morseTimingActive(const Radio* r) { return r && r->timing; }
namespace ui { enum class DisplayWake { Button }; }
}
struct Board {
bool external = false, usb = false;
bool isExternalPowered() const { return external; }
bool isUsbHostConnected() const { return usb; }
} board;
struct SensorMesh {
Radio* getProfileRadio() { return &radio; }
bool isDualRadioActive() const { return false; }
} sensor_mesh;
static SensorMesh& activeSensorMesh() { return sensor_mesh; }
struct DisplayDriver {
bool on = true, changed = false;
unsigned frames = 0, wakes = 0;
bool servicePower(bool) { bool result = changed; changed = false; return result; }
bool isOn() const { return on; }
void wake(mesh::ui::DisplayWake) { on = true; ++wakes; }
void startFrame() { ++frames; }
void endFrame() { now_ms += end_delay; }
};
struct MomentaryButton {
int event = BUTTON_EVENT_NONE;
bool held = false;
void begin() { event = BUTTON_EVENT_NONE; }
int check() { int result = event; event = BUTTON_EVENT_NONE; return result; }
bool isWakeHoldActive() const { return held; }
} user_btn;
struct NodePrefs {};
#include "UITask.h"
@BEGIN@
@RESET@
@LOOP@
void UITask::renderCurrScreen() { ++renders; now_ms += render_delay; }
static const char* scenario;
static unsigned checks = 0;
#define CHECK(expr) do { ++checks; if (!(expr)) { \
std::fprintf(stderr, "FAIL %s: %s (line %d)\n", scenario, #expr, __LINE__); \
return 1; } } while (0)
int main(int argc, char** argv) {
if (argc != 2) return 2;
scenario = argv[1];
DisplayDriver display;
NodePrefs prefs;
UITask ui(display);
now_ms = 10000;
if (!std::strcmp(scenario, "zero")) now_ms = 0;
else if (!std::strcmp(scenario, "upper-half")) now_ms = 0x80001000u;
else if (!std::strcmp(scenario, "max")) now_ms = UINT32_MAX;
else if (!std::strcmp(scenario, "wrapped-future")) now_ms = UINT32_MAX - 99u;
else if (!std::strcmp(scenario, "zero-boundary")) now_ms = UINT32_MAX - 199u;
else if (!std::strcmp(scenario, "overdue-wrap") || !std::strcmp(scenario, "morse-wrap"))
now_ms = UINT32_MAX - 1999u;
else if (!std::strcmp(scenario, "forced-refresh")) now_ms = 0x80001000u;
else if (!std::strcmp(scenario, "forced-zero")
|| !std::strcmp(scenario, "momentary-zero-wake")
|| !std::strcmp(scenario, "momentary-zero-morse")) now_ms = UINT32_MAX - 99u;
ui.begin(&prefs, "16 Oct 2026", "v1.2.3-test");
if (!std::strcmp(scenario, "completion-anchor")) {
read_delay = 10;
render_delay = 20;
end_delay = 30;
}
ui.loop();
CHECK(display.frames == 1 && renders == 1);
CHECK(reads == unsigned(TEST_RAW_BUTTON));
if (!std::strcmp(scenario, "zero") || !std::strcmp(scenario, "upper-half")
|| !std::strcmp(scenario, "max") || !std::strcmp(scenario, "wrapped-future")) {
++now_ms;
ui.loop();
CHECK(display.frames == 1 && reads == unsigned(TEST_RAW_BUTTON));
} else if (!std::strcmp(scenario, "cadence")) {
now_ms += 199;
ui.loop();
CHECK(reads == unsigned(TEST_RAW_BUTTON) && display.frames == 1);
++now_ms;
ui.loop();
CHECK(reads == 2u * TEST_RAW_BUTTON && display.frames == 1);
now_ms += 799;
ui.loop();
CHECK(display.frames == 1);
++now_ms;
ui.loop();
CHECK(display.frames == 2);
} else if (!std::strcmp(scenario, "zero-boundary")) {
now_ms = UINT32_MAX;
ui.loop();
CHECK(reads == unsigned(TEST_RAW_BUTTON) && display.frames == 1);
now_ms = 0;
ui.loop();
CHECK(reads == 2u * TEST_RAW_BUTTON && display.frames == 1);
now_ms = 799;
ui.loop();
CHECK(display.frames == 1);
++now_ms;
ui.loop();
CHECK(display.frames == 2);
} else if (!std::strcmp(scenario, "overdue-wrap")) {
now_ms = 100;
ui.loop();
CHECK(display.frames == 2 && reads == 2u * TEST_RAW_BUTTON);
ui.loop();
CHECK(display.frames == 2 && reads == 2u * TEST_RAW_BUTTON);
} else if (!std::strcmp(scenario, "morse-wrap")) {
radio.timing = true;
now_ms += 1500;
ui.loop();
CHECK(display.frames == 1 && reads == 2u * TEST_RAW_BUTTON);
now_ms += 1000;
ui.loop();
CHECK(display.frames == 1 && reads == 3u * TEST_RAW_BUTTON);
radio.timing = false;
ui.loop();
CHECK(display.frames == 2 && reads == 3u * TEST_RAW_BUTTON);
} else if (!std::strcmp(scenario, "forced-refresh")) {
++now_ms;
display.changed = true;
ui.loop();
CHECK(display.frames == 2);
ui.loop();
CHECK(display.frames == 2);
now_ms = UINT32_MAX;
display.changed = true;
ui.loop();
CHECK(display.frames == 3);
now_ms = 0;
ui.loop();
CHECK(display.frames == 3);
} else if (!std::strcmp(scenario, "forced-zero")) {
now_ms = 0;
display.changed = true;
ui.loop();
CHECK(display.frames == 2);
ui.loop();
CHECK(display.frames == 2);
} else if (!std::strcmp(scenario, "forced-morse")) {
++now_ms;
display.changed = true;
radio.timing = true;
ui.loop();
CHECK(display.frames == 1);
++now_ms;
radio.timing = false;
ui.loop();
CHECK(display.frames == 2);
ui.loop();
CHECK(display.frames == 2);
} else if (!std::strcmp(scenario, "momentary-zero-wake")
|| !std::strcmp(scenario, "momentary-zero-morse")) {
const bool during_morse = !std::strcmp(scenario, "momentary-zero-morse");
display.on = false;
radio.timing = during_morse;
now_ms = 0;
user_btn.event = 1;
ui.loop();
#if TEST_MOMENTARY_BUTTON
CHECK(display.on && display.wakes == 2);
CHECK(display.frames == (during_morse ? 1u : 2u));
now_ms = 1;
radio.timing = false;
ui.loop();
CHECK(display.frames == 2);
ui.loop();
CHECK(display.frames == 2);
#else
CHECK(!display.on && display.frames == 1 && display.wakes == 0);
#endif
} else if (!std::strcmp(scenario, "long-display-off")) {
display.on = false;
for (unsigned day = 1; day <= 26; ++day) {
now_ms = 10000u + day * 86400000u;
ui.loop();
CHECK(display.frames == 1);
}
display.on = true; // No force event: elapsed work must remain due.
ui.loop();
CHECK(display.frames == 2);
} else if (!std::strcmp(scenario, "completion-anchor")) {
const uint32_t poll_finished = 10000u + (TEST_RAW_BUTTON ? 10u : 0u);
const uint32_t frame_finished = poll_finished + 50u;
read_delay = render_delay = end_delay = 0;
now_ms = poll_finished + 199u;
ui.loop();
CHECK(reads == unsigned(TEST_RAW_BUTTON) && display.frames == 1);
++now_ms;
ui.loop();
CHECK(reads == 2u * TEST_RAW_BUTTON && display.frames == 1);
now_ms = frame_finished + 999u;
ui.loop();
CHECK(display.frames == 1);
++now_ms;
ui.loop();
CHECK(display.frames == 2);
} else if (!std::strcmp(scenario, "rebegin")) {
++now_ms;
ui.begin(&prefs, "16 Oct 2026", "v1.2.4-test");
ui.loop();
CHECK(display.frames == 2 && reads == 2u * TEST_RAW_BUTTON);
} else if (!std::strcmp(scenario, "button-wake")) {
display.on = false;
now_ms += 200;
button_level = LOW;
user_btn.event = 1;
ui.loop();
#if TEST_RAW_BUTTON
CHECK(display.on && display.wakes == 2 && reads == 2);
// Existing raw-button policy keeps the one-second render throttle.
CHECK(display.frames == 1);
#elif TEST_MOMENTARY_BUTTON
CHECK(display.on && display.wakes == 2 && display.frames == 2);
user_btn.held = true;
ui.loop();
CHECK(display.wakes == 3 && display.frames == 2);
#else
CHECK(!display.on && display.wakes == 0 && display.frames == 1);
#endif
} else {
std::fprintf(stderr, "unknown scenario: %s\n", scenario);
return 2;
}
std::printf("PASS %s: %u checks\n", scenario, checks);
}
+10 -2
View File
@@ -67,6 +67,7 @@ struct UI {
struct Prefs { const char* node_name="test"; } prefs;
Prefs* _node_prefs=&prefs;
uint32_t _next_refresh=0,_alert_expiry=0,ui_started_at=0,next_batt_chck=0;
uint32_t _last_refresh=0;
uint16_t _cached_batt_mv=4000,voltage=3350;
bool _need_refresh=true;
char _alert[4]={},_origin[4]={},_msg[4]={};
@@ -224,22 +225,29 @@ int main() {
self.assertIn("_display->startFrame()", redraw)
code = FIXTURE.replace("@REDRAW@", redraw).replace("@CUTOFF@", "") + r'''
int main() {
constexpr bool sensor_timestamps=@SENSOR_TIMESTAMPS@;
for(bool external : {false,true}) {
board.external=external;
for(unsigned mode=0;mode<4;++mode) {
UI ui; adc_reads=frames=0;
const uint32_t last_refresh_before=ui._last_refresh;
radio_driver.tx=mode==1; radio_driver.rx=mode==2; radio_driver.cw=mode==3;
ui.redraw();
if(mode==1 || mode==2) {
assert(frames==0 && adc_reads==0 && ui._next_refresh==0);
if(sensor_timestamps)
assert(frames==0 && adc_reads==0 && ui._last_refresh==last_refresh_before);
else assert(frames==0 && adc_reads==0 && ui._next_refresh==0);
radio_driver.tx=radio_driver.rx=false;
ui.redraw(); assert(frames==1 && adc_reads==1);
} else assert(frames==1 && adc_reads==1);
assert(ui._next_refresh>now_ms);
if(sensor_timestamps) assert(ui._last_refresh==now_ms);
else assert(ui._next_refresh>now_ms);
}
}
}
'''
code = code.replace("@SENSOR_TIMESTAMPS@",
"true" if path == "examples/simple_sensor/UITask.cpp" else "false")
compile_run(self, code, flags=("-Wno-unused-variable",), headers={"Dispatcher.h": RADIO})
def test_actual_full_and_tiny_ui_cutoff_reads_fresh_voltage_even_during_morse(self):
+109
View File
@@ -0,0 +1,109 @@
"""Exercise the actual sensor UI header, begin and full loop with a 32-bit clock.
Display, board, radio and button I/O are host adapters. Rendering, physical
sleep and hardware timing are not qualified by this scheduling regression.
"""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
UI = ROOT / 'examples/simple_sensor'
FIXTURE = ROOT / 'test/fixtures/sensor_ui_polling.cpp'
class SensorUiPollingTest(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.directory = tempfile.TemporaryDirectory(prefix='sensor-ui-polling-')
cls.addClassCleanup(cls.directory.cleanup)
work = Path(cls.directory.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')
(work / 'UITask.h').write_bytes((UI / 'UITask.h').read_bytes())
source = (UI / 'UITask.cpp').read_text(encoding='ascii')
generated = FIXTURE.read_text(encoding='ascii')
for marker, signature in (
('BEGIN', 'void UITask::begin('),
('RESET', 'void UITask::resetRadioProfileDisplayPage('),
('LOOP', 'void UITask::loop()')):
generated = generated.replace('@' + marker + '@', extract_braced(source, signature))
cpp = work / 'test.cpp'
cpp.write_text(generated, encoding='ascii')
sanitize = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie']
cls.binaries = {}
for variant in ('none', 'raw', 'momentary'):
macros = ['-DTEST_RAW_BUTTON=' + str(int(variant == 'raw')),
'-DTEST_MOMENTARY_BUTTON=' + str(int(variant == 'momentary'))]
if variant != 'none':
macros += ['-DPIN_USER_BTN=7', '-DDISPLAY_CLASS=DisplayDriver']
if variant == 'momentary':
macros += ['-DMOMENTARY_BUTTON_WAKE_FROM_SLEEP=1',
'-DMOMENTARY_BUTTON_WAKE_HOLD_MS=1000']
binary = work / (variant + ('.exe' if os.name == 'nt' else ''))
built = subprocess.run([
compiler, '-std=c++17', '-O1', '-g', '-Wall', '-Wextra', '-Werror',
*sanitize, *macros, '-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[variant] = binary
def check_case(self, case):
for variant, binary in self.binaries.items():
with self.subTest(variant=variant, case=case):
checked = subprocess.run([str(binary), case], capture_output=True,
text=True, timeout=10)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn('PASS ' + case, checked.stdout)
def test_initial_work_at_zero_upper_half_and_max(self):
for case in ('zero', 'upper-half', 'max'):
self.check_case(case)
def test_normal_cadence_and_wrapped_future(self):
for case in ('cadence', 'wrapped-future', 'zero-boundary'):
self.check_case(case)
def test_overdue_polling_and_morse_deferral_across_wrap(self):
for case in ('overdue-wrap', 'morse-wrap'):
self.check_case(case)
def test_force_and_long_idle_refresh(self):
for case in ('forced-refresh', 'long-display-off'):
self.check_case(case)
def test_force_at_zero_and_force_during_morse(self):
for case in ('forced-zero', 'forced-morse', 'momentary-zero-wake',
'momentary-zero-morse'):
self.check_case(case)
def test_work_is_anchored_after_io_and_begin_rearms(self):
for case in ('completion-anchor', 'rebegin'):
self.check_case(case)
def test_button_wake_paths(self):
self.check_case('button-wake')
class SensorUiPollingWiringTest(unittest.TestCase):
def test_live_role_and_ci_run_the_regression(self):
main = (UI / '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')
self.assertIn('python3 -B test/test_sensor_ui_polling.py -v', workflow)
if __name__ == '__main__':
unittest.main()