576 lines
27 KiB
Python
576 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""Keep constrained Mini Repeater builds compatible with the MQTT packet log.
|
|
|
|
Compile the production repeater RX method, Dispatcher TX method, packet wire
|
|
codec and bounded serial writer under all eight feature-gate combinations.
|
|
Hardware transport/radio are stand-ins; this does not qualify physical USB/RF.
|
|
"""
|
|
|
|
import configparser
|
|
import itertools
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
from cpp_source import body
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
HARNESS = r'''
|
|
#include <Arduino.h>
|
|
inline uint32_t micros() { return millis() * 1000U; }
|
|
#include <Packet.h>
|
|
#include <helpers/CarrierTools.h>
|
|
#include <helpers/ManagementFeatureConfig.h>
|
|
#include <helpers/PacketFileLoggingConfig.h>
|
|
#include <helpers/SerialPacketLog.h>
|
|
#include "@PRODUCTION_UTILS@"
|
|
#define MAX_ROUTE_HASH_BYTES @ROUTE_HASH_LIMIT@
|
|
static const uint8_t FLOOD_RETRY_BRIDGE_OTHER_BUCKET = @BRIDGE_BUCKET_COUNT@;
|
|
@BUCKET_MASK_METHOD@
|
|
#include <algorithm>
|
|
#include <cassert>
|
|
#include <cstdarg>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <limits>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
static_assert(MESH_PACKET_LOGGING == 1, "packet logging must remain compiled");
|
|
static_assert(MESH_PACKET_LOGGING_COMPACT == 1, "retain compact serial protocol");
|
|
static_assert(MESH_ENABLE_CARRIER_TOOLS == EXPECT_CARRIER, "exercise actual flag");
|
|
static_assert(MESH_ENABLE_MANAGEMENT_REPORTING == EXPECT_MANAGEMENT, "exercise actual flag");
|
|
static_assert(MESH_ENABLE_PACKET_FILE_LOGGING == EXPECT_FILE_LOGGING, "exercise actual flag");
|
|
|
|
struct Port : Stream {
|
|
std::string output;
|
|
bool stalled = false;
|
|
size_t chunk = 7, wedge_after = std::numeric_limits<size_t>::max();
|
|
int availableForWrite() override {
|
|
return stalled || output.size() >= wedge_after ? 0 : int(chunk);
|
|
}
|
|
size_t write(const uint8_t* data, size_t size) override {
|
|
if (!availableForWrite()) return 0;
|
|
size = std::min(size, chunk);
|
|
output.append(reinterpret_cast<const char*>(data), size);
|
|
return size;
|
|
}
|
|
} log_port;
|
|
|
|
namespace mesh {
|
|
bool logging_enabled = true;
|
|
bool isUsbLoggingEnabled() { return logging_enabled; }
|
|
Stream& usbLoggingPort() { return log_port; }
|
|
void Utils::toHex(char* destination, const uint8_t* source, size_t size) {
|
|
const char* digits = "0123456789ABCDEF";
|
|
while (size--) {
|
|
const uint8_t value = *source++;
|
|
*destination++ = digits[value >> 4];
|
|
*destination++ = digits[value & 15];
|
|
}
|
|
*destination = 0;
|
|
}
|
|
|
|
@PACKET_METHODS@
|
|
|
|
enum class RadioParamApplyResult { APPLIED, BUSY };
|
|
struct Profile { uint8_t cr = 5; };
|
|
struct Profiles {
|
|
Profile value;
|
|
const Profile& params(uint8_t) const { return value; }
|
|
};
|
|
struct Radio {
|
|
bool send_ok = true;
|
|
unsigned sends = 0;
|
|
std::vector<uint8_t> raw;
|
|
Profiles settings;
|
|
RadioParamApplyResult prepareTransmitProfile(uint8_t, bool) {
|
|
return RadioParamApplyResult::APPLIED;
|
|
}
|
|
Profiles* profiles() { return &settings; }
|
|
uint32_t getEstAirtimeFor(int) { return 10; }
|
|
bool setCodingRate(uint8_t) { return true; }
|
|
bool startSendRaw(const uint8_t* bytes, int length) {
|
|
++sends;
|
|
if (send_ok) raw.assign(bytes, bytes + length);
|
|
return send_ok;
|
|
}
|
|
float getLastSNR() const { return 2.5f; }
|
|
float getLastRSSI() const { return -92; }
|
|
};
|
|
struct Clock { uint32_t getMillis() const { return millis(); } };
|
|
struct Dispatcher {
|
|
Packet* outbound = nullptr;
|
|
Radio radio;
|
|
Radio* _radio = &radio;
|
|
Clock clock;
|
|
Clock* _ms = &clock;
|
|
uint8_t outbound_restore_cr = 0;
|
|
uint32_t outbound_start = 0, outbound_expiry = 0;
|
|
bool restoreOutboundTxOverrides() { return true; }
|
|
bool isPacketRadioCurrent(const Packet*) const { return true; }
|
|
uint8_t getDefaultTxCodingRate() const { return 5; }
|
|
uint32_t futureMillis(uint32_t wait) const { return millis() + wait; }
|
|
const char* getLogDateTime() const { return "unused compact timestamp"; }
|
|
bool startOutboundTransmit();
|
|
};
|
|
|
|
@TX_METHOD@
|
|
} // namespace mesh
|
|
|
|
#define PACKET_LOG_FILE "/packet_log"
|
|
struct FakeFS {
|
|
unsigned opens = 0, writes = 0, removes = 0;
|
|
std::string output;
|
|
void remove(const char* name) {
|
|
assert(!std::strcmp(name, PACKET_LOG_FILE));
|
|
++removes;
|
|
}
|
|
};
|
|
struct File {
|
|
FakeFS* fs;
|
|
explicit operator bool() const { return true; }
|
|
void print(const char* text) { ++fs->writes; fs->output += text; }
|
|
void printf(const char* format, ...) {
|
|
char text[1024];
|
|
va_list args;
|
|
va_start(args, format);
|
|
const int length = std::vsnprintf(text, sizeof(text), format, args);
|
|
va_end(args);
|
|
assert(length >= 0 && size_t(length) < sizeof(text));
|
|
++fs->writes;
|
|
fs->output += text;
|
|
}
|
|
void close() {}
|
|
};
|
|
struct Callbacks {
|
|
virtual void setLoggingOn(bool enable) = 0;
|
|
virtual void eraseLogFile() = 0;
|
|
};
|
|
inline uint8_t getRetryLogCodingRate(const mesh::Packet*, uint8_t fallback) { return fallback; }
|
|
inline uint16_t rxPowerSavingPreambleForParams(uint8_t, float) { return 16; }
|
|
unsigned direct_format_calls = 0;
|
|
void formatDirectRetryTarget(char* output, size_t size, const uint8_t*, uint8_t) {
|
|
++direct_format_calls;
|
|
std::snprintf(output, size, "01");
|
|
}
|
|
struct MyMesh : Callbacks {
|
|
struct { bool flood_retry_bridge_enabled = false; } _prefs;
|
|
struct FloodRetryBridgeState {
|
|
uint8_t source_mask = 0, target_mask = 0, progress_marker = 0;
|
|
};
|
|
mutable FloodRetryBridgeState retry_state;
|
|
bool has_retry_state = false;
|
|
bool _logging = false;
|
|
FakeFS fs;
|
|
FakeFS* _fs = &fs;
|
|
mesh::Radio radio;
|
|
mesh::Radio* _radio = &radio;
|
|
unsigned refreshed = 0;
|
|
uint8_t active_sf = 7;
|
|
float active_bw = 125;
|
|
File openAppend(const char* name) {
|
|
assert(!std::strcmp(name, PACKET_LOG_FILE));
|
|
++fs.opens;
|
|
return File{&fs};
|
|
}
|
|
const char* getLogDateTime() const { return "timestamp"; }
|
|
uint8_t getDefaultTxCodingRate() const { return 5; }
|
|
void formatFloodRetryPath(char* output, size_t size, const mesh::Packet*) {
|
|
std::snprintf(output, size, "01");
|
|
}
|
|
bool formatFloodRetryHeard(char* output, size_t size, const mesh::Packet* packet) const;
|
|
FloodRetryBridgeState* floodRetryBridgeStateFor(const mesh::Packet*, bool) const {
|
|
return has_retry_state ? &retry_state : nullptr;
|
|
}
|
|
uint8_t floodRetryBucketMaskForPathHop(const uint8_t* prefix, uint8_t,
|
|
uint8_t, uint8_t) const { return *prefix; }
|
|
uint8_t floodRetryBridgeHeardMask(const mesh::Packet* packet,
|
|
uint8_t source_mask, uint8_t progress_marker) const;
|
|
bool hasFloodRetryHeard(const mesh::Packet* packet) const;
|
|
void refreshFloodRetryReachability(const mesh::Packet*) { ++refreshed; }
|
|
@INLINE_CALLBACKS@
|
|
void logRxRaw(float snr, float rssi, const uint8_t raw[], int len);
|
|
void logRx(mesh::Packet* pkt, int len, float score);
|
|
void logTx(mesh::Packet* pkt, int len);
|
|
void logTxFail(mesh::Packet* pkt, int len);
|
|
void onFloodRetryEvent(const char* event, const mesh::Packet* packet,
|
|
uint32_t delay_millis, uint8_t retry_attempt);
|
|
void onDirectRetryEvent(const char* event, const mesh::Packet* packet,
|
|
uint32_t delay_millis, uint8_t retry_attempt,
|
|
const uint8_t* target_hash, uint8_t target_hash_len,
|
|
int16_t payload_type);
|
|
};
|
|
@RX_METHOD@
|
|
@FILE_METHODS@
|
|
|
|
static void reset() {
|
|
log_port = Port{};
|
|
mesh::logging_enabled = true;
|
|
mesh::serialLogDroppedCount() = 0;
|
|
mesh::serialLogPortSeen() = false;
|
|
g_mock_millis = 100;
|
|
}
|
|
static std::string hex(const uint8_t* bytes, size_t size) {
|
|
const char* digits = "0123456789ABCDEF";
|
|
std::string output;
|
|
for (size_t i = 0; i < size; ++i) {
|
|
output += digits[bytes[i] >> 4];
|
|
output += digits[bytes[i] & 15];
|
|
}
|
|
return output;
|
|
}
|
|
static mesh::Packet makePacket(bool maximum = false) {
|
|
mesh::Packet packet;
|
|
packet.header = (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT) | ROUTE_TYPE_TRANSPORT_DIRECT;
|
|
packet.transport_codes[0] = 0x1234;
|
|
packet.transport_codes[1] = 0xABCD;
|
|
packet.setPathHashSizeAndCount(2, maximum ? 32 : 2);
|
|
for (unsigned i = 0; i < packet.getPathByteLen(); ++i) packet.path[i] = uint8_t(i);
|
|
packet.payload_len = maximum ? MAX_PACKET_PAYLOAD : 3;
|
|
for (unsigned i = 0; i < packet.payload_len; ++i) packet.payload[i] = uint8_t(i ^ 0xAB);
|
|
return packet;
|
|
}
|
|
|
|
int main() {
|
|
MyMesh direct_diagnostics;
|
|
direct_diagnostics.setLoggingOn(true);
|
|
direct_diagnostics.onDirectRetryEvent("good", nullptr, 12, 1, nullptr, 0, 3);
|
|
assert(direct_format_calls == unsigned(EXPECT_FILE_LOGGING));
|
|
assert(direct_diagnostics.fs.opens == unsigned(EXPECT_FILE_LOGGING));
|
|
if (EXPECT_FILE_LOGGING)
|
|
assert(direct_diagnostics.fs.output.find(": direct retry good") != std::string::npos);
|
|
MyMesh repeater;
|
|
// Actual inline callbacks and persistent packet emitters remain independent
|
|
// of live USB logging. The file-off control cannot be re-enabled at runtime.
|
|
assert(!repeater._logging);
|
|
repeater.setLoggingOn(true);
|
|
assert(repeater._logging == bool(EXPECT_FILE_LOGGING));
|
|
mesh::Packet stored = makePacket();
|
|
repeater.logRx(&stored, 42, 0.25f);
|
|
repeater.logTx(&stored, 42);
|
|
repeater.logTxFail(&stored, 42);
|
|
repeater.onFloodRetryEvent("good", &stored, 12, 1);
|
|
assert(repeater.refreshed == 1); // Reachability is not a logging side effect.
|
|
repeater.onFloodRetryEvent("good", nullptr, 12, 1);
|
|
repeater.onFloodRetryEvent("failure", &stored, 12, 1);
|
|
assert(repeater.refreshed == 1);
|
|
repeater.eraseLogFile();
|
|
if (EXPECT_FILE_LOGGING) {
|
|
assert(repeater.fs.opens == 5 && repeater.fs.writes > 0 && repeater.fs.removes == 1);
|
|
for (const char* marker : {": RX,", ": TX,", ": TX FAIL!", ": FLOOD RETRY good"})
|
|
assert(repeater.fs.output.find(marker) != std::string::npos);
|
|
} else {
|
|
assert(repeater.fs.opens == 0 && repeater.fs.writes == 0 && repeater.fs.removes == 0);
|
|
assert(repeater.fs.output.empty());
|
|
}
|
|
repeater.setLoggingOn(false);
|
|
assert(!repeater._logging);
|
|
const unsigned old_opens = repeater.fs.opens;
|
|
repeater.logRx(&stored, 42, 0.25f);
|
|
repeater.logTx(&stored, 42);
|
|
repeater.logTxFail(&stored, 42);
|
|
repeater.onFloodRetryEvent("good", &stored, 12, 1);
|
|
assert(repeater.refreshed == 2 && repeater.fs.opens == old_opens);
|
|
|
|
// The unlogged path must make the same routing decision as the textual
|
|
// formatter: a real target echo, excluding source and progress-marker hops.
|
|
MyMesh routing;
|
|
mesh::Packet heard = makePacket();
|
|
heard.header = (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD;
|
|
heard.setPathHashSizeAndCount(1, 3);
|
|
heard.path[0] = 1; heard.path[1] = 2; heard.path[2] = 4;
|
|
assert(routing.hasFloodRetryHeard(&heard));
|
|
assert(!routing.hasFloodRetryHeard(nullptr));
|
|
const uint8_t valid_path = heard.path_len;
|
|
heard.path_len = 0;
|
|
assert(!routing.hasFloodRetryHeard(&heard));
|
|
heard.path_len = 0xC1; // Reserved four-byte route-hash mode.
|
|
assert(!routing.hasFloodRetryHeard(&heard));
|
|
heard.path_len = valid_path;
|
|
routing._prefs.flood_retry_bridge_enabled = true;
|
|
assert(!routing.hasFloodRetryHeard(&heard)); // No pending bridge retry.
|
|
routing.has_retry_state = true;
|
|
routing.retry_state = {1, 4, 2};
|
|
assert(routing.hasFloodRetryHeard(&heard));
|
|
routing.onFloodRetryEvent("good", &heard, 12, 1);
|
|
assert(routing.refreshed == 1);
|
|
routing.retry_state.target_mask = 2; // Progress marker is not an echo.
|
|
assert(!routing.hasFloodRetryHeard(&heard));
|
|
routing.onFloodRetryEvent("good", &heard, 12, 1);
|
|
assert(routing.refreshed == 1);
|
|
routing.retry_state.target_mask = 1; // The source is not a target echo.
|
|
assert(!routing.hasFloodRetryHeard(&heard));
|
|
routing.retry_state = {0, 2, 0};
|
|
assert(routing.hasFloodRetryHeard(&heard));
|
|
routing.onFloodRetryEvent("queued", &heard, 12, 1);
|
|
routing.onFloodRetryEvent("failure", &heard, 12, 1);
|
|
assert(routing.refreshed == 1);
|
|
routing.onFloodRetryEvent("good", &heard, 12, 1);
|
|
assert(routing.refreshed == 2);
|
|
// Compare the production eligibility check with the production formatter's
|
|
// success result using its actual 96-byte caller buffer, including every
|
|
// source/target/progress combination for representative route-hash sizes.
|
|
for (unsigned hash_size = 1; hash_size <= MAX_ROUTE_HASH_BYTES; ++hash_size) {
|
|
heard.setPathHashSizeAndCount(hash_size, 3);
|
|
memset(heard.path, 0, sizeof(heard.path));
|
|
heard.path[0] = 1;
|
|
heard.path[hash_size] = 2;
|
|
heard.path[hash_size * 2] = uint8_t(1U << FLOOD_RETRY_BRIDGE_OTHER_BUCKET);
|
|
for (unsigned source = 0; source < 8; ++source)
|
|
for (unsigned target : {0U, 1U, 2U, 3U, 64U, 127U})
|
|
for (unsigned progress = 0; progress <= 3; ++progress) {
|
|
routing.retry_state = {uint8_t(source), uint8_t(target), uint8_t(progress)};
|
|
char formatted[96];
|
|
assert(routing.hasFloodRetryHeard(&heard)
|
|
== routing.formatFloodRetryHeard(formatted, sizeof(formatted), &heard));
|
|
}
|
|
}
|
|
|
|
const uint8_t raw[] = {0x00, 0xAB, 0xFF};
|
|
reset();
|
|
repeater.logRxRaw(2.5f, -92, raw, sizeof(raw));
|
|
assert(log_port.output == "R00ABFF\n");
|
|
assert(g_mock_millis == 100 && mesh::serialLogDroppedCount() == 0);
|
|
|
|
uint8_t maximum_rx[MAX_TRANS_UNIT];
|
|
for (unsigned i = 0; i < sizeof(maximum_rx); ++i) maximum_rx[i] = uint8_t(i);
|
|
reset();
|
|
repeater.logRxRaw(0, -100, maximum_rx, sizeof(maximum_rx));
|
|
assert(log_port.output == "R" + hex(maximum_rx, sizeof(maximum_rx)) + "\n");
|
|
assert(log_port.output.size() == 2 + MAX_TRANS_UNIT * 2);
|
|
|
|
for (const bool maximum : {false, true}) {
|
|
mesh::Packet packet = makePacket(maximum);
|
|
uint8_t wire[MAX_TRANS_UNIT];
|
|
const size_t length = packet.writeTo(wire);
|
|
assert(length == size_t(packet.getRawLength()));
|
|
if (maximum) assert(length == 254);
|
|
// Exercise real decoding/bounds too: feature gates must not alter packet
|
|
// framing, transport codes, path bytes, payload or accepted wire lengths.
|
|
mesh::Packet decoded;
|
|
assert(decoded.readFrom(wire, uint8_t(length)));
|
|
assert(decoded.header == packet.header && decoded.path_len == packet.path_len);
|
|
assert(decoded.payload_len == packet.payload_len);
|
|
assert(!std::memcmp(decoded.payload, packet.payload, packet.payload_len));
|
|
assert(!decoded.readFrom(wire, 1));
|
|
auto invalid = std::vector<uint8_t>(wire, wire + length);
|
|
invalid[5] = 0xC1; // reserved four-byte path mode
|
|
assert(!decoded.readFrom(invalid.data(), uint8_t(invalid.size())));
|
|
|
|
reset();
|
|
mesh::Dispatcher dispatcher;
|
|
dispatcher.outbound = &packet;
|
|
assert(dispatcher.startOutboundTransmit());
|
|
assert(dispatcher.radio.raw == std::vector<uint8_t>(wire, wire + length));
|
|
assert(log_port.output == "T" + hex(wire, length) + "\n");
|
|
assert(mesh::serialLogDroppedCount() == 0);
|
|
|
|
reset();
|
|
mesh::logging_enabled = false;
|
|
repeater.logRxRaw(0, -100, maximum_rx, sizeof(maximum_rx));
|
|
assert(dispatcher.startOutboundTransmit());
|
|
assert(log_port.output.empty());
|
|
assert(dispatcher.radio.raw == std::vector<uint8_t>(wire, wire + length));
|
|
|
|
reset();
|
|
dispatcher.radio.send_ok = false;
|
|
assert(!dispatcher.startOutboundTransmit());
|
|
assert(log_port.output.empty()); // Never claim a TX which did not start.
|
|
}
|
|
|
|
// An initially undrained host must cost neither time nor a false drop.
|
|
reset();
|
|
log_port.stalled = true;
|
|
repeater.logRxRaw(0, -100, raw, sizeof(raw));
|
|
assert(log_port.output.empty() && g_mock_millis == 100);
|
|
assert(mesh::serialLogDroppedCount() == 0);
|
|
|
|
// Once a host was observed, refused RX and TX records count as lost. RF
|
|
// transmission still proceeds; recovery exposes the existing DROP marker.
|
|
reset();
|
|
repeater.logRxRaw(0, -100, raw, sizeof(raw));
|
|
log_port.output.clear();
|
|
log_port.stalled = true;
|
|
repeater.logRxRaw(0, -100, raw, sizeof(raw));
|
|
mesh::Packet packet = makePacket();
|
|
mesh::Dispatcher dispatcher;
|
|
dispatcher.outbound = &packet;
|
|
assert(dispatcher.startOutboundTransmit() && dispatcher.radio.sends == 1);
|
|
assert(log_port.output.empty() && g_mock_millis == 100);
|
|
assert(mesh::serialLogDroppedCount() == 2);
|
|
log_port.stalled = false;
|
|
repeater.logRxRaw(0, -100, raw, sizeof(raw));
|
|
assert(log_port.output == "DROP:2\r\nR00ABFF\n");
|
|
|
|
// Keep the bounded writer's existing mid-record stall behavior explicit.
|
|
reset();
|
|
mesh::serialLogPortSeen() = true;
|
|
log_port.wedge_after = 14;
|
|
repeater.logRxRaw(0, -100, maximum_rx, sizeof(maximum_rx));
|
|
assert(log_port.output.size() == 14);
|
|
assert(g_mock_millis - 100 <= SERIAL_LOG_WRITE_BUDGET_MS + 1);
|
|
assert(mesh::serialLogDroppedCount() == 1);
|
|
std::puts("PASS: production compact repeater RX/TX logging survives all feature gates");
|
|
}
|
|
'''
|
|
|
|
|
|
class MiniRepeaterLoggingTest(unittest.TestCase):
|
|
def test_actual_raw_rx_tx_packet_codec_and_backpressure_with_all_gate_combinations(self):
|
|
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
|
|
self.assertIsNotNone(compiler, "a host C++17 compiler is required")
|
|
packet = (ROOT / "src/Packet.cpp").read_text()
|
|
packet_methods = "\n".join(body(packet, signature) for signature in (
|
|
"Packet::Packet()", "bool Packet::isValidPathLen(",
|
|
"size_t Packet::writePath(", "int Packet::getRawLength() const",
|
|
"uint8_t Packet::writeTo(", "bool Packet::readFrom("))
|
|
dispatcher = (ROOT / "src/Dispatcher.cpp").read_text()
|
|
repeater = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text()
|
|
route_hash_limit = re.search(r"^#define MAX_ROUTE_HASH_BYTES\s+(\d+)\s*$",
|
|
(ROOT / "src/helpers/SimpleMeshTables.h").read_text(), re.MULTILINE)
|
|
self.assertIsNotNone(route_hash_limit)
|
|
bridge_bucket_count = re.search(r"^\s*#define FLOOD_RETRY_BRIDGE_BUCKETS\s+(\d+)\s*$",
|
|
(ROOT / "src/helpers/CommonCLI.h").read_text(), re.MULTILINE)
|
|
self.assertIsNotNone(bridge_bucket_count)
|
|
program = HARNESS.replace("@ROUTE_HASH_LIMIT@", route_hash_limit.group(1))
|
|
program = program.replace("@PRODUCTION_UTILS@", str(ROOT / "src/Utils.h"))
|
|
program = program.replace("@BRIDGE_BUCKET_COUNT@", bridge_bucket_count.group(1))
|
|
program = program.replace("@BUCKET_MASK_METHOD@",
|
|
body(repeater, "static uint8_t floodRetryBucketMask("))
|
|
program = program.replace("@PACKET_METHODS@", packet_methods).replace(
|
|
"@TX_METHOD@", body(dispatcher, "bool Dispatcher::startOutboundTransmit()"))
|
|
program = program.replace("@RX_METHOD@", body(repeater, "void MyMesh::logRxRaw("))
|
|
header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text()
|
|
program = program.replace("@INLINE_CALLBACKS@", "\n".join(body(header, signature) for signature in (
|
|
"void setLoggingOn(", "void eraseLogFile()")))
|
|
program = program.replace("@FILE_METHODS@", "\n".join(body(repeater, signature) for signature in (
|
|
"void MyMesh::logRx(", "void MyMesh::logTx(", "void MyMesh::logTxFail(",
|
|
"void MyMesh::onFloodRetryEvent(", "bool MyMesh::hasFloodRetryHeard(",
|
|
"uint8_t MyMesh::floodRetryBridgeHeardMask(", "bool MyMesh::formatFloodRetryHeard(",
|
|
"void MyMesh::onDirectRetryEvent(")))
|
|
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
|
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
|
|
with tempfile.TemporaryDirectory(prefix="mini-experiment-logging-") as directory:
|
|
source, executable = Path(directory) / "test.cpp", Path(directory) / "test"
|
|
source.write_text(program, encoding="ascii")
|
|
for carrier, management, file_logging in itertools.product((0, 1), repeat=3):
|
|
with self.subTest(carrier_tools=carrier, management_reporting=management,
|
|
packet_file_logging=file_logging):
|
|
command = [compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror",
|
|
"-Wno-unused-parameter", "-Wno-unused-variable",
|
|
"-Wno-unused-but-set-variable", "-Wno-sign-compare", *sanitizers,
|
|
"-DARDUINO=1", "-DSTM32_PLATFORM=1", "-DMESH_DEBUG=0",
|
|
"-DMESH_PACKET_LOGGING=1", "-DMESH_PACKET_LOGGING_COMPACT=1",
|
|
f"-DMESH_ENABLE_CARRIER_TOOLS={carrier}",
|
|
f"-DMESH_ENABLE_MANAGEMENT_REPORTING={management}",
|
|
f"-DMESH_ENABLE_PACKET_FILE_LOGGING={file_logging}",
|
|
f"-DEXPECT_CARRIER={carrier}", f"-DEXPECT_MANAGEMENT={management}",
|
|
f"-DEXPECT_FILE_LOGGING={file_logging}",
|
|
"-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"),
|
|
str(source), "-o", str(executable)]
|
|
built = subprocess.run(command, capture_output=True, text=True, timeout=60)
|
|
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
|
tested = subprocess.run([str(executable)], capture_output=True, text=True, timeout=15)
|
|
self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr)
|
|
self.assertIn("PASS: production compact repeater RX/TX", tested.stdout)
|
|
|
|
def test_timestamp_override_is_retained_for_every_actual_consumer(self):
|
|
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
|
|
self.assertIsNotNone(compiler, "a host C++17 compiler is required")
|
|
header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text()
|
|
source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text()
|
|
|
|
def guarded(text, signature):
|
|
start = text.index(signature)
|
|
guard = text.rfind("#if ", 0, start)
|
|
end = text.index("#endif", start) + len("#endif")
|
|
self.assertGreaterEqual(guard, 0)
|
|
return text[guard:end]
|
|
|
|
declaration = guarded(header, "const char* getLogDateTime() override;")
|
|
definition = guarded(source, "const char *MyMesh::getLogDateTime()")
|
|
blocks, checks = ["#include <cassert>\n#include <cstdint>\n#include <cstdio>\n#include <cstring>"], []
|
|
for index, (debug, files, packets, compact) in enumerate(itertools.product((0, 1), repeat=4)):
|
|
flags = dict(MESH_DEBUG=debug, MESH_ENABLE_PACKET_FILE_LOGGING=files,
|
|
MESH_PACKET_LOGGING=packets, MESH_PACKET_LOGGING_COMPACT=compact)
|
|
for name, value in flags.items():
|
|
blocks.append(f"#undef {name}\n#define {name} {value}")
|
|
blocks.append(f"namespace timestamp_variant_{index} {{")
|
|
blocks.append(r'''
|
|
struct Clock { uint32_t getCurrentTime() const { return 123; } };
|
|
struct DateTime {
|
|
explicit DateTime(uint32_t) {}
|
|
unsigned hour() const { return 3; } unsigned minute() const { return 4; }
|
|
unsigned second() const { return 5; } unsigned day() const { return 2; }
|
|
unsigned month() const { return 1; } unsigned year() const { return 2026; }
|
|
};
|
|
struct Base { virtual const char* getLogDateTime() { return "unused"; } };
|
|
struct MyMesh : Base {
|
|
Clock clock;
|
|
Clock* getRTCClock() { return &clock; }
|
|
''')
|
|
blocks.extend((declaration, "};", definition))
|
|
expected = ("03:04:05 - 2/1/2026 U" if debug or files or (packets and not compact)
|
|
else "unused")
|
|
blocks.append(f'void check() {{ MyMesh mesh; assert(!strcmp(mesh.getLogDateTime(), "{expected}")); }}\n}}')
|
|
checks.append(f"timestamp_variant_{index}::check();")
|
|
blocks.append("int main() {" + " ".join(checks) + "}")
|
|
with tempfile.TemporaryDirectory(prefix="mini-timestamp-consumers-") as directory:
|
|
path, executable = Path(directory) / "test.cpp", Path(directory) / "test"
|
|
path.write_text("\n".join(blocks), encoding="ascii")
|
|
built = subprocess.run([compiler, "-std=c++17", "-O1", "-Wall", "-Wextra",
|
|
"-Werror", str(path), "-o", str(executable)],
|
|
capture_output=True, text=True, timeout=60)
|
|
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
|
tested = subprocess.run([str(executable)], capture_output=True, text=True, timeout=15)
|
|
self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr)
|
|
|
|
def test_baseline_storage_logging_host_cli_and_authentication_contracts_remain(self):
|
|
config = configparser.ConfigParser(interpolation=None, inline_comment_prefixes=(";",))
|
|
config.read(ROOT / "variants/wio-e5-mini/platformio.ini")
|
|
variant = config["env:wio-e5-mini_repeater"]
|
|
self.assertEqual(variant.getint("board_upload.maximum_size"), 245760)
|
|
flags = variant["build_flags"]
|
|
self.assertRegex(flags, r"-D\s+LFS_FLASH_TOTAL_SIZE=16384\b")
|
|
self.assertRegex(flags, r"-D\s+MESH_PACKET_LOGGING_COMPACT=1\b")
|
|
self.assertNotRegex(flags, r"-D\s+MESH_ENABLE_HOST_CLI=0\b")
|
|
self.assertNotRegex(flags, r"-D\s+MESH_PACKET_LOGGING=0\b")
|
|
header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text()
|
|
self.assertIn("#ifndef MESH_ENABLE_HOST_CLI\n#define MESH_ENABLE_HOST_CLI 1\n#endif", header)
|
|
file_config = (ROOT / "src/helpers/PacketFileLoggingConfig.h").read_text()
|
|
self.assertRegex(file_config, r"#ifndef MESH_ENABLE_PACKET_FILE_LOGGING\s+"
|
|
r"#define MESH_ENABLE_PACKET_FILE_LOGGING 1\s+#endif")
|
|
source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text()
|
|
dispatch = body(source, "void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender,")
|
|
self.assertLess(dispatch.index("handleReplayResetCommand(sender, command, reply, usb_origin)"),
|
|
dispatch.index("_cli.handleCommand("))
|
|
self.assertIn("if (sender != nullptr && sender_timestamp == 0) sender_timestamp = 1;", dispatch)
|
|
receive = source[source.index("else if (type == PAYLOAD_TYPE_TXT_MSG && len > 5"):]
|
|
self.assertIn("client->isAdmin() || client->isRegionMgr() || client->isFilterMgr()", receive)
|
|
self.assertIn("sender_timestamp < client->last_timestamp && !cached_retry", receive)
|
|
host_complete = body(source, "bool MyMesh::completeHostCliRequest(")
|
|
self.assertIn("client_index < 0 || !acl.getClientByIdx(client_index)->isAdmin()", host_complete)
|
|
host_reply = body(source, "bool MyMesh::handleHostCliSerialReply(")
|
|
self.assertIn("parsed.request_id != deferred_cli_command.request_id", host_reply)
|
|
self.assertIn("parsed.request_nonce != host_cli_nonce", host_reply)
|
|
self.assertIn("else if (!host_cli_claimed)", host_reply)
|
|
deferred = body(source, "void __attribute__((noinline)) MyMesh::processDeferredCliCommand()")
|
|
self.assertIn("if (!client->isAdmin())", deferred)
|
|
self.assertIn("self_id.sign(signature, (const uint8_t*)signed_content,", deferred)
|
|
common = body((ROOT / "src/helpers/CommonCLI.cpp").read_text(), "void CommonCLI::handleCommand(")
|
|
self.assertIn("mesh::handleUsbLoggingWatchdogCommand(", common)
|
|
self.assertIn("_radio_profiles.handle(", common)
|
|
self.assertIn('#endif\n } else if (sender_timestamp == 0 && memcmp(command, "stats-packets",', common)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|