mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-17 11:04:20 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)')
|
||||
|
||||
Reference in New Issue
Block a user