diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 22eea1db8..896845991 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -262,6 +262,7 @@ jobs: run: | python3 -B test/test_gps_status.py python3 -B test/test_gps_upstream_adaptations.py -v + python3 -B test/test_esp32_gps_discovery.py -v python3 -B test/test_gps_sync_interval.py -v python3 -B test/test_gps_sync_preferences.py -v python3 -B test/test_t1000e_sensor_power.py -v diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index d383b95ac..aa3edcfd3 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -1112,7 +1112,16 @@ const char* EnvironmentSensorManager::getSettingValue(int i) const { bool EnvironmentSensorManager::setSettingValue(const char* name, const char* value) { #if ENV_INCLUDE_GPS - if (gps_detected && strcmp(name, "gps") == 0) { + bool gps_setting_available = gps_detected; +#if defined(ESP32_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) + // 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; +#endif bool enabled = strcmp(value, "0") != 0; #if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE) // A UART L76K cannot remove power from the shared 3V3_S rail. "Off" stops @@ -1172,6 +1181,51 @@ void EnvironmentSensorManager::setPowerSavingEnabled(bool enabled) { } #if ENV_INCLUDE_GPS +#if defined(ESP32_PLATFORM) && !defined(RAK_WISBLOCK_GPS) +void EnvironmentSensorManager::finishBasicGpsDiscovery(bool found) { + gps_discovery_pending = false; + gps_detected = found; + gps_serial_transport = found; + if (found) { + MESH_DEBUG_PRINTLN("GPS detected"); +#ifdef PERSISTANT_GPS + // Keep the established always-on default only when no loaded preference + // has superseded it. The receiver is already awake from the probe. + if (!gps_discovery_preference_known || isGpsTelemetryUserEnabled()) { + gps_active = true; + if (!gps_discovery_preference_known) setGpsTelemetryUserEnabled(true); + _location->setGPSPowerSaving(powersaving_enabled); + armGpsPowerSavingCycle(); + return; + } +#endif + } else { + MESH_DEBUG_PRINTLN("No GPS detected"); + } + _location->stop(); + gps_active = false; + if (found && isGpsTelemetryUserEnabled()) { + _location->setGPSPowerSaving(powersaving_enabled); + start_gps(); + } +} + +void EnvironmentSensorManager::serviceBasicGpsDiscovery() { + // A bridge may own Serial1 until it explicitly releases the receiver. Keep + // its pending probe reserved, but never poll or power it while blocked. + if (!gps_discovery_pending || gps_serial_transport_blocked) return; +#ifdef ENV_SKIP_GPS_DETECT + const bool found = true; +#else + const bool found = Serial1.available() > 0; +#endif + // Unsigned elapsed time keeps the same five-second cold-start window even + // when the millisecond clock wraps. No startup or command loop waits here. + if (!found && static_cast(millis() - gps_discovery_started_at) < 5000UL) return; + finishBasicGpsDiscovery(found); +} +#endif + void EnvironmentSensorManager::initBasicGPS() { // A repeated discovery pass must not steal a UART which an active bridge @@ -1184,6 +1238,10 @@ void EnvironmentSensorManager::initBasicGPS() { resetGpsTelemetryTransportState(); gps_serial_transport = false; gps_serial_transport_blocked = false; +#if defined(ESP32_PLATFORM) && !defined(RAK_WISBLOCK_GPS) + gps_detected = false; + gps_active = false; +#endif Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX); @@ -1201,6 +1259,15 @@ 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) + // 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. + gps_serial_transport = true; + gps_discovery_pending = true; + gps_discovery_started_at = millis(); + serviceBasicGpsDiscovery(); +#else // Cold-start receivers can take several seconds to emit their first NMEA // data. Check immediately, then wait at most five seconds; an already awake // receiver and the explicit bypass need no extra startup delay. @@ -1227,6 +1294,7 @@ void EnvironmentSensorManager::initBasicGPS() { } _location->stop(); gps_active = false; //Set GPS visibility off until setting is changed +#endif } // gps code for rak might be moved to MicroNMEALoactionProvider @@ -1472,7 +1540,12 @@ void EnvironmentSensorManager::stop_gps() { bool EnvironmentSensorManager::gpsUsesSerialUart(uint8_t uart) const { #if ENV_INCLUDE_GPS +#if defined(ESP32_PLATFORM) && !defined(RAK_WISBLOCK_GPS) + // The pending probe owns this UART before presence has been established. + return uart == 1 && gps_serial_transport; +#else return uart == 1 && gps_detected && gps_serial_transport; +#endif #else (void)uart; return false; @@ -1530,10 +1603,21 @@ 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 (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 (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. + initBasicGPS(); + return true; + } +#endif // Restoring availability restarts the receiver only when the user's GPS // preference is on. Otherwise a future authorized location query may // acquire it on demand. @@ -1553,6 +1637,9 @@ 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) + serviceBasicGpsDiscovery(); +#endif loopGpsTelemetry(now); if (powersaving_enabled && gps_detected && _location->getGPSPowerSaving()) { diff --git a/src/helpers/sensors/EnvironmentSensorManager.h b/src/helpers/sensors/EnvironmentSensorManager.h index ef923786d..6599e80d6 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.h +++ b/src/helpers/sensors/EnvironmentSensorManager.h @@ -26,6 +26,13 @@ protected: bool gps_serial_transport_blocked = false; #if ENV_INCLUDE_GPS + #if defined(ESP32_PLATFORM) && !defined(RAK_WISBLOCK_GPS) + bool gps_discovery_pending = false; + bool gps_discovery_preference_known = false; + uint32_t gps_discovery_started_at = 0; + void finishBasicGpsDiscovery(bool found); + void serviceBasicGpsDiscovery(); + #endif LocationProvider* _location; LocationProvider* _configured_location; void armGpsPowerSavingCycle(); diff --git a/test/test_esp32_gps_discovery.py b/test/test_esp32_gps_discovery.py new file mode 100644 index 000000000..7149fbd4f --- /dev/null +++ b/test/test_esp32_gps_discovery.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +"""Execute cooperative ESP32 GPS discovery with real preference/UART policy. + +Only the UART, clock and GPS hardware are peripheral doubles. Discovery, +completion, loop servicing, settings, power transitions and UART handoff come +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 +import tempfile +import unittest + +from test_gps_upstream_adaptations import ARDUINO, CAYENNE, MESH +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp" +HEADER = ROOT / "src/helpers/sensors/EnvironmentSensorManager.h" + +HARNESS = r''' +#include +#include +#include +#include +#define PIN_GPS_TX 39 +#define PIN_GPS_RX 38 +#define PIN_GPS_EN 34 +#define MESH_DEBUG_PRINTLN(...) ((void)0) +#define POWERSAVING_DEBUG_PRINTLN(...) ((void)0) +void LocationProvider::sendSentence(const char*) {} + +struct SerialMock { + unsigned begins=0, ends=0, polls=0, baud=0; + int first_pin=-1, second_pin=-1, bytes=0; + bool installed=false; + void setPins(int first, int second) { first_pin=first; second_pin=second; } + void begin(unsigned speed) { installed=true; baud=speed; ++begins; } + void end() { installed=false; ++ends; } + int available() { ++polls; return bytes; } +} Serial1; + +struct TestLocation : LocationProvider { + unsigned begins=0, resets=0, stops=0, loops=0; + int enable_pin=PIN_GPS_EN; + bool powered=false; + void begin() override { powered=true; ++begins; } + void reset() override { ++resets; } + void stop() override { powered=false; ++stops; } + void loop() override { ++loops; } + int getPinEn() override { return enable_pin; } + bool isEnabled() override { return powered; } + bool isValid() override { return false; } + long getLatitude() override { return 0; } + long getLongitude() override { return 0; } + long getAltitude() override { return 0; } + long satellitesCount() override { return 0; } + long getTimestamp() override { return 0; } +}; + +struct EnvironmentSensorManager : SensorManager { + @FIELDS@ + TestLocation* _location; + explicit EnvironmentSensorManager(TestLocation& location) : _location(&location) {} + bool telemetryGpsDetected() const override { return gps_detected; } + bool telemetryGpsActive() const override { return gps_active; } + void telemetryGpsStart() override { start_gps(); } + void telemetryGpsStop() override { stop_gps(); } + LocationProvider* getLocationProvider() override { return _location; } + 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(); + void loop() override; + bool setSettingValue(const char* name, const char* value) override; + void setPowerSavingEnabled(bool enabled) override; + bool gpsUsesSerialUart(uint8_t uart) const override; + bool gpsSerialTransportMayConflict(uint8_t uart) const override; + bool gpsSerialTransportCanYield(uint8_t uart) const override; + bool setGpsSerialTransportBlocked(uint8_t uart, bool blocked) override; +}; +@METHODS@ + +static void fresh(uint32_t now=100) { + now_ms=now; delay_count=0; Serial1=SerialMock{}; +} +static void requireImmediateInit(EnvironmentSensorManager& sensors) { + const uint32_t before=millis(); + sensors.initBasicGPS(); + assert(millis()==before && delay_count==0); + assert(Serial1.installed && Serial1.first_pin==PIN_GPS_TX && Serial1.second_pin==PIN_GPS_RX); +#ifdef GPS_BAUD_RATE + assert(Serial1.baud==GPS_BAUD_RATE); +#else + assert(Serial1.baud==9600); +#endif +} + +static void absent() { + fresh(); TestLocation location; EnvironmentSensorManager sensors(location); + requireImmediateInit(sensors); + assert(sensors.gps_discovery_pending && !sensors.gps_detected && !sensors.gps_active); + assert(location.powered && location.begins==1 && location.resets==1 && location.stops==0); + assert(sensors.gpsUsesSerialUart(1) && sensors.gpsSerialTransportMayConflict(1)); + assert(!sensors.gpsUsesSerialUart(0) && !sensors.gpsUsesSerialUart(2)); + assert(!sensors.setGpsSerialTransportBlocked(2, true)); + now_ms=5099; sensors.loop(); + assert(sensors.gps_discovery_pending && location.powered && delay_count==0); + now_ms=5100; sensors.loop(); + assert(!sensors.gps_discovery_pending && !sensors.gps_detected && !sensors.gps_active); + assert(!location.powered && location.stops==1 && !sensors.gpsUsesSerialUart(1)); + const unsigned polls=Serial1.polls; + Serial1.bytes=8; now_ms=6100; sensors.loop(); + assert(Serial1.polls==polls && !sensors.gps_detected && location.stops==1); + assert(delay_count==0); +} + +static void latePreferences() { + fresh(); TestLocation location; EnvironmentSensorManager sensors(location); + requireImmediateInit(sensors); + // Common preferences load after begin() while the optional receiver is cold. + assert(sensors.setSettingValue("gps", "1")); + sensors.setPowerSavingEnabled(true); + assert(sensors.gps_discovery_preference_known && sensors.userEnabled()); + assert(!sensors.gps_active && location.begins==1); + now_ms=1100; Serial1.bytes=8; sensors.loop(); + assert(!sensors.gps_discovery_pending && sensors.gps_detected && sensors.gps_active); + assert(sensors.userEnabled() && location.powered && location.getGPSPowerSaving()); + assert(location.waitingTimeSync() && location.getNextGPSOff()>millis()); + assert(location.loops==1 && delay_count==0); +#ifdef PERSISTANT_GPS + assert(location.stops==0 && location.begins==1 && location.resets==1); +#else + assert(location.stops==1 && location.begins==2 && location.resets==2); +#endif + sensors.setPowerSavingEnabled(false); + assert(sensors.gps_active && !location.getGPSPowerSaving()); + assert(location.getNextGPSOff()==0 && location.getNextGPSOn()==0); + assert(sensors.setSettingValue("gps", "0")); + assert(!sensors.userEnabled() && !sensors.gps_active && !location.powered); + assert(sensors.setSettingValue("gps", "1")); + assert(sensors.userEnabled() && sensors.gps_active && location.powered); +} + +static void latestPreferenceWins() { + fresh(); TestLocation location; EnvironmentSensorManager sensors(location); + requireImmediateInit(sensors); + assert(sensors.setSettingValue("gps", "1")); + sensors.setPowerSavingEnabled(true); + assert(sensors.setSettingValue("gps", "0")); + now_ms=900; Serial1.bytes=8; sensors.loop(); + assert(sensors.gps_detected && !sensors.gps_discovery_pending); + assert(sensors.gps_discovery_preference_known && !sensors.userEnabled()); + assert(!sensors.gps_active && !location.powered && !location.getGPSPowerSaving()); + // Reprobe preserves an explicit user choice; the current node power policy + // also wins over the policy in force when discovery originally started. + Serial1.bytes=0; sensors.initBasicGPS(); + assert(sensors.gps_discovery_pending); + assert(sensors.setSettingValue("gps", "1")); + sensors.setPowerSavingEnabled(false); + now_ms=1200; Serial1.bytes=8; sensors.loop(); + assert(sensors.gps_active && sensors.userEnabled() && location.powered); + assert(!location.getGPSPowerSaving() && location.getNextGPSOff()==0); +} + +static void awakeOrBypass() { + fresh(); TestLocation location; EnvironmentSensorManager sensors(location); +#ifndef ENV_SKIP_GPS_DETECT + Serial1.bytes=8; +#endif + requireImmediateInit(sensors); + assert(sensors.gps_detected && !sensors.gps_discovery_pending && sensors.gpsUsesSerialUart(1)); + assert(location.begins==1 && location.resets==1); +#ifdef ENV_SKIP_GPS_DETECT + assert(Serial1.polls==0); +#else + assert(Serial1.polls==1); +#endif +#ifdef PERSISTANT_GPS + assert(sensors.gps_active && sensors.userEnabled() && location.powered && location.stops==0); +#else + assert(!sensors.gps_active && !sensors.userEnabled() && !location.powered && location.stops==1); +#endif + assert(sensors.setSettingValue("gps", "0")); + assert(!sensors.gps_active && !sensors.userEnabled() && !location.powered); +} + +static void wrap() { + fresh(UINT32_MAX-1000U); TestLocation location; EnvironmentSensorManager sensors(location); + const uint32_t start=millis(); requireImmediateInit(sensors); + now_ms=start+4999U; sensors.loop(); + assert(sensors.gps_discovery_pending && location.powered); + now_ms=start+5000U; sensors.loop(); + assert(!sensors.gps_discovery_pending && !sensors.gps_detected && !location.powered); + assert(delay_count==0); +} + +static void blockedRelease() { + fresh(); TestLocation location; EnvironmentSensorManager sensors(location); + requireImmediateInit(sensors); + assert(sensors.setSettingValue("gps", "1")); + sensors.setPowerSavingEnabled(true); + assert(sensors.gpsSerialTransportCanYield(1)); + assert(sensors.setGpsSerialTransportBlocked(1, true)); + assert(sensors.gps_discovery_pending && sensors.gps_serial_transport_blocked); + assert(sensors.gpsUsesSerialUart(1) && !sensors.transportAvailable()); + assert(!location.powered && location.stops==1 && Serial1.ends==1 && !Serial1.installed); + const unsigned polls=Serial1.polls, begins=Serial1.begins, starts=location.begins; + // These are bytes belonging to the bridge, not evidence of a late GPS. + now_ms=20100; Serial1.bytes=8; sensors.loop(); + assert(Serial1.polls==polls && sensors.gps_discovery_pending && !sensors.gps_detected); + sensors.initBasicGPS(); sensors.start_gps(); + assert(Serial1.begins==begins && location.begins==starts && !location.powered); + assert(sensors.setSettingValue("gps", "0")); + assert(sensors.setSettingValue("gps", "1")); + assert(sensors.userEnabled() && !sensors.gps_active && !location.powered); + Serial1.bytes=0; + assert(sensors.setGpsSerialTransportBlocked(1, false)); + assert(sensors.gps_discovery_pending && !sensors.gps_serial_transport_blocked); + assert(sensors.gps_discovery_started_at==20100 && sensors.transportAvailable()); + assert(Serial1.begins==begins+1 && location.begins==starts+1 && location.powered); + now_ms=25099; sensors.loop(); + assert(sensors.gps_discovery_pending); // Bridge time cannot consume this window. + now_ms=25100; Serial1.bytes=8; sensors.loop(); + assert(sensors.gps_detected && !sensors.gps_discovery_pending && sensors.gps_active); + assert(sensors.userEnabled() && location.powered && location.getGPSPowerSaving()); + assert(delay_count==0); +} + +static void noEnablePin() { + fresh(); TestLocation location; location.enable_pin=-1; + EnvironmentSensorManager sensors(location); requireImmediateInit(sensors); + assert(sensors.gpsUsesSerialUart(1) && !sensors.gpsSerialTransportCanYield(1)); + assert(!sensors.setGpsSerialTransportBlocked(1, true)); + assert(sensors.gps_discovery_pending && !sensors.gps_serial_transport_blocked); + assert(location.powered && Serial1.installed && location.stops==0 && Serial1.ends==0); + now_ms=5100; sensors.loop(); + assert(!sensors.gps_discovery_pending && !sensors.gps_detected && !location.powered); +} + +int main(int argc, char** argv) { + assert(argc==2); + if (!strcmp(argv[1], "absent")) absent(); + else if (!strcmp(argv[1], "late")) latePreferences(); + else if (!strcmp(argv[1], "latest")) latestPreferenceWins(); + else if (!strcmp(argv[1], "awake")) awakeOrBypass(); + else if (!strcmp(argv[1], "wrap")) wrap(); + else if (!strcmp(argv[1], "blocked")) blockedRelease(); + else if (!strcmp(argv[1], "no_enable")) noEnablePin(); + else assert(false && "unknown scenario"); + std::printf("PASS %s\n", argv[1]); +} +''' + + +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) + signatures = ( + "void EnvironmentSensorManager::initBasicGPS()", + "void EnvironmentSensorManager::finishBasicGpsDiscovery(bool found)", + "void EnvironmentSensorManager::serviceBasicGpsDiscovery()", + "void EnvironmentSensorManager::armGpsPowerSavingCycle()", + "void EnvironmentSensorManager::start_gps()", + "void EnvironmentSensorManager::stop_gps()", + "bool EnvironmentSensorManager::setSettingValue(", + "void EnvironmentSensorManager::setPowerSavingEnabled(", + "bool EnvironmentSensorManager::gpsUsesSerialUart(", + "bool EnvironmentSensorManager::gpsSerialTransportMayConflict(", + "bool EnvironmentSensorManager::gpsSerialTransportCanYield(", + "bool EnvironmentSensorManager::setGpsSerialTransportBlocked(", + "void EnvironmentSensorManager::loop()", + ) + methods = "\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);"), + "blocked_poll": ("if (!gps_discovery_pending || gps_serial_transport_blocked) return;", + "if (!gps_discovery_pending) return;"), + "cancellation": (" if (gps_discovery_pending) _location->stop();", + " // Negative control: leave the pending receiver powered."), + "preference_loss": (" gps_discovery_preference_known = true;", + " // Negative control: forget that an explicit preference was loaded."), + } + if negative: + before, after = mutations[negative] + assert methods.count(before) == 1, "negative-control anchor changed" + methods = methods.replace(before, after, 1) + return HARNESS.replace("@FIELDS@", fields).replace("@METHODS@", methods) + + +class Esp32GpsDiscoveryTests(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): + with tempfile.TemporaryDirectory(prefix="meshcore-esp32-gps-") as directory: + work = Path(directory) + for name, text in { + "Arduino.h": ARDUINO, "Mesh.h": MESH, "CayenneLPP.h": CAYENNE, + "Wire.h": "#pragma once\nclass TwoWire {};\n", + }.items(): + (work / name).write_text(text, encoding="utf-8") + 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", + *["-D" + define for define in defines], + *(["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []), + "-I" + str(work), "-I" + str(ROOT / "src"), str(cpp), + str(ROOT / "src/helpers/SensorManager.cpp"), "-o", str(binary)] + 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): + result = subprocess.run([str(binary), case], capture_output=True, + text=True, timeout=10) + if negative: + self.assertNotEqual(result.returncode, 0, "negative control escaped assertions") + self.assertIn("Assertion", result.stderr) + else: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("PASS " + case, result.stdout) + + def test_boot_is_cooperative_and_loop_finishes_cold_gps(self): + self.compile_run(("absent", "late", "latest", "awake", "wrap", "blocked", "no_enable"), + defines=("GPS_BAUD_RATE=38400",)) + + def test_persistent_default_cannot_replace_loaded_user_preference(self): + self.compile_run(("absent", "late", "latest", "awake", "wrap", "blocked", "no_enable"), + defines=("PERSISTANT_GPS=1",)) + + def test_explicit_detection_bypass_is_immediate(self): + for defines in (("ENV_SKIP_GPS_DETECT=1",), + ("ENV_SKIP_GPS_DETECT=1", "PERSISTANT_GPS=1")): + self.compile_run(("awake",), defines=defines) + + def test_boot_blocking_negative_control(self): + self.compile_run(("absent",), negative="blocking") + + def test_bridge_bytes_cannot_finish_pending_discovery_negative_control(self): + self.compile_run(("blocked",), negative="blocked_poll") + + def test_uart_handoff_must_stop_pending_receiver_negative_control(self): + self.compile_run(("blocked",), negative="cancellation") + + def test_loaded_preference_must_be_retained_negative_control(self): + self.compile_run(("latest",), defines=("PERSISTANT_GPS=1",), negative="preference_loss") + + +if __name__ == "__main__": + unittest.main(verbosity=2)