Files
MeshCore/test/test_beacon_dispatcher.py
T

781 lines
37 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>
#if MESH_ENABLE_OTA_KISS_DIRECT
#include <helpers/ota/OtaFormat.h>
#endif
#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 supportsEthernetTunnel() 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;
#if MESH_ENABLE_FOREIGN_RADIO
using Dispatcher::queueForeignRadioFrame;
using Dispatcher::hasForeignRadioFrame;
bool queueOtherOwner(const uint8_t* bytes, size_t length) {
#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL
const auto protocol = MESH_ENABLE_MESHTEXT_CHAT
? RadioProfileProtocol::Meshtext : RadioProfileProtocol::Ethernet;
_radio->profiles()->secondary.params.protocol = protocol;
_radio->profiles()->secondary.mode = RadioProfileMode::RxTx;
return queueForeignRadioFrame(bytes, length, 1, _radio->profiles()->generation[1], protocol);
#elif MESH_ENABLE_OTA_KISS_DIRECT
const uint8_t message[] = {ota::OTA_DATA, 0x23};
return queueDirectOtaRadioFrame(message, sizeof(message), 0, _radio->profiles()->generation[0]);
#endif
}
#if MESH_ENABLE_OTA_KISS_DIRECT
bool allowDirectOtaTransmit(uint8_t, uint32_t) const override { return true; }
uint32_t directOtaEpoch() const override { return 1; }
#endif
#endif
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);
#if MESH_ENABLE_FOREIGN_RADIO
assert(!f.d.queueForeignRadioFrame(bytes, sizeof(bytes), 1, 1));
#endif
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(); }
#if MESH_ENABLE_MESHTEXT_CHAT
{ 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(); }
#endif
}
static void owners() {
Fixture f;
#if MESH_ENABLE_FOREIGN_RADIO
assert(f.d.queueOtherOwner(bytes, sizeof(bytes)) && f.d.hasForeignRadioFrame());
assert(!queue(f)); // A queued foreign frame already owns the shared raw slot.
f.tick();
assert(f.radio.normal_sends == 1 && !f.radio.beacon_sends);
assert(!queue(f)); // Ownership also survives until its TX completes.
f.radio.complete = true; f.tick();
assert(!f.d.hasForeignRadioFrame());
#endif
assert(queue(f));
#if MESH_ENABLE_FOREIGN_RADIO
assert(!f.d.queueOtherOwner(bytes, sizeof(bytes)));
#endif
f.tick();
assert(f.radio.beacon_sends == 1 && f.radio.owned);
f.radio.complete = true; f.tick();
assert(!f.radio.owned && !f.d.hasBeaconRadioFrame());
}
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 hour_boundary() {
for (uint32_t initial : {1000u, UINT32_MAX - 500u, UINT32_MAX}) {
Fixture f(initial);
assert(queue(f)); f.tick();
assert(f.radio.beacon_sends == 1);
const uint32_t first = f.radio.beacon_starts[0];
f.radio.complete = true; f.tick(20);
f.clock.now = first + 3599999u;
assert(queue(f)); f.d.loop();
assert(f.radio.beacon_sends == 1 && !f.radio.owned);
f.d.cancelBeaconRadioFrame();
f.clock.now = first + 3600000u;
assert(queue(f)); f.d.loop();
assert(f.radio.beacon_sends == 2 && f.radio.owned);
f.radio.complete = true; f.tick(20);
assert(!f.d.hasBeaconRadioFrame() && !f.radio.owned);
}
}
static void hour_idle(unsigned mode) {
// Exercise normal idle, unavailable radio, maintenance carrier, an owned
// restoration retry, and a failed start that still consumes its hour.
for (uint32_t initial : {1000u, UINT32_MAX - 500u, UINT32_MAX}) {
Fixture f(initial);
if (mode == 4) f.radio.start_ok = false;
assert(queue(f)); f.tick();
assert(f.radio.beacon_sends == 1);
const uint32_t first = f.radio.beacon_starts[0];
if (mode != 4) {
if (mode == 3) f.radio.restore = RadioParamApplyResult::BUSY;
f.radio.complete = true; f.tick(20);
}
if (mode == 1) f.d.setRadioAvailable(false);
if (mode == 2) f.radio.carrier = true;
const unsigned loops = f.radio.loops, noise = f.radio.noise;
const unsigned restores = f.radio.restore_calls, prepares = f.radio.prepare_calls;
for (uint64_t elapsed : {uint64_t(3600000), uint64_t(86400000),
uint64_t(864000000), uint64_t(1728000000), uint64_t(2592000000),
uint64_t(3456000000), uint64_t(4294967296)}) {
f.clock.now = uint32_t(uint64_t(first) + elapsed);
f.d.loop();
assert(f.radio.beacon_sends == 1);
assert(f.d.hasBeaconRadioFrame() == (mode == 3));
assert(f.radio.owned == (mode == 3));
if (mode == 1) {
assert(f.radio.loops == loops && f.radio.noise == noise);
assert(f.radio.restore_calls == restores && f.radio.prepare_calls == prepares);
}
}
// A supported delete-all/re-enable path can make a newly configured
// 24-hour slot due here; the manager's 64-bit scheduler permits it.
f.clock.now = uint32_t(uint64_t(first) + (uint64_t(1) << 32) + 1800000);
if (mode == 1) f.d.setRadioAvailable(true);
if (mode == 2) f.radio.carrier = false;
if (mode == 3) {
f.radio.restore = RadioParamApplyResult::APPLIED;
f.d.loop();
}
f.radio.start_ok = true;
assert(!f.radio.owned && !f.d.hasBeaconRadioFrame());
assert(queue(f)); f.d.loop();
assert(f.radio.beacon_sends == 2
&& "expired one-hour guard must not return after live full-wrap idle");
assert(f.radio.owned);
f.radio.complete = true; f.tick(20);
assert(!f.d.hasOutbound() && !f.radio.owned);
uint32_t delay = 99;
assert(!f.d.getNextQueueWakeDelay(delay)
&& "retired beacon cannot pin the fast lifecycle for 30 seconds");
// A fresh attempt must establish a fresh real one-hour guard, even when
// the old guard has been retired. Cancelling this pending work cannot
// remove the actual RF-start restriction.
assert(queue(f)); f.tick();
assert(f.radio.beacon_sends == 2 && !f.radio.owned);
f.d.cancelBeaconRadioFrame();
assert(queue(f)); f.tick();
assert(f.radio.beacon_sends == 2 && !f.radio.owned);
f.tick(30000);
assert(!f.d.hasBeaconRadioFrame() && f.radio.beacon_sends == 2);
}
}
static void hour_late_observation() {
for (uint32_t initial : {1000u, UINT32_MAX}) {
for (uint32_t age : {3600000u, 0x80000000u, UINT32_MAX}) {
Fixture f(initial);
assert(queue(f)); f.tick();
assert(f.radio.beacon_sends == 1);
const uint32_t first = f.radio.beacon_starts[0];
f.radio.complete = true; f.tick(20);
// A late first observation still has a known full-width unsigned age.
// In particular, do not turn a >half-wrap elapsed time into a future
// signed deadline. No RF work is queued during this observation.
f.clock.now = first + age;
f.d.loop();
assert(!f.d.hasOutbound());
f.clock.now = uint32_t(uint64_t(first) + (uint64_t(1) << 32) + 1800000);
assert(queue(f)); f.d.loop();
assert(f.radio.beacon_sends == 2
&& "expired one-hour guard must not return after a late observation");
f.radio.complete = true; f.tick(20);
assert(!f.d.hasOutbound() && !f.radio.owned);
}
}
}
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);
}
static void configure_agc(Fixture& f, bool adaptive) {
f.d.interval = 4000;
f.d.adaptive = adaptive;
f.tick(0);
assert(f.radio.agc == 0);
f.clock.now += 3990; // TX begins just before the original maintenance deadline.
}
static void agc_retirement() {
for (bool adaptive : {false, true}) for (bool wrap : {false, true}) {
const uint32_t initial = wrap ? UINT32_MAX - 2000U : 1000U;
Fixture ordinary(initial);
configure_agc(ordinary, adaptive);
normal(ordinary); ordinary.d.loop();
assert(ordinary.radio.normal_sends == 1 && ordinary.radio.agc == 0);
ordinary.clock.now += 100; ordinary.radio.complete = true; ordinary.d.loop();
assert(ordinary.radio.agc == unsigned(adaptive)); // Existing normal TX policy.
for (bool abort : {false, true}) {
Fixture f(initial);
configure_agc(f, adaptive);
assert(queue(f)); f.d.loop();
assert(f.radio.beacon_sends == 1 && f.radio.agc == 0);
f.clock.now += 100;
if (abort) f.d.cancelBeaconRadioFrame();
else f.radio.complete = true;
f.d.loop();
assert(!f.radio.owned && !f.d.hasBeaconRadioFrame() && f.radio.finishes == 1);
assert(f.d.getTotalAirTime() == 100);
assert(f.radio.agc == unsigned(adaptive)
&& "beacon TX must follow normal fixed/adaptive AGC retirement policy");
}
// Count retirement once, not again for each failed restore/cancel attempt.
Fixture delayed(initial);
configure_agc(delayed, adaptive);
assert(queue(delayed)); delayed.d.loop();
delayed.radio.restore = RadioParamApplyResult::BUSY;
delayed.clock.now += 100;
delayed.radio.complete = true; delayed.d.loop();
assert(delayed.radio.owned && delayed.radio.agc == 0);
delayed.clock.now += 1000; delayed.d.cancelBeaconRadioFrame();
assert(delayed.d.getTotalAirTime() == 100 && delayed.radio.finishes == 1);
delayed.clock.now = initial + 8091U;
delayed.radio.restore = RadioParamApplyResult::APPLIED; delayed.d.loop();
assert(delayed.radio.agc == 1 && "restore retries must not postpone AGC again");
// No accepted Sending phase means no TX retirement. In particular, a
// queued frame, failed preparation/CAD/start, or rejected admission cannot
// use periodic beacon attempts to starve fixed maintenance.
for (unsigned failure = 0; failure < 6; ++failure) {
Fixture f(initial);
configure_agc(f, adaptive);
if (failure == 0) {
f.radio.valid = false;
assert(!queue(f));
} else {
if (failure == 2) f.radio.prepare = RadioParamApplyResult::FAILED;
if (failure == 3) f.radio.channel_busy = true;
if (failure == 4) f.radio.start_ok = false;
if (failure == 5) f.radio.prepare = RadioParamApplyResult::BUSY;
assert(queue(f));
if (failure == 1) f.d.cancelBeaconRadioFrame();
else f.d.loop();
}
f.clock.now += 100;
f.d.cancelBeaconRadioFrame(); f.d.loop();
assert(f.radio.agc == 1 && "unsent beacon must not postpone AGC");
assert(f.d.getTotalAirTime() == 0);
}
}
}
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], "owners")) owners();
else if (!strcmp(argv[1], "budget")) budget_and_hour();
else if (!strcmp(argv[1], "hourly")) hourly_cad_delay();
else if (!strcmp(argv[1], "hour-boundary")) hour_boundary();
else if (!strcmp(argv[1], "hour-idle")) hour_idle(0);
else if (!strcmp(argv[1], "hour-unavailable")) hour_idle(1);
else if (!strcmp(argv[1], "hour-carrier")) hour_idle(2);
else if (!strcmp(argv[1], "hour-restoring")) hour_idle(3);
else if (!strcmp(argv[1], "hour-failed-start")) hour_idle(4);
else if (!strcmp(argv[1], "hour-late")) hour_late_observation();
else if (!strcmp(argv[1], "sleep")) sleep_deadlines();
else if (!strcmp(argv[1], "begin")) begin_lifecycle();
else if (!strcmp(argv[1], "agc")) agc_retirement();
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"]
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, feature_flags=None, dispatcher_source=None):
if feature_flags is None:
feature_flags = ["-DMESH_ENABLE_MESHTEXT_CHAT=1"]
return subprocess.run(
[cls.compiler, *cls.flags, *feature_flags,
"-I" + str(ROOT / "test/mocks"), "-I" + str(ROOT / "src"),
str(dispatcher_source or 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")
OWNER_FEATURES = {
"chat": ["-DMESH_ENABLE_MESHTEXT_CHAT=1"],
"ethernet": ["-DMESH_ENABLE_MESHTEXT_CHAT=0", "-DMESH_ENABLE_ETHERNET_TUNNEL=1"],
"direct_ota": ["-DMESH_ENABLE_MESHTEXT_CHAT=0", "-DENABLE_OTA=1"],
"none": ["-DMESH_ENABLE_MESHTEXT_CHAT=0", "-DMESH_ENABLE_FOREIGN_RADIO=0"],
}
def test_foreign_owners_are_exclusive_across_feature_matrix(self):
for name, flags in self.OWNER_FEATURES.items():
with self.subTest(features=name):
binary = self.work / ("owner-" + name)
compiled = self.compile_variant(ROOT / "src/DispatcherBeacon.cpp", binary, flags)
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
env = dict(os.environ, ASAN_OPTIONS="detect_leaks=0")
checked = subprocess.run([str(binary), "owners"], capture_output=True,
text=True, env=env, cwd=self.work, timeout=30)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("PASS", checked.stdout)
def test_old_chat_only_owner_guard_is_rejected(self):
source = (ROOT / "src/DispatcherBeacon.cpp").read_text(encoding="ascii")
guard = "#if MESH_ENABLE_FOREIGN_RADIO\n if (hasForeignRadioFrame()) return false;"
self.assertEqual(source.count(guard), 1)
broken = self.work / "old-owner-guard.cpp"
broken.write_text(source.replace(guard, guard.replace("FOREIGN_RADIO", "MESHTEXT_CHAT")),
encoding="ascii")
for name in ("ethernet", "direct_ota"):
with self.subTest(features=name):
binary = self.work / ("old-owner-" + name)
compiled = self.compile_variant(broken, binary, self.OWNER_FEATURES[name])
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
env = dict(os.environ, ASAN_OPTIONS="detect_leaks=0")
checked = subprocess.run([str(binary), "owners"], capture_output=True,
text=True, env=env, cwd=self.work, timeout=30)
self.assertNotEqual(checked.returncode, 0, "old owner guard unexpectedly passed")
self.assertIn("!queue(f)", checked.stderr)
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_real_one_hour_guard_preserves_zero_max_and_exact_boundary(self):
self.run_case("hour-boundary")
def test_expired_hour_guard_stays_retired_after_full_wrap_idle(self):
self.run_case("hour-idle")
def test_expired_hour_guard_retires_even_while_radio_is_unavailable(self):
self.run_case("hour-unavailable")
def test_expired_hour_guard_retires_before_carrier_early_return(self):
self.run_case("hour-carrier")
def test_expired_hour_guard_does_not_release_owned_restoration(self):
self.run_case("hour-restoring")
def test_failed_rf_attempt_guard_also_stays_expired_after_long_idle(self):
self.run_case("hour-failed-start")
def test_first_expiry_observation_uses_full_unsigned_elapsed_range(self):
self.run_case("hour-late")
def test_missing_expired_guard_retirement_negative_control(self):
source = (ROOT / "src/Dispatcher.cpp").read_text(encoding="ascii")
retirement = " beacon_has_started = false;\n"
self.assertEqual(source.count(retirement), 1)
broken = self.work / "old-hour-retirement.cpp"
broken.write_text(source.replace(retirement, ""), encoding="ascii")
binary = self.work / ("old-hour-retirement.exe" if sys.platform == "win32"
else "old-hour-retirement")
compiled = self.compile_variant(ROOT / "src/DispatcherBeacon.cpp", binary,
dispatcher_source=broken)
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
for case in ("hour-idle", "hour-unavailable", "hour-carrier",
"hour-restoring", "hour-failed-start", "hour-late"):
with self.subTest(case=case):
checked = subprocess.run([str(binary), case], capture_output=True,
text=True, env=dict(os.environ, ASAN_OPTIONS="detect_leaks=0"),
cwd=self.work, timeout=30)
self.assertNotEqual(checked.returncode, 0,
"missing hourly retirement escaped regression")
self.assertIn("expired one-hour guard must not return", checked.stderr)
self.assertNotIn("AddressSanitizer:", checked.stderr)
self.assertNotIn("runtime error:", checked.stderr)
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")
def test_sent_beacon_retires_agc_once_like_normal_tx_but_unsent_work_does_not(self):
self.run_case("agc")
def test_missing_beacon_agc_retirement_negative_control(self):
source = (ROOT / "src/DispatcherBeacon.cpp").read_text(encoding="ascii")
notify = " agc_reset.noteTransmit(now);\n"
self.assertEqual(source.count(notify), 1)
broken = self.work / "old-beacon-agc.cpp"
broken.write_text(source.replace(notify, ""), encoding="ascii")
binary = self.work / ("old-beacon-agc.exe" if sys.platform == "win32" else "old-beacon-agc")
compiled = self.compile_variant(broken, binary)
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
checked = subprocess.run([str(binary), "agc"], capture_output=True, text=True,
env=dict(os.environ, ASAN_OPTIONS="detect_leaks=0"),
cwd=self.work, timeout=30)
self.assertNotEqual(checked.returncode, 0, "missing AGC retirement escaped regression")
self.assertIn("beacon TX must follow normal fixed/adaptive AGC retirement policy", checked.stderr)
if __name__ == "__main__":
unittest.main()