diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 977d19dd2..0a8eed0f8 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -827,6 +827,7 @@ jobs: python3 -B test/test_sx126x_nrf_rp_constructors.py -v python3 -B test/test_xiao_nrf52_buffered_spi.py -v python3 -B test/test_nrf52_hil_spim1.py -v + python3 -B test/test_companion_hil_startup_frequency.py -v python3 -B test/test_esp32_buffered_radio_targets.py -v python3 -B test/test_xiao_battery_safety.py -v python3 -B test/test_peripheral_mode_prefs.py -v diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index e85855907..a28ceccd3 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -21,6 +21,7 @@ #include #include "NotificationSettingsFile.h" #include "MyMesh.h" +#include #include #include #include @@ -3027,6 +3028,12 @@ void MyMesh::begin(bool has_display, bool radio_available, // load persisted prefs const bool prefs_ready = _store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon); +#if MESH_HIL_COMPANION_STARTUP_FREQ_MHZ + // A role transition can expose old Companion preferences independently of + // the Repeater's saved radio. Keep this private image on its bench channel + // before saved parameters or automatic traffic can consume that frequency. + mesh::companion::applyHilStartupFrequency(_prefs.freq); +#endif FILESYSTEM* battery_calibration_fs = _store->getPrimaryFS(); #if defined(NRF52_PLATFORM) if (_store->isVolatilePrimaryFS()) battery_calibration_fs = nullptr; @@ -4162,6 +4169,19 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, snprintf(reply, reply_size, "> companion"); return true; } + if (strcmp(command, "get bootloader.ver") == 0) { +#if defined(NRF52_PLATFORM) + char version[128]; // Preserve the bootloader's preview/fork/build suffix. + if (board.getBootloaderVersion(version, sizeof(version))) { + snprintf(reply, reply_size, "> %s", version); + } else { + snprintf(reply, reply_size, "> unknown"); + } +#else + mesh::cli::formatError(reply, reply_size, "unsupported"); +#endif + return true; + } if (strcmp(command, "get lat") == 0 || strcmp(command, "get lon") == 0) { snprintf(reply, reply_size, "> %.6f", command[5] == 'a' ? sensors.node_lat : sensors.node_lon); return true; diff --git a/src/helpers/CompanionStartupFrequency.h b/src/helpers/CompanionStartupFrequency.h new file mode 100644 index 000000000..30ea3b05d --- /dev/null +++ b/src/helpers/CompanionStartupFrequency.h @@ -0,0 +1,31 @@ +#pragma once + +// Private role-transition safety only. Ordinary firmware always loads its +// saved frequency, and no startup override is persisted by this helper. +#ifndef MESH_HIL_COMPANION_STARTUP_FREQ_MHZ +#define MESH_HIL_COMPANION_STARTUP_FREQ_MHZ 0 +#endif + +#if MESH_HIL_COMPANION_STARTUP_FREQ_MHZ != 0 && MESH_HIL_COMPANION_STARTUP_FREQ_MHZ != 917 +#error "Companion HIL startup frequency must be disabled or exactly 917 MHz" +#endif + +#if MESH_HIL_COMPANION_STARTUP_FREQ_MHZ +#if !defined(NRF52_PLATFORM) || !defined(NRF52840_XXAA) || !defined(XIAO_NRF52) \ + || !defined(MC_XIAO_NRF52_HIL_FAST_SPI) || MC_XIAO_NRF52_HIL_FAST_SPI != 1 +#error "Companion startup frequency override requires the explicit XIAO nRF52840 HIL candidate" +#endif +#ifndef LORA_FREQ +#error "Companion startup override requires an explicit 917 MHz initial radio frequency" +#endif +// The board initializes its radio before MyMesh loads Companion preferences. +// Both the initial radio setup and the later saved-parameter path must agree. +static_assert(static_cast(LORA_FREQ) == 917.0f, + "Initial radio frequency must also be 917 MHz for this HIL image"); + +namespace mesh { namespace companion { +inline void applyHilStartupFrequency(float& frequency_mhz) { + frequency_mhz = static_cast(MESH_HIL_COMPANION_STARTUP_FREQ_MHZ); +} +} } // namespace mesh::companion +#endif diff --git a/test/fixtures/nrf52_bootloader_version/test_companion.cpp b/test/fixtures/nrf52_bootloader_version/test_companion.cpp new file mode 100644 index 000000000..65f1ef5f7 --- /dev/null +++ b/test/fixtures/nrf52_bootloader_version/test_companion.cpp @@ -0,0 +1,169 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct Board { +#if defined(NRF52_PLATFORM) + bool known = true; + std::string version = "0.11.0-OTAFIX2.4.9-preview+build"; + unsigned reads = 0; + size_t reader_capacity = 0; + bool getBootloaderVersion(char* output, size_t capacity) { + ++reads; + reader_capacity = capacity; + assert(capacity == 128); + if (!known) { + // Unknown never exposes even a faulty reader's partially filled data. + memset(output, 'F', capacity); + return false; + } + snprintf(output, capacity, "%s", version.c_str()); + return true; + } +#endif +}; +struct BaseSerialInterface { unsigned id; }; +struct Serial { + BaseSerialInterface usb{1}, ble{2}; + BaseSerialInterface* current = &usb; + BaseSerialInterface* captured = nullptr; + BaseSerialInterface* written = nullptr; + std::vector frame; + unsigned capture_calls = 0, write_calls = 0; + BaseSerialInterface* captureReplyRoute() { + ++capture_calls; + captured = current; + return captured; + } + size_t writeFrameToRoute(BaseSerialInterface* route, const uint8_t* data, size_t size) { + ++write_calls; + written = route; + frame.assign(data,data+size); + assert(size <= MAX_FRAME_SIZE); + return size; + } +}; +struct Output { + std::string text; + void printf(const char* format, ...) { + char bytes[256]; va_list args; va_start(args,format); + int count = vsnprintf(bytes,sizeof(bytes),format,args); va_end(args); + assert(count >= 0 && size_t(count) < sizeof(bytes)); + text += bytes; + } +}; +struct MyMesh { + Board board; + Serial serial; + Serial* _serial = &serial; + Output output; + char reply_buf[160] = {}; + uint8_t out_frame[MAX_FRAME_SIZE] = {}; + unsigned errors = 0, barrier_begin = 0, barrier_end = 0; + bool queued = false; + bool handleLocalControlCommand(const char*,char*,size_t); + bool handleCommand(const char*,uint32_t,char*); + void beginUsbLoggingReplyBarrier(BaseSerialInterface* route) { + assert(route == serial.captured); + ++barrier_begin; + // A different transport becoming current cannot steal the reply. + serial.current = route == &serial.usb ? &serial.ble : &serial.usb; + } + void endUsbLoggingReplyBarrier(bool accepted) { ++barrier_end; queued = accepted; } + void writeErrFrame(uint8_t) { ++errors; } + Output& terminalOutput() { return output; } + void frame(uint8_t* cmd_frame, size_t len) { + constexpr uint8_t ERR_CODE_ILLEGAL_ARG = 1; + constexpr uint8_t RESP_CODE_CLI_REPLY = 29; +#include "production_companion_frame.inc" + } + void terminal(const char* command) { + char local_reply[160]; + const bool usb_mota_owner_transition = false; +#include "production_companion_terminal.inc" + } +}; +#include "production_companion_getter.inc" + +static std::string wanted(const MyMesh& mesh) { +#if defined(NRF52_PLATFORM) + return mesh.board.known ? "> " + mesh.board.version.substr(0,127) : "> unknown"; +#else + (void)mesh; + return "Error: unsupported"; +#endif +} +static void bounds(MyMesh& mesh) { + const std::string expected = wanted(mesh); + for (size_t capacity=1; capacity <= 176; ++capacity) { + std::vector guarded(capacity+16,0xa5); + char* reply=reinterpret_cast(guarded.data()+8); + assert(mesh.handleLocalControlCommand("get bootloader.ver",reply,capacity)); + assert(std::string(reply) == expected.substr(0,capacity-1)); + for (size_t i=0;i<8;++i) assert(guarded[i] == 0xa5); + for (size_t i=8+capacity;i frame{0x42}; + frame.insert(frame.end(),command.begin(),command.end()); + if (nul) frame.push_back(0); + const size_t len = frame.size(); frame.push_back(0xa5); + mesh.serial.current=requester; + const unsigned captures=mesh.serial.capture_calls,writes=mesh.serial.write_calls; + mesh.frame(frame.data(),len); + assert(mesh.serial.capture_calls == captures+1 && mesh.serial.write_calls == writes+1); + assert(mesh.serial.written == requester && mesh.queued); + assert(mesh.serial.frame[0] == 29); + assert(std::string(mesh.serial.frame.begin()+1,mesh.serial.frame.end()) == "AB|"+wanted(mesh)); + assert(frame.back() == (nul ? 0xa5 : 0)); // Real CMD42 terminates no-NUL input. +} +int main() { + MyMesh mesh; + bounds(mesh); +#if defined(NRF52_PLATFORM) + assert(mesh.board.reader_capacity == 128); + mesh.board.version = "0.11.0-OTAFIX2.4.9-"; + mesh.board.version.append(127-mesh.board.version.size(),'x'); + assert(mesh.board.version.size() == 127); + bounds(mesh); +#endif + dispatch(mesh,&mesh.serial.usb,false); + dispatch(mesh,&mesh.serial.ble,true); + mesh.output.text.clear();mesh.terminal("get bootloader.ver"); + assert(mesh.output.text == " -> " + wanted(mesh) + "\r\n"); +#if defined(NRF52_PLATFORM) + mesh.board.known = false; + bounds(mesh); + dispatch(mesh,&mesh.serial.ble,false); + assert(mesh.serial.frame.size() == 1+3+strlen("> unknown")); +#endif + // Embedded NULs must fail before reading the bootloader or publishing a CLI reply. + uint8_t malformed[] = {0x42,'g','e','t',0,'b',0}; + const unsigned writes=mesh.serial.write_calls,errors=mesh.errors; + mesh.frame(malformed,sizeof(malformed)-1); + assert(mesh.errors == errors+1 && mesh.serial.write_calls == writes); + puts("Companion bootloader getter/dispatch cases passed"); +} diff --git a/test/test_companion_hil_startup_frequency.py b/test/test_companion_hil_startup_frequency.py new file mode 100644 index 000000000..98c3831c7 --- /dev/null +++ b/test/test_companion_hil_startup_frequency.py @@ -0,0 +1,109 @@ +"""Execute the private XIAO loaded-frequency override and its compile gates.""" +from pathlib import Path +import os +import re +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +HEADER = ROOT / "src/helpers/CompanionStartupFrequency.h" +FLAGS = ["NRF52_PLATFORM", "NRF52840_XXAA", "XIAO_NRF52", + "MC_XIAO_NRF52_HIL_FAST_SPI=1", "LORA_FREQ=917", + "MESH_HIL_COMPANION_STARTUP_FREQ_MHZ=917"] + + +def load_site(): + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + begin = source[source.index("void MyMesh::begin("): + source.index("void MyMesh::activateRadio(")] + start = begin.index(" const bool prefs_ready =") + end = begin.index(" FILESYSTEM* battery_calibration_fs", start) + return begin[start:end], begin + + +class CompanionHilStartupFrequency(unittest.TestCase): + def compile(self, source, flags=(), run=False): + with tempfile.TemporaryDirectory(prefix="companion-startup-frequency-") as folder: + cpp = Path(folder) / "test.cpp" + exe = Path(folder) / "test" + cpp.write_text(source, encoding="ascii") + result = subprocess.run([os.environ.get("CXX", "c++"), "-std=c++17", + "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "src"), + *["-D" + flag for flag in flags], str(cpp), "-o", str(exe)], + capture_output=True, text=True, timeout=30) + if run: + self.assertEqual(result.returncode, 0, result.stderr) + executed = subprocess.run([str(exe)], capture_output=True, + text=True, timeout=10) + self.assertEqual(executed.returncode, 0, executed.stderr) + return result + + def test_real_post_load_site_precedes_radio_apply_and_changes_only_frequency(self): + site, begin = load_site() + self.assertLess(begin.index("applyHilStartupFrequency(_prefs.freq)"), + begin.index("configureRadioFromPrefs();")) + fixture = r''' +#include +#include +struct Prefs { float freq=915, bw=62.5; unsigned sf=7; bool ps=true; } _prefs; +struct { double node_lat=1, node_lon=2; } sensors; +struct Store { + bool ready=true; + bool loadPrefs(Prefs& value, double&, double&) { + value.freq=910.525f; value.bw=250; value.sf=8; value.ps=false; + return ready; + } +} store, *_store=&store; +void loadAndApply() { + @SITE@ + assert(prefs_ready==store.ready); +#if MESH_HIL_COMPANION_STARTUP_FREQ_MHZ + assert(_prefs.freq==917.0f); +#else + assert(_prefs.freq==910.525f); +#endif + assert(_prefs.bw==250 && _prefs.sf==8 && !_prefs.ps); + assert(sensors.node_lat==1 && sensors.node_lon==2); +} +int main() { + loadAndApply();store.ready=false;loadAndApply(); +} +'''.replace("@SITE@", site) + self.compile(fixture, FLAGS, run=True) + self.compile(fixture, ["LORA_FREQ=915"], run=True) + + def test_startup_override_rejects_other_boards_values_and_initial_frequencies(self): + source = "#include \nint main() {}\n" + self.assertEqual(self.compile(source, FLAGS).returncode, 0) + for missing in ("NRF52_PLATFORM", "NRF52840_XXAA", "XIAO_NRF52", + "MC_XIAO_NRF52_HIL_FAST_SPI=1", "LORA_FREQ=917"): + with self.subTest(missing=missing): + self.assertNotEqual(self.compile(source, + [flag for flag in FLAGS if flag != missing]).returncode, 0) + for value in (1, 916, 918, -1): + with self.subTest(value=value): + flags = [flag for flag in FLAGS if not flag.startswith( + "MESH_HIL_COMPANION_STARTUP_FREQ_MHZ=")] + flags.append(f"MESH_HIL_COMPANION_STARTUP_FREQ_MHZ={value}") + self.assertNotEqual(self.compile(source, flags).returncode, 0) + flags = ["LORA_FREQ=915" if flag == "LORA_FREQ=917" else flag for flag in FLAGS] + self.assertNotEqual(self.compile(source, flags).returncode, 0) + target = (ROOT / "variants/xiao_nrf52/XiaoRadioSpiConfig.h").read_text() + self.assertIn("#include ", target) + + def test_default_off_site_has_identical_preprocessed_statements(self): + site, _begin = load_site() + original = site[:site.index("#if MESH_HIL_COMPANION_STARTUP_FREQ_MHZ")] + def preprocess(text): + result = subprocess.run([os.environ.get("CXX", "c++"), "-E", "-P", + "-x", "c++", "-I", str(ROOT / "src"), "-"], + input=text, capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stderr) + return re.sub(r"\s+", "", result.stdout) + self.assertEqual(preprocess(original), preprocess( + "#include \n" + site)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_nrf52_bootloader_version.py b/test/test_nrf52_bootloader_version.py index 302d05c01..073a2b7a4 100644 --- a/test/test_nrf52_bootloader_version.py +++ b/test/test_nrf52_bootloader_version.py @@ -88,6 +88,53 @@ class BootloaderVersionTest(unittest.TestCase): self.assertIn("char ver[128]", cli) self.assertIn("_board->getBootloaderVersion(ver, sizeof(ver))", cli) + def test_companion_getter_and_real_cmd42_dispatch(self): + from cpp_source import body + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + local = body(source, "bool MyMesh::handleLocalControlCommand(") + branch = body(local, 'if (strcmp(command, "get bootloader.ver") == 0)') + # Execute the actual pointer/size validation, space normalization and + # query. Other settings and hardware services cannot hide a getter bug. + entry = local[:local.index("#if defined(ESP32_PLATFORM)")] + getter = entry + branch + "\n return false;\n}\n" + shared = body(source, "bool MyMesh::handleCommand(") + self.assertIn("if (handleLocalControlCommand(command, reply, reply_capacity)) return true;", shared) + shared_entry = shared[:shared.index("#if defined(ESP32_PLATFORM)")] + getter += shared_entry + " (void)sender_timestamp;\n return handleLocalControlCommand(command, reply, reply_capacity);\n}\n" + framed = body(source, "if (mesh::companion::isRunCliFrame(") + terminal = body(source, "void MyMesh::handleTerminalCommand(") + terminal_adapter = body(terminal, "if (!usb_mota_owner_transition") + self.assertIn("handleLocalControlCommand(command, local_reply, sizeof(local_reply))", terminal_adapter) + # The read-only query is not hidden behind Full, OTA, logging, terminal, + # or private-key export feature switches. + import re + nesting = re.findall(r"^\s*#\s*(if|ifdef|ifndef|endif)\b", + local[:local.index(branch)], re.MULTILINE) + self.assertEqual(sum(-1 if token == "endif" else 1 for token in nesting), 0) + for forbidden in ("savePrefs", "reboot", "startOTAUpdate", "_prefs", "ota_"): + self.assertNotIn(forbidden, branch) + with tempfile.TemporaryDirectory(prefix="meshcore-companion-boot-version-") as temp: + work = Path(temp) + (work / "production_companion_getter.inc").write_text(getter) + (work / "production_companion_frame.inc").write_text(framed) + (work / "production_companion_terminal.inc").write_text(terminal_adapter) + for platform, full in (("NRF52_PLATFORM", 0), ("NRF52_PLATFORM", 1), + ("ESP32_PLATFORM", 0), ("STM32_PLATFORM", 0), + ("RP2040_PLATFORM", 0)): + with self.subTest(platform=platform, full=full): + exe = work / (platform + str(full)) + command = [os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + "-D" + platform + "=1", "-DCOMPANION_RADIO_FULL=" + str(full), + "-DCOMPANION_FEATURE_TEXT_TERMINAL=" + str(full), "-DCOMPANION_FEATURE_OTA_CLI=" + str(full), + "-I" + str(ROOT / "src"), "-I" + str(work), + str(FIXTURES / "test_companion.cpp"), "-o", str(exe)] + built = subprocess.run(command, capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + result = subprocess.run([str(exe)], capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("getter/dispatch cases passed", result.stdout) + def test_native_version_reader(self): compiler = shutil.which(os.environ.get("CXX", "g++")) self.assertIsNotNone(compiler, "g++ (or CXX) is required for this native test") diff --git a/variants/xiao_nrf52/XiaoRadioSpiConfig.h b/variants/xiao_nrf52/XiaoRadioSpiConfig.h index fb825a150..178b62544 100644 --- a/variants/xiao_nrf52/XiaoRadioSpiConfig.h +++ b/variants/xiao_nrf52/XiaoRadioSpiConfig.h @@ -23,3 +23,7 @@ #endif #include #endif + +// Also validate the initial radio setting before target.cpp can initialize +// the chip, including when this private safety flag is set on another role. +#include