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
+100 -63
View File
@@ -136,6 +136,78 @@ void UITask::powerPopup(bool restart) {
// ── Lock screen ───────────────────────────────────────────────────────────────
// Grows to push what follows to the bottom of a flex column.
static void flexFill(lv_obj_t* parent) {
lv_obj_t* f = lv_obj_create(parent);
lv_obj_remove_style_all(f);
lv_obj_set_width(f, 1);
lv_obj_set_flex_grow(f, 1);
}
static const int SLIDE_W = 264;
// A slide to confirm: a round knob in a track, `text` on it. Not clickable:
// slidePoll() drives it, so that a tap on the track doesn't jump to the end.
static lv_obj_t* slideBar(lv_obj_t* parent, const char* text, uint32_t knob) {
lv_obj_t* s = lv_slider_create(parent);
lv_obj_set_size(s, SLIDE_W, 48);
lv_slider_set_range(s, 0, 100);
lv_obj_set_style_anim_duration(s, 0, 0); // no lag behind the finger
lv_obj_set_style_bg_color(s, lv_color_hex(theme::SURFACE), LV_PART_MAIN);
lv_obj_set_style_bg_opa(s, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_radius(s, LV_RADIUS_CIRCLE, LV_PART_MAIN);
lv_obj_set_style_pad_hor(s, 24, LV_PART_MAIN); // half the track: the knob's centre stops short of its ends
lv_obj_set_style_bg_opa(s, LV_OPA_TRANSP, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(s, lv_color_hex(knob), LV_PART_KNOB);
lv_obj_set_style_pad_all(s, -4, LV_PART_KNOB); // inside the track
lv_obj_set_style_shadow_width(s, 0, LV_PART_KNOB);
lv_obj_set_style_bg_image_src(s, LV_SYMBOL_RIGHT, LV_PART_KNOB);
lv_obj_set_style_text_font(s, THEME_FONT_BODY, LV_PART_KNOB);
lv_obj_set_style_text_color(s, lv_color_hex(0xFFFFFF), LV_PART_KNOB);
lv_obj_remove_flag(s, LV_OBJ_FLAG_CLICKABLE);
lv_obj_center(label(s, text, THEME_FONT_BODY, theme::TEXT_MUTED)); // fades as the knob comes (slidePoll)
return s;
}
static void slideText(lv_obj_t* s, int32_t v) {
const int32_t opa = LV_OPA_COVER - v * LV_OPA_COVER / 60;
lv_obj_set_style_opa(lv_obj_get_child(s, 0), opa < 0 ? 0 : (lv_opa_t)opa, 0);
}
// True once, when a drag that began on the knob reached the end and let go;
// short of the end the knob springs back.
struct SlideDrag { bool dragging = false, was_down = false; };
static bool slidePoll(lv_obj_t* s, SlideDrag& d) {
lv_indev_t* in = lv_indev_get_next(NULL);
if (!s || !in) { d = SlideDrag(); return false; }
bool down = lv_indev_get_state(in) == LV_INDEV_STATE_PRESSED;
lv_point_t p;
lv_indev_get_point(in, &p);
lv_area_t a;
lv_obj_get_coords(s, &a);
int32_t knob = lv_area_get_height(&a); // round knob, as tall as the track
if (down && !d.was_down) { // a drag starts on the knob only
d.dragging = p.y >= a.y1 - 10 && p.y <= a.y2 + 10 && p.x <= a.x1 + knob + 16;
if (d.dragging) lv_anim_delete(s, anim::setSlider);
}
d.was_down = down;
if (!down) {
if (!d.dragging) return false;
d.dragging = false;
int32_t v = lv_slider_get_value(s);
if (v >= 100) return true;
if (v > 0) anim::run(s, anim::setSlider, v, 0, anim::SPRING_MS);
slideText(s, 0);
return false;
}
if (!d.dragging) return false;
int32_t span = lv_area_get_width(&a) - knob;
int32_t v = span > 0 ? (p.x - a.x1 - knob / 2) * 100 / span : 0;
v = v < 0 ? 0 : v >= 92 ? 100 : v; // snaps to the end: let go to confirm
lv_slider_set_value(s, v, LV_ANIM_OFF);
slideText(s, v);
return false;
}
// From sleep() when the lock is on. Built while the screen is dark, so waking
// shows it straight away.
void UITask::lockScreen() {
@@ -181,26 +253,9 @@ void UITask::lockScreen() {
s_lock_unread = label(s_lock, "", THEME_FONT_BODY, theme::ACCENT);
lv_obj_set_style_pad_top(s_lock_unread, 8, 0);
lv_obj_t* track = lv_obj_create(s_lock); // grows to push the slider to the bottom
lv_obj_remove_style_all(track);
lv_obj_set_width(track, 1);
lv_obj_set_flex_grow(track, 1);
s_lock_slider = lv_slider_create(s_lock);
lv_obj_set_size(s_lock_slider, 250, 44);
flexFill(s_lock);
s_lock_slider = slideBar(s_lock, "slide to unlock", theme::ACCENT);
lv_obj_set_style_margin_bottom(s_lock_slider, 44, 0); // clear of the toast below
lv_slider_set_range(s_lock_slider, 0, 100);
lv_obj_set_style_anim_duration(s_lock_slider, 0, 0); // no lag behind the finger
lv_obj_set_style_bg_color(s_lock_slider, lv_color_hex(theme::SURFACE), LV_PART_MAIN);
lv_obj_set_style_bg_opa(s_lock_slider, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_radius(s_lock_slider, LV_RADIUS_CIRCLE, LV_PART_MAIN);
lv_obj_set_style_bg_opa(s_lock_slider, LV_OPA_TRANSP, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(s_lock_slider, lv_color_hex(theme::ACCENT), LV_PART_KNOB);
lv_obj_set_style_pad_all(s_lock_slider, 2, LV_PART_KNOB);
lv_obj_set_style_shadow_width(s_lock_slider, 0, LV_PART_KNOB);
lv_obj_remove_flag(s_lock_slider, LV_OBJ_FLAG_CLICKABLE); // lockPoll() drives it
lv_obj_t* hint = label(s_lock_slider, "slide to unlock " LV_SYMBOL_RIGHT, THEME_FONT_BODY, theme::TEXT_MUTED);
lv_obj_align(hint, LV_ALIGN_CENTER, 14, 0);
if (_toast) lv_obj_move_foreground(_toast); // new-message toasts still show
refreshLock();
@@ -238,34 +293,8 @@ bool UITask::locked() const { return devview::s_lock != nullptr; }
// itself, like the Home page swipe -- LVGL stops feeding a pressed widget
// once the finger has moved past its scroll threshold.
void UITask::lockPoll() {
using namespace devview;
static bool s_dragging = false, s_was_down = false;
lv_indev_t* in = lv_indev_get_next(NULL);
if (!s_lock_slider || !in) { s_dragging = s_was_down = false; return; }
bool down = lv_indev_get_state(in) == LV_INDEV_STATE_PRESSED;
lv_point_t p;
lv_indev_get_point(in, &p);
lv_area_t a;
lv_obj_get_coords(s_lock_slider, &a);
int32_t knob = lv_area_get_height(&a); // round knob, as tall as the track
if (down && !s_was_down) { // a drag starts on the knob only
s_dragging = p.y >= a.y1 - 10 && p.y <= a.y2 + 10 && p.x <= a.x1 + knob + 16;
if (s_dragging) lv_anim_delete(s_lock_slider, anim::setSlider);
}
s_was_down = down;
if (!down) {
if (!s_dragging) return;
s_dragging = false;
int32_t v = lv_slider_get_value(s_lock_slider);
if (v >= 100) unlockScreen();
else if (v > 0) anim::run(s_lock_slider, anim::setSlider, v, 0, anim::SPRING_MS);
return;
}
if (!s_dragging) return;
int32_t span = lv_area_get_width(&a) - knob;
int32_t v = span > 0 ? (p.x - a.x1 - knob / 2) * 100 / span : 0;
v = v < 0 ? 0 : v >= 92 ? 100 : v; // snaps to the end: let go to unlock
lv_slider_set_value(s_lock_slider, v, LV_ANIM_OFF);
static SlideDrag s_drag;
if (slidePoll(devview::s_lock_slider, s_drag)) unlockScreen();
}
void UITask::refreshLock() {
@@ -499,6 +528,29 @@ void UITask::favAction(uint8_t act) {
favRefresh();
}
// A picker's contacts, favourites first with their star; `rooms`: rooms too.
// A row's user data is `base` + its number, its key s_pick_keys[number].
// `selected` (a key's prefix) stands out.
static void pickContacts(lv_obj_t* list, bool rooms, lv_event_cb_t cb, int base, const uint8_t* selected = nullptr) {
using namespace devview;
int rows = 0, total = the_mesh.getNumContacts();
for (int pass = 0; pass < 2; pass++) {
for (int i = 0; i < total && rows < PICK_MAX; i++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c)) continue;
if (c.type != ADV_TYPE_CHAT && !(rooms && c.type == ADV_TYPE_ROOM)) continue;
if (contactctl::favourite(c) != (pass == 0)) continue;
memcpy(s_pick_keys[rows], c.id.pub_key, PUB_KEY_SIZE);
char t[48];
snprintf(t, sizeof(t), "%s%s%s", contactctl::favourite(c) ? UI_SYMBOL_STAR " " : "", c.name,
c.type == ADV_TYPE_ROOM ? " (room)" : "");
lv_obj_t* row = listRow(list, t, NULL, cb, (void*)(uintptr_t)(base + rows));
if (selected && memcmp(c.id.pub_key, selected, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) styleSelected(row, true);
rows++;
}
}
}
// What to put in a slot: channels, then contacts and rooms (favourites first).
void UITask::favPickPopup(int slot) {
using namespace devview;
@@ -519,22 +571,7 @@ void UITask::favPickPopup(int slot) {
listRow(list, ch.name, NULL, onFavPick, (void*)(uintptr_t)i);
}
sectionTitle(list, "CONTACTS AND ROOMS");
int rows = 0, total = the_mesh.getNumContacts();
const int MAX_ROWS = PICK_MAX;
for (int pass = 0; pass < 2; pass++) {
for (int i = 0; i < total && rows < MAX_ROWS; i++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c)) continue;
if (c.type != ADV_TYPE_CHAT && c.type != ADV_TYPE_ROOM) continue;
if (contactctl::favourite(c) != (pass == 0)) continue;
memcpy(s_pick_keys[rows], c.id.pub_key, PUB_KEY_SIZE);
char t[48];
snprintf(t, sizeof(t), "%s%s%s", contactctl::favourite(c) ? UI_SYMBOL_STAR " " : "", c.name,
c.type == ADV_TYPE_ROOM ? " (room)" : "");
listRow(list, t, NULL, onFavPick, (void*)(uintptr_t)(PICK_CONTACT + rows));
rows++;
}
}
pickContacts(list, true, onFavPick, PICK_CONTACT);
}
void UITask::favPick(int code) {
@@ -35,6 +35,7 @@ static const App APP_LIST[] = {
{ 'D', UI_SYMBOL_CHART, "Diagnostics", onOpenDiag, false },
{ 'L', UI_SYMBOL_BULB, "Flashlight", onOpenFlashlight, false },
{ 'P', LV_SYMBOL_IMAGE, "Gallery", onOpenGallery, false },
{ 'O', LV_SYMBOL_WARNING, "SOS", onOpenSos, false },
};
static const int APP_COUNT = sizeof(APP_LIST) / sizeof(APP_LIST[0]);
static const int PER_PAGE = 6;
@@ -0,0 +1,383 @@
#pragma once
// SOS on the L2. The engine and the messages are ui-core (SosEngine,
// UiCore::sendSos); here: Home > SOS (the settings), and the view over
// everything, the lock screen and a screen PIN too, on lv_layer_top:
// - the sheet: holding the top button 5 s (Top button on) or Send SOS brings a
// slider, like a phone's emergency slider: a button in a pocket mustn't start one;
// - the countdown: big digits, Cancel; either button cancels too;
// - while it runs: how it's going, Hide, and a slider to stop it, so a pocket
// can't end a call for help. Hidden, the status bar and the title bar's
// clock turn red (refreshStatusBar()); holding the top button brings it back.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp after Flashlight.h.
namespace sosview {
enum Mode : uint8_t { NONE, SHEET, LIVE };
static const uint32_t VIEW_MS = 30000; // the view stays this long untouched (not the countdown)
static Mode s_mode = NONE;
static bool s_counting = false; // the LIVE view was built for the countdown
static bool s_wifi_was = false; // onSosRunning() rested it
static lv_obj_t* s_view = nullptr;
static lv_obj_t* s_slider = nullptr;
static lv_obj_t* s_title = nullptr;
static lv_obj_t* s_digits = nullptr;
static lv_obj_t* s_line[4] = { nullptr, nullptr, nullptr, nullptr };
static lv_obj_t* s_ch_lbl = nullptr; // Channels' value, behind its popup
static SlideDrag s_drag;
static uint32_t s_until = 0, s_next = 0;
static int s_left = 0; // the countdown second last beeped
static int s_edit = -1; // the row a popup is editing
enum Row : uint8_t { R_CHANNELS, R_DM1, R_DM2, R_DM3, R_ACK, R_NOTE };
enum Pref : uint8_t { P_COUNTDOWN, P_REPEAT, P_BEEP };
static const char* COUNTDOWNS[NodePrefs::SOS_COUNTDOWN_COUNT + 1] = { "5s", "10s", "15s", "" };
static const char* REPEATS[NodePrefs::SOS_REPEAT_COUNT + 1] = { "Off", "1m", "2m", "5m", "10m", "" };
static const char* BEEPS[NodePrefs::SOS_BEEP_COUNT + 1] = { "Off", "30s", "1m", "5m", "" };
} // namespace sosview
static void onOpenSos(lv_event_t* e) { (void)e; s_ui->showSos(); }
static void onSosButton(lv_event_t* e) { (void)e; s_ui->sosButton(); }
static void onSosSend(lv_event_t* e) { (void)e; s_ui->sosSheet(); }
static void onSosRow(lv_event_t* e) { s_ui->sosRow((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onSosSeg(lv_event_t* e) {
s_ui->sosSet((int)(uintptr_t)lv_event_get_user_data(e),
(int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)));
}
static void onSosChannel(lv_event_t* e) {
s_ui->sosChannel((int)(uintptr_t)lv_event_get_user_data(e), lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED));
}
static void onSosContact(lv_event_t* e) { s_ui->sosContact((int)(intptr_t)lv_event_get_user_data(e)); }
static void onSosTextKb(lv_event_t* e) { s_ui->sosTextDone(lv_event_get_code(e) == LV_EVENT_READY); }
// ── The view ──────────────────────────────────────────────────────────────────
bool UITask::sosViewOpen() const { return sosview::s_mode != sosview::NONE; }
void UITask::sosViewClose() {
using namespace sosview;
if (s_view) lv_obj_delete_async(s_view); // may be closing from its own button
s_view = s_slider = s_title = s_digits = nullptr;
s_line[0] = s_line[1] = s_line[2] = s_line[3] = nullptr;
s_drag = SlideDrag();
s_mode = NONE;
}
static lv_obj_t* sosPanel() {
lv_obj_t* v = lv_obj_create(lv_layer_top());
lv_obj_remove_style_all(v);
lv_obj_set_size(v, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(v, lv_color_hex(theme::BG), 0);
lv_obj_set_style_bg_opa(v, LV_OPA_COVER, 0);
lv_obj_add_flag(v, LV_OBJ_FLAG_CLICKABLE); // swallows every touch but its own
lv_obj_remove_flag(v, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(v, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(v, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_top(v, 14, 0);
lv_obj_set_style_pad_bottom(v, 12, 0);
lv_obj_set_style_pad_hor(v, theme::PAD, 0);
lv_obj_set_style_pad_row(v, 6, 0);
return v;
}
static lv_obj_t* sosText(lv_obj_t* v, const char* text, const lv_font_t* font, uint32_t col) {
lv_obj_t* l = label(v, text, font, col);
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
lv_obj_set_width(l, LV_PCT(100));
lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, 0);
return l;
}
static void sosWideButton(lv_obj_t* parent, const char* text, int32_t w, int32_t h, uint32_t bg, uint32_t col, lv_event_cb_t cb) {
lv_obj_t* b = lv_button_create(parent);
lv_obj_set_size(b, w, h);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_radius(b, theme::RADIUS, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(bg), 0);
lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(b, text, THEME_FONT_BODY, col));
}
static void sosFootButton(lv_obj_t* v, const char* text) {
sosWideButton(v, text, SLIDE_W, 40, theme::SURFACE_2, theme::TEXT, onSosButton);
}
// The sheet: what will happen, and the slide that starts it.
void UITask::sosSheet() {
using namespace sosview;
if (!_prefs || !_core->sos.idle()) { sosOpen(); return; }
if (!SosEngine::hasTargets(_prefs)) { showToast("SOS: no targets"); return; }
flashlightClose();
quickPanelClose(false);
sosViewClose();
s_view = sosPanel();
s_mode = SHEET;
s_until = millis() + VIEW_MS;
sosText(s_view, "Emergency SOS", THEME_FONT_LARGE, theme::FAIL);
const int ch = SosEngine::channels(_prefs), dm = SosEngine::contacts(_prefs);
char to[64];
snprintf(to, sizeof(to), "Sends your position to %d channel%s and %d contact%s", ch, ch == 1 ? "" : "s", dm, dm == 1 ? "" : "s");
sosText(s_view, to, THEME_FONT_BODY, theme::TEXT_MUTED);
flexFill(s_view);
s_slider = slideBar(s_view, "slide for SOS", theme::FAIL);
sosFootButton(s_view, "Cancel");
}
// The countdown, or how it's going.
void UITask::sosLive() {
using namespace sosview;
sosViewClose();
s_view = sosPanel();
s_mode = LIVE;
s_counting = _core->sos.counting();
s_until = millis() + VIEW_MS;
s_next = 0;
s_title = sosText(s_view, "", THEME_FONT_LARGE, theme::FAIL);
if (s_counting) s_digits = sosText(s_view, "", THEME_FONT_CLOCK, theme::TEXT);
else for (int i = 0; i < 4; i++) {
// Name and when, then what they said, for the latest two who answered.
s_line[i] = sosText(s_view, "", THEME_FONT_BODY, i % 2 ? theme::TEXT_MUTED : theme::TEXT);
lv_label_set_long_mode(s_line[i], LV_LABEL_LONG_DOT);
lv_obj_set_height(s_line[i], 18);
}
flexFill(s_view);
lv_obj_set_style_pad_row(s_view, 2, 0); // room for the lines
if (!s_counting) s_slider = slideBar(s_view, _core->sos.acked() ? "slide to end" : "slide to stop", theme::FAIL);
sosFootButton(s_view, s_counting ? "Cancel" : "Hide");
sosRefresh();
}
void UITask::sosRefresh() {
using namespace sosview;
SosEngine& s = _core->sos;
const uint32_t now = millis();
char buf[48];
if (s_counting) {
setText(s_title, "SOS in");
snprintf(buf, sizeof(buf), "%d", s.secsLeft(now));
setText(s_digits, buf);
return;
}
setText(s_title, s.acked() ? "Answered" : "SOS");
setTextColor(s_title, s.acked() ? theme::OK : theme::FAIL);
if (s.acked()) {
for (int i = 0; i < 2; i++) {
const bool on = i < s.responders();
buf[0] = '\0';
if (on) {
const SosEngine::Responder& r = s.responder(i);
const uint32_t mins = (now - r.at) / 60000UL;
if (mins) snprintf(buf, sizeof(buf), "%s, %u min ago", r.name, (unsigned)mins);
else snprintf(buf, sizeof(buf), "%s, now", r.name);
}
setText(s_line[i * 2], buf);
setText(s_line[i * 2 + 1], on ? s.responder(i).text : "");
}
return;
}
setText(s_line[3], "");
s.sentLine(buf, sizeof(buf));
setText(s_line[0], buf);
s.nextLine(buf, sizeof(buf), now);
setText(s_line[1], buf);
int32_t lat, lon;
setText(s_line[2], _core->course.currentLocation(lat, lon) ? "" : "No GPS fix");
}
// A screen that was dark comes on for it.
void UITask::sosOpen() {
if (_core->sos.idle()) return;
if (_asleep) wake();
lv_display_trigger_activity(NULL);
if (!sosViewOpen() || sosview::s_mode == sosview::LIVE) sosLive();
}
// The top button held.
void UITask::sosButtonHeld() {
if (!_core->sos.idle()) { sosOpen(); return; }
if (!_prefs || !_prefs->sos_enabled || sosViewOpen()) return;
if (_asleep) wake();
lv_display_trigger_activity(NULL);
playMelody(soundctl::MEL_TICK); // felt in a pocket: the hold got through
sosSheet();
}
// Cancel / Hide, or either physical button: anything ends the sheet and the
// countdown; after that it only hides the view.
void UITask::sosButton() {
if (_core->sos.counting()) {
_core->stopSos();
showToast("SOS cancelled");
}
sosViewClose();
}
// The sliders. Driven from the loop like the lock screen's.
void UITask::sosPoll() {
using namespace sosview;
if (!s_slider || !slidePoll(s_slider, s_drag)) return;
if (s_mode == SHEET) {
_core->startSos();
s_left = 0;
sosLive();
} else {
_core->stopSos();
sosViewClose();
showToast("SOS stopped");
}
}
void UITask::sosTick() {
using namespace sosview;
SosEngine& s = _core->sos;
const uint32_t now = millis();
if (_sensors) _sensors->setGpsKeepAwake(s.gpsWanted()); // past a GPS duty cycle
if (s.beepDue(now)) playMelody(soundctl::MEL_SOS);
if (s.counting()) {
const int left = s.secsLeft(now);
if (left != s_left) { // a beep a second, past the buzzer's mute
s_left = left;
playMelody(soundctl::MEL_TICK);
}
}
if (s_mode == LIVE && s_counting != s.counting()) sosLive(); // the countdown ended
if (s_mode == LIVE && (int32_t)(now - s_next) >= 0) {
s_next = now + 200;
sosRefresh();
}
if (s_mode != NONE && !s.counting()) {
if (lv_display_get_inactive_time(NULL) < 1000) s_until = now + VIEW_MS;
if ((int32_t)(now - s_until) >= 0) sosViewClose();
}
}
// The battery goes to the SOS: WiFi rests until it ends. Its switch is saved, so
// a restart mid-SOS leaves it off.
void UITask::onSosRunning(bool running) {
using namespace sosview;
if (running) {
s_wifi_was = lvport::wifiAllowed();
if (s_wifi_was) wifiSetAllowed(false);
} else if (s_wifi_was) {
wifiSetAllowed(true);
}
#ifdef PIN_BUZZER
if (_prefs) _buzzer.setVolume(running && _prefs->sos_beep_idx ? 4 : _prefs->buzzer_volume); // to be heard, not to be polite
#endif
refreshStatusBar();
}
// ── Home > SOS ────────────────────────────────────────────────────────────────
void UITask::showSos() {
_screen = SCR_SOS;
buildSos();
}
static lv_obj_t* sosValueRow(lv_obj_t* g, const char* name, const char* hint, const char* val, int row) {
return rowPick(settingRow(g, name, hint), val, 150, onSosRow, (void*)(uintptr_t)row);
}
void UITask::buildSos() {
using namespace sosview;
lv_obj_t* body = newScreen("SOS", true);
NodePrefs* p = _prefs;
if (!p) return;
lv_obj_t* list = scrollList(body);
lv_obj_t* g = group(list, nullptr);
switchRow(g, "Top button", "Hold 5 s for the SOS slider", &p->sos_enabled);
lv_obj_t* seg = segmented(settingRow(g, "Countdown", nullptr), COUNTDOWNS, p->sos_countdown_idx, 130, 32);
lv_obj_add_event_cb(seg, onSosSeg, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)P_COUNTDOWN);
seg = segmented(settingRow(g, "Repeat", nullptr), REPEATS, p->sos_repeat_idx, 200, 32);
lv_obj_add_event_cb(seg, onSosSeg, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)P_REPEAT);
seg = segmented(settingRow(g, "Beep", "To be found by"), BEEPS, p->sos_beep_idx, 170, 32);
lv_obj_add_event_cb(seg, onSosSeg, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)P_BEEP);
char val[40];
snprintf(val, sizeof(val), "%d on", SosEngine::channels(p));
s_ch_lbl = sosValueRow(g, "Channels", nullptr, val, R_CHANNELS);
for (int i = 0; i < NodePrefs::SOS_DM_MAX; i++) {
ContactInfo* c = SosEngine::hasDm(p, i)
? the_mesh.lookupContactByPubKey(p->sos_dm[i], NodePrefs::FAVOURITE_PREFIX_LEN) : nullptr;
char name[12];
snprintf(name, sizeof(name), "Contact %d", i + 1);
sosValueRow(g, name, nullptr, c ? c->name : "-", R_DM1 + i);
}
sosValueRow(g, "ACK word", "The reply that means received", p->sos_ack[0] ? p->sos_ack : "-", R_ACK);
sosValueRow(g, "Note", "Added to the message", p->sos_note[0] ? p->sos_note : "-", R_NOTE);
sosWideButton(list, "Send SOS", LV_PCT(100), 44, theme::FAIL, 0xFFFFFF, onSosSend);
}
void UITask::sosSet(int what, int val) {
using namespace sosview;
if (!_prefs) return;
if (what == P_COUNTDOWN && val >= 0 && val < NodePrefs::SOS_COUNTDOWN_COUNT) _prefs->sos_countdown_idx = (uint8_t)val;
else if (what == P_REPEAT && val >= 0 && val < NodePrefs::SOS_REPEAT_COUNT) _prefs->sos_repeat_idx = (uint8_t)val;
else if (what == P_BEEP && val >= 0 && val < NodePrefs::SOS_BEEP_COUNT) _prefs->sos_beep_idx = (uint8_t)val;
prefsSave();
}
void UITask::sosChannel(int idx, bool on) {
using namespace sosview;
if (!_prefs || idx < 0 || idx >= MAX_GROUP_CHANNELS) return;
if (on) _prefs->sos_ch_mask |= 1ULL << idx;
else _prefs->sos_ch_mask &= ~(1ULL << idx);
prefsSave();
char val[16];
snprintf(val, sizeof(val), "%d on", SosEngine::channels(_prefs));
if (s_ch_lbl) lv_label_set_text(s_ch_lbl, val);
}
void UITask::sosRow(int row) {
using namespace sosview;
NodePrefs* p = _prefs;
if (!p) return;
s_edit = row;
if (row == R_ACK || row == R_NOTE) {
TextEntry te = { row == R_ACK ? "ACK word" : "Note", onSosTextKb };
te.text = row == R_ACK ? p->sos_ack : p->sos_note;
te.max_bytes = (row == R_ACK ? sizeof(p->sos_ack) : sizeof(p->sos_note)) - 1;
te.hint = row == R_ACK ? "e.g. ACK" : "e.g. blood group";
navTextEntry(te);
return;
}
lv_obj_t* list = scrollList(navPopupPanel(row == R_CHANNELS ? "SOS channels" : "SOS contact", true));
if (row == R_CHANNELS) {
lv_obj_t* g = group(list, nullptr);
int n = 0;
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (!the_mesh.getChannel(i, ch) || !ch.name[0]) continue;
switchRowIdx(g, ch.name, nullptr, p->sos_ch_mask & (1ULL << i), onSosChannel, i);
n++;
}
if (n == 0) label(list, "No channels.", THEME_FONT_SMALL, theme::TEXT_MUTED);
return;
}
listRow(list, "(none)", NULL, onSosContact, (void*)(intptr_t)-1);
pickContacts(list, false, onSosContact, 0, p->sos_dm[row - R_DM1]);
}
void UITask::sosContact(int idx) {
using namespace sosview;
if (!_prefs || s_edit < R_DM1 || s_edit > R_DM3 || idx >= devview::PICK_MAX) return;
uint8_t* slot = _prefs->sos_dm[s_edit - R_DM1];
if (idx < 0) memset(slot, 0, NodePrefs::FAVOURITE_PREFIX_LEN);
else memcpy(slot, devview::s_pick_keys[idx], NodePrefs::FAVOURITE_PREFIX_LEN);
prefsSave();
navClosePopup();
buildSos();
}
void UITask::sosTextDone(bool ok) {
using namespace sosview;
if (ok && _nav_ta && _prefs && (s_edit == R_ACK || s_edit == R_NOTE)) {
if (s_edit == R_ACK) snprintf(_prefs->sos_ack, sizeof(_prefs->sos_ack), "%s", lv_textarea_get_text(_nav_ta));
else snprintf(_prefs->sos_note, sizeof(_prefs->sos_note), "%s", lv_textarea_get_text(_nav_ta));
prefsSave();
}
navClosePopup();
if (ok) buildSos();
}
+59 -9
View File
@@ -122,6 +122,8 @@ static bool s_sim_btn_click = false, s_sim_btn_hold = false, s_sim_wake = false;
extern "C" EMSCRIPTEN_KEEPALIVE void sim_lcd_button() { s_sim_btn_click = true; }
extern "C" EMSCRIPTEN_KEEPALIVE void sim_lcd_button_hold() { s_sim_btn_hold = true; }
extern "C" EMSCRIPTEN_KEEPALIVE void sim_wake_button() { s_sim_wake = true; }
static bool s_sim_wake_hold = false; // the top button held 5 s
extern "C" EMSCRIPTEN_KEEPALIVE void sim_wake_hold() { s_sim_wake_hold = true; }
static bool s_sim_shot = false; // the side + top button combo
extern "C" EMSCRIPTEN_KEEPALIVE void sim_screenshot() { s_sim_shot = true; }
// The speaker, polled every frame by the page's Web Audio oscillator.
@@ -211,6 +213,7 @@ void UITask::toggleMute() {
}
static constexpr uint32_t LOCK_OFF_MS = 30000; // the lock screen's own auto-off cap
static constexpr uint32_t SOS_HOLD_MS = 5000; // the top button held: the SOS slider
#include "PerfTest.h" // -D UI_PERF_TEST only: draw profiles and control timing
@@ -244,6 +247,7 @@ void UITask::loop() {
wake_press = down && !_wake_down;
wake_release = !down && _wake_down;
_wake_down = down;
if (wake_press) _wake_since_ms = millis();
}
#endif
// Side button held + top button: a screenshot (and neither does its own thing).
@@ -261,13 +265,29 @@ void UITask::loop() {
s_sim_shot = false;
if (mute_armed) { mute_armed = false; mute = true; } // the page sends the hold on release
#endif
// Top button held, alone: SOS. Its release then does nothing more.
bool sos_hold = false;
#if defined(SEEED_WIO_TRACKER_L2)
static bool sos_held = false;
if (wake_release) sos_held = false;
if (_wake_down && !sos_held && !user_btn.isPressed() && millis() - _wake_since_ms >= SOS_HOLD_MS) {
sos_held = true;
sos_hold = true;
}
#elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
sos_hold = s_sim_wake_hold;
s_sim_wake_hold = false;
#endif
if (sos_hold) { wake_used = true; sosButtonHeld(); }
if (wake_press && _core->clock.isRinging()) wake_used = true;
const bool wake_now = wake_press && _asleep && !wake_used;
if (wake_now) wake_used = true; // its release must not put it back to sleep
const bool sleep_now = wake_release && !wake_used && !_asleep;
const bool sos_press = sosViewOpen() && ((wake_release && !wake_used) || btn_click); // it takes the buttons
const bool sleep_now = wake_release && !wake_used && !_asleep && !sos_press;
if (wake_release) wake_used = false;
if (shot) { if (!_asleep) takeScreenshot(); }
else if ((btn_click || btn_hold || wake_press) && _core->clock.isRinging()) dismissRing();
else if (sos_press) sosButton();
else if (wake_now) wake();
else if (sleep_now) sleep();
else if (btn_hold && millis() - _ui_started_ms < 8000) { // held in the first 8 s: CLI rescue, as on the L1
@@ -294,11 +314,12 @@ void UITask::loop() {
// even with auto-off at Never (a woken-up pocket, a boot PIN, a message).
uint32_t aoff = autoOffMillis();
if (locked() && (aoff == 0 || aoff > LOCK_OFF_MS)) aoff = LOCK_OFF_MS;
if (aoff > 0 && lv_display_get_inactive_time(NULL) > aoff && !_core->clock.isRinging() && !otaBusy() && !flashlightOn()) sleep();
if (aoff > 0 && lv_display_get_inactive_time(NULL) > aoff && !_core->clock.isRinging() && !otaBusy() && !flashlightOn() && !_core->sos.counting()) sleep();
}
_core->loop();
drainCoreEvents();
sosTick();
prefsFlush();
usbPoll();
#if defined(UI_HEAP_REPORT) && defined(ESP32)
@@ -346,7 +367,8 @@ void UITask::loop() {
refreshBattCurve();
if (quickPanelOpen()) quickPanelRefresh();
}
if (locked()) lockPoll();
if (sosViewOpen()) sosPoll();
else if (locked()) lockPoll();
else if (_screen == SCR_HOME) { homeSwipePoll(); homeMapLoop(); }
else if (_screen == SCR_GALLERY) galleryTick();
if ((_screen == SCR_NEARBY || _screen == SCR_NODE) && (int32_t)(millis() - _next_nearby_ms) >= 0) {
@@ -434,8 +456,7 @@ void UITask::checkLowBattery() {
}
uint32_t UITask::autoOffMillis() const {
if (!_prefs || _prefs->auto_off_secs == 0) return 0;
return (uint32_t)_prefs->auto_off_secs * 1000UL;
return _core->sos.screenMs(_prefs ? (uint32_t)_prefs->auto_off_secs * 1000UL : 0);
}
static void bannerDismiss(); // below, with the banner
@@ -446,6 +467,7 @@ void UITask::sleep() {
quickPanelClose(false);
flashlightClose();
bannerDismiss();
if (sosViewOpen()) sosViewClose();
if (_toast) { // same: the toast's timer won't run while asleep
if (_toast_timer) lv_timer_pause(_toast_timer);
lv_anim_delete(_toast, NULL);
@@ -466,6 +488,7 @@ void UITask::wake() {
refreshStatusBar();
if (_screen == SCR_HOME) refreshHome();
if (_screen == SCR_THREAD) { if (locked()) refreshThread(); else reopenThreadIfUnread(); } // locked: on unlocking
if (_core->sos.acked()) sosLive(); // who answered, and what: it mustn't be missed
lv_obj_invalidate(lv_screen_active());
lv_refr_now(NULL);
lvport::backlightFadeIn();
@@ -480,6 +503,22 @@ void UITask::drainCoreEvents() {
case UiEventType::MessageArrived:
onMessageArrived(ev);
break;
case UiEventType::SosSent:
if (ev.flag) {
sosOpen();
playMelody(soundctl::MEL_ARRIVE);
}
break;
case UiEventType::SosAcked:
sosOpen();
playMelody(ev.flag ? soundctl::MEL_ALARM : soundctl::MEL_ARRIVE);
break;
case UiEventType::SosHeard: {
char text[48];
snprintf(text, sizeof(text), "SOS: %s", ev.text);
showToast(text, 5000);
break;
}
case UiEventType::ClockAlert:
showRing(ev.text);
playMelody(soundctl::MEL_ALARM);
@@ -833,8 +872,16 @@ void UITask::refreshStatusBar() {
// share, repeater, arrival alert -- in the accent colour. Right to left in
// that order, next to the battery.
struct Icon { const char* sym; uint32_t col; };
Icon icons[11];
Icon icons[12];
int n = 0;
static const char SOS_SYM[] = "SOS"; // text, not in the icon font
const bool sos = !_core->sos.idle();
const uint32_t sos_col = _core->sos.acked() ? theme::OK : theme::FAIL; // green: someone has answered
if (sos) icons[n++] = { SOS_SYM, sos_col };
if (_hdr_time) { // a screen with a title bar has no status bar: its clock takes the colour
lv_obj_set_style_bg_color(lv_obj_get_parent(_hdr_time), lv_color_hex(sos ? sos_col : theme::SURFACE), 0);
setTextColor(_hdr_time, sos ? 0xFFFFFF : theme::TEXT);
}
if (isBluetoothEnabled()) icons[n++] = { LV_SYMBOL_BLUETOOTH, hasConnection() ? theme::ACCENT : theme::TEXT_MUTED };
if (lvport::wifiAllowed()) // WiFi switched on: accent while connected (map tiles, update)
icons[n++] = { LV_SYMBOL_WIFI, lvport::netRadio() == lvport::NET_UP ? theme::ACCENT : theme::TEXT_MUTED };
@@ -860,7 +907,7 @@ void UITask::refreshStatusBar() {
strcpy(_status_sig, sig);
lv_obj_clean(_status_icons);
for (int i = n - 1; i >= 0; i--) // the icon font gives each the same size and cell
label(_status_icons, icons[i].sym, THEME_FONT_ICONS, icons[i].col);
label(_status_icons, icons[i].sym, icons[i].sym == SOS_SYM ? THEME_FONT_BODY : THEME_FONT_ICONS, icons[i].col);
}
placeLeftOf(_status_icons, left_of, -5);
}
@@ -1236,7 +1283,7 @@ int UITask::screenDepth(Screen s) {
switch (s) {
case SCR_HOME: return 0;
case SCR_CHATS: case SCR_NEARBY: case SCR_MAP: case SCR_SETTINGS:
case SCR_COMPASS: case SCR_GALLERY: case SCR_CLOCK: case SCR_BOT: case SCR_REPEATER: case SCR_ADMIN_PICK:
case SCR_COMPASS: case SCR_GALLERY: case SCR_CLOCK: case SCR_BOT: case SCR_SOS: case SCR_REPEATER: case SCR_ADMIN_PICK:
case SCR_DIAG: case SCR_GPS: return 1;
case SCR_THREAD: case SCR_CONTACTS: case SCR_CHANNEL_EDIT: case SCR_NODE:
case SCR_SETTINGS_NAV: case SCR_ADMIN: return 2;
@@ -1252,6 +1299,7 @@ void UITask::back() {
case SCR_CONTACTS: showChats(); break;
case SCR_CHANNEL_EDIT: showChats(); break;
case SCR_BOT: if (_nav_overlay) navClosePopup(); else showHome(); break;
case SCR_SOS: if (_nav_overlay) navClosePopup(); else showHome(); break;
case SCR_DIAG: if (_nav_overlay) navClosePopup(); else showHome(); break;
case SCR_ADMIN_PICK: showHome(); break;
case SCR_ROUTE: // back to the node; a trace or discovery in flight is dropped
@@ -1346,6 +1394,7 @@ static void onOpenNearby(lv_event_t* e) { (void)e; s_ui->showNearby(); }
static void onOpenNodesMap(lv_event_t* e) { (void)e; s_ui->openMap(false); }
static void onOpenClock(lv_event_t* e); // ClockScreen.h
static void onOpenBot(lv_event_t* e); // BotScreen.h
static void onOpenSos(lv_event_t* e); // SosScreen.h
static void onOpenCompass(lv_event_t* e); // CompassScreen.h
static void onOpenGallery(lv_event_t* e); // GalleryScreen.h
static void onOpenFlashlight(lv_event_t* e); // Flashlight.h
@@ -1392,6 +1441,7 @@ static void onOpenSchemaPage(lv_event_t* e); // Radio > Advanced
#include "DiagScreen.h"
#include "CompassScreen.h"
#include "Flashlight.h"
#include "SosScreen.h"
#include "BatteryScreen.h"
#include "GpsScreen.h"
#include "RadioExtras.h"
@@ -1452,7 +1502,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) {
{ "home", &UITask::showHome }, { "chats", &UITask::showChats }, { "settings", &UITask::showSettings },
{ "nodes", &UITask::showNearby }, { "ota", &UITask::showOta }, { "wifi", &UITask::showWifi },
{ "clock", &UITask::showClock }, { "radio", &UITask::showRadio }, { "scopes", &UITask::showScopes },
{ "repeater", &UITask::showRepeater }, { "bot", &UITask::showBot }, { "storage", &UITask::showStorage },
{ "repeater", &UITask::showRepeater }, { "bot", &UITask::showBot }, { "sos", &UITask::showSos }, { "storage", &UITask::showStorage },
{ "diag", &UITask::showDiag }, { "compass", &UITask::showCompass }, { "gallery", &UITask::showGallery }, { "admin", &UITask::showAdminPick },
{ "quick", &UITask::showQuickMsgs }, { "contacts", &UITask::showContacts },
};
+20 -1
View File
@@ -72,6 +72,7 @@ public:
void onViewedHistoryGrew(bool channel) override { (void)channel; _thread_dirty = true; }
void botSetGPS(bool on) override;
void applySoundPrefs() override;
void onSosRunning(bool running) override;
bool cliCommand(const char* command, char* reply, int n) override;
// ── Navigation (called from LVGL event callbacks) ────────────────────────
@@ -315,6 +316,15 @@ public:
void botToggle(int row, bool on);
void botCmd(int id, bool on);
void botGap(int idx);
void showSos(); // SosScreen.h
void sosSheet(); // the slider that starts one
void sosOpen(); // the running one's view
void sosButton(); // Cancel / Hide, or a physical button
void sosRow(int row);
void sosSet(int what, int val);
void sosChannel(int idx, bool on);
void sosContact(int idx);
void sosTextDone(bool ok);
void botPick(int idx);
void botHour(uint8_t act);
void botTextDone(bool ok);
@@ -403,7 +413,7 @@ public:
void statusBarSync(); // up or hidden, by the screen's title bar (UITask.cpp)
private:
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS, SCR_BATT, SCR_ROUTE, SCR_ROUTE_PICK, SCR_GALLERY };
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS, SCR_BATT, SCR_ROUTE, SCR_ROUTE_PICK, SCR_GALLERY, SCR_SOS };
void buildStatusBar();
void refreshStatusBar();
@@ -540,6 +550,14 @@ private:
void lockScreen();
void refreshLock();
void lockPoll();
bool sosViewOpen() const;
void sosViewClose();
void sosLive();
void sosRefresh();
void sosButtonHeld();
void sosPoll();
void sosTick();
void buildSos();
void favGrid(lv_obj_t* grid);
void favRefresh();
int unreadTotal(); // DMs, channels and rooms
@@ -571,6 +589,7 @@ private:
uint32_t _next_wake_poll_ms = 0;
uint32_t _next_touch_poll_ms = 0; // tap to wake, while the screen is off
bool _wake_down = false;
uint32_t _wake_since_ms = 0; // when the top button went down
uint32_t autoOffMillis() const;
uint32_t idleMillis(uint32_t lv_next);
bool cpuNeeded();