diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 7b6af1e0..74131210 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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: >- diff --git a/.gitignore b/.gitignore index ec693fb9..65d52ed7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index e0bd9ac3..56b65558 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -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((preamble_guard_us + 999) / 1000), + (payload_us + 999) / 1000}; } diff --git a/test/test_companion_contact_sleep_contract.py b/test/test_companion_contact_sleep_contract.py index d1557bbf..2c5754ab 100644 --- a/test/test_companion_contact_sleep_contract.py +++ b/test/test_companion_contact_sleep_contract.py @@ -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 +#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", diff --git a/test/test_radio_receive_contract.py b/test/test_radio_receive_contract.py index 9b053053..c6590bb4 100644 --- a/test/test_radio_receive_contract.py +++ b/test/test_radio_receive_contract.py @@ -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 +#include +#include +#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 +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(limits, RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED, + RADIOLIB_SX126X_IRQ_HEADER_VALID, RADIOLIB_SX126X_IRQ_HEADER_ERR, false); + checkReceive(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)')