Files
MeshCore/test/test_common_cli_tx_pending.py
T
mikecarper 73c5f572eb Apply busy radio settings and recover service starts automatically
Save valid busy RX, fast-switch and Companion tuple changes once, then
apply the latest accepted intent at a safe radio gap. Preserve checked-save
rollback, runtime RXEN intent, TempRadio ownership and exact remote reply
delivery. Repair failed preamble compensation without hiding uncertain saves.

Retry WebConfig, MQTT and NimBLE startup failures automatically. Preserve
explicit off choices, OTA ownership and the latest WiFi credentials through
cooperative shutdown. Add regression coverage and update command guides.

Validation: focused host transaction, rollback, ownership and lifecycle suites;
Full RAK4631, Heltec V4 R8 TFT and RC32 packages; representative room, sensor,
LR1110, LR2021, SX1276, STM32WL and ESP-NOW builds. Linked RAK crypto and OTA
workspace checks pass; its complete OTA image uses 712644/712704 bytes.
2026-10-25 15:20:55 -07:00

241 lines
13 KiB
Python

"""Exercise saved TX intent through the production CLI, save and loop methods."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
def preprocess(source, hil=False):
result = subprocess.run([
os.environ.get('CXX', 'g++'), '-E', '-P', '-x', 'c++',
*(['-DMESH_HIL_TX_FREQ_MHZ=909.5f'] if hil else []), '-',
], input=source, text=True, capture_output=True, check=True)
return result.stdout
def production_fixture():
source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
header = (ROOT / 'src/helpers/CommonCLI.h').read_text()
dispatcher = (ROOT / 'src/Dispatcher.h').read_text()
code = (ROOT / 'test/fixtures/common_cli_tx_pending.cpp').read_text()
methods = '\n'.join(extract_braced(source, signature) for signature in (
'void CommonCLI::applyAndSaveTxPower(', 'void CommonCLI::servicePendingTxPower(',
'bool CommonCLI::trySavePrefs(', 'void CommonCLI::loop('))
setter = extract_braced(source, 'void CommonCLI::handleSetCmd(')
getter = preprocess(extract_braced(source, 'void CommonCLI::handleGetCmd('), hil=True)
values = {
'METHODS': methods,
'SET': extract_braced(setter, 'if (memcmp(config, "tx ", 3) == 0)'),
'GET': ' else '.join(extract_braced(getter,
f'if (configKeyEquals(config, "{key}"))') for key in ('tx.status', 'tx')),
'KEY_EQUALS': extract_braced(source, 'static bool configKeyEquals('),
'ENUM': extract_braced(dispatcher, 'enum class RadioParamApplyResult') + ';',
'DEFAULT_CALLBACK': extract_braced(header,
'virtual mesh::RadioParamApplyResult trySetTxPower('),
'DEFAULT_APPLIED': extract_braced(header, 'virtual bool isTxPowerApplied('),
'PENDING_QUERY': extract_braced(header, 'bool hasPendingTxPower() const'),
}
for key, value in values.items():
code = code.replace('@' + key + '@', value)
return code
class CommonCLITxPendingTests(unittest.TestCase):
def test_saved_power_applies_automatically_without_resaving(self):
code = production_fixture()
with tempfile.TemporaryDirectory(prefix='meshcore-common-cli-tx-') as directory:
cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test'
variants = [('production', code, True),
('drops_pending', code.replace(
'_tx_power_pending = result == Result::BUSY;',
'_tx_power_pending = false;'), False),
('no_background_service', code.replace(
' servicePendingTxPower();', ''), False),
('unverified_applied_claim', code.replace(
' && _callbacks->isTxPowerApplied(_prefs->tx_power_dbm)', ''), False),
('stale_save_ack', code.replace(
' _common_save_result_known = false;\n'
' _common_save_succeeded = false;',
'/* stale save acknowledgement */'), False)]
for name, fixture, succeeds in variants:
with self.subTest(variant=name):
cpp.write_text(fixture, encoding='ascii')
flags = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie']
built = subprocess.run([
os.environ.get('CXX', 'g++'), '-std=c++17', '-Wall', '-Wextra',
'-Werror', *flags, '-I', str(ROOT / 'src'), str(cpp), '-o', str(exe),
], text=True, capture_output=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(exe)], text=True, capture_output=True)
if succeeds:
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
else:
self.assertNotEqual(ran.returncode, 0, name + ' escaped detection')
def test_actual_completion_callbacks_apply_before_a_saturated_queue_starts_next_tx(self):
dispatcher = extract_braced((ROOT / 'src/Dispatcher.cpp').read_text(),
'void Dispatcher::loop(')
completed = extract_braced(dispatcher, 'if (_radio->isSendComplete())')
self.assertLess(completed.index('_radio->onSendFinished();'),
completed.index('onSendComplete(outbound);'))
self.assertLess(dispatcher.index('onSendComplete(outbound);'), dispatcher.rindex('checkSend();'))
prefix = production_fixture().split('int main() {', 1)[0]
fixture = r'''
#define MAX_HASH_SIZE 8
#define PAYLOAD_TYPE_REQ 1
namespace mesh {
namespace tracker { constexpr unsigned kReplyWindowSeconds = 30; }
struct Packet {
int getPayloadType() const { return 0; }
void calculatePacketHash(uint8_t* out) const { memset(out, 0, MAX_HASH_SIZE); }
};
struct Mesh {
virtual void onSendComplete(Packet*) { completion_seen = true; }
};
}
struct TxRole : mesh::Mesh {
CommonCLI _cli;
struct Barrier {
bool complete(mesh::Packet*) { return false; }
bool waiting() const { return false; }
} temp_radio_reply_barrier;
mesh::Packet* pending_battery_alert_packet = nullptr;
bool battery_alert_sent = false, tracker_pending = false, tracker_sent = false;
uint64_t uptime_millis = 0, last_battery_alert_sent = 0;
uint32_t last_millis = 0, tracker_reply_until = 0;
uint8_t tracker_packet_hash[MAX_HASH_SIZE] = {};
uint32_t futureMillis(uint32_t delay) const { return millis() + delay; }
void finishRadioReply(bool) {}
void processScheduledRadioSettings(bool) {}
void servicePendingPrimaryRadioSettings(bool) {}
void onSendComplete(mesh::Packet*) override;
};
@CALLBACK@
int main() {
// The queue never drains: every completion schedules another packet before
// CommonCLI::loop gets control again. Only the actual completion hook can
// admit the queued TX setting in the idle hardware gap.
for (bool receive_busy_at_first_completion : {false, true}) {
now_ms = 1000;
TxRole role;
auto& cli = role._cli;
auto& driver = cli.callbacks;
mesh::Packet packet;
char reply[160];
driver.transmitting = true;
cli.command("set tx 14", reply);
assert(!strcmp(reply, "OK - saved; pending"));
assert(cli.hasPendingTxPower() && driver.saves == 1 && driver.hardware == 10);
unsigned applied_after = 0;
for (unsigned sent = 1; sent <= 4; ++sent) {
cli.loop(); assert(driver.transmitting);
// Dispatcher calls onSendFinished before the role completion callback.
driver.transmitting = false;
completion_seen = false;
driver.require_completion = true;
if (sent == 1 && receive_busy_at_first_completion) driver.results = {Result::BUSY};
now_ms += 5000;
role.onSendComplete(&packet);
assert(completion_seen && driver.saves == 1);
if (!receive_busy_at_first_completion || sent > 1) {
assert(!cli.hasPendingTxPower() && driver.hardware == 14);
if (!applied_after) applied_after = now_ms;
} else assert(cli.hasPendingTxPower() && driver.hardware == 10);
driver.require_completion = false;
// checkSend() immediately starts the next queued packet. A post-Mesh
// service would now see BUSY for the entire burst.
driver.transmitting = true;
cli.loop();
}
assert(applied_after > 0 && applied_after < 30000);
assert(driver.persisted == 14 && driver.saves == 1);
}
}
'''
with tempfile.TemporaryDirectory(prefix='meshcore-common-cli-tx-burst-') as directory:
cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test'
for role, name, filename in (
('simple_repeater', 'MyMesh', 'MyMesh.cpp'),
('simple_room_server', 'MyMesh', 'MyMesh.cpp'),
('simple_sensor', 'SensorMesh', 'SensorMesh.cpp'),
('simple_sensor', 'SensorMesh', 'Tracker.cpp')):
source = (ROOT / 'examples' / role / filename).read_text()
callback = preprocess(extract_braced(source, f'void {name}::onSendComplete('))
callback = callback.replace(name + '::', 'TxRole::')
for omit_gap in (False, True):
with self.subTest(role=role, file=filename, omit_gap=omit_gap):
actual = callback.replace('_cli.servicePendingTxPower();', '') if omit_gap else callback
cpp.write_text(prefix + fixture.replace('@CALLBACK@', actual), encoding='ascii')
flags = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie']
built = subprocess.run([
os.environ.get('CXX', 'g++'), '-std=c++17', '-Wall', '-Wextra', '-Werror',
*flags, '-I', str(ROOT / 'src'), str(cpp), '-o', str(exe),
], capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(exe)], capture_output=True, text=True)
if omit_gap:
self.assertNotEqual(ran.returncode, 0, 'missing completion service escaped detection')
else:
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
def test_applied_power_diagnostic_and_callbacks_are_hil_only(self):
common = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
header = (ROOT / 'src/helpers/CommonCLI.h').read_text()
getter = extract_braced(common, 'void CommonCLI::handleGetCmd(')
self.assertNotIn('"tx.status"', preprocess(getter))
self.assertIn('"tx.status"', preprocess(getter, hil=True))
self.assertIn('"tx"', preprocess(getter))
def check_guard(text, definition):
guarded = '#if defined(MESH_HIL_TX_FREQ_MHZ)\n' + definition + '\n#endif'
self.assertIn(guarded, text)
self.assertNotIn('isTxPowerApplied', preprocess(guarded))
self.assertIn('isTxPowerApplied', preprocess(guarded, hil=True))
check_guard(header, ' ' + extract_braced(header, 'virtual bool isTxPowerApplied('))
for role, name in (('simple_repeater', 'MyMesh'),
('simple_room_server', 'MyMesh'),
('simple_sensor', 'SensorMesh')):
with self.subTest(role=role):
source = (ROOT / 'examples' / role / (name + '.cpp')).read_text()
role_header = (ROOT / 'examples' / role / (name + '.h')).read_text()
check_guard(source, extract_braced(source, f'bool {name}::isTxPowerApplied('))
check_guard(role_header, ' bool isTxPowerApplied(int8_t power_dbm) const override;')
def test_every_infrastructure_role_preserves_tristate_and_pending_work(self):
for role, name in (('simple_repeater', 'MyMesh'),
('simple_room_server', 'MyMesh'),
('simple_sensor', 'SensorMesh')):
with self.subTest(role=role):
source = (ROOT / 'examples' / role / (name + '.cpp')).read_text()
header = (ROOT / 'examples' / role / (name + '.h')).read_text()
callback = extract_braced(source,
f'mesh::RadioParamApplyResult {name}::trySetTxPower(')
self.assertIn('return radio_driver.trySetTxPower(power_dbm);', callback)
applied = extract_braced(source,
f'bool {name}::isTxPowerApplied(')
self.assertIn('return radio_driver.isTxPowerApplied(power_dbm);', applied)
pending = extract_braced(source,
f'mesh::powerdiag::Mask {name}::pendingWorkReason() const')
# Include pending intent during hardware-failure backoff too:
# nRF52's event-driven sleep has no guaranteed timer wake.
self.assertIn('_cli.hasPendingRadioSettings()', pending)
self.assertLess(pending.index('_cli.hasPendingRadioSettings()'),
pending.index('radio_apply_backoff')
if 'radio_apply_backoff' in pending else len(pending))
recovery = extract_braced(header, 'bool canRecoverUsbLogging() const')
self.assertIn('!_cli.hasPendingRadioSettings()', recovery)
self.assertIn('_cli.loop();', extract_braced(source, f'void {name}::loop('))
if __name__ == '__main__':
unittest.main()