Harden USB logging and persist watchdog events in management reports

This commit is contained in:
mikecarper committed 2026-10-03 15:29:19 -07:00
1 parent bd55369aa9
commit 3add4695cc
98 files changed
+7207 -372

No files matched your search

+358
View File
@@ -0,0 +1,358 @@
"""Exercise actual CommonCLI USB debug commands, images, and import commits."""
from pathlib import Path
import os
import re
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <math.h>
#include <cstring>
#include <map>
#include <string>
#include <vector>
#include <helpers/IdentityStore.h>
class TestFS : public MemoryFS {
public:
using MemoryFS::open;
File open(const char* path, uint8_t mode) {
return MemoryFS::open(path, mode == FILE_O_WRITE ? "w" : "r");
}
};
#undef FILESYSTEM
#define FILESYSTEM TestFS
#include "ContactFileTransaction.h"
#define ATOMIC_FILE_WRITER_IMPLEMENTATION
#include <helpers/AtomicFileWriter.h>
#include <helpers/PrefsSaveRouting.h>
#include <helpers/CommonPrefsRecovery.h>
#include <helpers/MQTTPrefsAtomicStore.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/RepeaterRadioTiming.h>
#include <helpers/bridges/ESPNowBridgeFormat.h>
#include <helpers/radiolib/LR2021SideDetectorConfig.h>
#define MIN_LORA_TX_POWER -9
#define MAX_LORA_TX_POWER 22
#include <helpers/radiolib/RadioPowerLimits.h>
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#define MESH_USB_LOGGING_AVAILABLE 1
#define DEFAULT_POWERSAVING_ENABLED 1
#define DEFAULT_CAD_ENABLED 0
#define BRIDGE_MAX_BAUD 115200
#define RX_POWERSAVING_DEFAULT_RX_US 65625UL
#define RX_POWERSAVING_DEFAULT_SLEEP_US 60000UL
#define FLOOD_RETRY_PATH_GATE_DISABLED 0xFF
#define MIN_LOCAL_ADVERT_INTERVAL 60
#define constrain(value, low, high) ((value) < (low) ? (low) : ((value) > (high) ? (high) : (value)))
@CONSTANTS@
namespace mesh {
struct RadioProfiles { static constexpr unsigned MaxPreamble = 65535; };
static bool usb_logging = true, usb_debug = false;
static bool prohibit_stale_debug = false;
static unsigned usb_updates = 0, debug_updates = 0;
void setUsbLoggingEnabled(bool enabled) {
if (enabled && prohibit_stale_debug) assert(!usb_debug);
usb_logging = enabled; ++usb_updates;
}
void setUsbDebugEnabled(bool enabled) { usb_debug = enabled; ++debug_updates; }
bool isUsbLoggingEnabled() { return usb_logging; }
bool isUsbDebugEnabled() { return usb_debug; }
bool isUsbDebugLoggingEnabled() { return usb_logging && usb_debug; }
}
struct NodePrefs {
@FIELDS@
bool dirty = false;
void clearDirty() { dirty = false; }
void markUnsaved() { dirty = true; }
};
@HELPERS@
void ensureRxPowerSavingDefaults(uint32_t*, uint32_t*) {}
void recalcRxPowerSavingFromLevel(uint8_t, uint8_t, float, uint8_t,
uint32_t*, uint32_t*) {}
@LEGACY@
@SERIALIZER@
@STORE@;
struct CommonCLI;
struct Callbacks {
CommonCLI* cli;
void savePrefs(PrefsSaveRouting::Scope scope);
void updateAdvertTimer() {}
};
struct CommonCLI {
FILESYSTEM fs;
NodePrefs prefs;
NodePrefs* _prefs = &prefs;
LegacyObserverTail _legacy_tail{};
Callbacks callbacks{this};
Callbacks* _callbacks = &callbacks;
bool _com_prefs_needs_upgrade = false;
bool _common_save_result_known = false, _common_save_succeeded = false;
bool _observer_save_result_known = false, _observer_save_succeeded = false;
uint32_t _prefs_save_failures = 0;
CommonCLI() {
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
fs.rename_replaces = true;
#endif
prefs.freq = 910; prefs.bw = 62.5f; prefs.sf = 7; prefs.cr = 5;
prefs.primary_radio_preamble = 48;
}
void savePrefs(FILESYSTEM*, PrefsSaveRouting::Scope);
void savePrefs(PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common);
bool trySavePrefs(PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common);
void loadPrefsInt(FILESYSTEM*, const char*);
bool recoverCommonPrefsFiles(FILESYSTEM*);
bool saveCommonPrefsImageAtomically(FILESYSTEM*);
bool saveMQTTPrefs(FILESYSTEM*) { return true; }
void set(const char* config, char* reply) {
@SET_DEBUG@
@SET_MASTER@
strcpy(reply, "unknown");
}
void get(const char* config, char* reply) {
@GET_DEBUG@
@GET_MASTER@
strcpy(reply, "unknown");
}
};
void Callbacks::savePrefs(PrefsSaveRouting::Scope scope) {
cli->savePrefs(&cli->fs, scope);
}
@METHODS@
// Execute the production dynamic schema, replacing only its generic codec.
// Registered uint8_t references emulate imported configuration values.
class ConfigSerializer {
protected:
virtual void structure() = 0;
template<typename T> void def(const char*, T&) {}
void def(const char* key, uint8_t& value) { bytes[key] = &value; }
void def(const char*, char*, size_t) {}
public:
std::map<std::string, uint8_t*> bytes;
bool import(const char* key, uint8_t value) {
structure();
if (!bytes.count(key)) return false;
*bytes[key] = value;
return true;
}
};
@DYNAMIC@;
struct Capture {
std::vector<uint8_t> bytes;
size_t write(const uint8_t* data, size_t n) {
bytes.insert(bytes.end(), data, data + n);
return n;
}
};
int main() {
CommonCLI cli;
char reply[160] = {};
assert(cli.prefs.usb_debug_enabled == 0);
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
assert(cli._common_save_succeeded);
const auto baseline = cli.fs.files["/com_prefs"];
assert(baseline.size() == 870);
const size_t debug_offset = baseline.size() - 1;
const size_t logging_offset = baseline.size() - 7;
const size_t preamble_offset = baseline.size() - 4;
const size_t espnow_offset = baseline.size() - 2;
assert(baseline[debug_offset] == 0 && baseline[logging_offset] == 1);
assert(baseline[preamble_offset] == 48 && baseline[espnow_offset] == 1);
Capture capture;
assert(writeCommonPrefsImage(capture, &cli.prefs));
assert(capture.bytes == baseline); // Checked and ordinary writers agree.
cli.set("usb.debug on", reply);
assert(strcmp(reply, "OK - USB debug on (saved)") == 0);
assert(cli.prefs.usb_debug_enabled == 1 && mesh::isUsbDebugLoggingEnabled());
auto enabled = cli.fs.files["/com_prefs"];
assert(enabled.size() == baseline.size() && enabled[debug_offset] == 1);
assert(std::equal(baseline.begin(), baseline.begin() + debug_offset, enabled.begin()));
cli.set("usb.logging off", reply);
assert(!mesh::isUsbLoggingEnabled() && mesh::isUsbDebugEnabled());
assert(!mesh::isUsbDebugLoggingEnabled());
cli.get("usb.debug", reply);
assert(strcmp(reply, "> on") == 0); // Saved intent is visible with master OFF.
cli.get("usb.logging", reply);
assert(strcmp(reply, "> off") == 0);
// Malformed tokens and suffixes never mutate either gate or the image.
const auto unchanged = cli.fs.files["/com_prefs"];
for (const char* command : {"usb.debug", "usb.debug true", "usb.debug 1",
"usb.debug ON", "usb.debug off junk", "usb.debug off ",
"usb.debug on reboot", "usb.debug off reboot"}) {
const unsigned updates = mesh::debug_updates;
cli.set(command, reply);
assert(strcmp(reply, "Error: usage set usb.debug on|off") == 0);
assert(cli.prefs.usb_debug_enabled == 1 && mesh::isUsbDebugEnabled());
assert(mesh::debug_updates == updates);
assert(cli.fs.files["/com_prefs"] == unchanged);
}
cli.set("usb.debugger off", reply);
assert(strcmp(reply, "unknown") == 0);
cli.set("usb.debug\t \toff", reply);
assert(strcmp(reply, "OK - USB debug off (saved)") == 0);
assert(cli.prefs.usb_debug_enabled == 0 && !mesh::isUsbDebugEnabled());
const auto debug_off = cli.fs.files["/com_prefs"];
// Failed first/write/verification/publication stages cannot enable debug.
for (int fault : {0, 1, 2, 3}) {
if (fault == 0) cli.fs.fail_write = true;
if (fault == 1) cli.fs.fail_write_after = debug_offset;
if (fault == 2) cli.fs.fail_read_open = true;
if (fault == 3) cli.fs.fail_rename = 1;
const unsigned updates = mesh::debug_updates;
cli.set("usb.debug on", reply);
assert(strcmp(reply, "Error: USB debug not saved; unchanged") == 0);
assert(cli.prefs.usb_debug_enabled == 0 && !mesh::isUsbDebugEnabled());
assert(mesh::debug_updates == updates && !mesh::isUsbLoggingEnabled());
assert(cli.fs.files["/com_prefs"] == debug_off);
cli.fs.fail_write = false; cli.fs.fail_write_after = -1;
cli.fs.fail_read_open = false; cli.fs.fail_rename = 0;
}
// Imported schemas apply gates only after a successful common commit.
BridgePrefs dynamic(&cli.prefs);
assert(dynamic.import("usb_dbg", 1));
assert(!mesh::isUsbDebugEnabled());
const unsigned observer_updates = mesh::debug_updates;
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Observer);
assert(!mesh::isUsbDebugEnabled() && mesh::debug_updates == observer_updates);
assert(cli.fs.files["/com_prefs"] == debug_off);
cli.fs.fail_write = true;
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
assert(!cli._common_save_succeeded && !mesh::isUsbDebugEnabled());
cli.fs.fail_write = false;
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
assert(cli._common_save_succeeded && mesh::isUsbDebugEnabled());
assert(!mesh::isUsbDebugLoggingEnabled());
assert(dynamic.import("usb_log", 1));
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
assert(mesh::isUsbDebugLoggingEnabled());
// A debug-OFF import must take effect before the master is enabled again.
mesh::setUsbLoggingEnabled(false);
assert(dynamic.import("usb_dbg", 0));
mesh::prohibit_stale_debug = true;
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
mesh::prohibit_stale_debug = false;
assert(mesh::isUsbLoggingEnabled() && !mesh::isUsbDebugEnabled());
for (uint8_t bad : {2, 255}) {
assert(dynamic.import("usb_dbg", bad));
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
assert(cli._common_save_succeeded && !mesh::isUsbDebugEnabled());
assert(cli.prefs.usb_debug_enabled == 0 && cli.fs.files["/com_prefs"][debug_offset] == 0);
}
// Every older/torn length inherits debug OFF, even after loading debug ON.
auto image = baseline;
image[logging_offset] = 0; image[debug_offset] = 1;
for (size_t size = 0; size < image.size(); ++size) {
CommonCLI reader;
reader.prefs.usb_debug_enabled = 1;
reader.fs.files["/com_prefs"] = {image.begin(), image.begin() + size};
reader.loadPrefsInt(&reader.fs, "/com_prefs");
assert(reader.prefs.usb_debug_enabled == 0);
if (size > logging_offset) assert(reader.prefs.usb_logging_enabled == 0);
}
for (uint8_t saved : {0, 1, 2, 255}) {
image[debug_offset] = saved;
cli.fs.files["/com_prefs"] = image;
cli.prefs.usb_debug_enabled = 1;
cli.loadPrefsInt(&cli.fs, "/com_prefs");
assert(cli.prefs.usb_debug_enabled == (saved == 1 ? 1 : 0));
assert(cli.prefs.usb_logging_enabled == 0);
assert(cli.prefs.primary_radio_preamble == 48 && cli.prefs.espnow_bridge_enabled == 1);
}
cli.prefs.usb_debug_enabled = 1;
cli.loadPrefsInt(&cli.fs, "/missing");
assert(cli.prefs.usb_debug_enabled == 0);
}
'''
class CommonUsbDebugTest(unittest.TestCase):
def test_actual_common_commands_and_persistence(self):
source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text(encoding='utf-8')
header = (ROOT / 'src/helpers/CommonCLI.h').read_text(encoding='utf-8')
fields = header.split('class NodePrefs :', 1)[1].split('private:', 1)[0]
fields = '\n'.join(re.findall(
r'^\s*(?:float|double|char|u?int(?:8|16|32)_t)\s+[^;]+;', fields, re.M))
constants = '\n'.join(re.findall(
r'^\s*#define\s+\w+\s+(?:0x[\dA-Fa-f]+|\d+)\s*$', header, re.M))
constants += '\n' + '\n'.join(re.findall(
r'^static const size_t LEGACY_\w+\s*=[^;]+;', source, re.M))
constants += '\n' + re.search(
r'^static constexpr uint8_t DEFAULT_ADVERT_LOC_POLICY\s*=[^;]+;',
header, re.M).group()
helpers = '\n'.join(extract_braced(source, signature) for signature in (
'static bool bwMatches(', 'static bool isValidLoRaBandwidth(',
'static float defaultLoRaBandwidth(', 'static void markDirectRetryPrefsValid(',
'static void applyFloodRetryPreset(', 'static void applyDirectRetryPreset(',
'static void setDefaultDirectRetryPrefs(', 'static bool directRetryPrefsValid('))
methods = '\n'.join(extract_braced(source, signature) for signature in (
'static const char* commonPrefsSaveResultName(',
'bool CommonCLI::recoverCommonPrefsFiles(',
'bool CommonCLI::saveCommonPrefsImageAtomically(',
'void CommonCLI::savePrefs(FILESYSTEM*',
'void CommonCLI::savePrefs(PrefsSaveRouting::Scope scope)',
'bool CommonCLI::trySavePrefs(', 'void CommonCLI::loadPrefsInt('))
code = HARNESS
for key, value in {
'FIELDS': fields, 'CONSTANTS': constants, 'HELPERS': helpers,
'LEGACY': extract_braced(header, 'struct LegacyObserverTail') + ';',
'SERIALIZER': 'template<typename Writer>\n' + extract_braced(
source, 'static bool writeCommonPrefsImage('),
'STORE': extract_braced(source, 'class CommonPrefsFileStore'),
'METHODS': methods,
'DYNAMIC': extract_braced(header, 'class BridgePrefs :'),
'SET_DEBUG': extract_braced(source, 'if (strncmp(config, "usb.debug", 9)'),
'SET_MASTER': extract_braced(source, 'if (strncmp(config, "usb.logging", 11)'),
'GET_DEBUG': extract_braced(source, 'if (strcmp(config, "usb.debug") == 0)'),
'GET_MASTER': extract_braced(source, 'if (strcmp(config, "usb.logging") == 0)'),
}.items():
code = code.replace('@' + key + '@', value)
sanitizer_flags = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie']
with tempfile.TemporaryDirectory(prefix='common-usb-debug-') as directory:
work = Path(directory)
transaction = (ROOT / 'src/helpers/ContactFileTransaction.h').read_text()
(work / 'ContactFileTransaction.h').write_text(transaction.replace(
'#include "IdentityStore.h"', '#include <helpers/IdentityStore.h>'))
(work / 'test.cpp').write_text(code, encoding='utf-8')
variants = [('ENABLE_OTA', platform) for platform in (
'NRF52_PLATFORM', 'STM32_PLATFORM', 'ESP32_PLATFORM', 'RP2040_PLATFORM')]
variants.append(('ENABLE_OTA', 'ESP32_PLATFORM', 'WITH_MQTT_BRIDGE'))
variants.extend((('NRF52_PLATFORM',), ('ESP32_PLATFORM', 'WITH_MQTT_BRIDGE')))
for macros in variants:
with self.subTest(macros=macros):
exe = work / ('test.exe' if os.name == 'nt' else 'test')
built = subprocess.run([
os.environ.get('CXX', 'g++'), '-std=c++17',
*['-D' + macro + '=1' for macro in macros], *sanitizer_flags,
'-I' + str(work), '-I' + str(ROOT / 'test/fixtures/radio_profiles/mocks'),
'-I' + str(ROOT / 'src'), '-I' + str(ROOT / 'src/helpers'),
str(work / 'test.cpp'), '-o', str(exe)],
capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stderr)
tested = subprocess.run([str(exe)], capture_output=True, text=True, timeout=10)
self.assertEqual(tested.returncode, 0, tested.stderr)
def test_outer_load_applies_both_gates(self):
source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text(encoding='utf-8')
load = extract_braced(source, 'void CommonCLI::loadPrefs(FILESYSTEM*')
self.assertIn('_prefs->usb_debug_enabled = 0;', load)
self.assertIn('mesh::setUsbLoggingEnabled(_prefs->usb_logging_enabled != 0);', load)
self.assertIn('mesh::setUsbDebugEnabled(_prefs->usb_debug_enabled != 0);', load)
self.assertLess(load.index('mesh::setUsbDebugEnabled('),
load.index('mesh::setUsbLoggingEnabled('))
if __name__ == '__main__':
unittest.main()