Files
MeshCore/test/test_beacon_radio.py
T
mikecarper 8a49a98709 Support Meshtext beacons across LoRa radio families
Add checked protocol adapters for every project LoRa family and enable Indicator LoRa TX. Preserve normal modem restoration, LR1110 fast hops, LR2021 side detectors and physical-band power limits.

Fix SX1276 beacon CAD completion dispatch, concrete-chip validation on multi-chip variants, no-GPS sensor hook linkage and STM32 file construction. Add fault, negative-control and preset coverage to CI. Keep flash-constrained STM32 artifacts disabled after measured opt-in overflows.
2026-10-14 10:22:04 -07:00

403 lines
18 KiB
Python

#!/usr/bin/env python3
"""Execute the actual transient beacon radio methods against a faultable modem."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
def method(source, signature):
start = source.index(signature)
end = source.index("{", start) + 1
depth = 1
while depth:
depth += (source[end] == "{") - (source[end] == "}")
end += 1
return source[start:end]
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <initializer_list>
#include <RadioProfiles.h>
#define MESH_ENABLE_TX_BEACONS 1
#define MESH_ENABLE_MESHTEXT_WIRE 1
#define STATE_IDLE 0
#define STATE_RX 1
#define STATE_TX_WAIT 3
#define RADIOLIB_ERR_NONE 0
#define RADIOLIB_ERR_SPI_CMD_TIMEOUT -705
#define RADIOLIB_CHANNEL_FREE -15
#define MAX_TRANS_UNIT 255
#define NF_CALIB_SETTLE_MS 7
#define MESH_DEBUG_PRINTLN(...) ((void)0)
namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; }
using Result = mesh::RadioParamApplyResult;
uint32_t now_ms = 100;
uint32_t millis() { return now_ms; }
uint32_t profileTimestamp(bool) { return now_ms * 1000; }
struct Board {
unsigned before = 0, after = 0;
void onBeforeTransmit() { ++before; }
void onAfterTransmit() { ++after; }
};
struct Modem {
bool standby_fail = false, clear_fail = false, param_fail = false, arm_fail = false;
int tx_result = 0, scan_result = RADIOLIB_CHANNEL_FREE;
float freq = 906, bw = 62.5;
uint8_t sf = 7, cr = 5;
uint16_t preamble = 32;
mesh::RadioProfileProtocol protocol = mesh::RadioProfileProtocol::MeshCore;
int8_t power = 20;
unsigned standbys = 0, clears = 0, finishes = 0;
int standby() { ++standbys; return standby_fail ? -1 : 0; }
int clearIrqFlags(uint32_t) { ++clears; return clear_fail ? -1 : 0; }
int finishTransmit() { ++finishes; return 0; }
int startTransmit(uint8_t*, int) { return tx_result; }
int setPreambleLength(uint16_t value) { preamble = value; return 0; }
};
struct RadioLibWrapper {
Modem modem;
Modem* _radio = &modem;
Board board;
Board* _board = &board;
mesh::RadioProfiles _profiles;
bool _beacon_owned = false, _beacon_restoring = false;
uint8_t _beacon_restore_profile = 0;
uint32_t _beacon_restore_at = 0, _beacon_cad_timeout = 0;
uint8_t state = STATE_RX, _active_profile = 0;
uint32_t _profile_generation = 1, _profile_visit_stamp = 0;
bool _profile_refresh_required = false;
uint16_t _physical_preamble = 32;
mesh::RadioProfileProtocol _physical_protocol = mesh::RadioProfileProtocol::MeshCore;
float _cur_freq = 906, _cur_bw = 62.5;
uint8_t _cur_sf = 7, _cur_cr = 5;
int8_t _cur_dbm = 20;
bool _params_valid = true, _dbm_valid = true;
bool _cw_active = false, pending = false, packet = false, busy = false, calibrating = false;
bool _rx_ps_enabled = false, _rx_ps_armed = false, _rx_hold_continuous = false;
bool _rx_ps_continuous_fallback = false, _nf_calib_active = false;
bool _rx_boosted_gain_valid = true, _cur_rx_boosted_gain = true;
bool _wd_last_busy = false;
uint8_t _startrx_fails = 0, _irq_probe_failures = 0;
uint32_t _rx_ps_rx_us = 50000, _rx_ps_sleep_us = 50000;
uint32_t _wd_last_transition = 0, _wd_stuck_thresh = 0, _wd_observe_ms = 0;
uint32_t n_wd_soft = 0, n_wd_hard = 0;
unsigned long _nf_sample_from = 0;
unsigned applications = 0, protocols = 0, power_sets = 0, arms = 0, stops = 0;
unsigned deep_calls = 0, resets = 0, recalibrations = 0;
unsigned retune_begins = 0, retune_ends = 0, scans = 0;
bool deep_ok = true, repair_on_deep = false, power_fail = false, protocol_fail = false;
RadioLibWrapper() {
_profiles.primary.freq = 906; _profiles.primary.bw = 62.5;
_profiles.primary.sf = 7; _profiles.primary.cr = 5;
}
bool validateProfile(const mesh::RadioProfileParams& p) const {
return mesh::RadioProfiles::valid(p) && p.freq <= 960 && p.bw <= 500;
}
bool isInRecvMode() const { return state == STATE_RX; }
bool isCalibratingNoiseFloor() const { return calibrating; }
bool isChipBusy() { return busy; }
bool isReceivingPacket() { return packet; }
bool hasPendingRadioInterrupt() const { return pending; }
bool claimRadioInterrupt() { bool had = pending; pending = false; return had; }
void beginProfileRetune(bool) { ++retune_begins; }
void endProfileRetune(bool) { ++retune_ends; }
void cacheParams(float f, float b, uint8_t s, uint8_t c) {
_cur_freq = f; _cur_bw = b; _cur_sf = s; _cur_cr = c; _params_valid = true;
}
uint16_t profilePreamble(uint8_t profile) const { return _profiles.preamble(profile, 32); }
uint16_t currentPreambleLength() const { return _physical_preamble; }
bool applyParams(float f, float b, uint8_t s, uint8_t c) {
++applications;
if (modem.param_fail) return false;
modem.freq = f; modem.bw = b; modem.sf = s; modem.cr = c;
modem.preamble = _physical_preamble;
return true;
}
bool applyProtocol(mesh::RadioProfileProtocol p) {
++protocols;
if (protocol_fail) return false;
modem.protocol = p; return true;
}
int applyCachedTxPower(int8_t power) {
++power_sets; if (power_fail) return -1; modem.power = power; return 0;
}
uint32_t cadScanTimeoutMillis(uint8_t sf, float) const { return sf * 10; }
int performChannelScanWithTimeout(uint32_t timeout) {
assert(timeout == _beacon_cad_timeout); ++scans; pending = true;
return modem.scan_result;
}
void stopReceiveDutyCycle() { ++stops; _rx_ps_armed = false; }
int startReceiveMode() {
++arms;
if (modem.arm_fail) return -1;
_rx_ps_armed = _rx_ps_enabled; return 0;
}
uint32_t getEstAirtimeFor(int) { return 100; }
bool registerInterruptAction() { return true; }
void recalibrateNoiseFloor() { ++recalibrations; }
bool applyRxBoostedGainMode(bool) { return true; }
bool supportsRadioDeepInit() const { return true; }
bool radioDeepInit() {
++deep_calls;
if (!deep_ok) return false;
if (repair_on_deep) {
busy = false; modem.standby_fail = modem.clear_fail = modem.param_fail = modem.arm_fail = false;
power_fail = protocol_fail = false;
}
return true;
}
void resetAGC() { ++resets; }
void idle() { modem.standby(); state = STATE_IDLE; }
uint8_t beginReconfigure();
bool restoreAfterDeepInit();
bool recoverRadio(bool hard);
void startRecv();
bool startSendRaw(const uint8_t*, int);
void onSendFinished();
uint32_t getBeaconAirtime(const mesh::RadioProfileParams&, int);
void cacheBeaconRestoreParams();
Result prepareBeaconTransmit(const mesh::RadioProfileParams&, int8_t);
Result restoreBeaconReceive();
bool isBeaconChannelBusy();
};
@METHODS@
mesh::RadioProfileParams beacon(bool meshtext = false) {
mesh::RadioProfileParams p;
p.freq = 915; p.bw = 250; p.sf = 11; p.cr = 5;
if (meshtext) p.protocol = mesh::RadioProfileProtocol::Meshtext;
return p;
}
int main() {
unsigned cases = 0;
for (bool dual : {false, true}) for (bool rxps : {false, true}) for (bool mt : {false, true}) {
RadioLibWrapper w; now_ms = 100;
if (dual) {
w._profiles.secondary.params = w._profiles.primary;
w._profiles.secondary.params.freq = 908;
w._profiles.secondary.mode = mesh::RadioProfileMode::RxTx;
w._active_profile = 1; w._cur_freq = 908; w.modem.freq = 908;
}
w._rx_ps_enabled = w._rx_ps_armed = rxps;
const auto before = w._profiles;
assert(w.prepareBeaconTransmit(beacon(mt), 10) == Result::APPLIED);
assert(w._beacon_owned && w.state == STATE_IDLE && w.arms == 0);
assert(w.modem.freq == 915 && w.modem.power == 10);
assert(w.modem.preamble == (mt ? 16 : 32));
assert(w._cur_freq == (dual ? 908 : 906) && w._cur_dbm == 20);
assert(w._profiles.primary == before.primary && w._profiles.secondary.params == before.secondary.params);
assert(w._profiles.generation[0] == before.generation[0] && w._profiles.generation[1] == before.generation[1]);
assert(w._profiles.switches == 0 && w._profiles.switch_test_samples[0] == 0);
assert(w.beginReconfigure() == 2);
w.startRecv(); assert(w.arms == 0);
assert(w.recoverRadio(true) == false && w.deep_calls == 0);
assert(!w.isBeaconChannelBusy() && !w.pending && w.state == STATE_IDLE);
assert(w.restoreBeaconReceive() == Result::APPLIED);
assert(!w._beacon_owned && w.state == STATE_RX && w._rx_ps_armed == rxps);
assert(w.modem.freq == (dual ? 908 : 906) && w.modem.power == 20);
assert(w.modem.protocol == mesh::RadioProfileProtocol::MeshCore);
assert(w.recalibrations == 0 && w.deep_calls == 0);
++cases;
}
for (unsigned reason = 0; reason < 5; ++reason) {
RadioLibWrapper w;
if (reason == 0) w.pending = true;
if (reason == 1) w.packet = true;
if (reason == 2) w.busy = true;
if (reason == 3) w._cw_active = true;
if (reason == 4) w.calibrating = true;
assert(w.prepareBeaconTransmit(beacon(), 10) == Result::BUSY);
assert(!w._beacon_owned && w.applications == 0 && w.power_sets == 0);
++cases;
}
for (unsigned failure = 0; failure < 4; ++failure) {
RadioLibWrapper w;
if (failure == 0) w.modem.param_fail = true;
if (failure == 1) w.protocol_fail = true;
if (failure == 2) w.power_fail = true;
if (failure == 3) w.modem.standby_fail = true;
assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::FAILED && w._beacon_owned);
w.repair_on_deep = true;
assert(w.restoreBeaconReceive() == Result::APPLIED && !w._beacon_owned);
assert(w.modem.freq == 906 && w.modem.protocol == mesh::RadioProfileProtocol::MeshCore && w.modem.power == 20);
++cases;
}
for (unsigned changed = 0; changed < 2; ++changed) {
RadioLibWrapper w;
w._profiles.secondary.params = w._profiles.primary;
w._profiles.secondary.params.freq = 908;
w._profiles.secondary.mode = mesh::RadioProfileMode::RxTx;
w._active_profile = 1;
assert(w.prepareBeaconTransmit(beacon(), 10) == Result::APPLIED);
if (changed == 0) {
w._profiles.secondary.params.freq = 909; ++w._profiles.generation[1];
} else w._profiles.secondary.mode = mesh::RadioProfileMode::Off;
assert(w.restoreBeaconReceive() == Result::APPLIED);
assert(w.modem.freq == (changed == 0 ? 909 : 906));
assert(w._active_profile == (changed == 0 ? 1 : 0));
++cases;
}
for (int cad_result : {RADIOLIB_CHANNEL_FREE, -6, -16, -702}) {
RadioLibWrapper w;
assert(w.prepareBeaconTransmit(beacon(), 10) == Result::APPLIED);
w.modem.scan_result = cad_result;
assert(w.isBeaconChannelBusy() == (cad_result != RADIOLIB_CHANNEL_FREE));
assert(!w.pending && w.arms == 0 && w.state == STATE_IDLE);
assert(w.restoreBeaconReceive() == Result::APPLIED);
++cases;
}
for (bool spi_timeout : {false, true}) {
RadioLibWrapper w;
assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::APPLIED);
w.modem.tx_result = spi_timeout ? RADIOLIB_ERR_SPI_CMD_TIMEOUT : -1;
const uint8_t bytes[] = {1, 2};
assert(!w.startSendRaw(bytes, 2) && w._beacon_owned && w.deep_calls == 0);
assert(w._cur_freq == 906 && w._cur_dbm == 20);
assert(w.board.before == 1 && w.board.after == 1);
assert(w.restoreBeaconReceive() == Result::APPLIED && w.modem.freq == 906);
++cases;
}
{
RadioLibWrapper w;
assert(w.prepareBeaconTransmit(beacon(), 10) == Result::APPLIED);
const uint8_t bytes[] = {1, 2};
assert(w.startSendRaw(bytes, 2));
assert(w.restoreBeaconReceive() == Result::BUSY && w._beacon_owned);
w.onSendFinished();
assert(w.restoreBeaconReceive() == Result::APPLIED);
assert(w.board.before == 1 && w.board.after == 1 && w.modem.finishes == 1);
++cases;
}
for (unsigned failure = 0; failure < 5; ++failure) {
RadioLibWrapper w; now_ms = UINT32_MAX - 50;
assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::APPLIED);
if (failure == 0) w.modem.standby_fail = true;
if (failure == 1) w.modem.param_fail = true;
if (failure == 2) w.modem.clear_fail = true;
if (failure == 3) w.modem.arm_fail = true;
if (failure == 4) w.busy = true;
w.deep_ok = false;
assert(w.restoreBeaconReceive() == Result::BUSY && w._beacon_owned);
const unsigned before = w.applications;
++now_ms;
assert(w.restoreBeaconReceive() == Result::BUSY && w.applications == before);
now_ms += 100; w.deep_ok = w.repair_on_deep = true;
assert(w.restoreBeaconReceive() == Result::APPLIED && !w._beacon_owned);
assert(w.modem.freq == 906 && w.modem.power == 20 && w.state == STATE_RX);
++cases;
}
{
RadioLibWrapper w;
assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::APPLIED);
assert(w.restoreAfterDeepInit());
assert(w.modem.freq == 906 && w.modem.power == 20);
assert(w.modem.protocol == mesh::RadioProfileProtocol::MeshCore && w._beacon_owned);
assert(w.restoreBeaconReceive() == Result::APPLIED);
++cases;
}
{
RadioLibWrapper w;
auto p = beacon();
assert(w.getBeaconAirtime(p, 255) > 0);
assert(!w.getBeaconAirtime(p, 0) && !w.getBeaconAirtime(p, 256));
p.freq = 1000;
assert(!w.getBeaconAirtime(p, 20));
assert(w.prepareBeaconTransmit(p, 10) == Result::FAILED && !w._beacon_owned);
++cases;
}
std::printf("PASS: %u actual-method beacon radio cases\n", cases);
}
'''
class BeaconRadioTest(unittest.TestCase):
def source(self):
source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text()
signatures = [
"uint8_t RadioLibWrapper::beginReconfigure()",
"bool RadioLibWrapper::restoreAfterDeepInit()",
"bool RadioLibWrapper::recoverRadio(bool hard)",
"void RadioLibWrapper::startRecv()",
"bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len)",
"void RadioLibWrapper::onSendFinished()",
"uint32_t RadioLibWrapper::getBeaconAirtime(",
"void RadioLibWrapper::cacheBeaconRestoreParams()",
"mesh::RadioParamApplyResult RadioLibWrapper::prepareBeaconTransmit(",
"bool RadioLibWrapper::isBeaconChannelBusy()",
"mesh::RadioParamApplyResult RadioLibWrapper::restoreBeaconReceive()",
]
return HARNESS.replace("@METHODS@", "\n\n".join(method(source, name) for name in signatures))
def run_harness(self, code, expect_pass=True):
with tempfile.TemporaryDirectory(prefix="meshcore-beacon-radio-") as directory:
cpp = Path(directory) / "test.cpp"
binary = Path(directory) / "test"
cpp.write_text(code, encoding="ascii")
command = ["g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "src"),
"-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie",
str(cpp), "-o", str(binary)]
build = subprocess.run(command, capture_output=True, text=True)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
env = dict(os.environ, ASAN_OPTIONS="detect_leaks=1:halt_on_error=1",
UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1")
result = subprocess.run([str(binary)], capture_output=True, text=True, env=env)
if expect_pass:
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("PASS:", result.stdout)
print(result.stdout.strip())
else:
self.assertNotEqual(result.returncode, 0, "negative control unexpectedly passed")
self.assertIn("Assertion", result.stderr)
def test_actual_radio_transaction(self):
self.run_harness(self.source())
def test_ownership_and_snapshot_negative_controls(self):
source = self.source()
mutations = [
("if (_beacon_owned && !_beacon_restoring) return 2;", "/* remove ownership guard */"),
("if (_beacon_owned && !_beacon_restoring) return;", "/* permit RX on beacon tuple */"),
("bool applied = _radio->standby() == RADIOLIB_ERR_NONE",
"cacheParams(p.freq, p.bw, p.sf, p.cr); bool applied = _radio->standby() == RADIOLIB_ERR_NONE"),
]
for old, new in mutations:
with self.subTest(mutation=old):
self.assertIn(old, source)
self.run_harness(source.replace(old, new, 1), expect_pass=False)
def test_feature_gate_matrix(self):
cases = [
([], 0, 0),
(["STM32_PLATFORM"], 0, 0),
(["USE_SX1262"], 0, 0),
(["NRF52_PLATFORM", "USE_SX1262"], 0, 1),
# The codec is stable across translation units, including the
# Indicator's SPI LoRa radio with expander-controlled GPIOs.
(["NRF52_PLATFORM", "USE_SX1262", "SENSECAP_INDICATOR_LORA"], 0, 1),
(["USE_SX1262", "MESH_ENABLE_TX_BEACONS=0"], 0, 0),
(["USE_SX1262", "MESH_PRIMARY_ESPNOW=1"], 0, 0),
(["ESP32_PLATFORM", "CONFIG_IDF_TARGET_ESP32S3", "COMPANION_RADIO_FULL", "USE_SX1262"], 1, 1),
(["ESP32_PLATFORM", "CONFIG_IDF_TARGET_ESP32S3", "COMPANION_RADIO_FULL", "USE_SX1262",
"MESH_ENABLE_TX_BEACONS=0"], 1, 1),
(["NRF52_PLATFORM", "RAK_4631", "COMPANION_RADIO_FULL", "USE_SX1262"], 1, 1),
]
for flags, chat, wire in cases:
with self.subTest(flags=flags):
code = '#include <helpers/MeshtextConfig.h>\n'
code += f'static_assert(MESH_ENABLE_MESHTEXT_CHAT == {chat}, "chat gate");\n'
code += f'static_assert(MESH_ENABLE_MESHTEXT_WIRE == {wire}, "wire gate");\n'
command = ["g++", "-std=c++17", "-fsyntax-only", "-x", "c++", "-I", str(ROOT / "src")]
command += ["-D" + flag for flag in flags] + ["-"]
result = subprocess.run(command, input=code, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()