From fd920624a200af684b05b3da982cf9ec36bd3863 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 16 Sep 2026 21:27:15 -0700 Subject: [PATCH] Limit repeater GPS acquisition and repair CI tests --- .github/workflows/run-unit-tests.yml | 1 + docs/cli_commands.md | 4 +++ docs/gps_tracking.md | 25 +++++++++++----- examples/simple_repeater/main.cpp | 3 ++ src/helpers/SensorManager.cpp | 17 +++++++++++ src/helpers/SensorManager.h | 8 +++++ .../test_gps_transport_ownership.cpp | 29 +++++++++++++++++++ test/test_trace_retry/test_trace_retry.cpp | 6 +++- 8 files changed, 84 insertions(+), 9 deletions(-) diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 808a78e4..7a4fc56b 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,6 +20,7 @@ jobs: - name: Install real crypto implementations for cross-language tests run: | python3 -m pip install platformio pycryptodome==3.23.0 + mkdir -p .pio/libdeps/management-test pio pkg install --global --library 'rweather/Crypto@0.4.0' --storage-dir .pio/libdeps/management-test - name: Verify protocol, encryption, scheduling and MQTT decoding run: python3 -B test/test_management_report.py -v diff --git a/docs/cli_commands.md b/docs/cli_commands.md index b6dcb0fe..6eaba34e 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -4288,6 +4288,10 @@ Companion reports `on` or `off`; infrastructure includes receiver details: - `off` when the GPS hardware is disabled - `on, {active|deactivated}, {fix|no fix}, {sat count} sats` when the GPS hardware is enabled +On repeaters, `gps off` cancels any acquisition already in progress. A later +authorized location-telemetry request may wake the receiver, but only until its +first valid fix is cached; the receiver then powers down again. + --- #### Sync this node's clock with GPS time diff --git a/docs/gps_tracking.md b/docs/gps_tracking.md index a9e8acad..667c87c2 100644 --- a/docs/gps_tracking.md +++ b/docs/gps_tracking.md @@ -31,16 +31,25 @@ The cache behavior is: - Omit GPS from telemetry if there is no fix or if the cached fix is more than 12 hours old. -When a telemetry request asks for location, GPS is kept on for 2 hours after the -latest location request. During that hold window, later location telemetry -requests can use fresh GPS data as soon as valid fixes are available. +When a telemetry request asks for location, GPS is normally kept on for 2 hours +after the latest location request. During that hold window, later location +telemetry requests can use fresh GPS data as soon as valid fixes are available. If GPS is manually enabled, it stays on and valid fixes continue to update the cache. If no contact or ACL client can receive location telemetry, the scheduled -2-hour refresh does not run. A real location telemetry request still turns GPS -on for the 2-hour hold window, and manual GPS-on still keeps the cache updated. +2-hour refresh does not run. A real location telemetry request can still turn +GPS on, and manual GPS-on still keeps the cache updated. + +## Repeater Nodes + +Repeaters use a shorter on-demand policy when GPS is manually off. An authorized +location-telemetry request wakes the receiver only when its cached position is +stale. The first valid fix is cached and the receiver immediately powers down; +the next request can return that fix without another wake. `gps off` also +cancels an acquisition already in progress. An acquisition that never obtains a +fix retains the normal 15-minute safety timeout. ## Companion/Client Nodes @@ -55,6 +64,9 @@ Location telemetry is sent only when the requester's effective telemetry permissions include location. Those permissions are derived from the companion telemetry mode settings and contact flags. +No new phone app behavior is required. Existing clients see the existing GPS +telemetry field when it is present. + The scheduled GPS cache refresh runs only when at least one stored contact has effective location telemetry access: @@ -62,9 +74,6 @@ effective location telemetry access: - `location: allow flags` with at least one stored contact whose flags include location -No new phone app behavior is required. Existing clients see the existing GPS -telemetry field when it is present. - ## Sensor Nodes Sensor telemetry access is controlled by: diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index b03a99cf..3f9fbf1d 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -204,6 +204,9 @@ void setup() { #endif #if ENV_INCLUDE_GPS == 1 + // With GPS manually off, an authorized location telemetry request may wake + // the receiver only until the first valid fix has been cached. + sensors.setGpsTelemetryStopAfterFix(true); if (sensors.getLocationProvider() != NULL) { // Keep GPS awake for at most 10 minutes, then asleep for one day. sensors.getLocationProvider()->setPowerSavingProfile(600, 86400); diff --git a/src/helpers/SensorManager.cpp b/src/helpers/SensorManager.cpp index 6bbb7c6f..08c00e73 100644 --- a/src/helpers/SensorManager.cpp +++ b/src/helpers/SensorManager.cpp @@ -115,6 +115,18 @@ bool SensorManager::queryGpsTelemetry(uint8_t requester_permissions, CayenneLPP& gps_cache_altitude); return true; } + if (gps_stop_after_fix && !gps_user_enabled) { + // Repeaters acquire only when the cached position is stale. The request + // returns without GPS while acquisition is in progress; the next request + // can use the cached fix without waking the receiver again. + if (!gpsTelemetryCacheFresh(now) && !gps_acquiring) { + beginGpsTelemetryAcquisition(now); + } + if (!gpsTelemetryCacheFresh(now)) return false; + telemetry.addGPS(TELEM_CHANNEL_SELF, gps_cache_lat, gps_cache_lon, + gps_cache_altitude); + return true; + } gps_hold_until = now + GPS_TELEMETRY_HOLD_SEC * 1000UL; if (!telemetryGpsActive()) telemetryGpsStart(); if (!gpsTelemetryCacheFresh(now) && !gps_acquiring) beginGpsTelemetryAcquisition(now); @@ -138,6 +150,11 @@ void SensorManager::processGpsTelemetryFix(float lat, float lon, float altitude, gps_stable_started_at = now; gps_stable_origin_lat = lat; gps_stable_origin_lon = lon; + if (gps_stop_after_fix && !gps_user_enabled) { + updateGpsTelemetryCache(lat, lon, altitude, now); + finishGpsTelemetryAcquisition(now, false); + return; + } } static const float STABLE_RADIUS_M2 = diff --git a/src/helpers/SensorManager.h b/src/helpers/SensorManager.h index 33be9e4b..361c4563 100644 --- a/src/helpers/SensorManager.h +++ b/src/helpers/SensorManager.h @@ -18,6 +18,7 @@ class SensorManager { bool gps_location_access_available = false; bool gps_transport_available = true; bool gps_user_enabled = false; + bool gps_stop_after_fix = false; bool gps_acquiring = false; bool gps_acquire_has_fix = false; float gps_cache_lat = 0; @@ -100,6 +101,13 @@ public: } virtual void loop() { } virtual void setTelemetryLocationAccessAvailable(bool available); + void setGpsTelemetryStopAfterFix(bool enabled) { +#if ENV_INCLUDE_GPS + gps_stop_after_fix = enabled; +#else + (void)enabled; +#endif + } virtual int getNumSettings() const { return 0; } virtual const char* getSettingName(int i) const { return NULL; } virtual const char* getSettingValue(int i) const { return NULL; } diff --git a/test/test_gps_transport_ownership/test_gps_transport_ownership.cpp b/test/test_gps_transport_ownership/test_gps_transport_ownership.cpp index 4c8b9aed..e0d57fb3 100644 --- a/test/test_gps_transport_ownership/test_gps_transport_ownership.cpp +++ b/test/test_gps_transport_ownership/test_gps_transport_ownership.cpp @@ -18,6 +18,9 @@ public: void setLocationAccessAvailable(bool available) { setTelemetryLocationAccessAvailable(available); } + void setStopAfterFix(bool enabled) { + setGpsTelemetryStopAfterFix(enabled); + } void service(unsigned long now) { loopGpsTelemetry(now); } bool receiverRequired(unsigned long now) const { return gpsTelemetryReceiverRequired(now); @@ -103,6 +106,29 @@ TEST(GpsTransportOwnership, UserOffCancelsTelemetryAcquisitionAndHold) { EXPECT_EQ(1, sensors.starts); } +TEST(GpsTransportOwnership, RepeaterTelemetryStopsAfterFirstFix) { + resetArduinoMock(); + TestGpsSensorManager sensors; + CayenneLPP telemetry(64); + + sensors.setStopAfterFix(true); + EXPECT_FALSE(sensors.queryLocation(telemetry)); + ASSERT_TRUE(sensors.active); + ASSERT_TRUE(sensors.receiverRequired(millis())); + + sensors.acceptFix(47.61f, -122.33f, 125.0f, millis()); + EXPECT_FALSE(sensors.active); + EXPECT_FALSE(sensors.receiverRequired(millis())); + EXPECT_EQ(1, sensors.starts); + EXPECT_EQ(1, sensors.stops); + + // The next request receives the cached fix without another GPS wake. + EXPECT_TRUE(sensors.queryLocation(telemetry)); + EXPECT_EQ(1, telemetry.gps_count); + EXPECT_FALSE(sensors.active); + EXPECT_EQ(1, sensors.starts); +} + TEST(GpsTransportOwnership, LastGoodCacheSurvivesTemporaryUartOwnership) { resetArduinoMock(); TestGpsSensorManager sensors; @@ -112,6 +138,9 @@ TEST(GpsTransportOwnership, LastGoodCacheSurvivesTemporaryUartOwnership) { sensors.acceptFix(47.61f, -122.33f, 125.0f, millis()); g_mock_millis += 31000; sensors.acceptFix(47.61f, -122.33f, 125.0f, millis()); + // Non-repeater roles retain the normal two-hour request hold. + EXPECT_TRUE(sensors.active); + EXPECT_TRUE(sensors.receiverRequired(millis())); ASSERT_TRUE(sensors.queryLocation(telemetry)); ASSERT_EQ(1, telemetry.gps_count); diff --git a/test/test_trace_retry/test_trace_retry.cpp b/test/test_trace_retry/test_trace_retry.cpp index bbba0bfc..f0d3b90e 100644 --- a/test/test_trace_retry/test_trace_retry.cpp +++ b/test/test_trace_retry/test_trace_retry.cpp @@ -196,8 +196,12 @@ TEST(ManagementRouting, FloodForwardingDoesNotNeedAKeyAndStillHonorsFilters) { mesh.forwardFloods = true; mesh::Packet p; p.header = (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD; - p.setPathHashSizeAndCount(1, 0); memcpy(p.payload, "MGR1", 4); p.payload[79] = 1; + p.setPathHashSizeAndCount(1, 0); p.payload_len = mesh::management::floodSize(mesh::management::HEADER + mesh::management::TAG); + memset(p.payload, 0, p.payload_len); + memcpy(p.payload, "MGR1", 4); + p.payload[79] = 1; // one page containing zero ACL entries + ASSERT_TRUE(mesh::management::validPage(p.payload, p.payload_len, true)); EXPECT_NE(ACTION_RELEASE, mesh.receivePacket(&p)); EXPECT_FALSE(mesh.groupPacketObserved); // never delivered as decrypted channel data mesh.rejectFloods = true;