feat: SOS (experimental), issue #56: a countdown, then the position to chosen channels and contacts, repeated until someone answers

L1: Tools > SOS, Back held 3 s. L2: the SOS app, the top button held 5 s
opens a slider over everything, the lock screen and a PIN too. Off by default.
The ACK word stops the repeats; who answered, what they wrote and when show on
Answered, and whoever answered needs no word after that. Beep plays
... --- ... at full volume until it's ended. While one runs the screen goes off
by 30 s, the GPS stays awake past its duty cycle until nothing more will be
sent, the repeater and the L2's WiFi rest. A received SOS gets a banner and a
place on the map, past Track loc, for 24 h.

The lock screen's slider restyled with it, shared as slideBar() / slidePoll();
the L2's Favourites and SOS pickers share pickContacts(). The engine is
ui-core/SosEngine.h, tested on the host. The message's format isn't final.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-11-01 23:27:33 +01:00
co-authored by Claude Opus 5.5
parent 94a8f8fe72
commit d3baeadb40
26 changed files with 1866 additions and 90 deletions
@@ -81,6 +81,7 @@ class MessagesScreen : public UIScreen {
char _pin_slot_labels[NodePrefs::FAVOURITES_COUNT][22]; // per-slot picker labels
bool _pin_picker_active; // true while the slot-picker submenu is open
int _pick_fav_slot = -1; // >=0 = browsing to fill that Favourites dial slot
int _pick_sos_slot = -1; // >=0 = choosing the contact for that SOS slot
int _pin_slot_ch_idx = -1; // >=0 = that submenu is pinning this channel, not a contact
bool _ch_delete_confirm_active = false; // true while the Delete/Cancel submenu is open
bool _direct_entry; // entered a history view straight from the Favourites dial;
@@ -1311,6 +1312,7 @@ public:
_scope_pick_active = false;
_ch_delete_confirm_active = false;
_pick_fav_slot = -1;
_pick_sos_slot = -1;
_direct_entry = false;
_unread_at_entry = 0;
_new_idx = -1;
@@ -1421,6 +1423,24 @@ public:
_task->gotoBotScreen();
}
// Tools > SOS: a contact to call for help, straight to the Direct list.
void startPickSos(int slot) {
reset();
_pick_sos_slot = slot;
_contact_sel = _contact_scroll = 0;
_phase = CONTACT_PICK;
}
void commitPickSos(const ContactInfo& ci) {
NodePrefs* p = _task->getNodePrefs();
if (p) {
memcpy(p->sos_dm[_pick_sos_slot], ci.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
the_mesh.savePrefs();
}
_pick_sos_slot = -1;
_task->gotoSosScreen();
}
void commitPickTargetDM(const ContactInfo& ci) {
// A room server can't be a live-share target — a [LOC] DM to it is never
// reposted to the room's members. Reject the pick and keep the chooser open.
@@ -2235,6 +2255,7 @@ public:
}
if (c == KEY_CANCEL) {
if (_pick_bot_room) { _pick_bot_room = false; _room_mode = false; _task->gotoBotScreen(); return true; }
if (_pick_sos_slot >= 0) { _pick_sos_slot = -1; _task->gotoSosScreen(); return true; }
_room_mode = false;
_phase = MODE_SELECT;
return true;
@@ -2246,6 +2267,7 @@ public:
if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) {
if (_pick_target) { commitPickTargetDM(_sel_contact); return true; }
if (_pick_fav_slot >= 0) { commitPickFavContact(_sel_contact); return true; }
if (_pick_sos_slot >= 0) { commitPickSos(_sel_contact); return true; }
if (_pick_bot_room) {
if (!isRoomLoggedIn(_sel_contact.id.pub_key)) {
// Known password, just not re-established this boot: the Core
+259
View File
@@ -0,0 +1,259 @@
#pragma once
// Tools › SOS: what happens on a call for help, and the status while one runs.
// The status (renderStatus) isn't this screen's list: UITask draws it over
// whatever is showing, on a locked device too. Engine: ui-core/SosEngine.h.
// Included by UITask.cpp after LiveShareScreen.h.
#include "InfoKit.h"
#include "../NodePrefs.h"
#include "icons.h" // drawList (shared scrolling-list helper)
class SosScreen : public UIScreen {
UITask* _task;
NodePrefs* _prefs;
KeyboardWidget* _kb;
bool _dirty = false;
int _sel = 0;
int _scroll = 0;
int _kb_row = -1; // >=0: the keyboard is open on that row
bool _ch_list = false; // the Channels list instead of the rows
int _ch_sel = 0, _ch_scroll = 0, _ch_count = 0;
uint8_t _ch_idx[MAX_GROUP_CHANNELS];
enum Kind : uint8_t { K_HOLD, K_COUNTDOWN, K_REPEAT, K_BEEP, K_CHANNELS, K_DM1, K_DM2, K_DM3, K_ACK, K_NOTE, K_START };
struct Row { Kind kind; const char* label; };
static const int ROW_COUNT = 11;
static Row rows(int i) {
static const Row R[ROW_COUNT] = {
{ K_HOLD, "Hold Back" },
{ K_COUNTDOWN, "Countdown" },
{ K_REPEAT, "Repeat" },
{ K_BEEP, "Beep" },
{ K_CHANNELS, "Channels" },
{ K_DM1, "Contact 1" },
{ K_DM2, "Contact 2" },
{ K_DM3, "Contact 3" },
{ K_ACK, "ACK word" },
{ K_NOTE, "Note" },
{ K_START, "Send SOS" },
};
return R[i];
}
static void step(uint8_t& idx, int count, int dir) {
idx = (uint8_t)((idx + (dir >= 0 ? 1 : count - 1)) % count);
}
void valueLabel(Kind k, char* buf, int n) {
switch (k) {
case K_HOLD: snprintf(buf, n, "%s", _prefs->sos_enabled ? "ON" : "OFF"); break;
case K_COUNTDOWN: snprintf(buf, n, "%u s", (unsigned)NodePrefs::sosCountdownSecs(_prefs->sos_countdown_idx)); break;
case K_REPEAT: {
unsigned m = NodePrefs::sosRepeatMins(_prefs->sos_repeat_idx);
if (m) snprintf(buf, n, "%u min", m);
else snprintf(buf, n, "Off");
break;
}
case K_BEEP: {
unsigned s = NodePrefs::sosBeepSecs(_prefs->sos_beep_idx);
if (!s) snprintf(buf, n, "Off");
else if (s < 60) snprintf(buf, n, "%u s", s);
else snprintf(buf, n, "%u min", s / 60);
break;
}
case K_CHANNELS: snprintf(buf, n, "%d on", SosEngine::channels(_prefs)); break;
case K_DM1: case K_DM2: case K_DM3: {
int slot = k - K_DM1;
ContactInfo* c = SosEngine::hasDm(_prefs, slot)
? the_mesh.lookupContactByPubKey(_prefs->sos_dm[slot], NodePrefs::FAVOURITE_PREFIX_LEN) : nullptr;
snprintf(buf, n, "%s", c ? c->name : "-");
break;
}
case K_ACK: snprintf(buf, n, "%s", _prefs->sos_ack[0] ? _prefs->sos_ack : "-"); break;
case K_NOTE: snprintf(buf, n, "%s", _prefs->sos_note[0] ? _prefs->sos_note : "-"); break;
case K_START: snprintf(buf, n, "%s", _task->core().sos.idle() ? "" : "Running"); break;
}
}
void openChannels() {
_ch_count = 0;
ChannelDetails ch;
for (int i = 0; i < MAX_GROUP_CHANNELS; i++)
if (the_mesh.getChannel(i, ch) && ch.name[0]) _ch_idx[_ch_count++] = (uint8_t)i;
_ch_sel = _ch_scroll = 0;
_ch_list = true;
}
int renderChannels(DisplayDriver& display) {
drawScreenHeader(display, "SOS channels");
int mq_delay = 0;
drawList(display, _ch_count, _ch_sel, _ch_scroll, [&](int i, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve);
ChannelDetails ch;
char name[32] = "";
if (the_mesh.getChannel(_ch_idx[i], ch)) display.translateUTF8ToBlocks(name, ch.name, sizeof(name));
bool on = _prefs->sos_ch_mask & (1ULL << _ch_idx[i]);
int mqr = info::listRow(display, y, name, on ? "ON" : "OFF", sel, reserve);
if (mqr > 0) mq_delay = mqr;
});
return (mq_delay > 0 && mq_delay < 500) ? mq_delay : 500;
}
bool channelsInput(char c) {
if (c == KEY_CANCEL) { _ch_list = false; return true; }
if (_ch_count == 0) return true;
if (c == KEY_UP) { _ch_sel = (_ch_sel + _ch_count - 1) % _ch_count; return true; }
if (c == KEY_DOWN) { _ch_sel = (_ch_sel + 1) % _ch_count; return true; }
if (c == KEY_ENTER || keyIsPrev(c) || keyIsNext(c)) {
_prefs->sos_ch_mask ^= 1ULL << _ch_idx[_ch_sel];
_dirty = true;
return true;
}
return false;
}
// Enter: LEFT/RIGHT only step the values, or empty a contact.
void activate(int dir, bool enter) {
Kind k = rows(_sel).kind;
switch (k) {
case K_HOLD:
_prefs->sos_enabled ^= 1;
_dirty = true;
if (_prefs->sos_enabled && !SosEngine::hasTargets(_prefs)) _task->showAlert("Choose who it goes to", 1500);
break;
case K_COUNTDOWN:
step(_prefs->sos_countdown_idx, NodePrefs::SOS_COUNTDOWN_COUNT, dir);
_dirty = true; break;
case K_REPEAT:
step(_prefs->sos_repeat_idx, NodePrefs::SOS_REPEAT_COUNT, dir);
_dirty = true; break;
case K_BEEP:
step(_prefs->sos_beep_idx, NodePrefs::SOS_BEEP_COUNT, dir);
_dirty = true; break;
case K_CHANNELS:
if (enter) openChannels();
break;
case K_DM1: case K_DM2: case K_DM3:
if (enter) { _task->savePrefsIfDirty(_dirty); _task->pickSosContact(k - K_DM1); }
else { memset(_prefs->sos_dm[k - K_DM1], 0, NodePrefs::FAVOURITE_PREFIX_LEN); _dirty = true; }
break;
case K_ACK: case K_NOTE:
if (enter) {
bool ack = k == K_ACK;
_kb_row = _sel;
_kb->begin(ack ? _prefs->sos_ack : _prefs->sos_note, (ack ? sizeof(_prefs->sos_ack) : sizeof(_prefs->sos_note)) - 1);
_kb->prompt = ack ? "ACK word" : "Note";
_kb->clearPlaceholders(); // sent as it is
}
break;
case K_START:
if (enter) { _task->savePrefsIfDirty(_dirty); _task->startSos(); }
break;
}
}
public:
SosScreen(UITask* task, NodePrefs* prefs, KeyboardWidget* kb) : _task(task), _prefs(prefs), _kb(kb) {}
void onShow() override { _dirty = false; _kb_row = -1; _ch_list = false; } // the row stays: the contact list comes back here
int render(DisplayDriver& display) override {
if (_kb_row >= 0) return _kb->render(display);
display.setTextSize(1);
display.setColor(UIColor::primary_txt);
if (_ch_list) return renderChannels(display);
drawScreenHeader(display, "SOS");
int mq_delay = 0;
drawList(display, ROW_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
Row r = rows(i);
drawRowSelection(display, y, sel, reserve);
char val[64], shown[64];
valueLabel(r.kind, val, sizeof(val));
display.translateUTF8ToBlocks(shown, val, sizeof(shown));
int mqr = info::listRow(display, y, r.label, shown, sel, reserve);
if (mqr > 0) mq_delay = mqr;
});
return (mq_delay > 0 && mq_delay < 500) ? mq_delay : 500;
}
bool handleInput(char c) override {
if (_kb_row >= 0) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
bool ack = rows(_kb_row).kind == K_ACK;
char* dst = ack ? _prefs->sos_ack : _prefs->sos_note;
snprintf(dst, ack ? sizeof(_prefs->sos_ack) : sizeof(_prefs->sos_note), "%s", _kb->buf);
_dirty = true;
_kb_row = -1;
} else if (res == KeyboardWidget::CANCELLED) {
_kb_row = -1;
}
return true;
}
if (_ch_list) return channelsInput(c);
if (c == KEY_CANCEL) {
_task->savePrefsIfDirty(_dirty);
_sel = _scroll = 0;
_task->leaveTool();
return true;
}
if (c == KEY_UP) { _sel = (_sel + ROW_COUNT - 1) % ROW_COUNT; return true; }
if (c == KEY_DOWN) { _sel = (_sel + 1) % ROW_COUNT; return true; }
if (keyIsPrev(c)) { activate(-1, false); return true; }
if (keyIsNext(c)) { activate(+1, false); return true; }
if (c == KEY_ENTER) { activate(+1, true); return true; }
return false;
}
// The countdown, then what has been sent and who answered. Returns the delay
// to the next frame.
int renderStatus(DisplayDriver& d) {
SosEngine& s = _task->core().sos;
const uint32_t now = millis();
const int cx = d.width() / 2;
const int foot = d.height() - d.getLineHeight() - 1;
char buf[40];
d.setTextSize(1);
d.setColor(UIColor::primary_txt);
drawScreenHeader(d, s.counting() ? "SOS in" : s.acked() ? "Answered" : "SOS");
if (s.counting()) {
snprintf(buf, sizeof(buf), "%d", s.secsLeft(now));
d.setTextSize(2);
d.drawTextCentered(cx, d.listStart() + 4, buf);
d.setTextSize(1);
d.drawTextCentered(cx, foot, "Any key: cancel");
return 250;
}
int y = d.listStart();
if (s.acked()) {
const SosEngine::Responder& r = s.responder(0);
char line[64];
const uint32_t mins = (now - r.at) / 60000UL;
if (s.responders() > 1) snprintf(line, sizeof(line), "%s +%d", r.name, s.responders() - 1);
else snprintf(line, sizeof(line), "%s", r.name);
d.translateUTF8ToBlocks(buf, line, sizeof(buf));
d.drawTextEllipsized(2, y, d.width() - 4, buf, false);
y += d.lineStep();
if (r.text[0]) {
d.translateUTF8ToBlocks(buf, r.text, sizeof(buf));
d.drawTextEllipsized(2, y, d.width() - 4, buf, false);
y += d.lineStep();
}
if (mins) { snprintf(buf, sizeof(buf), "%u min ago", (unsigned)mins); d.drawTextCentered(cx, y, buf); }
d.drawTextCentered(cx, foot, "Hold Enter: end");
return 1000;
}
s.sentLine(buf, sizeof(buf));
d.drawTextCentered(cx, y, buf);
y += d.lineStep();
s.nextLine(buf, sizeof(buf), now);
d.drawTextCentered(cx, y, buf);
int32_t lat, lon;
if (!_task->currentLocation(lat, lon)) d.drawTextCentered(cx, y + d.lineStep(), "No GPS fix");
d.drawTextCentered(cx, foot, "Hold Enter: stop");
return 1000;
}
};
@@ -13,7 +13,7 @@ class ToolsScreen : public UIScreen {
public:
enum Action {
ACT_NEARBY, ACT_LIVESHARE, ACT_TRAIL, ACT_LOCATOR, ACT_COMPASS,
ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER, ACT_ADMIN,
ACT_SOS, ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER, ACT_ADMIN,
ACT_CLOCK, ACT_RINGTONE, ACT_FLASHLIGHT
#if defined(PIN_GPIO1)
, ACT_GPIO
@@ -45,6 +45,7 @@ public:
case ACT_TRAIL: task->gotoTrailScreen(); break;
case ACT_LOCATOR: task->gotoLocatorScreen(); break;
case ACT_COMPASS: task->gotoCompassScreen(); break;
case ACT_SOS: task->gotoSosScreen(); break;
case ACT_BOT: task->gotoBotScreen(); break;
case ACT_AUTOADVERT: task->gotoAutoAdvertScreen(); break;
case ACT_REPEATER: task->gotoRepeaterScreen(); break;
@@ -128,6 +129,7 @@ const ToolsScreen::Tool ToolsScreen::LOCATION_TOOLS[] = {
{ "Compass", ACT_COMPASS },
};
const ToolsScreen::Tool ToolsScreen::COMMS_TOOLS[] = {
{ "SOS", ACT_SOS },
{ "Remote Bot", ACT_BOT },
{ "Auto-Advert", ACT_AUTOADVERT },
{ "Repeater", ACT_REPEATER },
@@ -145,6 +147,6 @@ const ToolsScreen::Tool ToolsScreen::SYSTEM_TOOLS[] = {
};
const ToolsScreen::Section ToolsScreen::SECTIONS[] = {
{ "Location", LOCATION_TOOLS, (uint8_t)(sizeof(LOCATION_TOOLS)/sizeof(LOCATION_TOOLS[0])) },
{ "Comms", COMMS_TOOLS, 4 },
{ "Comms", COMMS_TOOLS, (uint8_t)(sizeof(COMMS_TOOLS)/sizeof(COMMS_TOOLS[0])) },
{ "System", SYSTEM_TOOLS, (uint8_t)(sizeof(SYSTEM_TOOLS)/sizeof(SYSTEM_TOOLS[0])) },
};
+141 -2
View File
@@ -100,6 +100,7 @@ static const telemetry::Style L1_INFO = { "\xc2\xb0", true, false, 5 };
#include "AutoAdvertScreen.h"
#include "BatteryCurveScreen.h"
#include "LiveShareScreen.h"
#include "SosScreen.h"
#include "LocatorScreen.h"
#include "TrailScreen.h"
#include "CompassScreen.h"
@@ -229,6 +230,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
auto_advert_screen = new AutoAdvertScreen(this, node_prefs);
batt_curve_screen = new BatteryCurveScreen(this, node_prefs);
live_share_screen = new LiveShareScreen(this, node_prefs);
sos_screen = new SosScreen(this, node_prefs, &_kb);
locator_screen = new LocatorScreen(this, node_prefs);
trail_screen = new TrailScreen(this, &_core->trail.store());
compass_screen = new CompassScreen(this);
@@ -292,6 +294,7 @@ void UITask::gotoGpioScreen() {
#endif
}
void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); }
void UITask::gotoSosScreen() { setCurrScreen(sos_screen); }
// ── Clock tools (engine in ui-core/ClockEngine.h) ───────────────────────────
// The melody (soundctl::MEL_ALARM) overrides mute (playMelody →
@@ -321,6 +324,77 @@ void UITask::tickCore() {
drainCoreEvents();
// Repeat the ring melody until dismissed or the ring window elapses.
if (_core->clock.isRinging() && !isMelodyPlaying()) playMelody(soundctl::MEL_ALARM);
tickSos();
}
// ── SOS (engine in ui-core/SosEngine.h) ─────────────────────────────────────
static const uint32_t SOS_HOLD_MS = 3000; // Back held; the lock sequence is shorter (3 s for three presses) and uses Enter
static const uint32_t SOS_VIEW_MS = 30000; // the status stays this long, then the display goes back to its usual rules
void UITask::openSosView(uint32_t ms) {
if (_display) _display->turnOn();
if (_unlock_kb) cancelUnlockPrompt();
_sos_view_until = (millis() + ms) | 1;
_next_refresh = 0;
}
void UITask::startSos() {
if (!_core->sos.idle()) { openSosView(SOS_VIEW_MS); return; }
if (!SosEngine::hasTargets(_node_prefs)) {
if (_display) _display->turnOn();
if (_locked) _lock_wake_until = millis() + 2000;
showAlert("SOS: no targets", 2000);
_next_refresh = 0;
return;
}
_core->startSos();
_sos_left = 0;
openSosView(SOS_VIEW_MS);
}
void UITask::onBackHeld() {
if (!_core->sos.idle() || (_node_prefs && _node_prefs->sos_enabled)) startSos();
}
// Countdown: any key cancels it. After it, Back only hides the status: stopping
// takes a hold, so a pocket can't end a call for help.
void UITask::sosKey(char c) {
if (_core->sos.counting() || c == KEY_CONTEXT_MENU) {
showAlert(_core->sos.counting() ? "SOS cancelled" : "SOS stopped", 2000);
if (_locked) _lock_wake_until = millis() + 2000;
_core->stopSos();
closeSosView();
} else if (c == KEY_CANCEL) {
closeSosView();
} else {
openSosView(SOS_VIEW_MS);
}
}
uint32_t UITask::autoOffMillis() const {
return _core->sos.screenMs(_node_prefs ? (uint32_t)_node_prefs->auto_off_secs * 1000UL : 0);
}
void UITask::onSosRunning(bool running) {
#ifdef PIN_BUZZER
if (!_node_prefs) return;
buzzer.setVolume(running && _node_prefs->sos_beep_idx ? 4 : _node_prefs->buzzer_volume); // to be heard, not to be polite
#endif
}
void UITask::tickSos() {
SosEngine& s = _core->sos;
if (s.beepDue(millis())) playMelody(soundctl::MEL_SOS);
if (s.counting()) {
int left = s.secsLeft(millis());
if (left != _sos_left) { // a beep a second, past the buzzer's mute
_sos_left = left;
playMelody(soundctl::MEL_TICK);
_next_refresh = 0;
}
} else if (sosViewOpen() && (int32_t)(millis() - _sos_view_until) >= 0) {
closeSosView();
}
}
void UITask::drainCoreEvents() {
@@ -353,6 +427,21 @@ void UITask::drainCoreEvents() {
case UiEventType::AdvertHeard:
notify(ev.flag ? UIEventType::advertReceivedFlood : UIEventType::advertReceivedZeroHop);
break;
case UiEventType::SosSent:
if (ev.flag) { openSosView(SOS_VIEW_MS); playMelody(soundctl::MEL_ARRIVE); }
_next_refresh = 0;
break;
case UiEventType::SosHeard: {
char text[48];
snprintf(text, sizeof(text), "SOS: %s", ev.text);
showAlert(text, 5000);
_next_refresh = 0;
break;
}
case UiEventType::SosAcked:
openSosView(SOS_VIEW_MS);
playMelody(ev.flag ? soundctl::MEL_ALARM : soundctl::MEL_ARRIVE);
break;
default:
break;
}
@@ -529,6 +618,11 @@ void UITask::pickLocShareTarget() {
setCurrScreen(messages_screen);
}
void UITask::pickSosContact(int slot) {
((MessagesScreen*)messages_screen)->startPickSos(slot);
setCurrScreen(messages_screen);
}
void UITask::pickFavouriteTarget(int slot) {
((MessagesScreen*)messages_screen)->startPickFavourite(slot);
setCurrScreen(messages_screen);
@@ -769,6 +863,22 @@ void UITask::renderMsgBadge() {
if (msgBadgeLit()) drawMailCount(d, x, 0, h, _alert_count);
}
// An SOS running behind whatever is showing: repeats go out and the GPS stays on
// until it's ended, so it mustn't be forgotten. In the header: at its left, clear
// of the centred title, and on Home in the middle, between the clock and the icons.
void UITask::renderSosBadge() {
DisplayDriver& d = *_display;
d.setTextSize(1);
const int pad = d.pixelScale(), w = d.getTextWidth("SOS") + 2 * pad, h = d.getLineHeight(); // Home has its page icons under the bar
const int x = (curr == home || _locked) ? (d.width() - w) / 2 : 0;
d.setColor(UIColor::primary_txt);
d.fillRect(x, 0, w, h);
d.setColor(UIColor::window_bkg);
d.setCursor(x + pad, 0);
d.print("SOS");
d.setColor(UIColor::primary_txt);
}
void UITask::setCurrScreen(UIScreen* c) {
// Fail safe on a null target: a screen pointer left uninitialised (member
// declared + navigator wired, but the `new XScreen()` line forgotten in
@@ -1346,6 +1456,16 @@ void UITask::loop() {
if (_lock_seq_used && millis() - _lock_seq_ms > 5000) {
_lock_seq_used = false; // safety reset if Back release event was missed
}
// Back held on its own: past the button's long press, so no click comes of it.
// Enter during the hold is the lock sequence, not this.
if (!back_btn.isPressed() || user_btn.isPressed() || _lock_seq_count > 0 || _lock_seq_used) {
_back_held_at = back_btn.isPressed() ? 1 : 0; // 1: spoken for until released
} else if (_back_held_at == 0) {
_back_held_at = millis() | 2;
} else if (_back_held_at != 1 && millis() - _back_held_at >= SOS_HOLD_MS) {
_back_held_at = 1;
onBackHeld();
}
ev = back_btn.check();
if (ev == BUTTON_EVENT_CLICK) {
if (_lock_seq_count > 0 || _lock_seq_used) {
@@ -1512,6 +1632,15 @@ void UITask::loop() {
if (_locked) _lock_wake_until = millis() + 5000;
}
// Over everything else, locked or not.
if (_kq_head != _kq_tail && sosViewOpen()) {
char k;
while (sosViewOpen() && dequeueKey(k)) sosKey(k);
_kq_head = _kq_tail = 0;
{ uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; }
_next_refresh = 0;
}
if (_kq_head != _kq_tail) {
if (_unlock_kb) {
// Lock-screen password keyboard: Consume every key press
@@ -1558,7 +1687,14 @@ void UITask::loop() {
cancelUnlockPrompt(); // Cancel unlock attempt on idle
_next_refresh = 0;
}
if (_locked && !_unlock_kb && (int32_t)(millis() - _lock_wake_until) >= 0) {
if (sosViewOpen()) {
if (refreshDue()) {
_display->startFrame();
_badge_in_bar = false;
_next_refresh = millis() + ((SosScreen*)sos_screen)->renderStatus(*_display);
_display->endFrame();
}
} else if (_locked && !_unlock_kb && (int32_t)(millis() - _lock_wake_until) >= 0) {
_display->turnOff();
} else if (_locked && _unlock_kb && refreshDue()) {
// While the prompt is up the password keyboard replaces the lockscreen view
@@ -1583,6 +1719,7 @@ void UITask::loop() {
} else {
home->render(*_display);
_next_refresh = millis() + Features::LOCKSCREEN_REFRESH_MS;
if (!_core->sos.idle()) renderSosBadge();
}
// Alert overlay on top — without this a ringing alarm on a locked device
// played its melody against a screen that never said what was ringing.
@@ -1600,6 +1737,7 @@ void UITask::loop() {
_kb.beginFrame();
int delay_millis = curr->render(*_display);
if (tabbar::endFrame(*_display) && delay_millis > 15) delay_millis = 15; // a tab turn sliding
if (!_core->sos.idle() && !_kb.isVisible()) renderSosBadge();
// Skip the alert overlay (new-message toast) while the keyboard is the
// thing actually on screen this frame -- it's shared across Messages/
// Bot/Settings/Admin/etc., so this covers every screen that uses it for
@@ -1637,7 +1775,7 @@ void UITask::loop() {
uint32_t aoff = autoOffMillis();
_auto_off = millis() + (aoff > 0 ? aoff : AUTO_OFF_MILLIS);
}
if (!_locked && autoOffMillis() > 0 && (int32_t)(millis() - _auto_off) >= 0 && !isRinging()) {
if (!_locked && autoOffMillis() > 0 && (int32_t)(millis() - _auto_off) >= 0 && !isRinging() && !sosViewOpen()) {
_display->turnOff();
#ifdef PIN_LED
digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power
@@ -1715,6 +1853,7 @@ void UITask::loop() {
bool gps_needed_live =
_core->trail.isRecording()
|| (_node_prefs && _node_prefs->loc_share_enabled)
|| _core->sos.gpsWanted()
|| (_node_prefs && _node_prefs->locator_enabled && _node_prefs->locator_has_target)
|| curr == compass_screen
|| (curr == status_screen && ((StatusScreen*)status_screen)->wantsLiveGps())
+18 -4
View File
@@ -131,6 +131,7 @@ class UITask : public UITaskBase, public UiCoreHost {
UIScreen* auto_advert_screen = nullptr;
UIScreen* batt_curve_screen = nullptr;
UIScreen* live_share_screen = nullptr;
UIScreen* sos_screen = nullptr;
UIScreen* locator_screen = nullptr;
UIScreen* trail_screen = nullptr;
UIScreen* compass_screen = nullptr;
@@ -148,6 +149,15 @@ class UITask : public UITaskBase, public UiCoreHost {
void tickCore();
void drainCoreEvents();
void onMessageArrived(const UiEvent& ev); // alert + wake + sound for an incoming message
void openSosView(uint32_t ms);
void closeSosView() { _sos_view_until = 0; _next_refresh = 0; }
bool sosViewOpen() const { return _sos_view_until != 0; }
void sosKey(char c);
void renderSosBadge();
void tickSos();
unsigned long _sos_view_until = 0; // 0 = closed
int _sos_left = 0; // countdown second last beeped
unsigned long _back_held_at = 0; // 0 = not held, or already acted on
@@ -404,6 +414,12 @@ public:
void gotoGpioScreen(); // no-op on boards without user GPIO pins (see PIN_GPIO1)
void gotoClockTools(); // Alarm / Timer / Stopwatch (from the home Clock page)
void gotoFlashlight();
void gotoSosScreen();
void pickSosContact(int slot); // open the Direct list for an SOS slot
// SOS: the full-screen status over everything, also on a locked device. Back
// held starts the SOS, or brings the status back while one is running.
void startSos();
void onBackHeld();
// Wake the display for an alarm/timer ring (force an immediate refresh).
void wakeForAlarm();
// Clear any active alert overlay early (alarm dismiss).
@@ -552,6 +568,7 @@ public:
// Settings changed through the schema (ui-core/SettingsSchema.h).
void applyDisplayPrefs() override { applyBrightness(); applyUiScale(); _next_refresh = 0; }
void applySoundPrefs() override;
void onSosRunning(bool running) override;
bool cliCommand(const char* command, char* reply, int n) override;
void setBuzzerVolumeLevel(uint8_t level);
void applyTxPower();
@@ -566,10 +583,7 @@ public:
void applyRotation();
void applyFullRefreshInterval();
void applyUiScale(); // Settings > Display > Text size / Icon size, from the prefs
uint32_t autoOffMillis() const {
if (!_node_prefs || _node_prefs->auto_off_secs == 0) return 0;
return (uint32_t)_node_prefs->auto_off_secs * 1000UL;
}
uint32_t autoOffMillis() const;
// from UITaskBase