diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5719cc775..eaf4c69f8 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/examples/simple_sensor/UITask.cpp b/examples/simple_sensor/UITask.cpp index 971f42bd1..2e633f5ea 100644 --- a/examples/simple_sensor/UITask.cpp +++ b/examples/simple_sensor/UITask.cpp @@ -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(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(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); diff --git a/examples/simple_sensor/UITask.h b/examples/simple_sensor/UITask.h index bb909c7e9..b10f3a799 100644 --- a/examples/simple_sensor/UITask.h +++ b/examples/simple_sensor/UITask.h @@ -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(); diff --git a/test/fixtures/sensor_ui_polling.cpp b/test/fixtures/sensor_ui_polling.cpp new file mode 100644 index 000000000..6679c624d --- /dev/null +++ b/test/fixtures/sensor_ui_polling.cpp @@ -0,0 +1,247 @@ +// Production header, begin and loop; only hardware/render boundaries are fake. +#include +#include +#include +#include + +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); +} diff --git a/test/test_morse_loop_policy.py b/test/test_morse_loop_policy.py index 4d2071695..6503c1e27 100644 --- a/test/test_morse_loop_policy.py +++ b/test/test_morse_loop_policy.py @@ -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): diff --git a/test/test_sensor_ui_polling.py b/test/test_sensor_ui_polling.py new file mode 100644 index 000000000..a8e3e8b21 --- /dev/null +++ b/test/test_sensor_ui_polling.py @@ -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()