Files
MeshCore/test/fixtures/telemetry_tx_persistence/fixture.cpp
T

1097 lines
52 KiB
C++

#include <IdentityStore.h>
#include "FileRead.h"
#include "FilePresence.h"
#include "PersistentStoreFormat.h"
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#define ATOMIC_FILE_WRITER_IMPLEMENTATION
#include "AtomicFileWriter.h"
#else
#include "ContactFileTransaction.h"
#endif
#include <helpers/TelemetryHistory.h>
#include <helpers/ExternalVoltageHistory.h>
#include <helpers/NoiseFloorHistory.h>
#include <helpers/CLICommandUtils.h>
#include <array>
#include <cassert>
#include <cstdio>
#include <cstdlib>
#define MAX_PATH_SIZE 64
#define MAX_PACKET_PAYLOAD 184
#define PAYLOAD_TYPE_RAW_CUSTOM 15
#define OUT_PATH_UNKNOWN 0xff
#define OUT_PATH_FORCE_FLOOD 0xfe
#define MESH_DEBUG_PRINTLN(...) do {} while (0)
static uint32_t test_millis = 0;
static uint32_t millis() { return test_millis; }
#if defined(ESP32_PLATFORM)
static MemoryFS* metadata_fs = nullptr;
// Exercise production FilePresence's stat/error handling without a real mount.
extern "C" int stat(const char* path, struct stat*) noexcept {
if (!metadata_fs || metadata_fs->fail_metadata) { errno = EIO; return -1; }
assert(!strncmp(path, "/spiffs", 7));
if (metadata_fs->exists(path + 7)) return 0;
errno = ENOENT; return -1;
}
#endif
namespace mesh {
struct Packet {
uint8_t payload[184] = {};
size_t payload_len = 0;
uint8_t path_len = 0, path[MAX_PATH_SIZE] = {};
uint8_t payload_type = PAYLOAD_TYPE_RAW_CUSTOM;
bool forwarded = false;
uint8_t getPayloadType() const { return payload_type; }
static bool isValidPathLen(uint8_t);
static size_t writePath(uint8_t*, const uint8_t*, uint8_t);
static uint8_t copyPath(uint8_t*, const uint8_t*, uint8_t);
};
class Mesh {
protected:
static bool isTelemetryHistoryPayload(const Packet*);
};
struct Utils { static void toHex(char*, const uint8_t*, size_t); };
struct DataRouteState;
}
struct TransportKey {};
struct RegionEntry { const char* name; };
class RegionMap {
public:
RegionEntry* findByNamePrefix(const char*) { return nullptr; }
};
namespace mesh {
// Region selection is a boundary here; route storage and command handling are real.
static bool usableScope(const DataRouteState&, RegionEntry*, TransportKey&) { return false; }
}
class CommonCLI {
public:
mesh::DataRouteState* _data_route = nullptr;
RegionMap* _region_map = nullptr;
bool adoptLegacyDataTxPath(const uint8_t*, uint8_t);
bool getDataTxPath(const uint8_t*&, uint8_t&) const;
bool resolveDataTxScope(TransportKey&, char*, size_t, bool* ambiguous = nullptr) const {
if (ambiguous) *ambiguous = false;
return false;
}
bool handleDataTxCommand(char*, char*);
std::string routeCommand(const char* text) {
std::vector<char> command(text, text + strlen(text) + 1);
char reply[160] = {};
assert(handleDataTxCommand(command.data(), reply));
return reply;
}
};
struct ClientInfo { bool admin = true; bool isAdmin() const { return admin; } };
class MyMesh : public mesh::Mesh {
public:
FILESYSTEM* _fs;
CommonCLI _cli;
struct Identity { uint8_t pub_key[32] = {}; } self_id;
mesh::TelemetryHistory telemetry_history;
mesh::ExternalVoltageHistory external_voltage_history;
mesh::NoiseFloorHistory noise_floor_history;
bool dual = false;
bool isDualRadioActive() const { return dual; }
bool telemetry_history_tx_prefs_healthy = false;
bool telemetry_history_tx_enabled = false;
uint8_t telemetry_history_tx_interval_days = 2;
uint8_t telemetry_history_tx_types = 31;
uint8_t telemetry_history_tx_pending = 0;
bool telemetry_history_tx_manual = false;
uint8_t telemetry_history_tx_external_channel = 0;
uint8_t telemetry_history_tx_external_chunk = 0;
uint64_t telemetry_history_next_tx_uptime = 0;
uint64_t telemetry_history_tx_resume_uptime = 0;
uint64_t telemetry_history_last_tx_uptime = UINT64_MAX;
uint64_t uptime_millis = 0;
uint32_t last_millis = 0;
std::vector<mesh::Packet> packets;
std::vector<uint64_t> packet_times;
std::vector<std::string> attempts;
std::string reject_magic;
unsigned rejected_sends = 0;
explicit MyMesh(FILESYSTEM& fs) : _fs(&fs) {}
void loadTelemetryHistoryTxPrefs();
bool saveTelemetryHistoryTxPrefs();
uint8_t availableTelemetryHistoryTxTypes() const;
void finishTelemetryHistoryTxBatch(uint64_t);
bool handleTelemetryHistoryTxTypesCommand(char*, char*, bool);
void serviceTelemetryHistoryTx();
bool sendTelemetryHistorySnapshot(mesh::TelemetryHistory::Series);
bool sendExternalVoltageHistorySnapshot(uint8_t, uint8_t);
bool sendNoiseFloorHistorySnapshot(uint8_t);
void formatTelemetryHistoryTxStatus(char*, size_t) const;
void recordTelemetryHistoryTx(const mesh::Packet*);
void handle(char*, char*, ClientInfo*);
// Radio queue boundary. Payload formatting and producer scheduling are real.
mesh::Packet* createRawData(const uint8_t* data, size_t size) {
assert(size <= 184);
auto* packet = new mesh::Packet;
memcpy(packet->payload, data, size); packet->payload_len = size;
return packet;
}
bool sendDirect(mesh::Packet* packet, const uint8_t* path, uint8_t length) {
const std::string magic(reinterpret_cast<char*>(packet->payload), 4);
attempts.push_back(magic);
if (magic == reject_magic && rejected_sends != 0) {
--rejected_sends; delete packet; return false;
}
packet->path_len = mesh::Packet::copyPath(packet->path, path, length);
packets.push_back(*packet);
packet_times.push_back(uptime_millis + uint32_t(millis() - last_millis));
delete packet; return true;
}
std::array<uint64_t, 9> state() const {
return {{telemetry_history_tx_enabled, telemetry_history_tx_interval_days,
telemetry_history_tx_pending, telemetry_history_tx_manual,
telemetry_history_tx_external_channel, telemetry_history_tx_external_chunk,
telemetry_history_next_tx_uptime, telemetry_history_tx_resume_uptime,
telemetry_history_tx_types}};
}
std::string command(const char* text, ClientInfo* sender = nullptr) {
std::vector<char> input(text, text + strlen(text) + 1);
char reply[160] = {};
handle(input.data(), reply, sender); return reply;
}
};
#include "production.inc"
static const char target[] = "/telemetry_tx";
static const char temp[] = "/telemetry_tx.tmp";
static const char backup[] = "/telemetry_tx.bak";
static std::vector<uint8_t> current(bool enabled = true, uint8_t days = 7,
uint8_t types = 31) {
return {'T', 'H', 'T', '4', uint8_t(enabled), days, types};
}
static std::vector<uint8_t> previous(bool enabled = true, uint8_t days = 7) {
return {'T', 'H', 'T', '3', uint8_t(enabled), days};
}
static std::vector<uint8_t> legacy(bool enabled = true, uint8_t length = 1,
uint8_t days = 7) {
std::vector<uint8_t> image(7 + MAX_PATH_SIZE, 0);
memcpy(image.data(), "THT2", 4);
image[4] = enabled; image[5] = length; image[6] = days;
for (size_t i = 0; i < MAX_PATH_SIZE; ++i) image[7 + i] = uint8_t(i + 0x12);
return image;
}
struct Fixture {
MemoryFS fs;
mesh::DataRouteState route;
MyMesh node;
Fixture() : node(fs) {
#if defined(ESP32_PLATFORM)
metadata_fs = &fs;
#endif
route.fs = &fs; node._cli._data_route = &route;
}
void boot() {
route = mesh::DataRouteState(); route.fs = &fs;
route.healthy = mesh::dataRouteLoad(route);
node.loadTelemetryHistoryTxPrefs();
}
void seed(const std::vector<uint8_t>& image = current()) {
fs.files[target] = image; boot();
}
void history() {
for (uint32_t i = 0; i < mesh::TelemetryHistory::BINARY_MAX_SAMPLES; ++i) {
node.telemetry_history.record(1000000 + i * 1800, 22, true, 3750, 0, 0, false);
}
}
void noiseHistory(uint16_t count = 300, bool missing = false) {
const uint32_t start = 999900;
node.noise_floor_history.advance(start);
for (uint32_t index = 0; index < count; ++index) {
for (uint8_t profile = 0; profile < 2; ++profile) {
for (uint8_t slot = 0; slot < 3; ++slot) {
if (missing) continue;
assert(node.noise_floor_history.observe(profile,
start + index * 900 + slot * 300, profile ? -66.0f : -119.0f,
index * 3 + slot));
}
}
node.noise_floor_history.advance(start + (index + 1) * 900);
}
assert(node.noise_floor_history.sampleCount(0) == count);
}
void expectFault() {
assert(!node.telemetry_history_tx_prefs_healthy);
const auto before = node.state();
const auto writes = fs.writes;
node.serviceTelemetryHistoryTx();
assert(node.packets.empty() && node.state() == before);
assert(node.command("send telemetry.tx now") == "Err - telemetry.tx prefs unavailable");
assert(node.state() == before && fs.writes == writes);
for (const char* key : {"temp", "volt", "volt.i2c", "noise", "noise2", "types"}) {
const std::string prefix = std::string("telemetry.tx.") + key;
assert(node.command(("get " + prefix).c_str()) == "Err - telemetry.tx prefs unavailable");
assert(node.command(("set " + prefix + " off").c_str()) == "Err - telemetry.tx prefs unavailable");
assert(node.state() == before && fs.writes == writes);
}
char reply[160]; node.formatTelemetryHistoryTxStatus(reply, sizeof(reply));
assert(strstr(reply, " fault=prefs"));
ClientInfo unauthorized; unauthorized.admin = false;
assert(node.command("send telemetry.tx now", &unauthorized) == "Err - not permitted");
assert(!node.saveTelemetryHistoryTxPrefs() && fs.writes == writes);
}
};
static void checkFreshAndValid() {
Fixture f; f.boot();
assert(f.node.telemetry_history_tx_prefs_healthy);
assert(!f.node.telemetry_history_tx_enabled && f.node.telemetry_history_tx_interval_days == 2);
assert(f.node.telemetry_history_tx_types == 31);
assert(f.fs.files.empty());
for (const bool enabled : {false, true}) {
for (const uint8_t days : {uint8_t(1), uint8_t(2), uint8_t(30)}) {
f.seed(current(enabled, days));
assert(f.node.telemetry_history_tx_prefs_healthy);
assert(f.node.telemetry_history_tx_enabled == enabled);
assert(f.node.telemetry_history_tx_interval_days == days);
assert(f.node.telemetry_history_tx_types == 31);
assert(f.node.saveTelemetryHistoryTxPrefs() && f.fs.files.at(target) == current(enabled, days));
assert(!f.fs.exists(temp) && !f.fs.exists(backup));
char reply[160]; f.node.formatTelemetryHistoryTxStatus(reply, sizeof(reply));
assert(!strstr(reply, "fault="));
}
}
f.node._fs = nullptr; f.node.loadTelemetryHistoryTxPrefs(); f.expectFault();
}
static void checkLoadFailures() {
for (size_t length = 0; length <= 80; ++length) {
Fixture f; f.fs.files[target] = std::vector<uint8_t>(length, 0); f.boot();
f.expectFault(); assert(f.fs.files.at(target) == std::vector<uint8_t>(length, 0));
}
for (const auto& image : {current(), previous(), legacy()}) {
for (size_t limit = 0; limit < image.size(); ++limit) {
Fixture f; f.fs.files[target] = image; f.fs.read_limit[target] = limit;
f.boot(); f.expectFault(); assert(f.fs.files.at(target) == image);
f.fs.clearFaults(); f.boot(); assert(f.node.telemetry_history_tx_prefs_healthy);
}
Fixture f; f.fs.files[target] = image; f.fs.fail_read_open.insert(target);
f.boot(); f.expectFault(); assert(f.fs.files.at(target) == image);
}
for (unsigned value = 0; value <= 255; ++value) {
for (const bool old : {false, true}) {
for (const unsigned offset : {4U, old ? 6U : 5U}) {
Fixture f; auto image = old ? legacy() : current(); image[offset] = uint8_t(value);
f.fs.files[target] = image; f.boot();
const bool valid = offset == 4 ? value <= 1 : value >= 1 && value <= 30;
assert(f.node.telemetry_history_tx_prefs_healthy == valid);
if (!valid) f.expectFault();
}
}
}
#if !defined(RP2040_PLATFORM)
Fixture metadata;
metadata.fs.files[target] = current(); metadata.fs.fail_metadata = true;
metadata.boot(); metadata.expectFault();
assert(metadata.fs.files.at(target) == current());
#endif
}
static void checkLegacyMigration() {
for (const bool enabled : {false, true}) {
for (const uint8_t days : {uint8_t(1), uint8_t(30)}) {
Fixture f; const auto image = previous(enabled, days); f.seed(image);
assert(f.node.telemetry_history_tx_prefs_healthy);
assert(f.node.telemetry_history_tx_enabled == enabled);
assert(f.node.telemetry_history_tx_interval_days == days);
assert(f.node.telemetry_history_tx_types == 31);
assert(f.fs.files.at(target) == image); // old files are not rewritten on boot
assert(f.node.saveTelemetryHistoryTxPrefs());
assert(f.fs.files.at(target) == current(enabled, days));
}
}
for (const uint8_t length : {uint8_t(0), uint8_t(1), uint8_t(0x42), uint8_t(0x82)}) {
Fixture f; const auto image = legacy(true, length); f.seed(image);
assert(f.node.telemetry_history_tx_prefs_healthy && f.node.telemetry_history_tx_enabled);
assert(f.node.telemetry_history_tx_types == 31);
assert(f.route.persisted && f.route.path_len == length);
const size_t bytes = (length & 63) * ((length >> 6) + 1);
assert(!memcmp(f.route.path, image.data() + 7, bytes));
assert(f.fs.files.at(target) == image); // loading never rewrites the legacy file
f.boot(); assert(f.node.telemetry_history_tx_prefs_healthy && f.route.path_len == length);
assert(f.node.saveTelemetryHistoryTxPrefs());
assert(f.fs.files.at(target) == current());
}
for (const uint8_t length : {uint8_t(0), uint8_t(0x42), uint8_t(OUT_PATH_UNKNOWN)}) {
Fixture f; f.route.path_len = length;
memset(f.route.path, 0xab, sizeof(f.route.path));
assert(mesh::dataRouteSave(f.route));
const auto saved_route = f.fs.files.at("/data_tx");
f.fs.files[target] = legacy(); const auto writes = f.fs.writes;
f.boot(); assert(f.node.telemetry_history_tx_prefs_healthy && f.route.path_len == length);
assert(f.fs.files.at("/data_tx") == saved_route && f.fs.writes == writes);
}
Fixture disabled; disabled.seed(legacy(false, OUT_PATH_UNKNOWN));
assert(disabled.node.telemetry_history_tx_prefs_healthy && !disabled.route.persisted);
for (unsigned length = 0; length <= 255; ++length) {
Fixture f; f.seed(legacy(true, uint8_t(length)));
const bool valid = length != OUT_PATH_UNKNOWN && length != OUT_PATH_FORCE_FLOOD
&& mesh::Packet::isValidPathLen(uint8_t(length));
assert(f.node.telemetry_history_tx_prefs_healthy == valid);
if (!valid) f.expectFault();
}
// The actual adoption helper must fail before applying enabled legacy state.
for (const int failure : {0, 1, 2}) {
Fixture f; f.fs.files[target] = legacy();
if (failure == 0) f.fs.fail_write_open.insert("/data_tx.tmp");
if (failure == 1) f.fs.write_limit["/data_tx.tmp"] = 107;
if (failure == 2) f.fs.fail_rename.insert({"/data_tx.tmp", "/data_tx"});
f.boot(); f.history(); f.expectFault();
assert(!f.node.telemetry_history_tx_enabled && !f.route.persisted);
assert(f.fs.files.at(target) == legacy() && !f.fs.exists("/data_tx"));
f.fs.clearFaults(); f.boot();
assert(f.node.telemetry_history_tx_prefs_healthy && f.route.persisted && f.route.path_len == 1);
assert(f.fs.files.at(target) == legacy());
}
Fixture bad_route; bad_route.fs.files["/data_tx"] = {'b', 'a', 'd'};
bad_route.seed(legacy()); bad_route.expectFault();
assert(bad_route.fs.files.at(target) == legacy());
}
static void verifyRollback(Fixture& f, const std::vector<uint8_t>& prior,
const std::array<uint64_t, 9>& state) {
assert(f.node.state() == state && f.node.telemetry_history_tx_prefs_healthy);
assert(f.fs.files.at(target) == prior);
assert(!f.fs.exists(temp));
f.fs.clearFaults(); f.boot();
assert(f.node.telemetry_history_tx_prefs_healthy && f.node.telemetry_history_tx_enabled);
assert(f.node.telemetry_history_tx_interval_days == 7);
assert(f.node.command("set telemetry.tx schedule off") == "OK - telemetry.tx schedule=off");
assert(f.fs.files.at(target) == current(false));
f.boot(); assert(f.node.telemetry_history_tx_prefs_healthy && !f.node.telemetry_history_tx_enabled);
}
static void checkWriteAndValidationFailures() {
for (const auto& prior : {current(), previous(), legacy()}) {
for (size_t limit = 0; limit < current().size(); ++limit) {
Fixture f; f.seed(prior); const auto before = f.node.state();
f.fs.write_limit[temp] = limit;
assert(f.node.command("set telemetry.tx schedule off") == "Err - unable to save telemetry.tx schedule");
verifyRollback(f, prior, before);
}
for (const int failure : {0, 1}) {
Fixture f; f.seed(prior); const auto before = f.node.state();
if (failure == 0) f.fs.fail_write_open.insert(temp);
else f.fs.fail_read_open.insert(temp);
assert(f.node.command("set telemetry.tx schedule off") == "Err - unable to save telemetry.tx schedule");
verifyRollback(f, prior, before);
}
for (size_t limit = 0; limit < current().size(); ++limit) {
Fixture f; f.seed(prior); const auto before = f.node.state();
f.fs.read_limit[temp] = limit;
assert(f.node.command("set telemetry.tx schedule off") == "Err - unable to save telemetry.tx schedule");
verifyRollback(f, prior, before);
}
Fixture f; f.seed(prior); const auto before = f.node.state();
f.fs.fail_rename.insert({temp, target});
assert(f.node.command("set telemetry.tx schedule off") == "Err - unable to save telemetry.tx schedule");
verifyRollback(f, prior, before);
}
for (const int failure : {0, 1}) {
Fixture f; f.seed();
if (failure == 0) f.fs.fail_read_open.insert(target);
else f.fs.read_limit[target] = 5;
const auto writes = f.fs.writes;
assert(f.node.command("set telemetry.tx schedule off") == "Err - unable to save telemetry.tx schedule");
f.expectFault(); assert(f.fs.writes == writes && f.fs.files.at(target) == current());
}
// A failed fresh-install transaction is retryable only when still absent.
Fixture fresh; fresh.boot(); fresh.fs.write_limit[temp] = 5;
assert(fresh.node.command("set telemetry.tx schedule 5d") == "Err - unable to save telemetry.tx schedule");
assert(fresh.node.telemetry_history_tx_prefs_healthy && !fresh.fs.exists(target));
fresh.fs.clearFaults();
assert(fresh.node.command("set telemetry.tx schedule 5d") == "OK - telemetry.tx schedule=5d");
assert(fresh.fs.files.at(target) == current(true, 5));
}
static void checkRecoveryAndRenameFailures() {
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
Fixture recovered; recovered.fs.files[backup] = current();
recovered.boot(); assert(recovered.node.telemetry_history_tx_prefs_healthy);
assert(recovered.fs.files.at(target) == current() && !recovered.fs.exists(backup));
Fixture failed; failed.fs.files[backup] = current();
failed.fs.fail_rename.insert({backup, target});
failed.boot(); failed.expectFault();
assert(!failed.fs.exists(target) && failed.fs.files.at(backup) == current());
failed.fs.clearFaults(); failed.boot(); assert(failed.node.telemetry_history_tx_prefs_healthy);
Fixture corrupt; corrupt.fs.files[target] = {'b', 'a', 'd'};
corrupt.fs.files[backup] = current(); corrupt.boot(); corrupt.expectFault();
assert(corrupt.fs.files.at(target) == std::vector<uint8_t>({'b', 'a', 'd'}));
assert(corrupt.fs.files.at(backup) == current());
for (const int failure : {0, 1, 2}) {
Fixture f; f.seed(); const auto before = f.node.state();
if (failure == 0) { f.fs.files[temp] = {1}; f.fs.fail_remove.insert(temp); }
if (failure == 1) { f.fs.files[backup] = {1}; f.fs.fail_remove.insert(backup); }
if (failure == 2) f.fs.fail_rename.insert({target, backup});
assert(f.node.command("set telemetry.tx schedule off") == "Err - unable to save telemetry.tx schedule");
assert(f.node.telemetry_history_tx_prefs_healthy && f.node.state() == before);
assert(f.fs.files.at(target) == current());
f.fs.clearFaults();
assert(f.node.command("set telemetry.tx schedule off") == "OK - telemetry.tx schedule=off");
}
Fixture gap; gap.seed(); gap.history(); const auto before = gap.node.state();
gap.fs.fail_rename.insert({temp, target}); gap.fs.fail_rename.insert({backup, target});
assert(gap.node.command("set telemetry.tx schedule off") == "Err - unable to save telemetry.tx schedule");
assert(gap.node.state() == before && gap.fs.files.at(backup) == current());
assert(!gap.fs.exists(target)); gap.expectFault();
gap.boot(); gap.expectFault(); // failed recovery remains fail-closed on reboot
gap.fs.clearFaults(); gap.boot();
assert(gap.node.telemetry_history_tx_prefs_healthy && gap.node.telemetry_history_tx_enabled);
assert(gap.fs.files.at(target) == current());
#endif
// Stale uncommitted candidates are never mistaken for a fresh durable image.
Fixture stale; stale.fs.files[temp] = current(); stale.boot();
assert(stale.node.telemetry_history_tx_prefs_healthy && !stale.node.telemetry_history_tx_enabled);
assert(stale.node.command("set telemetry.tx schedule 5d") == "OK - telemetry.tx schedule=5d");
assert(stale.fs.files.at(target) == current(true, 5) && !stale.fs.exists(temp));
}
static void checkAmbiguousPublicationAndServiceGate() {
for (const int failure : {0, 1}) {
Fixture f; f.seed(); f.history();
f.node.telemetry_history_tx_pending = TELEMETRY_HISTORY_TX_TEMPERATURE | TELEMETRY_HISTORY_TX_VOLTAGE;
f.node.telemetry_history_tx_manual = true;
f.node.telemetry_history_tx_resume_uptime = 10000000;
const auto before = f.node.state();
if (failure == 0) f.fs.fail_final_read = true;
else f.fs.corrupt_publish = true;
assert(f.node.command("set telemetry.tx schedule off") == "Err - unable to save telemetry.tx schedule");
assert(f.node.state() == before); f.expectFault();
const auto published = f.fs.files.at(target);
f.boot(); f.expectFault();
assert(f.fs.files.at(target) == published);
f.fs.clearFaults(); f.boot();
if (failure == 0) {
assert(f.node.telemetry_history_tx_prefs_healthy && !f.node.telemetry_history_tx_enabled);
assert(f.fs.files.at(target) == current(false));
} else f.expectFault();
}
for (int limit = 0; limit < int(current().size()); ++limit) {
Fixture f; f.seed(); f.history();
f.fs.final_read_limit = limit;
assert(f.node.command("set telemetry.tx schedule off") == "Err - unable to save telemetry.tx schedule");
assert(f.fs.files.at(target) == current(false));
f.expectFault(); f.boot(); f.expectFault();
assert(f.fs.files.at(target) == current(false));
f.fs.clearFaults(); f.boot();
assert(f.node.telemetry_history_tx_prefs_healthy && !f.node.telemetry_history_tx_enabled);
}
Fixture healthy; healthy.seed(); healthy.history();
healthy.node.serviceTelemetryHistoryTx();
assert(healthy.node.packets.size() == 1);
assert(healthy.node.packets[0].payload_len == 184);
assert(!memcmp(healthy.node.packets[0].payload, "TTB1", 4));
test_millis += 89999; healthy.node.serviceTelemetryHistoryTx();
assert(healthy.node.packets.size() == 1);
test_millis += 1; healthy.node.serviceTelemetryHistoryTx(); test_millis = 0;
assert(healthy.node.packets.size() == 2 && !memcmp(healthy.node.packets[1].payload, "TVB1", 4));
assert(healthy.node.command("send telemetry.tx now").find("OK - telemetry.tx queued") == 0);
}
static void checkRoutePublicationAuthority() {
for (const bool migration : {false, true}) {
Fixture f; f.boot();
if (!migration) assert(f.node._cli.routeCommand("set data.tx path 12") == "OK");
f.fs.publication_path = "/data_tx";
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
// Atomic publication succeeds, but its mandatory public readback fails.
f.fs.fail_final_read = true;
#else
// The old image is backed up but neither publication nor rollback happens.
if (migration) assert(mesh::dataRouteSave(f.route));
f.fs.fail_rename.insert({"/data_tx.tmp", "/data_tx"});
f.fs.fail_rename.insert({"/data_tx.bak", "/data_tx"});
#endif
if (migration) {
const uint8_t path = 0x34;
// Force the legacy adoption path without discarding the existing disk image.
f.route.persisted = false;
assert(!f.node._cli.adoptLegacyDataTxPath(&path, 1));
} else {
assert(f.node._cli.routeCommand("set data.tx path 34") == "Err - unable to save data.tx");
}
// A reported failure cannot authorize old RAM when disk authority is unknown.
assert(!f.route.healthy);
const uint8_t* path = nullptr; uint8_t length = 0;
assert(!f.node._cli.getDataTxPath(path, length));
assert(f.node._cli.routeCommand("get data.tx") == "Err - data.tx storage fault");
const auto writes = f.fs.writes;
assert(f.node._cli.routeCommand("set data.tx path 56") == "Err - data.tx storage fault");
assert(f.fs.writes == writes);
f.fs.clearFaults(); f.boot();
assert(f.route.healthy);
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
assert(f.route.path_len == 1 && f.route.path[0] == 0x34);
#else
assert(f.route.path_len == (migration ? 0 : 1));
if (!migration) assert(f.route.path[0] == 0x12);
#endif
}
}
static void checkRouteReadbackAndRetry() {
for (const bool region : {false, true}) {
const char* command = region ? "set data.tx region none" : "set data.tx path 34";
for (const int failure : {0, 1, 2, 3, 4, 5}) {
Fixture f; f.boot();
assert(f.node._cli.routeCommand("set data.tx path 12") == "OK");
const auto previous = f.fs.files.at("/data_tx");
f.fs.publication_path = "/data_tx";
if (failure == 0) f.fs.fail_write_open.insert("/data_tx.tmp");
if (failure == 1) f.fs.write_limit["/data_tx.tmp"] = 107;
if (failure == 2) f.fs.fail_read_open.insert("/data_tx.tmp");
if (failure == 3) f.fs.fail_final_read = true;
if (failure == 4) f.fs.final_read_limit = 107;
if (failure == 5) f.fs.corrupt_publish = true;
assert(f.node._cli.routeCommand(command) == "Err - unable to save data.tx");
assert(f.route.healthy == (failure < 3));
assert(f.route.path_len == 1 && f.route.path[0] == 0x12);
assert(f.route.region_mode == mesh::DATA_REGION_AUTO);
if (failure < 3) {
assert(f.fs.files.at("/data_tx") == previous);
f.fs.clearFaults();
assert(f.node._cli.routeCommand(command) == "OK");
f.boot(); assert(f.route.healthy);
} else {
const auto writes = f.fs.writes;
const uint8_t* path = nullptr; uint8_t length = 0;
assert(!f.node._cli.getDataTxPath(path, length));
assert(f.node._cli.routeCommand(command) == "Err - data.tx storage fault");
assert(f.fs.writes == writes);
f.fs.clearFaults(); f.boot();
assert(f.route.healthy == (failure != 5));
}
}
}
for (const bool existing : {false, true}) {
for (const bool no_op : {false, true}) {
Fixture f; f.boot();
if (existing) assert(f.node._cli.routeCommand("set data.tx path 12") == "OK");
f.fs.publication_path = "/data_tx";
f.fs.no_op_publish = no_op;
f.fs.late_publish_error = !no_op;
const auto reply = f.node._cli.routeCommand("set data.tx path 34");
if (no_op) {
assert(reply == "Err - unable to save data.tx");
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
assert(!f.route.healthy); // Atomic writer cannot prove old authority.
#else
assert(f.route.healthy); // Observed unchanged move + successful rollback.
#endif
} else {
assert(reply == "OK" && f.route.healthy && f.route.path[0] == 0x34);
}
f.fs.clearFaults(); f.boot(); assert(f.route.healthy);
if (!no_op) assert(f.route.path_len == 1 && f.route.path[0] == 0x34);
else assert(f.route.path_len == (existing ? 1 : 0));
}
}
#if !defined(RP2040_PLATFORM)
for (const bool existing : {false, true}) {
Fixture f; f.boot();
if (existing) assert(f.node._cli.routeCommand("set data.tx path 12") == "OK");
const auto files = f.fs.files;
f.fs.fail_metadata = true; f.boot();
assert(!f.route.healthy && f.fs.files == files);
const auto writes = f.fs.writes;
assert(f.node._cli.routeCommand("set data.tx path 34") == "Err - data.tx storage fault");
assert(f.fs.writes == writes);
f.fs.clearFaults(); f.boot(); assert(f.route.healthy);
}
#endif
}
static uint32_t read32(const uint8_t* bytes) {
return uint32_t(bytes[0]) | uint32_t(bytes[1]) << 8
| uint32_t(bytes[2]) << 16 | uint32_t(bytes[3]) << 24;
}
static uint8_t packedNoiseCode(const mesh::Packet& packet, unsigned index) {
uint8_t value = 0;
for (unsigned bit = 0; bit < 5; ++bit) {
const unsigned at = index * 5 + bit;
value = uint8_t((value << 1)
| ((packet.payload[19 + at / 8] >> (7 - at % 8)) & 1));
}
return value;
}
static void verifyNoisePacket(const MyMesh& node, const mesh::Packet& packet,
unsigned profile, bool missing) {
assert(packet.payload_len == 19 + (255 * 5 + 7) / 8);
assert(!memcmp(packet.payload, profile ? "N2B1" : "N1B1", 4));
assert(!memcmp(packet.payload + 4, node.self_id.pub_key, 8));
assert(read32(packet.payload + 12) == 999900 + (300 - 255) * 900);
assert(packet.payload[16] == 15 && packet.payload[17] == 0);
assert(packet.payload[18] == 255);
for (unsigned i = 0; i < 255; ++i) {
assert(packedNoiseCode(packet, i) == (missing ? 31 : profile ? 28 : 1));
}
assert((packet.payload[packet.payload_len - 1] & 0x1f) == 0);
}
static void serviceDue(MyMesh& node) {
node.uptime_millis = node.telemetry_history_next_tx_uptime;
node.last_millis = test_millis;
node.serviceTelemetryHistoryTx();
}
static void finishPending(MyMesh& node) {
unsigned calls = 0;
while (node.telemetry_history_tx_pending != 0) {
assert(++calls < 20);
serviceDue(node);
}
}
static void checkNoiseHistoryTransmission() {
for (const bool manual : {false, true}) {
for (const bool dual : {false, true}) {
for (const bool missing : {false, true}) {
Fixture f; f.seed(current(!manual, 2)); f.history(); f.noiseHistory(300, missing);
f.node.dual = dual;
for (unsigned i = 0; i < 8; ++i) f.node.self_id.pub_key[i] = uint8_t(0x80 + i);
if (manual) {
const auto reply = f.node.command("send telemetry.tx now");
assert(reply.find(dual ? "noise=1/1" : "noise=1/0") != std::string::npos);
assert(f.node.telemetry_history_tx_pending == (dual ? 27 : 11));
assert(f.node.packets.empty());
} else {
f.node.serviceTelemetryHistoryTx();
assert(f.node.packets.size() == 1);
}
finishPending(f.node);
assert(f.node.packets.size() == (dual ? 4U : 3U));
assert(f.node.attempts.size() == f.node.packets.size());
assert(!memcmp(f.node.packets[0].payload, "TTB1", 4));
assert(!memcmp(f.node.packets[1].payload, "TVB1", 4));
verifyNoisePacket(f.node, f.node.packets[2], 0, missing);
if (dual) verifyNoisePacket(f.node, f.node.packets[3], 1, missing);
assert(!f.node.telemetry_history_tx_manual);
assert(bool(f.node.telemetry_history_next_tx_uptime) == !manual);
}
}
}
}
static void checkNoiseHistoryRetries() {
for (const bool second : {false, true}) {
Fixture f; f.seed(current(false, 2)); f.history(); f.noiseHistory();
f.node.dual = true;
f.node.reject_magic = second ? "N2B1" : "N1B1";
f.node.rejected_sends = 1;
assert(f.node.command("send telemetry.tx now").find("noise=1/1") != std::string::npos);
while (f.node.rejected_sends != 0) serviceDue(f.node);
const auto attempts = f.node.attempts;
const uint64_t retry = f.node.telemetry_history_next_tx_uptime;
assert(retry == f.node.uptime_millis + TELEMETRY_HISTORY_TX_RETRY_MILLIS);
assert(f.node.telemetry_history_tx_pending == (second ? 16 : 24));
f.node.uptime_millis = retry - 1;
f.node.serviceTelemetryHistoryTx();
assert(f.node.attempts == attempts); // failed packet waits for its retry deadline
finishPending(f.node);
const std::vector<std::string> expected = second
? std::vector<std::string>{"TTB1", "TVB1", "N1B1", "N2B1", "N2B1"}
: std::vector<std::string>{"TTB1", "TVB1", "N1B1", "N1B1", "N2B1"};
assert(f.node.attempts == expected);
assert(f.node.packets.size() == 4); // successful earlier packets were never resent
assert(!f.node.telemetry_history_next_tx_uptime && !f.node.telemetry_history_tx_manual);
}
Fixture disabled; disabled.seed(current(false, 2));
disabled.history(); disabled.noiseHistory(); disabled.node.dual = true;
disabled.node.reject_magic = "N2B1"; disabled.node.rejected_sends = 1;
assert(disabled.node.command("send telemetry.tx now").find("noise=1/1") != std::string::npos);
while (disabled.node.rejected_sends != 0) serviceDue(disabled.node);
assert(disabled.node.telemetry_history_tx_pending == TELEMETRY_HISTORY_TX_NOISE2);
const auto attempts = disabled.node.attempts;
disabled.node.dual = false;
serviceDue(disabled.node);
assert(!disabled.node.telemetry_history_tx_pending && disabled.node.attempts == attempts);
assert(!disabled.node.telemetry_history_next_tx_uptime && !disabled.node.telemetry_history_tx_manual);
assert(disabled.node.packets.size() == 3);
}
static std::string txStatus(const MyMesh& node) {
char reply[160];
node.formatTelemetryHistoryTxStatus(reply, sizeof(reply));
return reply;
}
static void expectLastTx(const MyMesh& node, const char* age) {
assert(txStatus(node).find(std::string(" last=") + age + " ") != std::string::npos);
}
static std::vector<mesh::Packet> historyPackets(Fixture& f) {
f.history(); f.noiseHistory();
const uint8_t channel = 7;
assert(f.node.external_voltage_history.configure(&channel, 1));
const mesh::ExternalVoltageHistory::Reading reading = {channel, 12.5f, true};
f.node.external_voltage_history.record(1000000, &reading, 1);
assert(f.node.sendTelemetryHistorySnapshot(mesh::TelemetryHistory::SERIES_TEMPERATURE));
assert(f.node.sendTelemetryHistorySnapshot(mesh::TelemetryHistory::SERIES_VOLTAGE));
assert(f.node.sendExternalVoltageHistorySnapshot(0, 0));
assert(f.node.sendNoiseFloorHistorySnapshot(0));
assert(f.node.sendNoiseFloorHistorySnapshot(1));
assert(f.node.packets.size() == 5);
return f.node.packets;
}
static void checkLastTxCompletionAndOwnership() {
Fixture f; f.boot();
for (unsigned i = 0; i < 8; ++i) f.node.self_id.pub_key[i] = uint8_t(0x90 + i);
expectLastTx(f.node, "never");
const auto packets = historyPackets(f);
expectLastTx(f.node, "never"); // queue acceptance is not a completed RF send
for (const auto& packet : packets) {
f.node.uptime_millis += 1000;
f.node.last_millis = test_millis;
f.node.recordTelemetryHistoryTx(&packet);
assert(f.node.telemetry_history_last_tx_uptime == f.node.uptime_millis);
expectLastTx(f.node, "0s");
}
const uint64_t completed = f.node.telemetry_history_last_tx_uptime;
f.node.uptime_millis += 30000;
for (const auto& valid : packets) {
auto other = valid;
other.payload_type = 5;
f.node.recordTelemetryHistoryTx(&other);
other = valid; other.forwarded = true;
f.node.recordTelemetryHistoryTx(&other);
other = valid; other.payload[4] ^= 1;
f.node.recordTelemetryHistoryTx(&other);
for (const unsigned offset : {2U, 3U, 16U, 18U}) {
other = valid;
if (offset == 16) other.payload[16] = other.payload[17] = 0;
else other.payload[offset] = 0;
f.node.recordTelemetryHistoryTx(&other);
}
other = valid; --other.payload_len;
f.node.recordTelemetryHistoryTx(&other);
other = valid; other.payload_len = 0;
f.node.recordTelemetryHistoryTx(&other);
other = valid; other.payload_len = MAX_PACKET_PAYLOAD + 1;
f.node.recordTelemetryHistoryTx(&other);
assert(f.node.telemetry_history_last_tx_uptime == completed);
}
expectLastTx(f.node, "30s");
f.node.reject_magic = "TTB1"; f.node.rejected_sends = 1;
assert(!f.node.sendTelemetryHistorySnapshot(mesh::TelemetryHistory::SERIES_TEMPERATURE));
assert(f.node.telemetry_history_last_tx_uptime == completed);
expectLastTx(f.node, "30s");
// A completed echo retry is another RF transmission and refreshes the age.
f.node.recordTelemetryHistoryTx(&packets.front());
expectLastTx(f.node, "0s");
assert(f.node.telemetry_history_last_tx_uptime == completed + 30000);
}
static void checkLastTxAgeAndClockWrap() {
Fixture f; f.boot();
const auto packets = historyPackets(f);
f.node.uptime_millis = 100000;
f.node.last_millis = test_millis;
f.node.recordTelemetryHistoryTx(&packets.front());
struct AgeCase { uint32_t seconds; const char* text; };
for (const auto& age : {AgeCase{0, "0s"}, {30, "30s"}, {59, "59s"},
{60, "1m"}, {1800, "30m"}, {3600, "1h"},
{7200, "2h"}, {172800, "48h"}}) {
f.node.uptime_millis = 100000 + uint64_t(age.seconds) * 1000;
expectLastTx(f.node, age.text);
}
// Account for the time since the main loop last sampled millis(), across
// its 32-bit wrap, using the accumulated boot uptime rather than RTC time.
f.node.uptime_millis = (uint64_t(1) << 32) + 4000;
f.node.last_millis = UINT32_MAX - 999;
test_millis = 500;
const uint64_t sent = f.node.uptime_millis + 1500;
f.node.recordTelemetryHistoryTx(&packets[1]);
assert(f.node.telemetry_history_last_tx_uptime == sent);
expectLastTx(f.node, "0s");
test_millis += 30000;
expectLastTx(f.node, "30s");
// RTC jumps clear/rebuild the sample histories without changing the age.
f.node.telemetry_history.record(2000000000, 21, true, 3750, 0, 0, false);
f.node.telemetry_history.record(1000000, 21, true, 3750, 0, 0, false);
f.node.noise_floor_history.advance(2000000000);
f.node.noise_floor_history.advance(1000000);
expectLastTx(f.node, "30s");
f.node.uptime_millis = sent + 172800000;
f.node.last_millis = test_millis;
expectLastTx(f.node, "48h");
test_millis = 0;
}
static void checkLastTxSurvivesScheduleAndStatusTruncation() {
Fixture f; f.boot();
const auto packets = historyPackets(f);
f.node.uptime_millis = 1000;
f.node.last_millis = test_millis;
f.node.recordTelemetryHistoryTx(&packets.front());
const uint64_t sent = f.node.telemetry_history_last_tx_uptime;
for (const char* command : {"set telemetry.tx schedule 1d", "set telemetry.tx schedule off"}) {
assert(f.node.command(command).find("OK - telemetry.tx schedule=") == 0);
assert(f.node.telemetry_history_last_tx_uptime == sent);
f.boot();
assert(f.node.telemetry_history_last_tx_uptime == sent);
}
assert(f.node.command("send telemetry.tx now").find("OK - telemetry.tx queued") == 0);
assert(f.node.telemetry_history_last_tx_uptime == sent);
f.fs.fail_read_open.insert(target);
f.node.loadTelemetryHistoryTxPrefs();
assert(!f.node.telemetry_history_tx_prefs_healthy);
f.node.uptime_millis += 1800000;
expectLastTx(f.node, "30m");
assert(txStatus(f.node).find("last=30m fault=prefs p=") != std::string::npos);
f.fs.clearFaults(); f.boot();
f.route.path_len = 63;
memset(f.route.path, 0xab, sizeof(f.route.path));
const auto reply = txStatus(f.node);
assert(reply.size() == 159); // longest learned route exceeds the CLI reply
assert(reply.find("last=30m") < reply.find(" p="));
assert(reply.find("last=30m p=ABAB") != std::string::npos);
char prefixed[161];
memset(prefixed, '#', sizeof(prefixed));
memcpy(prefixed, " > ", 3);
f.node.formatTelemetryHistoryTxStatus(prefixed + 3, 157);
assert(prefixed[159] == 0 && prefixed[160] == '#');
assert(strstr(prefixed, "last=30m p=ABAB"));
Fixture restarted; restarted.fs.files = f.fs.files; restarted.boot();
expectLastTx(restarted.node, "never"); // boot-local; do not invent a timestamp
}
static const char* const type_keys[] = {"temp", "volt", "volt.i2c", "noise", "noise2"};
static void externalHistory(Fixture& f) {
const uint8_t channels[] = {7, 9};
assert(f.node.external_voltage_history.configure(channels, 2));
const mesh::ExternalVoltageHistory::Reading readings[] = {{7, 12.5f, true}, {9, 5.0f, true}};
for (unsigned i = 0; i < mesh::ExternalVoltageHistory::RETENTION_SAMPLES; ++i)
f.node.external_voltage_history.record(1000800 + i * 1800, readings, 2);
assert(f.node.external_voltage_history.binaryChunkCount() == 3);
}
static std::vector<std::string> selectedMagics(uint8_t types) {
std::vector<std::string> expected;
if (types & 1) expected.push_back("TTB1");
if (types & 2) expected.push_back("TVB1");
if (types & 8) expected.push_back("N1B1");
if (types & 16) expected.push_back("N2B1");
if (types & 4) for (unsigned i = 0; i < 6; ++i) expected.push_back("IVB1");
return expected;
}
static void checkTypePreferencesAndCommands() {
for (unsigned value = 0; value <= 255; ++value) {
Fixture f; const auto image = current(true, 7, uint8_t(value)); f.seed(image);
assert(f.fs.files.at(target) == image); // no eager format rewrite
assert(f.node.telemetry_history_tx_prefs_healthy == (value < 32));
if (value >= 32) { f.expectFault(); continue; }
assert(f.node.telemetry_history_tx_types == value);
assert(f.node.saveTelemetryHistoryTxPrefs() && f.fs.files.at(target) == image);
f.boot(); assert(f.node.telemetry_history_tx_types == value);
}
Fixture f; f.seed();
assert(f.node.command("get telemetry.tx.types") ==
"> temp=on volt=on volt.i2c=on noise=on noise2=on");
for (unsigned i = 0; i < 5; ++i) {
const std::string key = std::string("telemetry.tx.") + type_keys[i];
assert(f.node.command(("get " + key).c_str()) == "> on");
assert(f.node.command(("set " + key + " off").c_str()) == "OK - " + key + "=off");
assert(f.node.telemetry_history_tx_types == uint8_t(31 & ~(1U << i)));
assert(f.fs.files.at(target) == current(true, 7, uint8_t(31 & ~(1U << i))));
f.boot(); assert(f.node.command(("get " + key).c_str()) == "> off");
const auto writes = f.fs.writes;
assert(f.node.command(("set " + key + " off").c_str()) == "OK - " + key + "=off");
assert(f.fs.writes == writes); // idempotent settings don't wear flash
assert(f.node.command(("set " + key + " on").c_str()) == "OK - " + key + "=on");
assert(f.node.telemetry_history_tx_types == 31);
for (const auto& command : {"get " + key, "set " + key + " off"}) {
ClientInfo unauthorized; unauthorized.admin = false;
const auto before = f.node.state(); const auto files = f.fs.files;
assert(f.node.command(command.c_str(), &unauthorized) == "Err - not permitted");
assert(f.node.state() == before && f.fs.files == files);
}
}
for (const char* command : {"set telemetry.tx.types off", "set telemetry.tx.types on"}) {
const uint8_t expected = strstr(command, " off") ? 0 : 31;
assert(f.node.command(command).find("OK -") == 0);
assert(f.node.telemetry_history_tx_types == expected);
f.boot(); assert(f.node.telemetry_history_tx_types == expected);
}
for (const char* command : {"set telemetry.tx.temp", "set telemetry.tx.temp ",
"set telemetry.tx.temp true", "set telemetry.tx.temp 1", "set telemetry.tx.temp OFF",
"set telemetry.tx.temp on junk", "set telemetry.tx.noise2x off",
"get telemetry.tx.noise extra", "get telemetry.tx.types ",
"set telemetry.tx.volt.i2cx off", "set telemetry.tx.gps on", "get telemetry.tx.gps"}) {
const auto before = f.node.state(); const auto files = f.fs.files; const auto writes = f.fs.writes;
assert(f.node.command(command).find("Err -") == 0);
assert(f.node.state() == before && f.fs.files == files && f.fs.writes == writes);
}
ClientInfo unauthorized; unauthorized.admin = false;
for (const char* command : {"get telemetry.tx.types", "set telemetry.tx.types off"})
assert(f.node.command(command, &unauthorized) == "Err - not permitted");
}
static void checkSelectedManualAndScheduledBatches() {
test_millis = 0;
for (const bool manual : {false, true}) {
for (uint8_t types = 0; types < 32; ++types) {
Fixture f; f.seed(current(!manual, 2, types)); f.history(); f.noiseHistory(); externalHistory(f);
f.node.dual = true;
if (manual) {
const auto reply = f.node.command("send telemetry.tx now");
if (types == 0) assert(reply == "Err - no telemetry packet types enabled");
else {
const std::string expected = "OK - telemetry.tx queued temp=" + std::to_string(types & 1 ? 165 : 0)
+ " volt=" + std::to_string(types & 2 ? 165 : 0)
+ " i2c=" + std::to_string(types & 4 ? 2 : 0) + "/" + std::to_string(types & 4 ? 6 : 0)
+ " noise=" + std::to_string(bool(types & 8)) + "/" + std::to_string(bool(types & 16));
assert(reply == expected);
}
}
serviceDue(f.node); finishPending(f.node);
assert(f.node.attempts == selectedMagics(types));
assert(f.node.packets.size() == selectedMagics(types).size());
for (size_t i = 1; i < f.node.packet_times.size(); ++i)
assert(f.node.packet_times[i] - f.node.packet_times[i - 1] == 90000);
assert(!f.node.telemetry_history_tx_pending && !f.node.telemetry_history_tx_manual);
assert(f.node.telemetry_history_tx_types == types);
}
}
// Manual selections use their own available history, without requiring base samples.
for (const uint8_t types : {uint8_t(4), uint8_t(8), uint8_t(16)}) {
Fixture f; f.seed(current(false, 2, types)); f.node.dual = true;
if (types == 4) externalHistory(f); else f.noiseHistory(1);
assert(f.node.telemetry_history.voltageSampleCount() == 0);
assert(f.node.command("send telemetry.tx now").find("OK - telemetry.tx queued temp=0 volt=0") == 0);
serviceDue(f.node); finishPending(f.node);
assert(f.node.packets.size() == (types == 4 ? 6U : 1U));
assert(f.node.attempts.front() == (types == 4 ? "IVB1" : types == 8 ? "N1B1" : "N2B1"));
}
for (const uint8_t types : {uint8_t(1), uint8_t(2), uint8_t(4), uint8_t(8), uint8_t(16)}) {
Fixture f; f.seed(current(false, 2, types)); const auto before = f.node.state();
assert(f.node.command("send telemetry.tx now") == "Err - no enabled telemetry data available");
assert(f.node.state() == before && f.node.packets.empty());
}
Fixture warmup; warmup.seed(current(true, 2, 8)); warmup.noiseHistory(1);
warmup.node.telemetry_history.record(1000000, 22, true, 3750, 0, 0, false);
warmup.node.serviceTelemetryHistoryTx(); assert(warmup.node.packets.empty());
warmup.history(); warmup.node.serviceTelemetryHistoryTx();
assert(warmup.node.attempts == std::vector<std::string>{"N1B1"});
}
static void checkTypeSaveRollbackAndAmbiguity() {
for (const bool all : {false, true}) {
for (unsigned failure = 0; failure < 6; ++failure) {
Fixture f; f.seed(); f.history(); externalHistory(f);
f.node.telemetry_history_tx_pending = 31; f.node.telemetry_history_tx_manual = true;
f.node.telemetry_history_tx_external_channel = 1; f.node.telemetry_history_tx_external_chunk = 2;
f.node.telemetry_history_next_tx_uptime = 90000; f.node.telemetry_history_tx_resume_uptime = 9000000;
const auto before = f.node.state(); const auto prior = f.fs.files.at(target);
if (failure == 0) f.fs.fail_write_open.insert(temp);
if (failure == 1) f.fs.write_limit[temp] = 6;
if (failure == 2) f.fs.fail_rename.insert({temp, target});
if (failure == 3) f.fs.fail_final_read = true;
if (failure == 4) f.fs.final_read_limit = 6;
if (failure == 5) f.fs.corrupt_publish = true;
assert(f.node.command(all ? "set telemetry.tx.types off" : "set telemetry.tx.volt.i2c off") ==
"Err - unable to save telemetry.tx types");
assert(f.node.state() == before); // failed publication cannot prune work or reset timers/cursors
if (failure < 3) {
assert(f.node.telemetry_history_tx_prefs_healthy && f.fs.files.at(target) == prior);
f.fs.clearFaults();
assert(f.node.command(all ? "set telemetry.tx.types off" : "set telemetry.tx.volt.i2c off").find("OK -") == 0);
assert(f.fs.files.at(target) == current(true, 7, all ? 0 : 27));
} else {
assert(!f.node.telemetry_history_tx_prefs_healthy); f.expectFault();
f.boot(); f.expectFault(); // ambiguous storage remains closed across reboot
f.fs.clearFaults(); f.boot();
if (failure == 5) f.expectFault(); // corrupt published bytes never become authoritative
else {
assert(f.node.telemetry_history_tx_prefs_healthy);
assert(f.node.telemetry_history_tx_types == (all ? 0 : 27));
assert(f.node.telemetry_history_tx_enabled && f.node.telemetry_history_tx_interval_days == 7);
assert(!f.node.telemetry_history_tx_pending && !f.node.telemetry_history_tx_manual);
}
}
}
}
}
static void checkTypeChangesDuringBatches() {
test_millis = 0;
Fixture f; f.seed(current(false, 2, 12)); f.noiseHistory(1); externalHistory(f);
f.node.telemetry_history_next_tx_uptime = 9000000;
assert(f.node.command("send telemetry.tx now").find("OK -") == 0);
serviceDue(f.node); serviceDue(f.node); // N1B1, then first I2C chunk
assert(f.node.attempts == std::vector<std::string>({"N1B1", "IVB1"}));
assert(f.node.telemetry_history_tx_external_chunk == 1);
const auto due = f.node.telemetry_history_next_tx_uptime;
assert(f.node.command("set telemetry.tx.noise off").find("OK -") == 0);
assert(f.node.command("set telemetry.tx.noise on").find("OK -") == 0);
assert(f.node.telemetry_history_tx_pending == 4); // enabling adds nothing to the current batch
assert(f.node.telemetry_history_next_tx_uptime == due && f.node.telemetry_history_tx_external_chunk == 1);
f.node.uptime_millis = due - 1; f.node.last_millis = test_millis;
f.node.serviceTelemetryHistoryTx(); assert(f.node.packets.size() == 2);
serviceDue(f.node); // second I2C chunk, never repeat chunk zero
assert(read32(f.node.packets[2].payload + 12) == read32(f.node.packets[1].payload + 12) + 64 * 1800);
const size_t admitted = f.node.packets.size();
assert(f.node.command("set telemetry.tx.volt.i2c off").find("OK -") == 0);
assert(f.node.telemetry_history_tx_pending == 0 && !f.node.telemetry_history_tx_manual);
assert(f.node.telemetry_history_tx_external_channel == 0 && f.node.telemetry_history_tx_external_chunk == 0);
assert(f.node.telemetry_history_next_tx_uptime == 9000000 && !f.node.telemetry_history_tx_resume_uptime);
assert(f.node.packets.size() == admitted); // producer cancellation cannot retract admitted radio packets
f.node.uptime_millis += 90000; f.node.serviceTelemetryHistoryTx();
assert(f.node.packets.size() == admitted);
assert(f.node.command("set telemetry.tx.volt.i2c on").find("OK -") == 0);
assert(!f.node.telemetry_history_tx_pending);
assert(f.node.command("send telemetry.tx now").find("noise=1/0") != std::string::npos);
assert(f.node.telemetry_history_tx_pending == 12);
Fixture automatic; automatic.seed(); automatic.history();
automatic.node.serviceTelemetryHistoryTx(); assert(automatic.node.packets.size() == 1);
const uint64_t now = automatic.node.uptime_millis;
assert(automatic.node.command("set telemetry.tx.volt off").find("OK -") == 0);
assert(!automatic.node.telemetry_history_tx_pending);
assert(automatic.node.telemetry_history_next_tx_uptime == now + 7ULL * 24 * 60 * 60 * 1000);
automatic.node.serviceTelemetryHistoryTx(); assert(automatic.node.packets.size() == 1);
Fixture stale; stale.seed(current(false)); stale.history();
assert(stale.node.command("send telemetry.tx now").find("OK -") == 0);
stale.node.telemetry_history_tx_types = 0; // defensive service filtering also works before its old due time
stale.node.telemetry_history_next_tx_uptime = 1000000;
stale.node.serviceTelemetryHistoryTx();
assert(stale.node.packets.empty() && !stale.node.telemetry_history_tx_pending && !stale.node.telemetry_history_tx_manual);
}
int main() {
checkFreshAndValid();
checkLoadFailures();
checkLegacyMigration();
checkWriteAndValidationFailures();
checkRecoveryAndRenameFailures();
checkAmbiguousPublicationAndServiceGate();
checkRoutePublicationAuthority();
checkRouteReadbackAndRetry();
checkNoiseHistoryTransmission();
checkNoiseHistoryRetries();
checkLastTxCompletionAndOwnership();
checkLastTxAgeAndClockWrap();
checkLastTxSurvivesScheduleAndStatusTruncation();
checkTypePreferencesAndCommands();
checkSelectedManualAndScheduledBatches();
checkTypeSaveRollbackAndAmbiguity();
checkTypeChangesDuringBatches();
puts("telemetry persistence checks passed");
}