Files
HaloKeymind/test/test_common_usb_debug.py
T

368 lines
16 KiB
Python

"""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/HilStartupTrace.h>
#include <helpers/MQTTPrefsAtomicStore.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/RepeaterRadioTiming.h>
#include <helpers/GpsPowerPolicy.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() == 877);
const size_t debug_offset = 871, trace_offset = 872;
const size_t logging_offset = 863, preamble_offset = 866;
const size_t espnow_offset = 868, gps_offset = 869;
assert(baseline[debug_offset] == 0 && baseline[logging_offset] == 1);
assert(baseline[preamble_offset] == 48 && baseline[espnow_offset] == 1);
assert(baseline[gps_offset] == 0 && baseline[gps_offset + 1] == 0);
assert(baseline[trace_offset] == 0);
assert(baseline[873] == 0); // OTA channel follows the established tail.
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 image without the complete debug byte inherits debug OFF, even
// after loading debug ON; a missing later trace byte cannot erase debug.
auto image = baseline;
image[logging_offset] = 0; image[debug_offset] = 1;
image[gps_offset] = 0x50; image[gps_offset + 1] = 0x01;
for (size_t size = 0; size <= debug_offset; ++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);
if (size >= gps_offset + 2) assert(reader.prefs.gps_sync_interval_hours == 336);
assert(reader.prefs.trace_when_repeat_off == 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);
assert(cli.prefs.gps_sync_interval_hours == 336);
}
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()