feat(sos): the message is "[SOS]" + an editable text with placeholders; each send also adverts the position zero-hop

Like [LOC] and [WAY], a tag at the start marks an SOS for receivers, whatever
the rest says. The Note row becomes Message (sos_msg, 72 bytes, key "msg"),
"{loc} {alt} {time}" by default: {loc} (fix, "last ... 12min ago" or "no GPS"),
{alt}, {time}, {batt} (percent, as the status bar shows it). Without {loc} the
position still goes, straight after the tag; an empty placeholder leaves no
gap. Receivers read the first coordinate after [SOS]; [SOS] alone is an SOS
without a position. The old "SOS ..." text was never released and is dropped.

Each round with a position also sends a zero-hop advert carrying it at full
precision, whatever advert privacy says, so anyone in range sees it on their
map in any app (issue #56). No flooded advert, no name prefix, no accuracy.

L1: the keyboard offers the placeholders. L2: chips above the keyboard, shared
with the bot's reply editor (placeholderChips).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-11-02 12:54:37 +01:00
co-authored by Claude Opus 5.5
parent 9bbaedccce
commit a0c55c4cdb
16 changed files with 204 additions and 111 deletions
+28 -22
View File
@@ -55,25 +55,42 @@ and without opening a menu. Set it up before you need it:
| **Channels** | Which channels it goes to; **Enter** switches each one |
| **Contact 1-3** | Up to three direct contacts. **Enter** picks one, **Left / Right** empties the row |
| **ACK word** | The word that means "received", `ACK` by default |
| **Note** | Added to the end of the message, for example your blood group |
| **Message** | What follows `[SOS]`, with placeholders, see below |
| **Send SOS** | Starts it from here, on devices without a Back button too |
**Starting:** hold **Back** for 3 seconds. It works with the screen off and on a
locked device, and needs no PIN. The device beeps every second and counts down,
even with the buzzer off. **Any key** cancels.
**The message** is short, to reach as far as possible:
**The message** starts with `[SOS]`, like the other special messages (`[LOC]`,
`[WAY]`), so a receiver knows it for an SOS whatever follows. What follows is
the **Message** row, `{loc} {alt} {time}` by default:
```
SOS 50.06143,19.93658 ALT 212m sent 14:32
[SOS] 50.06143,19.93658 212m 14:32
```
| Placeholder | Gives |
| ----------- | ----- |
| `{loc}` | The position, `last 50.06143,19.93658 12min ago`, or `no GPS` |
| `{alt}` | The altitude from the GPS, with a fix only |
| `{time}` | When it was sent, if the clock is set |
| `{batt}` | The battery, as a percentage: how long the device will last |
Anything else is sent as written, for example `{loc} {batt} blood group 0+`,
or in your own language. The position always goes: without `{loc}` it comes
straight after `[SOS]`. A placeholder with nothing to give leaves no gap.
There's no accuracy (`±3m`): with few satellites the GPS can't estimate it
well, and a wrong figure would mislead a search more than none.
The GPS is turned on for it, if it was off, and back off when the SOS ends.
Without a fix it gives the last position from the past hour, with its age
(`SOS last 50.06143,19.93658 12min ago`). An older one could be far from where
you are, so then it says `SOS no GPS`. Either way one more goes as soon as the
GPS gets a fix. The **Note** comes last. The format isn't final, see
[below](#the-message-is-open-for-discussion).
Without a fix `{loc}` gives the last position from the past hour, with its age.
An older one could be far from where you are, so then it says `no GPS`. Either
way one more goes as soon as the GPS gets a fix.
With a position, each send also goes out as a zero-hop advert carrying it at full
precision, whatever **Privacy** says for adverts: anyone in direct range sees
you on their map in any MeshCore app, even if they aren't on your list.
**Receiving one:** a banner names the sender, also when there's no position.
A position shows on the map and in Nearby as `SOS name`, even with **Track loc**
@@ -119,25 +136,14 @@ this each time it wakes.
### The message is open for discussion
The text above is a first version, and a change is easy to make while SOS is
experimental. Only its start is fixed for receivers: `SOS` followed by a
position, `SOS last` followed by a position, or `SOS no GPS`. That's what the
map and the banner read, so a change there needs both sides updated. Everything
after it is for people to read.
SOS is experimental, so the format can still change. For receivers only the
start is fixed: `[SOS]`, and the first position after it. That's what the map
and the banner read; everything else is for people to read and is yours to set.
Open questions, for [issue #56](https://github.com/MarekZegare4/MeshCore-Solo/issues/56):
- **Accuracy:** the request asked for `±3m` after the position. It isn't sent
yet. The GPS reports HDOP, which is only a rough guide to metres, and not
every module gives it. Is an estimate worth the extra bytes?
- **Digits:** the position has 5 decimals, about 1 metre. 4 decimals, about
11 metres, saves 2 bytes.
- **Labels:** `ALT 212m sent 14:32` reads easily. Commas, or dropping `sent`,
would change little in length. Is the time useful at all, given that a
repeat can arrive late?
- **Anything missing:** the battery level would tell a rescuer how long the
device will last, at a few more bytes.
- **Own language:** the note can be anything, but the rest stays in English.
### On the Wio Tracker L2
+11 -12
View File
@@ -163,20 +163,19 @@ static inline void fmtLocShare(char* buf, int n, int32_t lat_1e6, int32_t lon_1e
if (name && name[0]) { size_t l = strlen(buf); snprintf(buf + l, n - l, " %s", name); }
}
// An SOS with a position ("SOS 50.06143,19.93658 ...", "SOS last ...", see
// ui-core/SosEngine.h): `text` without the sender's name in front. Only at the
// very start, so a message that mentions SOS isn't taken for one.
static inline bool parseSos(const char* text, int32_t& lat_1e6, int32_t& lon_1e6) {
if (!text || strncmp(text, "SOS ", 4) != 0) return false;
const char* p = text + 4;
if (strncmp(p, "last ", 5) == 0) p += 5;
if (*p != '-' && !isdigit((unsigned char)*p)) return false;
return parseLatLon(p, lat_1e6, lon_1e6);
// Tag starting an SOS: "[SOS] <the sender's own text, built from placeholders>"
// (ui-core/SosEngine.h). Only at the very start of `text` (without the
// sender's name in front), so a message that mentions one isn't taken for it.
#define SOS_MSG_TAG "[SOS]"
static inline bool isSos(const char* text) {
return text && strncmp(text, SOS_MSG_TAG, strlen(SOS_MSG_TAG)) == 0;
}
// The same without a position to give: the one a receiver mustn't miss either.
static inline bool isSosNoGps(const char* text) {
return text && strncmp(text, "SOS no GPS", 10) == 0 && (text[10] == '\0' || text[10] == ' ');
// An SOS with a position: its first coordinate (the sender's {loc}, which goes
// first unless the text puts it elsewhere).
static inline bool parseSos(const char* text, int32_t& lat_1e6, int32_t& lon_1e6) {
return isSos(text) && parseLatLon(text + strlen(SOS_MSG_TAG), lat_1e6, lon_1e6);
}
// True if `text` carries a LOCATION_MSG_TAG share; fills lat/lon (1e6-scaled)
+9 -1
View File
@@ -684,7 +684,7 @@ void MyMesh::noteSos(const uint8_t* pub_key, const char* name, const char* body,
int32_t lat, lon;
if (!_listener) return;
if (geo::parseSos(body, lat, lon)) _listener->onSosPosition(pub_key, name, lat, lon, verified);
else if (geo::isSosNoGps(body)) _listener->onSosNoPosition(name);
else if (geo::isSos(body)) _listener->onSosNoPosition(name);
}
void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
@@ -1488,6 +1488,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_prefs.sos_countdown_idx = 1; // 10 s
_prefs.sos_repeat_idx = 3; // every 5 minutes
strcpy(_prefs.sos_ack, "ACK");
strcpy(_prefs.sos_msg, "{loc} {alt} {time}");
_prefs.dm_show_all = 1; // show all contacts by default
_prefs.fav_sort = 1; // favourites first in lists
_prefs.msg_wake = 1; // a new message turns the screen on
@@ -3103,6 +3104,13 @@ bool MyMesh::advertFlood() {
}
}
bool MyMesh::advertSos(int32_t lat_1e6, int32_t lon_1e6) {
mesh::Packet* pkt = createSelfAdvert(_prefs.node_name, lat_1e6 / 1e6, lon_1e6 / 1e6);
if (!pkt) return false;
sendZeroHop(pkt);
return true;
}
// To check if there is pending work
bool MyMesh::hasPendingWork() const {
return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0;
+3
View File
@@ -227,6 +227,9 @@ public:
// (manual, flood, auto, phone-app and contact export).
mesh::Packet* createOwnAdvert();
bool advertFlood(); // flooded (default scope), as the app's "Advert" with flood
// Zero-hop, with this position at full precision whatever advert_loc_policy
// says: starting an SOS is the consent to give it.
bool advertSos(int32_t lat_1e6, int32_t lon_1e6);
void sendNodeDiscoverReq();
void enterCLIRescue();
+2 -2
View File
@@ -351,7 +351,7 @@ struct NodePrefsFields {
uint64_t sos_ch_mask;
uint8_t sos_dm[SOS_DM_MAX][6];
char sos_ack[16]; // the word a reply must hold; "" = none is recognised
char sos_note[48]; // added to the end of the message
char sos_msg[72]; // after the [SOS] tag, with SosEngine::compose()'s placeholders
// ui-new's new-message alert: the banner from the top, an envelope with a
// count in the corner, or nothing (the sound and the wake still happen).
static const uint8_t MSG_ALERT_NORMAL = 0, MSG_ALERT_COMPACT = 1, MSG_ALERT_OFF = 2;
@@ -954,7 +954,7 @@ class NodePrefs : public NodePrefsFields, public ConfigSerializer {
void sosSolo(Section& s) {
s.def("en", sos_enabled); s.def("count", sos_countdown_idx); s.def("repeat", sos_repeat_idx); s.def("beep", sos_beep_idx);
s.hex("ch", sos_ch_mask); s.hex("dm", sos_dm);
s.def("ack", sos_ack, sizeof(sos_ack)); s.def("note", sos_note, sizeof(sos_note));
s.def("ack", sos_ack, sizeof(sos_ack)); s.def("msg", sos_msg, sizeof(sos_msg));
}
void gpioSolo(Section& s) {
s.def("m1", gpio1_mode); s.def("m2", gpio2_mode); s.def("m3", gpio3_mode); s.def("m4", gpio4_mode);
+54 -15
View File
@@ -13,6 +13,7 @@
#include <strings.h>
#include <helpers/UTF8Helpers.h>
#include "../NodePrefs.h"
#include "../GeoUtils.h" // SOS_MSG_TAG
class SosEngine {
public:
@@ -160,22 +161,60 @@ public:
}
enum Where : uint8_t { FIX, LAST_KNOWN, NOWHERE };
// "SOS 50.06143,19.93658 ALT 212m sent 14:32 note": short, and the position is
// what a receiver's Navigate and map read (geo::parseSos). alt: nullptr without
// one, age_min: how old a LAST_KNOWN is, -1 not given, time: "" when the clock
// isn't set.
static void compose(char* out, int n, Where where, int32_t lat, int32_t lon,
const int16_t* alt, int age_min, const char* time, const char* note) {
// What the message text may hold (NodePrefs::sos_msg), offered by the editors.
static const int PLACEHOLDER_COUNT = 4;
static const char* placeholder(int i) {
static const char* const PH[PLACEHOLDER_COUNT] = { "{loc}", "{alt}", "{time}", "{batt}" };
return PH[i];
}
// "[SOS] 50.06143,19.93658 212m 14:32": the tag (geo::SOS_MSG_TAG), then
// `tmpl` with its placeholders filled. The position goes first when `tmpl`
// has no {loc}: an SOS never goes without it, and it's what a receiver's
// Navigate and map read (geo::parseSos).
// {loc} "50.06143,19.93658", "last 50.06143,19.93658 12min ago", "no GPS"
// {alt} "212m", with a fix only {time} "14:32" {batt} "18%"
// One that has nothing to give takes a space with it. alt: nullptr without
// one, age_min: how old a LAST_KNOWN is, -1 not given, time: "" when the
// clock isn't set, batt_pct: -1 unknown. An unknown {x} stays as written.
static void compose(char* out, int n, const char* tmpl, Where where, int32_t lat, int32_t lon,
const int16_t* alt, int age_min, const char* time, int batt_pct) {
if (n <= 0) return;
char loc[48];
if (where == NOWHERE) snprintf(loc, sizeof(loc), "no GPS");
else {
int l = snprintf(loc, sizeof(loc), "%s%.5f,%.5f", where == LAST_KNOWN ? "last " : "", lat / 1e6, lon / 1e6);
if (where == LAST_KNOWN && age_min >= 0) snprintf(loc + l, sizeof(loc) - l, " %dmin ago", age_min);
}
char alt_s[8] = "", batt_s[8] = "";
if (alt && where == FIX) snprintf(alt_s, sizeof(alt_s), "%dm", (int)*alt);
if (batt_pct >= 0) snprintf(batt_s, sizeof(batt_s), "%d%%", batt_pct);
if (!time) time = "";
if (!tmpl) tmpl = "";
int o = 0;
// snprintf answers with what it would have written: never past the buffer.
auto left = [&](int wrote) { o = o + wrote < n ? o + wrote : n - 1; return n - o; };
left(snprintf(out, n, "SOS"));
if (where == NOWHERE) left(snprintf(out + o, n - o, " no GPS"));
else left(snprintf(out + o, n - o, "%s %.5f,%.5f", where == LAST_KNOWN ? " last" : "", lat / 1e6, lon / 1e6));
if (alt && where == FIX) left(snprintf(out + o, n - o, " ALT %dm", (int)*alt));
if (where == LAST_KNOWN && age_min >= 0) left(snprintf(out + o, n - o, " %dmin ago", age_min));
if (time && time[0]) left(snprintf(out + o, n - o, " sent %s", time));
if (note && note[0]) snprintf(out + o, n - o, " %s", note);
auto put = [&](const char* s, int len) {
while (len-- > 0 && o < n - 1) out[o++] = *s++;
};
put(SOS_MSG_TAG, (int)strlen(SOS_MSG_TAG));
put(" ", 1);
if (!strstr(tmpl, "{loc}")) { put(loc, (int)strlen(loc)); put(" ", 1); }
for (const char* p = tmpl; *p; ) {
const char* val = nullptr;
int skip = 0;
if (*p == '{') {
if (!strncmp(p, "{loc}", 5)) { val = loc; skip = 5; }
else if (!strncmp(p, "{alt}", 5)) { val = alt_s; skip = 5; }
else if (!strncmp(p, "{time}", 6)) { val = time; skip = 6; }
else if (!strncmp(p, "{batt}", 6)) { val = batt_s; skip = 6; }
}
if (!val) { put(p++, 1); continue; }
p += skip;
if (val[0]) put(val, (int)strlen(val));
else while (*p == ' ') p++;
}
while (o > 0 && out[o - 1] == ' ') o--;
out[o] = '\0';
}
// Anywhere to send to. A channel or a contact that has gone clears its own
+5 -2
View File
@@ -449,8 +449,11 @@ private:
struct tm ti;
if (localTm(now, tzHoursAt(_prefs, now), ti)) snprintf(hhmm, sizeof(hhmm), "%02d:%02d", ti.tm_hour, ti.tm_min);
char text[MAX_TEXT_LEN + 1];
SosEngine::compose(text, sizeof(text), where, lat, lon, has_alt ? &alt : nullptr, recent ? (int)(age / 60) : -1,
hhmm, _prefs->sos_note);
SosEngine::compose(text, sizeof(text), _prefs->sos_msg, where, lat, lon, has_alt ? &alt : nullptr,
recent ? (int)(age / 60) : -1, hhmm, _host->battPercent());
// Anyone in range sees it on their map too, on any app, not only the
// targets: an advert, zero-hop so the mesh isn't flooded every round.
if (where != SosEngine::NOWHERE) the_mesh.advertSos(lat, lon);
bool ok = false;
for (int i = 0; i < MAX_GROUP_CHANNELS; i++)
@@ -33,6 +33,8 @@ public:
// An SOS began or ended (the UI's own startSos() / stopSos() calls): what only
// the frontend can rest or raise. May touch the status bar.
virtual void onSosRunning(bool running) {}
// The battery as the status bar shows it, for an SOS's {batt}; -1 unknown.
virtual int battPercent() { return -1; }
virtual void botBuzz(int seconds) {}
virtual bool botSetGPIO(int idx, bool on) { return false; }
virtual bool botGetGPIO(int idx, bool& is_output, bool& value) { return false; }
+19 -14
View File
@@ -58,6 +58,24 @@ static void botChip(const char* ph, void* parent) {
lv_obj_add_event_cb(b, onBotPlaceholder, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(b, ph, THEME_FONT_SMALL, theme::TEXT));
}
// Placeholder buttons in place of a text entry's title, typing into `ta` (no
// room for another row above the keyboard; the field says what it is).
// botChip() them onto what this returns.
static lv_obj_t* placeholderChips(lv_obj_t* panel, lv_obj_t* ta) {
lv_obj_t* hdr = lv_obj_get_child(panel, 0);
lv_obj_add_flag(lv_obj_get_child(hdr, 0), LV_OBJ_FLAG_HIDDEN);
lv_obj_t* chips = lv_obj_create(hdr);
lv_obj_remove_style_all(chips);
lv_obj_set_size(chips, 240, 28);
lv_obj_align(chips, LV_ALIGN_LEFT_MID, 0, 0);
lv_obj_set_flex_flow(chips, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(chips, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_gap(chips, 4, 0);
lv_obj_set_scroll_dir(chips, LV_DIR_HOR); // swipe for the rest
lv_obj_set_scrollbar_mode(chips, LV_SCROLLBAR_MODE_OFF);
s_chip_ta = ta;
return chips;
}
static void onOpenBot(lv_event_t* e) { (void)e; s_ui->showBot(); }
static lv_obj_t* botHourButton(lv_obj_t* parent, const char* text, uint8_t act, bool accent) {
@@ -224,20 +242,7 @@ void UITask::botRow(int row) {
te.hint = botcfg::isTrigger(r.kind) ? "e.g. !hi, or * for any message" : "Reply text";
lv_obj_t* panel = navTextEntry(te);
if (reply) {
// Placeholders the bot fills in, as buttons in place of the title (no
// room for another row above the keyboard; the field says "Reply").
lv_obj_t* hdr = lv_obj_get_child(panel, 0);
lv_obj_add_flag(lv_obj_get_child(hdr, 0), LV_OBJ_FLAG_HIDDEN);
lv_obj_t* chips = lv_obj_create(hdr);
lv_obj_remove_style_all(chips);
lv_obj_set_size(chips, 240, 28);
lv_obj_align(chips, LV_ALIGN_LEFT_MID, 0, 0);
lv_obj_set_flex_flow(chips, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(chips, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_gap(chips, 4, 0);
lv_obj_set_scroll_dir(chips, LV_DIR_HOR); // swipe for the rest
lv_obj_set_scrollbar_mode(chips, LV_SCROLLBAR_MODE_OFF);
s_chip_ta = _nav_ta;
lv_obj_t* chips = placeholderChips(panel, _nav_ta); // the ones the bot fills in
for (int i = 0; i < botcfg::REPLY_PLACEHOLDER_COUNT; i++) botChip(botcfg::REPLY_PLACEHOLDERS[i], chips);
msgtext::sensorPlaceholders(&sensors, board.getMCUTemperature(), [](const char* ph, void* c) {
if (strcmp(ph, "{batt}") != 0) botChip(ph, c); // already there
+18 -10
View File
@@ -30,7 +30,7 @@ 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 Row : uint8_t { R_CHANNELS, R_DM1, R_DM2, R_DM3, R_ACK, R_MSG };
enum Pref : uint8_t { P_COUNTDOWN, P_REPEAT, P_BEEP };
static const char* COUNTDOWNS[NodePrefs::SOS_COUNTDOWN_COUNT + 1] = { "5s", "10s", "15s", "" };
@@ -253,6 +253,10 @@ void UITask::sosTick() {
// The battery goes to the SOS: WiFi rests until it ends. Its switch is saved, so
// a restart mid-SOS leaves it off.
int UITask::battPercent() {
return _batt_mv ? battery::percent(_batt_mv, _prefs ? (int)_prefs->low_batt_mv : 0) : -1;
}
void UITask::onSosRunning(bool running) {
using namespace sosview;
if (running) {
@@ -300,7 +304,7 @@ void UITask::buildSos() {
snprintf(name, sizeof(name), "Contact %d", i + 1);
sosValueRow(g, name, nullptr, c ? c->name : "-", R_DM1 + i);
}
sosValueRow(g, "Note", "Added to the message", p->sos_note[0] ? p->sos_note : "-", R_NOTE);
sosValueRow(g, "Message", "After [SOS]; the position always goes", p->sos_msg[0] ? p->sos_msg : "-", R_MSG);
g = group(list, "WHILE IT RUNS"); // until someone answers, and after
seg = segmented(settingRow(g, "Repeat", nullptr), REPEATS, p->sos_repeat_idx, 200, 32);
@@ -337,12 +341,16 @@ void UITask::sosRow(int row) {
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);
if (row == R_ACK || row == R_MSG) {
TextEntry te = { row == R_ACK ? "ACK word" : "Message", onSosTextKb };
te.text = row == R_ACK ? p->sos_ack : p->sos_msg;
te.max_bytes = (row == R_ACK ? sizeof(p->sos_ack) : sizeof(p->sos_msg)) - 1;
te.hint = row == R_ACK ? "e.g. ACK" : "{loc} {alt} {time} blood group 0+";
lv_obj_t* panel = navTextEntry(te);
if (row == R_MSG) {
lv_obj_t* chips = placeholderChips(panel, _nav_ta);
for (int i = 0; i < SosEngine::PLACEHOLDER_COUNT; i++) botChip(SosEngine::placeholder(i), chips);
}
return;
}
lv_obj_t* list = scrollList(navPopupPanel(row == R_CHANNELS ? "SOS channels" : "SOS contact", true));
@@ -375,9 +383,9 @@ void UITask::sosContact(int idx) {
void UITask::sosTextDone(bool ok) {
using namespace sosview;
if (ok && _nav_ta && _prefs && (s_edit == R_ACK || s_edit == R_NOTE)) {
if (ok && _nav_ta && _prefs && (s_edit == R_ACK || s_edit == R_MSG)) {
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));
else snprintf(_prefs->sos_msg, sizeof(_prefs->sos_msg), "%s", lv_textarea_get_text(_nav_ta));
prefsSave();
}
navClosePopup();
@@ -73,6 +73,7 @@ public:
void botSetGPS(bool on) override;
void applySoundPrefs() override;
void onSosRunning(bool running) override;
int battPercent() override;
bool cliCommand(const char* command, char* reply, int n) override;
// ── Navigation (called from LVGL event callbacks) ────────────────────────
+10 -9
View File
@@ -22,7 +22,7 @@ class SosScreen : public UIScreen {
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 };
enum Kind : uint8_t { K_HOLD, K_COUNTDOWN, K_REPEAT, K_BEEP, K_CHANNELS, K_DM1, K_DM2, K_DM3, K_ACK, K_MSG, K_START };
struct Row { Kind kind; const char* label; };
static const int ROW_COUNT = 11;
static Row rows(int i) {
@@ -36,7 +36,7 @@ class SosScreen : public UIScreen {
{ K_DM2, "Contact 2" },
{ K_DM3, "Contact 3" },
{ K_ACK, "ACK word" },
{ K_NOTE, "Note" },
{ K_MSG, "Message" },
{ K_START, "Send SOS" },
};
return R[i];
@@ -72,7 +72,7 @@ class SosScreen : public UIScreen {
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_MSG: snprintf(buf, n, "%s", _prefs->sos_msg[0] ? _prefs->sos_msg : "-"); break;
case K_START: snprintf(buf, n, "%s", _task->core().sos.idle() ? "" : "Running"); break;
}
}
@@ -141,13 +141,14 @@ class SosScreen : public UIScreen {
if (enter) { _picking = true; _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:
case K_ACK: case K_MSG:
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
_kb->begin(ack ? _prefs->sos_ack : _prefs->sos_msg, (ack ? sizeof(_prefs->sos_ack) : sizeof(_prefs->sos_msg)) - 1);
_kb->prompt = ack ? "ACK word" : "Message";
_kb->clearPlaceholders(); // the ACK word is matched as it is
if (!ack) for (int i = 0; i < SosEngine::PLACEHOLDER_COUNT; i++) _kb->addPlaceholder(SosEngine::placeholder(i));
}
break;
case K_START:
@@ -190,8 +191,8 @@ public:
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);
char* dst = ack ? _prefs->sos_ack : _prefs->sos_msg;
snprintf(dst, ack ? sizeof(_prefs->sos_ack) : sizeof(_prefs->sos_msg), "%s", _kb->buf);
_dirty = true;
_kb_row = -1;
} else if (res == KeyboardWidget::CANCELLED) {
@@ -375,6 +375,10 @@ uint32_t UITask::autoOffMillis() const {
return _core->sos.screenMs(_node_prefs ? (uint32_t)_node_prefs->auto_off_secs * 1000UL : 0);
}
int UITask::battPercent() {
return _batt_mv ? battery::percent(_batt_mv, _node_prefs ? (int)_node_prefs->low_batt_mv : 0) : -1;
}
void UITask::onSosRunning(bool running) {
#ifdef PIN_BUZZER
if (!_node_prefs) return;
+1
View File
@@ -569,6 +569,7 @@ public:
void applyDisplayPrefs() override { applyBrightness(); applyUiScale(); _next_refresh = 0; }
void applySoundPrefs() override;
void onSosRunning(bool running) override;
int battPercent() override;
bool cliCommand(const char* command, char* reply, int n) override;
void setBuzzerVolumeLevel(uint8_t level);
void applyTxPower();
@@ -63,7 +63,7 @@ void fill(NodePrefs& p) {
text(p.bot_reply_room, sizeof(p.bot_reply_room));
text(p.locator_label, sizeof(p.locator_label));
text(p.sos_ack, sizeof(p.sos_ack));
text(p.sos_note, sizeof(p.sos_note));
text(p.sos_msg, sizeof(p.sos_msg));
for (auto& m : p.custom_msgs) text(m, sizeof(m));
for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) {
auto& r = p.user_radio_presets[i];
+36 -23
View File
@@ -140,23 +140,35 @@ TEST(SosText, WordOnItsOwn) {
TEST(SosText, Compose) {
char t[128];
int16_t alt = 212;
SosEngine::compose(t, sizeof(t), SosEngine::FIX, 50061430, 19936580, &alt, -1, "14:32", "");
EXPECT_STREQ(t, "SOS 50.06143,19.93658 ALT 212m sent 14:32");
SosEngine::compose(t, sizeof(t), SosEngine::FIX, -33868800, 151209300, nullptr, -1, "", "broken leg");
EXPECT_STREQ(t, "SOS -33.86880,151.20930 broken leg");
SosEngine::compose(t, sizeof(t), SosEngine::LAST_KNOWN, 50061430, 19936580, &alt, 12, "14:32", "");
EXPECT_STREQ(t, "SOS last 50.06143,19.93658 12min ago sent 14:32");
SosEngine::compose(t, sizeof(t), SosEngine::LAST_KNOWN, 50061430, 19936580, nullptr, -1, "14:32", "");
EXPECT_STREQ(t, "SOS last 50.06143,19.93658 sent 14:32");
SosEngine::compose(t, sizeof(t), SosEngine::NOWHERE, 0, 0, nullptr, -1, "14:32", "");
EXPECT_STREQ(t, "SOS no GPS sent 14:32");
const char* def = "{loc} {alt} {time}";
SosEngine::compose(t, sizeof(t), def, SosEngine::FIX, 50061430, 19936580, &alt, -1, "14:32", -1);
EXPECT_STREQ(t, "[SOS] 50.06143,19.93658 212m 14:32");
SosEngine::compose(t, sizeof(t), def, SosEngine::FIX, -33868800, 151209300, nullptr, -1, "", -1);
EXPECT_STREQ(t, "[SOS] -33.86880,151.20930"); // what has nothing to give takes its space
SosEngine::compose(t, sizeof(t), def, SosEngine::LAST_KNOWN, 50061430, 19936580, &alt, 12, "14:32", -1);
EXPECT_STREQ(t, "[SOS] last 50.06143,19.93658 12min ago 14:32");
SosEngine::compose(t, sizeof(t), def, SosEngine::LAST_KNOWN, 50061430, 19936580, nullptr, -1, "14:32", -1);
EXPECT_STREQ(t, "[SOS] last 50.06143,19.93658 14:32");
SosEngine::compose(t, sizeof(t), def, SosEngine::NOWHERE, 0, 0, nullptr, -1, "14:32", -1);
EXPECT_STREQ(t, "[SOS] no GPS 14:32");
SosEngine::compose(t, sizeof(t), "Help! {loc}, batt {batt} {x}", SosEngine::FIX, 50061430, 19936580, &alt, -1, "", 18);
EXPECT_STREQ(t, "[SOS] Help! 50.06143,19.93658, batt 18% {x}");
}
// Taking {loc} out doesn't take the position out.
TEST(SosText, PositionAlwaysGoes) {
char t[128];
SosEngine::compose(t, sizeof(t), "broken leg", SosEngine::FIX, 50061430, 19936580, nullptr, -1, "", -1);
EXPECT_STREQ(t, "[SOS] 50.06143,19.93658 broken leg");
SosEngine::compose(t, sizeof(t), "", SosEngine::NOWHERE, 0, 0, nullptr, -1, "14:32", -1);
EXPECT_STREQ(t, "[SOS] no GPS");
}
// What a receiver's Navigate reads out of it, on a channel with the sender in front.
TEST(SosText, PositionCanBeReadBack) {
char t[128], msg[160];
int16_t alt = 212;
SosEngine::compose(t, sizeof(t), SosEngine::FIX, 50061430, -19936580, &alt, -1, "14:32", "");
SosEngine::compose(t, sizeof(t), "{loc} {alt} {time}", SosEngine::FIX, 50061430, -19936580, &alt, -1, "14:32", -1);
snprintf(msg, sizeof(msg), "Hiker: %s", t);
int32_t lat = 0, lon = 0;
ASSERT_TRUE(geo::parseLatLon(msg, lat, lon));
@@ -166,14 +178,14 @@ TEST(SosText, PositionCanBeReadBack) {
TEST(SosText, OnlyAnSosWithAPositionIsTakenForOne) {
int32_t lat = 0, lon = 0;
EXPECT_TRUE(geo::parseSos("SOS 50.06143,19.93658 ALT 212m", lat, lon));
EXPECT_TRUE(geo::parseSos("[SOS] 50.06143,19.93658 212m", lat, lon));
EXPECT_EQ(lat, 50061430);
EXPECT_TRUE(geo::parseSos("SOS last -33.86880,151.20930 12min ago", lat, lon));
EXPECT_TRUE(geo::parseSos("[SOS] last -33.86880,151.20930 12min ago", lat, lon));
EXPECT_EQ(lat, -33868800);
EXPECT_FALSE(geo::parseSos("SOS no GPS sent 14:32", lat, lon));
EXPECT_FALSE(geo::parseSos("I got your SOS 50.06143,19.93658", lat, lon));
EXPECT_FALSE(geo::parseSos("SOS meet at 50.06143,19.93658", lat, lon));
EXPECT_FALSE(geo::parseSos("SOSNA 50.06143,19.93658", lat, lon));
EXPECT_TRUE(geo::parseSos("[SOS] Help! 50.06143,19.93658 14:32", lat, lon));
EXPECT_FALSE(geo::parseSos("[SOS] no GPS 14:32", lat, lon));
EXPECT_FALSE(geo::parseSos("I got your [SOS] 50.06143,19.93658", lat, lon));
EXPECT_FALSE(geo::parseSos("SOS 50.06143,19.93658", lat, lon));
}
// The [LOC] share every sender builds, and what the receiver reads back.
@@ -205,11 +217,12 @@ TEST(GeoText, TurnIsTheShortWayRound) {
TEST(SosText, NoGpsIsRecognisedOnItsOwn) {
char t[64];
SosEngine::compose(t, sizeof(t), SosEngine::NOWHERE, 0, 0, nullptr, -1, "14:32", "");
EXPECT_TRUE(geo::isSosNoGps(t));
EXPECT_TRUE(geo::isSosNoGps("SOS no GPS"));
EXPECT_FALSE(geo::isSosNoGps("SOS no GPSfoo"));
EXPECT_FALSE(geo::isSosNoGps("no SOS no GPS"));
int32_t lat = 0, lon = 0;
SosEngine::compose(t, sizeof(t), "{loc} {time}", SosEngine::NOWHERE, 0, 0, nullptr, -1, "14:32", -1);
EXPECT_TRUE(geo::isSos(t));
EXPECT_FALSE(geo::parseSos(t, lat, lon));
EXPECT_FALSE(geo::isSos("no [SOS]"));
EXPECT_FALSE(geo::isSos("SOS no GPS"));
}
TEST(LiveTrack, SosOutlivesAShareAndIsEvictedLast) {
@@ -230,7 +243,7 @@ TEST(LiveTrack, SosOutlivesAShareAndIsEvictedLast) {
TEST(SosText, ComposeNeverOverruns) {
char t[12];
SosEngine::compose(t, sizeof(t), SosEngine::NOWHERE, 0, 0, nullptr, -1, "14:32", "a long note");
SosEngine::compose(t, sizeof(t), "a long note {time}", SosEngine::NOWHERE, 0, 0, nullptr, -1, "14:32", -1);
EXPECT_LT(strlen(t), sizeof(t));
}