Keep RGB Companion status timing working across rollover

This commit is contained in:
mikecarper committed 2026-10-16 17:21:44 -07:00
1 parent ec2bf83882
commit 9684e410e3
4 files changed
+601 -34

No files matched your search

+1
View File
@@ -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
+44 -34
View File
@@ -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<uint32_t>(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<uint32_t>(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<uint32_t>(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<uint32_t>(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;
+422
View File
@@ -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 <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
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);
}
+134
View File
@@ -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<uint32_t>\(cur_time - (\w+)\) > 60000',
r'cur_time > static_cast<uint32_t>(\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()