Files
MeshCore/test/test_fleet_radio_schedules.py
T

444 lines
30 KiB
Python

#!/usr/bin/env python3
"""Run signed fleet commands through the actual primary and secondary schedulers.
The receiver, signed codec, secondary scheduler and storage transactions are
production code; primary CommonCLI branches and repeater methods are extracted
unchanged. Clock, filesystem I/O, physical radio, primary-save endpoint and
packet admission are mocked; signatures, AES and MAC checks remain real.
"""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from cpp_source import body
from test_fleet_channel_runtime import UTILS, FILESYSTEM, MESH, HARNESS as RUNTIME_HARNESS
from test_repeater_radio_timing_integration import HARNESS as PRIMARY_HARNESS
ROOT = Path(__file__).resolve().parents[1]
HARDWARE = r'''
#pragma once
#include <RadioProfiles.h>
namespace mesh {
class RTCClock {public:virtual ~RTCClock()=default;virtual uint32_t getCurrentTime()=0;};
enum class RadioParamApplyResult:uint8_t {APPLIED,BUSY,FAILED};
class Radio {public:
virtual ~Radio()=default;
static constexpr uint32_t CarrierWaveDefaultMillis=10000,CarrierWaveMaxMillis=60000;
virtual bool supportsCarrierWave()const{return false;}
virtual bool isCarrierWaveActive()const{return false;}
virtual uint32_t carrierWaveRemainingMillis()const{return 0;}
virtual uint8_t carrierWaveProfile()const{return 0;}
virtual RadioParamApplyResult setCarrierWave(uint8_t,uint32_t){return RadioParamApplyResult::FAILED;}
virtual bool isInRecvMode()const{return true;}
virtual RadioProfiles* profiles()=0;
virtual const RadioProfiles* profiles()const=0;
virtual bool validateProfile(const RadioProfileParams&)const=0;
virtual uint16_t profilePreamble(uint8_t)const=0;
virtual RadioParamApplyResult trySetPrimaryParams(const RadioProfileParams&,bool,const uint32_t*)=0;
};
}
'''
HARNESS = r'''
struct PhysicalRadio:mesh::Radio {
mesh::RadioProfiles state;
PhysicalRadio(){state.primary.freq=909.5;state.primary.bw=62.5;state.primary.sf=7;state.primary.cr=5;}
mesh::RadioProfiles* profiles()override{return &state;}
const mesh::RadioProfiles* profiles()const override{return &state;}
bool validateProfile(const mesh::RadioProfileParams& p)const override{return mesh::RadioProfiles::valid(p)&&p.bw<=500;}
uint16_t profilePreamble(uint8_t profile)const override{return state.preamble(profile,32);}
mesh::RadioParamApplyResult trySetPrimaryParams(const mesh::RadioProfileParams&,bool,const uint32_t*)override{
assert(false&&"radio2 scheduler must never mutate primary");return mesh::RadioParamApplyResult::FAILED;}
};
static uint32_t stageDirectProfile(RadioProfileCLI& profiles){
const uint32_t before=profiles.replyMutationGeneration();char reply[160]={};
profiles.beginReplyCommand();
assert(profiles.handle("set radio2 912.5,500,8,5,rxtx",reply,sizeof(reply),true));
profiles.endReplyCommand();
assert(!strncmp(reply,"OK",2)&&profiles.hasReplyMutation());
assert(profiles.replyMutationGeneration()!=before);return profiles.replyMutationGeneration();
}
struct Fixture {
fs::FS disk;Mesh mesh;PhysicalRadio radio;RadioProfileCLI profiles;FleetChannel fleet{&disk};LocalIdentity publisher;
unsigned dispatched=0;char reply[160]={};
Fixture(){profiles.begin(&disk,&radio,&mesh.clock,true);enroll(fleet,publisher);bind(fleet,mesh,profiles);}
const char* get(const char* text){assert(profiles.handle(text,reply,sizeof(reply)));return reply;}
Packet send(const char* text,bool mutation_expected=true){
fake_ms+=1000; // Each request occupies a fresh verification-budget interval.
Packet packet=command(publisher,mesh.clock.now,text);fleet.receive(&packet,mesh);
const unsigned before=dispatched;
fleet.service(mesh,profiles,"scheduler",[&](uint32_t sequence,const char* command,char* response){
++dispatched;assert(storage::readLE32(disk.files[Path].data()+56)==sequence);
assert(profiles.handle(command,response,160,true));strcpy(reply,response);
});
assert(dispatched==before+1);assert(!strncmp(reply,"OK",2)||!strncmp(text,"get ",4));
assert(fleet.waiting()==mutation_expected);return packet;
}
void drain(bool success){
while(!mesh.manager.outbound.empty()){
Packet* p=mesh.manager.removeOutboundByIdx(0);
if(success)fleet.complete(p,profiles);else fleet.fail(p,profiles);
}
profiles.loop();
}
void advance(uint32_t ms,bool wall=true){fake_ms+=ms;if(wall)mesh.clock.now+=ms/1000;profiles.loop();}
};
int main(int argc,char** argv){
assert(argc==2);const std::string scenario=argv[1];Fixture f;
if(scenario=="permanent_ack"){
Packet packet=f.send("set radioat2 910.5,500,8,5,rxtx,+1");
assert(!f.radio.state.enabled());f.advance(61000);assert(!f.radio.state.enabled());
assert(f.profiles.hasReplyMutation());f.drain(true);
assert(!f.fleet.waiting()&&!f.profiles.hasReplyMutation());
assert(f.radio.state.secondary.params.freq==910.5f&&!f.radio.state.secondary_temporary);
PhysicalRadio reboot;RadioProfileCLI restored;restored.begin(&f.disk,&reboot,&f.mesh.clock,true);
assert(reboot.state.secondary.params.freq==910.5f);
const unsigned before=f.dispatched;f.fleet.receive(&packet,f.mesh);
f.fleet.service(f.mesh,f.profiles,"node",[&](uint32_t,const char*,char*){++f.dispatched;});
assert(f.dispatched==before);
}else if(scenario=="temporary_ack"){
f.send("set tempradioat2 910.5,500,8,5,rxtx,+1,+2");
f.advance(61000);assert(!f.radio.state.enabled());f.drain(true);
assert(f.radio.state.secondary_temporary&&f.radio.state.secondary.params.freq==910.5f);
f.advance(59000);assert(!f.radio.state.enabled());
}else if(scenario=="ack_fail"){
f.send("set radioat2 910.5,500,8,5,rxtx,+1");f.drain(false);
f.advance(61000);assert(!f.radio.state.enabled());assert(!strcmp(f.get("get radioat2 all"),"> slots: "));
++f.mesh.clock.now;f.send("set tempradioat2 910.5,500,8,5,rxtx,+1,+2");f.drain(false);
f.advance(61000);assert(!f.radio.state.enabled());
}else if(scenario=="queue_fail"){
f.mesh.queue_ok=false;f.send("set radioat2 910.5,500,8,5,rxtx,+1",false);
f.advance(61000);assert(!f.radio.state.enabled());assert(!f.profiles.hasReplyMutation());
}else if(scenario=="ack_timeout"){
f.send("set radioat2 910.5,500,8,5,rxtx,+1");f.advance(301000);
f.fleet.service(f.mesh,f.profiles,"scheduler",[](uint32_t,const char*,char*){assert(false);});
f.profiles.loop();assert(!f.fleet.waiting()&&!f.profiles.hasReplyMutation());
assert(!f.radio.state.enabled()&&f.mesh.manager.outbound.empty());
}else if(scenario=="both_copies"){
f.mesh.fanout=true;f.send("set radioat2 910.5,500,8,5,rxtx,+1");f.advance(61000);
Packet* one=f.mesh.manager.removeOutboundByIdx(0);f.fleet.complete(one,f.profiles);f.profiles.loop();
assert(f.fleet.waiting()&&!f.radio.state.enabled());f.drain(true);
assert(f.radio.state.secondary.params.freq==910.5f);
}else if(scenario=="expired_lease"){
f.send("set tempradioat2 910.5,500,8,5,rxtx,+1,+2");
f.advance(121000);assert(!f.radio.state.enabled());f.drain(true);
assert(!f.radio.state.enabled());assert(!strcmp(f.get("get tempradioat2 all"),"> slots: "));
}else if(scenario=="save_fail"){
f.send("set radioat2 910.5,500,8,5,rxtx,+1");f.drain(true);f.disk.fail_write=true;
f.advance(61000);assert(!f.radio.state.enabled());assert(strstr(f.get("get radioat2 all"),"1@"));
f.disk.fail_write=false;f.advance(60000);assert(f.radio.state.secondary.params.freq==910.5f);
}else if(scenario=="delete_ack"){
assert(f.profiles.handle("set tempradioat2 910.5,500,8,5,rxtx,+1,+3",f.reply,160));
f.advance(61000);assert(f.radio.state.secondary_temporary);
f.send("del tempradioat2 1");assert(f.radio.state.secondary_temporary);
f.drain(false);assert(f.radio.state.secondary_temporary);
++f.mesh.clock.now;f.send("del tempradioat2 all");f.drain(true);
assert(!f.radio.state.enabled());
++f.mesh.clock.now;f.send("set radioat2 911.5,500,8,5,rxtx,+1");f.drain(true);
++f.mesh.clock.now;f.send("del radioat2 all");f.drain(false);
assert(strstr(f.get("get radioat2 all"),"1@"));
++f.mesh.clock.now;f.send("del radioat2 1");f.drain(true);
assert(!strcmp(f.get("get radioat2 all"),"> slots: "));
}else if(scenario=="unsigned_no_schedule"){
Packet packet=command(f.publisher,f.mesh.clock.now,"set radioat2 910.5,500,8,5,rxtx,+1");
packet.payload[1]^=1;f.fleet.receive(&packet,f.mesh);
f.fleet.service(f.mesh,f.profiles,"node",[&](uint32_t,const char*,char*){assert(false);});
f.advance(61000);assert(!f.radio.state.enabled());assert(f.disk.files.count("/radio_profiles")==0);
}else if(scenario=="bad_signature_no_schedule"){
Packet packet=command(f.publisher,f.mesh.clock.now,"set radioat2 910.5,500,8,5,rxtx,+1");
uint8_t clear[184];const int size=Utils::MACThenDecrypt(channel_key,clear,packet.payload+1,packet.payload_len-1);
assert(size>0);clear[3+clear[2]-1]^=1;packet=raw(clear,size);
const auto before=f.disk.files;f.fleet.receive(&packet,f.mesh);
f.fleet.service(f.mesh,f.profiles,"node",[&](uint32_t,const char*,char*){assert(false);});
assert(verify_calls&&f.disk.files==before);f.advance(61000);assert(!f.radio.state.enabled());
}else if(scenario=="clock_backwards"){
f.send("set tempradioat2 910.5,500,8,5,rxtx,+1,+2");f.drain(true);
f.advance(61000);assert(f.radio.state.secondary_temporary);f.mesh.clock.now-=3600;
f.advance(59000,false);assert(!f.radio.state.enabled());
}else if(scenario=="get_slots"){
f.send("set radioat2 910.5,500,8,5,rxtx,+1");f.drain(true);
++f.mesh.clock.now;f.send("get radioat2 all",false);assert(strstr(f.reply,"1@"));
++f.mesh.clock.now;f.send("get radioat2 1",false);assert(strstr(f.reply,"910.5"));
}else if(scenario=="replaced_secondary_ack"||scenario=="replaced_secondary_fail"||scenario=="replaced_secondary_timeout"){
f.send("set radioat2 910.5,500,8,5,rxtx,+1");
// Local primary recovery cancels the old secondary transition. A newer
// direct admin command then stages an independent, separately owned reply.
assert(f.profiles.finishReplyMutation(false));const uint32_t generation=stageDirectProfile(f.profiles);
if(scenario=="replaced_secondary_timeout"){
f.advance(301000);f.fleet.service(f.mesh,f.profiles,"node",[](uint32_t,const char*,char*){assert(false);});
}else f.drain(scenario=="replaced_secondary_ack");
f.profiles.loop();assert(!f.fleet.waiting()&&f.profiles.hasReplyMutation());
assert(f.profiles.replyMutationGeneration()==generation&&!f.radio.state.enabled());
assert(f.disk.files.count("/radio_profiles")==0);
assert(f.profiles.finishReplyMutation(true));f.profiles.loop();
assert(!f.profiles.hasReplyMutation()&&f.radio.state.secondary.params.freq==912.5f);
}else assert(false);
printf("fleet scheduled radio %s passed\n",argv[1]);
}
'''
PRIMARY_EXTRA = r'''
struct PrimaryFixture {
fs::FS disk;Mesh mesh;PhysicalRadio physical;RadioProfileCLI profiles;MyMesh node;
FleetChannel fleet{&disk,&node};LocalIdentity publisher;unsigned dispatched=0;char reply[160]={};
PrimaryFixture(){fake_ms=1000;node.rtc.now=mesh.clock.now;profiles.begin(&disk,&physical,&mesh.clock,true);
enroll(fleet,publisher);bind(fleet,mesh,profiles);}
void handle(const char* text,char* reply){
if(!strncmp(text,"set ",4)){
const char* config=text+4;RTC* _rtc=&node.rtc;MyMesh* _callbacks=&node;
RadioProfileCLI& _radio_profiles=profiles;RadioPrefs* _prefs=&node._prefs;
auto appendRxPowerSavingAdjustmentNote=[](char*,RadioPrefs*,uint8_t,float){};
if(false){
@PRIMARY_SET@
}else assert(false);
}else if(!strncmp(text,"del tempradioat",15))node.deleteScheduledRadioParams(true,text+15,reply);
else if(!strncmp(text,"del radioat",11))node.deleteScheduledRadioParams(false,text+11,reply);
else if(!strncmp(text,"get tempradioat",15))node.formatScheduledRadioParams(true,text+15,reply);
else if(!strncmp(text,"get radioat",11))node.formatScheduledRadioParams(false,text+11,reply);
else assert(false);
}
void send(const char* text,bool mutation_expected=true){
fake_ms+=1000;Packet packet=command(publisher,mesh.clock.now,text);fleet.receive(&packet,mesh);
const unsigned before=dispatched;
fleet.service(mesh,profiles,"primary",[&](uint32_t seq,const char* text,char* response){
++dispatched;assert(storage::readLE32(disk.files[Path].data()+56)==seq);
assert(node.fleet_command_);handle(text,response);strcpy(reply,response);
});
assert(dispatched==before+1&&!node.fleet_command_);assert(!strncmp(reply,"OK",2)||!strncmp(text,"get ",4));
assert(fleet.waiting()==mutation_expected);
}
void advance(uint32_t seconds,bool wall=true){
fake_ms+=seconds*1000;node.uptime_millis+=uint64_t(seconds)*1000;node.last_millis=fake_ms;
if(wall){node.rtc.now+=seconds;mesh.clock.now+=seconds;}node.processScheduledRadioSettings();
}
void drain(bool success){
while(!mesh.manager.outbound.empty()){
Packet* p=mesh.manager.removeOutboundByIdx(0);
if(success)fleet.complete(p,profiles);else fleet.fail(p,profiles);
}
}
};
static int primary(int argc,char** argv){
assert(argc==2);const std::string scenario=argv[1];PrimaryFixture f;
if(scenario=="primary_permanent"){
f.send("set radioat 910.5,500,8,5,+1");assert(f.node.countScheduledRadioSettings(false)==0);
f.advance(61);assert(f.node.saves==0&&f.node.restores==0);f.drain(true);
assert(f.node.saves==0&&f.node.restores==0);f.node.processScheduledRadioSettings();
assert(f.node._prefs.freq==910.5f&&f.node.saves==1&&f.node.restores==1);
}else if(scenario=="primary_both_copies"){
f.mesh.fanout=true;f.send("set tempradioat 910.5,500,8,5,+1,+2");f.advance(61);
Packet* one=f.mesh.manager.removeOutboundByIdx(0);f.fleet.complete(one,f.profiles);
f.node.processScheduledRadioSettings();assert(!f.node.temp_radio_applied&&f.fleet.waiting());
f.drain(true);assert(!f.node.temp_radio_applied);f.node.processScheduledRadioSettings();
assert(f.node.temp_radio_applied&&f.node.applies==1);f.advance(59);assert(!f.node.temp_radio_applied);
}else if(scenario=="primary_queue_failure"){
f.mesh.queue_ok=false;f.send("set radioat 910.5,500,8,5,+1",false);
assert(!f.node.hasFleetReplyMutation());f.advance(61);assert(f.node.saves==0&&f.node.restores==0);
}else if(scenario=="primary_tx_failure"){
f.send("set tempradioat 910.5,500,8,5,+1,+2");f.drain(false);f.advance(61);
assert(!f.node.temp_radio_applied&&f.node.countScheduledRadioSettings(true)==0);
}else if(scenario=="primary_ack_timeout"){
f.send("set radioat 910.5,500,8,5,+1");f.advance(301);
f.fleet.service(f.mesh,f.profiles,"primary",[](uint32_t,const char*,char*){assert(false);});
f.node.processScheduledRadioSettings();assert(!f.fleet.waiting()&&!f.node.hasFleetReplyMutation());
assert(f.node.saves==0&&f.node.restores==0&&f.mesh.manager.outbound.empty());
}else if(scenario=="primary_expired_window"){
f.send("set tempradioat 910.5,500,8,5,+1,+2");f.advance(121);f.drain(true);
f.node.processScheduledRadioSettings();assert(!f.node.temp_radio_applied&&f.node.applies==0);
}else if(scenario=="primary_local_busy"){
f.send("set radioat 910.5,500,8,5,+1");
f.handle("set radioat 911.5,500,8,5,+2",f.reply);
assert(strstr(f.reply,"acknowledgement pending")&&f.node.countScheduledRadioSettings(false)==0);
f.handle("del radioat all",f.reply);assert(strstr(f.reply,"acknowledgement pending"));f.drain(false);
f.handle("set radioat 911.5,500,8,5,+2",f.reply);assert(!strncmp(f.reply,"OK",2));
assert(f.node.countScheduledRadioSettings(false)==1);
}else if(scenario=="primary_active_delete"){
f.handle("set tempradioat 910.5,500,8,5,+1,+3",f.reply);f.advance(61);assert(f.node.temp_radio_applied);
f.send("del tempradioat all");f.drain(false);f.node.processScheduledRadioSettings();assert(f.node.temp_radio_applied);
++f.mesh.clock.now;++f.node.rtc.now;f.send("del tempradioat 1");f.drain(true);
assert(f.node.temp_radio_applied);f.node.processScheduledRadioSettings();assert(!f.node.temp_radio_applied);
}else if(scenario=="primary_expiry_during_pending"){
f.handle("set tempradioat 910.5,500,8,5,+1,+2",f.reply);f.advance(61);assert(f.node.temp_radio_applied);
f.send("set radioat 911.5,500,8,5,+2");f.advance(60);
assert(!f.node.temp_radio_applied&&f.node._prefs.freq==909.5f&&f.fleet.waiting());f.drain(false);
}else if(scenario=="primary_save_failure"){
f.send("set radioat 910.5,500,8,5,+1");f.drain(true);f.node._cli.save_success=false;f.advance(61);
assert(f.node._prefs.freq==909.5f&&f.node.restores==0);f.node._cli.save_success=true;
f.advance(61);assert(f.node._prefs.freq==910.5f&&f.node.restores==1);
}else if(scenario=="primary_get_slots"){
f.send("set radioat 910.5,500,8,5,+1");f.drain(true);f.node.processScheduledRadioSettings();
++f.mesh.clock.now;++f.node.rtc.now;f.send("get radioat all",false);assert(strstr(f.reply,"910.5"));f.drain(true);
++f.mesh.clock.now;++f.node.rtc.now;f.send("get radioat 1",false);assert(strstr(f.reply,"910.5"));
assert(f.node.countScheduledRadioSettings(false)==1&&f.node.saves==0);
}else if(scenario=="primary_bad_signature"){
Packet packet=command(f.publisher,f.mesh.clock.now,"set radioat 910.5,500,8,5,+1");
uint8_t clear[184];const int length=Utils::MACThenDecrypt(channel_key,clear,packet.payload+1,packet.payload_len-1);
assert(length>0);clear[3+clear[2]-1]^=1;packet=raw(clear,length);const auto before=f.disk.files;
f.fleet.receive(&packet,f.mesh);f.fleet.service(f.mesh,f.profiles,"primary",[&](uint32_t,const char*,char*){assert(false);});
assert(verify_calls&&f.disk.files==before&&!f.node.fleet_command_&&!f.node.hasFleetReplyMutation());
f.advance(61);assert(f.node.saves==0&&f.node.restores==0);
}else if(scenario=="primary_recovery_supersedes_delete"){
f.handle("set tempradioat 910.5,500,8,5,+1,+3",f.reply);f.advance(61);
f.send("del tempradioat all");f.node.applyTempRadioParams(911.5f,500,8,5,20);
f.drain(true);f.node.processScheduledRadioSettings();
assert(f.node.countScheduledRadioSettings(true)==1);
assert(f.node.scheduled_radio_settings[0].freq==911.5f);
f.advance(3);assert(f.node.temp_radio_applied&&f.node.applies==2);
}else if(scenario=="primary_recovery_supersedes_add"){
f.send("set tempradioat 910.5,500,8,5,+1,+2");assert(f.node.scheduleNormalRadio());
f.drain(true);f.node.processScheduledRadioSettings();
assert(f.node.countScheduledRadioSettings(true)==0);f.advance(61);assert(!f.node.temp_radio_applied);
}else if(scenario=="primary_ack_ignores_secondary"||scenario=="primary_fail_ignores_secondary"||scenario=="primary_timeout_ignores_secondary"){
f.send("set radioat 910.5,500,8,5,+1");const uint32_t generation=stageDirectProfile(f.profiles);
if(scenario=="primary_timeout_ignores_secondary"){
f.advance(301);f.fleet.service(f.mesh,f.profiles,"primary",[](uint32_t,const char*,char*){assert(false);});
}else f.drain(scenario=="primary_ack_ignores_secondary");
f.node.processScheduledRadioSettings();f.profiles.loop();
assert(!f.fleet.waiting()&&f.profiles.hasReplyMutation());
assert(f.profiles.replyMutationGeneration()==generation&&!f.physical.state.enabled());
assert(f.disk.files.count("/radio_profiles")==0);
assert(f.profiles.finishReplyMutation(true));f.profiles.loop();
assert(!f.profiles.hasReplyMutation()&&f.physical.state.secondary.params.freq==912.5f);
}else assert(false);
printf("fleet primary scheduled radio %s passed\n",argv[1]);return 0;
}
'''
class FleetRadioSchedulesTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler=shutil.which("g++") or shutil.which("clang++")
if not compiler:raise unittest.SkipTest("host C++ compiler required")
cls.work=tempfile.TemporaryDirectory(prefix="fleet-radio-schedules-");work=Path(cls.work.name)
mesh=MESH.replace('#include <Identity.h>','#include <Hardware.h>\n#include <Identity.h>')
mesh=mesh.replace('struct Clock {','struct Clock :RTCClock {').replace('uint32_t getCurrentTime() const{return now;}','uint32_t getCurrentTime()override{return now;}')
utils=UTILS.replace('class Utils { public:', 'class Utils { public:\n static int parseTextParts(char*,const char*[],int,char);')
for name,content in {"Utils.h":utils,"FS.h":FILESYSTEM,"Mesh.h":mesh,"Hardware.h":HARDWARE,
"Dispatcher.h":'#pragma once\n#include <Mesh.h>\n',
"Packet.h":'#pragma once\n#include <Mesh.h>\n',
"Arduino.h":'#pragma once\n#include <Mesh.h>\n#include <cstdlib>\n'}.items():
(work/name).write_text(content)
# Preserve production transport-key MAC algorithms with host Crypto void* parity.
sha=ROOT/"test/mocks/SHA256.h"
(work/"SHA256.h").write_text(f'''#pragma once\n#define SHA256 SHA256Base\n#include "{sha}"\n#undef SHA256
class SHA256:public SHA256Base{{public:
void finalize(void* out,size_t n){{SHA256Base::finalize(static_cast<uint8_t*>(out),n);}}
void finalizeHMAC(const uint8_t* key,size_t size,void* out,size_t n){{SHA256Base::finalizeHMAC(key,size,static_cast<uint8_t*>(out),n);}}
}};''')
transport=(ROOT/"src/helpers/TransportKeyStore.cpp").read_text()
methods="\n".join(body(transport,sig) for sig in ["uint16_t TransportKey::calcTransportCode(","bool TransportKey::isNull("])
prefix=RUNTIME_HARNESS[:RUNTIME_HARNESS.index('static unsigned apply(')]
source=(ROOT/"examples/simple_repeater/MyMesh.cpp").read_text()
primary=PRIMARY_HARNESS[:PRIMARY_HARNESS.index('static constexpr uint32_t HOUR')]
primary=primary.replace('static uint32_t now_ms = 1;\nuint32_t millis() { return now_ms; }','#define now_ms fake_ms')
primary=primary.replace('struct Packet {};','').replace('class MyMesh {','class MyMesh :public FleetReplyHooks {')
primary=primary.replace('struct Radio { uint32_t last_rx = 0; uint32_t getLastRecvMillis() { return last_rx; } };',
'struct PrimaryPHY { uint32_t last_rx=0; mesh::RadioProfiles state; uint32_t getLastRecvMillis(){return last_rx;} mesh::RadioProfiles* profiles(){return &state;} };')
primary=primary.replace('Radio radio; Radio* _radio','PrimaryPHY radio; PrimaryPHY* _radio')
primary=primary.replace(' MyMesh() {',r'''
ScheduledRadioSetting fleet_schedule_setting_{};
uint8_t fleet_schedule_delete_mask_[(MAX_SCHEDULED_RADIO_SETTINGS+7)/8]{};
int fleet_schedule_slot_=-1;
bool fleet_command_=false,fleet_schedule_pending_=false,fleet_schedule_delivered_=false,fleet_schedule_delete_all_temp_=false;
void beginFleetCommand()override{fleet_command_=true;}
void endFleetCommand()override{fleet_command_=false;}
bool hasFleetReplyMutation()const override{return fleet_schedule_pending_;}
void finishFleetReplyMutation(bool)override;
void serviceFleetScheduleReply();
int findScheduledRadioSettingByIndex(bool,int)const;
void deleteScheduledRadioParams(bool,const char*,char*);
void formatScheduledRadioParams(bool,const char*,char*);
void formatScheduledRadioSetting(char*,int,int)const;
void formatRadioParamTuple(char*,size_t,const ScheduledRadioSetting&)const;
MyMesh() {''')
signatures=[
'static const char* skipLocalSpaces(', 'static bool selectorIsEmpty(',
'static bool selectorIsAll(', 'static bool parsePositiveSelector(', 'static void formatFixed3(',
'void MyMesh::checkRxInactivityWatchdog()', 'void MyMesh::setTempRadioTiming(',
'void MyMesh::updateAdvertTimer()', 'void MyMesh::updateFloodAdvertTimer()',
'void MyMesh::queueSavedRadioApply()', 'bool MyMesh::applySavedRadioParams()',
'void MyMesh::refreshScheduledRadioState()', 'void MyMesh::processScheduledRadioSettings()',
'void MyMesh::applyTempRadioParams(', 'bool MyMesh::scheduleNormalRadio()',
'void MyMesh::clearScheduledRadioSetting(', 'int MyMesh::findFreeScheduledRadioSlot() const',
'int MyMesh::countScheduledRadioSettings(', 'bool MyMesh::scheduledRadioConflicts(',
'void MyMesh::addScheduledRadioParams(', 'int MyMesh::findScheduledRadioSettingByIndex(',
'void MyMesh::deleteScheduledRadioParams(', 'void MyMesh::formatScheduledRadioParams(',
'void MyMesh::formatScheduledRadioSetting(', 'void MyMesh::formatRadioParamTuple(',
'void MyMesh::finishFleetReplyMutation(', 'void MyMesh::serviceFleetScheduleReply(',
]
primary=primary.replace('@METHODS@','\n'.join(body(source,sig) for sig in signatures))
cli=(ROOT/'src/helpers/CommonCLI.cpp').read_text()
parsers='\n'.join(body(cli,sig) for sig in [
'static bool looksUnsignedInteger(', 'static const char* skipSpacesConst(',
'static bool parseUint32Strict(', 'static bool bwMatches(', 'static bool isValidLoRaBandwidth(',
'static int countSeparatedParts(', 'static bool parseScheduledRadioArgs(',
])
parts=body((ROOT/'src/Utils.cpp').read_text(),'int Utils::parseTextParts(')
formatting='''\nstruct StrHelper {
static void strncpy(char* dst,const char* src,size_t n){if(n){size_t len=strlen(src);if(len>=n)len=n-1;memcpy(dst,src,len);dst[len]=0;}}
static const char* ftoa3(float value){static char text[32];snprintf(text,sizeof(text),"%.3f",double(value));return text;}
};
uint16_t rxPowerSavingPreambleForParams(uint8_t,float){return 32;}
'''
parsing='\n#include <helpers/CLICommandUtils.h>\nnamespace mesh {\n'+parts+'\n}\n'+parsers
start=cli.index(' } else if (memcmp(config, "radioat ", 8) == 0)')
end=cli.index(' } else if (memcmp(config, "lat ", 4) == 0)',start)
primary_extra=PRIMARY_EXTRA.replace('@PRIMARY_SET@',cli[start:end])
physical,secondary=HARNESS.split('struct Fixture {',1)
secondary=('struct Fixture {'+secondary).replace(' assert(argc==2);const std::string scenario=argv[1];Fixture f;',
' assert(argc==2);if(!strncmp(argv[1],"primary_",8))return primary(argc,argv);const std::string scenario=argv[1];Fixture f;')
fixture=work/"fixture.cpp";fixture.write_text('#define MESH_ENABLE_FLEET_CONTROL 1\n'+
prefix.replace("@TRANSPORT_METHODS@",methods)+physical+formatting+primary+parsing+primary_extra+secondary)
cls.binary=work/"schedule-test"
sanitizer=["-fsanitize=address,undefined","-fno-omit-frame-pointer","-no-pie"] if os.environ.get("MESHCORE_FLEET_SANITIZERS")=="1" else []
built=subprocess.run([compiler,"-std=c++17","-O2","-g","-DRP2040_PLATFORM",*sanitizer,
"-Wall","-Wextra","-Werror","-Wno-unused-function","-Wno-unused-parameter","-Wno-sign-compare","-Wno-misleading-indentation",
*(["-Wno-format-truncation"] if "g++" in compiler else []),
"-I",str(work),"-isystem",str(ROOT/"test/mocks"),"-I",str(ROOT/"src"),str(fixture),
str(ROOT/"src/helpers/FleetCommand.cpp"),str(ROOT/"src/helpers/FleetChannel.cpp"),
str(ROOT/"src/helpers/RadioProfileCLI.cpp"),"-lcrypto","-o",str(cls.binary)],capture_output=True,text=True,timeout=60)
if built.returncode:cls.work.cleanup();raise AssertionError(built.stdout+built.stderr)
@classmethod
def tearDownClass(cls):cls.work.cleanup()
def scenario(self,name):
result=subprocess.run([str(self.binary),name],capture_output=True,text=True,timeout=10)
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
def test_permanent_schedule_waits_for_ack_then_persists_and_rejects_replay(self):self.scenario("permanent_ack")
def test_temporary_schedule_waits_for_ack_and_restores_at_original_end(self):self.scenario("temporary_ack")
def test_failed_ack_does_not_add_any_schedule(self):self.scenario("ack_fail")
def test_failed_queue_does_not_add_a_schedule(self):self.scenario("queue_fail")
def test_secondary_ack_timeout_removes_packet_and_cancels_schedule(self):self.scenario("ack_timeout")
def test_secondary_schedule_waits_for_both_physical_ack_copies(self):self.scenario("both_copies")
def test_delayed_ack_cannot_resurrect_an_expired_temporary_lease(self):self.scenario("expired_lease")
def test_due_permanent_schedule_keeps_old_radio_until_atomic_save_succeeds(self):self.scenario("save_fail")
def test_delete_active_or_future_schedule_is_delayed_until_ack_and_cancelled_on_failure(self):self.scenario("delete_ack")
def test_invalid_mac_never_reaches_actual_scheduler(self):self.scenario("unsigned_no_schedule")
def test_valid_mac_invalid_signature_cannot_reserve_or_schedule(self):self.scenario("bad_signature_no_schedule")
def test_backward_clock_correction_cannot_extend_temporary_lease(self):self.scenario("clock_backwards")
def test_get_all_and_individual_slot_reply_keeps_schedule_and_needs_no_barrier(self):self.scenario("get_slots")
def test_primary_permanent_schedule_runs_only_after_ack_and_deferred_loop(self):self.scenario("primary_permanent")
def test_primary_waits_for_both_physical_ack_copies_and_keeps_original_lease_end(self):self.scenario("primary_both_copies")
def test_primary_queue_failure_cancels_staged_schedule(self):self.scenario("primary_queue_failure")
def test_primary_failed_tx_cancels_staged_schedule(self):self.scenario("primary_tx_failure")
def test_primary_ack_timeout_removes_packet_and_cancels_schedule(self):self.scenario("primary_ack_timeout")
def test_primary_expired_window_is_discarded_after_late_ack(self):self.scenario("primary_expired_window")
def test_primary_local_schedule_edits_are_busy_until_fleet_ack_finishes(self):self.scenario("primary_local_busy")
def test_primary_active_delete_waits_for_ack_and_deferred_loop(self):self.scenario("primary_active_delete")
def test_primary_older_lease_expires_even_while_fleet_ack_is_pending(self):self.scenario("primary_expiry_during_pending")
def test_primary_permanent_radio_keeps_old_tuple_when_save_fails(self):self.scenario("primary_save_failure")
def test_primary_get_list_and_slot_do_not_mutate_schedule(self):self.scenario("primary_get_slots")
def test_primary_signed_handler_never_runs_for_forged_signature(self):self.scenario("primary_bad_signature")
def test_primary_direct_recovery_is_not_deleted_by_an_older_fleet_ack(self):self.scenario("primary_recovery_supersedes_delete")
def test_primary_return_to_saved_radio_cancels_older_pending_fleet_schedule(self):self.scenario("primary_recovery_supersedes_add")
def test_canceled_secondary_fleet_ack_does_not_release_new_direct_admin_mutation(self):self.scenario("replaced_secondary_ack")
def test_canceled_secondary_fleet_failure_does_not_cancel_new_direct_admin_mutation(self):self.scenario("replaced_secondary_fail")
def test_canceled_secondary_fleet_timeout_does_not_cancel_new_direct_admin_mutation(self):self.scenario("replaced_secondary_timeout")
def test_primary_only_fleet_ack_does_not_release_new_direct_secondary_mutation(self):self.scenario("primary_ack_ignores_secondary")
def test_primary_only_fleet_failure_does_not_cancel_new_direct_secondary_mutation(self):self.scenario("primary_fail_ignores_secondary")
def test_primary_only_fleet_timeout_does_not_cancel_new_direct_secondary_mutation(self):self.scenario("primary_timeout_ignores_secondary")
if __name__=="__main__":unittest.main()