feat(companion): quiet hours -- muted on a schedule, the alarm still rings

Settings > Sound > Quiet hours (L2: its own group; L1: Quiet hrs / from /
until): from quiet_from to quiet_to (local hours, wrapping past midnight)
the buzzer is muted and a message doesn't wake the screen; the clock alarm
still rings. A manual mute or unmute inside the window stands until the
window ends. Off with the clock unset.

NodePrefs gains quiet_hours / quiet_from / quiet_to (appended to the file,
sentinel 0xC0DE0030; sizeof 2832 on nRF52 and ESP32), off by default,
22:00-07:00. hourInWindow() / localHour() are shared with the bot's quiet
hours. soundctl::tick() replaces autoTick() (Auto and quiet hours).

Also fixes the L2 side-button mute: it went by the mode, not by whether
the buzzer was muted, so with Auto + an app connected (and now quiet hours)
the first press muted instead of unmuting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-28 21:59:40 +02:00
co-authored by Claude Opus 5.5
parent 2e9654fd1c
commit 1def29335c
10 changed files with 121 additions and 32 deletions
+15
View File
@@ -627,6 +627,15 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
rd(&_prefs.display_brightness_pct, sizeof(_prefs.display_brightness_pct));
if (_prefs.display_brightness_pct > 100) _prefs.display_brightness_pct = 0;
// quiet_hours / quiet_from / quiet_to. Out of range -> off, 22-7; an older
// file's sentinel bytes landing here are reset below on the mismatch.
rd(&_prefs.quiet_hours, sizeof(_prefs.quiet_hours));
rd(&_prefs.quiet_from, sizeof(_prefs.quiet_from));
rd(&_prefs.quiet_to, sizeof(_prefs.quiet_to));
if (_prefs.quiet_hours > 1) _prefs.quiet_hours = 0;
if (_prefs.quiet_from > 23) _prefs.quiet_from = 22;
if (_prefs.quiet_to > 23) _prefs.quiet_to = 7;
// Schema sentinel: bumped on layout changes. Mismatch means an older file
// (or a different schema); rd() and the clamps above already keep every
// field within its valid range regardless, so we just log it here —
@@ -636,6 +645,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
if (sentinel != NodePrefs::SCHEMA_SENTINEL) {
MESH_DEBUG_PRINTLN("prefs schema sentinel mismatch: got 0x%08X, expected 0x%08X — re-saving on next change",
(unsigned)sentinel, (unsigned)NodePrefs::SCHEMA_SENTINEL);
// 0xC0DE002F → 0xC0DE0030: quiet hours appended; from an older file they
// hold its sentinel's bytes, so start off, 22:00-07:00.
if (sentinel < 0xC0DE0030) { _prefs.quiet_hours = 0; _prefs.quiet_from = 22; _prefs.quiet_to = 7; }
// 0xC0DE002E → 0xC0DE002F: display_brightness_pct appended; from an older
// file it holds a stray sentinel byte (0x2E = "46 %"), so start from the level.
_prefs.display_brightness_pct = 0;
@@ -828,6 +840,9 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.loc_share_scope, sizeof(_prefs.loc_share_scope));
file.write((uint8_t *)&_prefs.loc_share_duration_idx, sizeof(_prefs.loc_share_duration_idx));
file.write((uint8_t *)&_prefs.display_brightness_pct, sizeof(_prefs.display_brightness_pct));
file.write((uint8_t *)&_prefs.quiet_hours, sizeof(_prefs.quiet_hours));
file.write((uint8_t *)&_prefs.quiet_from, sizeof(_prefs.quiet_from));
file.write((uint8_t *)&_prefs.quiet_to, sizeof(_prefs.quiet_to));
// 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
+2
View File
@@ -1846,6 +1846,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_prefs.radio_fem_txgain = 0;
_prefs.display_brightness = 2; // medium brightness by default
_prefs.buzzer_volume = 4; // max volume by default
_prefs.quiet_from = 22; // quiet hours (off by default) 22:00-07:00
_prefs.quiet_to = 7;
_prefs.ringtone_bpm_idx = 2; // 120 bpm default
_prefs.ringtone_len = 0; // no custom ringtone by default
_prefs.ringtone2_bpm_idx = 2; // 120 bpm default
+3 -8
View File
@@ -123,14 +123,9 @@ void MyMesh::botDmRecord(const uint8_t* pubkey) {
// width window (start == end) disables the feature; a window where start > end
// wraps past midnight.
bool MyMesh::botInQuietHours() const {
if (_prefs.bot_quiet_start == _prefs.bot_quiet_end) return false; // disabled
uint32_t utc = getRTCClock()->getCurrentTime();
if (utc < 1000000000UL) return false; // clock not set — don't suppress
uint32_t local = utc + (int32_t)_prefs.tz_offset_hours * 3600;
int h = (int)((local / 3600) % 24);
int s = _prefs.bot_quiet_start, e = _prefs.bot_quiet_end;
return (s < e) ? (h >= s && h < e) // same-day window
: (h >= s || h < e); // overnight window
int h;
if (!localHour(getRTCClock()->getCurrentTime(), _prefs.tz_offset_hours, h)) return false; // clock not set -- don't suppress
return hourInWindow(h, _prefs.bot_quiet_start, _prefs.bot_quiet_end);
}
void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text, uint8_t hops) {
+31 -2
View File
@@ -317,6 +317,13 @@ struct NodePrefs { // persisted to file
uint8_t buzzer_quiet;
uint8_t buzzer_volume; // 0=min..4=max, default 4
uint8_t buzzer_auto; // 0=manual (default), 1=auto-mute when BT connected
// Quiet hours: muted from quiet_from to quiet_to (local hours 0-23, wrapping
// past midnight) while quiet_hours is on; the clock alarm still rings and a
// message doesn't wake the screen (inQuietHours() below). A manual mute /
// unmute during the window stands until it ends (soundctl::tick()).
uint8_t quiet_hours; // 0=off (default), 1=on
uint8_t quiet_from; // default 22
uint8_t quiet_to; // default 7
// Settings > Display > "Msg wake". Stored inverted (same reason as
// fav_sort_off below) so both a fresh memset and an older prefs file (no
// bytes here at all) mean "on" -- today's behaviour, where an incoming
@@ -627,7 +634,7 @@ struct NodePrefs { // persisted to file
// repeat_* fields) instead of at the tail, which shifted every field after
// them by 25 bytes when loading an older file. Never released, but a dev
// build wrote it, so the number must not be reused for anything else.
static const uint32_t SCHEMA_SENTINEL = 0xC0DE002F;
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0030;
// 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
@@ -704,6 +711,25 @@ struct NodePrefs { // persisted to file
}
};
// Local hour `h` inside [start, end): a window with start > end wraps past
// midnight; start == end is no window. Shared by the bot's quiet hours and the
// sound's.
static inline bool hourInWindow(int h, int start, int end) {
if (start == end) return false;
return start < end ? (h >= start && h < end) : (h >= start || h < end);
}
// The local hour from a UTC time; false while the clock isn't set.
static inline bool localHour(uint32_t utc, int8_t tz_hours, int& h) {
if (utc < 1000000000UL) return false;
h = (int)(((int64_t)utc + (int64_t)tz_hours * 3600) / 3600 % 24);
return true;
}
// Settings > Sound > Quiet hours in force at `utc` (never with the clock unset).
static inline bool inQuietHours(const NodePrefs& p, uint32_t utc) {
int h;
return p.quiet_hours && localHour(utc, p.tz_offset_hours, h) && hourInWindow(h, p.quiet_from, p.quiet_to);
}
// ── Serialization tripwire ───────────────────────────────────────────────────
// NodePrefs is written/read field-by-field, in order, by DataStore::savePrefs()
// and loadPrefsInt(); the on-disk format IS the struct's field layout. There is
@@ -782,7 +808,10 @@ struct NodePrefs { // persisted to file
// (ESP32) builds, sizeof unchanged at 2824. loc_share_duration_idx (0xC0DE002E)
// likewise (sim build; see the check below). display_brightness_pct (0xC0DE002F)
// likewise (L2 ESP32 + L1 nRF52 builds).
static_assert(sizeof(NodePrefs) == 2824,
// quiet_hours / quiet_from / quiet_to (0xC0DE0030) added 8 bytes, not 3 (next
// to buzzer_auto, rounded up to the alignment) -- sizeof 2832, confirmed via real
// WioTrackerL1_companion_solo_dual (nRF52/ARM) and L2 (ESP32) builds.
static_assert(sizeof(NodePrefs) == 2832,
"NodePrefs layout changed — sync DataStore save/load + clamp, bump "
"SCHEMA_SENTINEL, then update this size (see steps above).");
@@ -25,7 +25,7 @@ enum Page : uint8_t { PG_NAV, PG_ADVERT, PG_DEVICE, PG_MESSAGES, PG_SOUND, PG_CO
enum Section : uint8_t { SEC_TRAIL, SEC_LIVE_SHARE, SEC_LOCATOR, SEC_ADVERT,
SEC_DISPLAY, SEC_POWER, SEC_TIME, SEC_UNITS,
SEC_MESSAGES, SEC_CONTACTS,
SEC_SOUND, SEC_SOUND_FOR, SEC_COUNT };
SEC_SOUND, SEC_SOUND_FOR, SEC_QUIET, SEC_COUNT };
static const char* pageTitle(uint8_t p) {
static const char* T[PG_COUNT] = { "Map options", "Advert", "Display & power", "Messages & contacts", "Sound" };
@@ -35,14 +35,14 @@ static const char* sectionTitle(uint8_t s) {
static const char* T[SEC_COUNT] = { "TRAIL", "LIVE SHARE", "ARRIVAL ALERT", "AUTOMATIC",
"DISPLAY", "POWER", "TIME", "UNITS",
"MESSAGES", "CONTACTS",
"SOUND", "PLAYS FOR" };
"SOUND", "PLAYS FOR", "QUIET HOURS" };
return s < SEC_COUNT ? T[s] : "";
}
static uint8_t sectionPage(uint8_t s) {
static const uint8_t P[SEC_COUNT] = { PG_NAV, PG_NAV, PG_NAV, PG_ADVERT,
PG_DEVICE, PG_DEVICE, PG_DEVICE, PG_DEVICE,
PG_MESSAGES, PG_MESSAGES,
PG_SOUND, PG_SOUND };
PG_SOUND, PG_SOUND, PG_SOUND };
return s < SEC_COUNT ? P[s] : PG_DEVICE;
}
@@ -136,6 +136,10 @@ static void optVolume(uint8_t v, char* b, int n, const NodePrefs&) {
static const char* L[5] = { "Quietest", "Quiet", "Medium", "Loud", "Loudest" };
snprintf(b, n, "%s", L[v < 5 ? v : 4]);
}
static void optHour(uint8_t v, char* b, int n, const NodePrefs& p) { // "22:00", or "10 PM" with the 12-hour clock
if (!p.clock_12h) snprintf(b, n, "%02u:00", (unsigned)v);
else snprintf(b, n, "%u %s", (unsigned)(v % 12 ? v % 12 : 12), v < 12 ? "AM" : "PM");
}
static void optSound(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%s", soundctl::soundLabel(v)); }
static void optAdvertScope(uint8_t v, char* b, int n, const NodePrefs&) {
snprintf(b, n, "%s", v == ADVERT_SOUND_SCOPE_ZERO_HOP ? "Direct only" : "All");
@@ -209,6 +213,9 @@ static const Setting ALL[] = {
// On / Off / Auto spans two fields (soundctl::setMode): the frontend's own row.
IDX("Volume", nullptr, SEC_SOUND, buzzer_volume, 5, optVolume, applySound),
SW("Quiet hours", "Muted, screen dark; alarms ring", SEC_QUIET, quiet_hours, nullptr),
IDX("From", nullptr, SEC_QUIET, quiet_from, 24, optHour, nullptr),
IDX("Until", nullptr, SEC_QUIET, quiet_to, 24, optHour, nullptr),
IDX("Direct messages", nullptr, SEC_SOUND_FOR, notif_melody_dm, soundctl::SOUND_COUNT, optSound, nullptr),
IDX("Channels", nullptr, SEC_SOUND_FOR, notif_melody_ch, soundctl::SOUND_COUNT, optSound, nullptr),
+26 -10
View File
@@ -3,7 +3,7 @@
// Ringtone) and ui-lvgl (Settings > Sound, Melodies): the On / Off / Auto mode
// (buzzer_quiet + buzzer_auto), the volume, the built-in sounds, the labels of
// the notification sound choices, and the two user melodies (ringtone_* /
// ringtone2_*) as an editable note list. Which sound a message plays is
// ringtone2_*) as an editable note list. Quiet hours mute on a schedule. Which sound a message plays is
// SoundNotifier.h.
#include "../NodePrefs.h"
@@ -44,10 +44,21 @@ static const char* soundLabel(uint8_t v) {
static const uint8_t OVERRIDE_COUNT = 3;
#ifdef PIN_BUZZER
// The mute that goes with the mode; `connected`: an app is connected.
static void applyMode(const NodePrefs* p, genericBuzzer& b, bool connected) {
// Quiet hours (NodePrefs::inQuietHours) mute like the mode does. A mute or
// unmute by hand inside the window stands until the window ends.
static bool s_quiet_now = false; // inside the window, as of the last tick
static bool s_quiet_override = false; // set by hand since the window began
// The mute that goes with the mode and quiet hours; `connected`: an app is
// connected.
static bool wantQuiet(const NodePrefs* p, bool connected) {
uint8_t m = mode(p);
b.quiet(m == MODE_OFF || (m == MODE_AUTO && connected));
if (m == MODE_OFF || (m == MODE_AUTO && connected)) return true;
return s_quiet_now && !s_quiet_override;
}
static void applyMode(const NodePrefs* p, genericBuzzer& b, bool connected, uint32_t utc) {
s_quiet_now = p && inQuietHours(*p, utc);
b.quiet(wantQuiet(p, connected));
}
// Auto keeps buzzer_quiet (the manual choice it goes back to). The caller
@@ -56,16 +67,21 @@ static void setMode(NodePrefs* p, genericBuzzer& b, uint8_t m, bool connected) {
if (!p || m >= MODE_COUNT) return;
p->buzzer_auto = m == MODE_AUTO;
if (m != MODE_AUTO) p->buzzer_quiet = m == MODE_OFF;
applyMode(p, b, connected);
s_quiet_override = s_quiet_now; // a choice made during quiet hours wins until they end
b.quiet(wantQuiet(p, connected));
}
// From the loop: Auto follows the app connection. True when the mute changed.
static bool autoTick(const NodePrefs* p, genericBuzzer& b, bool connected) {
if (!p || !p->buzzer_auto || b.isQuiet() == connected) return false;
b.quiet(connected);
// From the loop: Auto follows the app connection, quiet hours the clock.
// True when the mute changed.
static bool tick(const NodePrefs* p, genericBuzzer& b, bool connected, uint32_t utc) {
if (!p) return false;
bool q = inQuietHours(*p, utc);
if (q != s_quiet_now) { s_quiet_now = q; s_quiet_override = false; }
bool want = wantQuiet(p, connected);
if (b.isQuiet() == want) return false;
b.quiet(want);
return true;
}
// Level 0..4, with a preview beep (through mute: it answers the user's own
// change). The caller saves the prefs.
static void setVolume(NodePrefs* p, genericBuzzer& b, uint8_t level) {
+4 -4
View File
@@ -823,7 +823,7 @@ void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs
user_btn.begin();
#endif
#ifdef PIN_BUZZER
soundctl::applyMode(_prefs, _buzzer, isClientConnected());
soundctl::applyMode(_prefs, _buzzer, isClientConnected(), rtc_clock.getCurrentTime());
_buzzer.setVolume(_prefs ? _prefs->buzzer_volume : 4);
_buzzer.begin(); // plays the startup sound unless muted
#endif
@@ -852,7 +852,7 @@ MyMesh::Listener* UITask::meshListener() { return _core; }
void UITask::toggleMute() {
#ifdef PIN_BUZZER
if (!_prefs) return;
bool on = soundctl::mode(_prefs) != soundctl::MODE_ON;
bool on = _buzzer.isQuiet(); // muted now, however (mode, Auto, quiet hours): sound on
soundctl::setMode(_prefs, _buzzer, on ? soundctl::MODE_ON : soundctl::MODE_OFF, isClientConnected());
prefsSave();
if (on) _buzzer.playForced(soundctl::MEL_VOLUME);
@@ -988,7 +988,7 @@ void UITask::loop() {
#endif
#ifdef PIN_BUZZER
_buzzer.loop();
if (soundctl::autoTick(_prefs, _buzzer, isClientConnected()) && !_asleep) refreshStatusBar();
if (soundctl::tick(_prefs, _buzzer, isClientConnected(), rtc_clock.getCurrentTime()) && !_asleep) refreshStatusBar();
// The alarm melody repeats until dismissed or the ring window ends.
if (_core->clock.isRinging() && !_buzzer.isPlaying()) playMelody(soundctl::MEL_ALARM);
#endif
@@ -1289,7 +1289,7 @@ void UITask::onMessageArrived(const UiEvent& ev) {
}
// Wake for the message unless an app is already showing it, or the user
// turned message-wake off.
bool wake_disabled = _prefs && _prefs->msg_wake_screen_off;
bool wake_disabled = _prefs && (_prefs->msg_wake_screen_off || inQuietHours(*_prefs, rtc_clock.getCurrentTime())); // quiet hours: dark too
if (_asleep && !wake_disabled && !isClientConnected()) wake();
else if (!_asleep) lv_display_trigger_activity(NULL);
bool open_here = _screen == SCR_THREAD &&
@@ -41,6 +41,7 @@ class SettingsScreen : public UIScreen {
SECTION_SOUND,
BUZZER,
BUZZER_VOLUME,
QUIET_HOURS, QUIET_FROM, QUIET_TO,
DM_MELODY,
CH_MELODY,
AD_SOUND,
@@ -480,6 +481,17 @@ class SettingsScreen : public UIScreen {
display.setCursor(valCol(display), y);
display.print("N/A");
#endif
} else if (item == QUIET_HOURS) {
display.print("Quiet hrs");
display.setCursor(valCol(display), y);
display.print((p && p->quiet_hours) ? "ON" : "OFF");
} else if (item == QUIET_FROM || item == QUIET_TO) {
display.print(item == QUIET_FROM ? " from" : " until");
display.setCursor(valCol(display), y);
{ char hb[8]; uint8_t h = p ? (item == QUIET_FROM ? p->quiet_from : p->quiet_to) : 0;
if (p && p->clock_12h) snprintf(hb, sizeof(hb), "%u%s", (unsigned)(h % 12 ? h % 12 : 12), h < 12 ? "AM" : "PM");
else snprintf(hb, sizeof(hb), "%02u:00", (unsigned)h);
display.print(hb); }
} else if (item == DM_MELODY) {
display.print("DM sound");
display.setCursor(valCol(display), y);
@@ -1027,6 +1039,15 @@ public:
#endif
return right || left;
}
if (_selected == QUIET_HOURS && p && (left || right || enter)) {
p->quiet_hours ^= 1;
_dirty = true; return true;
}
if ((_selected == QUIET_FROM || _selected == QUIET_TO) && p && (left || right || enter)) {
uint8_t& h = _selected == QUIET_FROM ? p->quiet_from : p->quiet_to;
h = (h + (left ? 23 : 1)) % 24;
_dirty = true; return true;
}
if (_selected == DM_MELODY && p && (left || right || enter)) {
p->notif_melody_dm = (p->notif_melody_dm + (left ? SOUND_COUNT - 1 : 1)) % SOUND_COUNT;
_dirty = true; return true;
+3 -3
View File
@@ -1593,7 +1593,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
}
#ifdef PIN_BUZZER
soundctl::applyMode(_node_prefs, buzzer, false);
soundctl::applyMode(_node_prefs, buzzer, false, rtc_clock.getCurrentTime());
buzzer.setVolume(_node_prefs->buzzer_volume);
buzzer.begin();
#endif
@@ -1952,7 +1952,7 @@ void UITask::onMessageArrived(const UiEvent& ev) {
showAlert(alert_buf, 3000);
if (_display != NULL && !_locked) {
bool wake_disabled = _node_prefs && _node_prefs->msg_wake_screen_off;
bool wake_disabled = _node_prefs && (_node_prefs->msg_wake_screen_off || inQuietHours(*_node_prefs, rtc_clock.getCurrentTime()));
if (!wake_disabled && !_display->isOn() && !isClientConnected()) { // wake for the msg unless an app (BLE/USB) is already showing it, or the user disabled msg-wake
_display->turnOn();
}
@@ -2686,7 +2686,7 @@ void UITask::loop() {
userLedHandler();
#ifdef PIN_BUZZER
if (soundctl::autoTick(_node_prefs, buzzer, isClientConnected())) // BLE bonded or an open USB port
if (soundctl::tick(_node_prefs, buzzer, isClientConnected(), rtc_clock.getCurrentTime())) // BLE bonded or an open USB port
_next_refresh = 0;
if (buzzer.isPlaying()) buzzer.loop();
#endif