mirror of
https://github.com/ratspeak/ratdeck.git
synced 2026-03-30 14:15:39 +00:00
GPS time sync, status bar clock, configurable UTC offset
- Enable UBlox MIA-M10Q GPS on T-Deck Plus (HAS_GPS true) - Custom zero-dependency NMEA parser (NMEAParser.h) with XOR checksum - GPSManager: baud auto-detect (115200/38400/9600, 10s timeout), settimeofday() sync every 60s, NVS time/position persistence - GPS Time enabled by default, GPS Location disabled by default (user must explicitly opt in via Settings) - Replace status bar connectivity bars with clock display (top-left) - Default 12h format without AM/PM, configurable 24h in Settings - Add GPS/Time settings category: GPS Time, GPS Location, UTC Offset (-12 to +14), 24h Time toggle - Fix hardcoded UTC-5 NTP timezone to use configurable utcOffset - Change status bar brand text to "Ratspeak.org"
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
218
src/hal/GPSManager.cpp
Normal file
218
src/hal/GPSManager.cpp
Normal file
@@ -0,0 +1,218 @@
|
||||
#include "GPSManager.h"
|
||||
|
||||
#if HAS_GPS
|
||||
|
||||
#include <sys/time.h>
|
||||
#include <time.h>
|
||||
|
||||
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<offset>" 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
|
||||
75
src/hal/GPSManager.h
Normal file
75
src/hal/GPSManager.h
Normal file
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include "config/Config.h"
|
||||
|
||||
#if HAS_GPS
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <Preferences.h>
|
||||
#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
|
||||
248
src/hal/NMEAParser.h
Normal file
248
src/hal/NMEAParser.h
Normal file
@@ -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 <cstdint>
|
||||
#include <cstring>
|
||||
#include <cstdlib>
|
||||
#include <cmath>
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
67
src/main.cpp
67
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "LvStatusBar.h"
|
||||
#include "Theme.h"
|
||||
#include <Arduino.h>
|
||||
#include <time.h>
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -75,6 +75,7 @@ public:
|
||||
void setDestinationHash(const String& hash) { _destinationHash = hash; }
|
||||
void setSaveCallback(std::function<bool()> cb) { _saveCallback = cb; }
|
||||
void setTCPChangeCallback(std::function<void()> cb) { _tcpChangeCb = cb; }
|
||||
void setGPSChangeCallback(std::function<void(bool enabled)> cb) { _gpsChangeCb = cb; }
|
||||
|
||||
const char* title() const override { return "Settings"; }
|
||||
|
||||
@@ -114,6 +115,8 @@ private:
|
||||
String _destinationHash;
|
||||
std::function<bool()> _saveCallback;
|
||||
std::function<void()> _tcpChangeCb;
|
||||
std::function<void(bool)> _gpsChangeCb;
|
||||
bool _gpsSnapEnabled = true;
|
||||
|
||||
SettingsView _view = SettingsView::CATEGORY_LIST;
|
||||
std::vector<SettingsCategory> _categories;
|
||||
|
||||
Reference in New Issue
Block a user