mirror of
https://github.com/ratspeak/ratdeck.git
synced 2026-03-30 14:15:39 +00:00
v1.7.1: GPS time sync, timezone picker, status bar clock
- GPS time sync via UBlox MIA-M10Q (38400 baud auto-detect) - Custom zero-dependency NMEA parser with XOR checksum validation - Only trust GPS time when satellites > 0 (no stale RTC cache) - DST-aware timezone picker at first boot (21 cities, POSIX TZ strings) - Timezone also configurable in Settings > GPS/Time - GPS Time on by default, GPS Location off by default (opt-in) - Status bar: clock (top-left), Ratspeak.org (center), battery (right) - 12h time default (no AM/PM), configurable 24h in settings - NVS persistence of GPS time for approximate timestamps across reboots - Fix factory reset Enter key bleed-through (600ms input guard) - Fix hardcoded UTC-5 NTP timezone (now uses DST-aware POSIX TZ)
This commit is contained in:
@@ -68,7 +68,7 @@
|
||||
// --- GPS (UBlox MIA-M10Q UART) ---
|
||||
#define GPS_TX 43 // ESP TX -> GPS RX
|
||||
#define GPS_RX 44 // GPS TX -> ESP RX
|
||||
#define GPS_BAUD 115200
|
||||
#define GPS_BAUD 38400 // UBlox MIA-M10Q factory default
|
||||
|
||||
// --- Battery ADC ---
|
||||
#define BAT_ADC_PIN 4
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
#define RATDECK_VERSION_MAJOR 1
|
||||
#define RATDECK_VERSION_MINOR 7
|
||||
#define RATDECK_VERSION_PATCH 0
|
||||
#define RATDECK_VERSION_STRING "1.7.0"
|
||||
#define RATDECK_VERSION_PATCH 1
|
||||
#define RATDECK_VERSION_STRING "1.7.1"
|
||||
|
||||
// --- Feature Flags ---
|
||||
#define HAS_DISPLAY true
|
||||
|
||||
@@ -57,7 +57,8 @@ bool UserConfig::parseJson(const String& json) {
|
||||
|
||||
_settings.gpsTimeEnabled = doc["gps_time"] | true;
|
||||
_settings.gpsLocationEnabled = doc["gps_location"] | false;
|
||||
_settings.utcOffset = doc["utc_offset"] | (int)-5;
|
||||
_settings.timezoneIdx = doc["tz_idx"] | 6;
|
||||
_settings.timezoneSet = doc["tz_set"] | false;
|
||||
_settings.use24HourTime = doc["time_24h"] | false;
|
||||
|
||||
_settings.audioEnabled = doc["audio_on"] | true;
|
||||
@@ -105,7 +106,8 @@ String UserConfig::serializeToJson() const {
|
||||
|
||||
doc["gps_time"] = _settings.gpsTimeEnabled;
|
||||
doc["gps_location"] = _settings.gpsLocationEnabled;
|
||||
doc["utc_offset"] = _settings.utcOffset;
|
||||
doc["tz_idx"] = _settings.timezoneIdx;
|
||||
doc["tz_set"] = _settings.timezoneSet;
|
||||
doc["time_24h"] = _settings.use24HourTime;
|
||||
|
||||
doc["audio_on"] = _settings.audioEnabled;
|
||||
|
||||
@@ -53,7 +53,8 @@ struct UserSettings {
|
||||
// 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)
|
||||
uint8_t timezoneIdx = 6; // Index into TIMEZONE_TABLE (default: New York EST/EDT)
|
||||
bool timezoneSet = false; // false = show timezone picker at boot
|
||||
bool use24HourTime = false; // false = 12h (no AM/PM), true = 24h
|
||||
|
||||
// Audio
|
||||
|
||||
@@ -65,9 +65,15 @@ void GPSManager::loop() {
|
||||
NMEAData& d = _parser.data();
|
||||
if (d.timeUpdated) {
|
||||
d.timeUpdated = false;
|
||||
_lastFixMs = millis();
|
||||
if (d.timeValid && (millis() - _lastTimeSyncMs >= TIME_SYNC_INTERVAL_MS || _timeSyncCount == 0)) {
|
||||
// Only trust GPS time when we have actual satellite lock (sats > 0).
|
||||
// With sats=0 the module returns cached RTC time which drifts.
|
||||
bool hasSatFix = (d.satellites > 0);
|
||||
if (hasSatFix) _lastFixMs = millis();
|
||||
if (d.timeValid && hasSatFix && (millis() - _lastTimeSyncMs >= TIME_SYNC_INTERVAL_MS || _timeSyncCount == 0)) {
|
||||
syncSystemTime();
|
||||
// Persist time to NVS so reboots without WiFi/GPS have approximate time
|
||||
persistToNVS();
|
||||
_lastPersistMs = millis();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,12 +82,6 @@ void GPSManager::loop() {
|
||||
d.locationUpdated = false;
|
||||
_locationValid = d.locationValid;
|
||||
_lastFixMs = millis();
|
||||
|
||||
// Periodic NVS persist
|
||||
if (_locationValid && (millis() - _lastPersistMs >= PERSIST_INTERVAL_MS)) {
|
||||
persistToNVS();
|
||||
_lastPersistMs = millis();
|
||||
}
|
||||
}
|
||||
|
||||
// Stale fix detection
|
||||
@@ -150,12 +150,8 @@ void GPSManager::syncSystemTime() {
|
||||
_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);
|
||||
// Set TZ so localtime() returns correct local time (with DST if applicable)
|
||||
setenv("TZ", _posixTZ, 1);
|
||||
tzset();
|
||||
|
||||
Serial.printf("[GPS] System time synced: %04d-%02d-%02d %02d:%02d:%02d UTC (sync #%lu)\n",
|
||||
@@ -177,9 +173,7 @@ void GPSManager::restoreTimeFromNVS() {
|
||||
settimeofday(&tv, nullptr);
|
||||
|
||||
// Set TZ
|
||||
char tz[16];
|
||||
snprintf(tz, sizeof(tz), "UTC%d", -_utcOffset);
|
||||
setenv("TZ", tz, 1);
|
||||
setenv("TZ", _posixTZ, 1);
|
||||
tzset();
|
||||
|
||||
time_t now = time(nullptr);
|
||||
@@ -215,4 +209,9 @@ void GPSManager::persistToNVS() {
|
||||
prefs.end();
|
||||
}
|
||||
|
||||
void GPSManager::setPosixTZ(const char* tz) {
|
||||
strncpy(_posixTZ, tz, sizeof(_posixTZ) - 1);
|
||||
_posixTZ[sizeof(_posixTZ) - 1] = '\0';
|
||||
}
|
||||
|
||||
#endif // HAS_GPS
|
||||
|
||||
@@ -36,7 +36,7 @@ public:
|
||||
bool isRunning() const { return _running; }
|
||||
|
||||
// Configuration (set from outside before or after begin())
|
||||
void setUTCOffset(int8_t offset) { _utcOffset = offset; }
|
||||
void setPosixTZ(const char* tz);
|
||||
void setLocationEnabled(bool enable) { _parser.setParseLocation(enable); }
|
||||
|
||||
private:
|
||||
@@ -54,16 +54,16 @@ private:
|
||||
unsigned long _lastFixMs = 0;
|
||||
unsigned long _lastPersistMs = 0;
|
||||
uint32_t _timeSyncCount = 0;
|
||||
int8_t _utcOffset = -5;
|
||||
char _posixTZ[48] = "EST5EDT,M3.2.0,M11.1.0"; // POSIX TZ string
|
||||
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 uint32_t BAUD_RATES[] = {38400, 115200, 9600};
|
||||
static constexpr int BAUD_RATE_COUNT = 3;
|
||||
static constexpr unsigned long BAUD_DETECT_TIMEOUT_MS = 10000;
|
||||
static constexpr unsigned long BAUD_DETECT_TIMEOUT_MS = 3000;
|
||||
|
||||
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
|
||||
|
||||
@@ -217,22 +217,18 @@ private:
|
||||
// $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
|
||||
// Always parse fix quality and satellites (needed for time validation)
|
||||
_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]);
|
||||
// Only parse position fields if location tracking is enabled
|
||||
if (_parseLocation) {
|
||||
if (strlen(fields[8]) > 0) {
|
||||
_data.hdop = atof(fields[8]);
|
||||
}
|
||||
if (n >= 10 && strlen(fields[9]) > 0) {
|
||||
_data.altitude = atof(fields[9]);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
96
src/main.cpp
96
src/main.cpp
@@ -34,6 +34,7 @@
|
||||
#include "ui/screens/LvHelpOverlay.h"
|
||||
// Map screen removed
|
||||
#include "ui/screens/LvNameInputScreen.h"
|
||||
#include "ui/screens/LvTimezoneScreen.h"
|
||||
#include "ui/screens/LvDataCleanScreen.h"
|
||||
#include "storage/FlashStore.h"
|
||||
#include "storage/SDStore.h"
|
||||
@@ -107,6 +108,7 @@ LvSettingsScreen lvSettingsScreen;
|
||||
LvHelpOverlay lvHelpOverlay;
|
||||
// LvMapScreen removed
|
||||
LvNameInputScreen lvNameInputScreen;
|
||||
LvTimezoneScreen lvTimezoneScreen;
|
||||
LvDataCleanScreen lvDataCleanScreen;
|
||||
|
||||
// Tab-screen mapping (4 tabs) — LVGL versions
|
||||
@@ -131,6 +133,16 @@ constexpr unsigned long TCP_GLOBAL_BUDGET_MS = 35; // Max cumulative TCP ti
|
||||
bool wifiDeferredAnnounce = false;
|
||||
unsigned long wifiConnectedAt = 0;
|
||||
|
||||
// =============================================================================
|
||||
// Timezone helper — returns POSIX TZ string for current config
|
||||
// =============================================================================
|
||||
|
||||
static const char* currentPosixTZ() {
|
||||
uint8_t idx = userConfig.settings().timezoneIdx;
|
||||
if (idx < TIMEZONE_COUNT) return TIMEZONE_TABLE[idx].posixTZ;
|
||||
return "EST5EDT,M3.2.0,M11.1.0"; // Fallback
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Announce with display name (MessagePack-encoded app_data)
|
||||
// =============================================================================
|
||||
@@ -646,7 +658,7 @@ void setup() {
|
||||
#if HAS_GPS
|
||||
if (userConfig.settings().gpsTimeEnabled) {
|
||||
lvBootScreen.setProgress(0.93f, "Starting GPS...");
|
||||
gps.setUTCOffset(userConfig.settings().utcOffset);
|
||||
gps.setPosixTZ(currentPosixTZ());
|
||||
gps.setLocationEnabled(userConfig.settings().gpsLocationEnabled);
|
||||
gps.begin();
|
||||
Serial.println("[BOOT] GPS UART started (MIA-M10Q)");
|
||||
@@ -754,7 +766,7 @@ void setup() {
|
||||
#if HAS_GPS
|
||||
lvSettingsScreen.setGPSChangeCallback([](bool timeEnabled) {
|
||||
if (timeEnabled) {
|
||||
gps.setUTCOffset(userConfig.settings().utcOffset);
|
||||
gps.setPosixTZ(currentPosixTZ());
|
||||
gps.setLocationEnabled(userConfig.settings().gpsLocationEnabled);
|
||||
gps.begin();
|
||||
Serial.println("[GPS] Time enabled via settings");
|
||||
@@ -797,8 +809,49 @@ void setup() {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Boot flow helpers ---
|
||||
// Transition to home screen (shared by name input, timezone, and normal boot)
|
||||
auto goHome = []() {
|
||||
ui.setBootMode(false);
|
||||
ui.setLvScreen(&lvHomeScreen);
|
||||
ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME);
|
||||
announceWithName();
|
||||
lastAutoAnnounce = millis();
|
||||
Serial.println("[BOOT] Initial announce sent");
|
||||
};
|
||||
|
||||
// Show timezone screen, then go home
|
||||
auto showTimezone = [goHome]() {
|
||||
if (!userConfig.settings().timezoneSet) {
|
||||
lvTimezoneScreen.setSelectedIndex(userConfig.settings().timezoneIdx);
|
||||
ui.setLvScreen(&lvTimezoneScreen);
|
||||
Serial.println("[BOOT] Showing timezone selection");
|
||||
} else {
|
||||
goHome();
|
||||
}
|
||||
};
|
||||
|
||||
// Timezone screen done callback
|
||||
lvTimezoneScreen.setDoneCallback([goHome](int tzIdx) {
|
||||
userConfig.settings().timezoneIdx = (uint8_t)tzIdx;
|
||||
userConfig.settings().timezoneSet = true;
|
||||
userConfig.save(sdStore, flash);
|
||||
Serial.printf("[BOOT] Timezone set: %s (%s)\n",
|
||||
TIMEZONE_TABLE[tzIdx].label, TIMEZONE_TABLE[tzIdx].posixTZ);
|
||||
// Apply timezone immediately
|
||||
const char* tz = TIMEZONE_TABLE[tzIdx].posixTZ;
|
||||
setenv("TZ", tz, 1);
|
||||
tzset();
|
||||
#if HAS_GPS
|
||||
if (userConfig.settings().gpsTimeEnabled) {
|
||||
gps.setPosixTZ(tz);
|
||||
}
|
||||
#endif
|
||||
goHome();
|
||||
});
|
||||
|
||||
// Name input screen (first boot only — when no display name is set)
|
||||
lvNameInputScreen.setDoneCallback([](const String& name) {
|
||||
lvNameInputScreen.setDoneCallback([showTimezone](const String& name) {
|
||||
String finalName = name;
|
||||
if (finalName.isEmpty()) {
|
||||
// Auto-generate: Ratspeak.org-xxx (first 3 chars of LXMF dest hash)
|
||||
@@ -813,31 +866,22 @@ void setup() {
|
||||
}
|
||||
Serial.printf("[BOOT] Display name set: '%s'\n", finalName.c_str());
|
||||
|
||||
// Transition to home screen
|
||||
ui.setBootMode(false);
|
||||
ui.setLvScreen(&lvHomeScreen);
|
||||
ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME);
|
||||
|
||||
// Initial announce with name
|
||||
announceWithName();
|
||||
lastAutoAnnounce = millis();
|
||||
Serial.println("[BOOT] Initial announce sent");
|
||||
// Next step: timezone selection (or home if already set)
|
||||
showTimezone();
|
||||
});
|
||||
|
||||
if (userConfig.settings().displayName.isEmpty()) {
|
||||
// First boot — go to name input (SD data is handled gracefully by dual-backend)
|
||||
// First boot — go to name input
|
||||
ui.setLvScreen(&lvNameInputScreen);
|
||||
Serial.println("[BOOT] Showing name input screen");
|
||||
} else if (!userConfig.settings().timezoneSet) {
|
||||
// Name set but timezone not — show timezone picker
|
||||
lvTimezoneScreen.setSelectedIndex(userConfig.settings().timezoneIdx);
|
||||
ui.setLvScreen(&lvTimezoneScreen);
|
||||
Serial.println("[BOOT] Showing timezone selection (name already set)");
|
||||
} else {
|
||||
// Name already set — go straight to home
|
||||
ui.setBootMode(false);
|
||||
ui.setLvScreen(&lvHomeScreen);
|
||||
ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME);
|
||||
|
||||
// Initial announce with name
|
||||
announceWithName();
|
||||
lastAutoAnnounce = millis();
|
||||
Serial.println("[BOOT] Initial announce sent");
|
||||
// Everything configured — go straight to home
|
||||
goHome();
|
||||
}
|
||||
|
||||
// Clear boot loop counter — we survived!
|
||||
@@ -956,13 +1000,11 @@ void loop() {
|
||||
ui.lvStatusBar().setWiFiActive(true);
|
||||
Serial.printf("[WIFI] STA connected: %s\n", WiFi.localIP().toString().c_str());
|
||||
|
||||
// NTP time sync (configurable UTC offset)
|
||||
// NTP time sync (DST-aware POSIX TZ string)
|
||||
{
|
||||
int8_t off = userConfig.settings().utcOffset;
|
||||
char tz[16];
|
||||
snprintf(tz, sizeof(tz), "UTC%d", -off);
|
||||
const char* tz = currentPosixTZ();
|
||||
configTzTime(tz, "pool.ntp.org", "time.nist.gov");
|
||||
Serial.printf("[NTP] Time sync started (UTC%+d, TZ=%s)\n", off, tz);
|
||||
Serial.printf("[NTP] Time sync started (TZ=%s)\n", tz);
|
||||
}
|
||||
|
||||
// Recreate TCP clients on every WiFi connect (old clients may have stale sockets)
|
||||
|
||||
@@ -32,13 +32,6 @@ void LvStatusBar::create(lv_obj_t* parent) {
|
||||
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);
|
||||
@@ -104,11 +97,7 @@ void LvStatusBar::updateTime() {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -31,7 +31,6 @@ private:
|
||||
lv_obj_t* _bar = 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;
|
||||
|
||||
@@ -60,6 +60,8 @@ bool LvNameInputScreen::handleKey(const KeyEvent& event) {
|
||||
if (!_textarea) return false;
|
||||
|
||||
if (event.enter || event.character == '\n' || event.character == '\r') {
|
||||
// Guard: ignore Enter for first 600ms to prevent stale keypress from factory reset
|
||||
if (millis() - _enterTime < ENTER_GUARD_MS) return true;
|
||||
const char* text = lv_textarea_get_text(_textarea);
|
||||
if (_doneCb) {
|
||||
_doneCb(String(text && strlen(text) > 0 ? text : ""));
|
||||
|
||||
@@ -10,10 +10,13 @@ public:
|
||||
const char* title() const override { return "Setup"; }
|
||||
|
||||
void setDoneCallback(std::function<void(const String&)> cb) { _doneCb = cb; }
|
||||
void onEnter() override { _enterTime = millis(); }
|
||||
|
||||
static constexpr int MAX_NAME_LEN = 16;
|
||||
|
||||
private:
|
||||
lv_obj_t* _textarea = nullptr;
|
||||
std::function<void(const String&)> _doneCb;
|
||||
unsigned long _enterTime = 0;
|
||||
static constexpr unsigned long ENTER_GUARD_MS = 600; // Ignore Enter for 600ms after screen appears
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "ui/LvTheme.h"
|
||||
#include "config/Config.h"
|
||||
#include "config/UserConfig.h"
|
||||
#include "ui/screens/LvTimezoneScreen.h" // For TIMEZONE_TABLE
|
||||
#include "storage/FlashStore.h"
|
||||
#include "storage/SDStore.h"
|
||||
#include "radio/SX1262.h"
|
||||
@@ -382,10 +383,14 @@ void LvSettingsScreen::buildItems() {
|
||||
[](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});
|
||||
_items.push_back({"Timezone", SettingType::INTEGER,
|
||||
[&s]() { return (int)s.timezoneIdx; },
|
||||
[&s](int v) { s.timezoneIdx = (uint8_t)v; s.timezoneSet = true; },
|
||||
[](int v) {
|
||||
if (v >= 0 && v < TIMEZONE_COUNT) return String(TIMEZONE_TABLE[v].label);
|
||||
return String("Unknown");
|
||||
},
|
||||
0, TIMEZONE_COUNT - 1, 1});
|
||||
idx++;
|
||||
_items.push_back({"24h Time", SettingType::TOGGLE,
|
||||
[&s]() { return s.use24HourTime ? 1 : 0; },
|
||||
@@ -394,9 +399,9 @@ void LvSettingsScreen::buildItems() {
|
||||
idx++;
|
||||
_categories.push_back({"GPS/Time", gpsStart, idx - gpsStart,
|
||||
[&s]() {
|
||||
char buf[16];
|
||||
snprintf(buf, sizeof(buf), "UTC%+d", s.utcOffset);
|
||||
return String(buf);
|
||||
if (s.timezoneIdx < TIMEZONE_COUNT)
|
||||
return String(TIMEZONE_TABLE[s.timezoneIdx].label);
|
||||
return String("Not set");
|
||||
}});
|
||||
|
||||
// Audio
|
||||
@@ -540,7 +545,7 @@ void LvSettingsScreen::buildItems() {
|
||||
if (_sd && _sd->isReady()) _sd->wipeRatputer();
|
||||
if (_flash) _flash->format();
|
||||
nvs_flash_erase();
|
||||
delay(500);
|
||||
delay(1500); // Long enough for key state to clear before reboot
|
||||
ESP.restart();
|
||||
};
|
||||
_items.push_back(factoryReset);
|
||||
|
||||
152
src/ui/screens/LvTimezoneScreen.cpp
Normal file
152
src/ui/screens/LvTimezoneScreen.cpp
Normal file
@@ -0,0 +1,152 @@
|
||||
#include "LvTimezoneScreen.h"
|
||||
#include "ui/Theme.h"
|
||||
#include "ui/LvTheme.h"
|
||||
#include "config/Config.h"
|
||||
|
||||
void LvTimezoneScreen::createUI(lv_obj_t* parent) {
|
||||
_screen = parent;
|
||||
lv_obj_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0);
|
||||
|
||||
const lv_font_t* font12 = &lv_font_montserrat_12;
|
||||
const lv_font_t* font14 = &lv_font_montserrat_14;
|
||||
|
||||
// Title
|
||||
lv_obj_t* title = lv_label_create(parent);
|
||||
lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0);
|
||||
lv_obj_set_style_text_color(title, lv_color_hex(Theme::PRIMARY), 0);
|
||||
lv_label_set_text(title, "SELECT TIMEZONE");
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 8);
|
||||
|
||||
// Subtitle
|
||||
lv_obj_t* sub = lv_label_create(parent);
|
||||
lv_obj_set_style_text_font(sub, font12, 0);
|
||||
lv_obj_set_style_text_color(sub, lv_color_hex(Theme::SECONDARY), 0);
|
||||
lv_label_set_text(sub, "Choose your nearest city:");
|
||||
lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 30);
|
||||
|
||||
// Scrollable list container
|
||||
int listY = 50;
|
||||
int listH = VISIBLE_ROWS * ROW_H;
|
||||
_scrollContainer = lv_obj_create(parent);
|
||||
lv_obj_set_size(_scrollContainer, Theme::SCREEN_W - 20, listH);
|
||||
lv_obj_set_pos(_scrollContainer, 10, listY);
|
||||
lv_obj_set_style_bg_color(_scrollContainer, lv_color_hex(Theme::BG), 0);
|
||||
lv_obj_set_style_bg_opa(_scrollContainer, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_border_width(_scrollContainer, 1, 0);
|
||||
lv_obj_set_style_border_color(_scrollContainer, lv_color_hex(Theme::BORDER), 0);
|
||||
lv_obj_set_style_pad_all(_scrollContainer, 0, 0);
|
||||
lv_obj_set_style_radius(_scrollContainer, 4, 0);
|
||||
lv_obj_set_layout(_scrollContainer, LV_LAYOUT_FLEX);
|
||||
lv_obj_set_flex_flow(_scrollContainer, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_scroll_snap_y(_scrollContainer, LV_SCROLL_SNAP_CENTER);
|
||||
lv_obj_set_scrollbar_mode(_scrollContainer, LV_SCROLLBAR_MODE_OFF);
|
||||
|
||||
// Create rows
|
||||
for (int i = 0; i < TIMEZONE_COUNT; i++) {
|
||||
lv_obj_t* row = lv_obj_create(_scrollContainer);
|
||||
lv_obj_set_size(row, Theme::SCREEN_W - 24, ROW_H);
|
||||
lv_obj_set_style_pad_left(row, 8, 0);
|
||||
lv_obj_set_style_pad_right(row, 8, 0);
|
||||
lv_obj_set_style_pad_top(row, 0, 0);
|
||||
lv_obj_set_style_pad_bottom(row, 0, 0);
|
||||
lv_obj_set_style_border_width(row, 0, 0);
|
||||
lv_obj_set_style_radius(row, 2, 0);
|
||||
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
bool selected = (i == _selectedIdx);
|
||||
lv_obj_set_style_bg_color(row, lv_color_hex(selected ? Theme::SELECTION_BG : Theme::BG), 0);
|
||||
lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0);
|
||||
|
||||
// City name (left)
|
||||
lv_obj_t* lblCity = lv_label_create(row);
|
||||
lv_obj_set_style_text_font(lblCity, font14, 0);
|
||||
lv_obj_set_style_text_color(lblCity, lv_color_hex(selected ? Theme::BG : Theme::PRIMARY), 0);
|
||||
lv_label_set_text(lblCity, TIMEZONE_TABLE[i].label);
|
||||
lv_obj_align(lblCity, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
// UTC offset (right)
|
||||
lv_obj_t* lblOffset = lv_label_create(row);
|
||||
lv_obj_set_style_text_font(lblOffset, font12, 0);
|
||||
lv_obj_set_style_text_color(lblOffset, lv_color_hex(selected ? Theme::BG : Theme::ACCENT), 0);
|
||||
char buf[12];
|
||||
int8_t off = TIMEZONE_TABLE[i].baseOffset;
|
||||
snprintf(buf, sizeof(buf), "UTC%+d", off);
|
||||
lv_label_set_text(lblOffset, buf);
|
||||
lv_obj_align(lblOffset, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
}
|
||||
|
||||
// Scroll initial selection into view
|
||||
if (_selectedIdx >= 0 && _selectedIdx < TIMEZONE_COUNT) {
|
||||
lv_obj_t* selRow = lv_obj_get_child(_scrollContainer, _selectedIdx);
|
||||
if (selRow) lv_obj_scroll_to_view(selRow, LV_ANIM_OFF);
|
||||
}
|
||||
|
||||
// Hint at bottom
|
||||
lv_obj_t* hint = lv_label_create(parent);
|
||||
lv_obj_set_style_text_font(hint, font12, 0);
|
||||
lv_obj_set_style_text_color(hint, lv_color_hex(Theme::ACCENT), 0);
|
||||
lv_label_set_text(hint, "[Enter] Select");
|
||||
lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -10);
|
||||
}
|
||||
|
||||
void LvTimezoneScreen::updateSelection(int oldIdx, int newIdx) {
|
||||
if (!_scrollContainer) return;
|
||||
uint32_t count = lv_obj_get_child_cnt(_scrollContainer);
|
||||
|
||||
const lv_font_t* font14 = &lv_font_montserrat_14;
|
||||
const lv_font_t* font12 = &lv_font_montserrat_12;
|
||||
|
||||
// Deselect old
|
||||
if (oldIdx >= 0 && oldIdx < (int)count) {
|
||||
lv_obj_t* row = lv_obj_get_child(_scrollContainer, oldIdx);
|
||||
lv_obj_set_style_bg_color(row, lv_color_hex(Theme::BG), 0);
|
||||
// Update text colors
|
||||
lv_obj_t* lblCity = lv_obj_get_child(row, 0);
|
||||
lv_obj_t* lblOff = lv_obj_get_child(row, 1);
|
||||
if (lblCity) lv_obj_set_style_text_color(lblCity, lv_color_hex(Theme::PRIMARY), 0);
|
||||
if (lblOff) lv_obj_set_style_text_color(lblOff, lv_color_hex(Theme::ACCENT), 0);
|
||||
}
|
||||
|
||||
// Select new
|
||||
if (newIdx >= 0 && newIdx < (int)count) {
|
||||
lv_obj_t* row = lv_obj_get_child(_scrollContainer, newIdx);
|
||||
lv_obj_set_style_bg_color(row, lv_color_hex(Theme::SELECTION_BG), 0);
|
||||
lv_obj_t* lblCity = lv_obj_get_child(row, 0);
|
||||
lv_obj_t* lblOff = lv_obj_get_child(row, 1);
|
||||
if (lblCity) lv_obj_set_style_text_color(lblCity, lv_color_hex(Theme::BG), 0);
|
||||
if (lblOff) lv_obj_set_style_text_color(lblOff, lv_color_hex(Theme::BG), 0);
|
||||
|
||||
// Scroll to make visible
|
||||
lv_obj_scroll_to_view(row, LV_ANIM_ON);
|
||||
}
|
||||
}
|
||||
|
||||
bool LvTimezoneScreen::handleKey(const KeyEvent& event) {
|
||||
if (event.enter || event.character == '\n' || event.character == '\r') {
|
||||
// Guard: ignore Enter for first 600ms to prevent stale keypress bleed
|
||||
if (millis() - _enterTime < ENTER_GUARD_MS) return true;
|
||||
if (_doneCb) _doneCb(_selectedIdx);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.up) {
|
||||
if (_selectedIdx > 0) {
|
||||
int old = _selectedIdx;
|
||||
_selectedIdx--;
|
||||
updateSelection(old, _selectedIdx);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.down) {
|
||||
if (_selectedIdx < TIMEZONE_COUNT - 1) {
|
||||
int old = _selectedIdx;
|
||||
_selectedIdx++;
|
||||
updateSelection(old, _selectedIdx);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true; // Consume all keys
|
||||
}
|
||||
63
src/ui/screens/LvTimezoneScreen.h
Normal file
63
src/ui/screens/LvTimezoneScreen.h
Normal file
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include "ui/UIManager.h"
|
||||
#include <functional>
|
||||
|
||||
struct TimezoneEntry {
|
||||
const char* label; // Display name (e.g., "New York (EST/EDT)")
|
||||
const char* posixTZ; // POSIX TZ string for setenv("TZ", ...)
|
||||
int8_t baseOffset; // Base UTC offset for display (e.g., -5 for EST)
|
||||
};
|
||||
|
||||
// Major world timezones with DST rules where applicable
|
||||
static const TimezoneEntry TIMEZONE_TABLE[] = {
|
||||
{"Honolulu", "HST10", -10},
|
||||
{"Anchorage", "AKST9AKDT,M3.2.0,M11.1.0", -9},
|
||||
{"Los Angeles", "PST8PDT,M3.2.0,M11.1.0", -8},
|
||||
{"Denver", "MST7MDT,M3.2.0,M11.1.0", -7},
|
||||
{"Phoenix", "MST7", -7},
|
||||
{"Chicago", "CST6CDT,M3.2.0,M11.1.0", -6},
|
||||
{"New York", "EST5EDT,M3.2.0,M11.1.0", -5},
|
||||
{"San Juan", "AST4", -4},
|
||||
{"Sao Paulo", "<-03>3", -3},
|
||||
{"London", "GMT0BST,M3.5.0/1,M10.5.0", 0},
|
||||
{"Paris", "CET-1CEST,M3.5.0,M10.5.0/3", 1},
|
||||
{"Cairo", "EET-2", 2},
|
||||
{"Moscow", "MSK-3", 3},
|
||||
{"Dubai", "<+04>-4", 4},
|
||||
{"Karachi", "PKT-5", 5},
|
||||
{"Kolkata", "IST-5:30", 5},
|
||||
{"Bangkok", "<+07>-7", 7},
|
||||
{"Singapore", "<+08>-8", 8},
|
||||
{"Tokyo", "JST-9", 9},
|
||||
{"Sydney", "AEST-10AEDT,M10.1.0,M4.1.0/3", 10},
|
||||
{"Auckland", "NZST-12NZDT,M9.5.0,M4.1.0/3", 12},
|
||||
};
|
||||
|
||||
static constexpr int TIMEZONE_COUNT = sizeof(TIMEZONE_TABLE) / sizeof(TIMEZONE_TABLE[0]);
|
||||
|
||||
class LvTimezoneScreen : public LvScreen {
|
||||
public:
|
||||
void createUI(lv_obj_t* parent) override;
|
||||
bool handleKey(const KeyEvent& event) override;
|
||||
void onEnter() override { _enterTime = millis(); }
|
||||
const char* title() const override { return "Timezone"; }
|
||||
|
||||
void setDoneCallback(std::function<void(int tzIndex)> cb) { _doneCb = cb; }
|
||||
|
||||
// Pre-select an index (e.g., from saved config)
|
||||
void setSelectedIndex(int idx) { _selectedIdx = idx; }
|
||||
|
||||
private:
|
||||
void updateSelection(int oldIdx, int newIdx);
|
||||
|
||||
std::function<void(int)> _doneCb;
|
||||
int _selectedIdx = 6; // Default: New York (EST/EDT)
|
||||
unsigned long _enterTime = 0;
|
||||
static constexpr unsigned long ENTER_GUARD_MS = 600;
|
||||
|
||||
// LVGL widgets
|
||||
lv_obj_t* _scrollContainer = nullptr;
|
||||
static constexpr int VISIBLE_ROWS = 5;
|
||||
static constexpr int ROW_H = 28;
|
||||
};
|
||||
Reference in New Issue
Block a user