diff --git a/src/config/Config.h b/src/config/Config.h index 7337c16..babeadf 100644 --- a/src/config/Config.h +++ b/src/config/Config.h @@ -19,7 +19,7 @@ #define HAS_BLE true #define HAS_SD true #define HAS_AUDIO true -#define HAS_GPS false // Deprioritized +#define HAS_GPS true // UBlox MIA-M10Q UART GPS // --- WiFi Defaults --- #define WIFI_AP_PORT 4242 diff --git a/src/config/UserConfig.cpp b/src/config/UserConfig.cpp index c29be4e..c0479b5 100644 --- a/src/config/UserConfig.cpp +++ b/src/config/UserConfig.cpp @@ -55,6 +55,11 @@ bool UserConfig::parseJson(const String& json) { _settings.touchSensitivity = doc["touch_sens"] | 3; _settings.bleEnabled = doc["ble_enabled"] | false; + _settings.gpsTimeEnabled = doc["gps_time"] | true; + _settings.gpsLocationEnabled = doc["gps_location"] | false; + _settings.utcOffset = doc["utc_offset"] | (int)-5; + _settings.use24HourTime = doc["time_24h"] | false; + _settings.audioEnabled = doc["audio_on"] | true; _settings.audioVolume = doc["audio_vol"] | 80; @@ -98,6 +103,11 @@ String UserConfig::serializeToJson() const { doc["touch_sens"] = _settings.touchSensitivity; doc["ble_enabled"] = _settings.bleEnabled; + doc["gps_time"] = _settings.gpsTimeEnabled; + doc["gps_location"] = _settings.gpsLocationEnabled; + doc["utc_offset"] = _settings.utcOffset; + doc["time_24h"] = _settings.use24HourTime; + doc["audio_on"] = _settings.audioEnabled; doc["audio_vol"] = _settings.audioVolume; diff --git a/src/config/UserConfig.h b/src/config/UserConfig.h index d253f35..ed39ec8 100644 --- a/src/config/UserConfig.h +++ b/src/config/UserConfig.h @@ -50,6 +50,12 @@ struct UserSettings { // BLE bool bleEnabled = false; + // GPS & Time + bool gpsTimeEnabled = true; // GPS time sync (default ON) + bool gpsLocationEnabled = false; // GPS position tracking (default OFF, user must opt in) + int8_t utcOffset = -5; // Hours from UTC (default EST) + bool use24HourTime = false; // false = 12h (no AM/PM), true = 24h + // Audio bool audioEnabled = true; uint8_t audioVolume = 80; // 0-100 diff --git a/src/hal/GPSManager.cpp b/src/hal/GPSManager.cpp new file mode 100644 index 0000000..d10275e --- /dev/null +++ b/src/hal/GPSManager.cpp @@ -0,0 +1,218 @@ +#include "GPSManager.h" + +#if HAS_GPS + +#include +#include + +constexpr uint32_t GPSManager::BAUD_RATES[]; + +void GPSManager::begin() { + if (_running) return; + + // Restore last-known time from NVS on boot + restoreTimeFromNVS(); + + // Start baud rate auto-detection: try first rate + _baudDetected = false; + _baudAttemptIdx = 0; + _baudAttemptStart = millis(); + _serial.begin(BAUD_RATES[0], SERIAL_8N1, GPS_RX, GPS_TX); + Serial.printf("[GPS] UART started, trying %lu baud (GPIO RX=%d TX=%d)\n", + (unsigned long)BAUD_RATES[0], GPS_RX, GPS_TX); + _running = true; +} + +void GPSManager::loop() { + if (!_running) return; + + uint32_t prevSentences = _parser.sentencesParsed(); + + // Read available bytes (up to 64 per call to stay non-blocking) + int count = 0; + while (_serial.available() && count < 64) { + char c = _serial.read(); + _parser.feed(c); + count++; + } + + // Baud rate auto-detection: if no valid sentence after timeout, try next rate + if (!_baudDetected) { + if (_parser.sentencesParsed() > 0) { + _baudDetected = true; + _detectedBaud = BAUD_RATES[_baudAttemptIdx]; + Serial.printf("[GPS] Baud detected: %lu (%lu sentences parsed)\n", + (unsigned long)_detectedBaud, (unsigned long)_parser.sentencesParsed()); + } else if (millis() - _baudAttemptStart >= BAUD_DETECT_TIMEOUT_MS) { + _baudAttemptIdx++; + if (_baudAttemptIdx >= BAUD_RATE_COUNT) { + // Exhausted all baud rates — keep last one active, will retry on next begin() + Serial.println("[GPS] No valid NMEA data at any baud rate"); + _baudDetected = true; // Stop retrying + _detectedBaud = BAUD_RATES[BAUD_RATE_COUNT - 1]; + } else { + _serial.end(); + delay(10); + _serial.begin(BAUD_RATES[_baudAttemptIdx], SERIAL_8N1, GPS_RX, GPS_TX); + _baudAttemptStart = millis(); + Serial.printf("[GPS] Trying %lu baud...\n", + (unsigned long)BAUD_RATES[_baudAttemptIdx]); + } + } + } + + // Check for new time data + NMEAData& d = _parser.data(); + if (d.timeUpdated) { + d.timeUpdated = false; + _lastFixMs = millis(); + if (d.timeValid && (millis() - _lastTimeSyncMs >= TIME_SYNC_INTERVAL_MS || _timeSyncCount == 0)) { + syncSystemTime(); + } + } + + // Check for new location data + if (d.locationUpdated) { + d.locationUpdated = false; + _locationValid = d.locationValid; + _lastFixMs = millis(); + + // Periodic NVS persist + if (_locationValid && (millis() - _lastPersistMs >= PERSIST_INTERVAL_MS)) { + persistToNVS(); + _lastPersistMs = millis(); + } + } + + // Stale fix detection + if (_timeValid && (millis() - _lastFixMs >= FIX_TIMEOUT_MS)) { + // Don't clear _timeValid — system clock is still set and accurate. + // Only clear location validity since position may have changed. + _locationValid = false; + } +} + +void GPSManager::stop() { + if (!_running) return; + + // Persist current data before stopping + if (_timeValid || _locationValid) { + persistToNVS(); + } + + _serial.end(); + _running = false; + _baudDetected = false; + _locationValid = false; + Serial.println("[GPS] Stopped"); +} + +uint32_t GPSManager::fixAgeMs() const { + if (_lastFixMs == 0) return UINT32_MAX; + return millis() - _lastFixMs; +} + +void GPSManager::syncSystemTime() { + const NMEAData& d = _parser.data(); + if (!d.timeValid) return; + + // Sanity check year range (reject spoofed/garbage data) + if (d.year < 2024 || d.year > 2030) { + Serial.printf("[GPS] Rejected time: year %d out of range\n", d.year); + return; + } + + // Convert GPS UTC date/time to Unix epoch using arithmetic + // (avoids mktime() TZ contamination and timegm() unavailability on ESP32) + auto daysFromCivil = [](int y, int m, int d) -> int32_t { + // Converts civil date to days since 1970-01-01 (Howard Hinnant algorithm) + y -= (m <= 2); + int era = (y >= 0 ? y : y - 399) / 400; + unsigned yoe = (unsigned)(y - era * 400); + unsigned doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; + unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097 + (int32_t)doe - 719468; + }; + + int32_t days = daysFromCivil(d.year, d.month, d.day); + time_t epoch = (time_t)days * 86400 + d.hour * 3600 + d.minute * 60 + d.second; + if (epoch < 1700000000) { + Serial.printf("[GPS] Rejected time: epoch %ld too low\n", (long)epoch); + return; + } + + struct timeval tv = {}; + tv.tv_sec = epoch; + tv.tv_usec = 0; + settimeofday(&tv, nullptr); + + _timeValid = true; + _lastTimeSyncMs = millis(); + _timeSyncCount++; + + // Set TZ from utcOffset so localtime() works correctly + // POSIX TZ: "UTC" where offset sign is inverted + // e.g., UTC-5 (EST) → "UTC5" in POSIX notation + char tz[16]; + snprintf(tz, sizeof(tz), "UTC%d", -_utcOffset); + setenv("TZ", tz, 1); + tzset(); + + Serial.printf("[GPS] System time synced: %04d-%02d-%02d %02d:%02d:%02d UTC (sync #%lu)\n", + d.year, d.month, d.day, d.hour, d.minute, d.second, + (unsigned long)_timeSyncCount); +} + +void GPSManager::restoreTimeFromNVS() { + Preferences prefs; + if (!prefs.begin(NVS_NAMESPACE, true)) return; // read-only + + int64_t storedEpoch = prefs.getLong64("epoch", 0); + prefs.end(); + + if (storedEpoch > 1700000000) { + struct timeval tv = {}; + tv.tv_sec = (time_t)storedEpoch; + tv.tv_usec = 0; + settimeofday(&tv, nullptr); + + // Set TZ + char tz[16]; + snprintf(tz, sizeof(tz), "UTC%d", -_utcOffset); + setenv("TZ", tz, 1); + tzset(); + + time_t now = time(nullptr); + struct tm* local = localtime(&now); + if (local) { + Serial.printf("[GPS] Restored time from NVS: %04d-%02d-%02d %02d:%02d (stale, approximate)\n", + local->tm_year + 1900, local->tm_mon + 1, local->tm_mday, + local->tm_hour, local->tm_min); + } + // Note: _timeValid stays false — this is approximate/stale time. + // The system clock is set so timestamps work, but the GPS indicator + // won't show as "fixed" until a real satellite fix arrives. + } +} + +void GPSManager::persistToNVS() { + time_t now = time(nullptr); + if (now < 1700000000) return; // Don't persist if we don't have real time + + Preferences prefs; + if (!prefs.begin(NVS_NAMESPACE, false)) return; // read-write + + prefs.putLong64("epoch", (int64_t)now); + + const NMEAData& d = _parser.data(); + if (d.locationValid) { + // Store as integers (microdegrees) to avoid float NVS issues + prefs.putLong("lat_ud", (int32_t)(d.latitude * 1000000.0)); + prefs.putLong("lon_ud", (int32_t)(d.longitude * 1000000.0)); + prefs.putLong("alt_cm", (int32_t)(d.altitude * 100.0)); + } + + prefs.end(); +} + +#endif // HAS_GPS diff --git a/src/hal/GPSManager.h b/src/hal/GPSManager.h new file mode 100644 index 0000000..0ee8b53 --- /dev/null +++ b/src/hal/GPSManager.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include "config/Config.h" + +#if HAS_GPS + +#include +#include +#include "hal/NMEAParser.h" +#include "config/BoardConfig.h" + +class GPSManager { +public: + void begin(); + void loop(); // Non-blocking: reads available UART bytes, feeds parser + void stop(); // Disable UART, clear state + + // Time + bool hasTimeFix() const { return _timeValid; } + + // Location + bool hasLocationFix() const { return _locationValid; } + double latitude() const { return _parser.data().latitude; } + double longitude() const { return _parser.data().longitude; } + double altitude() const { return _parser.data().altitude; } + int satellites() const { return _parser.data().satellites; } + double hdop() const { return _parser.data().hdop; } + uint8_t fixQuality() const { return _parser.data().fixQuality; } + + // Diagnostics + uint32_t charsProcessed() const { return _parser.charsProcessed(); } + uint32_t sentencesParsed() const { return _parser.sentencesParsed(); } + uint32_t fixAgeMs() const; + uint32_t timeSyncCount() const { return _timeSyncCount; } + bool isRunning() const { return _running; } + + // Configuration (set from outside before or after begin()) + void setUTCOffset(int8_t offset) { _utcOffset = offset; } + void setLocationEnabled(bool enable) { _parser.setParseLocation(enable); } + +private: + void syncSystemTime(); + void restoreTimeFromNVS(); + void persistToNVS(); + bool tryBaudRate(uint32_t baud); + + NMEAParser _parser; + HardwareSerial _serial{2}; // UART2 on ESP32-S3 + bool _running = false; + bool _timeValid = false; + bool _locationValid = false; + unsigned long _lastTimeSyncMs = 0; + unsigned long _lastFixMs = 0; + unsigned long _lastPersistMs = 0; + uint32_t _timeSyncCount = 0; + int8_t _utcOffset = -5; + uint32_t _detectedBaud = 0; + + // Baud auto-detect state + bool _baudDetected = false; + int _baudAttemptIdx = 0; + unsigned long _baudAttemptStart = 0; + static constexpr uint32_t BAUD_RATES[] = {115200, 38400, 9600}; + static constexpr int BAUD_RATE_COUNT = 3; + static constexpr unsigned long BAUD_DETECT_TIMEOUT_MS = 10000; + + static constexpr unsigned long TIME_SYNC_INTERVAL_MS = 60000; // Re-sync every 60s + static constexpr unsigned long PERSIST_INTERVAL_MS = 300000; // Persist to NVS every 5 min + static constexpr unsigned long FIX_TIMEOUT_MS = 10000; // Fix stale after 10s + + static constexpr const char* NVS_NAMESPACE = "gps"; +}; + +#endif // HAS_GPS diff --git a/src/hal/NMEAParser.h b/src/hal/NMEAParser.h new file mode 100644 index 0000000..364c40e --- /dev/null +++ b/src/hal/NMEAParser.h @@ -0,0 +1,248 @@ +#pragma once + +// ============================================================================= +// NMEAParser — Zero-dependency NMEA 0183 sentence parser +// Parses $GNRMC/$GPRMC (time, date, position) and $GNGGA/$GPGGA (satellites, +// altitude, HDOP). Character-by-character state machine with XOR checksum +// validation. No heap allocation. +// ============================================================================= + +#include +#include +#include +#include + +struct NMEAData { + // Time (from RMC) + uint8_t hour = 0, minute = 0, second = 0; + uint16_t year = 0; + uint8_t month = 0, day = 0; + bool timeValid = false; + + // Position (from RMC + GGA) + double latitude = 0.0; // decimal degrees, negative = south + double longitude = 0.0; // decimal degrees, negative = west + double altitude = 0.0; // meters above MSL (from GGA) + bool locationValid = false; + + // Satellites / fix (from GGA) + uint8_t satellites = 0; + double hdop = 99.9; + uint8_t fixQuality = 0; // 0=none, 1=GPS, 2=DGPS, 6=estimated + + // Updated flags — set per-parse, cleared by caller + bool timeUpdated = false; + bool locationUpdated = false; +}; + +class NMEAParser { +public: + // Feed one character at a time. Returns true when a valid sentence was parsed. + bool encode(char c) { + if (c == '$') { + // Start of new sentence + _pos = 0; + _checksum = 0; + _checksumming = true; + _complete = false; + return false; + } + + if (_pos >= SENTENCE_MAX - 1) { + // Overflow — discard + _pos = 0; + return false; + } + + if (c == '*') { + // End of data, next 2 chars are checksum hex + _sentence[_pos] = '\0'; + _checksumming = false; + _checksumIdx = 0; + _receivedChecksum = 0; + return false; + } + + if (!_checksumming && _checksumIdx < 2) { + // Collecting checksum hex digits + _receivedChecksum = (_receivedChecksum << 4) | hexVal(c); + _checksumIdx++; + if (_checksumIdx == 2) { + // Validate checksum + if (_receivedChecksum == _checksum) { + return parseSentence(); + } + } + return false; + } + + if (c == '\r' || c == '\n') { + _pos = 0; + return false; + } + + if (_checksumming) { + _sentence[_pos++] = c; + _checksum ^= (uint8_t)c; + } + + return false; + } + + const NMEAData& data() const { return _data; } + NMEAData& data() { return _data; } + + // When false, position fields (lat/lon/alt/sats/hdop) are not parsed + void setParseLocation(bool enable) { _parseLocation = enable; } + bool parseLocation() const { return _parseLocation; } + + uint32_t sentencesParsed() const { return _sentencesParsed; } + uint32_t charsProcessed() const { return _charsProcessed; } + + // Call encode() and also track total chars for diagnostics + bool feed(char c) { + _charsProcessed++; + return encode(c); + } + +private: + static constexpr int SENTENCE_MAX = 96; + static constexpr int MAX_FIELDS = 20; + + char _sentence[SENTENCE_MAX] = {}; + int _pos = 0; + uint8_t _checksum = 0; + bool _checksumming = false; + uint8_t _receivedChecksum = 0; + int _checksumIdx = 0; + bool _complete = false; + + NMEAData _data; + bool _parseLocation = false; // Off by default — user must opt in + uint32_t _sentencesParsed = 0; + uint32_t _charsProcessed = 0; + + static uint8_t hexVal(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + return 0; + } + + // Split _sentence by commas into field pointers. Returns field count. + int splitFields(char* fields[], int maxFields) { + int count = 0; + char* p = _sentence; + fields[count++] = p; + while (*p && count < maxFields) { + if (*p == ',') { + *p = '\0'; + fields[count++] = p + 1; + } + p++; + } + return count; + } + + bool parseSentence() { + char* fields[MAX_FIELDS]; + int n = splitFields(fields, MAX_FIELDS); + if (n < 1) return false; + + // Identify sentence type (skip talker ID prefix: GP, GN, GL, GA, GB) + const char* type = fields[0]; + const char* suffix = type; + if (strlen(type) >= 5) { + suffix = type + 2; // Skip "GN", "GP", etc. + } + + bool parsed = false; + if (strcmp(suffix, "RMC") == 0 && n >= 10) { + parsed = parseRMC(fields, n); + } else if (strcmp(suffix, "GGA") == 0 && n >= 10) { + parsed = parseGGA(fields, n); + } + + if (parsed) _sentencesParsed++; + return parsed; + } + + // $xxRMC — Recommended Minimum + // fields: type,time,status,lat,N/S,lon,E/W,speed,course,date,magvar,E/W,mode + bool parseRMC(char* fields[], int n) { + // Status: A=active/valid, V=void + if (fields[2][0] != 'A') { + _data.timeValid = false; + _data.locationValid = false; + return true; // Parsed successfully, just no fix + } + + // Time: HHMMSS.ss + if (strlen(fields[1]) >= 6) { + _data.hour = (fields[1][0] - '0') * 10 + (fields[1][1] - '0'); + _data.minute = (fields[1][2] - '0') * 10 + (fields[1][3] - '0'); + _data.second = (fields[1][4] - '0') * 10 + (fields[1][5] - '0'); + } + + // Date: DDMMYY + if (n >= 10 && strlen(fields[9]) >= 6) { + _data.day = (fields[9][0] - '0') * 10 + (fields[9][1] - '0'); + _data.month = (fields[9][2] - '0') * 10 + (fields[9][3] - '0'); + int yy = (fields[9][4] - '0') * 10 + (fields[9][5] - '0'); + _data.year = 2000 + yy; + _data.timeValid = true; + _data.timeUpdated = true; + } + + // Position — only parse if location tracking is enabled + if (_parseLocation) { + // Latitude: DDMM.MMMM,N/S + if (strlen(fields[3]) > 0 && strlen(fields[4]) > 0) { + _data.latitude = parseCoord(fields[3]); + if (fields[4][0] == 'S') _data.latitude = -_data.latitude; + } + + // Longitude: DDDMM.MMMM,E/W + if (strlen(fields[5]) > 0 && strlen(fields[6]) > 0) { + _data.longitude = parseCoord(fields[5]); + if (fields[6][0] == 'W') _data.longitude = -_data.longitude; + _data.locationValid = true; + _data.locationUpdated = true; + } + } + + return true; + } + + // $xxGGA — Global Positioning System Fix Data + // fields: type,time,lat,N/S,lon,E/W,quality,sats,hdop,alt,M,geoid,M,age,station + bool parseGGA(char* fields[], int n) { + if (!_parseLocation) return true; // Skip entirely when location disabled + + // Fix quality + _data.fixQuality = atoi(fields[6]); + + // Satellites + _data.satellites = atoi(fields[7]); + + // HDOP + if (strlen(fields[8]) > 0) { + _data.hdop = atof(fields[8]); + } + + // Altitude above MSL + if (n >= 10 && strlen(fields[9]) > 0) { + _data.altitude = atof(fields[9]); + } + + return true; + } + + // Parse NMEA coordinate: DDDMM.MMMM → decimal degrees + static double parseCoord(const char* s) { + double raw = atof(s); + int degrees = (int)(raw / 100.0); + double minutes = raw - (degrees * 100.0); + return degrees + minutes / 60.0; + } +}; diff --git a/src/main.cpp b/src/main.cpp index 1f724fe..6a45288 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -15,6 +15,9 @@ #include "hal/Trackball.h" #include "hal/Keyboard.h" #include "hal/Power.h" +#if HAS_GPS +#include "hal/GPSManager.h" +#endif #include "radio/SX1262.h" #include "input/InputManager.h" #include "input/HotkeyManager.h" @@ -89,6 +92,9 @@ UserConfig userConfig; Power powerMgr; AudioNotify audio; IdentityManager identityMgr; +#if HAS_GPS +GPSManager gps; +#endif // --- LVGL Screens --- LvBootScreen lvBootScreen; @@ -636,6 +642,17 @@ void setup() { powerMgr.setOffTimeout(userConfig.settings().screenOffTimeout); powerMgr.setBrightness(userConfig.settings().brightness); + // Step 24.5: GPS init +#if HAS_GPS + if (userConfig.settings().gpsTimeEnabled) { + lvBootScreen.setProgress(0.93f, "Starting GPS..."); + gps.setUTCOffset(userConfig.settings().utcOffset); + gps.setLocationEnabled(userConfig.settings().gpsLocationEnabled); + gps.begin(); + Serial.println("[BOOT] GPS UART started (MIA-M10Q)"); + } +#endif + // Step 25: Audio init lvBootScreen.setProgress(0.94f, "Audio..."); // (LVGL boot renders via lv_timer_handler in setProgress) @@ -734,6 +751,20 @@ void setup() { reloadTCPClients(); if (announceManager) announceManager->clearTransientNodes(); }); +#if HAS_GPS + lvSettingsScreen.setGPSChangeCallback([](bool timeEnabled) { + if (timeEnabled) { + gps.setUTCOffset(userConfig.settings().utcOffset); + gps.setLocationEnabled(userConfig.settings().gpsLocationEnabled); + gps.begin(); + Serial.println("[GPS] Time enabled via settings"); + } else { + gps.stop(); + ui.lvStatusBar().setGPSFix(false); + Serial.println("[GPS] Disabled via settings"); + } + }); +#endif // LVGL help overlay lvHelpOverlay.create(); @@ -925,9 +956,14 @@ void loop() { ui.lvStatusBar().setWiFiActive(true); Serial.printf("[WIFI] STA connected: %s\n", WiFi.localIP().toString().c_str()); - // NTP time sync - configTzTime("UTC5", "pool.ntp.org", "time.nist.gov"); - Serial.println("[NTP] Time sync started (UTC-5)"); + // NTP time sync (configurable UTC offset) + { + int8_t off = userConfig.settings().utcOffset; + char tz[16]; + snprintf(tz, sizeof(tz), "UTC%d", -off); + configTzTime(tz, "pool.ntp.org", "time.nist.gov"); + Serial.printf("[NTP] Time sync started (UTC%+d, TZ=%s)\n", off, tz); + } // Recreate TCP clients on every WiFi connect (old clients may have stale sockets) reloadTCPClients(); @@ -984,6 +1020,13 @@ void loop() { bleInterface.loop(); bleSideband.loop(); + // 9.5. GPS poll (non-blocking, reads available UART bytes) +#if HAS_GPS + if (userConfig.settings().gpsTimeEnabled) { + gps.loop(); + } +#endif + // 10. Power management powerMgr.loop(); @@ -999,6 +1042,14 @@ void loop() { if (tcp && tcp->isConnected()) { anyTcpUp = true; break; } } ui.lvStatusBar().setTCPConnected(anyTcpUp); +#if HAS_GPS + if (userConfig.settings().gpsTimeEnabled) { + ui.lvStatusBar().setGPSFix(gps.hasTimeFix()); + } +#endif + // Update clock display (shows time from any valid source: GPS, NTP, etc.) + ui.lvStatusBar().setUse24Hour(userConfig.settings().use24HourTime); + ui.lvStatusBar().updateTime(); ui.update(); } } @@ -1054,6 +1105,16 @@ void loop() { (int)ifaces.size(), tcpUp, (int)tcpClients.size(), wifiSTAConnected ? "STA" : (wifiImpl ? "AP" : "OFF")); } +#if HAS_GPS + if (userConfig.settings().gpsTimeEnabled) { + Serial.printf("[GPS] sats=%d timeFix=%s locFix=%s syncs=%lu chars=%lu\n", + gps.satellites(), + gps.hasTimeFix() ? "YES" : "NO", + gps.hasLocationFix() ? "YES" : "NO", + (unsigned long)gps.timeSyncCount(), + (unsigned long)gps.charsProcessed()); + } +#endif maxLoopTime = 0; } } diff --git a/src/ui/LvStatusBar.cpp b/src/ui/LvStatusBar.cpp index 4453fe5..48e902b 100644 --- a/src/ui/LvStatusBar.cpp +++ b/src/ui/LvStatusBar.cpp @@ -1,6 +1,7 @@ #include "LvStatusBar.h" #include "Theme.h" #include +#include void LvStatusBar::create(lv_obj_t* parent) { _bar = lv_obj_create(parent); @@ -17,29 +18,27 @@ void LvStatusBar::create(lv_obj_t* parent) { const lv_font_t* font = &lv_font_montserrat_12; - // Left side: Signal bars (3 bars, increasing height) - static const int barW = 4; - static const int barH[] = {6, 10, 14}; - static const int barGap = 2; - for (int i = 0; i < 3; i++) { - _bars[i] = lv_obj_create(_bar); - lv_obj_set_size(_bars[i], barW, barH[i]); - lv_obj_set_style_radius(_bars[i], 1, 0); - lv_obj_set_style_bg_opa(_bars[i], LV_OPA_COVER, 0); - lv_obj_set_style_border_width(_bars[i], 0, 0); - lv_obj_set_style_pad_all(_bars[i], 0, 0); - int x = 4 + i * (barW + barGap); - int y = Theme::STATUS_BAR_H - barH[i] - 3; // bottom-aligned - lv_obj_set_pos(_bars[i], x, y); - } + // Left: Time display (hidden until valid time is available) + _lblTime = lv_label_create(_bar); + lv_obj_set_style_text_font(_lblTime, font, 0); + lv_obj_set_style_text_color(_lblTime, lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_text(_lblTime, ""); + lv_obj_align(_lblTime, LV_ALIGN_LEFT_MID, 4, 0); - // Center: "Ratspeak" + // Center: "Ratspeak.org" _lblBrand = lv_label_create(_bar); lv_obj_set_style_text_font(_lblBrand, font, 0); lv_obj_set_style_text_color(_lblBrand, lv_color_hex(Theme::ACCENT), 0); - lv_label_set_text(_lblBrand, "Ratspeak"); + lv_label_set_text(_lblBrand, "Ratspeak.org"); lv_obj_align(_lblBrand, LV_ALIGN_CENTER, 0, 0); + // Right: GPS indicator (left of battery, hidden until fix) + _lblGPS = lv_label_create(_bar); + lv_obj_set_style_text_font(_lblGPS, font, 0); + lv_obj_set_style_text_color(_lblGPS, lv_color_hex(Theme::PRIMARY), 0); + lv_label_set_text(_lblGPS, ""); + lv_obj_align(_lblGPS, LV_ALIGN_RIGHT_MID, -42, 0); + // Right: Battery % _lblBatt = lv_label_create(_bar); lv_obj_set_style_text_font(_lblBatt, font, 0); @@ -62,16 +61,12 @@ void LvStatusBar::create(lv_obj_t* parent) { lv_obj_set_style_text_color(_lblToast, lv_color_hex(Theme::BG), 0); lv_obj_center(_lblToast); lv_label_set_text(_lblToast, ""); - - // Set initial indicator colors - refreshIndicators(); } void LvStatusBar::update() { // Handle announce flash timeout if (_announceFlashEnd > 0 && millis() >= _announceFlashEnd) { _announceFlashEnd = 0; - refreshIndicators(); } // Handle toast timeout @@ -81,24 +76,39 @@ void LvStatusBar::update() { } } -void LvStatusBar::setLoRaOnline(bool online) { - _loraOnline = online; - refreshIndicators(); +void LvStatusBar::updateTime() { + if (!_lblTime) return; + + time_t now = time(nullptr); + if (now <= 1700000000) { + // No valid time yet — show nothing + lv_label_set_text(_lblTime, ""); + return; + } + + struct tm* local = localtime(&now); + if (!local) { + lv_label_set_text(_lblTime, ""); + return; + } + + char buf[8]; + if (_use24h) { + snprintf(buf, sizeof(buf), "%02d:%02d", local->tm_hour, local->tm_min); + } else { + int h = local->tm_hour % 12; + if (h == 0) h = 12; + snprintf(buf, sizeof(buf), "%d:%02d", h, local->tm_min); + } + lv_label_set_text(_lblTime, buf); } -void LvStatusBar::setBLEActive(bool active) { - _bleActive = active; - refreshIndicators(); -} - -void LvStatusBar::setWiFiActive(bool active) { - _wifiActive = active; - refreshIndicators(); -} - -void LvStatusBar::setTCPConnected(bool connected) { - _tcpConnected = connected; - refreshIndicators(); +void LvStatusBar::setGPSFix(bool hasFix) { + if (_gpsFix == hasFix) return; + _gpsFix = hasFix; + if (_lblGPS) { + lv_label_set_text(_lblGPS, hasFix ? "GPS" : ""); + } } void LvStatusBar::setBatteryPercent(int pct) { @@ -121,7 +131,6 @@ void LvStatusBar::setTransportMode(const char* mode) { void LvStatusBar::flashAnnounce() { _announceFlashEnd = millis() + 1000; - refreshIndicators(); } void LvStatusBar::showToast(const char* msg, uint32_t durationMs) { @@ -129,25 +138,3 @@ void LvStatusBar::showToast(const char* msg, uint32_t durationMs) { _toastEnd = millis() + durationMs; lv_obj_clear_flag(_toast, LV_OBJ_FLAG_HIDDEN); } - -void LvStatusBar::refreshIndicators() { - // Bar 0 (short): LoRa — green=online, red=offline - if (_bars[0]) { - uint32_t col = _loraOnline ? Theme::PRIMARY : Theme::ERROR_CLR; - lv_obj_set_style_bg_color(_bars[0], lv_color_hex(col), 0); - } - // Bar 1 (medium): WiFi — green=connected, yellow=enabled but not connected, red=off - if (_bars[1]) { - uint32_t col = Theme::ERROR_CLR; - if (_wifiActive) col = Theme::PRIMARY; - else if (_wifiEnabled) col = Theme::WARNING_CLR; - lv_obj_set_style_bg_color(_bars[1], lv_color_hex(col), 0); - } - // Bar 2 (tall): TCP — green=connected, yellow=WiFi up but TCP down, red=off - if (_bars[2]) { - uint32_t col = Theme::ERROR_CLR; - if (_tcpConnected) col = Theme::PRIMARY; - else if (_wifiActive) col = Theme::WARNING_CLR; - lv_obj_set_style_bg_color(_bars[2], lv_color_hex(col), 0); - } -} diff --git a/src/ui/LvStatusBar.h b/src/ui/LvStatusBar.h index 077c86f..fe5ca09 100644 --- a/src/ui/LvStatusBar.h +++ b/src/ui/LvStatusBar.h @@ -8,27 +8,31 @@ public: void create(lv_obj_t* parent); void update(); - // Status indicators: green=active, yellow=enabled-but-unconfigured, red=off - void setLoRaOnline(bool online); - void setBLEActive(bool active); - void setBLEEnabled(bool enabled) { _bleEnabled = enabled; refreshIndicators(); } - void setWiFiActive(bool active); - void setWiFiEnabled(bool enabled) { _wifiEnabled = enabled; refreshIndicators(); } - void setTCPConnected(bool connected); + // Status setters (kept for API compatibility, no bars drawn) + void setLoRaOnline(bool online) { _loraOnline = online; } + void setBLEActive(bool active) { _bleActive = active; } + void setBLEEnabled(bool enabled) { _bleEnabled = enabled; } + void setWiFiActive(bool active) { _wifiActive = active; } + void setWiFiEnabled(bool enabled) { _wifiEnabled = enabled; } + void setTCPConnected(bool connected) { _tcpConnected = connected; } + void setGPSFix(bool hasFix); void setBatteryPercent(int pct); void setTransportMode(const char* mode); void flashAnnounce(); void showToast(const char* msg, uint32_t durationMs = 1500); + // Time display + void setUse24Hour(bool use24h) { _use24h = use24h; } + void updateTime(); // Call at 1 Hz to refresh clock + lv_obj_t* obj() { return _bar; } private: - void refreshIndicators(); - lv_obj_t* _bar = nullptr; - lv_obj_t* _bars[3] = {}; - lv_obj_t* _lblBrand = nullptr; - lv_obj_t* _lblBatt = nullptr; + lv_obj_t* _lblTime = nullptr; // Top-left: current time + lv_obj_t* _lblBrand = nullptr; // Center: "Ratspeak.org" + lv_obj_t* _lblGPS = nullptr; // Right: GPS indicator + lv_obj_t* _lblBatt = nullptr; // Right: battery % lv_obj_t* _toast = nullptr; lv_obj_t* _lblToast = nullptr; @@ -38,6 +42,8 @@ private: bool _wifiActive = false; bool _wifiEnabled = false; bool _tcpConnected = false; + bool _gpsFix = false; + bool _use24h = false; int _battPct = -1; unsigned long _announceFlashEnd = 0; unsigned long _toastEnd = 0; diff --git a/src/ui/screens/LvSettingsScreen.cpp b/src/ui/screens/LvSettingsScreen.cpp index 8b77c0e..19272d7 100644 --- a/src/ui/screens/LvSettingsScreen.cpp +++ b/src/ui/screens/LvSettingsScreen.cpp @@ -368,6 +368,37 @@ void LvSettingsScreen::buildItems() { return String(modes[constrain((int)s.wifiMode, 0, 2)]); }}); + // GPS & Time + int gpsStart = idx; +#if HAS_GPS + _items.push_back({"GPS Time", SettingType::TOGGLE, + [&s]() { return s.gpsTimeEnabled ? 1 : 0; }, + [&s](int v) { s.gpsTimeEnabled = (v != 0); }, + [](int v) { return v ? String("ON") : String("OFF"); }}); + idx++; + _items.push_back({"GPS Location", SettingType::TOGGLE, + [&s]() { return s.gpsLocationEnabled ? 1 : 0; }, + [&s](int v) { s.gpsLocationEnabled = (v != 0); }, + [](int v) { return v ? String("ON") : String("OFF"); }}); + idx++; +#endif + _items.push_back({"UTC Offset", SettingType::INTEGER, + [&s]() { return (int)s.utcOffset; }, [&s](int v) { s.utcOffset = (int8_t)v; }, + [](int v) { char buf[8]; snprintf(buf, sizeof(buf), "UTC%+d", v); return String(buf); }, + -12, 14, 1}); + idx++; + _items.push_back({"24h Time", SettingType::TOGGLE, + [&s]() { return s.use24HourTime ? 1 : 0; }, + [&s](int v) { s.use24HourTime = (v != 0); }, + [](int v) { return v ? String("ON") : String("OFF"); }}); + idx++; + _categories.push_back({"GPS/Time", gpsStart, idx - gpsStart, + [&s]() { + char buf[16]; + snprintf(buf, sizeof(buf), "UTC%+d", s.utcOffset); + return String(buf); + }}); + // Audio int audioStart = idx; _items.push_back({"Audio", SettingType::TOGGLE, @@ -1133,6 +1164,7 @@ void LvSettingsScreen::snapshotRebootSettings() { _rebootSnap.wifiSTASSID = s.wifiSTASSID; _rebootSnap.wifiSTAPassword = s.wifiSTAPassword; _rebootSnap.bleEnabled = s.bleEnabled; + _gpsSnapEnabled = s.gpsTimeEnabled; } bool LvSettingsScreen::rebootSettingsChanged() const { @@ -1195,6 +1227,12 @@ void LvSettingsScreen::applyAndSave() { if (_tcpChangeCb) _tcpChangeCb(); } + // Apply GPS toggle live (start/stop GPS UART) + if (s.gpsTimeEnabled != _gpsSnapEnabled) { + _gpsSnapEnabled = s.gpsTimeEnabled; + if (_gpsChangeCb) _gpsChangeCb(s.gpsTimeEnabled); + } + // Check if reboot-required settings changed if (rebootSettingsChanged()) { _rebootNeeded = true; diff --git a/src/ui/screens/LvSettingsScreen.h b/src/ui/screens/LvSettingsScreen.h index 8af9599..0774f6f 100644 --- a/src/ui/screens/LvSettingsScreen.h +++ b/src/ui/screens/LvSettingsScreen.h @@ -75,6 +75,7 @@ public: void setDestinationHash(const String& hash) { _destinationHash = hash; } void setSaveCallback(std::function cb) { _saveCallback = cb; } void setTCPChangeCallback(std::function cb) { _tcpChangeCb = cb; } + void setGPSChangeCallback(std::function cb) { _gpsChangeCb = cb; } const char* title() const override { return "Settings"; } @@ -114,6 +115,8 @@ private: String _destinationHash; std::function _saveCallback; std::function _tcpChangeCb; + std::function _gpsChangeCb; + bool _gpsSnapEnabled = true; SettingsView _view = SettingsView::CATEGORY_LIST; std::vector _categories;