Preserve telemetry preferences and routes across storage failures

This commit is contained in:
mikecarper committed 2026-10-03 18:34:12 -07:00
1 parent 645c8a1119
commit 0cf1fcead9
9 files changed
+892 -52

No files matched your search

+8
View File
@@ -47,6 +47,14 @@ history is entirely zero are treated as disconnected and are not sent. The
command requires at least one collected base sample and works even when the
automatic schedule is off.
Schedule changes are saved transactionally; a failed write does not truncate
the previous preferences. A legacy route must be successfully migrated into
`data.tx` before its schedule can run. Corrupt, unreadable, or ambiguously
committed preferences stop automatic and pending sends. `get telemetry.tx`
shows `fault=prefs`, and manual sends return an error instead of acknowledging
work that cannot run. Such a storage fault must be repaired before settings
can be changed; the firmware does not overwrite an unmigrated legacy file.
## Decode a packet
<div class="telemetry-tool" data-telemetry-decoder>
+101 -52
View File
@@ -1,6 +1,14 @@
#include "MyMesh.h"
#include <helpers/UsbLogging.h>
#include <helpers/FileRead.h>
#if MESH_ENABLE_TELEMETRY_HISTORY
#include <helpers/FilePresence.h>
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#include <helpers/AtomicFileWriter.h>
#else
#include <helpers/ContactFileTransaction.h>
#endif
#endif
#include <helpers/radiolib/RadioPowerLimits.h>
#include <helpers/radiolib/RxBoostedGainDefaults.h>
#include <algorithm>
@@ -3537,6 +3545,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
clock_sync_next_attempt_uptime = CLOCK_SYNC_STARTUP_DELAY_MILLIS;
#if MESH_ENABLE_TELEMETRY_HISTORY
telemetry_history_tx_prefs_healthy = false;
telemetry_history_tx_enabled = false;
telemetry_history_tx_interval_days = TELEMETRY_HISTORY_TX_DEFAULT_DAYS;
telemetry_history_tx_pending = 0;
@@ -11577,6 +11586,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
strcpy(reply, "Err - not permitted");
return;
}
if (!telemetry_history_tx_prefs_healthy) {
strcpy(reply, "Err - telemetry.tx prefs unavailable");
return;
}
const uint8_t* data_path = NULL;
uint8_t data_path_len = OUT_PATH_UNKNOWN;
if (!_cli.getDataTxPath(data_path, data_path_len)) {
@@ -11642,6 +11655,12 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
if (strcmp(spec, "off") == 0) {
enable = false;
} else {
// strtoul accepts a minus sign and negates modulo unsigned-long width;
// a large negative input can otherwise wrap into a valid day count.
if (*spec < '0' || *spec > '9') {
strcpy(reply, "Err - use: set telemetry.tx schedule <off|1-30d>");
return;
}
char* end = NULL;
days = strtoul(spec, &end, 10);
if (end == spec) days = 0;
@@ -12398,7 +12417,36 @@ uint8_t MyMesh::resizeTelemetryGpsDays(uint8_t requested_days) {
}
#endif
static bool recoverTelemetryHistoryTxPrefs(FILESYSTEM* fs) {
if (fs == NULL) return false;
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
return mesh::ContactFileTransaction::recover(
fs, TELEMETRY_HISTORY_TX_PREFS_FILE, mesh::filePresence<FILESYSTEM>);
#else
return true;
#endif
}
static bool readTelemetryHistoryTxPrefsImage(FILESYSTEM* fs,
uint8_t image[7 + MAX_PATH_SIZE],
size_t& size) {
File file = openFloodSettingsRead(fs, TELEMETRY_HISTORY_TX_PREFS_FILE);
if (!file) return false;
size = file.size();
const bool complete = (size == 6 || size == 7 + MAX_PATH_SIZE)
&& file.read(image, size) == size;
file.close();
if (!complete) return false;
const bool legacy = size == 7 + MAX_PATH_SIZE;
if (memcmp(image, legacy ? "THT2" : "THT3", 4) || image[4] > 1) return false;
const uint8_t days = image[legacy ? 6 : 5];
if (days < 1 || days > TELEMETRY_HISTORY_TX_MAX_DAYS) return false;
return !legacy || (image[5] == OUT_PATH_UNKNOWN ? image[4] == 0
: image[5] != OUT_PATH_FORCE_FLOOD && mesh::Packet::isValidPathLen(image[5]));
}
void MyMesh::loadTelemetryHistoryTxPrefs() {
telemetry_history_tx_prefs_healthy = false;
telemetry_history_tx_enabled = false;
telemetry_history_tx_interval_days = TELEMETRY_HISTORY_TX_DEFAULT_DAYS;
telemetry_history_tx_pending = 0;
@@ -12408,60 +12456,59 @@ void MyMesh::loadTelemetryHistoryTxPrefs() {
telemetry_history_next_tx_uptime = 0;
telemetry_history_tx_resume_uptime = 0;
if (_fs == NULL || !_fs->exists(TELEMETRY_HISTORY_TX_PREFS_FILE)) return;
File file = openFloodSettingsRead(_fs, TELEMETRY_HISTORY_TX_PREFS_FILE);
if (!file) return;
uint8_t magic[4] = {};
uint8_t enabled = 0;
uint8_t path_len = OUT_PATH_UNKNOWN;
uint8_t interval_days = TELEMETRY_HISTORY_TX_DEFAULT_DAYS;
uint8_t path[MAX_PATH_SIZE] = {};
bool valid = file.read(magic, sizeof(magic)) == sizeof(magic);
const bool legacy = valid && memcmp(magic, "THT2", sizeof(magic)) == 0;
if (legacy) {
valid = file.read(&enabled, sizeof(enabled)) == sizeof(enabled)
&& file.read(&path_len, sizeof(path_len)) == sizeof(path_len)
&& file.read(&interval_days, sizeof(interval_days)) == sizeof(interval_days)
&& file.read(path, sizeof(path)) == sizeof(path);
} else if (valid && memcmp(magic, "THT3", sizeof(magic)) == 0) {
valid = file.read(&enabled, sizeof(enabled)) == sizeof(enabled)
&& file.read(&interval_days, sizeof(interval_days)) == sizeof(interval_days);
} else {
valid = false;
}
file.close();
valid = valid && enabled <= 1 && interval_days >= 1
&& interval_days <= TELEMETRY_HISTORY_TX_MAX_DAYS;
if (legacy && enabled != 0) {
valid = valid && path_len != OUT_PATH_UNKNOWN
&& path_len != OUT_PATH_FORCE_FLOOD
&& mesh::Packet::isValidPathLen(path_len);
}
if (!valid) return;
telemetry_history_tx_enabled = enabled != 0;
telemetry_history_tx_interval_days = interval_days;
if (legacy && path_len != OUT_PATH_UNKNOWN
&& mesh::Packet::isValidPathLen(path_len)) {
_cli.adoptLegacyDataTxPath(path, path_len);
}
if (!recoverTelemetryHistoryTxPrefs(_fs)) return;
bool present = false;
if (!mesh::filePresence(_fs, TELEMETRY_HISTORY_TX_PREFS_FILE, present)) return;
if (!present) { telemetry_history_tx_prefs_healthy = true; return; }
uint8_t image[7 + MAX_PATH_SIZE]; size_t size = 0;
if (!readTelemetryHistoryTxPrefsImage(_fs, image, size)) return;
const bool legacy = size == sizeof(image);
// Preserve the only durable old route until shared-route adoption succeeds.
// Otherwise an enabled legacy producer would silently use fresh zero-hop.
if (legacy && image[5] != OUT_PATH_UNKNOWN
&& !_cli.adoptLegacyDataTxPath(image + 7, image[5])) return;
telemetry_history_tx_enabled = image[4] != 0;
telemetry_history_tx_interval_days = image[legacy ? 6 : 5];
telemetry_history_tx_prefs_healthy = true;
}
bool MyMesh::saveTelemetryHistoryTxPrefs() {
if (_fs == NULL) return false;
File file = openFloodSettingsWrite(_fs, TELEMETRY_HISTORY_TX_PREFS_FILE);
if (!file) return false;
const uint8_t magic[4] = {'T', 'H', 'T', '3'};
const uint8_t enabled = telemetry_history_tx_enabled ? 1 : 0;
bool success = file.write(magic, sizeof(magic)) == sizeof(magic)
&& file.write(&enabled, sizeof(enabled)) == sizeof(enabled)
&& file.write(&telemetry_history_tx_interval_days,
sizeof(telemetry_history_tx_interval_days))
== sizeof(telemetry_history_tx_interval_days);
file.close();
if (!telemetry_history_tx_prefs_healthy) return false;
bool previous_present = false;
uint8_t previous[7 + MAX_PATH_SIZE]; size_t previous_size = 0;
if (!recoverTelemetryHistoryTxPrefs(_fs)
|| !mesh::filePresence(_fs, TELEMETRY_HISTORY_TX_PREFS_FILE, previous_present)
|| (previous_present && !readTelemetryHistoryTxPrefsImage(_fs, previous, previous_size))) {
telemetry_history_tx_prefs_healthy = false; return false;
}
const uint8_t image[] = {'T', 'H', 'T', '3', uint8_t(telemetry_history_tx_enabled),
telemetry_history_tx_interval_days};
bool success;
{
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
mesh::AtomicFileWriter writer(_fs, TELEMETRY_HISTORY_TX_PREFS_FILE);
#else
mesh::ContactFileTransaction writer(
_fs, TELEMETRY_HISTORY_TX_PREFS_FILE, mesh::filePresence<FILESYSTEM>);
#endif
success = writer && writer.write(image, sizeof(image)) == sizeof(image) && writer.commit();
}
uint8_t verify[7 + MAX_PATH_SIZE]; size_t verify_size = 0;
if (success) {
success = readTelemetryHistoryTxPrefsImage(_fs, verify, verify_size)
&& verify_size == sizeof(image) && !memcmp(verify, image, sizeof(image));
telemetry_history_tx_prefs_healthy = success;
} else {
// Permit an administrator retry only when the old durable state is
// demonstrably intact/recoverable. An ambiguous publish/readback must not
// keep a producer running or overwrite an unmigrated/corrupt statefile.
bool present = false;
telemetry_history_tx_prefs_healthy = recoverTelemetryHistoryTxPrefs(_fs)
&& mesh::filePresence(_fs, TELEMETRY_HISTORY_TX_PREFS_FILE, present)
&& present == previous_present
&& (!present || (readTelemetryHistoryTxPrefsImage(_fs, verify, verify_size)
&& verify_size == previous_size && !memcmp(verify, previous, previous_size)));
}
return success;
}
@@ -12476,11 +12523,12 @@ void MyMesh::formatTelemetryHistoryTxStatus(char* reply,
char path_reply[132];
formatPathReply(data_path, have_path ? data_path_len : OUT_PATH_UNKNOWN,
path_reply, sizeof(path_reply));
snprintf(reply, reply_size, "> %s%ud id=%s i2c=%u p=%s",
snprintf(reply, reply_size, "> %s%ud id=%s i2c=%u%s p=%s",
telemetry_history_tx_enabled ? "on " : "off ",
(unsigned)telemetry_history_tx_interval_days,
source_id,
(unsigned)external_voltage_history.populatedChannelCount(),
telemetry_history_tx_prefs_healthy ? "" : " fault=prefs",
path_reply[0] == '>' && path_reply[1] == ' '
? path_reply + 2 : path_reply);
}
@@ -12525,6 +12573,7 @@ bool MyMesh::sendExternalVoltageHistorySnapshot(uint8_t channel_index,
}
void MyMesh::serviceTelemetryHistoryTx() {
if (!telemetry_history_tx_prefs_healthy) return;
const uint64_t current_uptime_millis =
uptime_millis + (uint32_t)(millis() - last_millis);
if (telemetry_history_next_tx_uptime != 0
+1
View File
@@ -542,6 +542,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
#if MESH_ENABLE_TELEMETRY_HISTORY
mesh::TelemetryHistory telemetry_history;
mesh::ExternalVoltageHistory external_voltage_history;
bool telemetry_history_tx_prefs_healthy;
bool telemetry_history_tx_enabled;
uint8_t telemetry_history_tx_interval_days;
uint8_t telemetry_history_tx_pending;
+124
View File
@@ -0,0 +1,124 @@
#include <array>
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <limits>
#include <string>
#include <vector>
#define OUT_PATH_UNKNOWN 0xff
struct ClientInfo {
bool admin = true;
bool isAdmin() const { return admin; }
};
struct SharedRoute {
bool available = true;
unsigned lookups = 0;
bool getDataTxPath(const uint8_t*& path, uint8_t& length) {
static const uint8_t direct[] = {0};
++lookups; path = direct; length = available ? 0 : OUT_PATH_UNKNOWN;
return available;
}
};
struct Fixture {
SharedRoute _cli;
bool telemetry_history_tx_enabled = true;
uint8_t telemetry_history_tx_interval_days = 7;
uint8_t telemetry_history_tx_pending = 7;
bool telemetry_history_tx_manual = true;
uint8_t telemetry_history_tx_external_channel = 2;
uint8_t telemetry_history_tx_external_chunk = 3;
uint64_t telemetry_history_next_tx_uptime = 10000000000ULL;
uint64_t telemetry_history_tx_resume_uptime = 20000000000ULL;
bool fail_save = false;
unsigned saves = 0;
std::array<uint64_t, 8> persisted = state();
std::array<uint64_t, 8> 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}};
}
bool saveTelemetryHistoryTxPrefs() {
++saves;
if (fail_save) return false;
persisted = state(); return true;
}
void handle(char* command, char* reply, ClientInfo* sender);
std::string command(const std::string& suffix, ClientInfo* sender = nullptr) {
const std::string text = "set telemetry.tx schedule " + suffix;
std::vector<char> buffer(text.begin(), text.end()); buffer.push_back(0);
char reply[160] = {};
handle(buffer.data(), reply, sender);
return reply;
}
};
// Includes the complete actual schedule-command branch and trim helper.
#include "production_telemetry_tx.inc"
static void assertUnchanged(const Fixture& f, const std::array<uint64_t, 8>& before) {
assert(f.state() == before && f.persisted == before && f.saves == 0);
}
int main() {
for (const std::string value : {"1", "30", "30d", " 0005d ", " 5 "}) {
Fixture f;
const unsigned expected = value.find('3') != std::string::npos ? 30
: value.find('5') != std::string::npos ? 5 : 1;
assert(f.command(value) == "OK - telemetry.tx schedule=" + std::to_string(expected) + "d");
assert(f.telemetry_history_tx_enabled && f.telemetry_history_tx_interval_days == expected);
assert(f.saves == 1 && f.persisted == f.state());
const auto state = f.state();
for (size_t i = 2; i < state.size(); ++i) assert(state[i] == 0);
}
{
Fixture f;
assert(f.command("off") == "OK - telemetry.tx schedule=off");
assert(!f.telemetry_history_tx_enabled && f.telemetry_history_tx_interval_days == 7);
assert(f.saves == 1 && f.persisted == f.state());
}
std::vector<std::string> malformed = {
"", " ", "0", "31", "-1", "-4294967291", "-18446744073709551611",
"+5", "5dd", "5days", "5 d", "1x", "5.0", "offx", "\t5",
"4294967301", "18446744073709551621", std::string(500, '9')};
// strtoul accepts the sign and negates modulo unsigned-long width. This
// reproduces the wrapped-small-value defect on both 32- and 64-bit hosts.
malformed.push_back("-" + std::to_string(std::numeric_limits<unsigned long>::max() - 4));
for (const auto& value : malformed) {
Fixture f;
const auto before = f.state();
const auto reply = f.command(value);
if (reply.rfind("Err -", 0) != 0) {
fprintf(stderr, "malformed telemetry schedule accepted: '%s': %s\n", value.c_str(), reply.c_str());
assert(false);
}
assertUnchanged(f, before);
}
for (const std::string value : {"off", "5", "30d"}) {
Fixture f; ClientInfo unauthorized; unauthorized.admin = false;
const auto before = f.state();
assert(f.command(value, &unauthorized) == "Err - not permitted");
assertUnchanged(f, before); assert(f._cli.lookups == 0);
}
{
Fixture f; f._cli.available = false;
const auto before = f.state();
assert(f.command("5") == "Err - configure data.tx path first");
assertUnchanged(f, before);
assert(f.command("off") == "OK - telemetry.tx schedule=off");
}
for (const std::string value : {"off", "5", "30d"}) {
Fixture f; f.fail_save = true;
const auto before = f.state();
assert(f.command(value) == "Err - unable to save telemetry.tx schedule");
assert(f.state() == before && f.persisted == before && f.saves == 1);
}
puts("telemetry schedule command checks passed");
}
+126
View File
@@ -0,0 +1,126 @@
#pragma once
// Filesystem API boundary only. Production transaction, format validation,
// migration, command, and scheduling code is compiled unchanged by the runner.
#include <algorithm>
#include <cerrno>
#include <cstring>
#include <map>
#include <set>
#include <string>
#include <vector>
#include <stdint.h>
class MemoryFS;
class File {
MemoryFS* fs_ = nullptr;
std::string path_;
size_t cursor_ = 0;
public:
File() = default;
explicit File(MemoryFS& fs) : fs_(&fs) {}
File(MemoryFS* fs, const char* path) : fs_(fs), path_(path) {}
bool open(const char* path, uint8_t mode);
operator bool() const { return fs_ != nullptr; }
size_t size() const;
bool isDirectory() const { return false; }
int read(uint8_t* data, size_t size);
size_t write(const uint8_t* data, size_t size);
void flush() {}
void close() { fs_ = nullptr; }
};
class MemoryFS {
public:
std::map<std::string, std::vector<uint8_t>> files;
std::set<std::string> fail_read_open, fail_write_open, fail_remove;
std::set<std::pair<std::string, std::string>> fail_rename;
std::map<std::string, size_t> read_limit, write_limit;
bool fail_metadata = false, fail_final_read = false;
int final_read_limit = -1;
bool corrupt_publish = false, fail_metadata_after_publish = false;
unsigned writes = 0, renames = 0;
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
bool rename_replaces = true;
#else
bool rename_replaces = false;
#endif
bool exists(const char* path) const { return files.count(path); }
bool remove(const char* path) {
return !fail_remove.count(path) && files.erase(path);
}
bool rename(const char* from, const char* to) {
++renames;
if (fail_rename.count({from, to}) || !exists(from)
|| (!rename_replaces && exists(to))) return false;
files[to] = files[from]; files.erase(from);
if (!strcmp(from, "/telemetry_tx.tmp")) {
if (corrupt_publish) files[to][0] ^= 1;
if (fail_final_read) fail_read_open.insert(to);
if (final_read_limit >= 0) read_limit[to] = size_t(final_read_limit);
if (fail_metadata_after_publish) fail_metadata = true;
}
return true;
}
File open(const char* path, const char* mode = "r", bool = false) {
if (!path) return {};
if (*mode == 'r' && fail_read_open.count(path)) return {};
if (*mode == 'w') {
if (fail_write_open.count(path)) return {};
files[path].clear();
}
if (!exists(path)) return {};
return File(this, path);
}
void _lockFS() {}
void _unlockFS() {}
MemoryFS* _getFS() { return this; }
void clearFaults() {
fail_read_open.clear(); fail_write_open.clear(); fail_remove.clear();
fail_rename.clear(); read_limit.clear(); write_limit.clear();
fail_metadata = fail_final_read = corrupt_publish = false;
final_read_limit = -1;
fail_metadata_after_publish = false;
}
};
enum { FILE_O_READ = 0, FILE_O_WRITE = 1 };
inline bool File::open(const char* path, uint8_t mode) {
if (!fs_) return false;
*this = fs_->open(path, mode == FILE_O_WRITE ? "w" : "r");
return fs_ != nullptr;
}
inline size_t File::size() const { return fs_ ? fs_->files[path_].size() : 0; }
inline int File::read(uint8_t* data, size_t size) {
if (!fs_) return -1;
const auto limit = fs_->read_limit.find(path_);
if (limit != fs_->read_limit.end()) {
if (cursor_ >= limit->second) return -1;
size = std::min(size, limit->second - cursor_);
}
const auto& bytes = fs_->files.at(path_);
size = std::min(size, bytes.size() - cursor_);
if (size) memcpy(data, bytes.data() + cursor_, size);
cursor_ += size;
return static_cast<int>(size);
}
inline size_t File::write(const uint8_t* data, size_t size) {
if (!fs_) return 0;
++fs_->writes;
auto& bytes = fs_->files[path_];
const auto limit = fs_->write_limit.find(path_);
if (limit != fs_->write_limit.end()) {
if (bytes.size() >= limit->second) return 0;
size = std::min(size, limit->second - bytes.size());
}
bytes.insert(bytes.end(), data, data + size);
return size;
}
#define FILESYSTEM MemoryFS
inline MemoryFS InternalFS;
struct lfs_info {};
static constexpr int LFS_ERR_NOENT = -2;
inline int lfs_stat(MemoryFS* fs, const char* path, lfs_info*) {
return fs->fail_metadata ? -5 : (fs->exists(path) ? 0 : LFS_ERR_NOENT);
}
@@ -0,0 +1,2 @@
#pragma once
#include "IdentityStore.h"
+408
View File
@@ -0,0 +1,408 @@
#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 <array>
#include <cassert>
#include <cstdio>
#include <cstdlib>
#define MAX_PATH_SIZE 64
#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] = {};
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);
};
struct Utils { static void toHex(char*, const uint8_t*, size_t); };
struct DataRouteState;
}
class RegionMap;
class CommonCLI {
public:
mesh::DataRouteState* _data_route = nullptr;
bool adoptLegacyDataTxPath(const uint8_t*, uint8_t);
bool getDataTxPath(const uint8_t*&, uint8_t&) const;
};
struct ClientInfo { bool admin = true; bool isAdmin() const { return admin; } };
class MyMesh {
public:
FILESYSTEM* _fs;
CommonCLI _cli;
struct Identity { uint8_t pub_key[32] = {}; } self_id;
mesh::TelemetryHistory telemetry_history;
mesh::ExternalVoltageHistory external_voltage_history;
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_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 uptime_millis = 0;
uint32_t last_millis = 0;
std::vector<mesh::Packet> packets;
explicit MyMesh(FILESYSTEM& fs) : _fs(&fs) {}
void loadTelemetryHistoryTxPrefs();
bool saveTelemetryHistoryTxPrefs();
void serviceTelemetryHistoryTx();
bool sendTelemetryHistorySnapshot(mesh::TelemetryHistory::Series);
bool sendExternalVoltageHistorySnapshot(uint8_t, uint8_t);
void formatTelemetryHistoryTxStatus(char*, size_t) const;
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) {
packet->path_len = mesh::Packet::copyPath(packet->path, path, length);
packets.push_back(*packet); delete packet; return true;
}
std::array<uint64_t, 8> 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}};
}
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) {
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 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);
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.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.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(), 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 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.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, 8>& 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(), legacy()}) {
for (size_t limit = 0; limit < 6; ++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 < 6; ++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 < 6; ++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 += 2000; 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);
}
int main() {
checkFreshAndValid();
checkLoadFailures();
checkLegacyMigration();
checkWriteAndValidationFailures();
checkRecoveryAndRenameFailures();
checkAmbiguousPublicationAndServiceGate();
puts("telemetry persistence checks passed");
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Exercise the actual repeater telemetry schedule command on the host."""
from pathlib import Path
import os
import re
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
class TelemetryScheduleCommandTests(unittest.TestCase):
def test_actual_command_validation_permissions_and_rollback(self):
source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text(encoding="utf-8")
limit = re.search(r"static const uint8_t TELEMETRY_HISTORY_TX_MAX_DAYS[^;]*;", source).group()
helper = extract_braced(source, "static char* trimSpaces(")
branch = extract_braced(source, "if (strncmp(command, telemetry_tx_schedule_command,")
generated = (limit + "\n" + helper +
"\nvoid Fixture::handle(char* command, char* reply, ClientInfo* sender) {\n"
' static const char telemetry_tx_schedule_command[] = "set telemetry.tx schedule";\n' +
branch + '\n strcpy(reply, "Err - unrecognized command");\n}\n')
sanitizers = [] if os.name == "nt" else ["-fsanitize=address,undefined",
"-fno-sanitize-recover=all", "-fno-omit-frame-pointer"]
with tempfile.TemporaryDirectory() as work:
(Path(work) / "production_telemetry_tx.inc").write_text(generated, encoding="ascii")
executable = Path(work) / "telemetry-tx-command"
compiled = subprocess.run([shutil.which("g++") or "g++", "-std=c++17", "-O1", "-g",
"-Wall", "-Wextra", *sanitizers, "-I", work,
str(ROOT / "test/fixtures/telemetry_tx_command/fixture.cpp"),
"-o", str(executable)], capture_output=True, text=True)
self.assertEqual(compiled.returncode, 0, compiled.stderr)
subprocess.run([str(executable)], check=True)
if __name__ == "__main__":
unittest.main()
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Actual repeater prefs/migration/writers with injected filesystem failures."""
from pathlib import Path
import os
import re
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "test/fixtures/telemetry_tx_persistence"
class TelemetryPersistenceTests(unittest.TestCase):
def test_real_persistence_recovery_and_scheduling_on_each_filesystem(self):
source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text(encoding="ascii")
common = (ROOT / "src/helpers/CommonCLI_Management.cpp").read_text(encoding="ascii")
packet = (ROOT / "src/Packet.cpp").read_text(encoding="ascii")
utils = (ROOT / "src/Utils.cpp").read_text(encoding="ascii")
state = common[common.index("static constexpr char DATA_ROUTE_FILE[]"):
common.index("static uint32_t readRoute32(")]
generated = "namespace mesh {\n" + state + "\n"
generated += "\n".join(extract_braced(packet, signature) for signature in (
"bool Packet::isValidPathLen(", "size_t Packet::writePath(",
"uint8_t Packet::copyPath(")) + "\n"
generated += 'static const char hex_chars[] = "0123456789ABCDEF";\n'
generated += extract_braced(utils, "void Utils::toHex(") + "\n"
generated += "\n".join(extract_braced(common, signature) for signature in (
"static uint32_t readRoute32(", "static void writeRoute32(",
"static bool dataRouteSave(", "static bool dataRouteLoad(")) + "\n}\n"
generated += "\n".join(extract_braced(common, signature) for signature in (
"bool CommonCLI::adoptLegacyDataTxPath(", "bool CommonCLI::getDataTxPath(")) + "\n"
generated += '#define TELEMETRY_HISTORY_TX_PREFS_FILE "/telemetry_tx"\n'
for name in ("RETRY_MILLIS", "PACKET_SPACING_MILLIS", "DEFAULT_DAYS", "MAX_DAYS",
"TEMPERATURE", "VOLTAGE", "EXTERNAL_VOLTAGE"):
generated += re.search(r"static const uint(?:8|64)_t TELEMETRY_HISTORY_TX_" +
name + r"[^;]*;", source).group() + "\n"
generated += "\n".join(extract_braced(source, signature) for signature in (
"static File openFloodSettingsRead(", "static void formatPathReply(",
"static bool recoverTelemetryHistoryTxPrefs(",
"static bool readTelemetryHistoryTxPrefsImage(", "void MyMesh::loadTelemetryHistoryTxPrefs(",
"bool MyMesh::saveTelemetryHistoryTxPrefs(", "bool MyMesh::sendTelemetryHistorySnapshot(",
"bool MyMesh::sendExternalVoltageHistorySnapshot(", "void MyMesh::serviceTelemetryHistoryTx(",
"void MyMesh::formatTelemetryHistoryTxStatus(", "static char* trimSpaces(")) + "\n"
generated += ('void MyMesh::handle(char* command, char* reply, ClientInfo* sender) {\n'
' static const char telemetry_tx_schedule_command[] = "set telemetry.tx schedule";\n'
' static const char telemetry_tx_send_now_command[] = "send telemetry.tx now";\n')
generated += extract_braced(source, "if (strcmp(command, telemetry_tx_send_now_command)") + "\n"
generated += extract_braced(source, "if (strncmp(command, telemetry_tx_schedule_command,")
generated += '\n strcpy(reply, "Err - unrecognized command");\n}\n'
sanitizers = [] if os.name == "nt" else ["-fsanitize=address,undefined",
"-fno-sanitize-recover=all", "-fno-omit-frame-pointer",
"-fno-pie", "-no-pie"]
with tempfile.TemporaryDirectory() as work:
work = Path(work)
(work / "production.inc").write_text(generated, encoding="ascii")
# Copy the real helpers so their local hardware header resolves to
# this filesystem boundary rather than the Arduino implementation.
for name in ("AtomicFileWriter.h", "ContactFileTransaction.h", "FileRead.h",
"FilePresence.h", "PersistentStoreFormat.h"):
shutil.copyfile(ROOT / "src/helpers" / name, work / name)
shutil.copyfile(FIXTURE / "IdentityStore.h", work / "IdentityStore.h")
shutil.copyfile(FIXTURE / "InternalFileSystem.h", work / "InternalFileSystem.h")
for platform in ("ESP32_PLATFORM", "RP2040_PLATFORM", "NRF52_PLATFORM", "STM32_PLATFORM"):
with self.subTest(platform=platform):
executable = work / platform
compiled = subprocess.run([
shutil.which("g++") or "g++", "-std=c++17", "-O1", "-g", "-Wall", "-Wextra",
*sanitizers, "-D" + platform, "-I", str(work), "-I", str(ROOT / "src"),
str(FIXTURE / "fixture.cpp"), "-o", str(executable)],
capture_output=True, text=True, timeout=60)
self.assertEqual(compiled.returncode, 0, compiled.stderr)
executed = subprocess.run([str(executable)], capture_output=True, text=True, timeout=10)
self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr)
self.assertIn("telemetry persistence checks passed", executed.stdout)
if __name__ == "__main__":
unittest.main()