Add staggered transmit-only channel text beacons
This commit is contained in:
1 parent
8049754de9
commit
009bfff383
35 files changed
+3389
-19
No files matched your search
+364
@@ -0,0 +1,364 @@
|
||||
#include <helpers/BeaconManager.h>
|
||||
#include <Mesh.h>
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <string>
|
||||
|
||||
uint64_t beacon_test_time = 0;
|
||||
constexpr uint64_t H = mesh::BeaconManager::HourMillis;
|
||||
|
||||
struct Fixture {
|
||||
MemoryFS fs;
|
||||
mesh::Mesh mesh;
|
||||
mesh::RTCClock clock;
|
||||
mesh::BeaconManager manager;
|
||||
char reply[256] = {};
|
||||
char name[32] = "Beacon test";
|
||||
uint64_t now = 0;
|
||||
explicit Fixture(uint32_t boot = 0) {
|
||||
g_mock_millis = boot; beacon_test_time = 0;
|
||||
manager.begin(&fs, &mesh, &clock, name);
|
||||
}
|
||||
void tick(uint64_t elapsed) {
|
||||
now += elapsed; beacon_test_time = now; g_mock_millis += uint32_t(elapsed);
|
||||
manager.loop(g_mock_millis);
|
||||
}
|
||||
const char* cmd(const std::string& command, bool ok = true) {
|
||||
std::memset(reply, 0x55, sizeof(reply));
|
||||
const bool handled = manager.handle(command.c_str(), reply, sizeof(reply));
|
||||
assert(handled && std::memchr(reply, 0, sizeof(reply)));
|
||||
const bool actual_ok = std::strncmp(reply, "Err", 3) != 0;
|
||||
if (actual_ok != ok) { std::fprintf(stderr, "%s => %s\n", command.c_str(), reply); assert(false); }
|
||||
return reply;
|
||||
}
|
||||
void mc(unsigned slot, const char* text = "Hello") {
|
||||
cmd("set beacon " + std::to_string(slot) + " meshcore 906.5,62.5,7,5,14 " + text);
|
||||
}
|
||||
void all(unsigned hours) {
|
||||
cmd("set beacon.interval " + std::to_string(hours));
|
||||
for (unsigned i = 1; i <= 12; ++i) mc(i, std::to_string(i).c_str());
|
||||
}
|
||||
void mt(unsigned slot = 1) { cmd("set beacon " + std::to_string(slot) + " meshtext US LongFast 14 Hello MT"); }
|
||||
};
|
||||
|
||||
uint32_t crc(const uint8_t* bytes, size_t size) {
|
||||
uint32_t value = 0xffffffffU;
|
||||
while (size--) {
|
||||
value ^= *bytes++;
|
||||
for (unsigned i = 0; i < 8; ++i) value = (value >> 1) ^ ((value & 1) ? 0xedb88320U : 0);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
void put32(uint8_t* bytes, uint32_t value) {
|
||||
for (unsigned i = 0; i < 4; ++i) bytes[i] = uint8_t(value >> (8 * i));
|
||||
}
|
||||
void fixCrc(std::vector<uint8_t>& bytes) { put32(bytes.data() + bytes.size() - 4, crc(bytes.data(), bytes.size() - 4)); }
|
||||
|
||||
void commands() {
|
||||
Fixture f;
|
||||
assert(!f.mesh.wake_enabled && f.fs.files.empty());
|
||||
f.tick(48 * H);
|
||||
assert(f.mesh.sends.empty() && f.fs.files.empty());
|
||||
assert(std::strstr(f.cmd("get beacon.interval"), "24"));
|
||||
assert(!mesh::BeaconManager::isCommand(nullptr));
|
||||
for (const char* command : {"set beacons 1", "get beaconry", "set beacon.intervalx 12", "get radio", "send beacon 1"})
|
||||
assert(!mesh::BeaconManager::isCommand(command));
|
||||
for (const char* command : {"get beacon", "get beacon 1", "set beacon", "set beacon.interval", "delete beacon all", "del beacon 1"})
|
||||
assert(mesh::BeaconManager::isCommand(command));
|
||||
for (const char* command : {
|
||||
"set beacon", "set beacon.interval", "get beacon.interval garbage", "set beacon.interval 0",
|
||||
"set beacon.interval 13", "set beacon.interval 4294967296", "set beacon.interval 12 garbage",
|
||||
"get beacon 0", "get beacon 13", "get beacon -1", "get beacon 4294967296", "get beacon 1 extra",
|
||||
"delete beacon 1 extra", "set beacon 1 unknown 1 Hello",
|
||||
"set beacon 1 meshcore nan,62.5,7,5,14 Hello", "set beacon 1 meshcore inf,62.5,7,5,14 Hello",
|
||||
"set beacon 1 meshcore 906.5,nan,7,5,14 Hello", "set beacon 1 meshcore 906.5,62.5,256,5,14 Hello",
|
||||
"set beacon 1 meshcore 906.5,62.5,7,5,128 Hello", "set beacon 1 meshcore 906.5,62.5,7,5,23 Hello",
|
||||
"set beacon 1 meshcore 906.5,62.5,7,5,14,7 Hello", "set beacon 1 meshcore 906.5,62.5,7,5,14,65536 Hello",
|
||||
"set beacon 1 meshcore 906.5,62.5,7,5,14,32,1 Hello", "set beacon 1 meshcore 906.5,62.5,7,5,14",
|
||||
"set beacon 1 meshtext bad LongFast 14 Hello", "set beacon 1 meshtext US bad 14 Hello",
|
||||
"set beacon 1 meshtext US LongFast 128 Hello"}) f.cmd(command, false);
|
||||
f.cmd("set beacon 1 meshcore 906.5,62.5,7,5,14 " + std::string(80, 'A'));
|
||||
assert(std::strstr(f.cmd("get beacon 1"), std::string(80, 'A').c_str()));
|
||||
f.cmd("set beacon 1 meshcore 906.5,62.5,7,5,14 " + std::string(81, 'A'), false);
|
||||
f.cmd("set beacon 1 meshcore 906.5,62.5,7,5,14 Hello\nthere", false);
|
||||
f.cmd("set beacon 1 meshcore 906.5,62.5,7,5,14 \xc3\xa9");
|
||||
assert(std::strstr(f.cmd("get beacon"), "1"));
|
||||
f.cmd("delete beacon all");
|
||||
assert(!f.mesh.wake_enabled);
|
||||
char tiny[2] = {'x', 'x'};
|
||||
assert(f.manager.handle("get beacon", tiny, sizeof(tiny)) && tiny[1] == 0);
|
||||
assert(!f.manager.handle("get beacon", tiny, 0));
|
||||
}
|
||||
|
||||
void schedule() {
|
||||
for (unsigned cycle : {12U, 24U, 36U, 48U}) {
|
||||
for (bool wrap : {false, true}) {
|
||||
Fixture f(wrap ? UINT32_MAX - uint32_t(H) : 0);
|
||||
f.all(cycle);
|
||||
const uint64_t spacing = cycle / 12 * H;
|
||||
assert(f.mesh.wake_enabled && f.mesh.wake_delay == spacing);
|
||||
for (unsigned minute = 1; minute <= 48 * 60; ++minute) f.tick(60000);
|
||||
const unsigned expected = 48 / (cycle / 12);
|
||||
assert(f.mesh.sends.size() == expected);
|
||||
for (unsigned i = 0; i < expected; ++i) {
|
||||
const auto& send = f.mesh.sends[i];
|
||||
assert(send.time == (i + 1) * spacing);
|
||||
assert(std::string(send.slot.text) == std::to_string(i % 12 + 1));
|
||||
assert(send.slot.radio.preamble == 32 && send.slot.radio.protocol == mesh::RadioProfileProtocol::MeshCore);
|
||||
if (i) assert(send.time - f.mesh.sends[i - 1].time >= H);
|
||||
if (i >= 12) assert(send.time - f.mesh.sends[i - 12].time >= cycle * H);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sparse slots retain their hour positions; a long sleep never catches up
|
||||
// all missed slots in a burst.
|
||||
Fixture sparse;
|
||||
sparse.mc(1); sparse.mc(12);
|
||||
sparse.tick(13 * H);
|
||||
assert(sparse.mesh.sends.empty());
|
||||
assert(sparse.mesh.wake_delay == 11 * H);
|
||||
sparse.tick(11 * H);
|
||||
assert(sparse.mesh.sends.size() == 1);
|
||||
sparse.tick(24 * H);
|
||||
assert(sparse.mesh.sends.size() == 2);
|
||||
}
|
||||
|
||||
void rolling() {
|
||||
Fixture f;
|
||||
f.all(12);
|
||||
f.tick(H + 59 * 60000);
|
||||
assert(f.mesh.sends.size() == 1);
|
||||
f.tick(60000);
|
||||
assert(f.mesh.sends.size() == 1 && f.mesh.wake_delay == 59 * 60000);
|
||||
for (unsigned minute = 1; minute <= 24 * 60; ++minute) f.tick(60000);
|
||||
for (unsigned i = 1; i < f.mesh.sends.size(); ++i) {
|
||||
assert(f.mesh.sends[i].time - f.mesh.sends[i - 1].time >= H);
|
||||
if (i >= 12) assert(f.mesh.sends[i].time - f.mesh.sends[i - 12].time >= 12 * H);
|
||||
}
|
||||
// Failed queue admission consumes this window and causes no fast retry.
|
||||
Fixture failed;
|
||||
failed.mc(1); failed.mesh.accept = false;
|
||||
failed.tick(2 * H);
|
||||
assert(failed.mesh.attempts == 1 && failed.mesh.sends.empty());
|
||||
for (unsigned i = 0; i < 100; ++i) failed.tick(1000);
|
||||
assert(failed.mesh.attempts == 1);
|
||||
failed.mesh.accept = true; failed.tick(24 * H - 100000);
|
||||
assert(failed.mesh.attempts == 2 && failed.mesh.sends.size() == 1);
|
||||
// Interval changes start a new anchor, rather than sending immediately.
|
||||
failed.cmd("set beacon.interval 12");
|
||||
failed.tick(H - 1); assert(failed.mesh.sends.size() == 1);
|
||||
failed.tick(1); assert(failed.mesh.sends.size() == 1); // per-slot rolling guard
|
||||
const unsigned cancellations = failed.mesh.cancellations;
|
||||
failed.manager.suspend();
|
||||
assert(!failed.mesh.wake_enabled && failed.mesh.cancellations == cancellations + 1);
|
||||
failed.tick(100 * H); assert(failed.mesh.sends.size() == 1);
|
||||
failed.cmd("set beacon 1 meshcore 906.5,62.5,7,5,14 Gone", false);
|
||||
// An object created by an early get must not transmit when a later set
|
||||
// enables a slot whose current window is already due, before the CLI ack.
|
||||
Fixture edit;
|
||||
edit.cmd("set beacon.interval 12"); edit.tick(H + 10 * 60000);
|
||||
edit.mc(1); assert(edit.mesh.sends.empty());
|
||||
edit.tick(50 * 60000); assert(edit.mesh.sends.empty());
|
||||
edit.tick(11 * H); assert(edit.mesh.sends.size() == 1);
|
||||
}
|
||||
|
||||
void storage() {
|
||||
Fixture original;
|
||||
original.mc(1, "Old");
|
||||
const auto baseline = original.fs.files;
|
||||
const size_t size = baseline.at("/beacons").size();
|
||||
assert(size == 1188);
|
||||
for (size_t boundary = 0; boundary < size; ++boundary) {
|
||||
Fixture f; f.fs.files = baseline; f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name);
|
||||
f.fs.fail_write_after = int(boundary);
|
||||
f.cmd("set beacon 1 meshcore 907.5,125,8,6,10 New", false);
|
||||
assert(std::strstr(f.cmd("get beacon 1"), "Old"));
|
||||
f.fs.fail_write_after = -1;
|
||||
f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name);
|
||||
assert(std::strstr(f.cmd("get beacon 1"), "Old"));
|
||||
}
|
||||
for (int boundary : {0, 7, 8, 105, 1187}) {
|
||||
Fixture f; f.fs.files = baseline; f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name);
|
||||
f.fs.fail_read_after = boundary;
|
||||
f.cmd("set beacon 1 meshcore 907.5,125,8,6,10 New", false);
|
||||
assert(std::strstr(f.cmd("get beacon 1"), "Old"));
|
||||
}
|
||||
for (int rename : {1, 2}) {
|
||||
Fixture f; f.fs.files = baseline; f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name);
|
||||
f.fs.fail_rename = rename;
|
||||
f.cmd("set beacon 1 meshcore 907.5,125,8,6,10 New", false);
|
||||
f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name);
|
||||
assert(std::strstr(f.cmd("get beacon 1"), "Old"));
|
||||
}
|
||||
{
|
||||
Fixture f; f.fs.files = baseline;
|
||||
f.fs.files["/beacons.tmp"] = baseline.at("/beacons");
|
||||
f.fs.files.erase("/beacons");
|
||||
f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name);
|
||||
assert(!std::strstr(f.cmd("get beacon 1"), "Old")); // temp is not committed
|
||||
}
|
||||
for (bool future : {false, true}) {
|
||||
Fixture f; f.fs.files = baseline;
|
||||
f.fs.files["/beacons"][future ? 4 : 40] ^= future ? 3 : 1;
|
||||
f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name);
|
||||
f.cmd("get beacon", false); f.cmd("get beacon.interval", false); f.cmd("delete beacon all", false);
|
||||
f.tick(48 * H); assert(f.mesh.sends.empty());
|
||||
}
|
||||
{
|
||||
Fixture f; f.fs.files = baseline;
|
||||
f.fs.files["/beacons.bak"] = baseline.at("/beacons");
|
||||
f.fs.files["/beacons"][40] ^= 1;
|
||||
f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name);
|
||||
assert(std::strstr(f.cmd("get beacon 1"), "Old"));
|
||||
}
|
||||
{
|
||||
Fixture f; f.fs.files = baseline;
|
||||
f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name);
|
||||
f.fs.fail_rename_from = {"/beacons.tmp", "/beacons.bak"};
|
||||
f.cmd("set beacon 1 meshcore 907.5,125,8,6,10 New", false);
|
||||
f.cmd("get beacon", false);
|
||||
f.tick(48 * H); assert(f.mesh.sends.empty());
|
||||
}
|
||||
{
|
||||
Fixture f; f.fs.files = baseline; f.fs.fail_read_open = true;
|
||||
f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name);
|
||||
f.cmd("delete beacon all", false);
|
||||
assert(f.fs.files.at("/beacons") == baseline.at("/beacons"));
|
||||
}
|
||||
// CRC-valid but semantically invalid configuration is held as a unit.
|
||||
{
|
||||
Fixture f; f.fs.files = baseline;
|
||||
auto& bytes = f.fs.files["/beacons"];
|
||||
bytes[8 + 10] = 255; fixCrc(bytes);
|
||||
f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name);
|
||||
f.cmd("get beacon", false);
|
||||
}
|
||||
}
|
||||
|
||||
void ids() {
|
||||
Fixture f;
|
||||
f.mt();
|
||||
assert(f.fs.files.count("/beacon_ids"));
|
||||
f.tick(2 * H);
|
||||
assert(f.mesh.sends.size() == 1 && f.mesh.sends[0].id == 0x1234567a);
|
||||
assert(f.mesh.sends[0].from == 0x88776655);
|
||||
assert(f.mesh.sends[0].slot.radio.preamble == 16 && f.mesh.sends[0].slot.hops == 0);
|
||||
const auto baseline = f.fs.files;
|
||||
f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name); // reboots use the durable counter
|
||||
f.tick(2 * H);
|
||||
assert(f.mesh.sends.size() == 2 && f.mesh.sends.back().id == 0x1234567b);
|
||||
f.cmd("delete beacon all"); f.mt();
|
||||
f.tick(24 * H);
|
||||
assert(f.mesh.sends.back().id == 0x1234567c);
|
||||
for (unsigned fault = 0; fault < 4; ++fault) {
|
||||
Fixture bad; bad.fs.files = baseline;
|
||||
if (fault == 0) bad.fs.files.erase("/beacon_ids");
|
||||
if (fault == 1) bad.fs.files["/beacon_ids"][8] ^= 1;
|
||||
if (fault == 2) bad.fs.files["/beacon_ids"][4] = 2;
|
||||
if (fault == 3) {
|
||||
auto& bytes = bad.fs.files["/beacon_ids"];
|
||||
put32(bytes.data() + 8, UINT32_MAX); fixCrc(bytes);
|
||||
}
|
||||
bad.manager.begin(&bad.fs, &bad.mesh, &bad.clock, bad.name);
|
||||
bad.tick(2 * H); assert(bad.mesh.sends.empty());
|
||||
bad.cmd("delete beacon all");
|
||||
bad.cmd("set beacon 1 meshtext US LongFast 14 Hello MT", false);
|
||||
// A broken MT counter must not disable independent MeshCore slots.
|
||||
bad.mc(2); bad.tick(2 * H); assert(bad.mesh.sends.size() == 1);
|
||||
}
|
||||
// Every counter short-write boundary fails closed for this boot. Previous
|
||||
// durable state is unchanged, so the next boot cannot repeat an on-air ID.
|
||||
for (int boundary = 0; boundary < 16; ++boundary) {
|
||||
Fixture bad; bad.fs.files = baseline; bad.manager.begin(&bad.fs, &bad.mesh, &bad.clock, bad.name);
|
||||
bad.fs.fail_write_after = boundary;
|
||||
bad.tick(2 * H); assert(bad.mesh.sends.empty());
|
||||
bad.fs.fail_write_after = -1;
|
||||
bad.tick(24 * H); assert(bad.mesh.sends.empty());
|
||||
bad.manager.begin(&bad.fs, &bad.mesh, &bad.clock, bad.name);
|
||||
bad.tick(2 * H); assert(bad.mesh.sends.size() == 1 && bad.mesh.sends[0].id == 0x1234567b);
|
||||
}
|
||||
{
|
||||
Fixture rejected; rejected.mt(); rejected.mesh.accept = false;
|
||||
rejected.tick(2 * H); assert(rejected.mesh.attempts == 1);
|
||||
rejected.mesh.accept = true; rejected.tick(24 * H);
|
||||
assert(rejected.mesh.sends.size() == 1 && rejected.mesh.sends[0].id == 0x1234567b);
|
||||
}
|
||||
}
|
||||
|
||||
void mutations() {
|
||||
for (const char* command : {
|
||||
"set beacon 1 meshcore 907.5,125,8,6,10 New",
|
||||
"set beacon 2 meshcore 907.5,125,8,6,10 Other",
|
||||
"delete beacon 1", "delete beacon all", "set beacon.interval 12"}) {
|
||||
for (bool rename_failure : {false, true}) {
|
||||
Fixture f;
|
||||
f.mc(1, "Old"); f.tick(2 * H);
|
||||
assert(f.mesh.pending && f.mesh.sends.size() == 1);
|
||||
const unsigned cancellations = f.mesh.cancellations;
|
||||
f.cmd("get beacon 1");
|
||||
assert(f.mesh.pending && f.mesh.cancellations == cancellations);
|
||||
if (rename_failure) f.fs.fail_rename = 2;
|
||||
else f.fs.fail_write = true;
|
||||
f.cmd(command, false);
|
||||
assert(f.mesh.pending && f.mesh.cancellations == cancellations);
|
||||
f.tick(1000);
|
||||
assert(f.mesh.pending && f.mesh.sends.size() == 1);
|
||||
f.fs.fail_write = false;
|
||||
f.cmd(command);
|
||||
assert(!f.mesh.pending && f.mesh.cancellations == cancellations + 1);
|
||||
f.tick(10000);
|
||||
assert(!f.mesh.pending && f.mesh.sends.size() == 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void staleBackup() {
|
||||
Fixture f;
|
||||
f.mt();
|
||||
const auto stale = f.fs.files.at("/beacon_ids");
|
||||
f.tick(2 * H);
|
||||
assert(f.mesh.sends.size() == 1 && f.mesh.sends[0].id == 0x1234567a);
|
||||
assert(stale != f.fs.files.at("/beacon_ids"));
|
||||
// A previous backup-remove failure can leave this lower ID behind. Losing
|
||||
// only the active directory entry must not make the on-air ID reusable.
|
||||
f.fs.files["/beacon_ids.bak"] = stale;
|
||||
f.fs.files.erase("/beacon_ids");
|
||||
f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name);
|
||||
f.tick(2 * H);
|
||||
assert(f.mesh.sends.size() == 1);
|
||||
assert(!f.fs.files.count("/beacon_ids") && f.fs.files.at("/beacon_ids.bak") == stale);
|
||||
f.cmd("delete beacon all");
|
||||
f.cmd("set beacon 1 meshtext US LongFast 14 Hello MT", false);
|
||||
f.mc(2); f.tick(2 * H);
|
||||
assert(f.mesh.sends.size() == 2 && f.mesh.sends.back().slot.radio.protocol == mesh::RadioProfileProtocol::MeshCore);
|
||||
|
||||
// Absence of the config marker is not evidence that an independent backup
|
||||
// has never been used. A fresh MT setter must remain fail closed too.
|
||||
Fixture uninitialized;
|
||||
uninitialized.mc(2);
|
||||
assert(uninitialized.fs.files.at("/beacons")[6] == 0);
|
||||
uninitialized.fs.files["/beacon_ids.bak"] = stale;
|
||||
uninitialized.manager.begin(&uninitialized.fs, &uninitialized.mesh, &uninitialized.clock, uninitialized.name);
|
||||
uninitialized.cmd("set beacon 1 meshtext US LongFast 14 Hello MT", false);
|
||||
uninitialized.tick(4 * H);
|
||||
assert(uninitialized.mesh.sends.size() == 1
|
||||
&& uninitialized.mesh.sends[0].slot.radio.protocol == mesh::RadioProfileProtocol::MeshCore);
|
||||
assert(!uninitialized.fs.files.count("/beacon_ids")
|
||||
&& uninitialized.fs.files.at("/beacon_ids.bak") == stale);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 2);
|
||||
if (!std::strcmp(argv[1], "commands")) commands();
|
||||
else if (!std::strcmp(argv[1], "schedule")) schedule();
|
||||
else if (!std::strcmp(argv[1], "rolling")) rolling();
|
||||
else if (!std::strcmp(argv[1], "storage")) storage();
|
||||
else if (!std::strcmp(argv[1], "ids")) ids();
|
||||
else if (!std::strcmp(argv[1], "mutations")) mutations();
|
||||
else if (!std::strcmp(argv[1], "stale_backup")) staleBackup();
|
||||
else assert(false);
|
||||
std::printf("beacon manager %s passed\n", argv[1]);
|
||||
}
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
// BeaconManager uses only the production preset parser/modem calculator.
|
||||
// Encryption has its independent real-AES oracle in test_meshtext_chat.py.
|
||||
class BeaconUnusedAES {
|
||||
public:
|
||||
bool setKey(const uint8_t*, size_t) { return false; }
|
||||
void encryptBlock(uint8_t* output, const uint8_t* input) { std::memcpy(output, input, 16); }
|
||||
void clear() {}
|
||||
};
|
||||
using AES128 = BeaconUnusedAES;
|
||||
using AES256 = BeaconUnusedAES;
|
||||
Vendored
+49
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
#include <helpers/BeaconConfig.h>
|
||||
#include <Arduino.h>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
extern uint64_t beacon_test_time;
|
||||
|
||||
namespace mesh {
|
||||
class RTCClock {};
|
||||
class RNG {
|
||||
public:
|
||||
void random(uint8_t* output, size_t size) {
|
||||
const uint32_t value = 0x12345678;
|
||||
std::memcpy(output, &value, size);
|
||||
}
|
||||
};
|
||||
class Mesh {
|
||||
public:
|
||||
struct Send { uint64_t time; BeaconSlot slot; uint32_t from, id; };
|
||||
std::vector<Send> sends;
|
||||
bool accept = true, wake_enabled = false, wire_supported = true, pending = false;
|
||||
uint32_t wake_delay = 0, wake_now = 0, attempts = 0, cancellations = 0;
|
||||
bool validateTextBeacon(const BeaconSlot& slot) const {
|
||||
const auto& p = slot.radio;
|
||||
return slot.enabled && RadioProfiles::valid(p) && p.freq >= 300 && p.freq <= 1000
|
||||
&& (p.bw == 31.25f || p.bw == 62.5f || p.bw == 125 || p.bw == 250 || p.bw == 500)
|
||||
&& slot.power >= -9 && slot.power <= 22 && slot.text[0]
|
||||
&& (p.protocol != RadioProfileProtocol::Meshtext || wire_supported);
|
||||
}
|
||||
bool queueTextBeacon(const BeaconSlot& slot, const char*, uint32_t from, uint32_t id) {
|
||||
++attempts;
|
||||
if (!accept) return false;
|
||||
pending = true;
|
||||
sends.push_back({beacon_test_time, slot, from, id});
|
||||
return true;
|
||||
}
|
||||
void setBeaconWakeDelay(uint32_t now, uint32_t delay, bool enabled) {
|
||||
wake_now = now; wake_delay = delay; wake_enabled = enabled;
|
||||
}
|
||||
uint32_t beaconNodeId() const { return 0x88776655; }
|
||||
RNG* getRNG() { return &rng_; }
|
||||
void cancelBeaconRadioFrame() { ++cancellations; pending = false; }
|
||||
private:
|
||||
RNG rng_;
|
||||
};
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,446 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,370 @@
|
||||
"""Test TX-only beacon frames with real AES/SHA and independent wire oracles.
|
||||
|
||||
Only the radio queue, clock and packet-pool boundary are substituted. The
|
||||
production group-datagram builder, packet serializer and Mesh adapter run as
|
||||
written; no PlatformIO, hardware or downloaded dependency is needed.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cpp_source import body
|
||||
from test_meshtext_chat import DEFAULT_KEY, data_text, reference_frame
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PUBLIC_KEY = bytes.fromhex("8b3387e9c5cdea6ac9e5edbaa115cd72")
|
||||
|
||||
|
||||
ADAPTER = r'''
|
||||
#pragma once
|
||||
#include <Packet.h>
|
||||
#include <Utils.h>
|
||||
#include <helpers/BeaconConfig.h>
|
||||
#include <cassert>
|
||||
#include <vector>
|
||||
namespace mesh {
|
||||
struct GroupChannel {
|
||||
uint8_t hash[PATH_HASH_SIZE] = {};
|
||||
uint8_t secret[PUB_KEY_SIZE] = {};
|
||||
uint8_t tx_radio = RADIO_TX_AUTO;
|
||||
};
|
||||
class TestRadio {
|
||||
public:
|
||||
bool valid = true, meshtext = true;
|
||||
RadioProfiles profiles;
|
||||
bool validateProfile(const RadioProfileParams& p) const { return valid && RadioProfiles::valid(p); }
|
||||
bool supportsMeshtext() const { return meshtext; }
|
||||
};
|
||||
class TestClock {
|
||||
public:
|
||||
uint32_t now = 0x12345678;
|
||||
uint32_t getCurrentTimeUnique() { return now++; }
|
||||
};
|
||||
class TestTables {
|
||||
public:
|
||||
unsigned sent = 0;
|
||||
std::vector<uint8_t> marked;
|
||||
void markSent(const Packet* packet) {
|
||||
++sent;
|
||||
uint8_t raw[MAX_TRANS_UNIT];
|
||||
const size_t length = packet->writeTo(raw);
|
||||
marked.assign(raw, raw + length);
|
||||
}
|
||||
};
|
||||
class Mesh {
|
||||
public:
|
||||
TestRadio radio;
|
||||
TestRadio* _radio = &radio;
|
||||
TestClock clock;
|
||||
TestTables tables;
|
||||
struct { uint8_t pub_key[32] = {}; } self_id;
|
||||
bool temporary = false, accept = true, pool_available = true;
|
||||
unsigned obtained = 0, released = 0, queues = 0;
|
||||
std::vector<uint8_t> queued;
|
||||
RadioProfileParams queued_params;
|
||||
int8_t queued_power = 0;
|
||||
Packet pool_packet;
|
||||
bool validateTextBeacon(const BeaconSlot&) const;
|
||||
bool queueTextBeacon(const BeaconSlot&, const char*, uint32_t, uint32_t);
|
||||
uint32_t beaconNodeId() const;
|
||||
bool isAnyTempRadioActive() const { return temporary; }
|
||||
TestClock* getRTCClock() { return &clock; }
|
||||
TestTables* getTables() { return &tables; }
|
||||
Packet* obtainNewPacket() {
|
||||
if (!pool_available) return nullptr;
|
||||
++obtained;
|
||||
pool_packet = Packet();
|
||||
return &pool_packet;
|
||||
}
|
||||
void releasePacket(Packet* p) { assert(p == &pool_packet); ++released; }
|
||||
Packet* createGroupDatagram(uint8_t, const GroupChannel&, const uint8_t*, size_t);
|
||||
bool queueBeaconRadioFrame(const uint8_t* bytes, size_t size,
|
||||
const RadioProfileParams& params, int8_t power) {
|
||||
++queues;
|
||||
if (!accept) return false;
|
||||
queued.assign(bytes, bytes + size);
|
||||
queued_params = params;
|
||||
queued_power = power;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
HARNESS = r'''
|
||||
#include <Mesh.h>
|
||||
#include <helpers/BeaconFrames.h>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
using namespace mesh;
|
||||
static std::vector<uint8_t> unhex(const char* input) {
|
||||
const std::string text(input);
|
||||
assert(text.size() % 2 == 0);
|
||||
std::vector<uint8_t> bytes;
|
||||
for (size_t i = 0; i < text.size(); i += 2)
|
||||
bytes.push_back(uint8_t(std::stoul(text.substr(i, 2), nullptr, 16)));
|
||||
return bytes;
|
||||
}
|
||||
static BeaconSlot slot(bool mt = false) {
|
||||
BeaconSlot result;
|
||||
result.radio.freq = 906.875f;
|
||||
result.radio.bw = 250;
|
||||
result.radio.sf = 11;
|
||||
result.radio.cr = 5;
|
||||
result.radio.preamble = mt ? 16 : 32;
|
||||
result.radio.protocol = mt ? RadioProfileProtocol::Meshtext : RadioProfileProtocol::MeshCore;
|
||||
result.power = 17;
|
||||
result.preset = static_cast<uint8_t>(mt::Preset::LongFast);
|
||||
result.hops = 3;
|
||||
result.enabled = true;
|
||||
strcpy(result.text, "hello");
|
||||
return result;
|
||||
}
|
||||
static void public_frame(const char* expected) {
|
||||
Mesh mesh;
|
||||
const auto before = mesh.radio.profiles;
|
||||
auto config = slot();
|
||||
assert(mesh.validateTextBeacon(config));
|
||||
assert(mesh.queueTextBeacon(config, "Node:name", 0, 0));
|
||||
assert(mesh.queued == unhex(expected));
|
||||
assert(mesh.tables.sent == 1 && mesh.tables.marked == mesh.queued);
|
||||
assert(mesh.obtained == 1 && mesh.released == 1 && mesh.queues == 1);
|
||||
assert(mesh.queued_params == config.radio && mesh.queued_power == 17);
|
||||
assert(mesh.radio.profiles.primary == before.primary);
|
||||
assert(mesh.radio.profiles.secondary.params == before.secondary.params);
|
||||
assert(mesh.radio.profiles.secondary.mode == before.secondary.mode);
|
||||
assert(mesh.radio.profiles.generation[0] == before.generation[0]);
|
||||
assert(mesh.radio.profiles.generation[1] == before.generation[1]);
|
||||
}
|
||||
static void meshtext_frame(const char* expected, bool medium) {
|
||||
Mesh mesh;
|
||||
auto config = slot(true);
|
||||
if (medium) {
|
||||
config.preset = static_cast<uint8_t>(mt::Preset::MediumFast);
|
||||
config.radio.sf = 9;
|
||||
config.hops = 7;
|
||||
}
|
||||
strcpy(config.text, "caf\xc3\xa9");
|
||||
mesh.radio.profiles.secondary.params.freq = 915;
|
||||
mesh.radio.profiles.secondary.mode = RadioProfileMode::RxTx;
|
||||
const auto before = mesh.radio.profiles;
|
||||
assert(mesh.queueTextBeacon(config, "Node", 0x11223344, 0x89abcdef));
|
||||
assert(mesh.queued == unhex(expected));
|
||||
assert(mesh.obtained == 0 && mesh.released == 0 && mesh.tables.sent == 0);
|
||||
assert(mesh.queues == 1 && mesh.queued_params == config.radio);
|
||||
assert(mesh.radio.profiles.secondary.params == before.secondary.params);
|
||||
assert(mesh.radio.profiles.secondary.mode == before.secondary.mode);
|
||||
assert(mesh.radio.profiles.generation[0] == before.generation[0]);
|
||||
assert(mesh.radio.profiles.generation[1] == before.generation[1]);
|
||||
assert((mesh.queued[12] & 0x18) == 0); // No want_ack/via_mqtt bits.
|
||||
mt::DecodedText decoded;
|
||||
assert(mt::decodeText(mesh.queued.data(), mesh.queued.size(), medium ? "MediumFast" : "LongFast",
|
||||
mt::DefaultPsk, sizeof(mt::DefaultPsk), 0x99887766, decoded));
|
||||
assert(!strcmp(decoded.text, "Node: caf\xc3\xa9"));
|
||||
}
|
||||
static void bounds() {
|
||||
char output[beacon::MaxFormattedBytes + 2];
|
||||
uint8_t raw[mt::MaxFrameSize + 1];
|
||||
char name[33], text[82];
|
||||
memset(name, 'n', sizeof(name)); name[31] = 0;
|
||||
memset(text, 't', sizeof(text)); text[80] = 0;
|
||||
memset(output, 0xa5, sizeof(output));
|
||||
assert(beacon::formatText(name, text, output, sizeof(output)) == beacon::MaxFormattedBytes);
|
||||
assert(output[beacon::MaxFormattedBytes] == 0);
|
||||
assert(uint8_t(output[beacon::MaxFormattedBytes + 1]) == 0xa5);
|
||||
assert(!beacon::formatText(name, text, output, beacon::MaxFormattedBytes));
|
||||
assert(!beacon::formatText(nullptr, text, output, sizeof(output)));
|
||||
assert(!beacon::formatText("", text, output, sizeof(output)));
|
||||
assert(!beacon::formatText(name, text, nullptr, sizeof(output)));
|
||||
name[31] = 'n'; name[32] = 0;
|
||||
assert(!beacon::formatText(name, text, output, sizeof(output)));
|
||||
name[31] = 0;
|
||||
text[80] = 't'; text[81] = 0;
|
||||
assert(!beacon::validText(text));
|
||||
assert(!beacon::validText(nullptr) && !beacon::validText(""));
|
||||
for (unsigned control = 1; control <= 127; ++control) {
|
||||
if (control >= 32 && control != 127) continue;
|
||||
const char controlled[] = {'t', static_cast<char>(control), 'x', 0};
|
||||
assert(!beacon::validText(controlled));
|
||||
assert(!beacon::formatText("node", controlled, output, sizeof(output)));
|
||||
}
|
||||
for (const char* invalid : {"\x80", "\xc0\xaf", "\xe0\x80\x80", "\xed\xa0\x80",
|
||||
"\xf0\x80\x80\x80", "\xf4\x90\x80\x80", "\xc2", "\xe2\x82"}) {
|
||||
assert(!beacon::validText(invalid));
|
||||
assert(!beacon::formatText(invalid, "text", output, sizeof(output)));
|
||||
}
|
||||
// Exact maximum byte lengths, including multibyte scalars, stay intact.
|
||||
memset(text, 't', 77); memcpy(text + 77, "\xe2\x82\xac", 4);
|
||||
assert(beacon::validText(text));
|
||||
memset(name, 'n', 27); memcpy(name + 27, "\xf0\x9f\x9a\x80", 5);
|
||||
assert(beacon::formatText(name, text, output, sizeof(output)) == beacon::MaxFormattedBytes);
|
||||
memset(raw, 0xa5, sizeof(raw));
|
||||
assert(!beacon::meshtextFrame(static_cast<mt::Preset>(255), 4, 1, 0, "n", "text", raw));
|
||||
for (uint32_t from : {0U, UINT32_MAX})
|
||||
assert(!beacon::meshtextFrame(mt::Preset::LongFast, from, 1, 0, "n", "text", raw));
|
||||
assert(!beacon::meshtextFrame(mt::Preset::LongFast, 4, 0, 0, "n", "text", raw));
|
||||
assert(!beacon::meshtextFrame(mt::Preset::LongFast, 4, 1, 8, "n", "text", raw));
|
||||
for (uint8_t byte : raw) assert(byte == 0xa5); // Rejection produces no partial frame.
|
||||
assert(beacon::meshtextFrame(mt::Preset::LongFast, 4, 1, 0, name, text, raw));
|
||||
assert(raw[mt::MaxFrameSize] == 0xa5);
|
||||
uint8_t payload[beacon::MaxPublicPayloadBytes + 1];
|
||||
memset(payload, 0xa5, sizeof(payload));
|
||||
assert(!beacon::publicPayload(1, name, text, payload, beacon::MaxPublicPayloadBytes - 1));
|
||||
for (uint8_t byte : payload) assert(byte == 0xa5);
|
||||
assert(beacon::publicPayload(1, name, text, payload, sizeof(payload)) == beacon::MaxPublicPayloadBytes);
|
||||
assert(payload[beacon::MaxPublicPayloadBytes] == 0xa5);
|
||||
}
|
||||
static void admission() {
|
||||
auto config = slot();
|
||||
{ Mesh mesh; mesh.accept = false;
|
||||
assert(!mesh.queueTextBeacon(config, "Node", 0, 0));
|
||||
assert(mesh.obtained == 1 && mesh.released == 1 && mesh.tables.sent == 0 && mesh.queues == 1); }
|
||||
{ Mesh mesh; mesh.pool_available = false;
|
||||
assert(!mesh.queueTextBeacon(config, "Node", 0, 0));
|
||||
assert(mesh.obtained == 0 && mesh.released == 0 && mesh.queues == 0); }
|
||||
{ Mesh mesh; mesh.temporary = true;
|
||||
assert(mesh.queueTextBeacon(config, "Node", 0, 0));
|
||||
assert(mesh.queues == 1 && mesh.obtained == 1 && mesh.released == 1); }
|
||||
{ Mesh mesh; mesh.radio.valid = false;
|
||||
assert(!mesh.validateTextBeacon(config));
|
||||
assert(!mesh.queueTextBeacon(config, "Node", 0, 0) && !mesh.queues); }
|
||||
{ Mesh mesh; config.radio.sf = 0; assert(!mesh.validateTextBeacon(config)); }
|
||||
{ Mesh mesh; config = slot(); config.radio.preamble = 7; assert(!mesh.validateTextBeacon(config)); }
|
||||
{ Mesh mesh; config = slot(); config.radio.preamble = 65528; config.radio.sf = 12;
|
||||
config.radio.bw = 7.8f; assert(!mesh.validateTextBeacon(config)); }
|
||||
{ Mesh mesh; config = slot(); config.power = -10; assert(!mesh.validateTextBeacon(config)); }
|
||||
{ Mesh mesh; config = slot(); config.power = 23; assert(!mesh.validateTextBeacon(config)); }
|
||||
{ Mesh mesh; config = slot(); config.hops = 8; assert(!mesh.validateTextBeacon(config)); }
|
||||
{ Mesh mesh; config = slot(); config.radio.protocol = static_cast<RadioProfileProtocol>(2);
|
||||
assert(!mesh.validateTextBeacon(config)); }
|
||||
{ Mesh mesh; config = slot(true); mesh.radio.meshtext = false; assert(!mesh.validateTextBeacon(config)); }
|
||||
{ Mesh mesh; config = slot(true); config.preset = 255; assert(!mesh.validateTextBeacon(config)); }
|
||||
{ Mesh mesh; config = slot(true); assert(!mesh.queueTextBeacon(config, "Node", 4, 0));
|
||||
assert(!mesh.queues && !mesh.obtained); }
|
||||
{ Mesh mesh; config = slot(); assert(!mesh.queueTextBeacon(config, "", 0, 0));
|
||||
assert(!mesh.queues && !mesh.obtained); }
|
||||
}
|
||||
static void identity(const char* public_hex, const char* expected) {
|
||||
const auto public_key = unhex(public_hex);
|
||||
assert(public_key.size() == 32);
|
||||
const uint32_t wanted = static_cast<uint32_t>(strtoul(expected, nullptr, 10));
|
||||
assert(beacon::nodeId(public_key.data()) == wanted);
|
||||
assert(beacon::nodeId(nullptr) == 0);
|
||||
Mesh mesh;
|
||||
memcpy(mesh.self_id.pub_key, public_key.data(), 32);
|
||||
assert(mesh.beaconNodeId() == wanted && wanted >= 4 && wanted != UINT32_MAX);
|
||||
}
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc >= 2);
|
||||
if (!strcmp(argv[1], "public")) { assert(argc == 3); public_frame(argv[2]); }
|
||||
else if (!strcmp(argv[1], "longfast")) { assert(argc == 3); meshtext_frame(argv[2], false); }
|
||||
else if (!strcmp(argv[1], "mediumfast")) { assert(argc == 3); meshtext_frame(argv[2], true); }
|
||||
else if (!strcmp(argv[1], "bounds")) bounds();
|
||||
else if (!strcmp(argv[1], "admission")) admission();
|
||||
else if (!strcmp(argv[1], "identity")) { assert(argc == 4); identity(argv[2], argv[3]); }
|
||||
else assert(false);
|
||||
puts("PASS");
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class BeaconFramesTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
try:
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher # noqa: F401
|
||||
except ImportError:
|
||||
raise unittest.SkipTest("Python cryptography is required for independent frame vectors")
|
||||
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")
|
||||
required = ["AESCommon.cpp", "AES128.cpp", "AES256.cpp", "BlockCipher.cpp",
|
||||
"Crypto.cpp", "SHA256.cpp", "Hash.cpp"]
|
||||
override = os.environ.get("MESHCORE_CRYPTO_DIR")
|
||||
candidates = [Path(override)] if override else sorted((ROOT / ".pio/libdeps").glob("*/Crypto"))
|
||||
crypto = next((path for path in candidates if all((path / name).is_file() for name in required)), None)
|
||||
if not crypto:
|
||||
raise unittest.SkipTest("an existing rweather/Crypto checkout is required (MESHCORE_CRYPTO_DIR)")
|
||||
cls.temp = tempfile.TemporaryDirectory(prefix="meshcore-beacon-frames-")
|
||||
cls.addClassCleanup(cls.temp.cleanup)
|
||||
work = Path(cls.temp.name)
|
||||
(work / "Stream.h").write_text(
|
||||
"#pragma once\nclass Stream { public: void print(char) {} };\n", encoding="ascii")
|
||||
(work / "Mesh.h").write_text(ADAPTER, encoding="ascii")
|
||||
group_builder = body((ROOT / "src/Mesh.cpp").read_text(encoding="utf-8"),
|
||||
"Packet* Mesh::createGroupDatagram(")
|
||||
cpp = work / "beacon.cpp"
|
||||
cpp.write_text(HARNESS + "\nnamespace mesh {\n" + group_builder + "\n}\n", encoding="ascii")
|
||||
cls.binary = work / ("beacon.exe" if sys.platform == "win32" else "beacon")
|
||||
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
|
||||
if sys.platform.startswith("linux") else [])
|
||||
result = subprocess.run(
|
||||
[compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
|
||||
"-DMESH_ENABLE_TX_BEACONS=1",
|
||||
"-DMESH_ENABLE_MESHTEXT_WIRE=1", "-DMESH_ENABLE_MESHTEXT_CHAT=0",
|
||||
"-DMIN_LORA_TX_POWER=-9", "-DMAX_LORA_TX_POWER=22", *sanitizers,
|
||||
"-I" + str(work), "-I" + str(crypto), "-I" + str(ROOT / "src"), str(cpp),
|
||||
str(ROOT / "src/helpers/BeaconFrames.cpp"), str(ROOT / "src/MeshBeacon.cpp"),
|
||||
str(ROOT / "src/helpers/MeshtextChat.cpp"), str(ROOT / "src/Utils.cpp"),
|
||||
str(ROOT / "src/Packet.cpp"), *[str(crypto / name) for name in required],
|
||||
"-o", str(cls.binary)], capture_output=True, text=True, timeout=90)
|
||||
if result.returncode:
|
||||
raise AssertionError(result.stdout + result.stderr)
|
||||
|
||||
def run_case(self, *arguments):
|
||||
result = subprocess.run([str(self.binary), *arguments], capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("PASS", result.stdout)
|
||||
|
||||
def test_public_frame_matches_independent_aes_ecb_hmac_oracle_and_dedup(self):
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
plain = struct.pack("<I", 0x12345678) + b"\0Node;name: hello"
|
||||
plain += bytes((-len(plain)) % 16)
|
||||
encryptor = Cipher(algorithms.AES(PUBLIC_KEY), modes.ECB()).encryptor()
|
||||
encrypted = encryptor.update(plain) + encryptor.finalize()
|
||||
mac = hmac.digest(PUBLIC_KEY + bytes(16), encrypted, "sha256")[:2]
|
||||
expected = b"\x15\0" + hashlib.sha256(PUBLIC_KEY).digest()[:1] + mac + encrypted
|
||||
self.run_case("public", expected.hex())
|
||||
|
||||
def test_longfast_and_mediumfast_match_independent_aes_ctr_wire_oracle(self):
|
||||
for preset, hops in (("LongFast", 3), ("MediumFast", 7)):
|
||||
with self.subTest(preset=preset):
|
||||
frame = reference_frame(data_text("Node: caf\u00e9".encode()), preset, DEFAULT_KEY,
|
||||
flags=hops | (hops << 5))
|
||||
self.run_case(preset.lower(), frame.hex())
|
||||
|
||||
def test_utf8_size_null_sender_preset_and_no_partial_frame_bounds(self):
|
||||
self.run_case("bounds")
|
||||
|
||||
def test_queue_failure_pool_cleanup_temporary_radio_and_parameter_validation(self):
|
||||
self.run_case("admission")
|
||||
|
||||
def test_beacon_identity_is_domain_separated_from_normal_chat(self):
|
||||
for first in range(16):
|
||||
public = bytes([first]) + bytes(range(1, 32))
|
||||
raw = int.from_bytes(hashlib.sha256(public + b"beacon").digest()[:4], "little")
|
||||
expected = raw if 4 <= raw < 0xFFFFFFFF else 0x0BADF00D
|
||||
chat_raw = int.from_bytes(hashlib.sha256(public).digest()[:4], "little")
|
||||
chat = chat_raw if 4 <= chat_raw < 0xFFFFFFFF else 0x0BADF00D
|
||||
if expected == chat:
|
||||
expected = 4 if expected == 0xFFFFFFFE else expected + 1
|
||||
self.assertNotEqual(expected, chat)
|
||||
self.run_case("identity", public.hex(), str(expected))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Exercise production beacon CLI, scheduling, and transactional persistence.
|
||||
|
||||
The preset/modem helpers are production code; AES is deliberately not used by
|
||||
this fixture. Real-AES wire vectors have their separate meshtext codec suite.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class BeaconManagerTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
if not compiler:
|
||||
raise AssertionError("a C++ compiler is required")
|
||||
cls.work = tempfile.TemporaryDirectory(prefix="meshcore-beacon-manager-")
|
||||
cls.exe = Path(cls.work.name) / "manager"
|
||||
command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-O1", "-g",
|
||||
*(["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
|
||||
if sys.platform.startswith("linux") else []),
|
||||
"-DMESH_ENABLE_TX_BEACONS=1", "-DMESH_ENABLE_MESHTEXT_CHAT=1",
|
||||
"-DMESH_ENABLE_MESHTEXT_WIRE=1",
|
||||
"-I", str(ROOT / "test/fixtures/beacons/mocks"),
|
||||
"-I", str(ROOT / "test/fixtures/radio_profiles/mocks"),
|
||||
"-isystem", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"),
|
||||
str(ROOT / "src/helpers/BeaconManager.cpp"),
|
||||
str(ROOT / "src/helpers/MeshtextChat.cpp"),
|
||||
str(ROOT / "test/fixtures/beacons/manager_test.cpp"), "-o", str(cls.exe)]
|
||||
cls.build_command = command
|
||||
result = subprocess.run(command, capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise AssertionError(result.stdout + result.stderr)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.work.cleanup()
|
||||
|
||||
def run_scenario(self, scenario):
|
||||
result = subprocess.run([str(self.exe), scenario], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_commands_and_bounds(self):
|
||||
self.run_scenario("commands")
|
||||
|
||||
def test_all_intervals_and_wrap(self):
|
||||
self.run_scenario("schedule")
|
||||
|
||||
def test_rolling_guards_and_no_retries(self):
|
||||
self.run_scenario("rolling")
|
||||
|
||||
def test_every_config_short_write_and_recovery(self):
|
||||
self.run_scenario("storage")
|
||||
|
||||
def test_durable_meshtext_ids_fail_closed(self):
|
||||
self.run_scenario("ids")
|
||||
|
||||
def test_only_committed_mutations_cancel_queued_frames(self):
|
||||
self.run_scenario("mutations")
|
||||
|
||||
def test_missing_active_counter_never_rewinds_to_backup(self):
|
||||
self.run_scenario("stale_backup")
|
||||
|
||||
def negative_control(self, name, source, scenario):
|
||||
generated = Path(self.work.name) / (name + ".cpp")
|
||||
generated.write_text(source, encoding="ascii")
|
||||
executable = Path(self.work.name) / name
|
||||
command = list(self.build_command)
|
||||
command[command.index(str(ROOT / "src/helpers/BeaconManager.cpp"))] = str(generated)
|
||||
command[-1] = str(executable)
|
||||
command[1:1] = ["-I", str(ROOT / "src/helpers")]
|
||||
compiled = subprocess.run(command, capture_output=True, text=True)
|
||||
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
|
||||
result = subprocess.run([str(executable), scenario], capture_output=True, text=True)
|
||||
self.assertNotEqual(result.returncode, 0, "old behavior unexpectedly passed the regression")
|
||||
self.assertIn("Assertion", result.stderr)
|
||||
|
||||
def test_negative_control_uncancelled_old_frame_is_detected(self):
|
||||
source = (ROOT / "src/helpers/BeaconManager.cpp").read_text(encoding="ascii")
|
||||
cancellation = "if (mesh_) mesh_->cancelBeaconRadioFrame();"
|
||||
self.assertEqual(source.count(cancellation), 4)
|
||||
self.negative_control("uncancelled", source.replace(cancellation, ""), "mutations")
|
||||
|
||||
def test_negative_control_counter_backup_rewind_is_detected(self):
|
||||
source = (ROOT / "src/helpers/BeaconManager.cpp").read_text(encoding="ascii")
|
||||
held = """ // Missing active metadata does not prove an interrupted rename. A
|
||||
// stale backup can survive a cleanup failure after a newer ID was
|
||||
// transmitted. Never rewind to it, even before the first MT setting.
|
||||
ids_hold_ = true;"""
|
||||
restored = """ if (result != ReadResult::Missing || !fs_->rename(IdBackup, IdPath)) ids_hold_ = true;
|
||||
else result = ReadResult::Valid;"""
|
||||
self.assertEqual(source.count(held), 1)
|
||||
self.negative_control("rewound", source.replace(held, restored), "stale_backup")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,402 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute the actual transient beacon radio methods against a faultable modem."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def method(source, signature):
|
||||
start = source.index(signature)
|
||||
end = source.index("{", start) + 1
|
||||
depth = 1
|
||||
while depth:
|
||||
depth += (source[end] == "{") - (source[end] == "}")
|
||||
end += 1
|
||||
return source[start:end]
|
||||
|
||||
|
||||
HARNESS = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <initializer_list>
|
||||
#include <RadioProfiles.h>
|
||||
#define MESH_ENABLE_TX_BEACONS 1
|
||||
#define MESH_ENABLE_MESHTEXT_WIRE 1
|
||||
#define STATE_IDLE 0
|
||||
#define STATE_RX 1
|
||||
#define STATE_TX_WAIT 3
|
||||
#define RADIOLIB_ERR_NONE 0
|
||||
#define RADIOLIB_ERR_SPI_CMD_TIMEOUT -705
|
||||
#define RADIOLIB_CHANNEL_FREE -15
|
||||
#define MAX_TRANS_UNIT 255
|
||||
#define NF_CALIB_SETTLE_MS 7
|
||||
#define MESH_DEBUG_PRINTLN(...) ((void)0)
|
||||
namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; }
|
||||
using Result = mesh::RadioParamApplyResult;
|
||||
uint32_t now_ms = 100;
|
||||
uint32_t millis() { return now_ms; }
|
||||
uint32_t profileTimestamp(bool) { return now_ms * 1000; }
|
||||
struct Board {
|
||||
unsigned before = 0, after = 0;
|
||||
void onBeforeTransmit() { ++before; }
|
||||
void onAfterTransmit() { ++after; }
|
||||
};
|
||||
struct Modem {
|
||||
bool standby_fail = false, clear_fail = false, param_fail = false, arm_fail = false;
|
||||
int tx_result = 0, scan_result = RADIOLIB_CHANNEL_FREE;
|
||||
float freq = 906, bw = 62.5;
|
||||
uint8_t sf = 7, cr = 5;
|
||||
uint16_t preamble = 32;
|
||||
mesh::RadioProfileProtocol protocol = mesh::RadioProfileProtocol::MeshCore;
|
||||
int8_t power = 20;
|
||||
unsigned standbys = 0, clears = 0, finishes = 0;
|
||||
int standby() { ++standbys; return standby_fail ? -1 : 0; }
|
||||
int clearIrqFlags(uint32_t) { ++clears; return clear_fail ? -1 : 0; }
|
||||
int finishTransmit() { ++finishes; return 0; }
|
||||
int startTransmit(uint8_t*, int) { return tx_result; }
|
||||
int setPreambleLength(uint16_t value) { preamble = value; return 0; }
|
||||
};
|
||||
struct RadioLibWrapper {
|
||||
Modem modem;
|
||||
Modem* _radio = &modem;
|
||||
Board board;
|
||||
Board* _board = &board;
|
||||
mesh::RadioProfiles _profiles;
|
||||
bool _beacon_owned = false, _beacon_restoring = false;
|
||||
uint8_t _beacon_restore_profile = 0;
|
||||
uint32_t _beacon_restore_at = 0, _beacon_cad_timeout = 0;
|
||||
uint8_t state = STATE_RX, _active_profile = 0;
|
||||
uint32_t _profile_generation = 1, _profile_visit_stamp = 0;
|
||||
bool _profile_refresh_required = false;
|
||||
uint16_t _physical_preamble = 32;
|
||||
mesh::RadioProfileProtocol _physical_protocol = mesh::RadioProfileProtocol::MeshCore;
|
||||
float _cur_freq = 906, _cur_bw = 62.5;
|
||||
uint8_t _cur_sf = 7, _cur_cr = 5;
|
||||
int8_t _cur_dbm = 20;
|
||||
bool _params_valid = true, _dbm_valid = true;
|
||||
bool _cw_active = false, pending = false, packet = false, busy = false, calibrating = false;
|
||||
bool _rx_ps_enabled = false, _rx_ps_armed = false, _rx_hold_continuous = false;
|
||||
bool _rx_ps_continuous_fallback = false, _nf_calib_active = false;
|
||||
bool _rx_boosted_gain_valid = true, _cur_rx_boosted_gain = true;
|
||||
bool _wd_last_busy = false;
|
||||
uint8_t _startrx_fails = 0, _irq_probe_failures = 0;
|
||||
uint32_t _rx_ps_rx_us = 50000, _rx_ps_sleep_us = 50000;
|
||||
uint32_t _wd_last_transition = 0, _wd_stuck_thresh = 0, _wd_observe_ms = 0;
|
||||
uint32_t n_wd_soft = 0, n_wd_hard = 0;
|
||||
unsigned long _nf_sample_from = 0;
|
||||
unsigned applications = 0, protocols = 0, power_sets = 0, arms = 0, stops = 0;
|
||||
unsigned deep_calls = 0, resets = 0, recalibrations = 0;
|
||||
unsigned retune_begins = 0, retune_ends = 0, scans = 0;
|
||||
bool deep_ok = true, repair_on_deep = false, power_fail = false, protocol_fail = false;
|
||||
RadioLibWrapper() {
|
||||
_profiles.primary.freq = 906; _profiles.primary.bw = 62.5;
|
||||
_profiles.primary.sf = 7; _profiles.primary.cr = 5;
|
||||
}
|
||||
bool validateProfile(const mesh::RadioProfileParams& p) const {
|
||||
return mesh::RadioProfiles::valid(p) && p.freq <= 960 && p.bw <= 500;
|
||||
}
|
||||
bool isInRecvMode() const { return state == STATE_RX; }
|
||||
bool isCalibratingNoiseFloor() const { return calibrating; }
|
||||
bool isChipBusy() { return busy; }
|
||||
bool isReceivingPacket() { return packet; }
|
||||
bool hasPendingRadioInterrupt() const { return pending; }
|
||||
bool claimRadioInterrupt() { bool had = pending; pending = false; return had; }
|
||||
void beginProfileRetune(bool) { ++retune_begins; }
|
||||
void endProfileRetune(bool) { ++retune_ends; }
|
||||
void cacheParams(float f, float b, uint8_t s, uint8_t c) {
|
||||
_cur_freq = f; _cur_bw = b; _cur_sf = s; _cur_cr = c; _params_valid = true;
|
||||
}
|
||||
uint16_t profilePreamble(uint8_t profile) const { return _profiles.preamble(profile, 32); }
|
||||
uint16_t currentPreambleLength() const { return _physical_preamble; }
|
||||
bool applyParams(float f, float b, uint8_t s, uint8_t c) {
|
||||
++applications;
|
||||
if (modem.param_fail) return false;
|
||||
modem.freq = f; modem.bw = b; modem.sf = s; modem.cr = c;
|
||||
modem.preamble = _physical_preamble;
|
||||
return true;
|
||||
}
|
||||
bool applyProtocol(mesh::RadioProfileProtocol p) {
|
||||
++protocols;
|
||||
if (protocol_fail) return false;
|
||||
modem.protocol = p; return true;
|
||||
}
|
||||
int applyCachedTxPower(int8_t power) {
|
||||
++power_sets; if (power_fail) return -1; modem.power = power; return 0;
|
||||
}
|
||||
uint32_t cadScanTimeoutMillis(uint8_t sf, float) const { return sf * 10; }
|
||||
int performChannelScanWithTimeout(uint32_t timeout) {
|
||||
assert(timeout == _beacon_cad_timeout); ++scans; pending = true;
|
||||
return modem.scan_result;
|
||||
}
|
||||
void stopReceiveDutyCycle() { ++stops; _rx_ps_armed = false; }
|
||||
int startReceiveMode() {
|
||||
++arms;
|
||||
if (modem.arm_fail) return -1;
|
||||
_rx_ps_armed = _rx_ps_enabled; return 0;
|
||||
}
|
||||
uint32_t getEstAirtimeFor(int) { return 100; }
|
||||
bool registerInterruptAction() { return true; }
|
||||
void recalibrateNoiseFloor() { ++recalibrations; }
|
||||
bool applyRxBoostedGainMode(bool) { return true; }
|
||||
bool supportsRadioDeepInit() const { return true; }
|
||||
bool radioDeepInit() {
|
||||
++deep_calls;
|
||||
if (!deep_ok) return false;
|
||||
if (repair_on_deep) {
|
||||
busy = false; modem.standby_fail = modem.clear_fail = modem.param_fail = modem.arm_fail = false;
|
||||
power_fail = protocol_fail = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
void resetAGC() { ++resets; }
|
||||
void idle() { modem.standby(); state = STATE_IDLE; }
|
||||
uint8_t beginReconfigure();
|
||||
bool restoreAfterDeepInit();
|
||||
bool recoverRadio(bool hard);
|
||||
void startRecv();
|
||||
bool startSendRaw(const uint8_t*, int);
|
||||
void onSendFinished();
|
||||
uint32_t getBeaconAirtime(const mesh::RadioProfileParams&, int);
|
||||
void cacheBeaconRestoreParams();
|
||||
Result prepareBeaconTransmit(const mesh::RadioProfileParams&, int8_t);
|
||||
Result restoreBeaconReceive();
|
||||
bool isBeaconChannelBusy();
|
||||
};
|
||||
@METHODS@
|
||||
mesh::RadioProfileParams beacon(bool meshtext = false) {
|
||||
mesh::RadioProfileParams p;
|
||||
p.freq = 915; p.bw = 250; p.sf = 11; p.cr = 5;
|
||||
if (meshtext) p.protocol = mesh::RadioProfileProtocol::Meshtext;
|
||||
return p;
|
||||
}
|
||||
int main() {
|
||||
unsigned cases = 0;
|
||||
for (bool dual : {false, true}) for (bool rxps : {false, true}) for (bool mt : {false, true}) {
|
||||
RadioLibWrapper w; now_ms = 100;
|
||||
if (dual) {
|
||||
w._profiles.secondary.params = w._profiles.primary;
|
||||
w._profiles.secondary.params.freq = 908;
|
||||
w._profiles.secondary.mode = mesh::RadioProfileMode::RxTx;
|
||||
w._active_profile = 1; w._cur_freq = 908; w.modem.freq = 908;
|
||||
}
|
||||
w._rx_ps_enabled = w._rx_ps_armed = rxps;
|
||||
const auto before = w._profiles;
|
||||
assert(w.prepareBeaconTransmit(beacon(mt), 10) == Result::APPLIED);
|
||||
assert(w._beacon_owned && w.state == STATE_IDLE && w.arms == 0);
|
||||
assert(w.modem.freq == 915 && w.modem.power == 10);
|
||||
assert(w.modem.preamble == (mt ? 16 : 32));
|
||||
assert(w._cur_freq == (dual ? 908 : 906) && w._cur_dbm == 20);
|
||||
assert(w._profiles.primary == before.primary && w._profiles.secondary.params == before.secondary.params);
|
||||
assert(w._profiles.generation[0] == before.generation[0] && w._profiles.generation[1] == before.generation[1]);
|
||||
assert(w._profiles.switches == 0 && w._profiles.switch_test_samples[0] == 0);
|
||||
assert(w.beginReconfigure() == 2);
|
||||
w.startRecv(); assert(w.arms == 0);
|
||||
assert(w.recoverRadio(true) == false && w.deep_calls == 0);
|
||||
assert(!w.isBeaconChannelBusy() && !w.pending && w.state == STATE_IDLE);
|
||||
assert(w.restoreBeaconReceive() == Result::APPLIED);
|
||||
assert(!w._beacon_owned && w.state == STATE_RX && w._rx_ps_armed == rxps);
|
||||
assert(w.modem.freq == (dual ? 908 : 906) && w.modem.power == 20);
|
||||
assert(w.modem.protocol == mesh::RadioProfileProtocol::MeshCore);
|
||||
assert(w.recalibrations == 0 && w.deep_calls == 0);
|
||||
++cases;
|
||||
}
|
||||
for (unsigned reason = 0; reason < 5; ++reason) {
|
||||
RadioLibWrapper w;
|
||||
if (reason == 0) w.pending = true;
|
||||
if (reason == 1) w.packet = true;
|
||||
if (reason == 2) w.busy = true;
|
||||
if (reason == 3) w._cw_active = true;
|
||||
if (reason == 4) w.calibrating = true;
|
||||
assert(w.prepareBeaconTransmit(beacon(), 10) == Result::BUSY);
|
||||
assert(!w._beacon_owned && w.applications == 0 && w.power_sets == 0);
|
||||
++cases;
|
||||
}
|
||||
for (unsigned failure = 0; failure < 4; ++failure) {
|
||||
RadioLibWrapper w;
|
||||
if (failure == 0) w.modem.param_fail = true;
|
||||
if (failure == 1) w.protocol_fail = true;
|
||||
if (failure == 2) w.power_fail = true;
|
||||
if (failure == 3) w.modem.standby_fail = true;
|
||||
assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::FAILED && w._beacon_owned);
|
||||
w.repair_on_deep = true;
|
||||
assert(w.restoreBeaconReceive() == Result::APPLIED && !w._beacon_owned);
|
||||
assert(w.modem.freq == 906 && w.modem.protocol == mesh::RadioProfileProtocol::MeshCore && w.modem.power == 20);
|
||||
++cases;
|
||||
}
|
||||
for (unsigned changed = 0; changed < 2; ++changed) {
|
||||
RadioLibWrapper w;
|
||||
w._profiles.secondary.params = w._profiles.primary;
|
||||
w._profiles.secondary.params.freq = 908;
|
||||
w._profiles.secondary.mode = mesh::RadioProfileMode::RxTx;
|
||||
w._active_profile = 1;
|
||||
assert(w.prepareBeaconTransmit(beacon(), 10) == Result::APPLIED);
|
||||
if (changed == 0) {
|
||||
w._profiles.secondary.params.freq = 909; ++w._profiles.generation[1];
|
||||
} else w._profiles.secondary.mode = mesh::RadioProfileMode::Off;
|
||||
assert(w.restoreBeaconReceive() == Result::APPLIED);
|
||||
assert(w.modem.freq == (changed == 0 ? 909 : 906));
|
||||
assert(w._active_profile == (changed == 0 ? 1 : 0));
|
||||
++cases;
|
||||
}
|
||||
for (int cad_result : {RADIOLIB_CHANNEL_FREE, -6, -16, -702}) {
|
||||
RadioLibWrapper w;
|
||||
assert(w.prepareBeaconTransmit(beacon(), 10) == Result::APPLIED);
|
||||
w.modem.scan_result = cad_result;
|
||||
assert(w.isBeaconChannelBusy() == (cad_result != RADIOLIB_CHANNEL_FREE));
|
||||
assert(!w.pending && w.arms == 0 && w.state == STATE_IDLE);
|
||||
assert(w.restoreBeaconReceive() == Result::APPLIED);
|
||||
++cases;
|
||||
}
|
||||
for (bool spi_timeout : {false, true}) {
|
||||
RadioLibWrapper w;
|
||||
assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::APPLIED);
|
||||
w.modem.tx_result = spi_timeout ? RADIOLIB_ERR_SPI_CMD_TIMEOUT : -1;
|
||||
const uint8_t bytes[] = {1, 2};
|
||||
assert(!w.startSendRaw(bytes, 2) && w._beacon_owned && w.deep_calls == 0);
|
||||
assert(w._cur_freq == 906 && w._cur_dbm == 20);
|
||||
assert(w.board.before == 1 && w.board.after == 1);
|
||||
assert(w.restoreBeaconReceive() == Result::APPLIED && w.modem.freq == 906);
|
||||
++cases;
|
||||
}
|
||||
{
|
||||
RadioLibWrapper w;
|
||||
assert(w.prepareBeaconTransmit(beacon(), 10) == Result::APPLIED);
|
||||
const uint8_t bytes[] = {1, 2};
|
||||
assert(w.startSendRaw(bytes, 2));
|
||||
assert(w.restoreBeaconReceive() == Result::BUSY && w._beacon_owned);
|
||||
w.onSendFinished();
|
||||
assert(w.restoreBeaconReceive() == Result::APPLIED);
|
||||
assert(w.board.before == 1 && w.board.after == 1 && w.modem.finishes == 1);
|
||||
++cases;
|
||||
}
|
||||
for (unsigned failure = 0; failure < 5; ++failure) {
|
||||
RadioLibWrapper w; now_ms = UINT32_MAX - 50;
|
||||
assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::APPLIED);
|
||||
if (failure == 0) w.modem.standby_fail = true;
|
||||
if (failure == 1) w.modem.param_fail = true;
|
||||
if (failure == 2) w.modem.clear_fail = true;
|
||||
if (failure == 3) w.modem.arm_fail = true;
|
||||
if (failure == 4) w.busy = true;
|
||||
w.deep_ok = false;
|
||||
assert(w.restoreBeaconReceive() == Result::BUSY && w._beacon_owned);
|
||||
const unsigned before = w.applications;
|
||||
++now_ms;
|
||||
assert(w.restoreBeaconReceive() == Result::BUSY && w.applications == before);
|
||||
now_ms += 100; w.deep_ok = w.repair_on_deep = true;
|
||||
assert(w.restoreBeaconReceive() == Result::APPLIED && !w._beacon_owned);
|
||||
assert(w.modem.freq == 906 && w.modem.power == 20 && w.state == STATE_RX);
|
||||
++cases;
|
||||
}
|
||||
{
|
||||
RadioLibWrapper w;
|
||||
assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::APPLIED);
|
||||
assert(w.restoreAfterDeepInit());
|
||||
assert(w.modem.freq == 906 && w.modem.power == 20);
|
||||
assert(w.modem.protocol == mesh::RadioProfileProtocol::MeshCore && w._beacon_owned);
|
||||
assert(w.restoreBeaconReceive() == Result::APPLIED);
|
||||
++cases;
|
||||
}
|
||||
{
|
||||
RadioLibWrapper w;
|
||||
auto p = beacon();
|
||||
assert(w.getBeaconAirtime(p, 255) > 0);
|
||||
assert(!w.getBeaconAirtime(p, 0) && !w.getBeaconAirtime(p, 256));
|
||||
p.freq = 1000;
|
||||
assert(!w.getBeaconAirtime(p, 20));
|
||||
assert(w.prepareBeaconTransmit(p, 10) == Result::FAILED && !w._beacon_owned);
|
||||
++cases;
|
||||
}
|
||||
std::printf("PASS: %u actual-method beacon radio cases\n", cases);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class BeaconRadioTest(unittest.TestCase):
|
||||
def source(self):
|
||||
source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text()
|
||||
signatures = [
|
||||
"uint8_t RadioLibWrapper::beginReconfigure()",
|
||||
"bool RadioLibWrapper::restoreAfterDeepInit()",
|
||||
"bool RadioLibWrapper::recoverRadio(bool hard)",
|
||||
"void RadioLibWrapper::startRecv()",
|
||||
"bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len)",
|
||||
"void RadioLibWrapper::onSendFinished()",
|
||||
"uint32_t RadioLibWrapper::getBeaconAirtime(",
|
||||
"void RadioLibWrapper::cacheBeaconRestoreParams()",
|
||||
"mesh::RadioParamApplyResult RadioLibWrapper::prepareBeaconTransmit(",
|
||||
"bool RadioLibWrapper::isBeaconChannelBusy()",
|
||||
"mesh::RadioParamApplyResult RadioLibWrapper::restoreBeaconReceive()",
|
||||
]
|
||||
return HARNESS.replace("@METHODS@", "\n\n".join(method(source, name) for name in signatures))
|
||||
|
||||
def run_harness(self, code, expect_pass=True):
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-beacon-radio-") as directory:
|
||||
cpp = Path(directory) / "test.cpp"
|
||||
binary = Path(directory) / "test"
|
||||
cpp.write_text(code, encoding="ascii")
|
||||
command = ["g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "src"),
|
||||
"-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie",
|
||||
str(cpp), "-o", str(binary)]
|
||||
build = subprocess.run(command, capture_output=True, text=True)
|
||||
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
|
||||
env = dict(os.environ, ASAN_OPTIONS="detect_leaks=1:halt_on_error=1",
|
||||
UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1")
|
||||
result = subprocess.run([str(binary)], capture_output=True, text=True, env=env)
|
||||
if expect_pass:
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("PASS:", result.stdout)
|
||||
print(result.stdout.strip())
|
||||
else:
|
||||
self.assertNotEqual(result.returncode, 0, "negative control unexpectedly passed")
|
||||
self.assertIn("Assertion", result.stderr)
|
||||
|
||||
def test_actual_radio_transaction(self):
|
||||
self.run_harness(self.source())
|
||||
|
||||
def test_ownership_and_snapshot_negative_controls(self):
|
||||
source = self.source()
|
||||
mutations = [
|
||||
("if (_beacon_owned && !_beacon_restoring) return 2;", "/* remove ownership guard */"),
|
||||
("if (_beacon_owned && !_beacon_restoring) return;", "/* permit RX on beacon tuple */"),
|
||||
("bool applied = _radio->standby() == RADIOLIB_ERR_NONE",
|
||||
"cacheParams(p.freq, p.bw, p.sf, p.cr); bool applied = _radio->standby() == RADIOLIB_ERR_NONE"),
|
||||
]
|
||||
for old, new in mutations:
|
||||
with self.subTest(mutation=old):
|
||||
self.assertIn(old, source)
|
||||
self.run_harness(source.replace(old, new, 1), expect_pass=False)
|
||||
|
||||
def test_feature_gate_matrix(self):
|
||||
cases = [
|
||||
([], 0, 0),
|
||||
(["STM32_PLATFORM"], 0, 0),
|
||||
(["USE_SX1262"], 0, 0),
|
||||
(["NRF52_PLATFORM", "USE_SX1262"], 0, 1),
|
||||
# The codec is stable across translation units; the concrete
|
||||
# unqualified Indicator driver still declines Meshtext TX.
|
||||
(["NRF52_PLATFORM", "USE_SX1262", "SENSECAP_INDICATOR_LORA"], 0, 1),
|
||||
(["USE_SX1262", "MESH_ENABLE_TX_BEACONS=0"], 0, 0),
|
||||
(["USE_SX1262", "MESH_PRIMARY_ESPNOW=1"], 0, 0),
|
||||
(["ESP32_PLATFORM", "CONFIG_IDF_TARGET_ESP32S3", "COMPANION_RADIO_FULL", "USE_SX1262"], 1, 1),
|
||||
(["ESP32_PLATFORM", "CONFIG_IDF_TARGET_ESP32S3", "COMPANION_RADIO_FULL", "USE_SX1262",
|
||||
"MESH_ENABLE_TX_BEACONS=0"], 1, 1),
|
||||
(["NRF52_PLATFORM", "RAK_4631", "COMPANION_RADIO_FULL", "USE_SX1262"], 1, 1),
|
||||
]
|
||||
for flags, chat, wire in cases:
|
||||
with self.subTest(flags=flags):
|
||||
code = '#include <helpers/MeshtextConfig.h>\n'
|
||||
code += f'static_assert(MESH_ENABLE_MESHTEXT_CHAT == {chat}, "chat gate");\n'
|
||||
code += f'static_assert(MESH_ENABLE_MESHTEXT_WIRE == {wire}, "wire gate");\n'
|
||||
command = ["g++", "-std=c++17", "-fsyntax-only", "-x", "c++", "-I", str(ROOT / "src")]
|
||||
command += ["-D" + flag for flag in flags] + ["-"]
|
||||
result = subprocess.run(command, input=code, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -22,7 +22,8 @@ class MeshtextConfigTest(unittest.TestCase):
|
||||
if self.compiler is None:
|
||||
self.skipTest("a C++ preprocessor is required")
|
||||
|
||||
def effective(self, defines, *, arduino_first=False, sdk_target=None):
|
||||
def effective(self, defines, *, arduino_first=False, sdk_target=None,
|
||||
feature="MESH_ENABLE_MESHTEXT_CHAT"):
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-mt-config-") as folder:
|
||||
work = Path(folder)
|
||||
(work / "Arduino.h").write_text(
|
||||
@@ -37,7 +38,7 @@ class MeshtextConfigTest(unittest.TestCase):
|
||||
if arduino_first:
|
||||
includes.reverse()
|
||||
source = "".join("#include <" + name + ">\n" for name in includes)
|
||||
source += "MESH_ENABLE_MESHTEXT_CHAT\n"
|
||||
source += feature + "\n"
|
||||
result = subprocess.run(
|
||||
[self.compiler, "-E", "-P", "-x", "c++", "-",
|
||||
"-I", str(work), "-I", str(ROOT / "src"),
|
||||
@@ -85,6 +86,114 @@ class MeshtextConfigTest(unittest.TestCase):
|
||||
"USE_SX1262", "MESH_ENABLE_MESHTEXT_CHAT=0"], 0,
|
||||
sdk_target="CONFIG_IDF_TARGET_ESP32S3")
|
||||
|
||||
def test_tx_beacon_wire_support_does_not_enable_companion_chat(self):
|
||||
for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM", "RP2040_PLATFORM"):
|
||||
flags = [platform, "USE_SX1262"]
|
||||
self.assert_orders(flags, 0)
|
||||
self.assert_orders(flags, 1, feature="MESH_ENABLE_MESHTEXT_WIRE")
|
||||
self.assert_orders(flags + ["MESH_ENABLE_TX_BEACONS=0"], 0,
|
||||
feature="MESH_ENABLE_MESHTEXT_WIRE")
|
||||
self.assert_orders(flags + ["MESH_PRIMARY_ESPNOW=1"], 1,
|
||||
feature="MESH_ENABLE_MESHTEXT_WIRE")
|
||||
self.assert_orders(flags + ["SENSECAP_INDICATOR_LORA"], 1,
|
||||
feature="MESH_ENABLE_MESHTEXT_WIRE")
|
||||
self.assert_orders([platform, "USE_LR1110"], 1,
|
||||
feature="MESH_ENABLE_MESHTEXT_WIRE")
|
||||
for flags in (["STM32_PLATFORM", "USE_SX1262"], []):
|
||||
self.assert_orders(flags, 0, feature="MESH_ENABLE_MESHTEXT_WIRE")
|
||||
|
||||
def test_late_variant_chip_discovery_does_not_change_wire_layout(self):
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-mt-late-variant-") as folder:
|
||||
work = Path(folder)
|
||||
# Nano G2 Ultra declares USE_SX1262 in its Arduino variant header,
|
||||
# not in global flags. The codec translation unit omits Arduino.
|
||||
(work / "Arduino.h").write_text(
|
||||
"#pragma once\n#define USE_SX1262 1\n", encoding="ascii")
|
||||
for arduino_first in (False, True):
|
||||
source = "#include <Arduino.h>\n" if arduino_first else ""
|
||||
source += "#include <helpers/MeshtextConfig.h>\n"
|
||||
source += "static_assert(MESH_ENABLE_MESHTEXT_CHAT == 0, \"chat stays disabled\");\n"
|
||||
source += "static_assert(MESH_ENABLE_MESHTEXT_WIRE == 1, \"consistent wire layout\");\n"
|
||||
result = subprocess.run(
|
||||
[self.compiler, "-std=c++17", "-fsyntax-only", "-x", "c++", "-",
|
||||
"-DNRF52_PLATFORM", "-I", str(work), "-I", str(ROOT / "src")],
|
||||
input=source, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_actual_driver_capability_and_mc_only_no_spi(self):
|
||||
source = (ROOT / "src/helpers/radiolib/CustomSX1262Wrapper.h").read_text()
|
||||
support = source.index(" bool supportsMeshtext() const override")
|
||||
start = source.rfind("#if", 0, support)
|
||||
end = source.index("#endif", support) + len("#endif")
|
||||
capability = source[start:end]
|
||||
start = source.index("bool applyProtocol(")
|
||||
end = source.index("{", start) + 1
|
||||
depth = 1
|
||||
while depth:
|
||||
depth += (source[end] == "{") - (source[end] == "}")
|
||||
end += 1
|
||||
protocol = source[start:end].replace(" override", "")
|
||||
code = r'''
|
||||
#include <helpers/MeshtextConfig.h>
|
||||
#include <cstdint>
|
||||
#include <cassert>
|
||||
#define RADIOLIB_ERR_NONE 0
|
||||
namespace mesh { enum class RadioProfileProtocol { MeshCore, Meshtext }; }
|
||||
struct Base { virtual bool supportsMeshtext() const { return false; } };
|
||||
struct CustomSX1262 {
|
||||
unsigned writes = 0;
|
||||
int setProfileProtocol(uint8_t) { ++writes; return 0; }
|
||||
};
|
||||
struct Actual : Base {
|
||||
CustomSX1262 chip;
|
||||
CustomSX1262* _radio = &chip;
|
||||
@CAPABILITY@
|
||||
#if MESH_ENABLE_MESHTEXT_WIRE
|
||||
@PROTOCOL@
|
||||
#endif
|
||||
};
|
||||
int main() {
|
||||
Actual driver;
|
||||
assert(driver.supportsMeshtext() == EXPECTED);
|
||||
#if MESH_ENABLE_MESHTEXT_WIRE
|
||||
assert(driver.applyProtocol(mesh::RadioProfileProtocol::MeshCore));
|
||||
assert(driver.applyProtocol(mesh::RadioProfileProtocol::Meshtext) == EXPECTED);
|
||||
assert(driver.chip.writes == (EXPECTED ? 2U : 0U));
|
||||
#endif
|
||||
}
|
||||
'''.replace("@CAPABILITY@", capability).replace("@PROTOCOL@", protocol)
|
||||
cases = [
|
||||
(["NRF52_PLATFORM", "USE_SX1262"], True),
|
||||
(["NRF52_PLATFORM", "USE_SX1262", "SENSECAP_INDICATOR_LORA"], False),
|
||||
(["NRF52_PLATFORM", "USE_SX1262", "SENSECAP_INDICATOR_LORA",
|
||||
"MESH_ENABLE_MESHTEXT_CHAT=1"], True),
|
||||
(["NRF52_PLATFORM", "USE_SX1262", "MESH_ENABLE_MESHTEXT_WIRE=0"], False),
|
||||
(["NRF52_PLATFORM", "USE_SX1262", "MESH_ENABLE_MESHTEXT_CHAT=1",
|
||||
"MESH_ENABLE_MESHTEXT_WIRE=0"], False),
|
||||
]
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-mt-driver-gate-") as folder:
|
||||
binary = Path(folder) / "test"
|
||||
for flags, expected in cases:
|
||||
with self.subTest(flags=flags):
|
||||
command = [self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-x", "c++", "-", "-I", str(ROOT / "src"), "-o", str(binary)]
|
||||
command += ["-D" + flag for flag in flags] + ["-DEXPECTED=" + str(int(expected))]
|
||||
result = subprocess.run(command, input=code, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
result = subprocess.run([str(binary)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_chat_keeps_wire_support_with_beacons_disabled(self):
|
||||
flags = ["NRF52_PLATFORM", "RAK_4631", "COMPANION_RADIO_FULL", "USE_SX1262",
|
||||
"MESH_ENABLE_TX_BEACONS=0"]
|
||||
self.assert_orders(flags, 1)
|
||||
self.assert_orders(flags, 1, feature="MESH_ENABLE_MESHTEXT_WIRE")
|
||||
flags = ["ESP32_PLATFORM", "ARDUINO=10819", "COMPANION_RADIO_FULL", "USE_SX1262",
|
||||
"MESH_ENABLE_TX_BEACONS=0"]
|
||||
self.assert_orders(flags, 1, sdk_target="CONFIG_IDF_TARGET_ESP32S3")
|
||||
self.assert_orders(flags, 1, feature="MESH_ENABLE_MESHTEXT_WIRE",
|
||||
sdk_target="CONFIG_IDF_TARGET_ESP32S3")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -277,7 +277,8 @@ int main() {
|
||||
}
|
||||
}
|
||||
'''
|
||||
self.compile_and_run(harness + methods + cases, ['-DMESH_ENABLE_MESHTEXT_CHAT=1'])
|
||||
self.compile_and_run(harness + methods + cases,
|
||||
['-DMESH_ENABLE_MESHTEXT_CHAT=1', '-DMESH_ENABLE_MESHTEXT_WIRE=1'])
|
||||
|
||||
def test_sx1262_wire_settings_and_deep_recovery(self):
|
||||
wrapper = (ROOT/'src/helpers/radiolib/CustomSX1262Wrapper.h').read_text()
|
||||
@@ -314,6 +315,8 @@ struct CustomSX1262: SX1262 {
|
||||
};
|
||||
struct RadioLibWrapper {
|
||||
CustomSX1262 chip;CustomSX1262* _radio=&chip;
|
||||
bool supported=true;
|
||||
bool supportsMeshtext() const { return supported; }
|
||||
bool _rx_ps_armed=true,_params_valid=true,_dbm_valid=false,_rx_boosted_gain_valid=false;
|
||||
int state=1;uint8_t _cur_sf=11,_cur_cr=5;float _cur_freq=906.875,_cur_bw=250;
|
||||
int8_t _cur_dbm=22;bool _cur_rx_boosted_gain=false;
|
||||
@@ -343,11 +346,14 @@ int main() {
|
||||
assert(w.chip.sync==0x2b && w.chip.crc && w.chip.explicit_header && !w.chip.iq);
|
||||
assert(!w._rx_ps_armed && w.state==STATE_IDLE);
|
||||
w._physical_protocol=Protocol::MeshCore;assert(w.restoreAfterDeepInit());assert(w.chip.sync==0x12);
|
||||
RadioLibWrapper disabled;disabled.supported=false;
|
||||
assert(disabled.applyProtocol(Protocol::MeshCore));
|
||||
assert(!disabled.applyProtocol(Protocol::Meshtext) && disabled.chip.step==0);
|
||||
}
|
||||
'''
|
||||
self.compile_and_run('#include <initializer_list>\n' + harness.replace('@WIRE@',wire)
|
||||
.replace('@PROTOCOL@',protocol).replace('@RESTORE@',restore),
|
||||
['-DMESH_ENABLE_MESHTEXT_CHAT=1'])
|
||||
['-DMESH_ENABLE_MESHTEXT_CHAT=1', '-DMESH_ENABLE_MESHTEXT_WIRE=1'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in new issue
Block a user