From cfd3a381ed5c3281ad192564c9781544856981a2 Mon Sep 17 00:00:00 2001 From: Justinian Date: Tue, 19 May 2026 02:06:56 +0100 Subject: [PATCH] feat: X3 clock display with DS3231 RTC and NTP sync (#1612) --- lib/I18n/translations/english.yaml | 15 ++ lib/hal/HalClock.cpp | 182 ++++++++++++++ lib/hal/HalClock.h | 48 ++++ src/CrossPointSettings.h | 11 + src/SettingsList.h | 14 ++ .../network/WifiSelectionActivity.cpp | 12 + .../settings/ClockOffsetActivity.cpp | 231 ++++++++++++++++++ src/activities/settings/ClockOffsetActivity.h | 37 +++ src/activities/settings/ClockSyncActivity.cpp | 108 ++++++++ src/activities/settings/ClockSyncActivity.h | 24 ++ .../settings/StatusBarSettingsActivity.cpp | 190 +++++++++----- .../settings/StatusBarSettingsActivity.h | 2 + src/components/themes/BaseTheme.cpp | 17 +- src/main.cpp | 2 + 14 files changed, 836 insertions(+), 57 deletions(-) create mode 100644 lib/hal/HalClock.cpp create mode 100644 lib/hal/HalClock.h create mode 100644 src/activities/settings/ClockOffsetActivity.cpp create mode 100644 src/activities/settings/ClockOffsetActivity.h create mode 100644 src/activities/settings/ClockSyncActivity.cpp create mode 100644 src/activities/settings/ClockSyncActivity.h diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index e7027349..824e7240 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -234,6 +234,21 @@ STR_BATTERY: "Battery" STR_XTC_STATUS_BAR: "XTC Status Bar" STR_BOTTOM: "Bottom" STR_TOP: "Top" +STR_CLOCK: "Clock" +STR_CLOCK_UTC_OFFSET: "Clock UTC Offset" +STR_CLOCK_FORMAT: "Clock Format" +STR_CLOCK_FORMAT_24H: "24-hour" +STR_CLOCK_FORMAT_12H: "12-hour" +STR_CURRENT_TIME: "Current time:" +STR_NEXT_FIELD: "Next" +STR_CLOCK_SYNC: "Sync Clock" +STR_CLOCK_SYNC_NOW: "Sync clock now" +STR_CLOCK_SYNCING: "Syncing from NTP..." +STR_CLOCK_SYNC_OK: "Clock synced" +STR_CLOCK_SYNC_FAIL: "Sync failed" +STR_CLOCK_SYNC_NO_WIFI: "WiFi not connected" +STR_CLOCK_SYNC_NO_WIFI_HINT: "Connect to WiFi first, then try again." +STR_CLOCK_SYNCED: "Clock Synced" STR_UI_THEME: "UI Theme" STR_THEME_CLASSIC: "Classic" STR_THEME_LYRA: "Lyra" diff --git a/lib/hal/HalClock.cpp b/lib/hal/HalClock.cpp new file mode 100644 index 00000000..6e3c3a90 --- /dev/null +++ b/lib/hal/HalClock.cpp @@ -0,0 +1,182 @@ +#include "HalClock.h" + +#include +#include +#include +#include + +#include + +HalClock halClock; // Singleton instance + +// DS3231 register layout (BCD encoded): +// 0x00: Seconds (bits 6-4 = tens, bits 3-0 = ones) +// 0x01: Minutes (bits 6-4 = tens, bits 3-0 = ones) +// 0x02: Hours (bit 6 = 12/24 mode, bits 5-4 = tens, bits 3-0 = ones) + +static uint8_t bcdToDec(uint8_t bcd) { return ((bcd >> 4) * 10) + (bcd & 0x0F); } +static uint8_t decToBcd(uint8_t dec) { return ((dec / 10) << 4) | (dec % 10); } + +void HalClock::begin() { + if (!gpio.deviceIsX3()) { + _available = false; + return; + } + + // I2C is already initialised by HalPowerManager::begin() for X3. + // Probe the DS3231 by reading the seconds register. + Wire.beginTransmission(I2C_ADDR_DS3231); + Wire.write(DS3231_SEC_REG); + if (Wire.endTransmission(false) != 0) { + LOG_INF("CLK", "DS3231 RTC not found"); + _available = false; + return; + } + Wire.requestFrom(I2C_ADDR_DS3231, (uint8_t)1); + if (Wire.available() < 1) { + _available = false; + return; + } + Wire.read(); // discard — just testing connectivity + + _available = true; + LOG_INF("CLK", "DS3231 RTC found"); + + // Prime the cache with an initial read + uint8_t h, m; + getTime(h, m); +} + +bool HalClock::getTime(uint8_t& hour, uint8_t& minute) const { + if (!_available) return false; + + const unsigned long now = millis(); + if (_lastPollMs != 0 && (now - _lastPollMs) < CLOCK_POLL_MS) { + hour = _cachedHour; + minute = _cachedMinute; + return true; + } + + // Read 3 bytes starting at register 0x00: seconds, minutes, hours + Wire.beginTransmission(I2C_ADDR_DS3231); + Wire.write(DS3231_SEC_REG); + if (Wire.endTransmission(false) != 0) { + if (!_hasCachedTime) return false; + _lastPollMs = now; + hour = _cachedHour; + minute = _cachedMinute; + return true; + } + Wire.requestFrom(I2C_ADDR_DS3231, (uint8_t)3); + if (Wire.available() < 3) { + if (!_hasCachedTime) return false; + _lastPollMs = now; + hour = _cachedHour; + minute = _cachedMinute; + return true; + } + + Wire.read(); // seconds — not needed + const uint8_t rawMin = Wire.read(); + const uint8_t rawHour = Wire.read(); + + _cachedMinute = bcdToDec(rawMin & 0x7F); + // Handle 12/24h mode: bit 6 high = 12h mode + if (rawHour & 0x40) { + // 12h mode: bit 5 = PM, bits 4-0 = hours (1-12) + uint8_t h12 = bcdToDec(rawHour & 0x1F); + bool pm = rawHour & 0x20; + if (h12 == 12) h12 = 0; + _cachedHour = pm ? (h12 + 12) : h12; + } else { + // 24h mode: bits 5-0 = hours (0-23) + _cachedHour = bcdToDec(rawHour & 0x3F); + } + _lastPollMs = now; + _hasCachedTime = true; + + hour = _cachedHour; + minute = _cachedMinute; + return true; +} + +bool HalClock::formatTime(char* buf, size_t bufSize, uint8_t utcOffsetQuarterHoursBiased, bool use12Hour) const { + if (bufSize < (use12Hour ? 9u : 6u)) return false; + uint8_t h, m; + if (!getTime(h, m)) return false; + + // Apply UTC offset: convert biased value to signed quarter-hours. + // Clamp against corrupted persisted values so display time can't drift outside [-12:00, +14:00]. + if (utcOffsetQuarterHoursBiased > 104) utcOffsetQuarterHoursBiased = 104; + int offsetQuarterHours = static_cast(utcOffsetQuarterHoursBiased) - 48; + int totalMinutes = static_cast(h) * 60 + static_cast(m) + offsetQuarterHours * 15; + + // Wrap around 24 hours + totalMinutes = ((totalMinutes % 1440) + 1440) % 1440; + + const int hour24 = totalMinutes / 60; + const int min = totalMinutes % 60; + if (use12Hour) { + const bool pm = hour24 >= 12; + int hour12 = hour24 % 12; + if (hour12 == 0) hour12 = 12; + snprintf(buf, bufSize, "%d:%02d %s", hour12, min, pm ? "PM" : "AM"); + } else { + snprintf(buf, bufSize, "%02d:%02d", hour24, min); + } + return true; +} + +bool HalClock::writeTimeToRTC(uint8_t hour, uint8_t minute, uint8_t second) { + assert(hour < 24); + assert(minute < 60); + assert(second < 60); + Wire.beginTransmission(I2C_ADDR_DS3231); + Wire.write(DS3231_SEC_REG); // Start at register 0x00 + Wire.write(decToBcd(second)); // 0x00: Seconds + Wire.write(decToBcd(minute)); // 0x01: Minutes + Wire.write(decToBcd(hour)); // 0x02: Hours (24h mode, bit 6 = 0) + if (Wire.endTransmission() != 0) { + LOG_ERR("CLK", "Failed to write time to DS3231"); + return false; + } + + // Invalidate cache so next read fetches fresh data + _lastPollMs = 0; + _cachedHour = hour; + _cachedMinute = minute; + _hasCachedTime = true; + return true; +} + +bool HalClock::syncFromNTP() { + if (!_available) return false; + + if (WiFi.status() != WL_CONNECTED) { + LOG_ERR("CLK", "WiFi not connected, cannot sync NTP"); + return false; + } + + LOG_INF("CLK", "Starting NTP sync..."); + configTzTime("UTC0", "pool.ntp.org", "time.nist.gov"); + + // Wait for SNTP sync to complete (up to 5 seconds) + constexpr int maxAttempts = 50; + for (int i = 0; i < maxAttempts; i++) { + if (sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) { + time_t now = time(nullptr); + struct tm timeinfo; + gmtime_r(&now, &timeinfo); + + if (writeTimeToRTC(timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec)) { + LOG_INF("CLK", "RTC set to %02d:%02d:%02d UTC", timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); + return true; + } + return false; + } + delay(100); + } + + LOG_ERR("CLK", "NTP sync timed out"); + return false; +} diff --git a/lib/hal/HalClock.h b/lib/hal/HalClock.h new file mode 100644 index 00000000..a0cc4825 --- /dev/null +++ b/lib/hal/HalClock.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include + +#include "HalGPIO.h" + +class HalClock; +extern HalClock halClock; // Singleton + +class HalClock { + bool _available = false; + mutable uint8_t _cachedHour = 0; + mutable uint8_t _cachedMinute = 0; + mutable bool _hasCachedTime = false; + mutable unsigned long _lastPollMs = 0; + + static constexpr unsigned long CLOCK_POLL_MS = 10000; // 10 seconds + + public: + // Call after gpio.begin() and powerManager.begin() (I2C already initialised for X3) + void begin(); + + // True if the DS3231 RTC is present on this device + bool isAvailable() const { return _available; } + + // Get current hour (0-23) and minute (0-59). + // Returns false if RTC is not available. + bool getTime(uint8_t& hour, uint8_t& minute) const; + + // Format time into a caller-provided buffer. + // 24h mode produces "HH:MM" (needs >=6 bytes); 12h mode produces "H:MM AM"/"HH:MM PM" (needs >=9 bytes). + // utcOffsetQuarterHoursBiased: biased quarter-hour offset (48 = UTC+0, 0 = UTC-12, 104 = UTC+14). + // use12Hour: when true, format as 12-hour clock with AM/PM suffix. + // Returns false if RTC is not available. + bool formatTime(char* buf, size_t bufSize, uint8_t utcOffsetQuarterHoursBiased = 48, bool use12Hour = false) const; + + // Sync the DS3231 RTC from an NTP server. Requires WiFi to be connected. + // Blocks for up to ~5s while waiting for SNTP response. + // Returns true if the RTC was successfully updated. + // + // Debouncing (skip if already synced once) is enforced by the caller, not here, + // so the HAL stays free of any app-layer settings dependency. + bool syncFromNTP(); + + private: + bool writeTimeToRTC(uint8_t hour, uint8_t minute, uint8_t second); +}; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 372a1a53..c4d865e8 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -170,6 +170,17 @@ class CrossPointSettings { uint8_t statusBarTitle = CHAPTER_TITLE; uint8_t statusBarBattery = 1; uint8_t xtcStatusBarMode = XTC_STATUS_BAR_HIDE; + // Clock display in status bar (X3 only, requires DS3231 RTC) + uint8_t statusBarClock = 0; + // Clock UTC offset in quarter-hour steps, biased by 48 so it fits in uint8_t. + // Value 48 = UTC+0, 0 = UTC-12:00, 104 = UTC+14:00. + // Quarter-hour granularity supports oddball zones like Nepal (+5:45) and Chatham (+12:45). + uint8_t clockUtcOffsetQ = 48; + // Clock display format: 0 = 24-hour, 1 = 12-hour + uint8_t clockFormat = 0; + // Set once an NTP sync succeeds. Used to skip re-syncing on every WiFi connect. + // Resetting to 0 (e.g. via the web UI) forces a re-sync on next WiFi connect. + uint8_t clockHasBeenSynced = 0; // Text rendering settings uint8_t extraParagraphSpacing = 1; uint8_t textAntiAliasing = 1; diff --git a/src/SettingsList.h b/src/SettingsList.h index aae73477..510b019f 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -229,6 +230,19 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist SettingInfo::Enum(StrId::STR_XTC_STATUS_BAR, &CrossPointSettings::xtcStatusBarMode, {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}, "xtcStatusBarMode", StrId::STR_CUSTOMISE_STATUS_BAR), + // Clock entries (web settings only; device UI uses ClockOffsetActivity for the offset). + // Range 0..104 = quarter-hour steps from UTC-12:00 to UTC+14:00, biased by 48. + SettingInfo::Toggle(StrId::STR_CLOCK, &CrossPointSettings::statusBarClock, "statusBarClock", + StrId::STR_CUSTOMISE_STATUS_BAR), + SettingInfo::Value(StrId::STR_CLOCK_UTC_OFFSET, &CrossPointSettings::clockUtcOffsetQ, {0, 104, 1}, + "clockUtcOffsetQ", StrId::STR_CUSTOMISE_STATUS_BAR), + SettingInfo::Enum(StrId::STR_CLOCK_FORMAT, &CrossPointSettings::clockFormat, + {StrId::STR_CLOCK_FORMAT_24H, StrId::STR_CLOCK_FORMAT_12H}, "clockFormat", + StrId::STR_CUSTOMISE_STATUS_BAR), + // Persistence flag for NTP debounce. Resetting from the web UI forces a re-sync + // on next WiFi connect, which is useful when crossing time zones. + SettingInfo::Toggle(StrId::STR_CLOCK_SYNCED, &CrossPointSettings::clockHasBeenSynced, "clockHasBeenSynced", + StrId::STR_CUSTOMISE_STATUS_BAR), }; // Only show tilt page turn setting when the QMI8658 IMU is present (X3) if (halTiltSensor.isAvailable()) { diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index d1c6e1d2..515cb9e1 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -1,12 +1,14 @@ #include "WifiSelectionActivity.h" #include +#include #include #include #include #include +#include "CrossPointSettings.h" #include "MappedInputManager.h" #include "WifiCredentialStore.h" #include "activities/util/KeyboardEntryActivity.h" @@ -248,6 +250,16 @@ void WifiSelectionActivity::checkConnectionStatus() { connectedIP = ipStr; autoConnecting = false; + // Sync RTC from NTP on the first successful WiFi connection only. The DS3231 + // drifts ~2 ppm so one sync is enough; users can force a re-sync from + // Settings > Customise Status Bar > Sync clock now. + if (halClock.isAvailable() && !SETTINGS.clockHasBeenSynced) { + if (halClock.syncFromNTP()) { + SETTINGS.clockHasBeenSynced = 1; + SETTINGS.saveToFile(); + } + } + // Save this as the last connected network - SD card operations need lock as // we use SPI for both { diff --git a/src/activities/settings/ClockOffsetActivity.cpp b/src/activities/settings/ClockOffsetActivity.cpp new file mode 100644 index 00000000..b8b85f6a --- /dev/null +++ b/src/activities/settings/ClockOffsetActivity.cpp @@ -0,0 +1,231 @@ +#include "ClockOffsetActivity.h" + +#include +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { +constexpr uint8_t MAX_POS_HOURS = 14; +constexpr uint8_t MAX_NEG_HOURS = 12; +constexpr uint8_t MINUTE_STEPS = 4; // 0, 15, 30, 45 +constexpr uint8_t MINUTES_PER_QUARTER = 15; +constexpr uint8_t BIAS_QUARTER_HOURS = 48; // 0 stored = UTC-12, 48 stored = UTC+0 + +// Convert a (sign, hours, quarter) triple into the biased storage value. +// Returns a value in [0, 104]. +uint8_t encodeOffset(uint8_t sign, uint8_t hours, uint8_t quarter) { + int signedQuarter = static_cast(hours) * 4 + static_cast(quarter); + if (sign == 1) signedQuarter = -signedQuarter; + int biased = signedQuarter + BIAS_QUARTER_HOURS; + if (biased < 0) biased = 0; + if (biased > 104) biased = 104; + return static_cast(biased); +} + +// Decompose the biased storage value into (sign, hours, quarter). +void decodeOffset(uint8_t biased, uint8_t& sign, uint8_t& hours, uint8_t& quarter) { + if (biased > 104) biased = BIAS_QUARTER_HOURS; + int signedQuarter = static_cast(biased) - BIAS_QUARTER_HOURS; + if (signedQuarter < 0) { + sign = 1; + signedQuarter = -signedQuarter; + } else { + sign = 0; + } + hours = static_cast(signedQuarter / 4); + quarter = static_cast(signedQuarter % 4); +} +} // namespace + +void ClockOffsetActivity::onEnter() { + Activity::onEnter(); + loadFromSettings(); + activeField = FIELD_HOURS; + requestUpdate(); +} + +void ClockOffsetActivity::onExit() { + saveToSettings(); + Activity::onExit(); +} + +void ClockOffsetActivity::loadFromSettings() { + decodeOffset(SETTINGS.clockUtcOffsetQ, sign, hours, minutesQuarter); + clampForSign(); +} + +void ClockOffsetActivity::saveToSettings() const { + const uint8_t encoded = encodeOffset(sign, hours, minutesQuarter); + if (encoded == SETTINGS.clockUtcOffsetQ) return; + SETTINGS.clockUtcOffsetQ = encoded; + SETTINGS.saveToFile(); +} + +void ClockOffsetActivity::clampForSign() { + const uint8_t maxHours = (sign == 1) ? MAX_NEG_HOURS : MAX_POS_HOURS; + if (hours > maxHours) hours = maxHours; + // At the absolute boundary (-12:00 or +14:00) only :00 is valid. + if (hours == maxHours && minutesQuarter != 0) { + minutesQuarter = 0; + } +} + +void ClockOffsetActivity::adjustActiveField(int delta) { + switch (activeField) { + case FIELD_SIGN: { + sign = static_cast((sign + 1) % 2); + clampForSign(); + break; + } + case FIELD_HOURS: { + const uint8_t maxHours = (sign == 1) ? MAX_NEG_HOURS : MAX_POS_HOURS; + const int next = (static_cast(hours) + delta + (maxHours + 1)) % (maxHours + 1); + hours = static_cast(next); + clampForSign(); + break; + } + case FIELD_MINUTES: { + // At the boundary hour, lock minutes to :00. + const uint8_t maxHours = (sign == 1) ? MAX_NEG_HOURS : MAX_POS_HOURS; + if (hours == maxHours) { + minutesQuarter = 0; + break; + } + const int next = (static_cast(minutesQuarter) + delta + MINUTE_STEPS) % MINUTE_STEPS; + minutesQuarter = static_cast(next); + break; + } + default: + break; + } +} + +void ClockOffsetActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + activeField = static_cast((activeField + 1) % FIELD_COUNT); + requestUpdate(); + return; + } + + buttonNavigator.onNextRelease([this] { + adjustActiveField(+1); + requestUpdate(); + }); + buttonNavigator.onPreviousRelease([this] { + adjustActiveField(-1); + requestUpdate(); + }); + buttonNavigator.onNextContinuous([this] { + adjustActiveField(+1); + requestUpdate(); + }); + buttonNavigator.onPreviousContinuous([this] { + adjustActiveField(-1); + requestUpdate(); + }); +} + +void ClockOffsetActivity::render(RenderLock&&) { + renderer.clearScreen(); + + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_CLOCK_UTC_OFFSET)); + + // Build the offset string. Use a generous font and centre it. + char offsetBuf[16]; + snprintf(offsetBuf, sizeof(offsetBuf), "UTC %c %d:%02d", sign == 1 ? '-' : '+', hours, + minutesQuarter * MINUTES_PER_QUARTER); + + const int centreY = pageHeight / 2 - 40; + renderer.drawCenteredText(UI_12_FONT_ID, centreY, offsetBuf, true, EpdFontFamily::BOLD); + + // Underline / caret under the active field. Compute positions by measuring substrings of the + // formatted string so the caret follows the font glyph widths exactly. + // Field substrings: + // "UTC " -> prefix + // "{+/-}" -> sign + // " " + // "{hours}" -> hours + // ":" + // "{mm}" -> minutes + auto widthOf = [&](const char* s) { return renderer.getTextWidth(UI_12_FONT_ID, s); }; + const int totalWidth = widthOf(offsetBuf); + const int leftEdge = (pageWidth - totalWidth) / 2; + + // Locate each field by reformatting prefixes. + char prefixSign[16]; + snprintf(prefixSign, sizeof(prefixSign), "UTC "); + const int signX = leftEdge + widthOf(prefixSign); + + char prefixHours[16]; + snprintf(prefixHours, sizeof(prefixHours), "UTC %c ", sign == 1 ? '-' : '+'); + const int hoursX = leftEdge + widthOf(prefixHours); + + char prefixMinutes[16]; + snprintf(prefixMinutes, sizeof(prefixMinutes), "UTC %c %d:", sign == 1 ? '-' : '+', hours); + const int minutesX = leftEdge + widthOf(prefixMinutes); + + // Width of each field substring for the caret span. + const int signW = widthOf(sign == 1 ? "-" : "+"); + char hoursStr[8]; + snprintf(hoursStr, sizeof(hoursStr), "%d", hours); + const int hoursW = widthOf(hoursStr); + char minutesStr[8]; + snprintf(minutesStr, sizeof(minutesStr), "%02d", minutesQuarter * MINUTES_PER_QUARTER); + const int minutesW = widthOf(minutesStr); + + int caretX = 0; + int caretW = 0; + switch (activeField) { + case FIELD_SIGN: + caretX = signX; + caretW = signW; + break; + case FIELD_HOURS: + caretX = hoursX; + caretW = hoursW; + break; + case FIELD_MINUTES: + caretX = minutesX; + caretW = minutesW; + break; + default: + break; + } + // Caret drawn as a short bar below the active field. + const int caretY = centreY + 10; + for (int dy = 0; dy < 2; dy++) { + renderer.drawLine(caretX, caretY + dy, caretX + caretW, caretY + dy); + } + + // Live preview of the resulting wall-clock time, so users can verify against a watch. + if (halClock.isAvailable()) { + char timeBuf[9]; + const uint8_t encoded = encodeOffset(sign, hours, minutesQuarter); + if (halClock.formatTime(timeBuf, sizeof(timeBuf), encoded, SETTINGS.clockFormat == 1)) { + char preview[24]; + snprintf(preview, sizeof(preview), "%s %s", tr(STR_CURRENT_TIME), timeBuf); + renderer.drawCenteredText(UI_10_FONT_ID, centreY + 60, preview); + } + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_NEXT_FIELD), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/ClockOffsetActivity.h b/src/activities/settings/ClockOffsetActivity.h new file mode 100644 index 00000000..8f25ee4c --- /dev/null +++ b/src/activities/settings/ClockOffsetActivity.h @@ -0,0 +1,37 @@ +#pragma once + +#include "activities/Activity.h" +#include "util/ButtonNavigator.h" + +// Dedicated UTC offset picker for the status bar clock. +// Three editable fields (sign, hours, minutes); Confirm cycles fields, Up/Down adjust the active one. +// Supports the full IANA UTC offset range in 15 minute steps, including oddball zones like Nepal (+5:45). +class ClockOffsetActivity final : public Activity { + public: + explicit ClockOffsetActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("ClockOffset", renderer, mappedInput) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + + private: + ButtonNavigator buttonNavigator; + + enum Field { FIELD_SIGN = 0, FIELD_HOURS = 1, FIELD_MINUTES = 2, FIELD_COUNT }; + Field activeField = FIELD_HOURS; + + // Working copy of the offset, edited in-place. Saved back to SETTINGS on exit. + // 0 = positive offset, 1 = negative offset. + uint8_t sign = 0; + // Hours: 0..14 when positive, 0..12 when negative. + uint8_t hours = 0; + // Quarter-hour index 0..3 (0, 15, 30, 45). + uint8_t minutesQuarter = 0; + + void loadFromSettings(); + void saveToSettings() const; + void adjustActiveField(int delta); + void clampForSign(); +}; diff --git a/src/activities/settings/ClockSyncActivity.cpp b/src/activities/settings/ClockSyncActivity.cpp new file mode 100644 index 00000000..407af69a --- /dev/null +++ b/src/activities/settings/ClockSyncActivity.cpp @@ -0,0 +1,108 @@ +#include "ClockSyncActivity.h" + +#include +#include +#include +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "components/UITheme.h" +#include "fontIds.h" + +void ClockSyncActivity::onEnter() { + Activity::onEnter(); + state = SYNCING; + syncedTime[0] = '\0'; + requestUpdate(); +} + +void ClockSyncActivity::onExit() { Activity::onExit(); } + +void ClockSyncActivity::runSync() { + if (WiFi.status() != WL_CONNECTED) { + LOG_INF("CLK", "Manual sync requested but WiFi is not connected"); + state = NO_WIFI; + requestUpdate(); + return; + } + + const bool ok = halClock.syncFromNTP(); + if (!ok) { + state = FAILED; + requestUpdate(); + return; + } + + // Mark as synced so the auto-sync hook stops firing on future WiFi connects. + SETTINGS.clockHasBeenSynced = 1; + SETTINGS.saveToFile(); + + // Read the freshly synced time back for the user-facing confirmation. + char buf[9]; + if (halClock.formatTime(buf, sizeof(buf), SETTINGS.clockUtcOffsetQ, SETTINGS.clockFormat == 1)) { + snprintf(syncedTime, sizeof(syncedTime), "%s", buf); + } + state = SUCCESS; + requestUpdate(); +} + +void ClockSyncActivity::loop() { + if (state == SYNCING) { + // First-tick: render the "Syncing..." screen, then perform the (blocking) sync. + // requestUpdateAndWait below forces the render before we block on WiFi. + requestUpdateAndWait(); + runSync(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Back) || + mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + finish(); + } +} + +void ClockSyncActivity::render(RenderLock&&) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_CLOCK_SYNC)); + + const int midY = pageHeight / 2; + + switch (state) { + case SYNCING: + renderer.drawCenteredText(UI_12_FONT_ID, midY, tr(STR_CLOCK_SYNCING)); + break; + case SUCCESS: { + renderer.drawCenteredText(UI_12_FONT_ID, midY - 20, tr(STR_CLOCK_SYNC_OK), true, EpdFontFamily::BOLD); + if (syncedTime[0] != '\0') { + char line[32]; + snprintf(line, sizeof(line), "%s %s", tr(STR_CURRENT_TIME), syncedTime); + renderer.drawCenteredText(UI_10_FONT_ID, midY + 10, line); + } + break; + } + case NO_WIFI: + renderer.drawCenteredText(UI_12_FONT_ID, midY - 20, tr(STR_CLOCK_SYNC_NO_WIFI), true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, midY + 10, tr(STR_CLOCK_SYNC_NO_WIFI_HINT)); + break; + case FAILED: + renderer.drawCenteredText(UI_12_FONT_ID, midY - 20, tr(STR_CLOCK_SYNC_FAIL), true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, midY + 10, tr(STR_CHECK_SERIAL_OUTPUT)); + break; + } + + if (state != SYNCING) { + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_OK_BUTTON), "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + } + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/ClockSyncActivity.h b/src/activities/settings/ClockSyncActivity.h new file mode 100644 index 00000000..887ee2e5 --- /dev/null +++ b/src/activities/settings/ClockSyncActivity.h @@ -0,0 +1,24 @@ +#pragma once + +#include "activities/Activity.h" + +// Manual NTP resync action. Runs a forced sync (bypassing the once-per-device debounce), +// reports success/failure, then waits for Back. Requires WiFi to already be connected. +class ClockSyncActivity final : public Activity { + public: + explicit ClockSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("ClockSync", renderer, mappedInput) {} + + void onEnter() override; + void onExit() override; + void loop() override; + bool skipLoopDelay() override { return true; } + void render(RenderLock&&) override; + + private: + enum State { SYNCING, SUCCESS, NO_WIFI, FAILED }; + State state = SYNCING; + char syncedTime[16] = {0}; + + void runSync(); +}; diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index f62ce4d4..8108ae57 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -1,24 +1,69 @@ #include "StatusBarSettingsActivity.h" #include +#include #include #include +#include +#include "ClockOffsetActivity.h" +#include "ClockSyncActivity.h" #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" namespace { -constexpr int MENU_ITEMS = 7; -const StrId menuNames[MENU_ITEMS] = {StrId::STR_CHAPTER_PAGE_COUNT, - StrId::STR_BOOK_PROGRESS_PERCENTAGE, - StrId::STR_PROGRESS_BAR, - StrId::STR_PROGRESS_BAR_THICKNESS, - StrId::STR_TITLE, - StrId::STR_BATTERY, - StrId::STR_XTC_STATUS_BAR}; +// Menu items in their natural order. Clock entries are appended only when the +// DS3231 RTC is present so X4 devices don't see them at all. +enum MenuItem { + ITEM_CHAPTER_PAGE_COUNT = 0, + ITEM_BOOK_PROGRESS_PERCENTAGE, + ITEM_PROGRESS_BAR, + ITEM_PROGRESS_BAR_THICKNESS, + ITEM_TITLE, + ITEM_BATTERY, + ITEM_XTC_STATUS_BAR, + ITEM_CLOCK, // X3 only + ITEM_CLOCK_FORMAT, // X3 only + ITEM_CLOCK_UTC_OFFSET, // X3 only, launches ClockOffsetActivity + ITEM_CLOCK_SYNC, // X3 only, launches ClockSyncActivity + ITEM_COUNT +}; + +constexpr int BASE_MENU_ITEMS = ITEM_CLOCK; // Items shown on every device +constexpr int FULL_MENU_ITEMS = ITEM_COUNT; // Items shown when RTC is available + +const StrId menuNames[FULL_MENU_ITEMS] = { + StrId::STR_CHAPTER_PAGE_COUNT, + StrId::STR_BOOK_PROGRESS_PERCENTAGE, + StrId::STR_PROGRESS_BAR, + StrId::STR_PROGRESS_BAR_THICKNESS, + StrId::STR_TITLE, + StrId::STR_BATTERY, + StrId::STR_XTC_STATUS_BAR, + StrId::STR_CLOCK, + StrId::STR_CLOCK_FORMAT, + StrId::STR_CLOCK_UTC_OFFSET, + StrId::STR_CLOCK_SYNC_NOW, +}; + +constexpr int CLOCK_FORMAT_ITEMS = 2; +const StrId clockFormatNames[CLOCK_FORMAT_ITEMS] = {StrId::STR_CLOCK_FORMAT_24H, StrId::STR_CLOCK_FORMAT_12H}; + +std::string formatUtcOffset(uint8_t biasedQ) { + // biasedQ is in quarter-hour steps, biased by 48 (so 48 = UTC+0). + if (biasedQ > 104) biasedQ = 48; + int totalMinutes = (static_cast(biasedQ) - 48) * 15; + bool neg = totalMinutes < 0; + int absMinutes = neg ? -totalMinutes : totalMinutes; + int hours = absMinutes / 60; + int mins = absMinutes % 60; + char buf[16]; + snprintf(buf, sizeof(buf), "UTC%c%d:%02d", neg ? '-' : '+', hours, mins); + return buf; +} constexpr int PROGRESS_BAR_ITEMS = 3; const StrId progressBarNames[PROGRESS_BAR_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}; @@ -32,7 +77,6 @@ const StrId titleNames[TITLE_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrI constexpr int XTC_STATUS_BAR_ITEMS = 3; const StrId xtcStatusBarNames[XTC_STATUS_BAR_ITEMS] = {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}; -const int widthMargin = 10; const int verticalPreviewPadding = 50; const int verticalPreviewTextPadding = 40; } // namespace @@ -41,6 +85,7 @@ void StatusBarSettingsActivity::onEnter() { Activity::onEnter(); selectedIndex = 0; + visibleItemCount = halClock.isAvailable() ? FULL_MENU_ITEMS : BASE_MENU_ITEMS; // Clamp statusBarProgressBar and statusBarTitle in case of corrupt/migrated data if (SETTINGS.statusBarProgressBar >= PROGRESS_BAR_ITEMS) { @@ -59,6 +104,14 @@ void StatusBarSettingsActivity::onEnter() { SETTINGS.xtcStatusBarMode = CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_HIDE; } + if (SETTINGS.clockUtcOffsetQ > 104) { + SETTINGS.clockUtcOffsetQ = 48; // Default to UTC+0 + } + + if (SETTINGS.clockFormat >= CLOCK_FORMAT_ITEMS) { + SETTINGS.clockFormat = 0; + } + requestUpdate(); } @@ -78,49 +131,65 @@ void StatusBarSettingsActivity::loop() { // Handle navigation buttonNavigator.onNextRelease([this] { - selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEMS); + selectedIndex = ButtonNavigator::nextIndex(selectedIndex, visibleItemCount); requestUpdate(); }); buttonNavigator.onPreviousRelease([this] { - selectedIndex = ButtonNavigator::previousIndex(selectedIndex, MENU_ITEMS); + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, visibleItemCount); requestUpdate(); }); buttonNavigator.onNextContinuous([this] { - selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEMS); + selectedIndex = ButtonNavigator::nextIndex(selectedIndex, visibleItemCount); requestUpdate(); }); buttonNavigator.onPreviousContinuous([this] { - selectedIndex = ButtonNavigator::previousIndex(selectedIndex, MENU_ITEMS); + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, visibleItemCount); requestUpdate(); }); } void StatusBarSettingsActivity::handleSelection() { - if (selectedIndex == 0) { - // Chapter Page Count - SETTINGS.statusBarChapterPageCount = (SETTINGS.statusBarChapterPageCount + 1) % 2; - } else if (selectedIndex == 1) { - // Book Progress % - SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2; - } else if (selectedIndex == 2) { - // Progress Bar - SETTINGS.statusBarProgressBar = (SETTINGS.statusBarProgressBar + 1) % PROGRESS_BAR_ITEMS; - } else if (selectedIndex == 3) { - // Progress Bar Thickness - SETTINGS.statusBarProgressBarThickness = - (SETTINGS.statusBarProgressBarThickness + 1) % PROGRESS_BAR_THICKNESS_ITEMS; - } else if (selectedIndex == 4) { - // Chapter Title - SETTINGS.statusBarTitle = (SETTINGS.statusBarTitle + 1) % TITLE_ITEMS; - } else if (selectedIndex == 5) { - // Show Battery - SETTINGS.statusBarBattery = (SETTINGS.statusBarBattery + 1) % 2; - } else if (selectedIndex == 6) { - // XTC Status Bar - SETTINGS.xtcStatusBarMode = (SETTINGS.xtcStatusBarMode + 1) % XTC_STATUS_BAR_ITEMS; + switch (selectedIndex) { + case ITEM_CHAPTER_PAGE_COUNT: + SETTINGS.statusBarChapterPageCount = (SETTINGS.statusBarChapterPageCount + 1) % 2; + break; + case ITEM_BOOK_PROGRESS_PERCENTAGE: + SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2; + break; + case ITEM_PROGRESS_BAR: + SETTINGS.statusBarProgressBar = (SETTINGS.statusBarProgressBar + 1) % PROGRESS_BAR_ITEMS; + break; + case ITEM_PROGRESS_BAR_THICKNESS: + SETTINGS.statusBarProgressBarThickness = + (SETTINGS.statusBarProgressBarThickness + 1) % PROGRESS_BAR_THICKNESS_ITEMS; + break; + case ITEM_TITLE: + SETTINGS.statusBarTitle = (SETTINGS.statusBarTitle + 1) % TITLE_ITEMS; + break; + case ITEM_BATTERY: + SETTINGS.statusBarBattery = (SETTINGS.statusBarBattery + 1) % 2; + break; + case ITEM_XTC_STATUS_BAR: + SETTINGS.xtcStatusBarMode = (SETTINGS.xtcStatusBarMode + 1) % XTC_STATUS_BAR_ITEMS; + break; + case ITEM_CLOCK: + SETTINGS.statusBarClock = (SETTINGS.statusBarClock + 1) % 2; + break; + case ITEM_CLOCK_FORMAT: + SETTINGS.clockFormat = (SETTINGS.clockFormat + 1) % CLOCK_FORMAT_ITEMS; + break; + case ITEM_CLOCK_UTC_OFFSET: + // Launch the dedicated offset picker. It saves on exit, no result handler needed. + startActivityForResult(std::make_unique(renderer, mappedInput), nullptr); + return; + case ITEM_CLOCK_SYNC: + startActivityForResult(std::make_unique(renderer, mappedInput), nullptr); + return; + default: + return; } SETTINGS.saveToFile(); } @@ -137,27 +206,36 @@ void StatusBarSettingsActivity::render(RenderLock&&) { const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; GUI.drawList( - renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast(MENU_ITEMS), - static_cast(selectedIndex), [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr, - nullptr, - [this](int index) { - // Draw status for each setting - if (index == 0) { - return SETTINGS.statusBarChapterPageCount ? tr(STR_SHOW) : tr(STR_HIDE); - } else if (index == 1) { - return SETTINGS.statusBarBookProgressPercentage ? tr(STR_SHOW) : tr(STR_HIDE); - } else if (index == 2) { - return I18N.get(progressBarNames[SETTINGS.statusBarProgressBar]); - } else if (index == 3) { - return I18N.get(progressBarThicknessNames[SETTINGS.statusBarProgressBarThickness]); - } else if (index == 4) { - return I18N.get(titleNames[SETTINGS.statusBarTitle]); - } else if (index == 5) { - return SETTINGS.statusBarBattery ? tr(STR_SHOW) : tr(STR_HIDE); - } else if (index == 6) { - return I18N.get(xtcStatusBarNames[SETTINGS.xtcStatusBarMode]); - } else { - return tr(STR_HIDE); + renderer, Rect{0, contentTop, pageWidth, contentHeight}, visibleItemCount, static_cast(selectedIndex), + [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr, nullptr, + [](int index) -> std::string { + switch (index) { + case ITEM_CHAPTER_PAGE_COUNT: + return SETTINGS.statusBarChapterPageCount ? tr(STR_SHOW) : tr(STR_HIDE); + case ITEM_BOOK_PROGRESS_PERCENTAGE: + return SETTINGS.statusBarBookProgressPercentage ? tr(STR_SHOW) : tr(STR_HIDE); + case ITEM_PROGRESS_BAR: + return I18N.get(progressBarNames[SETTINGS.statusBarProgressBar]); + case ITEM_PROGRESS_BAR_THICKNESS: + return I18N.get(progressBarThicknessNames[SETTINGS.statusBarProgressBarThickness]); + case ITEM_TITLE: + return I18N.get(titleNames[SETTINGS.statusBarTitle]); + case ITEM_BATTERY: + return SETTINGS.statusBarBattery ? tr(STR_SHOW) : tr(STR_HIDE); + case ITEM_XTC_STATUS_BAR: + return I18N.get(xtcStatusBarNames[SETTINGS.xtcStatusBarMode]); + case ITEM_CLOCK: + return SETTINGS.statusBarClock ? tr(STR_SHOW) : tr(STR_HIDE); + case ITEM_CLOCK_FORMAT: { + const uint8_t fmt = SETTINGS.clockFormat < CLOCK_FORMAT_ITEMS ? SETTINGS.clockFormat : 0; + return std::string(I18N.get(clockFormatNames[fmt])); + } + case ITEM_CLOCK_UTC_OFFSET: + return formatUtcOffset(SETTINGS.clockUtcOffsetQ); + case ITEM_CLOCK_SYNC: + return SETTINGS.clockHasBeenSynced ? tr(STR_CLOCK_SYNCED) : tr(STR_NOT_SET); + default: + return tr(STR_HIDE); } }, true); diff --git a/src/activities/settings/StatusBarSettingsActivity.h b/src/activities/settings/StatusBarSettingsActivity.h index 2cbc86dc..7029767c 100644 --- a/src/activities/settings/StatusBarSettingsActivity.h +++ b/src/activities/settings/StatusBarSettingsActivity.h @@ -21,6 +21,8 @@ class StatusBarSettingsActivity final : public Activity { ButtonNavigator buttonNavigator; int selectedIndex = 0; + // Decided in onEnter() based on halClock.isAvailable() so clock entries are hidden on X4. + int visibleItemCount = 0; void handleSelection(); }; diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 0650948b..250659b5 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -1,6 +1,7 @@ #include "BaseTheme.h" #include +#include #include #include #include @@ -758,6 +759,19 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c showBatteryPercentage); } + // Draw Clock (X3 only — DS3231 RTC) + int clockTextWidth = 0; + if (SETTINGS.statusBarClock && halClock.isAvailable()) { + char timeBuf[9]; + if (halClock.formatTime(timeBuf, sizeof(timeBuf), SETTINGS.clockUtcOffsetQ, SETTINGS.clockFormat == 1)) { + clockTextWidth = renderer.getTextWidth(SMALL_FONT_ID, timeBuf); + // Position to the left of the progress text (with a small gap) + const int clockX = renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight - + progressTextWidth - (progressTextWidth > 0 ? 10 : 0) - clockTextWidth; + renderer.drawText(SMALL_FONT_ID, clockX, textY, timeBuf); + } + } + // Draw Title if (!title.empty()) { textY -= textYOffset; @@ -768,7 +782,8 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c const int batterySize = SETTINGS.statusBarBattery ? (showBatteryPercentage ? 50 : 20) : 0; const int titleMarginLeft = batterySize + 30; - const int titleMarginRight = progressTextWidth + 30; + const int clockReserve = clockTextWidth > 0 ? (clockTextWidth + 10) : 0; + const int titleMarginRight = progressTextWidth + clockReserve + 30; // Attempt to center title on the screen, but if title is too wide then later we will center it within the // available space. diff --git a/src/main.cpp b/src/main.cpp index 9ff49684..9c78f11c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -273,6 +274,7 @@ void setup() { gpio.begin(); powerManager.begin(); halTiltSensor.begin(); + halClock.begin(); #ifdef ENABLE_SERIAL_LOG if (gpio.isUsbConnected()) {