Files
MeshCore/test/test_beacon_dispatcher.py
T

447 lines
21 KiB
Python

"""Execute the production TX-only beacon owner and Dispatcher lifecycle.
The test uses the actual Dispatcher, packet serializer, and static pool. Radio
and clock boundaries are counted adapters. No PlatformIO or hardware is used.
"""
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <Dispatcher.h>
#include <helpers/StaticPoolPacketManager.h>
#include <cassert>
#include <cstring>
#include <cstdio>
#include <vector>
using namespace mesh;
class Clock : public MillisecondClock {
public:
uint32_t now;
explicit Clock(uint32_t initial = 1000) : now(initial) {}
unsigned long getMillis() override { return now; }
};
class TestRadio : public Radio {
public:
RadioProfiles p;
RadioProfileParams physical, saved;
int8_t power = 22, saved_power = 22;
uint8_t active = 0;
bool owned = false, receiving = false, complete = false, sending = false;
bool start_ok = true, carrier = false, morse_tx = false, morse_rx = false;
bool calibrating = false, incoming = false, channel_busy = false;
bool valid = true, busy_owns = false, fail_owns = false;
unsigned sends = 0, beacon_sends = 0, normal_sends = 0, finishes = 0, recovery = 0;
unsigned processed = 0, prepare_calls = 0, restore_calls = 0, loops = 0;
unsigned agc = 0, noise = 0, begins = 0, begin_while_owned = 0;
uint32_t airtime = 100;
Clock* clock = nullptr;
uint32_t cad_delay = 0;
RadioParamApplyResult prepare = RadioParamApplyResult::APPLIED;
RadioParamApplyResult restore = RadioParamApplyResult::APPLIED;
std::vector<uint8_t> sent;
std::vector<uint32_t> beacon_starts;
TestRadio() {
p.primary.freq = 909.5f; p.primary.bw = 125; p.primary.sf = 7; p.primary.cr = 5;
p.primary.preamble = 32;
p.secondary.params = p.primary; p.secondary.params.freq = 910;
physical = saved = p.primary;
}
RadioProfiles* profiles() override { return &p; }
const RadioProfiles* profiles() const override { return &p; }
bool supportsMeshtext() const override { return true; }
bool validateProfile(const RadioProfileParams& params) const override {
return valid && RadioProfiles::valid(params);
}
uint8_t receiveProfile() const override { return active; }
int recvRaw(uint8_t* bytes, int) override {
assert(!owned);
if (!incoming) return 0;
incoming = false; bytes[0] = 0x15; 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; }
uint32_t getBeaconAirtime(const RadioProfileParams&, int) override { return airtime; }
float packetScore(float, int) override { return 1; }
RadioParamApplyResult prepareTransmitProfile(uint8_t profile) override {
assert(!owned); active = profile; physical = p.params(profile); return RadioParamApplyResult::APPLIED;
}
RadioParamApplyResult prepareBeaconTransmit(const RadioProfileParams& params, int8_t requested_power) override {
++prepare_calls;
if (prepare == RadioParamApplyResult::APPLIED || busy_owns || fail_owns) {
if (!owned) { saved = physical; saved_power = power; }
owned = true; physical = params; power = requested_power;
}
return prepare;
}
RadioParamApplyResult restoreBeaconReceive() override {
++restore_calls;
if (restore == RadioParamApplyResult::APPLIED) {
if (owned) { physical = saved; power = saved_power; }
owned = false;
}
return restore;
}
bool isBeaconRadioOwned() const override { return owned; }
bool isBeaconChannelBusy() override {
assert(owned);
if (clock) clock->now += cad_delay;
return channel_busy;
}
bool startSendRaw(const uint8_t* bytes, int length) override {
++sends;
if (owned) {
++beacon_sends;
if (clock) beacon_starts.push_back(clock->now);
} else ++normal_sends;
sent.assign(bytes, bytes + length); 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 isMorseTxActive() const override { return morse_tx; }
bool isMorseRxActive() const override { return morse_rx; }
bool isCalibratingNoiseFloor() const override { return calibrating; }
bool recoverRadio(bool) override { ++recovery; return true; }
uint32_t getRecoveryCount() const override { return recovery; }
void loop() override { assert(!owned); ++loops; }
void begin() override { ++begins; if (owned) ++begin_while_owned; }
void resetAGC() override { assert(!owned); ++agc; }
bool tryResetAGC() override { resetAGC(); return true; }
void triggerNoiseFloorCalibrate(int) override { assert(!owned); ++noise; }
};
class TestDispatcher : public Dispatcher {
public:
unsigned received = 0, mc_sent = 0, failed = 0;
unsigned long duty_window = 3600000;
int interval = 0;
bool adaptive = false;
TestDispatcher(TestRadio& radio, Clock& clock, StaticPoolPacketManager& manager)
: Dispatcher(radio, clock, manager) {}
using Dispatcher::setRadioAvailable;
using Dispatcher::getNextQueueWakeDelay;
using Dispatcher::queueForeignRadioFrame;
DispatcherAction onRecvPacket(Packet*) override { ++received; return ACTION_RELEASE; }
void onSendComplete(Packet*) override { ++mc_sent; }
void onSendFail(Packet*) override { ++failed; }
unsigned long getDutyCycleWindowMs() const override { return duty_window; }
int getAGCResetInterval() const override { return interval; }
bool useAdaptiveAGC() const override { return adaptive; }
};
struct Fixture {
Clock clock;
TestRadio radio;
StaticPoolPacketManager manager{8};
TestDispatcher d{radio, clock, manager};
explicit Fixture(uint32_t initial = 1000) : clock(initial) { radio.clock = &clock; d.begin(); }
void tick(uint32_t delta = 1) { clock.now += delta; d.loop(); }
};
static const uint8_t bytes[] = {0x15, 0, 0x23};
static RadioProfileParams beacon_params() {
RadioProfileParams p;
p.freq = 906.875f; p.bw = 250; p.sf = 11; p.cr = 5; p.preamble = 16;
p.protocol = RadioProfileProtocol::Meshtext;
return p;
}
static bool queue(Fixture& f) {
return f.d.queueBeaconRadioFrame(bytes, sizeof(bytes), beacon_params(), 17);
}
static Packet* normal(Fixture& f, uint32_t delay = 0) {
Packet* packet = f.d.obtainNewPacket(); assert(packet);
packet->header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT);
packet->tx_radio = RADIO_TX_PRIMARY; packet->payload[0] = 0x12; packet->payload_len = 1;
assert(f.d.sendPacket(packet, 0, delay));
return packet;
}
static void basic() {
Fixture f;
f.radio.p.secondary.mode = RadioProfileMode::RxTx;
f.radio.p.secondary_temporary = true;
const auto saved = f.radio.p;
assert(queue(f)); assert(f.d.hasOutbound() && !queue(f));
uint32_t delay = 99;
assert(f.d.getNextQueueWakeDelay(delay) && delay == 0);
f.tick();
assert(f.radio.beacon_sends == 1 && f.radio.owned && f.radio.physical == beacon_params());
assert(f.radio.sent == std::vector<uint8_t>(bytes, bytes + sizeof(bytes)));
assert(f.radio.power == 17 && !f.d.queueForeignRadioFrame(bytes, sizeof(bytes), 1, 1));
f.tick(20); assert(f.d.hasOutbound() && f.radio.finishes == 0);
f.radio.complete = true; f.tick();
assert(!f.d.hasOutbound() && !f.radio.owned && f.radio.finishes == 1);
assert(f.radio.physical == saved.primary && f.radio.power == 22);
assert(f.radio.p.primary == saved.primary && f.radio.p.secondary.params == saved.secondary.params);
assert(f.radio.p.secondary.mode == saved.secondary.mode && f.radio.p.secondary_temporary);
assert(f.radio.p.generation[0] == saved.generation[0] && f.radio.p.generation[1] == saved.generation[1]);
assert(!f.d.getNumSentFlood() && !f.d.getNumSentDirect() && !f.d.mc_sent);
assert(f.d.getTotalAirTime() == 21);
}
static void preparation() {
{ Fixture f; f.radio.prepare = RadioParamApplyResult::BUSY;
assert(queue(f)); f.tick(); assert(f.radio.prepare_calls == 1 && !f.radio.owned && !f.radio.sends);
f.tick(10); assert(f.radio.prepare_calls == 1);
f.tick(1); assert(f.radio.prepare_calls == 2);
f.tick(29987); assert(f.d.hasBeaconRadioFrame());
f.tick(1); assert(!f.d.hasBeaconRadioFrame() && !f.radio.sends && !f.radio.owned);
assert(queue(f)); // No RF attempt means the one-hour guard was not consumed.
f.d.cancelBeaconRadioFrame(); }
{ Fixture f; f.radio.prepare = RadioParamApplyResult::BUSY; f.radio.busy_owns = true;
f.radio.restore = RadioParamApplyResult::BUSY; assert(queue(f)); f.tick();
assert(f.d.hasBeaconRadioFrame() && f.radio.owned && f.radio.restore_calls == 1);
const unsigned loops = f.radio.loops; f.tick(500);
assert(f.radio.loops == loops && !f.radio.sends);
f.radio.restore = RadioParamApplyResult::APPLIED; f.tick();
assert(!f.d.hasBeaconRadioFrame() && !f.radio.owned); }
{ Fixture f; f.radio.prepare = RadioParamApplyResult::FAILED; f.radio.fail_owns = true;
assert(queue(f)); f.tick(); assert(!f.radio.owned && !f.d.hasBeaconRadioFrame() && !f.radio.sends); }
}
static void failures() {
{ Fixture f; f.radio.channel_busy = true; assert(queue(f)); f.tick();
assert(!f.radio.sends && !f.d.hasBeaconRadioFrame() && !f.radio.owned);
f.radio.channel_busy = false; assert(queue(f)); f.d.cancelBeaconRadioFrame(); }
{ Fixture f; f.radio.start_ok = false; assert(queue(f)); f.tick();
assert(f.radio.sends == 1 && !f.d.hasBeaconRadioFrame() && !f.radio.owned);
assert(queue(f)); f.tick(); assert(f.radio.sends == 1);
f.tick(30000); assert(!f.d.hasBeaconRadioFrame() && f.radio.sends == 1);
f.tick(3600000); assert(queue(f)); f.d.cancelBeaconRadioFrame(); }
{ Fixture f; f.radio.airtime = 0; assert(queue(f)); f.tick();
assert(!f.d.hasBeaconRadioFrame() && !f.radio.sends); }
{ Fixture f; f.radio.airtime = UINT32_MAX; assert(queue(f)); f.tick();
assert(!f.d.hasBeaconRadioFrame() && !f.radio.sends); }
{ Fixture f; assert(queue(f)); f.tick(); f.tick(1150);
assert(!f.d.hasBeaconRadioFrame() && !f.radio.owned && f.radio.finishes == 1 && f.radio.recovery == 1);
assert(f.d.getErrFlags() & ERR_EVENT_RADIO_WATCHDOG);
f.tick(10000); assert(f.radio.sends == 1); }
}
static void restoration() {
Fixture f; f.d.interval = 8; f.d.adaptive = true;
assert(queue(f)); f.tick();
const unsigned loops = f.radio.loops, agc = f.radio.agc, noise = f.radio.noise;
f.radio.restore = RadioParamApplyResult::BUSY;
f.radio.complete = true; f.radio.incoming = true;
normal(f); f.tick(20);
assert(f.radio.owned && f.d.hasBeaconRadioFrame() && f.radio.finishes == 1);
f.tick(30000);
assert(f.radio.loops == loops && f.radio.agc == agc && f.radio.noise == noise);
assert(f.radio.normal_sends == 0 && f.radio.incoming && f.manager.getOutboundTotal() == 1);
f.radio.restore = RadioParamApplyResult::FAILED; f.tick();
assert(f.radio.owned && f.radio.loops == loops && !f.radio.normal_sends);
f.radio.restore = RadioParamApplyResult::APPLIED; f.tick();
assert(!f.radio.owned && !f.d.hasBeaconRadioFrame() && !f.radio.incoming);
assert(f.radio.normal_sends == 1 && f.radio.agc > agc);
f.radio.complete = true; f.tick();
}
static void availability() {
{ Fixture f; assert(queue(f)); f.d.setRadioAvailable(false);
assert(!f.d.hasBeaconRadioFrame() && !queue(f));
const unsigned restores = f.radio.restore_calls, loops = f.radio.loops;
f.tick(10000); assert(f.radio.restore_calls == restores && f.radio.loops == loops);
f.d.setRadioAvailable(true); assert(queue(f)); f.d.cancelBeaconRadioFrame(); }
{ Fixture f; assert(queue(f)); f.tick(); f.radio.restore = RadioParamApplyResult::BUSY;
f.d.setRadioAvailable(false); assert(f.radio.owned && f.d.hasBeaconRadioFrame());
const unsigned restores = f.radio.restore_calls, finishes = f.radio.finishes, loops = f.radio.loops;
f.tick(10000);
assert(f.radio.restore_calls == restores && f.radio.finishes == finishes && f.radio.loops == loops);
f.d.setRadioAvailable(true);
assert(!f.radio.begin_while_owned); // Never initialize through an outstanding beacon lease.
f.radio.restore = RadioParamApplyResult::APPLIED; f.tick();
assert(!f.radio.owned && !f.d.hasBeaconRadioFrame()); }
}
static void priority() {
{ Fixture f; assert(queue(f)); normal(f); f.tick();
assert(f.radio.normal_sends == 1 && !f.radio.beacon_sends && !f.radio.owned);
assert(f.d.hasBeaconRadioFrame()); f.radio.complete = true; f.tick(); f.tick(101);
assert(f.radio.beacon_sends == 1 && f.radio.owned);
f.radio.complete = true; f.tick(); }
{ Fixture f; normal(f, 5000); assert(queue(f)); f.tick();
assert(f.radio.beacon_sends == 1 && !f.radio.normal_sends);
f.radio.complete = true; f.tick(); f.tick(5000);
assert(f.radio.normal_sends == 1); f.radio.complete = true; f.tick(); }
{ Fixture f; normal(f); f.tick(); assert(!queue(f)); f.radio.complete = true; f.tick(); }
{ Fixture f; f.radio.p.secondary.params.protocol = RadioProfileProtocol::Meshtext;
f.radio.p.secondary.mode = RadioProfileMode::RxTx;
assert(f.d.queueForeignRadioFrame(bytes, sizeof(bytes), 1, f.radio.p.generation[1]));
assert(!queue(f)); f.tick(); f.radio.complete = true; f.tick(); }
}
static void budget_and_hour() {
{ Fixture f; f.d.duty_window = 200; f.d.begin();
assert(queue(f)); f.tick(); f.radio.complete = true; f.tick(100);
assert(f.d.getRemainingTxBudget() == 0 && f.d.getTotalAirTime() == 100);
assert(queue(f)); f.tick(); assert(f.radio.beacon_sends == 1);
f.d.cancelBeaconRadioFrame();
f.clock.now = 3601000; assert(queue(f)); // Admission just before the actual RF deadline is valid.
f.d.loop(); assert(f.radio.beacon_sends == 1);
f.clock.now = 3601001; f.d.loop(); assert(f.radio.beacon_sends == 1);
f.clock.now = 3601101; f.d.loop(); assert(f.radio.beacon_sends == 2);
f.radio.complete = true; f.tick(100); }
{ Fixture f; f.d.duty_window = 20; f.d.begin(); assert(queue(f)); f.tick();
assert(!f.radio.sends && f.d.hasBeaconRadioFrame());
f.tick(30000); assert(!f.d.hasBeaconRadioFrame() && !f.radio.sends); }
{ Fixture f(UINT32_MAX - 500); assert(queue(f)); f.tick();
f.radio.complete = true; f.tick(50); assert(queue(f));
f.tick(); assert(f.radio.beacon_sends == 1); f.d.cancelBeaconRadioFrame();
f.clock.now = uint32_t(UINT32_MAX - 499 + uint64_t(3600000));
assert(queue(f)); f.tick(); assert(f.radio.beacon_sends == 2);
f.radio.complete = true; f.tick(); }
}
static void hourly_cad_delay() {
Fixture f;
f.radio.cad_delay = 30;
const uint32_t first_admission = f.clock.now;
assert(queue(f)); f.tick();
assert(f.radio.beacon_sends == 1 && f.radio.beacon_starts[0] == first_admission + 31);
f.radio.complete = true; f.tick();
// The manager staggers admission, not the end of synchronous CAD. A new
// hourly slot must wait for the RF guard instead of being dropped here.
f.clock.now = first_admission + 3600000;
assert(queue(f));
f.tick(); assert(f.radio.beacon_sends == 1 && !f.radio.owned);
f.tick(30); assert(f.radio.beacon_sends == 1 && !f.radio.owned);
f.tick(71); assert(f.radio.beacon_sends == 2 && f.radio.owned);
assert(uint32_t(f.radio.beacon_starts[1] - f.radio.beacon_starts[0]) >= 3600000);
f.radio.complete = true; f.tick();
}
static void sleep_deadlines() {
Fixture f; f.d.interval = 8000; f.d.adaptive = true; f.tick();
uint32_t delay = 0;
f.d.setBeaconWakeDelay(f.clock.now, 3600000, true);
assert(f.d.getNextQueueWakeDelay(delay) && delay == 8000);
f.d.setBeaconWakeDelay(f.clock.now, 1000, true);
assert(f.d.getNextQueueWakeDelay(delay) && delay == 1000);
f.d.setBeaconWakeDelay(f.clock.now, 1000, false);
assert(f.d.getNextQueueWakeDelay(delay) && delay == 8000);
assert(queue(f)); assert(f.d.getNextQueueWakeDelay(delay) && delay == 0);
f.d.cancelBeaconRadioFrame();
f.d.setBeaconWakeDelay(f.clock.now, 0, true);
assert(f.d.getNextQueueWakeDelay(delay) && delay == 0);
f.d.setRadioAvailable(false);
assert(!f.d.getNextQueueWakeDelay(delay));
}
static void begin_lifecycle() {
Fixture f; assert(queue(f)); f.tick();
assert(f.radio.owned);
f.d.begin();
assert(!f.radio.begin_while_owned && !f.radio.owned && !f.d.hasBeaconRadioFrame());
assert(f.radio.finishes == 1 && f.radio.restore_calls == 1);
}
int main(int argc, char** argv) {
assert(argc == 2);
if (!strcmp(argv[1], "basic")) basic();
else if (!strcmp(argv[1], "preparation")) preparation();
else if (!strcmp(argv[1], "failures")) failures();
else if (!strcmp(argv[1], "restoration")) restoration();
else if (!strcmp(argv[1], "availability")) availability();
else if (!strcmp(argv[1], "priority")) priority();
else if (!strcmp(argv[1], "budget")) budget_and_hour();
else if (!strcmp(argv[1], "hourly")) hourly_cad_delay();
else if (!strcmp(argv[1], "sleep")) sleep_deadlines();
else if (!strcmp(argv[1], "begin")) begin_lifecycle();
else assert(false);
puts("PASS");
}
'''
class BeaconDispatcherTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if not compiler:
raise unittest.SkipTest("a host C++ compiler is required")
cls.temp = tempfile.TemporaryDirectory(prefix="meshcore-beacon-dispatcher-")
cls.addClassCleanup(cls.temp.cleanup)
work = Path(cls.temp.name)
cpp = work / "dispatcher.cpp"
cpp.write_text(HARNESS, encoding="ascii")
cls.binary = work / ("dispatcher.exe" if sys.platform == "win32" else "dispatcher")
flags = ["-std=c++17", "-Wall", "-Wextra", "-Wno-unused-parameter",
"-DMESH_ENABLE_TX_BEACONS=1", "-DMESH_ENABLE_MESHTEXT_CHAT=1"]
if sys.platform.startswith("linux"):
flags += ["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
cls.compiler, cls.flags, cls.cpp, cls.work = compiler, flags, cpp, work
result = cls.compile_variant(ROOT / "src/DispatcherBeacon.cpp", cls.binary)
if result.returncode:
raise AssertionError(result.stdout + result.stderr)
@classmethod
def compile_variant(cls, beacon_source, binary):
return subprocess.run(
[cls.compiler, *cls.flags, "-I" + str(ROOT / "test/mocks"), "-I" + str(ROOT / "src"),
str(ROOT / "src/Dispatcher.cpp"), str(beacon_source),
str(ROOT / "src/Packet.cpp"), str(ROOT / "src/helpers/StaticPoolPacketManager.cpp"),
str(cls.cpp), "-o", str(binary)], capture_output=True, text=True, timeout=90)
def run_case(self, name):
env = os.environ.copy()
# The legacy static pool intentionally lives for the entire firmware
# lifetime and has no teardown. Keep ASan/UBSan fatal, exclude only LSAN.
env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":detect_leaks=0"
result = subprocess.run(
[str(self.binary), name], capture_output=True, text=True,
env=env, cwd=self.work, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("PASS", result.stdout)
def test_one_tx_uses_private_tuple_preserves_radio2_and_accounts_airtime(self):
self.run_case("basic")
def test_prepare_busy_is_bounded_and_owned_failures_restore_exclusively(self):
self.run_case("preparation")
def test_cad_start_failure_invalid_airtime_and_tx_timeout_do_not_retry(self):
self.run_case("failures")
def test_restore_busy_blocks_normal_tx_rx_agc_and_noise_until_repaired(self):
self.run_case("restoration")
def test_suspension_and_reactivation_do_not_initialize_through_a_beacon_lease(self):
self.run_case("availability")
def test_due_normal_traffic_wins_and_meshtext_ownership_is_exclusive(self):
self.run_case("priority")
def test_budget_hour_guard_and_millisecond_rollover(self):
self.run_case("budget")
def test_hourly_admission_waits_for_actual_rf_guard_after_cad_delay(self):
self.run_case("hourly")
def test_old_hour_guard_at_admission_is_rejected_by_regression(self):
source = (ROOT / "src/DispatcherBeacon.cpp").read_text(encoding="utf-8")
marker = " const uint32_t now = _ms->getMillis();\n"
old_guard = " if (beacon_has_started && uint32_t(now - beacon_last_start) < 3600000UL) return false;\n"
self.assertIn(marker, source)
broken = self.work / "old-admission.cpp"
broken.write_text(source.replace(marker, marker + old_guard, 1), encoding="ascii")
binary = self.work / ("old-admission.exe" if sys.platform == "win32" else "old-admission")
compile_result = self.compile_variant(broken, binary)
self.assertEqual(compile_result.returncode, 0, compile_result.stdout + compile_result.stderr)
env = os.environ.copy()
env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":detect_leaks=0"
result = subprocess.run([str(binary), "hourly"], capture_output=True, text=True,
env=env, cwd=self.work, timeout=30)
self.assertNotEqual(result.returncode, 0, "old admission guard unexpectedly passed")
self.assertIn("queue(f)", result.stderr)
def test_beacon_sleep_deadline_is_merged_with_adaptive_agc(self):
self.run_case("sleep")
def test_begin_retires_an_in_flight_beacon_before_radio_initialization(self):
self.run_case("begin")
if __name__ == "__main__":
unittest.main()