Files
MeshCore/test/test_radio_receive_retry.py
T

214 lines
13 KiB
Python

#!/usr/bin/env python3
"""Run failed RX starts through actual Dispatcher and radio lifecycle methods."""
from pathlib import Path
import os
import re
import subprocess
import tempfile
import unittest
from test_radio_receive_contract import HARNESS, ROOT, method
class RadioReceiveRetryTests(unittest.TestCase):
def build(self, folder, mutation=None, soft_only=False):
source = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
header = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.h').read_text()
dispatcher = (ROOT / 'src/Dispatcher.h').read_text()
rxps = (ROOT / 'src/helpers/radiolib/RXPowerSaving.h').read_text()
if mutation == 'unpaced':
source = source.replace(' if (_rx_retry_pending) {', ' if (false) {')
if mutation == 'lost_restore':
body = method(source, 'void RadioLibWrapper::endNoiseFloorCalib(')
self.assertEqual(body.count(' _rx_hold_continuous = true;'), 1)
source = source.replace(body, body.replace(' _rx_hold_continuous = true;', ''))
if mutation == 'duplicate_restore':
body = method(source, 'void RadioLibWrapper::finishReceiveProcessing(')
self.assertEqual(body.count(' || _rx_ps_armed'), 1)
source = source.replace(body, body.replace(' || _rx_ps_armed', ''))
if mutation == 'restore_sleep':
body = method(source, 'bool RadioLibWrapper::getReceiveRetryDelay(')
self.assertEqual(body.count('state == STATE_RX && _rx_hold_continuous'), 1)
source = source.replace(body, body.replace(
'state == STATE_RX && _rx_hold_continuous', 'false && _rx_hold_continuous'))
if mutation == 'recovery_admission':
body = method(source, 'bool RadioLibWrapper::recoverRadio(')
self.assertEqual(body.count('return resetAGCIfIdle();'), 3)
source = source.replace(body, body.replace(
'return resetAGCIfIdle();', 'resetAGC(); return true;'))
getter = method(header, 'bool isCalibratingNoiseFloor() const')
if mutation == 'pending_veto':
getter = getter.replace('isInRecvMode() && !_rx_ps_armed && ', '')
names = [('int16_t', 'performChannelScanWithTimeout'),
('bool', 'isPacketPendingOrReceiving'), ('bool', 'isChannelActive'),
('bool', 'isReceivingPassive'), ('bool', 'setRxPowerSaving'),
('bool', 'setRxBoostedGainMode'), ('void', 'startRecv'),
('void', 'checkReceiveMode'), ('void', 'loop'),
('void', 'triggerNoiseFloorCalibrate'), ('void', 'requestRestartRecv'),
('void', 'noiseFloorCalibCheck'), ('void', 'endNoiseFloorCalib'),
('void', 'requestNoiseFloorRefresh'), ('void', 'requestPeriodicNoiseFloorRefresh'),
('void', 'recalibrateNoiseFloor'), ('bool', 'resetAGCIfIdle'),
('bool', 'tryResetAGC'), ('void', 'resetAGC'), ('int', 'recvRaw'),
('void', 'finishReceiveProcessing'),
('void', 'onSendFinished'), ('void', 'begin'),
('bool', 'getNoiseFloorWakeDelay'),
('bool', 'getReceiveRetryDelay'), ('void', 'rxPsWatchdogCheck')]
methods = method(rxps, 'inline bool rxPowerSavingUsesContinuousFallback(') + '\n'
methods += '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(')
for kind, name in names)
constants = '\n'.join(re.findall(r'^#define NF_\w+\s+[^\n]+', source, re.MULTILINE))
constants += '\n' + re.search(
r'^#define RX_POWERSAVING_CONTINUOUS_FALLBACK_US\s+[^\n]+', rxps, re.MULTILINE).group(0)
start = header.index('#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LLCC68)')
settle = header[start:header.index('#endif', start) + len('#endif')]
harness = HARNESS[:HARNESS.index('int main() {')]
harness = harness.replace('#include <RadioProfiles.h>',
'#include <Dispatcher.h>\n#include <helpers/StaticPoolPacketManager.h>\n'
'#include <helpers/PowerManagementUtils.h>\n#define BD_STARTUP_RX_PACKET 1')
harness = harness.replace('struct RadioLibWrapper {', 'struct RadioLibWrapper : mesh::Radio {')
harness = harness.replace('Radio radio{&events}; Radio* _radio',
'::Radio radio{&events}; ::Radio* _radio')
harness = harness.replace('unsigned long getEstAirtimeFor(int)', 'uint32_t getEstAirtimeFor(int)')
harness = harness.replace('void rxPsWatchdogCheck() {}', 'void rxPsWatchdogCheck();')
harness = harness.replace('bool isChipBusy() { return busy; }',
'bool isChipBusy() { ++busy_probes; return busy; }')
harness = harness.replace('bool isReceivingPacket() { return packet; }',
'''bool isReceivingPacket() {
if (++packet_probes == inject_packet_irq_at) events.record();
return packet;
}''')
harness = harness.replace(' void serviceWatchdog()',
' unsigned after = 0;\n int getStartupReason() { return 0; }\n'
' void onAfterTransmit() { ++after; }\n void serviceWatchdog()')
harness = harness.replace(' int startChannelScan()', '''
unsigned packet_length = 0, direct_arms = 0, fifo_reads = 0;
uint8_t fifo[8] = {0x42,0x42,0x42,0x42,0x42,0x42,0x42,0x42};
int startReceive() { ++direct_arms; return start_result; }
int setPreambleLength(uint16_t) { return 0; }
int finishTransmit() { return 0; }
size_t getPacketLength() { return packet_length; }
float getSNR() { return 0; }
float getRSSI() { return 0; }
int readData(uint8_t* out, int len) {
++fifo_reads;
for (int i=0;i<len;++i) out[i]=fifo[i % sizeof(fifo)];
return 0;
}
int startChannelScan()''')
harness = harness.replace('return events.pending();', 'return _loop_event_pending || events.pending();')
harness = harness.replace('bool claimRadioInterrupt() { return events.claim(); }', '''
bool claimRadioInterrupt() {
const bool pending = _loop_event_pending; _loop_event_pending = false;
return events.claim() || pending;
}''')
harness = harness.replace(' @RECOVERY_GETTER@', '''
int recvRaw(uint8_t*, int) override;
void begin() override;
void onSendFinished() override;
bool getReceiveRetryDelay(uint32_t, uint32_t&) const override;
bool registerInterruptAction() { _loop_event_pending=false; events.claim(); return true; }
uint16_t currentPreambleLength() const { return 32; }
bool isPacketReady() { return true; }
bool isInRecvMode() const override { return state == STATE_RX; }
bool startSendRaw(const uint8_t*, int) override { ++sends; return false; }
bool isSendComplete() override { return false; }
float packetScore(float, int) override { return 0; }
bool _loop_event_pending = false;
void finishReceiveProcessing();
void onReceiveProcessed() override { ++processed; finishReceiveProcessing(); }
unsigned processed = 0;
unsigned busy_probes = 0, packet_probes = 0, inject_packet_irq_at = 0;
uint32_t last_radio_interrupt_millis = 0, last_recv_millis = 0;
float _last_snr = 0, _last_rssi = 0;
unsigned n_recv = 0, n_recv_errors = 0, sends = 0;
uint8_t _wd_stage = 0, _wd_strikes = 0, _irq_probe_failures = 0;
unsigned long _wd_observe_until = 0;
''' + getter.replace(' override', '') + '\n @RECOVERY_GETTER@')
harness = harness.replace(
'bool recoverRadio(bool use_hard) { use_hard ? ++hard : ++soft; return true; }',
method(source, 'bool RadioLibWrapper::recoverRadio(').replace('RadioLibWrapper::', '')
+ '\n bool supportsRadioDeepInit() const { return false; }\n'
+ ' bool restoreAfterDeepInit() { recalibrateNoiseFloor(); return false; }')
harness = (harness.replace('@METHODS@', methods).replace('@NF_CONSTANTS@', constants)
.replace('@SETTLE_CONSTANT@', settle)
.replace('@RECOVERY_GETTER@', method(header, 'uint32_t getRecoveryCount() const'))
.replace('@LEGACY_TRY_RESET@', method(dispatcher, 'virtual bool tryResetAGC('))
.replace('@LEGACY_RECOVERY_GETTER@', method(dispatcher, 'virtual uint32_t getRecoveryCount(')))
harness += (ROOT / 'test/fixtures/radio_receive_retry.cpp').read_text()
folder = Path(folder)
cpp, binary = folder / 'retry.cpp', folder / 'retry'
cpp.write_text(harness.replace('\u2014', '-'), encoding='ascii')
dispatcher_source = ROOT / 'src/Dispatcher.cpp'
if mutation == 'agc_fast':
original = dispatcher_source.read_text()
alignment = (' if (adaptive && _radio->getReceiveRetryDelay(deferred_at, receive_delay)\n'
' && receive_delay > retry_delay) retry_delay = receive_delay;\n')
self.assertEqual(original.count(alignment), 1)
dispatcher_source = folder / 'Dispatcher.cpp'
dispatcher_source.write_text(original.replace(alignment, ''), encoding='ascii')
built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', '-O1',
'-DUSE_SX1262=1', '-fsanitize=address,undefined', '-fno-sanitize-recover=all',
*(['-DRADIO_LIVENESS_SOFT_ONLY=1'] if soft_only else []),
'-fno-pie', '-no-pie', '-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'),
str(cpp), str(dispatcher_source), str(ROOT / 'src/Packet.cpp'),
str(ROOT / 'src/helpers/StaticPoolPacketManager.cpp'), '-o', str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stderr)
return binary
def run_probe(self, binary, scenario=None):
env = os.environ.copy()
env['ASAN_OPTIONS'] = env.get('ASAN_OPTIONS', '') + ':detect_leaks=0'
return subprocess.run([str(binary), *([scenario] if scenario else [])],
capture_output=True, text=True, env=env, timeout=20)
def test_recovery_reports_ownership_recheck_refusal_and_preserves_fifo(self):
for soft_only in (False, True):
with self.subTest(soft_only=soft_only), tempfile.TemporaryDirectory(
prefix='meshcore-rx-recovery-admission-') as folder:
result = self.run_probe(self.build(folder, soft_only=soft_only), 'recovery')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('PASS: recovery ownership admission (12 cases)', result.stdout)
def test_old_success_after_refused_recovery_negative_control(self):
for soft_only in (False, True):
with self.subTest(soft_only=soft_only), tempfile.TemporaryDirectory(
prefix='meshcore-rx-recovery-negative-') as folder:
result = self.run_probe(self.build(folder, 'recovery_admission', soft_only), 'recovery')
self.assertNotEqual(result.returncode, 0)
self.assertIn('refused recovery must not report success', result.stderr)
def test_actual_dispatcher_and_receive_lifecycle(self):
with tempfile.TemporaryDirectory(prefix='meshcore-rx-retry-') as folder:
result = self.run_probe(self.build(folder))
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('PASS: receive retry lifecycle', result.stdout)
print(result.stdout, end='')
def test_failed_profile_transaction_blocks_fifo_delivery_and_rx_rearm(self):
with tempfile.TemporaryDirectory(prefix='meshcore-profile-repair-rx-') as folder:
result = self.run_probe(self.build(folder), 'profile-repair')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('PASS: untrusted profile RX waits for repair', result.stdout)
def test_old_hot_poll_and_pending_calibration_veto_negative_controls(self):
for mutation in ('unpaced', 'pending_veto', 'agc_fast'):
with self.subTest(mutation=mutation), tempfile.TemporaryDirectory(
prefix='meshcore-rx-retry-negative-') as folder:
result = self.run_probe(self.build(folder, mutation))
self.assertNotEqual(result.returncode, 0)
self.assertIn('busyAfterFailure' if mutation == 'agc_fast'
else 'persistentFailure', result.stderr)
def test_lost_calibration_restore_negative_control(self):
for mutation in ('lost_restore', 'duplicate_restore', 'restore_sleep'):
with self.subTest(mutation=mutation), tempfile.TemporaryDirectory(
prefix='meshcore-rx-restore-negative-') as folder:
result = self.run_probe(self.build(folder, mutation))
self.assertNotEqual(result.returncode, 0)
self.assertIn('deferredCalibrationRestore', result.stderr)
if __name__ == '__main__':
unittest.main()