From dd826a447e408fa807b6ba011ab82fb292961e8f Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:03:57 +0200 Subject: [PATCH] refactor(ui-core): move clock tools into ClockEngine; add Core event queue Alarm / countdown / ring logic moves from UITask to ui-core/ClockEngine.h unchanged. New ui-core/UiEvents.h: fixed-size Core -> frontend event queue; UITask::tickCore() runs UiCore::loop() and reacts to ClockAlert / ClockRingEnded (wake, alert overlay, melody). ClockToolsScreen API unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/development/ui-core.md | 4 +- .../companion_radio/ui-core/ClockEngine.h | 132 ++++++++++++++++++ examples/companion_radio/ui-core/UiCore.h | 16 +++ examples/companion_radio/ui-core/UiEvents.h | 51 +++++++ .../companion_radio/ui-new/ClockToolsScreen.h | 9 +- examples/companion_radio/ui-new/UITask.cpp | 126 +++++------------ examples/companion_radio/ui-new/UITask.h | 46 ++---- 7 files changed, 253 insertions(+), 131 deletions(-) create mode 100644 examples/companion_radio/ui-core/ClockEngine.h create mode 100644 examples/companion_radio/ui-core/UiEvents.h diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index a5a2382f..f78c0c4f 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -38,7 +38,7 @@ interleaved. Classified: - Locator — geofence state machine, proximity beeper (`evaluateLocator`, `fireLocator`, targets). - Trail — sampling, auto-pause, low-battery auto-save. - Course over ground — `pushCogFix`, `currentCourse`, `currentLocation`. -- Clock tools — alarm / countdown / ring (`evaluateAlarm`, `tickClockTools`, `fireClockAlert`). +- Clock tools — alarm / countdown / ring ✅ `ui-core/ClockEngine.h`. - Ping — `startPing`, `handlePingResult`. - Device controls — GPS on/off, GPIO (`setGpioMode`, bot GPIO), buzzer mode/volume, brightness, radio apply (`applyTxPower`, `applyApc`, `applyRadioParams`, …). - Bot hooks — `botSetGPS`, `botBuzz`, `botSetGPIO`, … @@ -151,7 +151,7 @@ checked in the sim plus on L1 hardware before the next one. 0. **Listener boundary** ✅ (upstream `MyMesh::Listener` ported; `MyMesh` has no UI calls left). 1. **Skeleton** ✅. `examples/companion_radio/ui-core/` with `UiCore.h` (facade), `MessageHistory.h`, `DmUnreadTable.h`. Header-only for now, reached from `ui-new/UITask.cpp` by relative include, so none of the 66 variant `platformio.ini` files that build `ui-new` change. `UITask` heap-allocates one `UiCore` in `begin()` (before the screens, as `MessagesScreen` used to own the history on the heap); `MessagesScreen` binds to `core().history` by reference. When the Core grows real `.cpp` files, compile them through a unity `.cpp` inside each frontend directory. -2. **Engines, one per commit.** Clock tools → ping → course-over-ground → live share → locator → trail → notifications. `UITask` shrinks to screen management + drawing. +2. **Engines, one per commit.** Clock tools ✅ (also introduced `ui-core/UiEvents.h`, the Core → frontend event queue; `UITask::tickCore()` runs `UiCore::loop()` and drains it) → ping → course-over-ground → live share → locator → trail → notifications. `UITask` shrinks to screen management + drawing. 3. **Flip the interface.** `UiCore` implements `AbstractUITask`; `ui-new`'s `UITask` becomes a frontend fed by events. 4. **Settings schema.** Convert `SettingsScreen` section by section. 5. **`ui-lvgl` skeleton** for L2 + sim target: boot, home, message list/conversation, keyboard. Then screens by priority. diff --git a/examples/companion_radio/ui-core/ClockEngine.h b/examples/companion_radio/ui-core/ClockEngine.h new file mode 100644 index 00000000..38be535d --- /dev/null +++ b/examples/companion_radio/ui-core/ClockEngine.h @@ -0,0 +1,132 @@ +#pragma once +// Clock tools engine: the alarm (wall-clock, persisted in NodePrefs), the +// countdown timer and the ring window that follows either firing. Runs from +// UiCore::loop() every frontend loop so it fires regardless of the current +// screen. The stopwatch is pure view state and stays in the frontend. +// +// The alarm is scheduled as an ABSOLUTE wall instant, recomputed from the stored +// time-of-day, so it survives RTC re-syncs (mesh/app/GPS/CLI all jump the +// clock) -- small corrections still fire on time, a jump over the target still +// fires (late). Timer + ring are millis-based. +// +// Output: UiEventType::ClockAlert when something fires, ClockRingEnded when the +// ring window lapses undismissed. While isRinging() the frontend keeps the +// melody going (it owns the buzzer). + +#include "UiEvents.h" + +class ClockEngine { +public: + static const uint32_t RING_MS = 60000; + static const uint32_t ALARM_CATCHUP_SECS = 6 * 3600; // fire late up to 6 h, else reschedule + + void begin(NodePrefs* prefs, UiEventQueue* events) { _prefs = prefs; _events = events; } + + void loop() { + uint32_t now_ms = millis(); + // Ring window. Signed-difference compares (like the trail/loc-share timers) + // so deadlines landing past the millis() rollover don't read as elapsed. + if (_ringing && (int32_t)(now_ms - _ring_until_ms) >= 0) { + _ringing = false; + _events->push(UiEventType::ClockRingEnded); + } + // Countdown timer (millis -- sync-immune). + if (_timer_running && (int32_t)(now_ms - _timer_deadline_ms) >= 0) { + _timer_running = false; + fire("Timer done"); + } + // Alarm (wall clock -- absolute schedule for sync robustness). + evaluateAlarm(); + } + + // ── Alarm ────────────────────────────────────────────────────────────────── + void onAlarmChanged() { _alarm_next_fire = 0; } // re-schedule after an alarm edit + + // ── Countdown ────────────────────────────────────────────────────────────── + void startTimer(uint32_t duration_ms) { _timer_running = true; _timer_deadline_ms = millis() + duration_ms; } + void stopTimer() { _timer_running = false; } + bool isTimerRunning() const { return _timer_running; } + uint32_t timerRemainingMs() const { + if (!_timer_running) return 0; + uint32_t now = millis(); + if ((int32_t)(now - _timer_deadline_ms) >= 0) return 0; + return _timer_deadline_ms - now; + } + + // ── Ring ─────────────────────────────────────────────────────────────────── + bool isRinging() const { return _ringing; } + void dismissRing() { _ringing = false; } + +private: + void fire(const char* label) { + _ringing = true; + _ring_until_ms = millis() + RING_MS; + _events->push(UiEventType::ClockAlert, label); + } + + // Next absolute wall instant matching alarm_hour:alarm_min in local time, + // strictly after now_wall (an alarm set to the current minute waits a day). + // With alarm_repeat_mask == 0 that's just tomorrow's occurrence (one-shot). + // With a repeat mask set, scan today..+6 days for the next weekday whose bit + // is set (struct tm's tm_wday convention, same as the mask) -- today counts + // only if its time hasn't already passed. + uint32_t computeAlarmNextFire(uint32_t now_wall) const { + int tz = _prefs->tz_offset_hours; + int64_t now_local = (int64_t)now_wall + (int64_t)tz * 3600; + time_t t = (time_t)now_local; + struct tm* ti = gmtime(&t); + int64_t sod = ti->tm_hour * 3600 + ti->tm_min * 60 + ti->tm_sec; // secs since local midnight + int64_t midnight = now_local - sod; + int64_t time_of_day = (int64_t)_prefs->alarm_hour * 3600 + (int64_t)_prefs->alarm_min * 60; + uint8_t mask = _prefs->alarm_repeat_mask; + if (mask != 0) { + for (int d = 0; d < 7; d++) { + if (mask & (1 << ((ti->tm_wday + d) % 7))) { + int64_t target = midnight + (int64_t)d * 86400 + time_of_day; + if (target > now_local) return (uint32_t)(target - (int64_t)tz * 3600); + } + } + // Mask had no bit set (shouldn't happen -- the UI only offers non-empty + // presets) -- fall through to the one-shot calculation so it still fires. + } + int64_t target = midnight + time_of_day; + if (target <= now_local) target += 86400; + return (uint32_t)(target - (int64_t)tz * 3600); + } + + void evaluateAlarm() { + if (!_prefs || !_prefs->alarm_on) return; + uint32_t now_ms = millis(); + if (now_ms - _alarm_check_ms < 500) return; // ~2 Hz is plenty for a minute alarm + _alarm_check_ms = now_ms; + uint32_t now_wall = rtc_clock.getCurrentTime(); + if (now_wall < 1000000000UL) return; // need a real time sync first + if (_alarm_next_fire == 0) _alarm_next_fire = computeAlarmNextFire(now_wall); + if (now_wall < _alarm_next_fire) return; + if (now_wall - _alarm_next_fire < ALARM_CATCHUP_SECS) { + char lbl[20]; + snprintf(lbl, sizeof(lbl), "Alarm %02d:%02d", _prefs->alarm_hour, _prefs->alarm_min); + if (_prefs->alarm_repeat_mask == 0) { + _prefs->alarm_on = 0; // one-shot + the_mesh.savePrefs(); + } + // Repeating: alarm_on stays set: computeAlarmNextFire() re-arms it for the + // next matching weekday. + _alarm_next_fire = 0; + fire(lbl); + } else { + // Clock jumped implausibly far past the target -- reschedule rather than + // ringing absurdly late. + _alarm_next_fire = computeAlarmNextFire(now_wall); + } + } + + NodePrefs* _prefs = nullptr; + UiEventQueue* _events = nullptr; + uint32_t _alarm_next_fire = 0; // unix; 0 = (re)compute lazily once time is valid + uint32_t _alarm_check_ms = 0; // throttle the wall-clock read to ~2 Hz + bool _timer_running = false; + uint32_t _timer_deadline_ms = 0; + bool _ringing = false; + uint32_t _ring_until_ms = 0; +}; diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index 1471e5ff..fcf24720 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -9,9 +9,25 @@ #include "MessageHistory.h" #include "DmUnreadTable.h" +#include "UiEvents.h" +#include "ClockEngine.h" class UiCore { public: + void begin(NodePrefs* prefs) { + clock.begin(prefs, &events); + } + + // Driven from the frontend's loop(), before it drains `events`. + void loop() { + clock.loop(); + } + + UiEventQueue events; // Core → frontend; drained by the frontend's loop() + + // ── Engines ─────────────────────────────────────────────────────────────── + ClockEngine clock; // alarm / countdown / ring + // ── Models ──────────────────────────────────────────────────────────────── MessageHistory history; // channel + DM rings, delivery state, channel unread DmUnreadTable dm_unread; // per-contact DM unread counters diff --git a/examples/companion_radio/ui-core/UiEvents.h b/examples/companion_radio/ui-core/UiEvents.h new file mode 100644 index 00000000..e464e442 --- /dev/null +++ b/examples/companion_radio/ui-core/UiEvents.h @@ -0,0 +1,51 @@ +#pragma once +// Core → frontend events. Engines never call into the frontend (no drawing, no +// sound, no display power); they push a tagged event here and the frontend +// drains the queue from its own loop() and reacts in its own way (ui-new: +// alert overlay + buzzer; ui-lvgl: a dialog). See docs/development/ui-core.md. +// +// Fixed-size ring, no heap. When full the oldest event is dropped -- events are +// hints for the view; the authoritative state stays queryable on the engines. + +enum class UiEventType : uint8_t { + None = 0, + ClockAlert, // alarm / countdown fired: wake, show `text`, start the ring melody + ClockRingEnded, // ring window elapsed with no dismiss: stop melody, clear alert +}; + +struct UiEvent { + UiEventType type; + char text[20]; +}; + +class UiEventQueue { +public: + static const int SIZE = 8; + + UiEventQueue() : _head(0), _count(0) {} + + void push(UiEventType type, const char* text = nullptr) { + int pos; + if (_count < SIZE) { pos = (_head + _count) % SIZE; _count++; } + else { pos = _head; _head = (_head + 1) % SIZE; } // drop oldest + _q[pos].type = type; + if (text) { + strncpy(_q[pos].text, text, sizeof(_q[pos].text) - 1); + _q[pos].text[sizeof(_q[pos].text) - 1] = '\0'; + } else { + _q[pos].text[0] = '\0'; + } + } + + bool pop(UiEvent& out) { + if (_count == 0) return false; + out = _q[_head]; + _head = (_head + 1) % SIZE; + _count--; + return true; + } + +private: + UiEvent _q[SIZE]; + int _head, _count; +}; diff --git a/examples/companion_radio/ui-new/ClockToolsScreen.h b/examples/companion_radio/ui-new/ClockToolsScreen.h index 1f2eb1a4..4c65729c 100644 --- a/examples/companion_radio/ui-new/ClockToolsScreen.h +++ b/examples/companion_radio/ui-new/ClockToolsScreen.h @@ -3,12 +3,13 @@ // Entered with Enter on the home CLOCK page. A small top menu picks one of the // three tools; Cancel backs out a level (tool → menu → home). // -// This screen is pure UI. The time-critical machinery lives in UITask, which -// drives it every loop regardless of the current screen: -// • Alarm — UITask schedules an absolute fire instant from NodePrefs' +// This screen is pure UI. The time-critical machinery lives in the UI Core's +// ClockEngine (ui-core/ClockEngine.h), which UITask drives every loop +// regardless of the current screen: +// • Alarm — the engine schedules an absolute fire instant from NodePrefs' // alarm_hour/min (robust to RTC re-syncs) and rings. Here we just edit the // persisted fields and call onAlarmChanged() to re-schedule. -// • Timer — UITask owns the running countdown (startTimer / stopTimer / +// • Timer — the engine owns the running countdown (startTimer / stopTimer / // isTimerRunning / timerRemainingMs). It rings even when off-screen. // • Stopwatch — purely a display utility with no background action, so its // millis() state lives here; it keeps counting while you're elsewhere. diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 41c3bac6..8ca4498b 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1655,6 +1655,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no _ping_rtt_ms = 0; _core = new UiCore(); // before any screen -- MessagesScreen binds to its history + _core->begin(node_prefs); splash = new SplashScreen(this); home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); syncLockToHome(); // booted locked (e.g. cover closed) → home starts on the LOCK page @@ -1712,13 +1713,10 @@ void UITask::gotoGpioScreen() { } void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); } -// ── Clock tools engine (alarm / countdown / ring) ─────────────────────────── -// Lives here, not in ClockToolsScreen, so it fires regardless of the current -// screen. The melody overrides mute (playMelody → buzzer.playForced); the ring -// auto-stops after CLOCK_RING_MS if no key dismisses it (see UITask::loop). -static const char* CLOCK_ALARM_MELODY = "alarm:d=8,o=6,b=125:c,c,c,c,p,c,c,c,c,p"; -static const uint32_t CLOCK_RING_MS = 60000; -static const uint32_t CLOCK_ALARM_CATCHUP_SECS = 6 * 3600; // fire late up to 6 h, else reschedule +// ── Clock tools (engine in ui-core/ClockEngine.h) ─────────────────────────── +// The melody overrides mute (playMelody → buzzer.playForced); the ring +// auto-stops after ClockEngine::RING_MS if no key dismisses it (see loop()). +static const char* CLOCK_ALARM_MELODY = "alarm:d=8,o=6,b=125:c,c,c,c,p,c,c,c,c,p"; void UITask::wakeForAlarm() { if (_display != NULL) _display->turnOn(); @@ -1726,92 +1724,38 @@ void UITask::wakeForAlarm() { // back off once _lock_wake_until is in the past — which it always is by the // time an alarm fires. Hold the wake window open for the whole ring so the // lock screen (and its alert overlay) stays visible while ringing. - if (_locked) _lock_wake_until = millis() + CLOCK_RING_MS; + if (_locked) _lock_wake_until = millis() + ClockEngine::RING_MS; _next_refresh = 0; // draw the alert overlay immediately } -// Next absolute wall instant matching alarm_hour:alarm_min in local time, -// strictly after now_wall (an alarm set to the current minute waits a day). -// With alarm_repeat_mask == 0 that's just tomorrow's occurrence (one-shot). -// With a repeat mask set, scan today..+6 days for the next weekday whose bit -// is set (struct tm's tm_wday convention, same as the mask) — today counts -// only if its time hasn't already passed. -uint32_t UITask::computeAlarmNextFire(uint32_t now_wall) const { - int tz = _node_prefs ? _node_prefs->tz_offset_hours : 0; - int64_t now_local = (int64_t)now_wall + (int64_t)tz * 3600; - time_t t = (time_t)now_local; - struct tm* ti = gmtime(&t); - int64_t sod = ti->tm_hour * 3600 + ti->tm_min * 60 + ti->tm_sec; // secs since local midnight - int64_t midnight = now_local - sod; - int64_t time_of_day = (int64_t)_node_prefs->alarm_hour * 3600 + (int64_t)_node_prefs->alarm_min * 60; - uint8_t mask = _node_prefs->alarm_repeat_mask; - if (mask != 0) { - for (int d = 0; d < 7; d++) { - if (mask & (1 << ((ti->tm_wday + d) % 7))) { - int64_t target = midnight + (int64_t)d * 86400 + time_of_day; - if (target > now_local) return (uint32_t)(target - (int64_t)tz * 3600); - } +void UITask::onAlarmChanged() { _core->clock.onAlarmChanged(); } +void UITask::startTimer(uint32_t duration_ms) { _core->clock.startTimer(duration_ms); } +void UITask::stopTimer() { _core->clock.stopTimer(); } +bool UITask::isTimerRunning() const { return _core->clock.isTimerRunning(); } +uint32_t UITask::timerRemainingMs() const { return _core->clock.timerRemainingMs(); } +bool UITask::isRinging() const { return _core->clock.isRinging(); } +void UITask::dismissRing() { stopMelody(); _core->clock.dismissRing(); clearAlert(); } + +void UITask::tickCore() { + _core->loop(); + UiEvent ev; + while (_core->events.pop(ev)) { + switch (ev.type) { + case UiEventType::ClockAlert: + wakeForAlarm(); + showAlert(ev.text, ClockEngine::RING_MS); + playMelody(CLOCK_ALARM_MELODY); + break; + case UiEventType::ClockRingEnded: + stopMelody(); + clearAlert(); + break; + default: + break; } - // Mask had no bit set (shouldn't happen — the UI only offers non-empty - // presets) — fall through to the one-shot calculation so it still fires. } - int64_t target = midnight + time_of_day; - if (target <= now_local) target += 86400; - return (uint32_t)(target - (int64_t)tz * 3600); -} - -void UITask::fireClockAlert(const char* label) { - snprintf(_ring_label, sizeof(_ring_label), "%s", label); - _ringing = true; - _ring_until_ms = millis() + CLOCK_RING_MS; - wakeForAlarm(); - showAlert(label, CLOCK_RING_MS); - playMelody(CLOCK_ALARM_MELODY); -} - -void UITask::evaluateAlarm() { - if (!_node_prefs || !_node_prefs->alarm_on) return; - uint32_t now_ms = millis(); - if (now_ms - _alarm_check_ms < 500) return; // ~2 Hz is plenty for a minute alarm - _alarm_check_ms = now_ms; - uint32_t now_wall = rtc_clock.getCurrentTime(); - if (now_wall < 1000000000UL) return; // need a real time sync first - if (_alarm_next_fire == 0) _alarm_next_fire = computeAlarmNextFire(now_wall); - if (now_wall < _alarm_next_fire) return; - if (now_wall - _alarm_next_fire < CLOCK_ALARM_CATCHUP_SECS) { - char lbl[20]; - snprintf(lbl, sizeof(lbl), "Alarm %02d:%02d", _node_prefs->alarm_hour, _node_prefs->alarm_min); - if (_node_prefs->alarm_repeat_mask == 0) { - _node_prefs->alarm_on = 0; // one-shot - bool dirty = true; savePrefsIfDirty(dirty); - } - // Repeating: alarm_on stays set: computeAlarmNextFire() re-arms it for the - // next matching weekday below. - _alarm_next_fire = 0; - fireClockAlert(lbl); - } else { - // Clock jumped implausibly far past the target — reschedule rather than - // ringing absurdly late. - _alarm_next_fire = computeAlarmNextFire(now_wall); - } -} - -void UITask::tickClockTools() { - uint32_t now_ms = millis(); - // Ring maintenance: repeat the melody until dismissed or the window elapses. - // Signed-difference compares (like the trail/loc-share timers) so deadlines - // landing past the millis() rollover don't read as already elapsed. - if (_ringing) { - if ((int32_t)(now_ms - _ring_until_ms) >= 0) { stopMelody(); _ringing = false; clearAlert(); } - else if (!isMelodyPlaying()) playMelody(CLOCK_ALARM_MELODY); - } - // Countdown timer (millis — sync-immune). - if (_timer_running && (int32_t)(now_ms - _timer_deadline_ms) >= 0) { - _timer_running = false; - fireClockAlert("Timer done"); - } - // Alarm (wall clock — absolute schedule for sync robustness). - evaluateAlarm(); + // Repeat the ring melody until dismissed or the ring window elapses. + if (_core->clock.isRinging() && !isMelodyPlaying()) playMelody(CLOCK_ALARM_MELODY); } // Ringtone takes a slot argument that onShow() can't carry — pass it after the @@ -2847,9 +2791,9 @@ void UITask::loop() { if (curr) curr->poll(); - // Alarm + countdown run regardless of the current screen / display state, so - // they're driven here (not via the current screen's poll()). - tickClockTools(); + // UI Core engines (alarm + countdown, …) run regardless of the current screen + // / display state, so they're driven here (not via the current screen's poll()). + tickCore(); if (_display != NULL && _display->isOn()) { if (_locked && (int32_t)(millis() - _lock_wake_until) >= 0) { diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index dcd1d636..c72e182e 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -152,24 +152,9 @@ class UITask : public AbstractUITask { void fireLocator(bool arrived); void locatorProximityBeeper(); - // Clock tools engine — owned here (not by ClockToolsScreen) so the one-shot - // alarm and the countdown timer fire every loop regardless of the current - // screen / display state. ClockToolsScreen is pure UI over this state. The - // alarm is scheduled as an ABSOLUTE wall instant, recomputed from the stored - // time-of-day, so it survives RTC re-syncs (mesh/app/GPS/CLI all jump the - // clock) — small corrections still fire on time, a jump over the target still - // fires (late). See evaluateAlarm(). Timer + ring are millis-based. - uint32_t _alarm_next_fire = 0; // unix; 0 = (re)compute lazily once time is valid - uint32_t _alarm_check_ms = 0; // throttle the wall-clock read to ~2 Hz - bool _timer_running = false; - uint32_t _timer_deadline_ms = 0; - bool _ringing = false; - uint32_t _ring_until_ms = 0; - char _ring_label[20] = {0}; - uint32_t computeAlarmNextFire(uint32_t now_wall) const; - void evaluateAlarm(); // alarm scheduling + fire detection - void fireClockAlert(const char* label); // wake + alert + melody + start ring - void tickClockTools(); // driven from loop(): ring + timer + alarm + // Runs the UI Core engines and reacts to their events (alert overlay, buzzer, + // display wake). Driven from loop() regardless of the current screen. + void tickCore(); // Course-over-ground ring — a heading source independent of trail recording. // Filled from the same periodic GPS poll regardless of _trail.isActive(). @@ -399,22 +384,15 @@ public: void wakeForAlarm(); // Clear any active alert overlay early (alarm dismiss). void clearAlert() { _alert_expiry = 0; } - // Clock tools engine API — ClockToolsScreen drives these; the engine itself - // runs in tickClockTools() from loop() so it fires regardless of the screen. - void onAlarmChanged() { _alarm_next_fire = 0; } // re-schedule after an alarm edit - void startTimer(uint32_t duration_ms) { _timer_running = true; _timer_deadline_ms = millis() + duration_ms; } - void stopTimer() { _timer_running = false; } - bool isTimerRunning() const { return _timer_running; } - uint32_t timerRemainingMs() const { - if (!_timer_running) return 0; - uint32_t now = millis(); - // Signed-difference compare so a deadline that lands past the millis() - // rollover (~49.7 days) doesn't read as already elapsed. - if ((int32_t)(now - _timer_deadline_ms) >= 0) return 0; - return _timer_deadline_ms - now; - } - bool isRinging() const { return _ringing; } - void dismissRing() { stopMelody(); _ringing = false; clearAlert(); } + // Clock tools engine API (ui-core/ClockEngine.h) — ClockToolsScreen drives + // these; the engine runs from tickCore() so it fires regardless of the screen. + void onAlarmChanged(); + void startTimer(uint32_t duration_ms); + void stopTimer(); + bool isTimerRunning() const; + uint32_t timerRemainingMs() const; + bool isRinging() const; + void dismissRing(); TrailStore& trail() { return _trail; } WaypointStore& waypoints() { return _waypoints; } LiveTrackStore& liveTrack() { return _livetrack; }