Keep Companion sensor polling running across timer rollover

This commit is contained in:
mikecarper committed 2026-10-16 16:43:54 -07:00
1 parent 4c3a5691e1
commit eec1caa170
6 files changed
+411 -19

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_home_sensor_refresh.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
+12 -8
View File
@@ -576,7 +576,7 @@ class HomeScreen : public UIScreen {
int sensors_nb = 0;
bool sensors_scroll = false;
int sensors_scroll_offset = 0;
int next_sensors_refresh = 0;
uint32_t last_sensors_refresh = 0;
void refresh_sensors() {
// CayenneLPP retains its requested capacity even if its allocation fails.
@@ -587,7 +587,15 @@ class HomeScreen : public UIScreen {
sensors_scroll_offset = 0;
return;
}
if (millis() > next_sensors_refresh) {
#if AUTO_OFF_MILLIS > 0
const uint32_t refresh_interval = 5000;
#else
const uint32_t refresh_interval = 60000;
#endif
// Empty data requests an initial/forced sample. Elapsed time also handles
// returning to this page after more than half a millis() cycle.
if (sensors_lpp.getSize() == 0
|| static_cast<uint32_t>(millis() - last_sensors_refresh) >= refresh_interval) {
sensors_lpp.reset();
sensors_nb = 0;
sensors_lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f);
@@ -599,11 +607,7 @@ class HomeScreen : public UIScreen {
sensors_nb ++;
}
sensors_scroll = sensors_nb > UI_RECENT_LIST_SIZE;
#if AUTO_OFF_MILLIS > 0
next_sensors_refresh = millis() + 5000; // refresh sensor values every 5 sec
#else
next_sensors_refresh = millis() + 60000; // refresh sensor values every 1 min
#endif
last_sensors_refresh = millis();
}
}
@@ -1420,7 +1424,7 @@ public:
#if UI_SENSORS_PAGE == 1
if (key == KEY_ENTER && _page == HomePage::SENSORS) {
_task->toggleGPS();
next_sensors_refresh=0;
sensors_lpp.reset();
return true;
}
#endif
+12 -8
View File
@@ -168,13 +168,21 @@ class HomeScreen : public UIScreen {
int sensors_nb = 0;
bool sensors_scroll = false;
int sensors_scroll_offset = 0;
int next_sensors_refresh = 0;
uint32_t last_sensors_refresh = 0;
uint32_t _radio_profile_page_started_at = 0;
uint8_t _radio_status_page = 0;
bool _dual_radio_enabled_seen = false;
void refresh_sensors() {
if (millis() > next_sensors_refresh) {
#if AUTO_OFF_MILLIS > 0
const uint32_t refresh_interval = 5000;
#else
const uint32_t refresh_interval = 60000;
#endif
// Empty data requests an initial/forced sample. Elapsed time also handles
// returning to this page after more than half a millis() cycle.
if (sensors_lpp.getSize() == 0
|| static_cast<uint32_t>(millis() - last_sensors_refresh) >= refresh_interval) {
sensors_lpp.reset();
sensors_nb = 0;
sensors_lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f);
@@ -186,11 +194,7 @@ class HomeScreen : public UIScreen {
sensors_nb ++;
}
sensors_scroll = sensors_nb > UI_RECENT_LIST_SIZE;
#if AUTO_OFF_MILLIS > 0
next_sensors_refresh = millis() + 5000; // refresh sensor values every 5 sec
#else
next_sensors_refresh = millis() + 60000; // refresh sensor values every 1 min
#endif
last_sensors_refresh = millis();
}
}
@@ -575,7 +579,7 @@ public:
#if UI_SENSORS_PAGE == 1
if (c == KEY_ENTER && _page == HomePage::SENSORS) {
_task->toggleGPS();
next_sensors_refresh=0;
sensors_lpp.reset();
return true;
}
#endif
+288
View File
@@ -0,0 +1,288 @@
// The Home fields, refresh method, and input block come from production.
// Only clock, board, sensor/LPP transport, and task boundaries are mocked.
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#define TELEM_CHANNEL_SELF 0
#define UI_RECENT_LIST_SIZE 3
#define KEY_ENTER '\r'
static uint32_t clock_ms = 0;
uint32_t millis() { return clock_ms; }
static const char* active_case = "initialization";
static unsigned checks = 0;
static unsigned scenarios = 0;
#define CHECK(condition) do { \
++checks; \
if (!(condition)) { \
std::fprintf(stderr, "FAIL %s: %s (line %d)\n", active_case, #condition, __LINE__); \
std::exit(1); \
} \
} while (0)
struct CayenneLPP {
uint8_t bytes[200] = {};
uint8_t size = 0;
bool allocated = true;
unsigned writes = 0;
uint8_t* getBuffer() { return allocated ? bytes : nullptr; }
uint8_t getSize() { return size; }
void reset() { size = 0; }
void addVoltage(int channel, float voltage) {
CHECK(getBuffer() != nullptr);
CHECK(channel == TELEM_CHANNEL_SELF && voltage == 4.0f);
++writes;
bytes[size++] = 4;
size += 3;
}
void addSample() {
CHECK(getBuffer() != nullptr && size < sizeof(bytes) - 4);
++writes;
bytes[size++] = 4;
size += 3;
}
};
struct LPPReader {
unsigned remaining;
LPPReader(uint8_t* buffer, uint8_t size) : remaining(buffer ? size / 4 : 0) {}
bool readHeader(uint8_t& channel, uint8_t& type) {
if (!remaining) return false;
--remaining;
channel = 0;
type = 1;
return true;
}
void skipData(uint8_t) {}
};
struct Board {
unsigned reads = 0;
int getBattMilliVolts() { ++reads; return 4000; }
} board;
struct Sensors {
unsigned calls = 0;
unsigned samples = 0;
uint32_t duration_ms = 0;
void querySensors(int mask, CayenneLPP& lpp) {
CHECK(mask == 0xFF);
++calls;
for (unsigned i = 0; i < samples; ++i) lpp.addSample();
clock_ms += duration_ms;
}
} sensors;
struct Task {
unsigned toggles = 0;
void toggleGPS() { ++toggles; }
};
enum class HomePage { OTHER, SENSORS };
struct Home {
@FIELDS@
Task task;
Task* _task = &task;
HomePage _page = HomePage::SENSORS;
@REFRESH@
bool input(char @KEY@) {
@INPUT@
return false;
}
};
static constexpr uint32_t interval_ms = AUTO_OFF_MILLIS > 0 ? 5000u : 60000u;
static void begin_case(const char* name, uint32_t initial_ms) {
active_case = name;
++scenarios;
clock_ms = initial_ms;
sensors = Sensors{};
board = Board{};
}
static void initial_at(uint32_t at) {
begin_case("fresh page", at);
Home home;
CHECK(home.sensors_lpp.getSize() == 0);
home.refresh_sensors();
CHECK(sensors.calls == 1 && board.reads == 1);
CHECK(home.sensors_lpp.getSize() == 4);
CHECK(home.sensors_nb == 1 && !home.sensors_scroll);
home.refresh_sensors();
CHECK(sensors.calls == 1 && board.reads == 1);
}
static void cadence_and_records() {
begin_case("normal cadence and records", 100);
Home home;
sensors.samples = 4;
home.refresh_sensors();
CHECK(sensors.calls == 1 && home.sensors_nb == 5 && home.sensors_scroll);
clock_ms += interval_ms - 1;
home.refresh_sensors();
CHECK(sensors.calls == 1);
++clock_ms;
sensors.samples = 0;
home.refresh_sensors();
CHECK(sensors.calls == 2 && home.sensors_nb == 1 && !home.sensors_scroll);
CHECK(home.sensors_lpp.getSize() == 4);
home.refresh_sensors();
CHECK(sensors.calls == 2);
}
static void wrap_visible() {
begin_case("visible across wrap", UINT32_MAX - interval_ms / 2);
Home home;
home.refresh_sensors();
CHECK(sensors.calls == 1);
++clock_ms;
home.refresh_sensors();
CHECK(sensors.calls == 1);
clock_ms += interval_ms - 2;
home.refresh_sensors();
CHECK(sensors.calls == 1);
++clock_ms;
home.refresh_sensors();
CHECK(sensors.calls == 2);
}
static void wake_across_wrap() {
begin_case("wake across wrap", UINT32_MAX - 2 * interval_ms);
Home home;
home.refresh_sensors();
CHECK(sensors.calls == 1);
// The display is not rendered while asleep; wake after millis wraps.
clock_ms = 1000;
home.refresh_sensors();
CHECK(sensors.calls == 2);
home.refresh_sensors();
CHECK(sensors.calls == 2);
}
static void exact_wrap_deadline() {
begin_case("deadline at zero", uint32_t(0u - interval_ms));
Home home;
home.refresh_sensors();
CHECK(sensors.calls == 1);
++clock_ms;
home.refresh_sensors();
CHECK(sensors.calls == 1);
clock_ms = UINT32_MAX;
home.refresh_sensors();
CHECK(sensors.calls == 1);
clock_ms = 0;
home.refresh_sensors();
CHECK(sensors.calls == 2);
home.refresh_sensors();
CHECK(sensors.calls == 2);
}
static void long_idle(uint32_t elapsed) {
begin_case("long inactive page", 100);
Home home;
home.refresh_sensors();
CHECK(sensors.calls == 1);
clock_ms += elapsed;
home.refresh_sensors();
CHECK(sensors.calls == 2);
home.refresh_sensors();
CHECK(sensors.calls == 2);
}
static void force_at(uint32_t initial, uint32_t forced) {
begin_case("forced input", initial);
Home home;
home.refresh_sensors();
CHECK(sensors.calls == 1);
clock_ms = forced;
CHECK(!home.input('x'));
CHECK(home.task.toggles == 0);
home._page = HomePage::OTHER;
CHECK(!home.input(KEY_ENTER));
CHECK(home.task.toggles == 0);
home._page = HomePage::SENSORS;
CHECK(home.input(KEY_ENTER));
CHECK(home.task.toggles == 1);
CHECK(sensors.calls == 1);
home.refresh_sensors();
CHECK(sensors.calls == 2 && home.sensors_nb == 1);
home.refresh_sensors();
CHECK(sensors.calls == 2);
}
static void completion_anchor(bool wrap) {
begin_case("completion-relative cadence", wrap ? UINT32_MAX - 10 : 100);
Home home;
sensors.duration_ms = 37;
home.refresh_sensors();
CHECK(sensors.calls == 1);
const uint32_t completion = clock_ms;
clock_ms = completion + interval_ms - 1;
home.refresh_sensors();
CHECK(sensors.calls == 1);
++clock_ms;
home.refresh_sensors();
CHECK(sensors.calls == 2);
CHECK(clock_ms == uint32_t(completion + interval_ms + sensors.duration_ms));
}
static void empty_query_keeps_base_voltage() {
begin_case("empty sensor query", 100);
Home home;
CHECK(sensors.samples == 0);
home.refresh_sensors();
CHECK(home.sensors_lpp.getSize() == 4 && home.sensors_nb == 1);
++clock_ms;
home.refresh_sensors();
CHECK(sensors.calls == 1);
}
#if TEST_NEW_UI
static void allocation_guard() {
begin_case("allocation guard", 0);
Home home;
home.sensors_lpp.allocated = false;
home.sensors_nb = 5;
home.sensors_scroll = true;
home.sensors_scroll_offset = 4;
home.refresh_sensors();
CHECK(sensors.calls == 0 && board.reads == 0 && home.sensors_lpp.writes == 0);
CHECK(home.sensors_nb == 0 && !home.sensors_scroll && home.sensors_scroll_offset == 0);
home.sensors_lpp.allocated = true;
home.refresh_sensors();
CHECK(sensors.calls == 1 && home.sensors_nb == 1);
}
#endif
int main(int argc, char** argv) {
if (argc == 2 && std::strcmp(argv[1], "force") == 0) {
force_at(100, 101);
} else {
initial_at(0);
initial_at(0x80000000u);
initial_at(UINT32_MAX);
cadence_and_records();
wrap_visible();
wake_across_wrap();
exact_wrap_deadline();
long_idle(26u * 24u * 60u * 60u * 1000u);
long_idle(UINT32_MAX - 1000);
force_at(100, 101);
force_at(0x80000000u, 0x80000001u);
force_at(UINT32_MAX - 10, 0);
completion_anchor(false);
completion_anchor(true);
empty_query_keeps_base_voltage();
#if TEST_NEW_UI
allocation_guard();
#endif
}
std::printf("PASS: %u scenarios; %u checks\n", scenarios, checks);
}
+94
View File
@@ -0,0 +1,94 @@
"""Test actual HomeScreen sensor-page refresh and force-input timer bodies.
Both UI implementations are compiled with the sensor page enabled at each
cadence. This does not claim that every normal board recipe enables that page.
Clock, task, and board/sensor/LPP boundaries are host fakes, not hardware tests.
"""
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/home_sensor_refresh.cpp'
def harness(ui, mutation=None):
source = (ROOT / 'examples/companion_radio' / ui / 'UITask.cpp').read_text(encoding='ascii')
field_start = source.index(' CayenneLPP sensors_lpp;')
refresh_start = source.index(' void refresh_sensors()', field_start)
fields = source[field_start:refresh_start]
# Keep sensor fields exactly as declared, without the neighboring radio UI.
fields = fields.split(' uint32_t _radio_profile_page_started_at')[0]
refresh = extract_braced(source, 'void refresh_sensors()')
key = 'key' if ui == 'ui-new' else 'c'
input_block = extract_braced(source,
f'if ({key} == KEY_ENTER && _page == HomePage::SENSORS)')
if mutation == 'no-force-invalidation':
if input_block.count('sensors_lpp.reset();') != 1:
raise AssertionError('sensor-page input must explicitly invalidate its LPP cache')
input_block = input_block.replace('sensors_lpp.reset();', '', 1)
elif mutation == 'no-empty-marker':
refresh, count = re.subn(r'sensors_lpp\.getSize\(\)\s*==\s*0\s*\|\|\s*',
'', refresh, count=1)
if count != 1:
raise AssertionError('refresh must recognize its empty initial/forced cache')
code = FIXTURE.read_text(encoding='ascii')
for token, value in {'FIELDS': fields, 'REFRESH': refresh,
'KEY': key, 'INPUT': input_block}.items():
code = code.replace('@' + token + '@', value)
return code
class HomeSensorRefreshTest(unittest.TestCase):
def test_actual_sensor_page_cadence_wrap_force_and_allocation(self):
self.run_variants()
def test_missing_force_invalidation_fails_intended_regression(self):
self.run_variants('no-force-invalidation', 'force', 'FAIL forced input:')
def test_missing_empty_marker_fails_first_poll_at_zero(self):
self.run_variants('no-empty-marker', None, 'FAIL fresh page:')
def run_variants(self, mutation=None, argument=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='home-sensor-refresh-') as directory:
work = Path(directory)
for ui in ('ui-new', 'ui-tiny'):
source = work / (ui + '.cpp')
source.write_text(harness(ui, mutation), encoding='ascii')
for auto_off in (15000, 0):
with self.subTest(ui=ui, auto_off=auto_off):
binary = work / (ui + '-' + str(auto_off) +
('.exe' if os.name == 'nt' else ''))
compiled = subprocess.run([
compiler, '-std=c++17', '-O1', '-g', '-Wall', '-Wextra', '-Werror',
*sanitize, '-DAUTO_OFF_MILLIS=' + str(auto_off),
'-DTEST_NEW_UI=' + str(int(ui == 'ui-new')),
str(source), '-o', str(binary),
], text=True, capture_output=True, timeout=60)
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
tested = subprocess.run([str(binary), *([argument] if argument else [])],
text=True, capture_output=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: (15|16) scenarios; \d+ checks')
if __name__ == '__main__':
unittest.main()
+4 -3
View File
@@ -98,7 +98,7 @@ class T096FullMemoryTest(unittest.TestCase):
#define AUTO_OFF_MILLIS 15000
#define UI_RECENT_LIST_SIZE 3
#define TELEM_CHANNEL_SELF 0
unsigned long millis() { return 100; }
uint32_t millis() { return 100; }
struct Lpp {
uint8_t storage[8];
bool allocated = false;
@@ -121,7 +121,8 @@ struct Board { int getBattMilliVolts() { return 4000; } } board;
struct Sensors { int calls = 0; void querySensors(int, Lpp&) { ++calls; } } sensors;
struct Home {
Lpp sensors_lpp;
int sensors_nb = 5, sensors_scroll_offset = 4, next_sensors_refresh = 0;
int sensors_nb = 5, sensors_scroll_offset = 4;
uint32_t last_sensors_refresh = 0;
bool sensors_scroll = true;
@REFRESH@
};
@@ -133,7 +134,7 @@ int main() {
home.sensors_lpp.allocated = true;
home.refresh_sensors();
assert(home.sensors_nb == 1 && sensors.calls == 1);
assert(home.next_sensors_refresh == 5100);
assert(home.last_sensors_refresh == 100);
home.refresh_sensors();
assert(sensors.calls == 1);
}