388 lines
17 KiB
Python
388 lines
17 KiB
Python
"""Execute Companion radio commands with production commit/recovery methods."""
|
|
from pathlib import Path
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
from test_replay_reset_integration import extract_braced
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SOURCE = ROOT / 'examples/companion_radio/MyMesh.cpp'
|
|
|
|
HARNESS = r'''
|
|
#include <cassert>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <deque>
|
|
#include <initializer_list>
|
|
#include <vector>
|
|
#include <helpers/CLICommandUtils.h>
|
|
#define MESH_DEBUG_PRINTLN(...) ((void)0)
|
|
#define MAX_LORA_TX_POWER 22
|
|
#define COMPANION_FEATURE_TEMP_RADIO 0
|
|
#define COMMAND_RADIO_APPLY_TIMEOUT_MS 3000
|
|
@CODES@
|
|
namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; }
|
|
using Apply = mesh::RadioParamApplyResult;
|
|
static uint32_t now_ms = 100;
|
|
struct Clock { uint32_t getMillis() const { return now_ms; } } clock_source;
|
|
struct BaseSerialInterface {
|
|
BaseSerialInterface* current = this;
|
|
BaseSerialInterface* disconnected = nullptr;
|
|
bool busy = false;
|
|
BaseSerialInterface* captureReplyRoute() { return current; }
|
|
bool isReplyRouteAvailable(BaseSerialInterface* route) { return route && route != disconnected; }
|
|
bool isReplyRouteWriteBusy(BaseSerialInterface*) { return busy; }
|
|
};
|
|
struct Prefs {
|
|
float freq=915, bw=250;
|
|
uint8_t sf=7, cr=5, client_repeat=0, rx_ps_level=1, rx_ps_preamble=16;
|
|
uint32_t rx_ps_rx_us=700, rx_ps_sleep_us=25000;
|
|
int8_t tx_power_dbm=3;
|
|
bool rx_boosted_gain=false, rx_powersaving_enabled=false;
|
|
};
|
|
void recalcRxPowerSavingFromLevel(uint8_t,uint8_t sf,float bw,uint8_t,
|
|
uint32_t* rx,uint32_t* sleep) {
|
|
*rx=sf*100; *sleep=uint32_t(bw*100);
|
|
}
|
|
struct Driver {
|
|
int8_t power=3;
|
|
unsigned calls=0;
|
|
std::deque<bool> accepted;
|
|
bool setTxPower(int8_t requested) {
|
|
++calls;
|
|
bool ok=true;
|
|
if (!accepted.empty()) { ok=accepted.front(); accepted.pop_front(); }
|
|
if (ok) power=requested;
|
|
return ok;
|
|
}
|
|
bool supportsRxBoostedGainMode() const { return false; }
|
|
bool setRxBoostedGainMode(bool) { return true; }
|
|
bool supportsRxPowerSaving() const { return false; }
|
|
bool setRxPowerSaving(bool,uint32_t,uint32_t) { return true; }
|
|
} radio_driver;
|
|
@RETRY_DELAY@
|
|
struct MyMesh {
|
|
Prefs _prefs, durable, live;
|
|
BaseSerialInterface serial, original, other;
|
|
BaseSerialInterface* _serial=&serial;
|
|
Clock* _ms=&clock_source;
|
|
bool storage_accepts=true, _radio_available=true, outbound=false;
|
|
unsigned saves=0, applies=0;
|
|
std::deque<Apply> accepted;
|
|
bool saved_radio_apply_pending=false, command_radio_apply_pending=false;
|
|
uint32_t radio_apply_retry_at=0, command_radio_apply_deadline=0;
|
|
uint8_t radio_apply_failures=0;
|
|
float command_radio_freq=0, command_radio_bw=0;
|
|
uint8_t command_radio_sf=0, command_radio_cr=0, command_radio_repeat=0;
|
|
BaseSerialInterface* command_radio_reply_route=nullptr;
|
|
uint8_t cmd_frame[180]={};
|
|
struct Reply { uint8_t error; BaseSerialInterface* route; };
|
|
std::vector<Reply> replies;
|
|
@RESULT_ENUM@;
|
|
MyMesh() { serial.current=&original; radio_driver=Driver(); now_ms=100; }
|
|
uint32_t futureMillis(uint32_t delay) const { return now_ms+delay; }
|
|
bool millisHasNowPassed(uint32_t deadline) const { return int32_t(now_ms-deadline)>=0; }
|
|
bool hasOutbound() const { return outbound; }
|
|
bool isValidClientRepeatFreq(uint32_t) const { return true; }
|
|
bool savePrefs() { ++saves; if(!storage_accepts)return false; durable=_prefs; return true; }
|
|
void writeOKFrame(BaseSerialInterface* route=nullptr) { replies.push_back({0,route?route:serial.current}); }
|
|
void writeErrFrame(uint8_t code,BaseSerialInterface* route=nullptr) { replies.push_back({code,route?route:serial.current}); }
|
|
Apply tryApplyRadioParams(float freq,float bw,uint8_t sf,uint8_t cr) {
|
|
++applies;
|
|
Apply result=Apply::APPLIED;
|
|
if(!accepted.empty()) { result=accepted.front(); accepted.pop_front(); }
|
|
if(result==Apply::APPLIED) { live.freq=freq;live.bw=bw;live.sf=sf;live.cr=cr; }
|
|
return result;
|
|
}
|
|
bool applySavedRadioParams();
|
|
RadioSettingResult applyAndSaveTxPower(int8_t);
|
|
void finishRadioParamApply(float,float,uint8_t,uint8_t,uint8_t,BaseSerialInterface* = nullptr);
|
|
void servicePendingRadioParamApply();
|
|
void cancelPendingRadioParamApply();
|
|
void frame(size_t len) { @FRAME_BRANCHES@ }
|
|
void recover() { @RECOVERY@ }
|
|
bool command(const char* command,char* reply) {
|
|
const size_t reply_capacity=160;
|
|
@CLI_BRANCH@
|
|
return false;
|
|
}
|
|
void powerFrame(uint8_t power,size_t size=2) {
|
|
cmd_frame[0]=CMD_SET_RADIO_TX_POWER;cmd_frame[1]=power;frame(size);
|
|
}
|
|
void tupleFrame() {
|
|
cmd_frame[0]=CMD_SET_RADIO_PARAMS;
|
|
uint32_t freq=920000,bw=125000;
|
|
memcpy(cmd_frame+1,&freq,4);memcpy(cmd_frame+5,&bw,4);
|
|
cmd_frame[9]=9;cmd_frame[10]=6;cmd_frame[11]=1;
|
|
frame(12);
|
|
}
|
|
void expectError(uint8_t code,BaseSerialInterface* route=nullptr) {
|
|
assert(replies.size()==1 && replies[0].error==code);
|
|
assert(replies[0].route==(route?route:&original));
|
|
}
|
|
void expectOriginalPrefs() {
|
|
assert(_prefs.freq==915 && _prefs.bw==250 && _prefs.sf==7 && _prefs.cr==5);
|
|
assert(_prefs.client_repeat==0 && _prefs.rx_ps_rx_us==700 && _prefs.rx_ps_sleep_us==25000);
|
|
assert(durable.freq==915 && durable.bw==250 && durable.sf==7 && durable.cr==5);
|
|
}
|
|
};
|
|
@METHODS@
|
|
int main() {
|
|
// Reject unavailable/busy hardware before either persisting or reporting it.
|
|
{
|
|
MyMesh m;radio_driver.accepted={false};m.powerFrame(17);
|
|
m.expectError(ERR_CODE_BAD_STATE);
|
|
assert(m.saves==0 && m._prefs.tx_power_dbm==3 && radio_driver.power==3);
|
|
}
|
|
for (int value : {-10,23}) {
|
|
MyMesh m;m.powerFrame(uint8_t(value));m.expectError(ERR_CODE_ILLEGAL_ARG);
|
|
assert(m.saves==0 && radio_driver.calls==0);
|
|
}
|
|
{
|
|
MyMesh m;m.powerFrame(17,1);m.expectError(ERR_CODE_ILLEGAL_ARG);
|
|
assert(m.saves==0 && radio_driver.calls==0);
|
|
}
|
|
{
|
|
MyMesh m;m.powerFrame(17);m.expectError(0);
|
|
assert(m.saves==1 && m.durable.tx_power_dbm==17 && radio_driver.power==17);
|
|
}
|
|
{
|
|
MyMesh m;m._radio_available=false;m.powerFrame(17);m.expectError(0);
|
|
assert(m.durable.tx_power_dbm==17 && radio_driver.calls==0);
|
|
}
|
|
// A failed commit restores RAM and the physical output-power setting.
|
|
{
|
|
MyMesh m;m.storage_accepts=false;m.powerFrame(17);m.expectError(ERR_CODE_FILE_IO_ERROR);
|
|
assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3);
|
|
assert(radio_driver.calls==2 && !m.saved_radio_apply_pending);
|
|
}
|
|
// RX may become busy during flash I/O. Retain recovery through a rejected
|
|
// rollback and through a later successful tuple restore but failed power apply.
|
|
{
|
|
MyMesh m;m.storage_accepts=false;radio_driver.accepted={true,false,false,true};
|
|
m.powerFrame(17);m.expectError(ERR_CODE_FILE_IO_ERROR);
|
|
assert(m._prefs.tx_power_dbm==3 && radio_driver.power==17 && m.saved_radio_apply_pending);
|
|
m.recover();assert(m.saved_radio_apply_pending && radio_driver.power==17);
|
|
assert(m.radio_apply_failures==1 && m.radio_apply_retry_at>now_ms);
|
|
unsigned calls=radio_driver.calls;m.recover();assert(radio_driver.calls==calls);
|
|
now_ms=m.radio_apply_retry_at;m.recover();
|
|
assert(!m.saved_radio_apply_pending && radio_driver.power==3 && m.saves==1);
|
|
assert(m.replies.size()==1); // no later success for the failed command
|
|
}
|
|
// ASCII and framed setters share identical hardware/durability semantics.
|
|
{
|
|
MyMesh m;char reply[160]={};radio_driver.accepted={false};
|
|
assert(m.command("set tx 17",reply) && strstr(reply,"Error"));
|
|
assert(m.saves==0 && m._prefs.tx_power_dbm==3);
|
|
m.storage_accepts=false;
|
|
assert(m.command("set tx 17",reply) && strstr(reply,"saved"));
|
|
assert(strstr(reply,"Error") && radio_driver.power==3);
|
|
assert(m.command("set tx 17junk",reply) && strstr(reply,"Error"));
|
|
assert(m.saves==1);
|
|
}
|
|
// Both immediate and deferred tuple changes must commit before OK.
|
|
{
|
|
MyMesh m;m.tupleFrame();m.expectError(0);
|
|
assert(m._prefs.freq==920 && m.durable.freq==920 && m.live.freq==920);
|
|
assert(m.durable.rx_ps_rx_us==900 && m.durable.rx_ps_sleep_us==12500);
|
|
assert(!m.saved_radio_apply_pending);
|
|
}
|
|
{
|
|
MyMesh m;m.storage_accepts=false;m.tupleFrame();m.expectError(ERR_CODE_FILE_IO_ERROR);
|
|
m.expectOriginalPrefs();assert(m.live.freq==915 && !m.saved_radio_apply_pending);
|
|
}
|
|
{
|
|
MyMesh m;m.accepted={Apply::BUSY,Apply::APPLIED};m.tupleFrame();
|
|
assert(m.replies.empty() && m.command_radio_apply_pending && m.saves==0);
|
|
m.serial.current=&m.other;m.servicePendingRadioParamApply();m.expectError(0);
|
|
assert(!m.command_radio_apply_pending && m.durable.freq==920);
|
|
}
|
|
{
|
|
MyMesh m;m.storage_accepts=false;m.accepted={Apply::BUSY,Apply::APPLIED,Apply::BUSY};
|
|
m.tupleFrame();assert(m.replies.empty() && m.command_radio_apply_pending);
|
|
m.serial.current=&m.other;m.servicePendingRadioParamApply();
|
|
m.expectError(ERR_CODE_FILE_IO_ERROR);m.expectOriginalPrefs();
|
|
assert(m.saved_radio_apply_pending && m.live.freq==920 && !m.command_radio_apply_pending);
|
|
m.recover();assert(!m.saved_radio_apply_pending && m.live.freq==915);
|
|
assert(m.saves==1 && m.replies.size()==1);
|
|
}
|
|
// Disconnect cancels a deferred command without persisting its candidate.
|
|
{
|
|
MyMesh m;m.accepted={Apply::BUSY};m.tupleFrame();
|
|
m.serial.disconnected=&m.original;m.servicePendingRadioParamApply();
|
|
assert(!m.command_radio_apply_pending && m.replies.empty() && m.saves==0);
|
|
m.expectOriginalPrefs();
|
|
}
|
|
// Tuple completion must not swallow an outstanding power rollback.
|
|
{
|
|
MyMesh m;m.saved_radio_apply_pending=true;radio_driver.power=17;
|
|
m.tupleFrame();m.expectError(0);assert(m.saved_radio_apply_pending);
|
|
m.recover();assert(!m.saved_radio_apply_pending && radio_driver.power==3);
|
|
assert(m.live.freq==920 && m.durable.freq==920);
|
|
}
|
|
puts("Companion radio-setting commit and recovery regressions passed");
|
|
}
|
|
'''
|
|
|
|
SLEEP_HARNESS = r'''
|
|
#include <cassert>
|
|
#include <cstdint>
|
|
#include <cstddef>
|
|
#include <helpers/CompanionDelayedReplies.h>
|
|
static uint32_t now_ms=100;
|
|
struct Serial {
|
|
bool hasPendingIO() const { return false; }
|
|
bool isConnected() const { return false; }
|
|
bool isReplyRouteAvailable(const Serial* route) const {
|
|
return route==this&&isConnected();
|
|
}
|
|
};
|
|
struct {
|
|
bool isWatchdogObserving() const { return false; }
|
|
bool isCalibratingNoiseFloor() const { return false; }
|
|
} radio_driver;
|
|
struct MyMesh {
|
|
bool _radio_available=true, _iter_started=false, command_radio_apply_pending=false;
|
|
bool saved_radio_apply_pending=false, _terminal_trace_pending=false;
|
|
uint32_t radio_apply_retry_at=0, _scheduled_reboot_at=0;
|
|
uint32_t emergency_client_repeat_send_at=0, dirty_contacts_expiry=0;
|
|
uint8_t dirty_contacts_failures=0;
|
|
mesh::CompanionDelayedReplies _delayed_replies;
|
|
const void *sign_data=nullptr;
|
|
const void *emergency_client_repeat_packet=nullptr;
|
|
Serial* _serial=nullptr;
|
|
Serial* _iter_reply_route=nullptr;
|
|
#if COMPANION_FEATURE_TEMP_RADIO
|
|
bool _temp_radio_applied=false;
|
|
uint32_t _temp_radio_set_at=0, _temp_radio_revert_at=0, _temp_radio_retry_at=0;
|
|
#endif
|
|
bool millisHasNowPassed(uint32_t at) const { return int32_t(now_ms-at)>=0; }
|
|
bool isDualRadioActive() const { return false; }
|
|
bool isContactWriteDue() const { return false; }
|
|
bool hasPendingOtaApply() const { return false; }
|
|
bool hasPendingReqs() const { return _delayed_replies.hasRequest(); }
|
|
bool hasQueuedWorkDue() const { return false; }
|
|
bool hasRetryWorkDue() const { return false; }
|
|
bool hasPendingWork() const;
|
|
};
|
|
@PENDING_WORK@
|
|
int main() {
|
|
MyMesh m;
|
|
assert(!m.hasPendingWork());
|
|
m.saved_radio_apply_pending=true;
|
|
assert(m.hasPendingWork());
|
|
m.radio_apply_retry_at=now_ms+10;
|
|
assert(!m.hasPendingWork());
|
|
now_ms+=10;
|
|
assert(m.hasPendingWork());
|
|
m.radio_apply_retry_at=0;
|
|
#if COMPANION_FEATURE_TEMP_RADIO
|
|
// An already active lease can last hours; a rejected power-save rollback
|
|
// must not keep spinning solely because saved radio restoration is pending.
|
|
m._temp_radio_applied=true;
|
|
m._temp_radio_revert_at=now_ms+3600000;
|
|
assert(!m.hasPendingWork());
|
|
// A future scheduled TempRadio entry also blocks normal-radio restoration.
|
|
m._temp_radio_applied=false;m._temp_radio_revert_at=0;
|
|
m._temp_radio_set_at=now_ms+20;
|
|
assert(!m.hasPendingWork());
|
|
now_ms=m._temp_radio_set_at;
|
|
assert(m.hasPendingWork()); // the entry timer, not the blocked restore
|
|
m._temp_radio_set_at=0;m._temp_radio_applied=true;
|
|
m._temp_radio_revert_at=now_ms+20;
|
|
assert(!m.hasPendingWork());
|
|
now_ms=m._temp_radio_revert_at;
|
|
assert(m.hasPendingWork()); // lease expiry still gets serviced
|
|
m._temp_radio_applied=false;m._temp_radio_revert_at=0;
|
|
assert(m.hasPendingWork()); // rollback is runnable after the lease clears
|
|
#endif
|
|
m.saved_radio_apply_pending=false;
|
|
assert(!m.hasPendingWork());
|
|
// Terminal TRACE has no binary slot but must keep event-driven nodes awake
|
|
// until its independent timeout releases the reserved history entry.
|
|
m._terminal_trace_pending=true;
|
|
assert(m.hasPendingWork()==bool(COMPANION_FEATURE_TEXT_TERMINAL));
|
|
m._terminal_trace_pending=false;
|
|
assert(!m.hasPendingWork());
|
|
}
|
|
'''
|
|
|
|
|
|
class CompanionRadioSettingsTransactionTests(unittest.TestCase):
|
|
def test_lease_blocked_recovery_does_not_prevent_sleep(self):
|
|
text = SOURCE.read_text(encoding='utf-8')
|
|
pending = extract_braced(text, 'bool MyMesh::hasPendingWork() const')
|
|
harness = SLEEP_HARNESS.replace('@PENDING_WORK@', pending)
|
|
with tempfile.TemporaryDirectory(prefix='mesh-companion-radio-sleep-') as tmp:
|
|
cpp = Path(tmp) / 'test.cpp'
|
|
binary = Path(tmp) / ('test.exe' if os.name == 'nt' else 'test')
|
|
cpp.write_text(harness, encoding='utf-8')
|
|
for macros in (['-DCOMPANION_FEATURE_TEMP_RADIO=0'],
|
|
['-DCOMPANION_FEATURE_TEMP_RADIO=1'],
|
|
['-DCOMPANION_FEATURE_TEMP_RADIO=1', '-DNRF52_PLATFORM=1']):
|
|
for terminal in (0, 1):
|
|
with self.subTest(macros=macros, terminal=terminal):
|
|
subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17',
|
|
'-Wall', '-Wextra', '-Werror', *macros,
|
|
'-DCOMPANION_FEATURE_TEXT_TERMINAL=' + str(terminal),
|
|
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
|
|
'-fno-pie', '-no-pie',
|
|
'-isystem', str(ROOT / "test/mocks"), f'-I{ROOT / "src"}',
|
|
str(cpp), str(ROOT / 'src/helpers/CompanionDelayedReplies.cpp'),
|
|
'-o', str(binary)], check=True)
|
|
subprocess.run([str(binary)], check=True)
|
|
|
|
def test_production_commands_commit_and_restore(self):
|
|
text = SOURCE.read_text(encoding='utf-8')
|
|
header = (SOURCE.parent / 'MyMesh.h').read_text(encoding='utf-8')
|
|
codes = '\n'.join(re.findall(
|
|
r'^#define (?:CMD_SET_RADIO_PARAMS|CMD_SET_RADIO_TX_POWER|ERR_CODE_\w+)\s+\d+',
|
|
text, flags=re.MULTILINE))
|
|
methods = '\n'.join(extract_braced(text, signature) for signature in (
|
|
'bool MyMesh::applySavedRadioParams(',
|
|
'MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(',
|
|
'void MyMesh::finishRadioParamApply(',
|
|
'void MyMesh::cancelPendingRadioParamApply(',
|
|
'void MyMesh::servicePendingRadioParamApply(',
|
|
))
|
|
frame_branches = ' else '.join(extract_braced(text, f'if (cmd_frame[0] == {command})')
|
|
for command in ('CMD_SET_RADIO_PARAMS', 'CMD_SET_RADIO_TX_POWER'))
|
|
replacements = {
|
|
'@CODES@': codes,
|
|
'@RESULT_ENUM@': extract_braced(header, 'enum class RadioSettingResult'),
|
|
'@RETRY_DELAY@': extract_braced(text, 'static uint32_t nextRadioApplyRetryDelay('),
|
|
'@METHODS@': methods,
|
|
'@FRAME_BRANCHES@': frame_branches,
|
|
'@CLI_BRANCH@': extract_braced(text, 'if (strncmp(command, "set tx ", 7) == 0)'),
|
|
'@RECOVERY@': extract_braced(text, 'if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound()'),
|
|
}
|
|
harness = HARNESS
|
|
for marker, value in replacements.items():
|
|
harness = harness.replace(marker, value)
|
|
with tempfile.TemporaryDirectory(prefix='mesh-companion-radio-settings-') as tmp:
|
|
cpp = Path(tmp) / 'test.cpp'
|
|
binary = Path(tmp) / ('test.exe' if os.name == 'nt' else 'test')
|
|
cpp.write_text(harness, encoding='utf-8')
|
|
subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', '-Wall', '-Wextra', '-Werror',
|
|
'-Wno-sign-compare', # existing frame parser compares size_t and byte offsets
|
|
'-I', str(ROOT / 'src'), str(cpp), '-o', str(binary)], check=True)
|
|
subprocess.run([str(binary)], check=True)
|
|
|
|
def test_all_tx_power_frontends_use_checked_handler(self):
|
|
text = SOURCE.read_text(encoding='utf-8')
|
|
terminal = extract_braced(text, 'if (strncmp(config, "tx ", 3) == 0)')
|
|
self.assertIn('handleCommand(command, 0, local_reply)', terminal)
|
|
web = extract_braced(text, 'if (strcmp(key, "tx") == 0)')
|
|
self.assertIn('applyAndSaveTxPower(', web)
|
|
self.assertIn('RadioSettingResult::SaveFailed',
|
|
extract_braced(text, 'if (strncmp(command, "set tx ", 7) == 0)'))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|