From a901cf16129601248bdeb9ca1b7028a8d1bc0632 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 25 Oct 2026 22:55:05 -0400 Subject: [PATCH 1/6] Add GPS power cycling between fixes for companion Battery companions with a GPS enable pin could only keep the GPS powered all the time or turn it off. - New sensor setting gps_duty (seconds between fixes, 0 = always on, default; max 86400) via the existing custom vars, persisted in NodePrefs (gps.duty) and re-applied in applyGpsPrefs(). - While GPS is on and gps_duty > 0: power on, wait for a fix, hold it GPS_DUTY_SETTLE_SECS (10 s) so the RTC syncs, store the position, power off, wake gps_duty seconds later. gps_active stays true throughout, so adverts/telemetry keep the last fix. gps_duty=0 behaves as before. - Optional GPS_DUTY_MAX_ON_SECS gives up without a fix (default 0). - OLED GPS page shows "gps on 5m" / "gps sleep 5m"; long-press on the GPS page cycles off -> on -> 1m -> 5m -> 15m -> 60m -> off (plain toggle if the board has no gps_duty). - Not applied to RAK WisBlock GPS boards. FAQ 5.17. Co-Authored-By: Claude Opus 5.5 --- docs/faq.md | 14 ++++ examples/companion_radio/MyMesh.cpp | 4 + examples/companion_radio/MyMesh.h | 3 + examples/companion_radio/NodePrefs.h | 2 + examples/companion_radio/ui-new/UITask.cpp | 61 +++++++++++++- examples/companion_radio/ui-new/UITask.h | 2 + .../sensors/EnvironmentSensorManager.cpp | 81 ++++++++++++++++++- .../sensors/EnvironmentSensorManager.h | 10 +++ 8 files changed, 174 insertions(+), 3 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index ade59bf0090..d21b65c3e58 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -65,6 +65,7 @@ A list of frequently-asked questions and answers for MeshCore - [5.14. Q: Are there projects built around MeshCore?](#514-q-are-there-projects-built-around-meshcore) - [5.15. Q: Are there client applications for Windows or Mac?](#515-q-are-there-client-applications-for-windows-or-mac) - [5.16. Q: Are there any resources that compare MeshCore to other LoRa systems?](#516-q-are-there-any-resources-that-compare-meshcore-to-other-lora-systems) + - [5.17. Q: Can the GPS be powered down between fixes to save battery?](#517-q-can-the-gps-be-powered-down-between-fixes-to-save-battery) - [6. Troubleshooting](#6-troubleshooting) - [6.1. Q: My client says another client or a repeater or a room server was last seen many, many days ago.](#61-q-my-client-says-another-client-or-a-repeater-or-a-room-server-was-last-seen-many-many-days-ago) - [6.2. Q: A repeater or a client or a room server I expect to see on my discover list (on T-Deck) or contact list (on a smart device client) are not listed.](#62-q-a-repeater-or-a-client-or-a-room-server-i-expect-to-see-on-my-discover-list-on-t-deck-or-contact-list-on-a-smart-device-client-are-not-listed) @@ -665,6 +666,19 @@ Both the Windows and Mac versions of the client app are fully unlocked and are f - MeshCore Advantages by MCarper: - MeshCore vs Meshtastic by austinmesh.org: +### 5.17. Q: Can the GPS be powered down between fixes to save battery? +**A:** Yes, on companion firmware for boards whose GPS has a power/enable pin. Set the custom variable `gps_duty` to the number of seconds to wait between fixes (`0` = always on, the default; max `86400`). While GPS is on, the companion powers the GPS up, waits for a fix, keeps it for 10 seconds (so the clock can sync), stores the position, powers the GPS down, and waits `gps_duty` seconds before the next fix. GPS stays "on" in the app the whole time, and adverts and telemetry use the last stored position. + +On devices with a screen, long-pressing on the GPS page steps through **off → on → every 1m → 5m → 15m → 60m → off**. The page shows `gps sleep` while the GPS is powered down. + +``` +meshcli -s /dev/ttyUSB0 set gps_duty 300 +``` + +Notes: +- If the GPS has no backup battery, every wake is a cold start (often 30-60 s outdoors). The GPS stays powered until it gets a fix, so with no sky view it stays on. +- Build flags: `GPS_DUTY_SETTLE_SECS` (default 10) and `GPS_DUTY_MAX_ON_SECS` (give up after N seconds without a fix, default 0 = wait for a fix). + --- diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 6112b2dbb17..b548481e6c1 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -993,6 +993,7 @@ void MyMesh::begin() { _prefs.tx_power_dbm = constrain(_prefs.tx_power_dbm, -9, MAX_LORA_TX_POWER); _prefs.gps_enabled = constrain(_prefs.gps_enabled, 0, 1); // Ensure boolean 0 or 1 _prefs.gps_interval = constrain(_prefs.gps_interval, 0, 86400); // Max 24 hours + _prefs.gps_duty = constrain(_prefs.gps_duty, 0, 86400); _active_ble_pin = _prefs.ble_pin; @@ -1883,6 +1884,9 @@ void MyMesh::handleCmdFrame(size_t len) { uint32_t interval_seconds = atoi(np); _prefs.gps_interval = constrain(interval_seconds, 0, 86400); savePrefs(); + } else if (strcmp(sp, "gps_duty") == 0) { + _prefs.gps_duty = atol(np); // range checked by sensors + savePrefs(); } #endif writeOKFrame(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 944790df922..9c6120d2d4e 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -209,6 +209,9 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { sprintf(interval_str, "%u", _prefs.gps_interval); sensors.setSettingValue("gps_interval", interval_str); } + char duty_str[12]; + sprintf(duty_str, "%u", _prefs.gps_duty); + sensors.setSettingValue("gps_duty", duty_str); } #endif diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 3cceed56668..a85bf507975 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -35,6 +35,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file uint8_t vibe_quiet = 0; uint8_t gps_enabled = 0; // GPS enabled flag (0=disabled, 1=enabled) uint32_t gps_interval = 0; // GPS read interval in seconds + uint32_t gps_duty = 0; // GPS power cycle in seconds (0 = always on) uint8_t autoadd_config = 0; // bitmask for auto-add contacts config uint8_t rx_boosted_gain = 0; // SX126x RX boosted gain mode (0=power saving, 1=boosted) uint8_t radio_fem_rxgain = 0; // external LoRa FEM RX gain (LNA) @@ -130,6 +131,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file def("en", _parent->gps_enabled); // boolean def("int", _parent->gps_interval); // interval in seconds def("adv_loc", _parent->advert_loc_policy); + def("duty", _parent->gps_duty); } public: GPSPrefs(NodePrefs* parent) : _parent(parent) { } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index aced739dc45..047a220f573 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -404,6 +404,14 @@ class HomeScreen : public UIScreen { #else strcpy(buf, gps_state ? "gps on" : "gps off"); #endif + uint32_t duty = _task->getGPSDuty(); + if (gps_state && duty > 0) { + if (nmea != NULL && !nmea->isEnabled()) strcpy(buf, "gps sleep"); // powered down between fixes + char* dp = strchr(buf, 0); + if (duty % 3600 == 0) sprintf(dp, " %luh", (unsigned long)(duty / 3600)); + else if (duty % 60 == 0) sprintf(dp, " %lum", (unsigned long)(duty / 60)); + else sprintf(dp, " %lus", (unsigned long)duty); + } display.setColor(UIColor::primary_txt); display.drawTextLeftAlign(0, y, buf); if (nmea == NULL) { @@ -599,7 +607,7 @@ class HomeScreen : public UIScreen { } #if ENV_INCLUDE_GPS == 1 if (c == KEY_ENTER && _page == HomePage::GPS) { - _task->toggleGPS(); + _task->cycleGPSMode(); return true; } #endif @@ -1159,6 +1167,57 @@ bool UITask::getGPSState() { return false; } +uint32_t UITask::getGPSDuty() { + const char* v = _sensors != NULL ? _sensors->getSettingByKey("gps_duty") : NULL; + return v ? strtoul(v, NULL, 10) : 0; +} + +// Steps through off -> always on -> power-cycled every 1m / 5m / 15m / 60m -> off +void UITask::cycleGPSMode() { + static const uint32_t presets[] = { 0, 60, 300, 900, 3600 }; // duty secs while on, 0 = always on + const int n = sizeof(presets) / sizeof(presets[0]); + if (_sensors == NULL || _sensors->getSettingByKey("gps") == NULL) return; + + bool on = getGPSState(); + if (_sensors->getSettingByKey("gps_duty") == NULL) { // no duty cycling on this board + toggleGPS(); + return; + } + uint32_t duty = getGPSDuty(); + int next; // index into presets, or n = off + if (!on) { + next = 0; + } else { + int cur = 0; // a custom duty from the app continues as if "always on" + for (int i = 0; i < n; i++) if (presets[i] == duty) cur = i; + next = cur + 1; + } + + char tmp[12]; + if (next >= n) { + _sensors->setSettingValue("gps", "0"); + _node_prefs->gps_enabled = 0; + showAlert("GPS: Disabled", 800); + } else { + sprintf(tmp, "%lu", (unsigned long)presets[next]); + _sensors->setSettingValue("gps_duty", tmp); + _node_prefs->gps_duty = presets[next]; + if (!on) { // only start when off; start_gps() is not idempotent + _sensors->setSettingValue("gps", "1"); + _node_prefs->gps_enabled = 1; + } + if (presets[next] == 0) { + showAlert("GPS: Always on", 800); + } else { + sprintf(tmp, "GPS: %lum", (unsigned long)(presets[next] / 60)); // fits tmp up to "GPS: 60m" + showAlert(tmp, 800); + } + } + notify(UIEventType::ack); + the_mesh.savePrefs(); + _next_refresh = 0; +} + void UITask::toggleGPS() { if (_sensors != NULL) { // toggle GPS on/off diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 4d44bfe1a6e..976d3eafad0 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -114,6 +114,8 @@ class UITask : public AbstractUITask { void toggleBuzzer(); bool getGPSState(); void toggleGPS(); + void cycleGPSMode(); + uint32_t getGPSDuty(); // MyMesh::Listener void onMessageRecv(mesh::Packet *pkt, const ContactInfo &from, uint8_t txt_type, uint32_t sender_timestamp, const char* text) override; diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 0dc892c3a52..bd25eb2ea06 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -699,11 +699,18 @@ bool EnvironmentSensorManager::querySensors(uint8_t requester_permissions, Cayen } +#if ENV_INCLUDE_GPS && !defined(RAK_WISBLOCK_GPS) + #define ENV_GPS_DUTY_CYCLE 1 +#endif + int EnvironmentSensorManager::getNumSettings() const { int settings = 0; #if ENV_INCLUDE_GPS if (gps_detected) settings++; // only show GPS setting if GPS is detected #endif + #if ENV_GPS_DUTY_CYCLE + if (gps_detected) settings++; // gps_duty + #endif return settings; } @@ -714,6 +721,11 @@ const char* EnvironmentSensorManager::getSettingName(int i) const { return "gps"; } #endif + #if ENV_GPS_DUTY_CYCLE + if (gps_detected && i == settings++) { + return "gps_duty"; + } + #endif return NULL; } @@ -724,6 +736,12 @@ const char* EnvironmentSensorManager::getSettingValue(int i) const { return gps_active ? "1" : "0"; } #endif + #if ENV_GPS_DUTY_CYCLE + if (gps_detected && i == settings++) { + snprintf(gps_duty_val, sizeof(gps_duty_val), "%lu", (unsigned long)gps_duty_secs); + return gps_duty_val; + } + #endif return NULL; } @@ -743,6 +761,15 @@ bool EnvironmentSensorManager::setSettingValue(const char* name, const char* val return true; } #endif + #if ENV_GPS_DUTY_CYCLE + if (gps_detected && strcmp(name, "gps_duty") == 0) { + long secs = atol(value); + if (secs < 0 || secs > 86400) return false; + gps_duty_secs = secs; + if (gps_duty_secs == 0 && gps_active && !gps_powered) power_on_gps(); // back to always-on + return true; + } + #endif return false; // not supported } @@ -897,14 +924,24 @@ void EnvironmentSensorManager::start_gps() { return; #endif - _location->begin(); - _location->reset(); + power_on_gps(); #ifndef PIN_GPS_EN MESH_DEBUG_PRINTLN("Start GPS is N/A on this board. Actual GPS state unchanged"); #endif } +void EnvironmentSensorManager::power_on_gps() { + _location->begin(); + _location->reset(); + if (gps_duty_secs > 0) { + _location->syncTime(); // also clears the parser, so duty cycling waits for a fresh fix + } + gps_powered = true; + gps_powered_at = millis(); + gps_fix_since = 0; +} + void EnvironmentSensorManager::stop_gps() { gps_active = false; #ifdef RAK_WISBLOCK_GPS @@ -916,6 +953,7 @@ void EnvironmentSensorManager::stop_gps() { #endif _location->stop(); + gps_powered = false; #ifndef PIN_GPS_EN MESH_DEBUG_PRINTLN("Stop GPS is N/A on this board. Actual GPS state unchanged"); @@ -923,6 +961,42 @@ void EnvironmentSensorManager::stop_gps() { } #endif // ENV_INCLUDE_GPS +#if ENV_GPS_DUTY_CYCLE +#ifndef GPS_DUTY_SETTLE_SECS + #define GPS_DUTY_SETTLE_SECS 10 // keep the fix this long before powering down (lets the RTC sync) +#endif +#ifndef GPS_DUTY_MAX_ON_SECS + #define GPS_DUTY_MAX_ON_SECS 0 // give up after this long without a fix, 0 = wait for a fix +#endif + +void EnvironmentSensorManager::dutyCycleGPS() { + if (!gps_powered) { + if ((long)(millis() - gps_next_wake) >= 0) { + MESH_DEBUG_PRINTLN("GPS duty cycle: waking"); + power_on_gps(); + } + return; + } + + bool give_up = GPS_DUTY_MAX_ON_SECS > 0 && (millis() - gps_powered_at) >= GPS_DUTY_MAX_ON_SECS * 1000UL; + if (_location->isValid()) { + if (gps_fix_since == 0) gps_fix_since = millis(); + if ((millis() - gps_fix_since) < GPS_DUTY_SETTLE_SECS * 1000UL && !give_up) return; + node_lat = ((double)_location->getLatitude())/1000000.; + node_lon = ((double)_location->getLongitude())/1000000.; + node_altitude = ((double)_location->getAltitude()) / 1000.0; + MESH_DEBUG_PRINTLN("GPS duty cycle: fix lat %f lon %f, sleeping", node_lat, node_lon); + } else { + gps_fix_since = 0; + if (!give_up) return; + MESH_DEBUG_PRINTLN("GPS duty cycle: no fix, sleeping"); + } + _location->stop(); + gps_powered = false; + gps_next_wake = millis() + gps_duty_secs * 1000UL; +} +#endif + #if ENV_INCLUDE_GPS || defined(ENV_INCLUDE_BME680_BSEC) void EnvironmentSensorManager::loop() { @@ -930,6 +1004,9 @@ void EnvironmentSensorManager::loop() { static unsigned long next_gps_update = 0; if (gps_active) { _location->loop(); + #if ENV_GPS_DUTY_CYCLE + if (gps_duty_secs > 0) dutyCycleGPS(); + #endif } if ((long)(millis() - next_gps_update) > 0) { diff --git a/src/helpers/sensors/EnvironmentSensorManager.h b/src/helpers/sensors/EnvironmentSensorManager.h index 17706cbc492..7d55cf117f0 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.h +++ b/src/helpers/sensors/EnvironmentSensorManager.h @@ -25,6 +25,16 @@ class EnvironmentSensorManager : public SensorManager { #if ENV_INCLUDE_GPS LocationProvider* _location; + // Duty cycling: with gps_duty_secs > 0, power the GPS down after each fix and wake it + // gps_duty_secs later. gps_active (the user setting) stays true while powered down. + uint32_t gps_duty_secs = 0; + mutable char gps_duty_val[12]; + bool gps_powered = false; + unsigned long gps_next_wake = 0; + unsigned long gps_powered_at = 0; + unsigned long gps_fix_since = 0; + void power_on_gps(); + void dutyCycleGPS(); void start_gps(); void stop_gps(); void initBasicGPS(); From 92503837373adca6dd587e533a2176650c6b7989 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 26 Oct 2026 11:06:26 +0000 Subject: [PATCH 2/6] Don't resync the RTC from a stale fix while the GPS is powered down With gps_duty > 0, loop() kept calling _location->loop() while the GPS was powered down. The NMEA parser still held the last fix (it is only cleared on wake), so isValid() stayed true, and the provider's 30-minute RTC re-sync could fire during the sleep and set the clock to the last fix's time: up to gps_duty seconds in the past (an hour with the 1h preset, a day at the 86400 maximum). Only run the parser while the GPS is powered, or when not duty cycling. The stored position is unaffected; it is copied before power-down. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/helpers/sensors/EnvironmentSensorManager.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index bd25eb2ea06..d2532bdeb0c 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -1003,9 +1003,13 @@ void EnvironmentSensorManager::loop() { #if ENV_INCLUDE_GPS static unsigned long next_gps_update = 0; if (gps_active) { - _location->loop(); #if ENV_GPS_DUTY_CYCLE + // While powered down the parser still holds the last fix. Running it would let the provider's + // periodic RTC sync set the clock back to that fix's time. + if (gps_duty_secs == 0 || gps_powered) _location->loop(); if (gps_duty_secs > 0) dutyCycleGPS(); + #else + _location->loop(); #endif } if ((long)(millis() - next_gps_update) > 0) { From 4f73d5c116745927dbb74586ab94bcc185cb3cf1 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 26 Oct 2026 11:06:50 +0000 Subject: [PATCH 3/6] Only list gps_duty on firmware that saves it gps_duty was listed whenever a GPS was detected, so repeater and room server firmware showed it in "sensor list" and accepted "sensor set gps_duty N" with an immediate effect, but never saved it: the value was lost on reboot. Only the companion persists gps_duty. Follow the same opt-in as the runtime GPS pin settings: the setting is listed and accepted only after the firmware calls configureGPSDuty(), which the companion does from applyGpsPrefs(). ENV_GPS_DUTY_CYCLE moves to the header so the companion can test for it; boards whose sensor manager is not an EnvironmentSensorManager (e.g. Heltec Wireless Tracker) don't define it and are unaffected. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/companion_radio/MyMesh.h | 6 +++--- .../sensors/EnvironmentSensorManager.cpp | 19 +++++++++++-------- .../sensors/EnvironmentSensorManager.h | 11 +++++++++++ 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 9c6120d2d4e..5c6b12e9f74 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -209,9 +209,9 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { sprintf(interval_str, "%u", _prefs.gps_interval); sensors.setSettingValue("gps_interval", interval_str); } - char duty_str[12]; - sprintf(duty_str, "%u", _prefs.gps_duty); - sensors.setSettingValue("gps_duty", duty_str); +#if ENV_GPS_DUTY_CYCLE + sensors.configureGPSDuty(_prefs.gps_duty); +#endif } #endif diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index d2532bdeb0c..1b7fea1a478 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -699,17 +699,13 @@ bool EnvironmentSensorManager::querySensors(uint8_t requester_permissions, Cayen } -#if ENV_INCLUDE_GPS && !defined(RAK_WISBLOCK_GPS) - #define ENV_GPS_DUTY_CYCLE 1 -#endif - int EnvironmentSensorManager::getNumSettings() const { int settings = 0; #if ENV_INCLUDE_GPS if (gps_detected) settings++; // only show GPS setting if GPS is detected #endif #if ENV_GPS_DUTY_CYCLE - if (gps_detected) settings++; // gps_duty + if (gps_detected && gps_duty_enabled) settings++; // gps_duty #endif return settings; } @@ -722,7 +718,7 @@ const char* EnvironmentSensorManager::getSettingName(int i) const { } #endif #if ENV_GPS_DUTY_CYCLE - if (gps_detected && i == settings++) { + if (gps_detected && gps_duty_enabled && i == settings++) { return "gps_duty"; } #endif @@ -737,7 +733,7 @@ const char* EnvironmentSensorManager::getSettingValue(int i) const { } #endif #if ENV_GPS_DUTY_CYCLE - if (gps_detected && i == settings++) { + if (gps_detected && gps_duty_enabled && i == settings++) { snprintf(gps_duty_val, sizeof(gps_duty_val), "%lu", (unsigned long)gps_duty_secs); return gps_duty_val; } @@ -762,7 +758,7 @@ bool EnvironmentSensorManager::setSettingValue(const char* name, const char* val } #endif #if ENV_GPS_DUTY_CYCLE - if (gps_detected && strcmp(name, "gps_duty") == 0) { + if (gps_detected && gps_duty_enabled && strcmp(name, "gps_duty") == 0) { long secs = atol(value); if (secs < 0 || secs > 86400) return false; gps_duty_secs = secs; @@ -995,6 +991,13 @@ void EnvironmentSensorManager::dutyCycleGPS() { gps_powered = false; gps_next_wake = millis() + gps_duty_secs * 1000UL; } + +void EnvironmentSensorManager::configureGPSDuty(uint32_t secs) { + gps_duty_enabled = true; + char buf[12]; + snprintf(buf, sizeof(buf), "%lu", (unsigned long)secs); + setSettingValue("gps_duty", buf); +} #endif #if ENV_INCLUDE_GPS || defined(ENV_INCLUDE_BME680_BSEC) diff --git a/src/helpers/sensors/EnvironmentSensorManager.h b/src/helpers/sensors/EnvironmentSensorManager.h index 7d55cf117f0..ac94846a901 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.h +++ b/src/helpers/sensors/EnvironmentSensorManager.h @@ -4,6 +4,11 @@ #include #include +// Not for RAK WisBlock GPS (same condition as RAK_WISBLOCK_GPS in the .cpp), which has its own power handling +#if ENV_INCLUDE_GPS && !(defined(RAK_BOARD) && !defined(RAK_WISMESH_TAG)) + #define ENV_GPS_DUTY_CYCLE 1 +#endif + class EnvironmentSensorManager : public SensorManager { protected: static const int MAX_ACTIVE_SENSORS = 16; @@ -27,7 +32,9 @@ class EnvironmentSensorManager : public SensorManager { LocationProvider* _location; // Duty cycling: with gps_duty_secs > 0, power the GPS down after each fix and wake it // gps_duty_secs later. gps_active (the user setting) stays true while powered down. + // Only exposed as a setting once the app calls configureGPSDuty(), since it must persist it. uint32_t gps_duty_secs = 0; + bool gps_duty_enabled = false; mutable char gps_duty_val[12]; bool gps_powered = false; unsigned long gps_next_wake = 0; @@ -49,6 +56,10 @@ class EnvironmentSensorManager : public SensorManager { EnvironmentSensorManager(LocationProvider &location): _location(&location){}; LocationProvider* getLocationProvider() { return _location; } bool isGPSDetected() const override { return gps_detected; } + #if ENV_GPS_DUTY_CYCLE + // Call after begin(). Lists gps_duty and applies the saved value (invalid = always on). + void configureGPSDuty(uint32_t secs); + #endif #else EnvironmentSensorManager(){}; #endif From 567cc28153dbe6d3e584462eaf73fc0c73774ec6 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 26 Oct 2026 11:07:00 +0000 Subject: [PATCH 4/6] Track GPS power state on PERSISTANT_GPS boards On PERSISTANT_GPS boards initBasicGPS() leaves the GPS powered and sets gps_active without going through start_gps(), so gps_powered stayed false. With gps_duty > 0 the first loop then "woke" a GPS that was already on: a second begin()/reset() and parser clear right after boot, and an extra claim on the location provider. None of today's PERSISTANT_GPS boards (T-Beam, T-Beam 1W, ThinkNode M5) pass a shared power pin, so the extra claim had no effect yet. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/helpers/sensors/EnvironmentSensorManager.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 1b7fea1a478..220abe1343b 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -806,6 +806,8 @@ void EnvironmentSensorManager::initBasicGPS() { MESH_DEBUG_PRINTLN("GPS detected"); #ifdef PERSISTANT_GPS gps_active = true; + gps_powered = true; // left on by begin() above + gps_powered_at = millis(); return; #endif } else { From e0b60dd24eb8ba3d18e05278eef71f9e4ae70e2e Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 26 Oct 2026 11:07:29 +0000 Subject: [PATCH 5/6] Use one duty label format on the OLED GPS page and its alerts The GPS page showed a 3600 s duty as "gps on 1h" while the long-press alert for the same preset said "GPS: 60m". Format both with one helper that picks the largest exact unit (s, m or h), and update the FAQ preset list to match. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/faq.md | 2 +- examples/companion_radio/ui-new/UITask.cpp | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index d21b65c3e58..19df8e7667b 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -669,7 +669,7 @@ Both the Windows and Mac versions of the client app are fully unlocked and are f ### 5.17. Q: Can the GPS be powered down between fixes to save battery? **A:** Yes, on companion firmware for boards whose GPS has a power/enable pin. Set the custom variable `gps_duty` to the number of seconds to wait between fixes (`0` = always on, the default; max `86400`). While GPS is on, the companion powers the GPS up, waits for a fix, keeps it for 10 seconds (so the clock can sync), stores the position, powers the GPS down, and waits `gps_duty` seconds before the next fix. GPS stays "on" in the app the whole time, and adverts and telemetry use the last stored position. -On devices with a screen, long-pressing on the GPS page steps through **off → on → every 1m → 5m → 15m → 60m → off**. The page shows `gps sleep` while the GPS is powered down. +On devices with a screen, long-pressing on the GPS page steps through **off → on → every 1m → 5m → 15m → 1h → off**. The page shows the interval (`gps on 5m`), and `gps sleep 5m` while the GPS is powered down. ``` meshcli -s /dev/ttyUSB0 set gps_duty 300 diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 047a220f573..e0e4b7dbb09 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -33,6 +33,13 @@ #include "icons.h" +// GPS duty in its largest exact unit ("30s", "5m", "1h"), shared by the GPS page and its alerts +static void formatGPSDuty(char* dest, uint32_t secs) { + if (secs % 3600 == 0) sprintf(dest, "%luh", (unsigned long)(secs / 3600)); + else if (secs % 60 == 0) sprintf(dest, "%lum", (unsigned long)(secs / 60)); + else sprintf(dest, "%lus", (unsigned long)secs); +} + class SplashScreen : public UIScreen { UITask* _task; unsigned long dismiss_after; @@ -407,10 +414,8 @@ class HomeScreen : public UIScreen { uint32_t duty = _task->getGPSDuty(); if (gps_state && duty > 0) { if (nmea != NULL && !nmea->isEnabled()) strcpy(buf, "gps sleep"); // powered down between fixes - char* dp = strchr(buf, 0); - if (duty % 3600 == 0) sprintf(dp, " %luh", (unsigned long)(duty / 3600)); - else if (duty % 60 == 0) sprintf(dp, " %lum", (unsigned long)(duty / 60)); - else sprintf(dp, " %lus", (unsigned long)duty); + strcat(buf, " "); + formatGPSDuty(strchr(buf, 0), duty); } display.setColor(UIColor::primary_txt); display.drawTextLeftAlign(0, y, buf); @@ -1209,7 +1214,8 @@ void UITask::cycleGPSMode() { if (presets[next] == 0) { showAlert("GPS: Always on", 800); } else { - sprintf(tmp, "GPS: %lum", (unsigned long)(presets[next] / 60)); // fits tmp up to "GPS: 60m" + strcpy(tmp, "GPS: "); + formatGPSDuty(&tmp[5], presets[next]); // presets fit tmp ("GPS: 15m") showAlert(tmp, 800); } } From 513a175f827b3fad150060d9286ec278c0e707c6 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 26 Oct 2026 11:07:29 +0000 Subject: [PATCH 6/6] FAQ: gps_duty saves no power without a GPS power switch gps_duty is listed on every board with a detected GPS, but it can only power the GPS down where the firmware controls its power (an enable pin or switched supply). Say so, and what it still does on other boards. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/faq.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/faq.md b/docs/faq.md index 19df8e7667b..24f701862ae 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -677,6 +677,7 @@ meshcli -s /dev/ttyUSB0 set gps_duty 300 Notes: - If the GPS has no backup battery, every wake is a cold start (often 30-60 s outdoors). The GPS stays powered until it gets a fix, so with no sky view it stays on. +- On boards where the firmware can't switch the GPS off (no enable pin or switched power), `gps_duty` saves no power. It only limits how often the position is updated. - Build flags: `GPS_DUTY_SETTLE_SECS` (default 10) and `GPS_DUTY_MAX_ON_SECS` (give up after N seconds without a fix, default 0 = wait for a fix).