From f0dc218a570bc8f59aaef067457fe630d5b768e1 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Tue, 10 Feb 2026 22:26:59 +1100 Subject: [PATCH] GPS duty cycle and cpu power management for extended battery life implemented --- examples/companion_radio/MyMesh.h | 2 +- examples/companion_radio/main.cpp | 189 +++++++++++-------- examples/companion_radio/ui-new/UITask.cpp | 133 +++++++------ variants/lilygo_tdeck_pro/CPUPowerManager.h | 70 +++++++ variants/lilygo_tdeck_pro/GPSDutyCycle.h | 185 ++++++++++++++++++ variants/lilygo_tdeck_pro/GPSStreamCounter.h | 72 +++++++ variants/lilygo_tdeck_pro/target.cpp | 5 +- variants/lilygo_tdeck_pro/target.h | 2 + 8 files changed, 522 insertions(+), 136 deletions(-) create mode 100644 variants/lilygo_tdeck_pro/CPUPowerManager.h create mode 100644 variants/lilygo_tdeck_pro/GPSDutyCycle.h create mode 100644 variants/lilygo_tdeck_pro/GPSStreamCounter.h diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 6665ca6..1aa70bc 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -12,7 +12,7 @@ #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "Meck v0.8.3" +#define FIRMWARE_VERSION "Meck v0.8.4" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index df78591..f5ce61e 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -3,6 +3,8 @@ #include "MyMesh.h" #include "variant.h" // Board-specific defines (HAS_GPS, etc.) #include "target.h" // For sensors, board, etc. +#include "GPSDutyCycle.h" +#include "CPUPowerManager.h" // T-Deck Pro Keyboard support #if defined(LilyGo_TDeck_Pro) @@ -11,7 +13,6 @@ #include "TextReaderScreen.h" #include "ContactsScreen.h" #include "ChannelScreen.h" - #include "SettingsScreen.h" extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire); @@ -40,6 +41,12 @@ // Text reader mode state static bool readerMode = false; + + // Power management + #if HAS_GPS + GPSDutyCycle gpsDuty; + #endif + CPUPowerManager cpuPower; void initKeyboard(); void handleKeyboardInput(); @@ -392,7 +399,7 @@ void setup() { MESH_DEBUG_PRINTLN("setup() - SPIFFS.begin() done"); // --------------------------------------------------------------------------- - // Early SD card init — needed BEFORE the_mesh.begin() so we can restore + // Early SD card init — needed BEFORE the_mesh.begin() so we can restore // settings from a previous firmware flash. The display SPI bus is already // up (display.begin() ran earlier), so SD can share it now. // --------------------------------------------------------------------------- @@ -448,12 +455,6 @@ void setup() { the_mesh.startInterface(serial_interface); MESH_DEBUG_PRINTLN("setup() - the_mesh.startInterface() done"); - // T-Deck Pro: default BLE to OFF on boot (user can toggle with Bluetooth page) - #if defined(LilyGo_TDeck_Pro) - serial_interface.disable(); - MESH_DEBUG_PRINTLN("setup() - BLE disabled by default (toggle via home screen)"); - #endif - #else #error "need to define filesystem" #endif @@ -501,6 +502,7 @@ void setup() { if (reader) { reader->setSDReady(true); if (disp) { + cpuPower.setBoost(); // Boost CPU for EPUB processing reader->bootIndex(*disp); } } @@ -510,30 +512,29 @@ void setup() { } #endif - // --------------------------------------------------------------------------- - // First-boot onboarding detection - // Check if node name is still the default hex prefix (first 4 bytes of pub key) - // If so, launch onboarding wizard to set name and radio preset - // --------------------------------------------------------------------------- - #if defined(LilyGo_TDeck_Pro) + // GPS duty cycle — honour saved pref, default to enabled on first boot + #if HAS_GPS { - char defaultName[10]; - mesh::Utils::toHex(defaultName, the_mesh.self_id.pub_key, 4); - NodePrefs* prefs = the_mesh.getNodePrefs(); - if (strcmp(prefs->node_name, defaultName) == 0) { - MESH_DEBUG_PRINTLN("setup() - Default node name detected, launching onboarding"); - ui_task.gotoOnboarding(); + bool gps_wanted = the_mesh.getNodePrefs()->gps_enabled; + gpsDuty.setStreamCounter(&gpsStream); + gpsDuty.begin(gps_wanted); + if (gps_wanted) { + sensors.setSettingValue("gps", "1"); + } else { + sensors.setSettingValue("gps", "0"); } + MESH_DEBUG_PRINTLN("setup() - GPS duty cycle started (enabled=%d)", gps_wanted); } #endif - // Enable GPS by default on T-Deck Pro - #if HAS_GPS - // Set GPS enabled in both sensor manager and node prefs - sensors.setSettingValue("gps", "1"); - the_mesh.getNodePrefs()->gps_enabled = 1; - the_mesh.savePrefs(); // SD backup triggered automatically - MESH_DEBUG_PRINTLN("setup() - GPS enabled by default"); + // CPU frequency scaling — drop to 80 MHz for idle mesh listening + cpuPower.begin(); + + // T-Deck Pro: BLE starts disabled for standalone-first operation + // User can toggle it on from the Bluetooth home page (Enter or long-press) + #if defined(LilyGo_TDeck_Pro) && defined(BLE_PIN_CODE) + serial_interface.disable(); + MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)"); #endif MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ==="); @@ -541,7 +542,24 @@ void setup() { void loop() { the_mesh.loop(); + + // GPS duty cycle — check for fix and manage power state + #if HAS_GPS + { + bool gps_hw_on = gpsDuty.loop(); + if (gps_hw_on) { + LocationProvider* lp = sensors.getLocationProvider(); + if (lp != NULL && lp->isValid()) { + gpsDuty.notifyFix(); + } + } + } + #endif + sensors.loop(); + + // CPU frequency auto-timeout back to idle + cpuPower.loop(); #ifdef DISPLAY_CLASS // Skip UITask rendering when in compose mode to prevent flickering #if defined(LilyGo_TDeck_Pro) @@ -769,28 +787,20 @@ void handleKeyboardInput() { return; } - // All other keys pass through to the reader screen - ui_task.injectKey(key); - return; - } - - // *** SETTINGS MODE *** - if (ui_task.isOnSettingsScreen()) { - SettingsScreen* settings = (SettingsScreen*)ui_task.getSettingsScreen(); - - // Q key: exit settings (when not editing) - if (!settings->isEditing() && (key == 'q' || key == 'Q')) { - if (settings->hasRadioChanges()) { - // Let settings show "apply changes?" confirm dialog - ui_task.injectKey(key); - } else { - Serial.println("Exiting settings"); - ui_task.gotoHomeScreen(); - } + // C key: allow entering compose mode from reader + if (key == 'c' || key == 'C') { + composeDM = false; + composeDMContactIdx = -1; + composeMode = true; + composeBuffer[0] = '\0'; + composePos = 0; + Serial.printf("Entering compose mode from reader, channel %d\n", composeChannelIdx); + drawComposeScreen(); + lastComposeRefresh = millis(); return; } - - // All other keys → settings screen via injectKey (no forceRefresh) + + // All other keys pass through to the reader screen ui_task.injectKey(key); return; } @@ -799,11 +809,38 @@ void handleKeyboardInput() { switch (key) { case 'c': case 'C': - // Open contacts list - Serial.println("Opening contacts"); - ui_task.gotoContactsScreen(); + // Enter compose mode - DM if on contacts screen, channel otherwise + if (ui_task.isOnContactsScreen()) { + ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen(); + int idx = cs->getSelectedContactIdx(); + uint8_t ctype = cs->getSelectedContactType(); + if (idx >= 0 && ctype == ADV_TYPE_CHAT) { + composeDM = true; + composeDMContactIdx = idx; + cs->getSelectedContactName(composeDMName, sizeof(composeDMName)); + composeMode = true; + composeBuffer[0] = '\0'; + composePos = 0; + Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx); + drawComposeScreen(); + lastComposeRefresh = millis(); + } + } else { + composeDM = false; + composeDMContactIdx = -1; + composeMode = true; + composeBuffer[0] = '\0'; + composePos = 0; + // If on channel screen, sync compose channel with viewed channel + if (ui_task.isOnChannelScreen()) { + composeChannelIdx = ui_task.getChannelScreenViewIdx(); + } + Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx); + drawComposeScreen(); + lastComposeRefresh = millis(); + } break; - + case 'm': case 'M': // Go to channel message screen @@ -811,22 +848,18 @@ void handleKeyboardInput() { ui_task.gotoChannelScreen(); break; - case 'e': - case 'E': - // Open text reader (ebooks) + case 'r': + case 'R': + // Open text reader Serial.println("Opening text reader"); ui_task.gotoTextReader(); break; - case 's': - case 'S': - // Open settings (from home), or navigate down on channel/contacts - if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) { - ui_task.injectKey('s'); // Pass directly for channel/contacts scrolling - } else { - Serial.println("Opening settings"); - ui_task.gotoSettingsScreen(); - } + case 'n': + case 'N': + // Open contacts list + Serial.println("Opening contacts"); + ui_task.gotoContactsScreen(); break; case 'w': @@ -840,6 +873,17 @@ void handleKeyboardInput() { } break; + case 's': + case 'S': + // Navigate down/next (scroll on channel screen) + if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) { + ui_task.injectKey('s'); // Pass directly for channel/contacts switching + } else { + Serial.println("Nav: Next"); + ui_task.injectKey(0xF1); // KEY_NEXT + } + break; + case 'a': case 'A': // Navigate left or switch channel (on channel screen) @@ -863,7 +907,7 @@ void handleKeyboardInput() { break; case '\r': - // Enter = compose (only from channel or contacts screen) + // Select/Enter - if on contacts screen, enter DM compose for chat contacts if (ui_task.isOnContactsScreen()) { ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen(); int idx = cs->getSelectedContactIdx(); @@ -879,21 +923,12 @@ void handleKeyboardInput() { drawComposeScreen(); lastComposeRefresh = millis(); } else if (idx >= 0) { + // Non-chat contact selected (repeater, room, etc.) - future use Serial.printf("Selected non-chat contact type=%d idx=%d\n", ctype, idx); } - } else if (ui_task.isOnChannelScreen()) { - composeDM = false; - composeDMContactIdx = -1; - composeChannelIdx = ui_task.getChannelScreenViewIdx(); - composeMode = true; - composeBuffer[0] = '\0'; - composePos = 0; - Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx); - drawComposeScreen(); - lastComposeRefresh = millis(); } else { - // Other screens: pass Enter as generic select - ui_task.injectKey(13); + Serial.println("Nav: Enter/Select"); + ui_task.injectKey(13); // KEY_ENTER } break; @@ -1059,6 +1094,8 @@ void drawEmojiPicker() { void sendComposedMessage() { if (composePos == 0) return; + cpuPower.setBoost(); // Boost CPU for crypto + radio TX + // Convert escape bytes back to UTF-8 for mesh transmission and BLE app char utf8Buf[512]; emojiUnescape(composeBuffer, utf8Buf, sizeof(utf8Buf)); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ff38ec7..b3e4c14 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2,6 +2,7 @@ #include #include "../MyMesh.h" #include "target.h" +#include "GPSDutyCycle.h" #ifdef WIFI_SSID #include #endif @@ -33,7 +34,6 @@ #include "ChannelScreen.h" #include "ContactsScreen.h" #include "TextReaderScreen.h" -#include "SettingsScreen.h" class SplashScreen : public UIScreen { UITask* _task; @@ -329,21 +329,37 @@ public: display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL); #if ENV_INCLUDE_GPS == 1 } else if (_page == HomePage::GPS) { + extern GPSDutyCycle gpsDuty; + extern GPSStreamCounter gpsStream; LocationProvider* nmea = sensors.getLocationProvider(); char buf[50]; int y = 18; - bool gps_state = _task->getGPSState(); -#ifdef PIN_GPS_SWITCH - bool hw_gps_state = digitalRead(PIN_GPS_SWITCH); - if (gps_state != hw_gps_state) { - strcpy(buf, gps_state ? "gps off(hw)" : "gps off(sw)"); + + // GPS state line with duty cycle info + if (!_node_prefs->gps_enabled) { + strcpy(buf, "gps off"); } else { - strcpy(buf, gps_state ? "gps on" : "gps off"); + switch (gpsDuty.getState()) { + case GPSDutyState::ACQUIRING: { + uint32_t elapsed = gpsDuty.acquireElapsedSecs(); + sprintf(buf, "acquiring %us", (unsigned)elapsed); + break; + } + case GPSDutyState::SLEEPING: { + uint32_t remain = gpsDuty.sleepRemainingSecs(); + if (remain >= 60) { + sprintf(buf, "sleep %um%02us", (unsigned)(remain / 60), (unsigned)(remain % 60)); + } else { + sprintf(buf, "sleep %us", (unsigned)remain); + } + break; + } + default: + strcpy(buf, "gps off"); + } } -#else - strcpy(buf, gps_state ? "gps on" : "gps off"); -#endif display.drawTextLeftAlign(0, y, buf); + if (nmea == NULL) { y = y + 12; display.drawTextLeftAlign(0, y, "Can't access GPS"); @@ -355,6 +371,19 @@ public: sprintf(buf, "%d", nmea->satellitesCount()); display.drawTextRightAlign(display.width()-1, y, buf); y = y + 12; + + // NMEA sentence counter — confirms baud rate and data flow + display.drawTextLeftAlign(0, y, "sentences"); + if (gpsDuty.isHardwareOn()) { + uint16_t sps = gpsStream.getSentencesPerSec(); + uint32_t total = gpsStream.getSentenceCount(); + sprintf(buf, "%u/s (%lu)", sps, (unsigned long)total); + } else { + strcpy(buf, "hw off"); + } + display.drawTextRightAlign(display.width()-1, y, buf); + y = y + 12; + display.drawTextLeftAlign(0, y, "pos"); sprintf(buf, "%.4f %.4f", nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.); @@ -524,6 +553,12 @@ public: if (c == KEY_LEFT || c == KEY_PREV) { _page = (_page + HomePage::Count - 1) % HomePage::Count; + #if ENV_INCLUDE_GPS == 1 + if (_page == HomePage::GPS) { + extern GPSDutyCycle gpsDuty; + gpsDuty.forceWake(); + } + #endif return true; } if (c == KEY_NEXT || c == KEY_RIGHT) { @@ -531,6 +566,12 @@ public: if (_page == HomePage::RECENT) { _task->showAlert("Recent adverts", 800); } + #if ENV_INCLUDE_GPS == 1 + if (_page == HomePage::GPS) { + extern GPSDutyCycle gpsDuty; + gpsDuty.forceWake(); + } + #endif return true; } if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) { @@ -717,7 +758,6 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no channel_screen = new ChannelScreen(this, &rtc_clock); contacts_screen = new ContactsScreen(this, &rtc_clock); text_reader = new TextReaderScreen(this); - settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs); setCurrScreen(splash); } @@ -1037,39 +1077,36 @@ char UITask::handleTripleClick(char c) { } bool UITask::getGPSState() { - if (_sensors != NULL) { - int num = _sensors->getNumSettings(); - for (int i = 0; i < num; i++) { - if (strcmp(_sensors->getSettingName(i), "gps") == 0) { - return !strcmp(_sensors->getSettingValue(i), "1"); - } - } - } - return false; + #if ENV_INCLUDE_GPS == 1 + return _node_prefs != NULL && _node_prefs->gps_enabled; + #else + return false; + #endif } void UITask::toggleGPS() { + #if ENV_INCLUDE_GPS == 1 + extern GPSDutyCycle gpsDuty; + if (_sensors != NULL) { - // toggle GPS on/off - int num = _sensors->getNumSettings(); - for (int i = 0; i < num; i++) { - if (strcmp(_sensors->getSettingName(i), "gps") == 0) { - if (strcmp(_sensors->getSettingValue(i), "1") == 0) { - _sensors->setSettingValue("gps", "0"); - _node_prefs->gps_enabled = 0; - notify(UIEventType::ack); - } else { - _sensors->setSettingValue("gps", "1"); - _node_prefs->gps_enabled = 1; - notify(UIEventType::ack); - } - the_mesh.savePrefs(); - showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800); - _next_refresh = 0; - break; + if (_node_prefs->gps_enabled) { + // Disable GPS — cut hardware power + _sensors->setSettingValue("gps", "0"); + _node_prefs->gps_enabled = 0; + gpsDuty.disable(); + notify(UIEventType::ack); + } else { + // Enable GPS — start duty cycle + _sensors->setSettingValue("gps", "1"); + _node_prefs->gps_enabled = 1; + gpsDuty.enable(); + notify(UIEventType::ack); } + the_mesh.savePrefs(); + showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800); + _next_refresh = 0; } - } + #endif } void UITask::toggleBuzzer() { @@ -1157,26 +1194,6 @@ void UITask::gotoTextReader() { _next_refresh = 100; } -void UITask::gotoSettingsScreen() { - ((SettingsScreen*)settings_screen)->enter(); - setCurrScreen(settings_screen); - if (_display != NULL && !_display->isOn()) { - _display->turnOn(); - } - _auto_off = millis() + AUTO_OFF_MILLIS; - _next_refresh = 100; -} - -void UITask::gotoOnboarding() { - ((SettingsScreen*)settings_screen)->enterOnboarding(); - setCurrScreen(settings_screen); - if (_display != NULL && !_display->isOn()) { - _display->turnOn(); - } - _auto_off = millis() + AUTO_OFF_MILLIS; - _next_refresh = 100; -} - uint8_t UITask::getChannelScreenViewIdx() const { return ((ChannelScreen *) channel_screen)->getViewChannelIdx(); } diff --git a/variants/lilygo_tdeck_pro/CPUPowerManager.h b/variants/lilygo_tdeck_pro/CPUPowerManager.h new file mode 100644 index 0000000..d1f33a5 --- /dev/null +++ b/variants/lilygo_tdeck_pro/CPUPowerManager.h @@ -0,0 +1,70 @@ +#pragma once + +#include + +// CPU Frequency Scaling for ESP32-S3 +// +// Typical current draw (CPU only, rough): +// 240 MHz ~70-80 mA +// 160 MHz ~50-60 mA +// 80 MHz ~30-40 mA +// +// SPI peripherals and UART use their own clock dividers from the APB clock, +// so LoRa, e-ink, and GPS serial all work fine at 80MHz. + +#ifdef ESP32 + +#ifndef CPU_FREQ_IDLE +#define CPU_FREQ_IDLE 80 // MHz — normal mesh listening +#endif + +#ifndef CPU_FREQ_BOOST +#define CPU_FREQ_BOOST 240 // MHz — heavy processing +#endif + +#ifndef CPU_BOOST_TIMEOUT_MS +#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds +#endif + +class CPUPowerManager { +public: + CPUPowerManager() : _boosted(false), _boost_started(0) {} + + void begin() { + setCpuFrequencyMhz(CPU_FREQ_IDLE); + _boosted = false; + MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE); + } + + void loop() { + if (_boosted && (millis() - _boost_started >= CPU_BOOST_TIMEOUT_MS)) { + setIdle(); + } + } + + void setBoost() { + if (!_boosted) { + setCpuFrequencyMhz(CPU_FREQ_BOOST); + _boosted = true; + MESH_DEBUG_PRINTLN("CPU power: boosted to %d MHz", CPU_FREQ_BOOST); + } + _boost_started = millis(); + } + + void setIdle() { + if (_boosted) { + setCpuFrequencyMhz(CPU_FREQ_IDLE); + _boosted = false; + MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE); + } + } + + bool isBoosted() const { return _boosted; } + uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); } + +private: + bool _boosted; + unsigned long _boost_started; +}; + +#endif // ESP32 \ No newline at end of file diff --git a/variants/lilygo_tdeck_pro/GPSDutyCycle.h b/variants/lilygo_tdeck_pro/GPSDutyCycle.h new file mode 100644 index 0000000..49a1e57 --- /dev/null +++ b/variants/lilygo_tdeck_pro/GPSDutyCycle.h @@ -0,0 +1,185 @@ +#pragma once + +#include +#include "variant.h" +#include "GPSStreamCounter.h" + +// GPS Duty Cycle Manager +// Controls the hardware GPS enable pin (PIN_GPS_EN) to save power. +// When enabled, cycles between acquiring a fix and sleeping with power cut. +// +// States: +// OFF – User has disabled GPS. Hardware power is cut. +// ACQUIRING – GPS module powered on, waiting for a fix or timeout. +// SLEEPING – GPS module powered off, timer counting down to next cycle. + +#if HAS_GPS + +// How long to leave GPS powered on while acquiring a fix (ms) +#ifndef GPS_ACQUIRE_TIMEOUT_MS +#define GPS_ACQUIRE_TIMEOUT_MS 60000 // 60 seconds +#endif + +// How long to sleep between acquisition cycles (ms) +#ifndef GPS_SLEEP_DURATION_MS +#define GPS_SLEEP_DURATION_MS 900000 // 15 minutes +#endif + +// If we get a fix quickly, power off immediately but still respect +// a minimum on-time so the RTC can sync properly +#ifndef GPS_MIN_ON_TIME_MS +#define GPS_MIN_ON_TIME_MS 5000 // 5 seconds after fix +#endif + +enum class GPSDutyState : uint8_t { + OFF = 0, // User-disabled, hardware power off + ACQUIRING, // Hardware on, waiting for fix + SLEEPING // Hardware off, timer running +}; + +class GPSDutyCycle { +public: + GPSDutyCycle() : _state(GPSDutyState::OFF), _state_entered(0), + _last_fix_time(0), _got_fix(false), _time_synced(false), + _stream(nullptr) {} + + // Attach the stream counter so we can reset it on power cycles + void setStreamCounter(GPSStreamCounter* stream) { _stream = stream; } + + // Call once in setup() after board.begin() and GPS serial init. + void begin(bool initial_enable) { + if (initial_enable) { + _powerOn(); + _setState(GPSDutyState::ACQUIRING); + } else { + _powerOff(); + _setState(GPSDutyState::OFF); + } + } + + // Call every iteration of loop(). + // Returns true if GPS hardware is currently powered on. + bool loop() { + switch (_state) { + case GPSDutyState::OFF: + return false; + + case GPSDutyState::ACQUIRING: { + unsigned long elapsed = millis() - _state_entered; + + if (_got_fix && elapsed >= GPS_MIN_ON_TIME_MS) { + MESH_DEBUG_PRINTLN("GPS duty: fix acquired, powering off for %u min", + (unsigned)(GPS_SLEEP_DURATION_MS / 60000)); + _powerOff(); + _setState(GPSDutyState::SLEEPING); + return false; + } + + if (elapsed >= GPS_ACQUIRE_TIMEOUT_MS) { + MESH_DEBUG_PRINTLN("GPS duty: acquire timeout (%us), sleeping", + (unsigned)(GPS_ACQUIRE_TIMEOUT_MS / 1000)); + _powerOff(); + _setState(GPSDutyState::SLEEPING); + return false; + } + + return true; + } + + case GPSDutyState::SLEEPING: { + if (millis() - _state_entered >= GPS_SLEEP_DURATION_MS) { + MESH_DEBUG_PRINTLN("GPS duty: waking up for next acquisition cycle"); + _got_fix = false; + _powerOn(); + _setState(GPSDutyState::ACQUIRING); + return true; + } + return false; + } + } + return false; + } + + void notifyFix() { + if (_state == GPSDutyState::ACQUIRING && !_got_fix) { + _got_fix = true; + _last_fix_time = millis(); + MESH_DEBUG_PRINTLN("GPS duty: fix notification received"); + } + } + + void notifyTimeSync() { + _time_synced = true; + } + + void enable() { + if (_state == GPSDutyState::OFF) { + _got_fix = false; + _powerOn(); + _setState(GPSDutyState::ACQUIRING); + MESH_DEBUG_PRINTLN("GPS duty: enabled, starting acquisition"); + } + } + + void disable() { + _powerOff(); + _setState(GPSDutyState::OFF); + _got_fix = false; + MESH_DEBUG_PRINTLN("GPS duty: disabled, power off"); + } + + void forceWake() { + if (_state == GPSDutyState::SLEEPING) { + _got_fix = false; + _powerOn(); + _setState(GPSDutyState::ACQUIRING); + MESH_DEBUG_PRINTLN("GPS duty: forced wake for user request"); + } + } + + GPSDutyState getState() const { return _state; } + bool isHardwareOn() const { return _state == GPSDutyState::ACQUIRING; } + bool hadFix() const { return _got_fix; } + bool hasTimeSynced() const { return _time_synced; } + + uint32_t sleepRemainingSecs() const { + if (_state != GPSDutyState::SLEEPING) return 0; + unsigned long elapsed = millis() - _state_entered; + if (elapsed >= GPS_SLEEP_DURATION_MS) return 0; + return (GPS_SLEEP_DURATION_MS - elapsed) / 1000; + } + + uint32_t acquireElapsedSecs() const { + if (_state != GPSDutyState::ACQUIRING) return 0; + return (millis() - _state_entered) / 1000; + } + +private: + void _powerOn() { + #ifdef PIN_GPS_EN + digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE); + delay(10); + #endif + if (_stream) _stream->resetCounters(); + } + + void _powerOff() { + #ifdef PIN_GPS_EN + digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE); + #endif + } + + void _setState(GPSDutyState s) { + _state = s; + _state_entered = millis(); + } + + GPSDutyState _state; + unsigned long _state_entered; + unsigned long _last_fix_time; + bool _got_fix; + bool _time_synced; + GPSStreamCounter* _stream; +}; + +#endif // HAS_GPS \ No newline at end of file diff --git a/variants/lilygo_tdeck_pro/GPSStreamCounter.h b/variants/lilygo_tdeck_pro/GPSStreamCounter.h new file mode 100644 index 0000000..5992fb7 --- /dev/null +++ b/variants/lilygo_tdeck_pro/GPSStreamCounter.h @@ -0,0 +1,72 @@ +#pragma once + +#include + +// Transparent Stream wrapper that counts NMEA sentences (newline-delimited) +// flowing from the GPS serial port to the MicroNMEA parser. +// +// Usage: Instead of MicroNMEALocationProvider gps(Serial2, &rtc_clock); +// Use: GPSStreamCounter gpsStream(Serial2); +// MicroNMEALocationProvider gps(gpsStream, &rtc_clock); +// +// Every read() call passes through to the underlying stream; when a '\n' +// is seen the sentence counter increments. This lets the UI display a +// live "nmea" count so users can confirm the baud rate is correct and +// the GPS module is actually sending data. + +class GPSStreamCounter : public Stream { +public: + GPSStreamCounter(Stream& inner) + : _inner(inner), _sentences(0), _sentences_snapshot(0), + _last_snapshot(0), _sentences_per_sec(0) {} + + // --- Stream read interface (passes through) --- + int available() override { return _inner.available(); } + int peek() override { return _inner.peek(); } + + int read() override { + int c = _inner.read(); + if (c == '\n') { + _sentences++; + } + return c; + } + + // --- Stream write interface (pass through for NMEA commands if needed) --- + size_t write(uint8_t b) override { return _inner.write(b); } + + // --- Sentence counting API --- + + // Total sentences received since boot (or last reset) + uint32_t getSentenceCount() const { return _sentences; } + + // Sentences received per second (updated each time you call it, + // with a 1-second rolling window) + uint16_t getSentencesPerSec() { + unsigned long now = millis(); + unsigned long elapsed = now - _last_snapshot; + if (elapsed >= 1000) { + uint32_t delta = _sentences - _sentences_snapshot; + // Scale to per-second if interval wasn't exactly 1000ms + _sentences_per_sec = (uint16_t)((delta * 1000UL) / elapsed); + _sentences_snapshot = _sentences; + _last_snapshot = now; + } + return _sentences_per_sec; + } + + // Reset all counters (e.g. when GPS hardware power cycles) + void resetCounters() { + _sentences = 0; + _sentences_snapshot = 0; + _sentences_per_sec = 0; + _last_snapshot = millis(); + } + +private: + Stream& _inner; + volatile uint32_t _sentences; + uint32_t _sentences_snapshot; + unsigned long _last_snapshot; + uint16_t _sentences_per_sec; +}; \ No newline at end of file diff --git a/variants/lilygo_tdeck_pro/target.cpp b/variants/lilygo_tdeck_pro/target.cpp index f8adccd..324c148 100644 --- a/variants/lilygo_tdeck_pro/target.cpp +++ b/variants/lilygo_tdeck_pro/target.cpp @@ -17,7 +17,10 @@ ESP32RTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); #if HAS_GPS - MicroNMEALocationProvider gps(Serial2, &rtc_clock); + // Wrap Serial2 with a sentence counter so the UI can show NMEA throughput. + // MicroNMEALocationProvider reads through this wrapper transparently. + GPSStreamCounter gpsStream(Serial2); + MicroNMEALocationProvider gps(gpsStream, &rtc_clock); EnvironmentSensorManager sensors(gps); #else SensorManager sensors; diff --git a/variants/lilygo_tdeck_pro/target.h b/variants/lilygo_tdeck_pro/target.h index 83747c0..f2b6bd9 100644 --- a/variants/lilygo_tdeck_pro/target.h +++ b/variants/lilygo_tdeck_pro/target.h @@ -18,6 +18,7 @@ #if HAS_GPS #include "helpers/sensors/EnvironmentSensorManager.h" #include "helpers/sensors/MicroNMEALocationProvider.h" + #include "GPSStreamCounter.h" #else #include #endif @@ -27,6 +28,7 @@ extern WRAPPER_CLASS radio_driver; extern AutoDiscoverRTCClock rtc_clock; #if HAS_GPS + extern GPSStreamCounter gpsStream; extern EnvironmentSensorManager sensors; #else extern SensorManager sensors;