Files
MeshCore/test/test_meshtext_transport.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

363 lines
19 KiB
Python

"""Run the actual Dispatcher foreign-frame ownership and isolation contract."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_radio_receive_contract import method
from test_radio_profile_scan import HARNESS as SCAN_HARNESS
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <Dispatcher.h>
#include <helpers/StaticPoolPacketManager.h>
#include <cassert>
#include <cstring>
#include <vector>
using namespace mesh;
class Clock : public MillisecondClock {
public: unsigned long now=1000; unsigned long getMillis() override { return now; }
};
class TestRadio : public Radio {
public:
RadioProfiles p;
uint8_t active=0;
bool supported=true, receiving=false, complete=false, sending=false, start_ok=true, carrier=false;
bool incoming=false;
unsigned sends=0, finishes=0, recovery=0, processed=0;
RadioParamApplyResult prepare=RadioParamApplyResult::APPLIED;
std::vector<uint8_t> sent;
TestRadio() {
p.primary.freq=909.5f; p.primary.bw=500; p.primary.sf=5; p.primary.cr=5;
p.secondary.params=p.primary; p.secondary.params.protocol=RadioProfileProtocol::Meshtext;
p.secondary.params.preamble=16; p.secondary.mode=RadioProfileMode::RxTx;
}
RadioProfiles* profiles() override { return &p; }
const RadioProfiles* profiles() const override { return &p; }
bool supportsMeshtext() const override { return supported; }
uint8_t receiveProfile() const override { return active; }
int recvRaw(uint8_t* bytes,int) override {
if (!incoming) return 0;
incoming=false; bytes[0]=0x11;bytes[1]=0;bytes[2]=0x23;return 3;
}
void onReceiveProcessed() override { ++processed; }
uint32_t getEstAirtimeFor(int) override { return 100; }
uint32_t getProfileAirtime(uint8_t,int,uint8_t=0) override { return 100; }
float packetScore(float,int) override { return 1; }
RadioParamApplyResult prepareTransmitProfile(uint8_t n) override {
if (prepare==RadioParamApplyResult::APPLIED) active=n;
return prepare;
}
bool startSendRaw(const uint8_t* bytes,int len) override {
++sends;sent.assign(bytes,bytes+len);sending=start_ok;complete=false;return start_ok;
}
bool isSendComplete() override { return complete; }
void onSendFinished() override { ++finishes;sending=false; }
bool isInRecvMode() const override { return !sending; }
bool isReceiving() override { return receiving; }
bool isCarrierWaveActive() const override { return carrier; }
bool recoverRadio(bool) override { ++recovery;return true; }
};
class TestDispatcher : public Dispatcher {
public:
unsigned foreign_rx=0, mc_rx=0, raw_logs=0, mc_sent=0;
bool allow_foreign=true;
unsigned long duty_window=3600000;
std::vector<bool> done;
TestDispatcher(TestRadio& r, Clock& c, StaticPoolPacketManager& m):Dispatcher(r,c,m) {}
using Dispatcher::queueForeignRadioFrame;
using Dispatcher::hasForeignRadioFrame;
using Dispatcher::setRadioAvailable;
using Dispatcher::getTransmitProfileMask;
using Dispatcher::getNextQueueWakeDelay;
DispatcherAction onRecvPacket(Packet*) override { ++mc_rx;return ACTION_RELEASE; }
void logRxRaw(float,float,const uint8_t*,int) override { ++raw_logs; }
bool onForeignRadioFrame(const uint8_t* bytes,size_t length,uint8_t profile,uint32_t generation,float,float) override {
assert(length==3 && bytes[0]==0x11 && profile==1 && generation);++foreign_rx;return false;
}
void onForeignRadioSendComplete(bool ok) override { done.push_back(ok); }
bool allowForeignRadioTransmit() const override { return allow_foreign; }
unsigned long getDutyCycleWindowMs() const override { return duty_window; }
void onSendComplete(Packet*) override { ++mc_sent; }
bool queue(const uint8_t* b,size_t n=3) { return queueForeignRadioFrame(b,n,1,_radio->profiles()->generation[1]); }
};
struct Fixture {
Clock clock;TestRadio radio;StaticPoolPacketManager manager{8};TestDispatcher d{radio,clock,manager};
Fixture() { d.begin(); }
void tick(unsigned delta=1) { clock.now+=delta;d.loop(); }
};
static const uint8_t bytes[]={0x11,0,0x23};
static Packet* newPacket(TestDispatcher& d,uint8_t policy=RADIO_TX_BOTH) {
auto* p=d.obtainNewPacket();assert(p);p->header=ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_TXT<<2);
p->tx_radio=policy;p->payload[0]=0x12;p->payload_len=1;return p;
}
int main() {
{ Fixture f;assert(f.d.queue(bytes));assert(f.d.hasOutbound());assert(!f.d.queue(bytes));
uint32_t delay=99;assert(f.d.getNextQueueWakeDelay(delay) && delay==0);
f.tick();assert(f.radio.sends==1 && f.radio.active==1 && f.radio.sent==std::vector<uint8_t>(bytes,bytes+3));
f.tick(20);assert(f.radio.sends==1 && f.d.done.empty());
f.radio.complete=true;f.tick();assert(f.d.done.size()==1 && f.d.done[0]);
assert(!f.d.hasOutbound() && f.radio.finishes==1 && f.radio.p.tx_packets[1]==1);
assert(f.d.getNumSentFlood()==0 && f.d.getNumSentDirect()==0 && f.d.getTotalAirTime()==21);
}
{ Fixture f;f.radio.active=1;f.radio.incoming=true;f.tick();
assert(f.d.foreign_rx==1 && !f.d.mc_rx && !f.d.raw_logs && f.d.getLastMeshCoreRecvMillis()==0);
assert(f.radio.processed==1 && f.d.getNumRecvFlood()==0 && f.radio.p.rx_packets[1]==1);
f.radio.p.secondary.mode=RadioProfileMode::Off;f.radio.incoming=true;f.tick();
assert(f.d.foreign_rx==1 && !f.d.raw_logs && !f.d.mc_rx);
}
{ Fixture f;assert(f.d.queue(bytes));++f.radio.p.generation[1];f.tick();
assert(f.radio.sends==0 && f.d.done.size()==1 && !f.d.done[0] && !f.d.hasOutbound());
}
{ Fixture f;f.radio.start_ok=false;assert(f.d.queue(bytes));f.tick();f.tick(2000);
assert(f.radio.sends==1 && f.d.done.size()==1 && !f.d.done[0] && !f.d.hasOutbound());
}
{ Fixture f;assert(f.d.queue(bytes));f.tick();f.tick(1151);
assert(f.radio.sends==1 && f.radio.finishes==1 && f.radio.recovery==1);
assert(f.d.done.size()==1 && !f.d.done[0] && !f.d.hasOutbound());f.tick(10000);assert(f.radio.sends==1);
}
{ Fixture f;assert(f.d.queue(bytes));f.tick();f.d.setRadioAvailable(false);f.tick(10000);
assert(f.radio.sends==1 && f.radio.finishes==1 && f.d.done.size()==1 && !f.d.done[0]);
assert(!f.d.queue(bytes) && !f.d.hasOutbound());
}
{ Fixture f;assert(f.d.queue(bytes));f.tick();++f.radio.p.generation[1];f.radio.complete=true;f.tick();
assert(f.d.done.size()==1 && !f.d.done[0] && f.radio.finishes==1);
}
{ Fixture f;f.radio.prepare=RadioParamApplyResult::FAILED;assert(f.d.queue(bytes));f.tick();
assert(f.radio.sends==0 && f.d.done.size()==1 && !f.d.done[0]);
}
{ Fixture f;assert(f.d.queue(bytes));f.d.allow_foreign=false;f.tick();
assert(f.radio.sends==0 && f.d.done.size()==1 && !f.d.done[0] && !f.d.queue(bytes));
}
{ Fixture f;assert(f.d.queue(bytes));f.tick();f.d.begin();
assert(f.radio.finishes==1 && f.d.done.size()==1 && !f.d.done[0] && !f.d.hasOutbound());
}
{ Fixture f;f.radio.prepare=RadioParamApplyResult::BUSY;assert(f.d.queue(bytes));f.tick();f.tick(8151);
assert(f.radio.sends==0 && f.d.done.size()==1 && !f.d.done[0] && f.radio.recovery==1);
}
{ Fixture f;f.radio.receiving=true;assert(f.d.queue(bytes));f.tick();f.tick(201);
assert(f.radio.sends==0 && f.d.hasOutbound());f.radio.receiving=false;f.tick(201);assert(f.radio.sends==1);
}
{ Fixture f;auto* p=newPacket(f.d);f.radio.p.cross=RadioCrossMode::On;
assert(!f.radio.p.canCross() && f.d.getTransmitProfileMask(p)==1);
assert(f.d.sendPacket(p,0));assert(f.manager.getOutboundTotal()==1);f.tick();
assert(f.radio.sends==1 && f.radio.active==0);f.radio.complete=true;f.tick();assert(f.d.mc_sent==1);
auto* denied=newPacket(f.d,RADIO_TX_SECONDARY);assert(!f.d.sendPacket(denied,0));
assert(f.manager.getOutboundTotal()==0 && f.radio.sends==1);
}
{ Fixture f;auto* p=newPacket(f.d,RADIO_TX_PRIMARY);assert(f.d.sendPacket(p,0));f.tick();
assert(f.d.queue(bytes));f.tick();assert(f.radio.sends==1);f.radio.complete=true;f.tick();
f.tick();assert(f.radio.sends==2 && f.radio.active==1);f.radio.complete=true;f.tick();
assert(f.d.done.size()==1 && f.d.done[0] && f.d.mc_sent==1);
}
{ Fixture f;f.d.duty_window=200;f.d.begin();assert(f.d.queue(bytes));f.tick();
f.clock.now+=100;f.radio.complete=true;f.d.loop();assert(f.d.getRemainingTxBudget()==0);
assert(f.d.queue(bytes));f.tick(100);assert(f.radio.sends==1);
f.tick(101);assert(f.radio.sends==2);
}
{ Fixture f;assert(f.d.queue(bytes));f.tick();f.radio.complete=true;f.tick();
assert(f.d.queue(bytes));auto* p=newPacket(f.d,RADIO_TX_PRIMARY);assert(f.d.sendPacket(p,0));f.tick();
assert(f.radio.sends==2 && f.radio.active==0);f.radio.complete=true;f.tick();f.tick();
assert(f.radio.sends==3 && f.radio.active==1);
}
{ Fixture f;f.radio.supported=false;assert(!f.d.queue(bytes));f.radio.supported=true;
assert(!f.d.queueForeignRadioFrame(bytes,3,0,f.radio.p.generation[0]));
assert(!f.d.queueForeignRadioFrame(bytes,3,1,f.radio.p.generation[1]+1));
assert(!f.d.queue(nullptr));assert(!f.d.queue(bytes,0));uint8_t full[256]={};assert(!f.d.queue(full,256));
f.radio.carrier=true;assert(!f.d.queue(bytes));f.radio.carrier=false;
f.radio.p.secondary.mode=RadioProfileMode::Rx;assert(!f.d.queue(bytes));
}
}
'''
class MeshtextTransportTest(unittest.TestCase):
def compile_and_run(self, text, extra_flags=()):
compiler = shutil.which('g++') or shutil.which('clang++')
self.assertIsNotNone(compiler)
with tempfile.TemporaryDirectory() as folder:
cpp, exe = Path(folder)/'test.cpp', Path(folder)/'test.exe'
(Path(folder)/'Arduino.h').write_text('#pragma once\n#include <cstdint>\n#include <cmath>\n')
cpp.write_text(text)
result = subprocess.run([compiler, '-std=c++17', '-Wall', '-Wextra',
*extra_flags, '-I', folder, '-I', str(ROOT/'src'), str(cpp), '-o', str(exe)],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr[-12000:])
run = subprocess.run([str(exe)], capture_output=True, text=True)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
def test_actual_dispatcher(self):
compiler = shutil.which('g++') or shutil.which('clang++')
self.assertIsNotNone(compiler)
with tempfile.TemporaryDirectory() as folder:
cpp, exe = Path(folder)/'test.cpp', Path(folder)/'test.exe'
cpp.write_text(HARNESS)
flags = ['-std=c++17', '-Wall', '-Wextra', '-DMESH_ENABLE_MESHTEXT_CHAT=1']
if os.name != 'nt':
flags += ['-fsanitize=address,undefined', '-fno-omit-frame-pointer', '-fno-pie', '-no-pie']
result = subprocess.run([compiler, *flags,
'-I', str(ROOT/'test/mocks'), '-I', str(ROOT/'src'),
str(ROOT/'src/Dispatcher.cpp'), str(ROOT/'src/Packet.cpp'),
str(ROOT/'src/helpers/StaticPoolPacketManager.cpp'), str(cpp), '-o', str(exe)],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr[-12000:])
env = os.environ.copy()
env['ASAN_OPTIONS'] = env.get('ASAN_OPTIONS', '') + ':detect_leaks=0'
run = subprocess.run([str(exe)], capture_output=True, text=True, env=env)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
def test_protocol_retune_and_rollback(self):
source = (ROOT/'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
names = [('uint8_t', 'beginReconfigure'), ('void', 'endReconfigure'),
('void', 'serviceProfileScan')] + [('mesh::RadioParamApplyResult', n) for n in
('tuneProfile', 'prepareTransmitProfile', 'tryRestoreCodingRate', 'trySetParams', 'trySetPrimaryParams')]
methods = '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(') for kind, name in names)
methods += '\n' + method(source, 'mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile, bool')
wrapper = (ROOT/'src/helpers/radiolib/CustomSX1262Wrapper.h').read_text()
methods += '\n' + method(wrapper, 'void setProfileStandbyWarm(').replace(
'void setProfileStandbyWarm(bool enabled) override',
'void RadioLibWrapper::setProfileStandbyWarm(bool enabled)')
harness = SCAN_HARNESS.split('@METHODS@')[0]
harness = harness.replace('uint16_t _physical_preamble=32;', '''
uint16_t _physical_preamble=32;
mesh::RadioProfileProtocol _physical_protocol=mesh::RadioProfileProtocol::MeshCore;
mesh::RadioProfileProtocol applied_protocol=mesh::RadioProfileProtocol::MeshCore;
unsigned protocol_writes=0;
bool protocol_fail=false;
bool applyProtocol(mesh::RadioProfileProtocol p) {
++protocol_writes;
if (protocol_fail && p==mesh::RadioProfileProtocol::Meshtext) return false;
applied_protocol=p;return true;
}''')
cases = r'''
int main() {
using Protocol=mesh::RadioProfileProtocol;
using Result=mesh::RadioParamApplyResult;
{
RadioLibWrapper w;
mesh::RadioProfileConfig cfg;cfg.params=w._profiles.primary;
cfg.params.protocol=Protocol::Meshtext;cfg.mode=mesh::RadioProfileMode::RxTx;
w._profiles.setSecondary(cfg,false);
assert(w._profiles.preamble(1,128)==16 && w._profiles.automaticPreambleFits());
assert(w.tuneProfile(1)==Result::APPLIED);
assert(w._physical_protocol==Protocol::Meshtext && w.applied_protocol==Protocol::Meshtext);
assert(w.protocol_writes==1 && w._physical_preamble==16);
assert(w.tuneProfile(0)==Result::APPLIED);
assert(w._physical_protocol==Protocol::MeshCore && w.applied_protocol==Protocol::MeshCore);
assert(w.protocol_writes==2);
const auto generation=w._profiles.generation[1];
cfg.params.protocol=Protocol::MeshCore;w._profiles.setSecondary(cfg,false);
assert(w._profiles.generation[1]==generation+1);
}
{
RadioLibWrapper w;mesh::RadioProfileConfig cfg;cfg.params=w._profiles.primary;
cfg.params.protocol=Protocol::Meshtext;cfg.mode=mesh::RadioProfileMode::RxTx;
w._profiles.setSecondary(cfg,false);w.protocol_fail=true;
assert(w.tuneProfile(1)==Result::FAILED);
assert(w._physical_protocol==Protocol::MeshCore && w.applied_protocol==Protocol::MeshCore);
assert(w._active_profile==0 && w._profile_generation==1);
assert(w.protocol_writes==2);
}
{
RadioLibWrapper w;mesh::RadioProfileConfig cfg;cfg.params=w._profiles.primary;
cfg.params.protocol=Protocol::Meshtext;cfg.mode=mesh::RadioProfileMode::RxTx;
w._profiles.setSecondary(cfg,false);w.failRxStarts=1;
assert(w.tuneProfile(1)==Result::FAILED);
assert(w._physical_protocol==Protocol::MeshCore && w.applied_protocol==Protocol::MeshCore);
assert(w._active_profile==0 && w.isInRecvMode());
}
{
mesh::RadioProfiles p;p.primary.freq=909.5;p.primary.bw=500;p.primary.sf=5;p.primary.cr=5;
p.secondary.params=p.primary;p.secondary.params.protocol=Protocol::Meshtext;
p.secondary.mode=mesh::RadioProfileMode::RxTx;
for (auto cross: {mesh::RadioCrossMode::Off,mesh::RadioCrossMode::On,mesh::RadioCrossMode::Auto}) {
p.cross=cross;assert(!p.canCross());assert(p.transmitMask(0)==1 && p.transmitMask(1)==2);
}
}
}
'''
self.compile_and_run(harness + methods + cases,
['-DMESH_ENABLE_MESHTEXT_CHAT=1', '-DMESH_ENABLE_MESHTEXT_WIRE=1'])
def test_sx1262_wire_settings_and_deep_recovery(self):
wrapper = (ROOT/'src/helpers/radiolib/CustomSX1262Wrapper.h').read_text()
wire = method(wrapper, 'bool applyProtocol(').replace(' override', '')
chip_source = (ROOT/'src/helpers/radiolib/CustomSX1262.h').read_text()
protocol = method(chip_source, 'int16_t setProfileProtocol(')
source = (ROOT/'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
restore = method(source, 'bool RadioLibWrapper::restoreAfterDeepInit()')
harness = r'''
#include <cassert>
#include <cstdint>
#include <RadioProfiles.h>
#include <helpers/radiolib/SX1262ProfileSwitchState.h>
#define RADIOLIB_ERR_NONE 0
#define RADIOLIB_SX126X_LORA_CRC_ON 1
#define RADIOLIB_SX126X_LORA_HEADER_EXPLICIT 0
#define RADIOLIB_SX126X_LORA_IQ_STANDARD 0
#define STATE_IDLE 0
struct SX1262 {
uint8_t sync=0x12;bool crc=false,explicit_header=false,iq=true;
size_t preambleLengthLoRa=32;
uint8_t crcTypeLoRa=1,implicitLen=255,headerType=0,invertIQEnabled=0;
unsigned step=0,fail_at=0;
int status() { return ++step==fail_at ? -1 : 0; }
int setSyncWord(uint8_t n) { sync=n;return status(); }
int setCRC(uint8_t n) { crc=n==1;return status(); }
int explicitHeader() { explicit_header=true;return status(); }
int invertIQ(bool n) { iq=n;return status(); }
int setPreambleLength(uint16_t) { return 0; }
};
struct CustomSX1262: SX1262 {
SX1262ProfileSwitchState _profileSwitch{false};
@PROTOCOL@
};
struct RadioLibWrapper {
CustomSX1262 chip;CustomSX1262* _radio=&chip;
bool supported=true;
bool supportsMeshtext() const { return supported; }
bool _rx_ps_armed=true,_params_valid=true,_dbm_valid=false,_rx_boosted_gain_valid=false;
int state=1;uint8_t _cur_sf=11,_cur_cr=5;float _cur_freq=906.875,_cur_bw=250;
int8_t _cur_dbm=22;bool _cur_rx_boosted_gain=false;
mesh::RadioProfileProtocol _physical_protocol=mesh::RadioProfileProtocol::Meshtext;
bool radioDeepInit() { chip.sync=0x12;chip.step=0;return true; }
bool registerInterruptAction() { return true; }
bool applyParams(float,float,uint8_t,uint8_t) { return true; }
uint16_t currentPreambleLength() { return 16; }
int applyCachedTxPower(int8_t) { return 0; }
bool applyRxBoostedGainMode(bool) { return true; }
void recalibrateNoiseFloor() {}
@WIRE@
bool restoreAfterDeepInit();
};
@RESTORE@
int main() {
using Protocol=mesh::RadioProfileProtocol;
for (Protocol p: {Protocol::MeshCore,Protocol::Meshtext}) {
RadioLibWrapper w;assert(w.applyProtocol(p));
assert(w.chip.sync==(p==Protocol::MeshCore ? 0x12 : 0x2b));
assert(w.chip.crc && w.chip.explicit_header && !w.chip.iq);
}
for (unsigned fail=1;fail<=4;++fail) {
RadioLibWrapper w;w.chip.fail_at=fail;assert(!w.applyProtocol(Protocol::Meshtext));
}
RadioLibWrapper invalid;
assert(!invalid.applyProtocol(static_cast<Protocol>(99)) && invalid.chip.step==0);
RadioLibWrapper w;assert(w.restoreAfterDeepInit());
assert(w.chip.sync==0x2b && w.chip.crc && w.chip.explicit_header && !w.chip.iq);
assert(!w._rx_ps_armed && w.state==STATE_IDLE);
w._physical_protocol=Protocol::MeshCore;assert(w.restoreAfterDeepInit());assert(w.chip.sync==0x12);
RadioLibWrapper disabled;disabled.supported=false;
assert(disabled.applyProtocol(Protocol::MeshCore));
assert(!disabled.applyProtocol(Protocol::Meshtext) && disabled.chip.step==0);
}
'''
self.compile_and_run('#include <initializer_list>\n' + harness.replace('@WIRE@',wire)
.replace('@PROTOCOL@',protocol).replace('@RESTORE@',restore),
['-DMESH_ENABLE_MESHTEXT_CHAT=1', '-DMESH_ENABLE_MESHTEXT_WIRE=1'])
if __name__ == '__main__':
unittest.main()