From 7d3ee7200f665decaf1ab90a1a7ba46cc0a289d9 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 5 Oct 2026 15:01:40 -0700 Subject: [PATCH] Keep nRF52 GPS discovery from blocking USB setup --- .../sensors/EnvironmentSensorManager.cpp | 22 ++- .../sensors/EnvironmentSensorManager.h | 4 +- test/test_esp32_gps_discovery.py | 80 +++++++-- test/test_gps_upstream_adaptations.py | 108 ++++++++++++ test/test_official_config_gui.py | 166 ++++++++++++++++-- 5 files changed, 344 insertions(+), 36 deletions(-) diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index aa3edcfd3..227124e7a 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -348,6 +348,10 @@ static bool serialHasValidGpsSentence(Stream& serial, uint32_t timeout_ms) { const uint32_t started = millis(); do { while (serial.available()) { + // Continuous non-NMEA input must not bypass the existing probe window. + // Sample inside the byte loop as well as after an empty FIFO; unsigned + // elapsed time preserves the deadline across millisecond rollover. + if (static_cast(millis() - started) >= timeout_ms) return false; if (probe.ingest(static_cast(serial.read()))) return true; } delay(5); @@ -1113,11 +1117,11 @@ const char* EnvironmentSensorManager::getSettingValue(int i) const { bool EnvironmentSensorManager::setSettingValue(const char* name, const char* value) { #if ENV_INCLUDE_GPS bool gps_setting_available = gps_detected; -#if defined(ESP32_PLATFORM) && !defined(RAK_WISBLOCK_GPS) +#if (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM)) && !defined(RAK_WISBLOCK_GPS) gps_setting_available = gps_setting_available || gps_discovery_pending; #endif if (gps_setting_available && strcmp(name, "gps") == 0) { -#if defined(ESP32_PLATFORM) && !defined(RAK_WISBLOCK_GPS) +#if (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM)) && !defined(RAK_WISBLOCK_GPS) // Preferences load before a cold receiver finishes its bounded probe. // Retain intent so late discovery cannot replace a saved off/on choice. gps_discovery_preference_known = true; @@ -1181,7 +1185,7 @@ void EnvironmentSensorManager::setPowerSavingEnabled(bool enabled) { } #if ENV_INCLUDE_GPS -#if defined(ESP32_PLATFORM) && !defined(RAK_WISBLOCK_GPS) +#if (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM)) && !defined(RAK_WISBLOCK_GPS) void EnvironmentSensorManager::finishBasicGpsDiscovery(bool found) { gps_discovery_pending = false; gps_detected = found; @@ -1238,7 +1242,7 @@ void EnvironmentSensorManager::initBasicGPS() { resetGpsTelemetryTransportState(); gps_serial_transport = false; gps_serial_transport_blocked = false; -#if defined(ESP32_PLATFORM) && !defined(RAK_WISBLOCK_GPS) +#if (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM)) && !defined(RAK_WISBLOCK_GPS) gps_detected = false; gps_active = false; #endif @@ -1259,7 +1263,7 @@ void EnvironmentSensorManager::initBasicGPS() { MESH_DEBUG_PRINTLN("No GPS wake/reset pin found for this board. Continuing on..."); #endif -#if defined(ESP32_PLATFORM) && !defined(RAK_WISBLOCK_GPS) +#if (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM)) && !defined(RAK_WISBLOCK_GPS) // Reserve the configured UART immediately, including while presence is // still unknown. Continue discovery from loop() so an absent optional GPS // cannot consume the website's entire five-second USB bootstrap deadline. @@ -1540,7 +1544,7 @@ void EnvironmentSensorManager::stop_gps() { bool EnvironmentSensorManager::gpsUsesSerialUart(uint8_t uart) const { #if ENV_INCLUDE_GPS -#if defined(ESP32_PLATFORM) && !defined(RAK_WISBLOCK_GPS) +#if (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM)) && !defined(RAK_WISBLOCK_GPS) // The pending probe owns this UART before presence has been established. return uart == 1 && gps_serial_transport; #else @@ -1603,14 +1607,14 @@ bool EnvironmentSensorManager::setGpsSerialTransportBlocked(uint8_t uart, // Cancel remote-query acquisition/hold state before releasing the UART. setGpsTelemetryTransportAvailable(false); gps_serial_transport_blocked = true; -#if defined(ESP32_PLATFORM) && !defined(RAK_WISBLOCK_GPS) +#if (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM)) && !defined(RAK_WISBLOCK_GPS) if (gps_discovery_pending) _location->stop(); #endif if (gps_active) stop_gps(); Serial1.end(); } else { gps_serial_transport_blocked = false; -#if defined(ESP32_PLATFORM) && !defined(RAK_WISBLOCK_GPS) +#if (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM)) && !defined(RAK_WISBLOCK_GPS) if (gps_discovery_pending) { // The bridge's time cannot count as a GPS absence timeout. Reclaim only // after release and give the newly awakened receiver its full window. @@ -1637,7 +1641,7 @@ void EnvironmentSensorManager::loop() { #if ENV_INCLUDE_GPS static unsigned long next_gps_update = 0; unsigned long now = millis(); -#if defined(ESP32_PLATFORM) && !defined(RAK_WISBLOCK_GPS) +#if (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM)) && !defined(RAK_WISBLOCK_GPS) serviceBasicGpsDiscovery(); #endif loopGpsTelemetry(now); diff --git a/src/helpers/sensors/EnvironmentSensorManager.h b/src/helpers/sensors/EnvironmentSensorManager.h index 6599e80d6..9f3fccc49 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.h +++ b/src/helpers/sensors/EnvironmentSensorManager.h @@ -26,7 +26,9 @@ protected: bool gps_serial_transport_blocked = false; #if ENV_INCLUDE_GPS - #if defined(ESP32_PLATFORM) && !defined(RAK_WISBLOCK_GPS) + #if (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM)) \ + && !defined(RAK_WISBLOCK_GPS) \ + && !(defined(RAK_BOARD) && !defined(RAK_WISMESH_TAG)) bool gps_discovery_pending = false; bool gps_discovery_preference_known = false; uint32_t gps_discovery_started_at = 0; diff --git a/test/test_esp32_gps_discovery.py b/test/test_esp32_gps_discovery.py index 7149fbd4f..c605a691f 100644 --- a/test/test_esp32_gps_discovery.py +++ b/test/test_esp32_gps_discovery.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Execute cooperative ESP32 GPS discovery with real preference/UART policy. +"""Execute cooperative ESP32/nRF52 GPS discovery with preference/UART policy. Only the UART, clock and GPS hardware are peripheral doubles. Discovery, completion, loop servicing, settings, power transitions and UART handoff come @@ -7,7 +7,6 @@ from EnvironmentSensorManager.cpp; SensorManager.cpp and LocationProvider's power/timing policy compile unchanged. This does not qualify physical GPS/USB. """ from pathlib import Path -import re import shutil import subprocess import sys @@ -73,8 +72,6 @@ struct EnvironmentSensorManager : SensorManager { bool userEnabled() const { return isGpsTelemetryUserEnabled(); } bool transportAvailable() const { return isGpsTelemetryTransportAvailable(); } void initBasicGPS(); - void finishBasicGpsDiscovery(bool found); - void serviceBasicGpsDiscovery(); void armGpsPowerSavingCycle(); void start_gps(); void stop_gps(); @@ -263,15 +260,14 @@ int main(int argc, char** argv) { def harness(*, negative=None): source = SOURCE.read_text(encoding="utf-8") header = HEADER.read_text(encoding="utf-8") - names = ("gps_detected", "gps_active", "gps_serial_transport", - "gps_serial_transport_blocked", "gps_discovery_pending", - "gps_discovery_preference_known", "gps_discovery_started_at") - fields = "\n".join(re.search(r"^\s*(?:bool|uint32_t)\s+" + name + r"\s*=[^;]*;", - header, re.MULTILINE)[0] for name in names) + # Preserve the actual header's platform/provider guards and declarations. + # Extracting each field without its guard could invent nRF52 state that + # the production header never made available to that platform. + fields = header[header.index(" bool gps_detected"): + header.index(" LocationProvider* _location")] + fields += "\n#endif // ENV_INCLUDE_GPS\n" signatures = ( "void EnvironmentSensorManager::initBasicGPS()", - "void EnvironmentSensorManager::finishBasicGpsDiscovery(bool found)", - "void EnvironmentSensorManager::serviceBasicGpsDiscovery()", "void EnvironmentSensorManager::armGpsPowerSavingCycle()", "void EnvironmentSensorManager::start_gps()", "void EnvironmentSensorManager::stop_gps()", @@ -283,7 +279,15 @@ def harness(*, negative=None): "bool EnvironmentSensorManager::setGpsSerialTransportBlocked(", "void EnvironmentSensorManager::loop()", ) - methods = "\n".join(extract_braced(source, signature) for signature in signatures) + # These two methods live under the production platform/provider guard. + # Compile that guard unchanged as well, instead of calling an extracted + # unconditional definition that would mask a missing nRF52 implementation. + begin = source.rindex("#if ENV_INCLUDE_GPS\n", 0, source.index( + "void EnvironmentSensorManager::finishBasicGpsDiscovery(bool found)")) + end = source.index("void EnvironmentSensorManager::initBasicGPS()", begin) + guarded_discovery = source[begin:end] + "\n#endif // ENV_INCLUDE_GPS\n" + methods = guarded_discovery + "\n" + "\n".join( + extract_braced(source, signature) for signature in signatures) mutations = { "blocking": (" gps_discovery_started_at = millis();", " gps_discovery_started_at = millis();\n delay(5000);"), @@ -301,14 +305,15 @@ def harness(*, negative=None): return HARNESS.replace("@FIELDS@", fields).replace("@METHODS@", methods) -class Esp32GpsDiscoveryTests(unittest.TestCase): +class CooperativeGpsDiscoveryTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.compiler = shutil.which("g++") or shutil.which("clang++") if cls.compiler is None: raise unittest.SkipTest("a host C++17 compiler is required") - def compile_run(self, cases, *, defines=(), negative=None): + def compile_run(self, cases, *, defines=(), negative=None, + platform="ESP32_PLATFORM"): with tempfile.TemporaryDirectory(prefix="meshcore-esp32-gps-") as directory: work = Path(directory) for name, text in { @@ -319,7 +324,7 @@ class Esp32GpsDiscoveryTests(unittest.TestCase): cpp, binary = work / "discovery.cpp", work / "discovery" cpp.write_text(harness(negative=negative), encoding="utf-8") command = [self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", - "-Wno-unused-parameter", "-DENV_INCLUDE_GPS=1", "-DESP32_PLATFORM=1", + "-Wno-unused-parameter", "-DENV_INCLUDE_GPS=1", "-D" + platform, *["-D" + define for define in defines], *(["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []), @@ -328,7 +333,8 @@ class Esp32GpsDiscoveryTests(unittest.TestCase): compiled = subprocess.run(command, capture_output=True, text=True, timeout=60) self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) for case in cases: - with self.subTest(case=case, defines=defines, negative=negative): + with self.subTest(case=case, defines=defines, negative=negative, + platform=platform): result = subprocess.run([str(binary), case], capture_output=True, text=True, timeout=10) if negative: @@ -363,6 +369,48 @@ class Esp32GpsDiscoveryTests(unittest.TestCase): def test_loaded_preference_must_be_retained_negative_control(self): self.compile_run(("latest",), defines=("PERSISTANT_GPS=1",), negative="preference_loss") + def test_nrf52_basic_gps_uses_production_guarded_state_and_methods(self): + for defines in (("GPS_BAUD_RATE=38400",), ("PERSISTANT_GPS=1",), + ("RAK_BOARD=1", "RAK_WISMESH_TAG=1")): + self.compile_run(("absent", "late", "latest", "awake", "wrap", "blocked", "no_enable"), + defines=defines, platform="NRF52_PLATFORM") + for defines in (("ENV_SKIP_GPS_DETECT=1",), + ("ENV_SKIP_GPS_DETECT=1", "PERSISTANT_GPS=1")): + self.compile_run(("awake",), defines=defines, platform="NRF52_PLATFORM") + + def test_nrf52_guards_preserve_distinct_wisblock_provider(self): + # Unlike WisMesh Tag, ordinary WisBlock must not acquire basic GPS + # discovery members. Preprocess the real header; the complete RAK + # provider has its own behavior and tests. + header = HEADER.read_text(encoding="ascii") + with tempfile.TemporaryDirectory(prefix="meshcore-nrf52-gps-header-") as directory: + work = Path(directory) + for relative in ("Mesh.h", "helpers/SensorManager.h", + "helpers/sensors/LocationProvider.h"): + path = work / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n", encoding="ascii") + cpp = work / "header.cpp" + cpp.write_text(header, encoding="ascii") + for defines, expected in ((("NRF52_PLATFORM",), True), + (("NRF52_PLATFORM", "RAK_BOARD"), False), + (("NRF52_PLATFORM", "RAK_BOARD", "RAK_WISMESH_TAG"), True), + (("RP2040_PLATFORM",), False)): + with self.subTest(defines=defines): + result = subprocess.run([self.compiler, "-E", "-P", "-x", "c++", + "-DENV_INCLUDE_GPS=1", *["-D" + value for value in defines], + "-I" + str(work), str(cpp)], text=True, capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual("gps_discovery_pending" in result.stdout, expected) + + def test_nrf52_runtime_negative_controls(self): + for cases, negative, defines in ((("absent",), "blocking", ()), + (("blocked",), "blocked_poll", ()), + (("blocked",), "cancellation", ()), + (("latest",), "preference_loss", ("PERSISTANT_GPS=1",))): + self.compile_run(cases, negative=negative, defines=defines, + platform="NRF52_PLATFORM") + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/test/test_gps_upstream_adaptations.py b/test/test_gps_upstream_adaptations.py index 6369a6189..48875a2de 100644 --- a/test/test_gps_upstream_adaptations.py +++ b/test/test_gps_upstream_adaptations.py @@ -295,6 +295,73 @@ int main(){ } ''' +RAK_UART_PROBE = r''' +#include +#include +#include +#include +#include +@TIMEOUT@ +@PROBE@ +struct Uart : Stream { + bool continuous_noise=false; + uint32_t noise_before=0, reads=0; + std::string bytes; + int available() override { + return continuous_noise || noise_before || !bytes.empty(); + } + int read() override { + ++reads; ++now_ms; // A real peripheral clock advances while bytes arrive. + if (continuous_noise) return '!'; + if (noise_before) { --noise_before; return '!'; } + if (bytes.empty()) return -1; + const int value=bytes.front(); bytes.erase(0,1); return value; + } + void validSentence() { + const std::string body="GPRMC,123"; + uint8_t checksum=0; + for(uint8_t byte:body) checksum^=byte; + char hex[3]; std::snprintf(hex,sizeof(hex),"%02X",checksum); + bytes="$"+body+"*"+hex; + } +}; +int main(int argc,char** argv) { + assert(argc==2); + Uart uart; + bool expected=false; + if(!strcmp(argv[1],"noise")) uart.continuous_noise=true; + else if(!strcmp(argv[1],"wrap_noise")) { + now_ms=UINT32_MAX-400U; uart.continuous_noise=true; + } else if(!strcmp(argv[1],"valid")) { + uart.validSentence(); expected=true; + } else if(!strcmp(argv[1],"noisy_valid")) { + uart.noise_before=600; uart.validSentence(); expected=true; + } else if(!strcmp(argv[1],"wrap_valid")) { + now_ms=UINT32_MAX-400U; uart.noise_before=600; + uart.validSentence(); expected=true; + } else if(!strcmp(argv[1],"invalid_checksum")) { + uart.validSentence(); uart.bytes.back()='Z'; + } else if(!strcmp(argv[1],"too_late")) { + uart.noise_before=RAK_UART_GPS_PROBE_TIMEOUT_MS-2; uart.validSentence(); + } else if(!strcmp(argv[1],"truncated")) { + uart.bytes="$GPRMC,123*"; + } else assert(!strcmp(argv[1],"empty")); + const uint32_t started=millis(); + assert(serialHasValidGpsSentence(uart,RAK_UART_GPS_PROBE_TIMEOUT_MS)==expected); + const uint32_t elapsed=millis()-started; + if(expected) { + assert(elapsed=RAK_UART_GPS_PROBE_TIMEOUT_MS); + assert(elapsed +#include +#include +#include +#include +static std::deque nrf_input; +int nrfInputAvailable() { return nrf_input.size(); } +int nrfInputRead() { + if (nrf_input.empty()) return -1; + const int value = nrf_input.front(); nrf_input.pop_front(); return value; +} +MockSerial Serial; +''') + pump = body((ROOT / "examples/simple_repeater/main.cpp").read_text(), + "static void __attribute__((noinline)) serviceCommandInterfaces()") + return source + r''' +struct MyMesh { + void handleUsbCommand(const char*, char* reply) { strcpy(reply, "Unknown"); } + void cancelPendingSerialOutput() {} + bool hasPendingSerialOutput() { return false; } +} the_mesh; +struct Board { void loop() {} } board; +static constexpr size_t LOCAL_SERIAL_COMMAND_MAX = 159; +static char command[LOCAL_SERIAL_COMMAND_MAX + 2] = {}; +static bool command_overflow = false; +''' + pump + r''' +static void advance() { + ++g_mock_millis; + serviceCommandInterfaces(); + mesh::serviceUsbTerminalPort(); + if (!fifo.empty()) { + host += fifo; fifo.clear(); tud_cdc_tx_complete_cb(0); + } +} +static void emit() { + for (unsigned tick = 0; tick < 100; ++tick) advance(); + if (!host.empty()) { + std::cout << "DATA "; + for (uint8_t value : host) printf("%02x", value); + std::cout << '\n'; host.clear(); + } + std::cout << "DONE\n" << std::flush; +} +int main() { + static_assert(MESH_NRF52_USB_CONSOLE_COOPERATIVE, + "Use nRF52's actual TinyUSB console gate"); + mesh::setUsbLoggingEnabled(false); + mesh::prepareUsbLoggingPort(); + mesh::beginUsbLoggingPort(); + std::string line; + while (std::getline(std::cin, line)) { + if (line.rfind("WRITE ", 0) == 0) { + for (size_t offset = 6; offset < line.size(); offset += 2) { + unsigned byte; assert(offset + 1 < line.size()); + assert(sscanf(line.c_str() + offset, "%2x", &byte) == 1); + nrf_input.push_back(uint8_t(byte)); + } + } else if (line == "CLOSE") { + // The unchanged GUI never requests DTR. The production gate must admit + // real RX as reader proof and revoke it at this low-DTR host close. + tud_cdc_line_state_cb(0, false, false); + mounted = false; tud_umount_cb(); + } else { assert(false && "Unknown peripheral operation"); } + emit(); + assert(nrf_input.empty()); + } +} +''' + + +def nrf52_headers(work): + # Add Print formatting and USB input to the existing peripheral mocks. + # These are SDK boundaries, not copies of firmware transport behavior. + fixture = ROOT / "test/fixtures/nrf52_usb_console/mocks" + stream = (ROOT / "test/mocks/Stream.h").read_text().replace( + "#include ", "#include \n#include \n#include \n#include ") + stream = stream.replace(" virtual void flush()", r''' + size_t println(const char* text) { return write(text) + write("\r\n"); } + size_t printf(const char* format, ...) { + char buffer[256]; va_list args; va_start(args, format); + int length = vsnprintf(buffer, sizeof(buffer), format, args); va_end(args); + assert(length >= 0 && size_t(length) < sizeof(buffer)); + return write(reinterpret_cast(buffer), length); + } + virtual void flush()''') + (work / "Stream.h").write_text(stream, encoding="ascii") + arduino = (ROOT / "test/mocks/Arduino.h").read_text() + mock_serial = (fixture / "Arduino.h").read_text().split("#include ", 1)[1] + mock_serial = mock_serial.replace("return 0;", "return nrfInputAvailable();", 1) + mock_serial = mock_serial.replace("int read() override { return -1; }", + "int read() override { return nrfInputRead(); }") + arduino += "\n#include \nint nrfInputAvailable();\nint nrfInputRead();\n" + mock_serial + (work / "Arduino.h").write_text(arduino, encoding="ascii") + return fixture + + +def cli_source(*, lf_reply=False, reject_cr=False, platform="ESP32_PLATFORM"): + source = (nrf52_cli_source() if platform == "NRF52_PLATFORM" + else console.firmware_harness(lose_crlf=lf_reply)) + if platform == "NRF52_PLATFORM" and lf_reply: + source = source.replace('console.printf(" -> %s\\r\\n", reply);', + 'console.printf(" -> %s\\n", reply);') common = (ROOT / "src/helpers/CommonCLI.cpp").read_text() time_handler = body(common, 'if (memcmp(command, "time ", 5) == 0)') support = r''' @@ -105,7 +218,7 @@ class OfficialConfigGuiTests(unittest.TestCase): cls.sources = official_sources() def run_contract(self, *, blocking=False, lf_reply=False, reject_cr=False, - extra_boot_ms=0): + extra_boot_ms=0, platform="ESP32_PLATFORM"): with tempfile.TemporaryDirectory(prefix="official-config-gui-") as directory: work = Path(directory) gui = self.sources["src/gui.js"].read_text() @@ -123,7 +236,7 @@ class OfficialConfigGuiTests(unittest.TestCase): boot_cpp, boot = work / "gps.cpp", work / "gps" boot_cpp.write_text(gps_boot_source(blocking=blocking), encoding="ascii") command = [self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Wno-unused-parameter", - "-Wno-unused-function", "-DENV_INCLUDE_GPS=1", "-DESP32_PLATFORM=1", + "-Wno-unused-function", "-DENV_INCLUDE_GPS=1", "-D" + platform, *sanitize, "-I" + str(work), "-I" + str(ROOT / "src"), str(boot_cpp), str(ROOT / "src/helpers/SensorManager.cpp"), "-o", str(boot)] built = subprocess.run(command, text=True, capture_output=True, timeout=60) @@ -133,13 +246,23 @@ class OfficialConfigGuiTests(unittest.TestCase): self.assertGreaterEqual(extra_boot_ms, 0) boot_ms = int(measured.stdout.strip()) + extra_boot_ms cli_cpp, cli = work / "cli.cpp", work / "cli" - cli_cpp.write_text(cli_source(lf_reply=lf_reply, reject_cr=reject_cr), encoding="ascii") + cli_cpp.write_text(cli_source(lf_reply=lf_reply, reject_cr=reject_cr, + platform=platform), encoding="ascii") + if platform == "NRF52_PLATFORM": + peripheral = nrf52_headers(work) + cli_flags = ["-DARDUINO", "-DNRF52_PLATFORM", "-DUSE_TINYUSB", + "-DMESH_DEBUG=1", "-I", str(work), "-I", str(peripheral)] + cli_sources = [str(ROOT / "src/helpers" / relative) for relative in ( + "UsbLogging.cpp", "UsbLoggingClientActivity.cpp", "UsbLoggingLineStateOverride.cpp")] + else: + cli_flags = ["-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DESP32_PLATFORM=1", "-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=1", + "-DMESH_USB_CONSOLE_COOPERATIVE=1", "-DMESH_USB_LOGGING_AVAILABLE=1", + "-DCONFIG_TINYUSB_ENABLED=0"] + cli_sources = [] command = [self.compiler, "-std=c++17", "-Wall", "-Wextra", - "-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DESP32_PLATFORM=1", "-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=1", - "-DMESH_USB_CONSOLE_COOPERATIVE=1", "-DMESH_USB_LOGGING_AVAILABLE=1", - "-DCONFIG_TINYUSB_ENABLED=0", *sanitize, "-I", str(ROOT / "src"), - str(cli_cpp), "-o", str(cli)] + *cli_flags, *sanitize, "-I", str(ROOT / "src"), + str(cli_cpp), *cli_sources, "-o", str(cli)] built = subprocess.run(command, text=True, capture_output=True, timeout=60) self.assertEqual(built.returncode, 0, built.stdout + built.stderr) result = subprocess.run([self.node, str(FIXTURE / "contract.mjs"), @@ -189,6 +312,29 @@ class OfficialConfigGuiTests(unittest.TestCase): self.assertNotEqual(result.returncode, 0, "Negative control did not fail") self.assertIn("initial time missed the stock 5000 ms deadline", result.stderr) + def test_nrf52_basic_gps_preserves_stock_gui_initial_time_deadline(self): + # Use nRF52's compiled GPS guards, actual USB transport/callbacks and + # production repeater CR command pump/time reply with DTR low. Add + # setup's one-second settle; filesystem/radio timing is not invented. + result = self.run_contract(platform="NRF52_PLATFORM", extra_boot_ms=1000) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_nrf52_old_blocking_gps_breaks_stock_gui_initial_time(self): + # Reinsert the former five-second GPS wait without changing the + # pinned client's actual timer, retry policy, or first command. + result = self.run_contract(platform="NRF52_PLATFORM", blocking=True, + extra_boot_ms=1000) + self.assertNotEqual(result.returncode, 0, "Negative control did not fail") + self.assertIn("initial time missed the stock 5000 ms deadline", result.stderr) + + def test_nrf52_cr_framing_and_crlf_reply_are_required_by_stock_gui(self): + for options in ({"reject_cr": True}, {"lf_reply": True}): + with self.subTest(options=options): + result = self.run_contract(platform="NRF52_PLATFORM", extra_boot_ms=1000, + **options) + self.assertNotEqual(result.returncode, 0, "Negative control did not fail") + self.assertIn("initial time missed the stock 5000 ms deadline", result.stderr) + if __name__ == "__main__": unittest.main(verbosity=2)