"""Host regressions for bounded GPS discovery, query sync and BLE LED opt-out. Uses the real LocationProvider/SensorManager/MicroNMEA-provider headers and SensorManager.cpp. The cold-start and sensor-request cases execute extracted production methods, with only their hardware/library boundaries mocked. """ from pathlib import Path import re import shutil import subprocess import sys import tempfile import unittest from test_replay_reset_integration import extract_braced ROOT = Path(__file__).resolve().parents[1] ARDUINO = r''' #pragma once #include #include #include #include #include #include #define HIGH 1 #define LOW 0 #define OUTPUT 1 inline uint32_t now_ms=0; inline uint32_t delay_count=0; inline uint32_t millis(){return now_ms;} inline void delay(uint32_t ms){now_ms+=ms;++delay_count;} inline void yield(){++now_ms;} inline void pinMode(int,int){} inline void digitalWrite(int,int){} inline int digitalRead(int){return HIGH;} class Stream { public: virtual ~Stream()=default; virtual int available(){return 0;} virtual int read(){return -1;} virtual size_t write(uint8_t){return 1;} }; using std::isnan; ''' MESH = r''' #pragma once #include namespace mesh { class RTCClock { uint32_t utc=1790899200U; public: uint32_t getCurrentTime(){return utc;} void setCurrentTime(uint32_t value){utc=value;} }; } ''' NMEA = r''' #pragma once #include class MicroNMEA { bool valid=false; public: inline static unsigned clears=0; MicroNMEA(char*,size_t){} void clear(){++clears;valid=false;} bool process(char c){if(c=='\n'){valid=true;return true;}return false;} static bool testChecksum(const char*){return true;} static void sendSentence(Stream&,const char*){} const char* getSentence()const{return "$GPRMC,valid*00";} const char* getMessageID()const{return "RMC";} bool isValid()const{return valid;} long getLatitude()const{return 47610000;} long getLongitude()const{return -122330000;} bool getAltitude(long& value)const{value=125000;return valid;} long getNumSatellites()const{return valid?8:0;} uint16_t getYear()const{return valid?2026:0;} uint8_t getMonth()const{return valid?10:0;} uint8_t getDay()const{return valid?2:0;} uint8_t getHour()const{return 12;} uint8_t getMinute()const{return 0;} uint8_t getSecond()const{return 0;} }; ''' RTC = r''' #pragma once #include class DateTime { public: DateTime(uint16_t,uint8_t,uint8_t,uint8_t,uint8_t,uint8_t){} uint32_t unixtime()const{return 1790942400U;} }; ''' CAYENNE = r''' #pragma once #include #include #include class CayenneLPP { uint8_t data[64]={}; uint8_t size=0; void addScalar(uint8_t channel,uint8_t type,float value){ data[size++]=channel;data[size++]=type; size+=LPPData::putFloat(data+size,value,2,LPPData::getMultiplier(type),true); } public: unsigned resets=0,gps_count=0; explicit CayenneLPP(size_t){} void reset(){++resets;size=0;} void addVoltage(uint8_t channel,float value){addScalar(channel,LPP_VOLTAGE,value);} void addTemperature(uint8_t channel,float value){addScalar(channel,LPP_TEMPERATURE,value);} void addGPS(uint8_t channel,float latitude,float longitude,float altitude){ ++gps_count;data[size++]=channel;data[size++]=LPP_GPS; size+=LPPData::putFloat(data+size,latitude,3,LPP_GPS_LAT_LON_MULT,true); size+=LPPData::putFloat(data+size,longitude,3,LPP_GPS_LAT_LON_MULT,true); size+=LPPData::putFloat(data+size,altitude,3,LPP_GPS_ALT_MULT,true); } const uint8_t* getBuffer()const{return data;} uint8_t getSize()const{return size;} }; ''' QUERY = r''' #include #include #include #include #include void LocationProvider::sendSentence(const char*){} struct Uart:Stream { std::deque incoming; int available()override{return static_cast(incoming.size());} int read()override{if(incoming.empty())return -1;char c=incoming.front();incoming.pop_front();return c;} void fix(){incoming.push_back('\n');} }; struct TestProvider:MicroNMEALocationProvider { using MicroNMEALocationProvider::MicroNMEALocationProvider; void applied(){markTimeSyncApplied();stopTimeSync();} void resetQueryState(){resetTimeSyncRequestState();} }; struct TestSensors:SensorManager { LocationProvider* provider; bool detected=true,active=true; unsigned queries=0; explicit TestSensors(LocationProvider* p):provider(p){} LocationProvider* getLocationProvider()override{return provider;} bool telemetryGpsDetected()const override{return detected;} bool telemetryGpsActive()const override{return active;} void telemetryGpsStart()override{active=true;} void telemetryGpsStop()override{active=false;} void block(bool value){setGpsTelemetryTransportAvailable(!value);} bool querySensors(uint8_t,CayenneLPP&)override{++queries;return true;} }; struct Board { int getBattMilliVolts(){return 4200;} float getMCUTemperature(){return 25.0f;} }; uint8_t getTelemetryPermissions(uint8_t perms,int){return perms;} @CONFIG@ struct SensorRequestHarness { TestSensors& sensors; CayenneLPP telemetry{64}; Board board; struct {int telemetry_access=0;} _prefs; uint8_t reply_data[128]={}; explicit SensorRequestHarness(TestSensors& s):sensors(s){} uint8_t request(uint8_t perms,uint8_t mask=0,uint8_t length=1, size_t reply_capacity=sizeof(reply_data)){ uint8_t payload[1]={mask}; size_t payload_len=length; const unsigned req_type=3; @REQUEST@ return 0; } }; int main(){ // First acquisition already underway: burst requests must preserve a parsed // fix, not call the NMEA provider's clearing force-sync method. now_ms=1;Uart uart;mesh::RTCClock clock; TestProvider gps(uart,&clock,-1,-1); gps.setGPSPowerSaving(true);uart.fix();gps.loop();assert(gps.isValid()); const unsigned initial_clears=MicroNMEA::clears; for(unsigned i=0;i<100;++i){now_ms=i+1;assert(!gps.requestTimeSync(60));} assert(MicroNMEA::clears==initial_clears&&gps.isValid()); // Successful production NMEA sync marks monotonic time and leaves the edge // notification available for existing mesh clock consumers. now_ms=1100;gps.loop();now_ms=2200;gps.loop(); assert(!gps.waitingTimeSync()); assert(gps.consumeTimeSyncApplied());assert(!gps.consumeTimeSyncApplied()); now_ms=62199;assert(!gps.requestTimeSync(60)); // A backward/forward RTC correction cannot underflow or defeat the throttle. clock.setCurrentTime(1);assert(!gps.requestTimeSync(60)); clock.setCurrentTime(UINT32_MAX);assert(!gps.requestTimeSync(60)); now_ms=62200;assert(gps.requestTimeSync(60)); assert(MicroNMEA::clears==initial_clears+1&&!gps.isValid()); for(unsigned i=0;i<100;++i){++now_ms;assert(!gps.requestTimeSync(0));} assert(MicroNMEA::clears==initial_clears+1); // Aborted work is still protected by the last request, even without a fix. gps.stopTimeSync();assert(!gps.requestTimeSync(60)); now_ms=122200;assert(gps.requestTimeSync(60)); gps.stopTimeSync();assert(gps.requestTimeSync(0)); // explicit zero disables pace only // Forced CLI/startup callers retain their previous behavior. const unsigned force_clears=MicroNMEA::clears; gps.syncTime();assert(MicroNMEA::clears==force_clears+1); // millis rollover, including a valid successful sync at zero, is safe. TestProvider wrap(uart,&clock,-1,-1);now_ms=UINT32_MAX-1000;wrap.applied(); now_ms=58998;assert(!wrap.requestTimeSync(60)); now_ms=58999;assert(wrap.requestTimeSync(60)); TestProvider zero(uart,&clock,-1,-1);now_ms=0;zero.applied(); now_ms=59999;assert(!zero.requestTimeSync(60));now_ms=60000;assert(zero.requestTimeSync(60)); // Clamp huge/negative-converted overrides before multiplication. Signed-long // deadline comparisons elsewhere can never see an overflowing long interval. TestProvider huge(uart,&clock,-1,-1);now_ms=100;huge.applied(); now_ms=100+0x7ffffffeU;assert(!huge.requestTimeSync(UINT64_MAX)); now_ms=100+0x7fffffffU;assert(huge.requestTimeSync(UINT64_MAX)); huge.stopTimeSync();assert(!huge.requestTimeSync(static_cast(-1))); // Requests denied by transport/presence/mode/null provider do not consume // throttle. A powered external rail is not permission to reclaim the UART. TestProvider owned(uart,&clock,-1,-1);owned.setGPSPowerSaving(true);owned.stopTimeSync(); TestSensors sensors(&owned);now_ms=200;sensors.block(true); assert(owned.isEnabled());assert(!sensors.requestGpsTelemetryTimeSync(60)); sensors.block(false);sensors.detected=false;assert(!sensors.requestGpsTelemetryTimeSync(60)); sensors.detected=true;owned.setGPSPowerSaving(false);assert(!sensors.requestGpsTelemetryTimeSync(60)); owned.setGPSPowerSaving(true);sensors.provider=nullptr;assert(!sensors.requestGpsTelemetryTimeSync(60)); sensors.provider=&owned;assert(sensors.requestGpsTelemetryTimeSync(60)); // A RAK discovery reset intentionally forgets the old singleton's throttle. owned.stopTimeSync();owned.resetQueryState();assert(sensors.requestGpsTelemetryTimeSync(60)); // Execute actual SensorMesh permission and query wiring, including the build // override's seconds units. No-location/masked requests cannot stamp a query. TestProvider request_gps(uart,&clock,-1,-1);request_gps.setGPSPowerSaving(true);request_gps.stopTimeSync(); TestSensors request_sensors(&request_gps);SensorRequestHarness requests(request_sensors); now_ms=1000;const unsigned before=MicroNMEA::clears; requests.request(TELEM_PERM_BASE);requests.request(TELEM_PERM_LOCATION,TELEM_PERM_LOCATION); requests.request(TELEM_PERM_LOCATION,0,0);assert(MicroNMEA::clears==before); requests.request(TELEM_PERM_LOCATION);assert(MicroNMEA::clears==before+1); request_gps.stopTimeSync(); now_ms=1000+static_cast(GPS_READ_INTERVAL_SECS)*1000U-1; requests.request(TELEM_PERM_LOCATION);assert(MicroNMEA::clears==before+1); ++now_ms;requests.request(TELEM_PERM_LOCATION);assert(MicroNMEA::clears==before+2); // The production request now bounds complete LPP records to the caller's // reply capacity. Exercise real voltage/temperature bytes at both edges of // each four-byte record; no partial entry or write beyond the budget is valid. for(size_t capacity=4;capacity<=16;++capacity){ memset(requests.reply_data,0xA5,sizeof(requests.reply_data)); const uint8_t length=requests.request(TELEM_PERM_BASE,0,1,capacity); const size_t payload_size=capacity>=12?8:capacity>=8?4:0; const uint8_t* expected=requests.telemetry.getBuffer(); assert(requests.telemetry.getSize()==8); assert(expected[0]==TELEM_CHANNEL_SELF&&expected[1]==LPP_VOLTAGE); assert(expected[4]==TELEM_CHANNEL_SELF&&expected[5]==LPP_TEMPERATURE); assert(length==4+payload_size&&length<=capacity); assert(!memcmp(requests.reply_data+4,expected,payload_size)); for(size_t offset=length;offset #include #define PIN_GPS_TX 8 #define PIN_GPS_RX 7 #define MESH_DEBUG_PRINTLN(...) ((void)0) struct SerialMock { uint32_t start=0,arrival=UINT32_MAX; unsigned begins=0,polls=0; void setPins(int,int){} void begin(int){++begins;start=millis();} int available(){++polls;return arrival!=UINT32_MAX&&static_cast(millis()-start)>=arrival;} } Serial1; struct Location { unsigned begins=0,resets=0,stops=0; void begin(){++begins;}void reset(){++resets;}void stop(){++stops;} }; struct EnvironmentSensorManager { Location* _location; bool gps_serial_transport_blocked=false,gps_serial_transport=false; bool gps_detected=false,gps_active=false,user_enabled=false; unsigned transport_resets=0; explicit EnvironmentSensorManager(Location& p):_location(&p){} void resetGpsTelemetryTransportState(){++transport_resets;} void setGpsTelemetryUserEnabled(bool enabled){user_enabled=enabled;} void initBasicGPS(); }; @INIT@ int main(){ for(uint32_t arrival:{0U,1U,250U,2600U,5000U,5001U,UINT32_MAX}){ now_ms=UINT32_MAX-1000;delay_count=0;Serial1=SerialMock();Serial1.arrival=arrival; Location location;EnvironmentSensorManager sensors(location);const uint32_t start=now_ms; sensors.initBasicGPS(); #ifdef ENV_SKIP_GPS_DETECT assert(sensors.gps_detected&&now_ms==start&&delay_count==0&&Serial1.polls==0); #else const bool found=arrival<=5000; const uint32_t elapsed=arrival==0?0:found?((arrival+249)/250)*250:5000; assert(sensors.gps_detected==found&&static_cast(now_ms-start)==elapsed); assert(delay_count==elapsed/250&&delay_count<=20); #endif assert(location.begins==1&&location.resets==1&&sensors.transport_resets==1); assert(sensors.gps_serial_transport==sensors.gps_detected); #ifdef PERSISTANT_GPS assert(sensors.gps_active==sensors.gps_detected&&sensors.user_enabled==sensors.gps_detected); assert(location.stops==static_cast(!sensors.gps_detected)); #else assert(!sensors.gps_active&&location.stops==1); #endif } Location location;EnvironmentSensorManager blocked(location); blocked.gps_serial_transport_blocked=true;delay_count=0;Serial1=SerialMock(); blocked.initBasicGPS();assert(delay_count==0&&Serial1.begins==0&&Serial1.polls==0); assert(location.begins==0&&blocked.transport_resets==0); } ''' 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= 1)") return QUERY.replace("@CONFIG@", config).replace("@REQUEST@", request).replace( "const unsigned req_type=3;", "const unsigned req_type=3;\n#define REQ_TYPE_GET_TELEMETRY_DATA 3") def test_monotonic_query_throttle_permissions_and_ownership(self): self.compile_run(self.query_harness(), ("ENV_INCLUDE_GPS=1",), ("src/helpers/SensorManager.cpp",)) def test_build_override_keeps_seconds_separate_from_position_interval(self): self.compile_run(self.query_harness(), ("ENV_INCLUDE_GPS=1", "GPS_READ_INTERVAL_SECS=2"), ("src/helpers/SensorManager.cpp",)) def test_no_gps_build_keeps_safe_noop(self): self.compile_run(r''' #include #include void LocationProvider::sendSentence(const char*){} int main(){SensorManager sensors;assert(!sensors.requestGpsTelemetryTimeSync(UINT64_MAX));} ''', ("ENV_INCLUDE_GPS=0",), ("src/helpers/SensorManager.cpp",)) def test_bounded_discovery_fast_cold_absent_bypass_and_uart_owner(self): source = (ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp").read_text() init = extract_braced(source, "void EnvironmentSensorManager::initBasicGPS()") for defines in ((), ("ENV_SKIP_GPS_DETECT=1",), ("PERSISTANT_GPS=1",)): with self.subTest(defines=defines): self.compile_run(COLD.replace("@INIT@", init), defines) def run_rak_uart_probe(self, *, negative=False): source = (ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp").read_text() timeout = re.search(r"#ifndef RAK_UART_GPS_PROBE_TIMEOUT_MS\s*[\s\S]*?#endif", source).group() probe = extract_braced(source, "static bool serialHasValidGpsSentence(") if negative: # Reproduce the original continuously nonempty FIFO loop. The # process watchdog below bounds this deliberately infinite host # control; it does not change the production's existing deadline. deadline = " if (static_cast(millis() - started) >= timeout_ms) return false;" self.assertEqual(probe.count(deadline), 1) probe = probe.replace(deadline, "") with tempfile.TemporaryDirectory(prefix="meshcore-rak-uart-probe-") as directory: work = Path(directory) (work / "Arduino.h").write_text(ARDUINO, encoding="ascii") cpp, binary = work / "probe.cpp", work / "probe" cpp.write_text(RAK_UART_PROBE.replace("@TIMEOUT@", timeout).replace("@PROBE@", probe), encoding="ascii") result = subprocess.run([self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", *(["-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), "-o", str(binary)], capture_output=True, text=True, timeout=60) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) if negative: with self.assertRaises(subprocess.TimeoutExpired): subprocess.run([str(binary), "noise"], capture_output=True, text=True, timeout=1) return for scenario in ("noise", "wrap_noise", "valid", "noisy_valid", "wrap_valid", "invalid_checksum", "too_late", "truncated", "empty"): with self.subTest(scenario=scenario): result = subprocess.run([str(binary), scenario], capture_output=True, text=True, timeout=10) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) def test_rak_uart_probe_deadline_survives_continuous_noise_and_rollover(self): self.run_rak_uart_probe() def test_rak_original_nonempty_fifo_loop_negative_control(self): self.run_rak_uart_probe(negative=True) def test_ble_led_optout_preserves_default_and_begin_order(self): source = (ROOT / "src/helpers/nrf52/SerialBLEInterface.cpp").read_text() begin = extract_braced(source, "bool SerialBLEInterface::begin(") block = re.search(r"#ifdef DISABLE_BLE_LED[\s\S]*?#endif", begin).group() self.assertLess(begin.index(block), begin.index("Bluefruit.configPrphConn(")) self.assertLess(begin.index(block), begin.index("if (!Bluefruit.begin())")) self.assertLess(begin.index("if (_begin_attempted)"), begin.index(block)) harness = r''' #include struct BluefruitMock { bool controlled=true;unsigned calls=0; void autoConnLed(bool enabled){controlled=enabled;++calls;} } Bluefruit; void configure(){ @BLOCK@ } int main(){ configure(); #ifdef DISABLE_BLE_LED assert(!Bluefruit.controlled&&Bluefruit.calls==1); #else assert(Bluefruit.controlled&&Bluefruit.calls==0); #endif Bluefruit.controlled=false;configure();assert(!Bluefruit.controlled); } '''.replace("@BLOCK@", block) self.compile_run(harness) self.compile_run(harness, ("DISABLE_BLE_LED=1",)) def test_shared_success_mark_and_rak_discovery_reset_are_wired(self): provider = (ROOT / "src/helpers/sensors/MicroNMEALocationProvider.h").read_text() rak = (ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp").read_text() self.assertIn("markTimeSyncApplied();", provider) rak_provider = rak[rak.index("class RAK12500LocationProvider"):rak.index("static RAK12500LocationProvider")] self.assertIn("markTimeSyncApplied();", rak_provider) self.assertIn("resetTimeSyncRequestState();", rak_provider) # Explicit CLI sync remains forced; scheduled power cycles honor the # configured hourly cadence, with force-sync retained for legacy0. self.assertIn("void syncTime() override { nmea.clear(); LocationProvider::syncTime(); }", provider) self.assertIn("_location->syncTimeForPowerSavingCycle();", extract_braced(rak, "void EnvironmentSensorManager::armGpsPowerSavingCycle()")) if __name__ == "__main__": unittest.main()