Preserve telemetry preferences and routes across storage failures
This commit is contained in:
1 parent
645c8a1119
commit
0cf1fcead9
9 files changed
+892
-52
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user