mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
feat(companion): clock tools — alarm, countdown timer, stopwatch
Add a Clock Tools screen (Enter on the home Clock page) with three time utilities, plus the engine that drives them from UITask::loop() so they work regardless of the current screen / display state: - Alarm: a single one-shot wake alarm. Persisted in NodePrefs as a local time-of-day; UITask schedules it as an ABSOLUTE fire instant recomputed from that time, 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, up to 6 h). Disarms after firing. A bell icon signals an armed alarm on the clock face and the top status bar. - Timer: a millis-based countdown (sync-immune), big HH:MM:SS readout, rings even when off-screen. - Stopwatch: millis-based, keeps running in the background. Numeric fields use the shared framework DigitEditor (digit-by-digit, min/max enforced). The alarm/timer/ring engine lives in UITask alongside the locator/live-share engines (no screen-cast for time-critical logic). E-ink: live readouts refresh coarsely (and on any key); timing is exact regardless, and the countdown's buzzer fires on time. Rings override mute and are dismissed by any key (auto-stop after 1 min). Cannot wake from a full Shutdown (CPU/RAM powered down). NodePrefs: +alarm_on/alarm_hour/alarm_min (fit existing tail padding, sizeof unchanged 2488), SCHEMA_SENTINEL 0xC0DE0016 -> 0xC0DE0017, DataStore read/clamp/write in lockstep. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -59,3 +59,33 @@ Sensor fields show `--` when the sensor is not connected or has no data.
|
||||
<!-- screenshot pending: Dashboard Config — three field slots cycled with LEFT/RIGHT -->
|
||||
|
||||
**Hold Enter** (or press the **Context menu** key) on the Clock page to open the Dashboard Config screen, where each of the three field slots can be cycled with **LEFT/RIGHT**.
|
||||
|
||||
---
|
||||
|
||||
### Clock tools — Alarm, Timer, Stopwatch
|
||||
|
||||
**Press Enter** (short press) on the Clock page to open **Clock Tools**, a small menu with three time utilities. **Cancel** backs out one level (tool → menu → home).
|
||||
|
||||
#### Alarm
|
||||
|
||||
A single one-shot wake alarm. Rows: **Hour**, **Minute** and **Armed**. **Enter** on Hour or Minute opens the digit editor (LEFT/RIGHT moves between the tens/units, UP/DOWN changes the digit); **Enter** on Armed toggles ON/OFF. The configured time is shown next to the **Alarm** menu row when armed, and the setting persists across reboots.
|
||||
|
||||
While an alarm is armed a bell icon signals it in two places: the top-left corner of the **Clock page** itself, and the **top status bar** of the other home pages (the status bar is hidden on the Clock page, which is why the clock face carries its own indicator). The bell is icon-only — the exact alarm time is on the **Alarm** row inside Clock Tools.
|
||||
|
||||
The alarm is scheduled as an absolute fire instant, so it is **robust to clock re-syncs** — the mesh (every inbound packet), the companion app, GPS and the CLI can all jump the device clock at any moment. A correction that moves the clock a little still fires at the right wall-clock time; a jump that skips over the alarm time still fires (late). After firing once, the alarm disarms itself.
|
||||
|
||||
The alarm only fires while the device is **awake** (it keeps running with the display off or locked). It cannot wake the device from a full **Shutdown** (the CPU and RAM are powered down), and needs a valid time source — it stays pending until the clock is synced.
|
||||
|
||||
#### Timer (countdown)
|
||||
|
||||
A large **HH:MM:SS** readout with one digit underlined. **LEFT/RIGHT** moves the cursor one digit at a time, **Up/Down** changes the digit under it (minute/second tens cap at 5, hours at 23), and **Enter** starts the countdown. While running it shows **H:MM:SS** — **Enter** stops it, **Cancel** returns to the menu and leaves it counting. When it reaches zero the device rings, even if you have navigated to another screen.
|
||||
|
||||
#### Stopwatch
|
||||
|
||||
**Enter** starts/stops; **Up/Down** resets when stopped; **Cancel** returns to the menu and leaves it running.
|
||||
|
||||
#### Ringing
|
||||
|
||||
When the alarm or timer fires the device plays a melody (overriding mute) and shows an alert. **Any key** silences it; otherwise it stops on its own after a minute.
|
||||
|
||||
> **E-ink note:** the live timer/stopwatch readouts would thrash a slow e-paper panel if redrawn every second, so on e-ink they refresh only coarsely (and immediately on any key press). The underlying timing is exact regardless, and the countdown's buzzer always fires on time.
|
||||
|
||||
@@ -421,6 +421,13 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
// → 0xC0DE0016: GPS-averaging duration for waypoint marking.
|
||||
rd(&_prefs.gps_avg_idx, sizeof(_prefs.gps_avg_idx));
|
||||
if (_prefs.gps_avg_idx >= NodePrefs::GPS_AVG_COUNT) _prefs.gps_avg_idx = 0;
|
||||
// → 0xC0DE0017: one-shot alarm clock (local time-of-day + armed flag).
|
||||
rd(&_prefs.alarm_on, sizeof(_prefs.alarm_on));
|
||||
rd(&_prefs.alarm_hour, sizeof(_prefs.alarm_hour));
|
||||
rd(&_prefs.alarm_min, sizeof(_prefs.alarm_min));
|
||||
if (_prefs.alarm_on > 1) _prefs.alarm_on = 0;
|
||||
if (_prefs.alarm_hour > 23) _prefs.alarm_hour = 0;
|
||||
if (_prefs.alarm_min > 59) _prefs.alarm_min = 0;
|
||||
// Pre-0x10 files leave stray sentinel bytes here, same as a never-configured
|
||||
// device. Either way there's no valid saved profile, so default to a profile
|
||||
// in the same band as the companion's own network (_prefs.freq, already read
|
||||
@@ -622,6 +629,9 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
||||
file.write((uint8_t *)&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind));
|
||||
file.write((uint8_t *)_prefs.locator_key, sizeof(_prefs.locator_key));
|
||||
file.write((uint8_t *)&_prefs.gps_avg_idx, sizeof(_prefs.gps_avg_idx));
|
||||
file.write((uint8_t *)&_prefs.alarm_on, sizeof(_prefs.alarm_on));
|
||||
file.write((uint8_t *)&_prefs.alarm_hour, sizeof(_prefs.alarm_hour));
|
||||
file.write((uint8_t *)&_prefs.alarm_min, sizeof(_prefs.alarm_min));
|
||||
|
||||
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is
|
||||
// the one we check: once the flash fills, writes return 0, so a good
|
||||
|
||||
@@ -270,6 +270,18 @@ struct NodePrefs { // persisted to file
|
||||
// Index into gpsAvgSecs(). [Tools › Trail › Settings › Mark avg]
|
||||
uint8_t gps_avg_idx;
|
||||
|
||||
// Alarm clock — a single one-shot wake alarm, configured from the Clock page
|
||||
// (Enter › Alarm). Stored as a local time-of-day; the actual fire instant is
|
||||
// (re)computed as an absolute time in tickBackground(), which is what makes it
|
||||
// robust to RTC re-syncs: the mesh (every inbound packet), the companion app,
|
||||
// GPS and the CLI can all jump getCurrentTime() at any moment, but an absolute
|
||||
// target instant stays correct across small corrections and still fires (late)
|
||||
// if the clock jumps over it. Fires once, then alarm_on clears. The minutnik
|
||||
// (countdown) and stoper (stopwatch) are runtime-only and not persisted.
|
||||
uint8_t alarm_on; // 0=off (default), 1=armed (one-shot)
|
||||
uint8_t alarm_hour; // 0-23, local time
|
||||
uint8_t alarm_min; // 0-59
|
||||
|
||||
// Single source of truth for the live-share option tables (shared by the Map
|
||||
// UI labels and the auto-send engine in UITask).
|
||||
static const uint8_t LOC_SHARE_MOVE_COUNT = 4;
|
||||
@@ -332,7 +344,7 @@ struct NodePrefs { // persisted to file
|
||||
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
|
||||
// older saves are detected on load and skipped (zero-init defaults kept).
|
||||
// High 24 bits identify the file format; low byte is the schema revision.
|
||||
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0016;
|
||||
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0017;
|
||||
|
||||
// Bit-index for each home page. Used by page_order (entries store bit+1) and
|
||||
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
|
||||
|
||||
298
examples/companion_radio/ui-new/ClockToolsScreen.h
Normal file
298
examples/companion_radio/ui-new/ClockToolsScreen.h
Normal file
@@ -0,0 +1,298 @@
|
||||
#pragma once
|
||||
// Clock tools — Alarm, Countdown timer (minutnik) and Stopwatch (stoper).
|
||||
// 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'
|
||||
// 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 /
|
||||
// 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.
|
||||
//
|
||||
// Numeric fields (alarm hour/minute, timer h/m/s) are edited with the shared
|
||||
// framework DigitEditor (digit-by-digit: LEFT/RIGHT cursor, UP/DOWN +/- the
|
||||
// place, min/max enforced) — the same widget Settings/Repeater use for Freq,
|
||||
// rather than a hand-rolled wrap.
|
||||
//
|
||||
// E-INK: the running timer/stopwatch readouts would thrash a slow e-paper panel
|
||||
// if redrawn every second, so on e-ink the live readout refreshes only coarsely
|
||||
// (and immediately on any key); the millis() timing underneath is unaffected.
|
||||
// Included by UITask.cpp after the other screen fragments.
|
||||
|
||||
#include "../NodePrefs.h"
|
||||
#include "icons.h" // drawList / drawRowSelection
|
||||
#include "DigitEditor.h" // shared digit-by-digit numeric editor
|
||||
|
||||
class ClockToolsScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
|
||||
enum View : uint8_t { V_MENU, V_ALARM, V_TIMER, V_STOPWATCH };
|
||||
uint8_t _view = V_MENU;
|
||||
int _sel = 0, _scroll = 0; // shared list cursor
|
||||
bool _alarm_dirty = false; // unsaved edits to alarm_* (persist on exit)
|
||||
|
||||
// Countdown config (the duration to start; the running countdown lives in UITask).
|
||||
uint8_t _timer_h = 0, _timer_m = 5, _timer_s = 0;
|
||||
uint8_t _timer_cur = 0; // digit cursor over HH:MM:SS, 0..5 (skips the colons)
|
||||
|
||||
// Stopwatch (millis — display-only, no background action).
|
||||
bool _sw_running = false;
|
||||
uint32_t _sw_start_ms = 0;
|
||||
uint32_t _sw_accum_ms = 0;
|
||||
|
||||
// Shared numeric editor (alarm hour/minute) + which field it targets. The
|
||||
// timer uses its own continuous digit cursor (per-place caps a single
|
||||
// DigitEditor can't express), so it isn't listed here.
|
||||
enum EditKind : uint8_t { EK_NONE, EK_A_HOUR, EK_A_MIN };
|
||||
DigitEditor _editor;
|
||||
EditKind _edit_kind = EK_NONE;
|
||||
|
||||
// E-ink: coarse refresh for the live readouts (millis underneath is exact).
|
||||
static int liveTickMs(DisplayDriver& d, int oled_ms) { return d.isEink() ? 30000 : oled_ms; }
|
||||
|
||||
static void fmtClock(char* b, int n, int hh, int mm) { snprintf(b, n, "%02d:%02d", hh, mm); }
|
||||
|
||||
// millis → "H:MM:SS" (the countdown always shows hours).
|
||||
static void fmtHMS(char* b, int n, uint32_t ms) {
|
||||
uint32_t s = ms / 1000, h = s / 3600; s %= 3600;
|
||||
uint32_t m = s / 60; s %= 60;
|
||||
snprintf(b, n, "%lu:%02lu:%02lu", (unsigned long)h, (unsigned long)m, (unsigned long)s);
|
||||
}
|
||||
|
||||
// Stopwatch readout: "M:SS.t" while under an hour (tenths only on a fast
|
||||
// panel), "H:MM:SS" once it rolls past an hour.
|
||||
static void fmtStopwatch(char* b, int n, uint32_t ms, bool tenths) {
|
||||
if (ms >= 3600000UL) { fmtHMS(b, n, ms); return; }
|
||||
uint32_t total_s = ms / 1000, m = total_s / 60, s = total_s % 60;
|
||||
if (tenths) snprintf(b, n, "%lu:%02lu.%lu", (unsigned long)m, (unsigned long)s,
|
||||
(unsigned long)((ms % 1000) / 100));
|
||||
else snprintf(b, n, "%lu:%02lu", (unsigned long)m, (unsigned long)s);
|
||||
}
|
||||
|
||||
uint32_t stopwatchMs() const {
|
||||
return _sw_accum_ms + (_sw_running ? (millis() - _sw_start_ms) : 0);
|
||||
}
|
||||
|
||||
// Step the digit under the timer cursor by dir (+1/−1), wrapping within that
|
||||
// place and keeping each field valid: minute/second tens 0-5, hours 0-23.
|
||||
void stepTimerDigit(int dir) {
|
||||
uint8_t* f; int tens_max; bool is_hours = false;
|
||||
if (_timer_cur < 2) { f = &_timer_h; tens_max = 2; is_hours = true; }
|
||||
else if (_timer_cur < 4) { f = &_timer_m; tens_max = 5; }
|
||||
else { f = &_timer_s; tens_max = 5; }
|
||||
int t = *f / 10, u = *f % 10;
|
||||
if (_timer_cur % 2 == 0) { // tens place
|
||||
t = (t + dir + (tens_max + 1)) % (tens_max + 1);
|
||||
if (is_hours && t == 2 && u > 3) u = 3; // 2X hours can't exceed 23
|
||||
} else { // units place
|
||||
int umax = (is_hours && t == 2) ? 3 : 9;
|
||||
u = (u + dir + (umax + 1)) % (umax + 1);
|
||||
}
|
||||
*f = (uint8_t)(t * 10 + u);
|
||||
}
|
||||
|
||||
// Open the DigitEditor on a numeric field (2 integer digits, no decimals).
|
||||
void editField(EditKind kind, uint8_t value, uint8_t vmax) {
|
||||
_edit_kind = kind;
|
||||
_editor.begin((float)value, 0.0f, (float)vmax, 2, 0);
|
||||
}
|
||||
|
||||
// Feed a key to the open editor, write the (live) value back to its field, and
|
||||
// close the editor when DigitEditor reports DONE/CANCELLED.
|
||||
bool feedEditor(char c) {
|
||||
DigitEditor::Result r = _editor.handleInput(c);
|
||||
uint8_t v = (uint8_t)(_editor.value + 0.5f);
|
||||
switch (_edit_kind) {
|
||||
case EK_A_HOUR: _prefs->alarm_hour = v; _alarm_dirty = true; _task->onAlarmChanged(); break;
|
||||
case EK_A_MIN: _prefs->alarm_min = v; _alarm_dirty = true; _task->onAlarmChanged(); break;
|
||||
default: break;
|
||||
}
|
||||
if (r != DigitEditor::NONE) _edit_kind = EK_NONE; // editor closed
|
||||
return true;
|
||||
}
|
||||
|
||||
// Draw a row's value column: the live editor when this row is the one being
|
||||
// edited, otherwise the static text.
|
||||
void drawValue(DisplayDriver& d, int y, int valx, int reserve, bool sel,
|
||||
EditKind mykind, const char* text) {
|
||||
if (sel && _editor.active && _edit_kind == mykind) _editor.render(d, valx, y);
|
||||
else d.drawTextEllipsized(valx, y, d.width() - valx - reserve, text);
|
||||
}
|
||||
|
||||
// ── Sub-renders ───────────────────────────────────────────────────────────
|
||||
int renderMenu(DisplayDriver& d) {
|
||||
d.drawCenteredHeader("CLOCK TOOLS");
|
||||
static const char* items[3] = { "Alarm", "Timer", "Stopwatch" };
|
||||
if (_sel > 2) _sel = 2;
|
||||
drawList(d, 3, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
|
||||
drawRowSelection(d, y, sel, reserve);
|
||||
d.setCursor(4, y);
|
||||
d.print(items[i]);
|
||||
if (i == 0 && _prefs && _prefs->alarm_on) { // show the armed alarm time
|
||||
char b[8]; fmtClock(b, sizeof(b), _prefs->alarm_hour, _prefs->alarm_min);
|
||||
int vx = d.width() - d.getTextWidth(b) - reserve - 2;
|
||||
d.setCursor(vx, y); d.print(b);
|
||||
}
|
||||
});
|
||||
return 60000;
|
||||
}
|
||||
|
||||
int renderAlarm(DisplayDriver& d) {
|
||||
d.drawCenteredHeader("ALARM");
|
||||
if (!_prefs) return 60000;
|
||||
const char* rows[3] = { "Hour", "Minute", "Armed" };
|
||||
if (_sel > 2) _sel = 2;
|
||||
const int valx = d.width() / 2 + 6;
|
||||
drawList(d, 3, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
|
||||
drawRowSelection(d, y, sel, reserve);
|
||||
d.setCursor(4, y); d.print(rows[i]);
|
||||
char b[6];
|
||||
if (i == 0) snprintf(b, sizeof(b), "%02u", _prefs->alarm_hour);
|
||||
else if (i == 1) snprintf(b, sizeof(b), "%02u", _prefs->alarm_min);
|
||||
else snprintf(b, sizeof(b), "%s", _prefs->alarm_on ? "ON" : "OFF");
|
||||
EditKind k = (i == 0) ? EK_A_HOUR : (i == 1) ? EK_A_MIN : EK_NONE;
|
||||
drawValue(d, y, valx, reserve, sel, k, b);
|
||||
});
|
||||
return _editor.active ? 50 : 60000;
|
||||
}
|
||||
|
||||
int renderTimer(DisplayDriver& d) {
|
||||
d.drawCenteredHeader("TIMER");
|
||||
const int cx = d.width() / 2;
|
||||
if (_task->isTimerRunning()) {
|
||||
char buf[16];
|
||||
fmtHMS(buf, sizeof(buf), _task->timerRemainingMs());
|
||||
d.setTextSize(2);
|
||||
d.drawTextCentered(cx, d.listStart() + 4, buf);
|
||||
d.setTextSize(1);
|
||||
d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, "Ent=stop");
|
||||
return liveTickMs(d, 1000);
|
||||
}
|
||||
// Setting mode: big HH:MM:SS with the digit under the cursor underlined.
|
||||
// LEFT/RIGHT move the cursor one digit, UP/DOWN change it, Enter starts.
|
||||
char buf[12];
|
||||
snprintf(buf, sizeof(buf), "%02u:%02u:%02u", _timer_h, _timer_m, _timer_s);
|
||||
d.setTextSize(2);
|
||||
const int ty = d.listStart() + 2;
|
||||
d.drawTextCentered(cx, ty, buf);
|
||||
const int cw = d.getCharWidth(); // size-2 cell width
|
||||
const int lh2 = d.getLineHeight();
|
||||
const int sx = cx - d.getTextWidth(buf) / 2;
|
||||
const int char_idx = _timer_cur + (_timer_cur / 2); // skip the two colons
|
||||
d.fillRect(sx + char_idx * cw, ty + lh2, cw - 1, 2);
|
||||
d.setTextSize(1);
|
||||
d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, "Up/Dn=set Ent=start");
|
||||
return 60000;
|
||||
}
|
||||
|
||||
int renderStopwatch(DisplayDriver& d) {
|
||||
d.drawCenteredHeader("STOPWATCH");
|
||||
const int cx = d.width() / 2;
|
||||
char buf[16];
|
||||
bool tenths = !d.isEink() && _sw_running; // tenths only on a fast panel
|
||||
fmtStopwatch(buf, sizeof(buf), stopwatchMs(), tenths);
|
||||
d.setTextSize(2);
|
||||
d.drawTextCentered(cx, d.listStart() + 4, buf);
|
||||
d.setTextSize(1);
|
||||
const char* hint = _sw_running ? "Ent=stop" : "Ent=start Dn=reset";
|
||||
d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, hint);
|
||||
return _sw_running ? liveTickMs(d, 100) : 60000;
|
||||
}
|
||||
|
||||
// ── Input per view ────────────────────────────────────────────────────────
|
||||
bool inputMenu(char c) {
|
||||
if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; }
|
||||
if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 2; return true; }
|
||||
if (c == KEY_DOWN) { _sel = (_sel < 2) ? _sel + 1 : 0; return true; }
|
||||
if (c == KEY_ENTER) {
|
||||
_view = (_sel == 0) ? V_ALARM : (_sel == 1) ? V_TIMER : V_STOPWATCH;
|
||||
_sel = 0; _scroll = 0;
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool inputAlarm(char c) {
|
||||
if (!_prefs) { if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; } return true; }
|
||||
if (_editor.active) return feedEditor(c);
|
||||
if (c == KEY_CANCEL) {
|
||||
_task->savePrefsIfDirty(_alarm_dirty); // persist alarm fields only if edited
|
||||
_task->onAlarmChanged(); // re-schedule from the new time
|
||||
_view = V_MENU; _sel = 0; _scroll = 0;
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 2; return true; }
|
||||
if (c == KEY_DOWN) { _sel = (_sel < 2) ? _sel + 1 : 0; return true; }
|
||||
if (c == KEY_ENTER) {
|
||||
if (_sel == 0) editField(EK_A_HOUR, _prefs->alarm_hour, 23);
|
||||
else if (_sel == 1) editField(EK_A_MIN, _prefs->alarm_min, 59);
|
||||
else { _prefs->alarm_on ^= 1; _alarm_dirty = true; _task->onAlarmChanged(); }
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool inputTimer(char c) {
|
||||
if (_task->isTimerRunning()) {
|
||||
if (c == KEY_ENTER) { _task->stopTimer(); return true; } // stop/cancel countdown
|
||||
if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; return true; } // keeps counting in background
|
||||
return true; // any other key just forces a refresh (e-ink)
|
||||
}
|
||||
if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; return true; }
|
||||
if (keyIsPrev(c)) { _timer_cur = (_timer_cur + 5) % 6; return true; } // cursor ←
|
||||
if (keyIsNext(c)) { _timer_cur = (_timer_cur + 1) % 6; return true; } // cursor →
|
||||
if (c == KEY_UP) { stepTimerDigit(+1); return true; }
|
||||
if (c == KEY_DOWN) { stepTimerDigit(-1); return true; }
|
||||
if (c == KEY_ENTER) {
|
||||
uint32_t dur = (((uint32_t)_timer_h * 60 + _timer_m) * 60 + _timer_s) * 1000UL;
|
||||
if (dur == 0) { _task->showAlert("Set a duration", 1000); return true; }
|
||||
_task->startTimer(dur);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool inputStopwatch(char c) {
|
||||
if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; return true; } // keeps running in background
|
||||
if (c == KEY_ENTER) {
|
||||
if (_sw_running) { _sw_accum_ms += millis() - _sw_start_ms; _sw_running = false; }
|
||||
else { _sw_start_ms = millis(); _sw_running = true; }
|
||||
return true;
|
||||
}
|
||||
if ((c == KEY_UP || c == KEY_DOWN) && !_sw_running) { _sw_accum_ms = 0; return true; }
|
||||
return true; // other keys just refresh the readout
|
||||
}
|
||||
|
||||
public:
|
||||
ClockToolsScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
|
||||
|
||||
void onShow() override {
|
||||
_view = V_MENU; _sel = 0; _scroll = 0;
|
||||
_timer_cur = 0;
|
||||
_editor.active = false; _edit_kind = EK_NONE;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
switch (_view) {
|
||||
case V_ALARM: return renderAlarm(display);
|
||||
case V_TIMER: return renderTimer(display);
|
||||
case V_STOPWATCH: return renderStopwatch(display);
|
||||
default: return renderMenu(display);
|
||||
}
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
switch (_view) {
|
||||
case V_ALARM: return inputAlarm(c);
|
||||
case V_TIMER: return inputTimer(c);
|
||||
case V_STOPWATCH: return inputStopwatch(c);
|
||||
default: return inputMenu(c);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -146,6 +146,7 @@ static const int QUICK_MSGS_MAX = 10;
|
||||
#include "DiagnosticsScreen.h"
|
||||
#include "RepeaterScreen.h"
|
||||
#include "ToolsScreen.h"
|
||||
#include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch (Clock page › Enter)
|
||||
|
||||
#ifndef BATT_MIN_MILLIVOLTS
|
||||
#define BATT_MIN_MILLIVOLTS 3200
|
||||
@@ -474,6 +475,13 @@ class HomeScreen : public UIScreen {
|
||||
}
|
||||
#endif
|
||||
|
||||
// Alarm armed — a persistent status cue (like mute), always shown (no blink).
|
||||
if (_node_prefs && _node_prefs->alarm_on) {
|
||||
int alX = battLeftX - ind - ind_gap;
|
||||
drawBoxedIcon(display, alX, ind, ind_h, ICON_ALARM);
|
||||
battLeftX = alX;
|
||||
}
|
||||
|
||||
// BT connection indicator (left of muted/battery icons)
|
||||
int leftmostX = battLeftX;
|
||||
if (_task->isSerialEnabled()) {
|
||||
@@ -803,6 +811,16 @@ public:
|
||||
snprintf(buf, sizeof(buf),"%s %d %s %d", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon], 1900 + ti->tm_year);
|
||||
display.drawTextCentered(display.width() / 2, date_y, buf);
|
||||
|
||||
// Alarm armed: a small bell in the top-left corner. The status bar (and
|
||||
// its bell) is hidden on this page, so signal the armed alarm here. Just
|
||||
// the glyph — no time text — so it stays clear of the centred clock
|
||||
// digits (which can reach the corner when seconds are shown), matching
|
||||
// the icon-only status-bar indicator. The exact time is in Clock Tools.
|
||||
if (_node_prefs && _node_prefs->alarm_on) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
miniIconDrawTop(display, 0, 0, ICON_ALARM);
|
||||
}
|
||||
|
||||
int sep_y = date_y + lh + 1;
|
||||
int dash0 = sep_y + display.sepH() + 2;
|
||||
display.fillRect(0, sep_y, display.width(), display.sepH());
|
||||
@@ -1384,6 +1402,10 @@ public:
|
||||
_shutdown_init = true; // need to wait for button to be released
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER && _page == HomePage::CLOCK) {
|
||||
_task->gotoClockTools(); // Alarm / Timer / Stopwatch
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CONTEXT_MENU && _page == HomePage::CLOCK) {
|
||||
_task->gotoDashboardConfig();
|
||||
return true;
|
||||
@@ -1467,6 +1489,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
compass_screen = new CompassScreen(this);
|
||||
diag_screen = new DiagnosticsScreen(this);
|
||||
repeater_screen = new RepeaterScreen(this);
|
||||
clock_tools = new ClockToolsScreen(this, node_prefs);
|
||||
applyBrightness();
|
||||
applyFont();
|
||||
applyRotation();
|
||||
@@ -1484,8 +1507,84 @@ void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); }
|
||||
void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); }
|
||||
void UITask::gotoDiagnosticsScreen() { setCurrScreen(diag_screen); }
|
||||
void UITask::gotoRepeaterScreen() { setCurrScreen(repeater_screen); }
|
||||
void UITask::gotoClockTools() { setCurrScreen(clock_tools); }
|
||||
void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); }
|
||||
|
||||
void UITask::wakeForAlarm() {
|
||||
if (_display != NULL) _display->turnOn();
|
||||
_next_refresh = 0; // draw the alert overlay immediately
|
||||
}
|
||||
|
||||
// ── 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
|
||||
|
||||
// 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).
|
||||
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 target = (now_local - sod)
|
||||
+ (int64_t)_node_prefs->alarm_hour * 3600 + (int64_t)_node_prefs->alarm_min * 60;
|
||||
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);
|
||||
_node_prefs->alarm_on = 0; // one-shot
|
||||
bool dirty = true; savePrefsIfDirty(dirty);
|
||||
_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.
|
||||
if (_ringing) {
|
||||
if (now_ms >= _ring_until_ms) { stopMelody(); _ringing = false; clearAlert(); }
|
||||
else if (!isMelodyPlaying()) playMelody(CLOCK_ALARM_MELODY);
|
||||
}
|
||||
// Countdown timer (millis — sync-immune).
|
||||
if (_timer_running && now_ms >= _timer_deadline_ms) {
|
||||
_timer_running = false;
|
||||
fireClockAlert("Timer done");
|
||||
}
|
||||
// Alarm (wall clock — absolute schedule for sync robustness).
|
||||
evaluateAlarm();
|
||||
}
|
||||
|
||||
// Ringtone takes a slot argument that onShow() can't carry — pass it after the
|
||||
// reset (setCurrScreen's onShow runs first, then this layers the slot on top).
|
||||
void UITask::gotoRingtoneEditor(int slot) {
|
||||
@@ -1985,6 +2084,14 @@ void UITask::loop() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// A ringing alarm/timer is dismissed by ANY key, even when locked or on another
|
||||
// screen — and that key is swallowed so it doesn't also act on the current view.
|
||||
if (c != 0 && isRinging()) {
|
||||
dismissRing();
|
||||
c = 0;
|
||||
_next_refresh = 0;
|
||||
}
|
||||
|
||||
if (c != 0) {
|
||||
if (!_locked && curr) {
|
||||
curr->handleInput(c);
|
||||
@@ -2022,6 +2129,10 @@ 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();
|
||||
|
||||
if (_display != NULL && _display->isOn()) {
|
||||
if (_locked && millis() > _lock_wake_until) {
|
||||
_display->turnOff();
|
||||
@@ -2131,7 +2242,7 @@ void UITask::loop() {
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
}
|
||||
#endif
|
||||
if (!_locked && autoOffMillis() > 0 && millis() > _auto_off) {
|
||||
if (!_locked && autoOffMillis() > 0 && millis() > _auto_off && !isRinging()) {
|
||||
_display->turnOff();
|
||||
#ifdef PIN_LED
|
||||
digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power
|
||||
|
||||
@@ -89,6 +89,7 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* compass_screen = nullptr;
|
||||
UIScreen* diag_screen = nullptr;
|
||||
UIScreen* repeater_screen = nullptr;
|
||||
UIScreen* clock_tools = nullptr;
|
||||
UIScreen* curr = nullptr;
|
||||
CayenneLPP _dash_lpp;
|
||||
TrailStore _trail;
|
||||
@@ -126,6 +127,25 @@ 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
|
||||
|
||||
// Course-over-ground ring — a heading source independent of trail recording.
|
||||
// Filled from the same periodic GPS poll regardless of _trail.isActive().
|
||||
// Heading = bearing across the window (oldest→newest) once the cumulative
|
||||
@@ -245,6 +265,24 @@ public:
|
||||
void gotoCompassScreen();
|
||||
void gotoDiagnosticsScreen();
|
||||
void gotoRepeaterScreen();
|
||||
void gotoClockTools(); // Alarm / Timer / Stopwatch (from the home Clock page)
|
||||
// Wake the display for an alarm/timer ring (force an immediate refresh).
|
||||
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();
|
||||
return (now >= _timer_deadline_ms) ? 0 : (_timer_deadline_ms - now);
|
||||
}
|
||||
bool isRinging() const { return _ringing; }
|
||||
void dismissRing() { stopMelody(); _ringing = false; clearAlert(); }
|
||||
TrailStore& trail() { return _trail; }
|
||||
WaypointStore& waypoints() { return _waypoints; }
|
||||
LiveTrackStore& liveTrack() { return _livetrack; }
|
||||
|
||||
@@ -150,6 +150,14 @@ MINI_ICON(ICON_ADVERT, 6, // broadcast mast + radiating waves (auto-advert)
|
||||
packRow(".####."),
|
||||
packRow(".####."));
|
||||
|
||||
MINI_ICON(ICON_ALARM, 5, // bell — an alarm is armed
|
||||
packRow("..#.."),
|
||||
packRow(".###."),
|
||||
packRow(".###."),
|
||||
packRow(".###."),
|
||||
packRow("#####"),
|
||||
packRow("..#.."));
|
||||
|
||||
MINI_ICON(ICON_TRAIL, 6, // map pin / location marker (GPS trail logging)
|
||||
packRow(".####."),
|
||||
packRow("######"),
|
||||
|
||||
Reference in New Issue
Block a user