Add preamble timeout margin and missing CI coverage

Allow four additional RX preamble symbols without reducing the payload timeout. Exercise receive deadlines and OTA pending-work behavior, run the omitted radio and contact tests in CI, and ignore local lab credentials and transient files.
This commit is contained in:
mikecarper
2026-09-14 22:13:32 -07:00
parent b8697d7db0
commit a5700aaa66
5 changed files with 240 additions and 4 deletions
+10
View File
@@ -93,6 +93,13 @@ jobs:
python3 -B test/test_repeater_radio_timing_integration.py
python3 -B test/test_temp_radio_reply_delivery_contract.py
- name: Verify dual-profile switching and chirp timing
working-directory: test
run: >-
python3 -B -m unittest -v
test_sx1262_profile_switch test_sx1262_batched_modulation
test_radio_chirp_math
- name: Verify ESP32 static DRAM budget
run: |
python3 -B test/test_esp32_dram.py
@@ -110,6 +117,9 @@ jobs:
python3 -B test/test_esp32_usb_serial_hygiene.py
python3 -B test/test_stm32_float_conversion.py
- name: Verify Companion contact persistence and OTA sleep guards
run: python3 -B test/test_companion_contact_sleep_contract.py -v
- name: Verify message reader buttons, touch targets, and footer layouts
working-directory: test
run: >-
+20
View File
@@ -36,6 +36,26 @@ src/helpers/esp32/WebConfigHtml.h
src/idf_component.yml
ssl_certs/cacert.pem
platformio.local.ini
# Local machine login details and private hardware-test configuration.
.target
.target.*
*.target
!.target.example
!.target.template
/tools/hil/S3SoakWiFiKey.h
/tools/hil/*.local.json
# Disposable hardware-test transport exports and process state. Keep reviewed
# result captures, manifests, validation summaries and cleanup evidence visible.
/tools/hil/profile_*_export.json
/tools/hil/profile_*_run-control.json
/tools/hil/profile_*_run-exit.json
/tools/hil/**/run-control.json
/tools/hil/**/run-exit.json
/tools/hil/**/*.log
/tools/hil/**/*.jsonl
/tools/hil/**/*.pid
/tools/hil/**/*.sock
/tools/hil/**/*.tmp
.cursor/*
.claude/*
.cursorrules
+5 -1
View File
@@ -1200,6 +1200,9 @@ PacketMillis RadioLibWrapper::calcMaxPacketMillis(uint8_t sf, float bw, uint8_t
// preamble + syncword + sfd + header
uint64_t preamble_us = (((uint64_t)(preambleSymbols + 8) * 4 + sfCoeff1_x4) * tsym_us) / 4;
// Allow four extra preamble symbols before giving up on header detection.
// This is an RX guard only; keep it out of the payload airtime subtraction.
const uint64_t preamble_guard_us = preamble_us + 4ULL * tsym_us;
// airtime for max packet at current radio settings
uint32_t total_us = _radio->getTimeOnAir(MAX_TRANS_UNIT);
@@ -1212,5 +1215,6 @@ PacketMillis RadioLibWrapper::calcMaxPacketMillis(uint8_t sf, float bw, uint8_t
// rescale payload_us for max possible CR
if (cr >= 5 && cr < 8) { payload_us = (payload_us * 8) / cr; }
return PacketMillis {(preamble_us + 999) / 1000, (payload_us + 999) / 1000};
return PacketMillis {static_cast<uint32_t>((preamble_guard_us + 999) / 1000),
(payload_us + 999) / 1000};
}
+54 -3
View File
@@ -2,7 +2,10 @@
"""Integration guards for Companion contact writes across event-only sleep."""
from pathlib import Path
import os
import re
import subprocess
import tempfile
import unittest
@@ -179,9 +182,57 @@ class CompanionContactSleepContractTest(unittest.TestCase):
self.assertIn("bool hasPendingOtaApply() const;", mesh_base_header)
pending = function_body(mesh_base, "bool Mesh::hasPendingOtaApply() const")
self.assertIn("defined(ENABLE_OTA)", pending)
self.assertIn("!defined(OTA_SEEDER_ONLY)", pending)
self.assertIn("ota::ota_ctx().apply_pending", pending)
source = r'''
#include <cassert>
#if defined(ENABLE_OTA) && !defined(OTA_SEEDER_ONLY)
namespace ota {
struct OtaContext { bool apply_pending = false; };
static OtaContext dormant;
static OtaContext* active = nullptr;
const OtaContext* ota_context_if_active() { return active; }
// Catch an idle-work query accidentally acquiring the released OTA context.
OtaContext& ota_ctx() {
if (!active) active = &dormant;
return *active;
}
}
#endif
struct Mesh {
bool hasPendingOtaApply() const { @PENDING@ }
};
int main() {
const Mesh mesh;
assert(!mesh.hasPendingOtaApply());
#if defined(ENABLE_OTA) && !defined(OTA_SEEDER_ONLY)
assert(ota::active == nullptr);
ota::OtaContext context;
ota::active = &context;
assert(!mesh.hasPendingOtaApply());
context.apply_pending = true;
assert(mesh.hasPendingOtaApply());
context.apply_pending = false;
assert(!mesh.hasPendingOtaApply());
ota::active = nullptr;
assert(!mesh.hasPendingOtaApply());
assert(ota::active == nullptr);
#endif
}
'''
with tempfile.TemporaryDirectory(prefix="meshcore-ota-sleep-") as tmp:
cpp = Path(tmp) / "test.cpp"
cpp.write_text(source.replace("@PENDING@", pending))
for index, defines in enumerate(([], ["ENABLE_OTA"],
["ENABLE_OTA", "OTA_SEEDER_ONLY"])):
with self.subTest(defines=defines):
binary = Path(tmp) / f"test-{index}"
built = subprocess.run(
[os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra",
*(f"-D{define}" for define in defines), str(cpp), "-o", str(binary)],
capture_output=True, text=True,
)
self.assertEqual(built.returncode, 0, built.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(ran.returncode, 0, ran.stderr)
role_sources = (
ROOT / "examples/companion_radio/MyMesh.cpp",
+151
View File
@@ -280,6 +280,157 @@ int main() {
'''
class RadioReceiveContractTest(unittest.TestCase):
def test_preamble_margin_and_receive_deadlines(self):
radio_source = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
timing = method(radio_source, 'PacketMillis RadioLibWrapper::calcMaxPacketMillis(')
sx1262 = method((ROOT / 'src/helpers/radiolib/CustomSX1262.h').read_text(),
'bool isReceiving()')
lr1110 = method((ROOT / 'src/helpers/radiolib/CustomLR1110.h').read_text(),
'bool isReceiving()')
source = r'''
#include <cassert>
#include <cstdint>
#include <initializer_list>
#define MAX_TRANS_UNIT 255
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#define RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED 4U
#define RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID 8U
#define RADIOLIB_SX126X_IRQ_HEADER_VALID 16U
#define RADIOLIB_SX126X_IRQ_HEADER_ERR 32U
#define RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED 16U
#define RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID 32U
#define RADIOLIB_LR11X0_IRQ_HEADER_ERR 64U
static uint32_t now_ms;
uint32_t millis() { return now_ms; }
struct PacketMillis { uint32_t preambleMillis, payloadMillis; };
struct Chip { uint32_t getTimeOnAir(int) { return 1000000; } };
struct RadioLibWrapper {
Chip chip;
Chip* _radio = &chip;
PacketMillis calcMaxPacketMillis(uint8_t, float, uint8_t, uint16_t);
};
@TIMING@
struct ReceiverState {
uint32_t _preambleMillis, _maxPayloadMillis;
uint32_t _activityAt = 0, irq = 0;
bool _headerSeen = false;
explicit ReceiverState(PacketMillis p)
: _preambleMillis(p.preambleMillis), _maxPayloadMillis(p.payloadMillis) {}
bool isChipBusy() { return false; }
uint32_t getIrqFlags() { return irq; }
uint32_t getIrqStatus() { return irq; }
void clearIrqFlags(uint32_t flags) { irq &= ~flags; }
void clearIrqState(uint32_t flags) { irq &= ~flags; }
};
struct SX1262Receiver : ReceiverState {
using ReceiverState::ReceiverState;
@SX1262@
};
struct LR1110Receiver : ReceiverState {
using ReceiverState::ReceiverState;
@LR1110@
};
template <typename Receiver>
void checkReceive(PacketMillis limits, uint32_t preamble, uint32_t header,
uint32_t error, bool preserve_header_error) {
// A latched preamble with no header expires despite repeated polling.
// Check both normal uptime and a millis() rollover during the hold.
for (uint32_t start : {100U, 0xFFFFFFF0U}) {
Receiver rx(limits);
now_ms = start;
assert(!rx.isReceiving());
rx.irq = preamble;
assert(rx.isReceiving());
for (uint32_t elapsed : {1U, limits.preambleMillis - 1, limits.preambleMillis}) {
now_ms = start + elapsed;
assert(rx.isReceiving());
}
now_ms = start + limits.preambleMillis + 1;
assert(!rx.isReceiving() && !(rx.irq & preamble));
// A subsequent packet gets a fresh hold, not the abandoned packet's timer.
++now_ms;
rx.irq = preamble;
assert(rx.isReceiving());
now_ms += limits.preambleMillis;
rx.irq |= header;
assert(rx.isReceiving());
now_ms += limits.payloadMillis;
assert(rx.isReceiving());
++now_ms;
assert(!rx.isReceiving() && !(rx.irq & (preamble | header)));
}
{
// A completed packet releases the scan immediately when its IRQs are consumed.
Receiver rx(limits);
now_ms = 100;
rx.irq = preamble;
assert(rx.isReceiving());
++now_ms;
rx.irq |= header;
assert(rx.isReceiving());
++now_ms;
rx.irq = 0;
assert(!rx.isReceiving());
rx.irq = preamble;
assert(rx.isReceiving());
}
{
// Header failure releases the hold; LR1110's recovery path owns its error IRQ.
Receiver rx(limits);
now_ms = 100;
rx.irq = preamble;
assert(rx.isReceiving());
++now_ms;
rx.irq |= error;
assert(!rx.isReceiving());
assert(bool(rx.irq & error) == preserve_header_error);
}
}
int main() {
RadioLibWrapper radio;
struct Case {
uint8_t sf;
float bw;
uint8_t cr;
uint16_t preamble;
uint32_t hold_ms, payload_ms;
};
// Independent timing examples: configured preamble + four symbols + the
// existing sync/header allowance. Payload limits retain their old values.
const Case cases[] = {
{7, 62.5f, 5, 32, 99, 1456},
{7, 125, 5, 32, 50, 1528},
{7, 500, 5, 64, 21, 1569},
{8, 500, 5, 88, 54, 1518},
{9, 500, 5, 32, 50, 1528},
{5, 500, 5, 128, 10, 1586},
{6, 125, 8, 16, 18, 985},
{7, 62.5f, 8, 120, 280, 730},
{7, 125, 8, 65535, 67125, 4000},
};
for (const Case& c : cases) {
const auto limits = radio.calcMaxPacketMillis(c.sf, c.bw, c.cr, c.preamble);
assert(limits.preambleMillis == c.hold_ms);
assert(limits.payloadMillis == c.payload_ms);
checkReceive<SX1262Receiver>(limits, RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED,
RADIOLIB_SX126X_IRQ_HEADER_VALID, RADIOLIB_SX126X_IRQ_HEADER_ERR, false);
checkReceive<LR1110Receiver>(limits, RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED,
RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID, RADIOLIB_LR11X0_IRQ_HEADER_ERR, true);
}
}
'''
with tempfile.TemporaryDirectory(prefix='meshcore-preamble-hold-') as tmp:
cpp, binary = Path(tmp) / 'test.cpp', Path(tmp) / 'test'
cpp.write_text(source.replace('@TIMING@', timing)
.replace('@SX1262@', sx1262).replace('@LR1110@', lr1110))
result = subprocess.run([os.environ.get('CXX', 'c++'), '-std=c++17', '-O1',
'-Wall', '-Wextra', str(cpp), '-o', str(binary)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_real_rxps_setter_preserves_intent_and_rejects_bad_values(self):
common = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
setter = method(common, 'if (memcmp(config, "radio.rxps ", 11) == 0)')