From 0cf1fcead9dc55cd9318abb3a308152556612088 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 3 Oct 2026 18:34:12 -0700 Subject: [PATCH] Preserve telemetry preferences and routes across storage failures --- docs/telemetry_decoder.md | 8 + examples/simple_repeater/MyMesh.cpp | 153 ++++--- examples/simple_repeater/MyMesh.h | 1 + .../fixtures/telemetry_tx_command/fixture.cpp | 124 ++++++ .../telemetry_tx_persistence/IdentityStore.h | 126 ++++++ .../InternalFileSystem.h | 2 + .../telemetry_tx_persistence/fixture.cpp | 408 ++++++++++++++++++ test/test_telemetry_tx_command.py | 40 ++ test/test_telemetry_tx_persistence.py | 82 ++++ 9 files changed, 892 insertions(+), 52 deletions(-) create mode 100644 test/fixtures/telemetry_tx_command/fixture.cpp create mode 100644 test/fixtures/telemetry_tx_persistence/IdentityStore.h create mode 100644 test/fixtures/telemetry_tx_persistence/InternalFileSystem.h create mode 100644 test/fixtures/telemetry_tx_persistence/fixture.cpp create mode 100644 test/test_telemetry_tx_command.py create mode 100644 test/test_telemetry_tx_persistence.py diff --git a/docs/telemetry_decoder.md b/docs/telemetry_decoder.md index febe30d2e..631a80b2a 100644 --- a/docs/telemetry_decoder.md +++ b/docs/telemetry_decoder.md @@ -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
diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 4f23a45ad..e6849d70b 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,6 +1,14 @@ #include "MyMesh.h" #include #include +#if MESH_ENABLE_TELEMETRY_HISTORY +#include +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#include +#else +#include +#endif +#endif #include #include #include @@ -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 "); + 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); +#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); +#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 diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index a141ede2c..558be2f11 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -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; diff --git a/test/fixtures/telemetry_tx_command/fixture.cpp b/test/fixtures/telemetry_tx_command/fixture.cpp new file mode 100644 index 000000000..8450b4d05 --- /dev/null +++ b/test/fixtures/telemetry_tx_command/fixture.cpp @@ -0,0 +1,124 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 persisted = state(); + + std::array 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 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& 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 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::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"); +} diff --git a/test/fixtures/telemetry_tx_persistence/IdentityStore.h b/test/fixtures/telemetry_tx_persistence/IdentityStore.h new file mode 100644 index 000000000..76f765279 --- /dev/null +++ b/test/fixtures/telemetry_tx_persistence/IdentityStore.h @@ -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 +#include +#include +#include +#include +#include +#include +#include + +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> files; + std::set fail_read_open, fail_write_open, fail_remove; + std::set> fail_rename; + std::map 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(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); +} diff --git a/test/fixtures/telemetry_tx_persistence/InternalFileSystem.h b/test/fixtures/telemetry_tx_persistence/InternalFileSystem.h new file mode 100644 index 000000000..43b4dabe6 --- /dev/null +++ b/test/fixtures/telemetry_tx_persistence/InternalFileSystem.h @@ -0,0 +1,2 @@ +#pragma once +#include "IdentityStore.h" diff --git a/test/fixtures/telemetry_tx_persistence/fixture.cpp b/test/fixtures/telemetry_tx_persistence/fixture.cpp new file mode 100644 index 000000000..7dc59c7f9 --- /dev/null +++ b/test/fixtures/telemetry_tx_persistence/fixture.cpp @@ -0,0 +1,408 @@ +#include +#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 +#include +#include +#include +#include +#include + +#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 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 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 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 current(bool enabled = true, uint8_t days = 7) { + return {'T', 'H', 'T', '3', uint8_t(enabled), days}; +} +static std::vector legacy(bool enabled = true, uint8_t length = 1, + uint8_t days = 7) { + std::vector 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& 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(length, 0); f.boot(); + f.expectFault(); assert(f.fs.files.at(target) == std::vector(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& prior, + const std::array& 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({'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"); +} diff --git a/test/test_telemetry_tx_command.py b/test/test_telemetry_tx_command.py new file mode 100644 index 000000000..24c0c163c --- /dev/null +++ b/test/test_telemetry_tx_command.py @@ -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() diff --git a/test/test_telemetry_tx_persistence.py b/test/test_telemetry_tx_persistence.py new file mode 100644 index 000000000..45f26b7a5 --- /dev/null +++ b/test/test_telemetry_tx_persistence.py @@ -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()