From 9684e410e398ecdeb05d4445d388556d3364b057 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 16 Oct 2026 17:21:44 -0700 Subject: [PATCH] Keep RGB Companion status timing working across rollover --- .github/workflows/run-unit-tests.yml | 1 + examples/companion_radio/ui-orig/UITask.cpp | 78 ++-- test/fixtures/rgb_led_rollover.cpp | 422 ++++++++++++++++++++ test/test_rgb_led_rollover.py | 134 +++++++ 4 files changed, 601 insertions(+), 34 deletions(-) create mode 100644 test/fixtures/rgb_led_rollover.cpp create mode 100644 test/test_rgb_led_rollover.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 6bd6de21d..5719cc775 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -805,6 +805,7 @@ jobs: python3 -B test/test_notification_led_ownership.py -v python3 -B test/test_mono_led_rollover.py -v python3 -B test/test_backlight_poll_rollover.py -v + python3 -B test/test_rgb_led_rollover.py -v python3 -B test/test_display_power.py python3 -B test/test_legacy_ui_version_bounds.py -v python3 -B test/test_ssd1306_power.py -v diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index 0494d571e..9ef7432aa 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -470,62 +470,71 @@ static void statusLedWheel(uint8_t pos) { // smooth hue sweep, 0..255 void UITask::userLedHandler() { if (_notification_led_owned || !(_notification_outputs & mesh::notify::Led)) return; #ifdef STATUS_LED_RGB - static bool booted = false; - static bool flourish_active = false; - static unsigned long flourish_until = 0; + enum class FlourishState : uint8_t { Unseen, Boot, Message, Idle }; + static FlourishState flourish = FlourishState::Unseen; + static uint32_t flourish_started = 0; static int prev_msgcount = 0; static int state = 0; - static unsigned long next_change = 0; - static int last_increment = 0; - static unsigned long next_batt_check = 0; - static bool low_batt = false; + static uint32_t last_change = 0; + static uint32_t phase_millis = 0; + static uint32_t last_batt_check = 0; + enum class BatteryState : uint8_t { Unseen, Normal, Low }; + static BatteryState battery_state = BatteryState::Unseen; - unsigned long cur_time = millis(); + const uint32_t cur_time = millis(); - if (!booted) { // color sweep on boot - booted = true; - flourish_until = cur_time + 1200; - flourish_active = true; + if (flourish == FlourishState::Unseen) { // color sweep on boot + flourish_started = cur_time; + flourish = FlourishState::Boot; } if (_msgcount > prev_msgcount) { // color sweep when a new message arrives - flourish_until = cur_time + 800; - flourish_active = true; + flourish_started = cur_time; + flourish = FlourishState::Message; } prev_msgcount = _msgcount; - if (flourish_active) { - if (cur_time < flourish_until) { + if (flourish == FlourishState::Boot || flourish == FlourishState::Message) { + const uint32_t duration = flourish == FlourishState::Boot ? 1200 : 800; + if (static_cast(cur_time - flourish_started) < duration) { statusLedWheel((cur_time % 1200) * 255 / 1200); return; } statusLedWrite(0, 0, 0); - flourish_active = false; + flourish = FlourishState::Idle; state = 0; - next_change = cur_time; + last_change = cur_time; + phase_millis = 0; } - if (cur_time > next_batt_check) { // battery reads are not free, keep them rare + // Keep the cached entry-time cadence, including across wrap and long idle. + // Sample state distinguishes an initial check from a completed check at zero. + if (battery_state == BatteryState::Unseen ? cur_time > 0 + : static_cast(cur_time - last_batt_check) > 60000) { uint16_t low_batt_mv = LOW_BATT_MILLIVOLTS; #if defined(NRF52_POWER_MANAGEMENT) low_batt_mv = mesh::power::remapBatteryWarningMillivolts( LOW_BATT_MILLIVOLTS, BATT_MIN_MILLIVOLTS, BATT_MAX_MILLIVOLTS, mesh::power::configuredEmpty(), mesh::power::configuredFull()); #endif - low_batt = _board->getBattMilliVolts() < low_batt_mv; - next_batt_check = cur_time + 60000; + battery_state = _board->getBattMilliVolts() < low_batt_mv + ? BatteryState::Low : BatteryState::Normal; + last_batt_check = cur_time; } #ifdef EXT_CHRG_DETECT // charge status display while docked (skipped when messages are waiting, // so the unread indication is never masked) - static unsigned long next_pwr_check = 0; - static bool ext_powered = false, ext_charging = false; - if (cur_time > next_pwr_check) { - ext_powered = _board->isExternalPowered(); - ext_charging = ext_powered && digitalRead(EXT_CHRG_DETECT) == LOW; - next_pwr_check = cur_time + 1000; + static uint32_t last_pwr_check = 0; + enum class PowerState : uint8_t { Unseen, No, Yes }; + static PowerState power_state = PowerState::Unseen; + static bool ext_charging = false; + if (power_state == PowerState::Unseen ? cur_time > 0 + : static_cast(cur_time - last_pwr_check) > 1000) { + power_state = _board->isExternalPowered() ? PowerState::Yes : PowerState::No; + ext_charging = power_state == PowerState::Yes && digitalRead(EXT_CHRG_DETECT) == LOW; + last_pwr_check = cur_time; } - if (ext_powered && _msgcount == 0) { + if (power_state == PowerState::Yes && _msgcount == 0) { if (ext_charging) { // amber breathing while charging int ph = cur_time % 2000; @@ -536,17 +545,17 @@ void UITask::userLedHandler() { statusLedWrite(0, 40, 0); // dim solid green: charge complete } state = 0; - next_change = cur_time; + last_change = cur_time; + phase_millis = 0; return; } #endif - if (cur_time > next_change) { + if (static_cast(cur_time - last_change) > phase_millis) { if (state == 0) { state = 1; - last_increment = (_msgcount > 0) ? LED_ON_MSG_MILLIS : LED_ON_MILLIS; - next_change = cur_time + last_increment; - if (low_batt) { + phase_millis = (_msgcount > 0) ? LED_ON_MSG_MILLIS : LED_ON_MILLIS; + if (battery_state == BatteryState::Low) { statusLedWrite(255, 0, 0); // red: battery low } else if (_msgcount > 0) { statusLedWrite(255, 90, 0); // amber: unread messages @@ -557,9 +566,10 @@ void UITask::userLedHandler() { } } else { state = 0; - next_change = cur_time + LED_CYCLE_MILLIS - last_increment; + phase_millis = LED_CYCLE_MILLIS - phase_millis; statusLedWrite(0, 0, 0); } + last_change = cur_time; } #elif defined(PIN_STATUS_LED) static int state = 0; diff --git a/test/fixtures/rgb_led_rollover.cpp b/test/fixtures/rgb_led_rollover.cpp new file mode 100644 index 000000000..8d066ae90 --- /dev/null +++ b/test/fixtures/rgb_led_rollover.cpp @@ -0,0 +1,422 @@ +// Full actual handler, output enum, ownership setters and RGB writers are injected. +// The clock is uint32_t even on hosts where unsigned long has 64 bits. +#include +#include +#include +#include + +namespace mesh { namespace notify { +@OUTPUT_ENUM@ +} } + +@CONSTANTS@ + +static uint32_t clock_ms = 0; +uint32_t millis() { return clock_ms; } +static unsigned writes = 0, batt_reads = 0, power_reads = 0, charge_reads = 0; +static uint32_t batt_delay = 0, power_delay = 0; +#ifdef EXT_CHRG_DETECT +static uint32_t charge_delay = 0; +#endif +static const char* active_case = "startup"; +static unsigned checks = 0; +#define CHECK(value) do { \ + ++checks; \ + if (!(value)) { \ + std::fprintf(stderr, "FAIL %s: %s (line %d)\n", active_case, #value, __LINE__); \ + std::exit(1); \ + } \ +} while (0) + +struct Board { + uint16_t battery = 4000; + bool external = false; + uint16_t getBattMilliVolts() { + ++batt_reads; + clock_ms += batt_delay; + return battery; + } + bool isExternalPowered() { + ++power_reads; + clock_ms += power_delay; + return external; + } +}; + +#ifdef STATUS_LED_RGB +static unsigned red = 0, green = 0, blue = 0, channel = 0; +void analogWrite(unsigned pin, unsigned level) { + CHECK(level <= 255); + if (channel == 0) { CHECK(pin == PIN_STATUS_LED_R); red = level; } + else if (channel == 1) { CHECK(pin == PIN_STATUS_LED_G); green = level; } + else { CHECK(pin == PIN_STATUS_LED_B); blue = level; ++writes; } + channel = (channel + 1) % 3; +} +@WRITERS@ +#endif + +#ifdef EXT_CHRG_DETECT +constexpr unsigned LOW = 0; +static unsigned charge_level = 1; +int digitalRead(unsigned pin) { + CHECK(pin == EXT_CHRG_DETECT); + ++charge_reads; + clock_ms += charge_delay; + return int(charge_level); +} +#endif + +class UITask { +public: + @DEFAULTS@ + @SETTERS@ + int _msgcount = 0; + bool _connected = false; + Board board; + Board* _board = &board; + void userLedHandler(); +}; + +@HANDLER@ + +static void tick(UITask& ui, uint32_t stamp) { + clock_ms = stamp; + ui.userLedHandler(); +} + +#ifdef STATUS_LED_RGB +static void color(unsigned r, unsigned g, unsigned b) { + CHECK(channel == 0); + CHECK(red == r); + CHECK(green == g); + CHECK(blue == b); +} + +static uint32_t boot(UITask& ui, uint32_t start = 100) { + const uint32_t duration = ui._msgcount > 0 ? 800 : 1200; + tick(ui, start); + CHECK(writes == 1); + CHECK(batt_reads == 0); + CHECK(power_reads == 0); + tick(ui, start + duration); + CHECK(writes == 2); + color(0, 0, 0); + if (start + duration != 0) { + CHECK(batt_reads == 1); +#ifdef EXT_CHRG_DETECT + CHECK(power_reads == 1); +#endif + } + return start + duration; +} + +static uint32_t heartbeat(UITask& ui, uint32_t start = 100) { + const uint32_t end = boot(ui, start); + tick(ui, end + 1); + CHECK(writes == 3); + return end + 1; +} + +static void veto(UITask& ui, bool owner, bool enabled) { + if (owner) ui.setNotificationLedOwnership(enabled); + else ui.setNotificationOutputs(enabled ? uint8_t(31 & ~mesh::notify::Led) : 31); +} + +static void suppressLong(UITask& ui, bool owner, bool on) { + uint32_t stamp = heartbeat(ui); + if (!on) { stamp += 21; tick(ui, stamp); } + const unsigned before = writes, battery_before = batt_reads, power_before = power_reads; + veto(ui, owner, true); + constexpr uint32_t idle = 26u * 24u * 60u * 60u * 1000u; + tick(ui, stamp + idle); + CHECK(writes == before); + CHECK(batt_reads == battery_before); + CHECK(power_reads == power_before); + veto(ui, owner, false); + tick(ui, stamp + idle); + CHECK(writes == before + 1); + if (on) color(0, 0, 0); else color(0, 255, 0); + CHECK(batt_reads == battery_before + 1); +#ifdef EXT_CHRG_DETECT + CHECK(power_reads == power_before + 1); +#endif + tick(ui, stamp + idle); + CHECK(writes == before + 1); +} +#endif + +// A fresh process for every case leaves actual function statics untouched. +int main(int argc, char** argv) { + CHECK(argc == 2); + active_case = argv[1]; + const char* name = argv[1]; + UITask ui; +#ifdef STATUS_LED_RGB + if (!std::strcmp(name, "boot-zero") || !std::strcmp(name, "boot-upper") || + !std::strcmp(name, "boot-wrap") || !std::strcmp(name, "boot-zero-end")) { + const uint32_t start = !std::strcmp(name, "boot-zero") ? 0u : + !std::strcmp(name, "boot-upper") ? 0x80000000u : + !std::strcmp(name, "boot-wrap") ? UINT32_MAX - 10u : 0u - 1200u; + tick(ui, start); + CHECK(writes == 1); + if (start == 0) color(255, 0, 0); + tick(ui, start + 1199); + CHECK(writes == 2); + CHECK(batt_reads == 0); + CHECK(power_reads == 0); + tick(ui, start + 1200); + CHECK(writes == 3); + color(0, 0, 0); + CHECK(batt_reads == unsigned(start + 1200 != 0)); + tick(ui, start + 1200); + CHECK(writes == 3); + tick(ui, start + 1201); + CHECK(writes == 4); + color(0, 255, 0); + CHECK(batt_reads == 1); + } else if (!std::strcmp(name, "wheel-colors")) { + tick(ui, 0); color(255, 0, 0); + tick(ui, 399); color(3, 252, 0); + tick(ui, 400); color(0, 255, 0); + tick(ui, 800); color(0, 0, 255); + tick(ui, 1199); color(252, 0, 3); + CHECK(batt_reads == 0); CHECK(power_reads == 0); + } else if (!std::strcmp(name, "message-first")) { + ui._msgcount = 1; + const uint32_t end = boot(ui, UINT32_MAX - 400u); + tick(ui, end + 1); + CHECK(writes == 3); + color(255, 90, 0); + } else if (!std::strcmp(name, "message") || !std::strcmp(name, "message-wrap") || + !std::strcmp(name, "message-retrigger") || !std::strcmp(name, "message-decrease")) { + boot(ui); + const uint32_t start = !std::strcmp(name, "message-wrap") ? UINT32_MAX - 100u : 2000u; + ui._msgcount = 1; + tick(ui, start); + CHECK(writes == 3); + const unsigned battery_before = batt_reads; + tick(ui, start + 799); + CHECK(writes == 4); + CHECK(batt_reads == battery_before); + uint32_t end = start + 800; + if (!std::strcmp(name, "message-retrigger")) { + ui._msgcount = 2; + tick(ui, start + 799); + CHECK(writes == 5); + end = start + 1599; + tick(ui, end - 1); + CHECK(writes == 6); + } else if (!std::strcmp(name, "message-decrease")) { + ui._msgcount = 0; + } + const unsigned before = writes; + tick(ui, end); + CHECK(writes == before + 1); + color(0, 0, 0); + tick(ui, end + 1); + CHECK(writes == before + 2); + if (ui._msgcount) color(255, 90, 0); else color(0, 255, 0); + } else if (!std::strcmp(name, "normal") || !std::strcmp(name, "unread") || + !std::strcmp(name, "connected") || !std::strcmp(name, "low-battery") || + !std::strcmp(name, "threshold")) { + if (!std::strcmp(name, "unread")) ui._msgcount = 1; + if (!std::strcmp(name, "connected")) ui._connected = true; + if (!std::strcmp(name, "low-battery")) { + ui.board.battery = 3499; + ui._connected = true; + ui._msgcount = 1; + } + if (!std::strcmp(name, "threshold")) ui.board.battery = 3500; + const uint32_t on = heartbeat(ui); + if (ui.board.battery < 3500) color(255, 0, 0); + else if (ui._msgcount) color(255, 90, 0); + else if (ui._connected) color(0, 0, 255); + else color(0, 255, 0); + const uint32_t duration = ui._msgcount ? 200 : 20; + tick(ui, on + duration); CHECK(writes == 3); + tick(ui, on + duration + 1); CHECK(writes == 4); color(0, 0, 0); + tick(ui, on + 4001); CHECK(writes == 4); + tick(ui, on + 4002); CHECK(writes == 5); + } else if (!std::strcmp(name, "heartbeat-wrap-on") || !std::strcmp(name, "heartbeat-wrap-off") || + !std::strcmp(name, "heartbeat-zero-stamp")) { + const uint32_t on = heartbeat(ui); + tick(ui, on + 21); CHECK(writes == 4); + const bool zero = !std::strcmp(name, "heartbeat-zero-stamp"); + const uint32_t near = zero ? 0u - 21u : UINT32_MAX - 10u; + tick(ui, near); CHECK(writes == 5); color(0, 255, 0); + if (!std::strcmp(name, "heartbeat-wrap-off")) { + tick(ui, 100); CHECK(writes == 6); color(0, 0, 0); + tick(ui, 4080); CHECK(writes == 6); + tick(ui, 4081); CHECK(writes == 7); + } else { + tick(ui, near + 20); CHECK(writes == 5); + tick(ui, near + 21); CHECK(writes == 6); color(0, 0, 0); + tick(ui, near + 4001); CHECK(writes == 6); + tick(ui, near + 4002); CHECK(writes == 7); + } + } else if (!std::strcmp(name, "heartbeat-long")) { + const uint32_t on = heartbeat(ui); + tick(ui, on + 100000); CHECK(writes == 4); color(0, 0, 0); + tick(ui, on + 100000); CHECK(writes == 4); + tick(ui, on + 103980); CHECK(writes == 4); + tick(ui, on + 103981); CHECK(writes == 5); + } else if (!std::strcmp(name, "heartbeat-msg-phase")) { + ui._msgcount = 1; + const uint32_t on = heartbeat(ui); + ui._msgcount = 0; + tick(ui, on + 200); CHECK(writes == 3); + tick(ui, on + 201); CHECK(writes == 4); + tick(ui, on + 4001); CHECK(writes == 4); + tick(ui, on + 4002); CHECK(writes == 5); color(0, 255, 0); + tick(ui, on + 4022); CHECK(writes == 5); + tick(ui, on + 4023); CHECK(writes == 6); + } else if (!std::strcmp(name, "battery-cadence")) { + const uint32_t end = boot(ui); + tick(ui, end + 60000); CHECK(batt_reads == 1); + tick(ui, end + 60001); CHECK(batt_reads == 2); + tick(ui, end + 60001); CHECK(batt_reads == 2); + } else if (!std::strcmp(name, "poll-wrap-before") || !std::strcmp(name, "poll-wrap-after")) { + const bool before = !std::strcmp(name, "poll-wrap-before"); + const uint32_t end = boot(ui, UINT32_MAX - (before ? 2000u : 62000u)); + tick(ui, before ? end + 1 : 100u); + CHECK(batt_reads == unsigned(before ? 1 : 2)); +#ifdef EXT_CHRG_DETECT + CHECK(power_reads == unsigned(before ? 1 : 2)); +#endif + } else if (!std::strcmp(name, "poll-zero-stamp")) { + const uint32_t end = boot(ui, UINT32_MAX - 62000u); + CHECK(end != 0); + tick(ui, 0); CHECK(batt_reads == 2); + tick(ui, 1); CHECK(batt_reads == 2); + tick(ui, 60000); CHECK(batt_reads == 2); + tick(ui, 60001); CHECK(batt_reads == 3); +#ifdef EXT_CHRG_DETECT + CHECK(power_reads == 3); +#endif + } else if (!std::strcmp(name, "poll-long") || !std::strcmp(name, "poll-near-full")) { + const uint32_t end = boot(ui); + const uint32_t gap = !std::strcmp(name, "poll-long") ? + 26u * 24u * 60u * 60u * 1000u : UINT32_MAX - 100u; + tick(ui, end + gap); CHECK(batt_reads == 2); +#ifdef EXT_CHRG_DETECT + CHECK(power_reads == 2); +#endif + tick(ui, end + gap); CHECK(batt_reads == 2); + } else if (!std::strcmp(name, "poll-read-delays")) { + batt_delay = 25; + power_delay = 17; +#ifdef EXT_CHRG_DETECT + charge_delay = 9; +#endif + const uint32_t end = boot(ui); + tick(ui, end + 60001); CHECK(batt_reads == 2); + uint32_t expected_clock = end + 60001 + 25; +#ifdef EXT_CHRG_DETECT + expected_clock += 17; +#endif + CHECK(clock_ms == expected_clock); + tick(ui, end + 120002); CHECK(batt_reads == 3); +#ifdef EXT_CHRG_DETECT + const unsigned before = power_reads; + tick(ui, end + 121002); CHECK(power_reads == before); + tick(ui, end + 121003); CHECK(power_reads == before + 1); +#endif + } else if (!std::strcmp(name, "owner-fresh") || !std::strcmp(name, "mask-fresh")) { + const bool owner = !std::strcmp(name, "owner-fresh"); + veto(ui, owner, true); + tick(ui, 0); tick(ui, 0x80000000u); + CHECK(batt_reads == 0); CHECK(power_reads == 0); CHECK(writes == 0); + veto(ui, owner, false); + boot(ui, 0x80000000u); + } else if (!std::strcmp(name, "owner-flourish-short") || !std::strcmp(name, "mask-flourish-long")) { + const bool short_gap = !std::strcmp(name, "owner-flourish-short"); + tick(ui, 100); CHECK(writes == 1); + veto(ui, short_gap, true); + const uint32_t now = short_gap ? 500u : 0x80000000u; + tick(ui, now); CHECK(writes == 1); CHECK(batt_reads == 0); + veto(ui, short_gap, false); + tick(ui, now); CHECK(writes == 2); + if (short_gap) { + CHECK(batt_reads == 0); + tick(ui, 1300); CHECK(writes == 3); color(0, 0, 0); + } else { CHECK(batt_reads == 1); color(0, 0, 0); } + } else if (!std::strcmp(name, "owner-on-long")) { + suppressLong(ui, true, true); + } else if (!std::strcmp(name, "owner-off-long")) { + suppressLong(ui, true, false); + } else if (!std::strcmp(name, "mask-on-long")) { + suppressLong(ui, false, true); + } else if (!std::strcmp(name, "mask-off-long")) { + suppressLong(ui, false, false); + } else if (!std::strcmp(name, "both-vetoes")) { + ui.setNotificationLedOwnership(true); ui.setNotificationOutputs(0); + tick(ui, 100); CHECK(writes == 0); + ui.setNotificationLedOwnership(false); + tick(ui, 200); CHECK(writes == 0); + ui.setNotificationOutputs(31); + boot(ui, 200); +#ifdef EXT_CHRG_DETECT + } else if (!std::strcmp(name, "power-cadence")) { + const uint32_t end = boot(ui); + tick(ui, end + 1000); CHECK(power_reads == 1); + tick(ui, end + 1001); CHECK(power_reads == 2); + tick(ui, end + 1001); CHECK(power_reads == 2); + } else if (!std::strcmp(name, "charging") || !std::strcmp(name, "charged")) { + boot(ui); + ui.board.external = true; + charge_level = !std::strcmp(name, "charging") ? 0 : 1; + tick(ui, 3000); CHECK(charge_reads == 1); + if (charge_level) color(0, 40, 0); else color(255, 89, 0); + tick(ui, 3500); CHECK(charge_reads == 1); + if (charge_level) color(0, 40, 0); else color(127, 44, 0); + } else if (!std::strcmp(name, "charge-unread")) { + boot(ui); + ui.board.external = true; charge_level = 0; + tick(ui, 3000); color(255, 89, 0); + ui._msgcount = 1; + tick(ui, 3100); + tick(ui, 3900); color(0, 0, 0); + tick(ui, 3901); color(255, 90, 0); + } else if (!std::strcmp(name, "charge-to-heartbeat")) { + boot(ui); + ui.board.external = true; + tick(ui, 3000); color(0, 40, 0); + ui.board.external = false; + tick(ui, 4001); color(0, 255, 0); + const unsigned before = writes; + tick(ui, 4021); CHECK(writes == before); + tick(ui, 4022); CHECK(writes == before + 1); color(0, 0, 0); + } else if (!std::strcmp(name, "power-unpowered-charge-skip")) { + boot(ui); + tick(ui, 3000); + CHECK(power_reads == 2); CHECK(charge_reads == 0); + } else if (!std::strcmp(name, "poll-powered-read-delays")) { + boot(ui); + ui.board.external = true; charge_level = 0; + batt_delay = 25; power_delay = 17; charge_delay = 9; + tick(ui, 3000); CHECK(power_reads == 2); CHECK(charge_reads == 1); + CHECK(clock_ms == 3026); color(255, 89, 0); + tick(ui, 4000); CHECK(power_reads == 2); CHECK(charge_reads == 1); + tick(ui, 4001); CHECK(power_reads == 3); CHECK(charge_reads == 2); + CHECK(clock_ms == 4027); +#endif + } else { + std::fprintf(stderr, "unknown case: %s\n", name); + return 2; + } +#else + CHECK(std::strcmp(name, "no-led") == 0); + const uint32_t stamps[] = {0u, 1u, 0x7fffffffu, 0x80000000u, UINT32_MAX}; + for (uint32_t stamp : stamps) { + tick(ui, stamp); + ui.setNotificationLedOwnership(true); tick(ui, stamp); + ui.setNotificationLedOwnership(false); ui.setNotificationOutputs(0); tick(ui, stamp); + ui.setNotificationOutputs(31); + } + CHECK(writes == 0); CHECK(batt_reads == 0); CHECK(power_reads == 0); CHECK(charge_reads == 0); +#endif +#ifndef EXT_CHRG_DETECT + CHECK(charge_reads == 0); +#endif + std::printf("PASS %s: %u checks\n", name, checks); +} diff --git a/test/test_rgb_led_rollover.py b/test/test_rgb_led_rollover.py new file mode 100644 index 000000000..e90d69123 --- /dev/null +++ b/test/test_rgb_led_rollover.py @@ -0,0 +1,134 @@ +"""Exercise the complete original-UI RGB status handler and its actual writers. + +Clock, ADC, power and GPIO are host fakes. Separate processes preserve real +function-static initialization. This is not physical LED or power-use proof. +""" +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / 'test/fixtures/rgb_led_rollover.cpp' +CASES = ( + 'boot-zero', 'boot-upper', 'boot-wrap', 'boot-zero-end', 'wheel-colors', 'message', + 'message-wrap', 'message-retrigger', 'message-first', 'message-decrease', + 'normal', 'unread', 'connected', 'low-battery', 'threshold', + 'heartbeat-wrap-on', 'heartbeat-wrap-off', 'heartbeat-zero-stamp', + 'heartbeat-long', 'heartbeat-msg-phase', 'battery-cadence', + 'poll-wrap-before', 'poll-wrap-after', 'poll-zero-stamp', 'poll-long', + 'poll-near-full', 'poll-read-delays', 'owner-fresh', 'mask-fresh', + 'owner-flourish-short', 'mask-flourish-long', 'owner-on-long', + 'owner-off-long', 'mask-on-long', 'mask-off-long', 'both-vetoes', +) +POWER_CASES = ( + 'power-cadence', 'charging', 'charged', 'charge-unread', + 'charge-to-heartbeat', 'power-unpowered-charge-skip', 'poll-powered-read-delays', +) + + +def harness(mutation=None): + source = (ROOT / 'examples/companion_radio/ui-orig/UITask.cpp').read_text(encoding='ascii') + abstract = (ROOT / 'examples/companion_radio/AbstractUITask.h').read_text(encoding='ascii') + notification = (ROOT / 'src/helpers/CompanionNotificationPolicy.h').read_text(encoding='ascii') + output_enum = extract_braced(notification, 'enum Output : uint8_t') + ';' + constants = source[source.index('#if defined(PIN_STATUS_LED) ||'): + source.index('#ifndef USER_BTN_PRESSED')] + defaults = '\n'.join(re.findall( + r'^ (?:bool _notification_led_owned|uint8_t _notification_outputs) = .*;', + abstract, re.MULTILINE)) + setters = '\n'.join(extract_braced(abstract, signature) for signature in ( + 'void setNotificationLedOwnership(', 'void setNotificationOutputs(')) + handler = extract_braced(source, 'void UITask::userLedHandler()') + if mutation == 'no-owner-guard': + guard = 'if (_notification_led_owned || !(_notification_outputs & mesh::notify::Led)) return;' + if handler.count(guard) != 1: + raise AssertionError('the actual ownership/output guard must be present') + handler = handler.replace(guard, '', 1) + elif mutation == 'absolute-battery-deadline': + handler, count = re.subn( + r'static_cast\(cur_time - (\w+)\) > 60000', + r'cur_time > static_cast(\1 + 60000u)', handler) + if count != 1: + raise AssertionError('the actual elapsed battery timer must be present') + elif mutation == 'completion-time-anchor': + # The actual handler deliberately captures the clock before either read. + # Do not depend on the private name of the elapsed-time anchor. + rgb, mono = handler.split('#elif defined(PIN_STATUS_LED)', 1) + rgb, count = re.subn(r'(\b\w+\s*=\s*)cur_time;', r'\1millis();', rgb) + if count < 2: + raise AssertionError('RGB timers must retain captured-time anchors') + handler = rgb + '#elif defined(PIN_STATUS_LED)' + mono + writers = '\n'.join(extract_braced(source, signature) for signature in ( + 'static void statusLedWrite(', 'static void statusLedWheel(')) + code = FIXTURE.read_text(encoding='ascii') + for token, value in {'CONSTANTS': constants, 'DEFAULTS': defaults, + 'SETTERS': setters, 'HANDLER': handler, + 'WRITERS': writers, 'OUTPUT_ENUM': output_enum}.items(): + code = code.replace('@' + token + '@', value) + return code + + +class RgbLedRolloverTest(unittest.TestCase): + def test_actual_rgb_handler(self): + self.run_variants() + + def test_ownership_regression_is_rejected(self): + self.run_variants('no-owner-guard', ('owner-fresh',), 'batt_reads == 0') + + def test_captured_poll_anchor_regression_is_rejected(self): + self.run_variants('completion-time-anchor', ('poll-read-delays',), 'batt_reads == 2') + + def test_absolute_poll_deadline_regression_is_rejected(self): + self.run_variants('absolute-battery-deadline', ('poll-wrap-before',), + 'batt_reads == unsigned(before ? 1 : 2)') + + def run_variants(self, mutation=None, cases=None, expected_failure=None): + compiler = shutil.which('g++') or shutil.which('clang++') + if compiler is None: + self.skipTest('a host C++17 compiler is required') + sanitize = [] if os.name == 'nt' else [ + '-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie'] + with tempfile.TemporaryDirectory(prefix='rgb-led-rollover-') as directory: + work = Path(directory) + source = work / 'fixture.cpp' + source.write_text(harness(mutation), encoding='ascii') + variants = ('charge', 'no-charge') if mutation else ('charge', 'no-charge', 'disabled') + for variant in variants: + with self.subTest(variant=variant): + macros = [] if variant == 'disabled' else [ + '-DPIN_STATUS_LED_R=3', '-DPIN_STATUS_LED_G=24', '-DPIN_STATUS_LED_B=28'] + if variant == 'charge': + # X1 aliases its monochrome status pin to RGB green. + macros += ['-DEXT_CHRG_DETECT=35', '-DPIN_STATUS_LED=24'] + 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(ROOT / 'src'), str(source), '-o', str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + selected = cases if cases is not None else ( + CASES + POWER_CASES if variant == 'charge' else + CASES if variant == 'no-charge' else ('no-led',)) + for case in selected: + with self.subTest(case=case): + tested = subprocess.run([str(binary), case], capture_output=True, + text=True, timeout=10) + if expected_failure: + self.assertEqual(tested.returncode, 1, tested.stdout + tested.stderr) + self.assertIn(expected_failure, tested.stderr) + self.assertNotIn('AddressSanitizer:', tested.stderr) + self.assertNotIn('runtime error:', tested.stderr) + else: + self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) + self.assertRegex(tested.stdout, r'PASS ' + case + r': \d+ checks') + + +if __name__ == '__main__': + unittest.main()