feat: summer time - Settings > Summer time (Europe, North America, Australia, New Zealand) puts the clock an hour ahead and back by itself; every local time goes through tzHoursAt(), the alarm keeps its time across the change (a skipped time rings an hour later, a repeated one once)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-29 14:09:25 +01:00
co-authored by Claude Opus 5.5
parent c46a0a2b55
commit cdc2ff25c9
17 changed files with 119 additions and 50 deletions
+34 -22
View File
@@ -64,34 +64,44 @@ private:
_events->push(UiEventType::ClockAlert, label);
}
// The UTC instant a local wall time first happens. Summer time starting
// skips an hour: a time inside it comes as long after the jump as it was
// meant to be after its start (02:30 rings at 03:30). Summer time ending
// repeats one: the first of the two counts.
int64_t wallToUtc(int64_t local) const {
const int8_t tz = _prefs->tz_offset_hours;
for (int8_t h = tz + 1; h >= tz; h--) { // the summer offset gives the earlier instant
int64_t u = local - (int64_t)h * 3600;
if (tzHoursAt(_prefs, (uint32_t)u) == h) return u;
}
return local - (int64_t)tz * 3600; // skipped
}
// 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.
// With alarm_repeat_mask == 0 that's just the next occurrence (one-shot).
// With a repeat mask set, scan today..+7 days for the next weekday whose bit
// is set (struct tm's tm_wday convention, same as the mask). Each day's time
// is turned into UTC with that day's offset, so summer time starting or
// ending in between doesn't move it.
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;
int64_t now_local = (int64_t)now_wall + (int64_t)tzHoursAt(_prefs, now_wall) * 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;
const int wday = ti->tm_wday;
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.
// A mask with no weekday bit (shouldn't happen -- the UI only offers
// non-empty presets) falls back to one-shot so it still fires.
if ((mask & 0x7F) == 0) mask = 0x7F;
for (int d = 0; d <= 7; d++) {
if (!(mask & (1 << ((wday + d) % 7)))) continue;
int64_t u = wallToUtc(midnight + (int64_t)d * 86400 + time_of_day);
if (u > (int64_t)now_wall) return (uint32_t)u;
}
int64_t target = midnight + time_of_day;
if (target <= now_local) target += 86400;
return (uint32_t)(target - (int64_t)tz * 3600);
return (uint32_t)wallToUtc(midnight + 86400 + time_of_day); // not reached
}
void evaluateAlarm() {
@@ -101,9 +111,11 @@ private:
_alarm_check_ms = now_ms;
uint32_t now_wall = rtc_clock.getCurrentTime();
if (now_wall < 1000000000UL) return; // need a real time sync first
// A time zone change (Settings, CLI tz.offset, the app) moves the local
// alarm time: reschedule here rather than trusting every caller to say so.
if (_alarm_tz != _prefs->tz_offset_hours) { _alarm_tz = _prefs->tz_offset_hours; _alarm_next_fire = 0; }
// A time zone change (Settings, CLI tz.offset, the app) or summer time
// starting / ending moves the local alarm time: reschedule here rather
// than trusting every caller to say so.
const int8_t tz = tzHoursAt(_prefs, now_wall);
if (_alarm_tz != tz) { _alarm_tz = tz; _alarm_next_fire = 0; }
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) {
@@ -127,7 +139,7 @@ private:
NodePrefs* _prefs = nullptr;
UiEventQueue* _events = nullptr;
uint32_t _alarm_next_fire = 0; // unix; 0 = (re)compute lazily once time is valid
int8_t _alarm_tz = 0; // the tz_offset_hours _alarm_next_fire was computed with
int8_t _alarm_tz = 0; // the offset (tzHoursAt) _alarm_next_fire was computed with
uint32_t _alarm_check_ms = 0; // throttle the wall-clock read to ~2 Hz
bool _timer_running = false;
uint32_t _timer_deadline_ms = 0;
@@ -88,8 +88,9 @@ static void expand(const char* tmpl, char* out, int n, const NodePrefs* p) {
}
#endif
float batt = (float)board.getBattMilliVolts() / 1000.0f;
::expandMsg(tmpl, out, n, lat, lon, gps_valid, rtc_clock.getCurrentTime(),
p ? p->tz_offset_hours : 0, &sensors, batt, nullptr, -1, board.getMCUTemperature());
const uint32_t now = rtc_clock.getCurrentTime();
::expandMsg(tmpl, out, n, lat, lon, gps_valid, now,
tzHoursAt(p, now), &sensors, batt, nullptr, -1, board.getMCUTemperature());
}
// A typed message: a reply's leading "@[nick] " stays as it is (a nick may
@@ -26,7 +26,7 @@ static const Key KEYS[] = {
K("display.alert", msg_alert), K("lock", auto_lock), K("lock.clock", lock_compact),
K("display.text.size", ui_text_scale), K("display.icon.size", ui_icon_scale),
K("batt.display", batt_display_mode), K("batt.shutdown", low_batt_mv), K("gps.interval", gps_interval),
K("clock.12h", clock_12h), K("clock.seconds", clock_hide_seconds), K("units.imperial", units_imperial),
K("clock.12h", clock_12h), K("clock.summer", dst_rule), K("clock.seconds", clock_hide_seconds), K("units.imperial", units_imperial),
K("sound.volume", buzzer_volume), K("quiet", quiet_hours), K("quiet.from", quiet_from), K("quiet.until", quiet_to),
K("melody.dm", notif_melody_dm), K("melody.channel", notif_melody_ch), K("melody.advert", notif_melody_ad),
K("advert.sound", advert_sound_scope),
@@ -157,6 +157,10 @@ static void optLockLook(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(
static void optExpiry(uint8_t v, char* b, int n, const NodePrefs&) {
snprintf(b, n, "%s", NodePrefs::contactExpiryLabel(v));
}
static void optDst(uint8_t v, char* b, int n, const NodePrefs&) {
static const char* L[NodePrefs::DST_COUNT] = { "Off", "Europe", "North America", "Australia", "New Zealand" };
snprintf(b, n, "%s", L[v < NodePrefs::DST_COUNT ? v : 0]);
}
static void optChSort(uint8_t v, char* b, int n, const NodePrefs&) {
static const char* L[NodePrefs::CH_SORT_COUNT] = { "Slots", "Newest", "Name" };
snprintf(b, n, "%s", L[v < NodePrefs::CH_SORT_COUNT ? v : 0]);
@@ -276,6 +280,7 @@ static const Setting ALL[] = {
MAP("GPS power saving", "Sleep between fixes", SEC_POWER, gps_interval, GPS_DUTY, optGpsDuty, applyGpsDuty),
MAP("Time zone", "For the clock and the alarm", SEC_TIME, tz_offset_hours, TZ, optTz, nullptr),
IDX("Summer time", "An hour ahead, changed by itself", SEC_TIME, dst_rule, NodePrefs::DST_COUNT, optDst, nullptr),
SW("12-hour clock", "AM / PM instead of 24 h", SEC_TIME, clock_12h, nullptr),
MAP("Clock seconds", "On the home clock", SEC_TIME, clock_hide_seconds, INVERTED, nullptr, nullptr),