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 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-24 23:03:57 +02:00
co-authored by Claude Opus 5.5
parent 3b7aa28ff9
commit dd826a447e
7 changed files with 253 additions and 131 deletions
@@ -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;
};
+16
View File
@@ -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
@@ -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;
};