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 | | **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 | | **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 | | **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 | | **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 **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, locked device, and needs no PIN. The device beeps every second and counts down,
even with the buzzer off. **Any key** cancels. 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. 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 Without a fix `{loc}` 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 An older one could be far from where you are, so then it says `no GPS`. Either
you are, so then it says `SOS no GPS`. Either way one more goes as soon as the way one more goes as soon as the GPS gets a fix.
GPS gets a fix. The **Note** comes last. The format isn't final, see
[below](#the-message-is-open-for-discussion). 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. **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** 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 message is open for discussion
The text above is a first version, and a change is easy to make while SOS is SOS is experimental, so the format can still change. For receivers only the
experimental. Only its start is fixed for receivers: `SOS` followed by a start is fixed: `[SOS]`, and the first position after it. That's what the map
position, `SOS last` followed by a position, or `SOS no GPS`. That's what the and the banner read; everything else is for people to read and is yours to set.
map and the banner read, so a change there needs both sides updated. Everything
after it is for people to read.
Open questions, for [issue #56](https://github.com/MarekZegare4/MeshCore-Solo/issues/56): 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 - **Digits:** the position has 5 decimals, about 1 metre. 4 decimals, about
11 metres, saves 2 bytes. 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 ### 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); } 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 // Tag starting an SOS: "[SOS] <the sender's own text, built from placeholders>"
// ui-core/SosEngine.h): `text` without the sender's name in front. Only at the // (ui-core/SosEngine.h). Only at the very start of `text` (without the
// very start, so a message that mentions SOS isn't taken for one. // sender's name in front), so a message that mentions one isn't taken for it.
static inline bool parseSos(const char* text, int32_t& lat_1e6, int32_t& lon_1e6) { #define SOS_MSG_TAG "[SOS]"
if (!text || strncmp(text, "SOS ", 4) != 0) return false;
const char* p = text + 4; static inline bool isSos(const char* text) {
if (strncmp(p, "last ", 5) == 0) p += 5; return text && strncmp(text, SOS_MSG_TAG, strlen(SOS_MSG_TAG)) == 0;
if (*p != '-' && !isdigit((unsigned char)*p)) return false;
return parseLatLon(p, lat_1e6, lon_1e6);
} }
// The same without a position to give: the one a receiver mustn't miss either. // An SOS with a position: its first coordinate (the sender's {loc}, which goes
static inline bool isSosNoGps(const char* text) { // first unless the text puts it elsewhere).
return text && strncmp(text, "SOS no GPS", 10) == 0 && (text[10] == '\0' || text[10] == ' '); 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) // 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; int32_t lat, lon;
if (!_listener) return; if (!_listener) return;
if (geo::parseSos(body, lat, lon)) _listener->onSosPosition(pub_key, name, lat, lon, verified); 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, 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_countdown_idx = 1; // 10 s
_prefs.sos_repeat_idx = 3; // every 5 minutes _prefs.sos_repeat_idx = 3; // every 5 minutes
strcpy(_prefs.sos_ack, "ACK"); strcpy(_prefs.sos_ack, "ACK");
strcpy(_prefs.sos_msg, "{loc} {alt} {time}");
_prefs.dm_show_all = 1; // show all contacts by default _prefs.dm_show_all = 1; // show all contacts by default
_prefs.fav_sort = 1; // favourites first in lists _prefs.fav_sort = 1; // favourites first in lists
_prefs.msg_wake = 1; // a new message turns the screen on _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 // To check if there is pending work
bool MyMesh::hasPendingWork() const { bool MyMesh::hasPendingWork() const {
return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0; return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0;
+3
View File
@@ -227,6 +227,9 @@ public:
// (manual, flood, auto, phone-app and contact export). // (manual, flood, auto, phone-app and contact export).
mesh::Packet* createOwnAdvert(); mesh::Packet* createOwnAdvert();
bool advertFlood(); // flooded (default scope), as the app's "Advert" with flood 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 sendNodeDiscoverReq();
void enterCLIRescue(); void enterCLIRescue();
+2 -2
View File
@@ -351,7 +351,7 @@ struct NodePrefsFields {
uint64_t sos_ch_mask; uint64_t sos_ch_mask;
uint8_t sos_dm[SOS_DM_MAX][6]; uint8_t sos_dm[SOS_DM_MAX][6];
char sos_ack[16]; // the word a reply must hold; "" = none is recognised 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 // 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). // 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; 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) { 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.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.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) { void gpioSolo(Section& s) {
s.def("m1", gpio1_mode); s.def("m2", gpio2_mode); s.def("m3", gpio3_mode); s.def("m4", gpio4_mode); 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 <strings.h>
#include <helpers/UTF8Helpers.h> #include <helpers/UTF8Helpers.h>
#include "../NodePrefs.h" #include "../NodePrefs.h"
#include "../GeoUtils.h" // SOS_MSG_TAG
class SosEngine { class SosEngine {
public: public:
@@ -160,22 +161,60 @@ public:
} }
enum Where : uint8_t { FIX, LAST_KNOWN, NOWHERE }; 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 the message text may hold (NodePrefs::sos_msg), offered by the editors.
// what a receiver's Navigate and map read (geo::parseSos). alt: nullptr without static const int PLACEHOLDER_COUNT = 4;
// one, age_min: how old a LAST_KNOWN is, -1 not given, time: "" when the clock static const char* placeholder(int i) {
// isn't set. static const char* const PH[PLACEHOLDER_COUNT] = { "{loc}", "{alt}", "{time}", "{batt}" };
static void compose(char* out, int n, Where where, int32_t lat, int32_t lon, return PH[i];
const int16_t* alt, int age_min, const char* time, const char* note) { }
// "[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; int o = 0;
// snprintf answers with what it would have written: never past the buffer. auto put = [&](const char* s, int len) {
auto left = [&](int wrote) { o = o + wrote < n ? o + wrote : n - 1; return n - o; }; while (len-- > 0 && o < n - 1) out[o++] = *s++;
left(snprintf(out, n, "SOS")); };
if (where == NOWHERE) left(snprintf(out + o, n - o, " no GPS")); put(SOS_MSG_TAG, (int)strlen(SOS_MSG_TAG));
else left(snprintf(out + o, n - o, "%s %.5f,%.5f", where == LAST_KNOWN ? " last" : "", lat / 1e6, lon / 1e6)); put(" ", 1);
if (alt && where == FIX) left(snprintf(out + o, n - o, " ALT %dm", (int)*alt)); if (!strstr(tmpl, "{loc}")) { put(loc, (int)strlen(loc)); put(" ", 1); }
if (where == LAST_KNOWN && age_min >= 0) left(snprintf(out + o, n - o, " %dmin ago", age_min)); for (const char* p = tmpl; *p; ) {
if (time && time[0]) left(snprintf(out + o, n - o, " sent %s", time)); const char* val = nullptr;
if (note && note[0]) snprintf(out + o, n - o, " %s", note); 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 // 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; struct tm ti;
if (localTm(now, tzHoursAt(_prefs, now), ti)) snprintf(hhmm, sizeof(hhmm), "%02d:%02d", ti.tm_hour, ti.tm_min); 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]; char text[MAX_TEXT_LEN + 1];
SosEngine::compose(text, sizeof(text), where, lat, lon, has_alt ? &alt : nullptr, recent ? (int)(age / 60) : -1, SosEngine::compose(text, sizeof(text), _prefs->sos_msg, where, lat, lon, has_alt ? &alt : nullptr,
hhmm, _prefs->sos_note); 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; bool ok = false;
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) 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 // 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. // the frontend can rest or raise. May touch the status bar.
virtual void onSosRunning(bool running) {} 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 void botBuzz(int seconds) {}
virtual bool botSetGPIO(int idx, bool on) { return false; } virtual bool botSetGPIO(int idx, bool on) { return false; }
virtual bool botGetGPIO(int idx, bool& is_output, bool& value) { 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_add_event_cb(b, onBotPlaceholder, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(b, ph, THEME_FONT_SMALL, theme::TEXT)); 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 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) { 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"; te.hint = botcfg::isTrigger(r.kind) ? "e.g. !hi, or * for any message" : "Reply text";
lv_obj_t* panel = navTextEntry(te); lv_obj_t* panel = navTextEntry(te);
if (reply) { if (reply) {
// Placeholders the bot fills in, as buttons in place of the title (no lv_obj_t* chips = placeholderChips(panel, _nav_ta); // the ones the bot fills in
// 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;
for (int i = 0; i < botcfg::REPLY_PLACEHOLDER_COUNT; i++) botChip(botcfg::REPLY_PLACEHOLDERS[i], chips); 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) { msgtext::sensorPlaceholders(&sensors, board.getMCUTemperature(), [](const char* ph, void* c) {
if (strcmp(ph, "{batt}") != 0) botChip(ph, c); // already there 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_left = 0; // the countdown second last beeped
static int s_edit = -1; // the row a popup is editing 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 }; 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* 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 // The battery goes to the SOS: WiFi rests until it ends. Its switch is saved, so
// a restart mid-SOS leaves it off. // 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) { void UITask::onSosRunning(bool running) {
using namespace sosview; using namespace sosview;
if (running) { if (running) {
@@ -300,7 +304,7 @@ void UITask::buildSos() {
snprintf(name, sizeof(name), "Contact %d", i + 1); snprintf(name, sizeof(name), "Contact %d", i + 1);
sosValueRow(g, name, nullptr, c ? c->name : "-", R_DM1 + i); 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 g = group(list, "WHILE IT RUNS"); // until someone answers, and after
seg = segmented(settingRow(g, "Repeat", nullptr), REPEATS, p->sos_repeat_idx, 200, 32); 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; NodePrefs* p = _prefs;
if (!p) return; if (!p) return;
s_edit = row; s_edit = row;
if (row == R_ACK || row == R_NOTE) { if (row == R_ACK || row == R_MSG) {
TextEntry te = { row == R_ACK ? "ACK word" : "Note", onSosTextKb }; TextEntry te = { row == R_ACK ? "ACK word" : "Message", onSosTextKb };
te.text = row == R_ACK ? p->sos_ack : p->sos_note; te.text = row == R_ACK ? p->sos_ack : p->sos_msg;
te.max_bytes = (row == R_ACK ? sizeof(p->sos_ack) : sizeof(p->sos_note)) - 1; te.max_bytes = (row == R_ACK ? sizeof(p->sos_ack) : sizeof(p->sos_msg)) - 1;
te.hint = row == R_ACK ? "e.g. ACK" : "e.g. blood group"; te.hint = row == R_ACK ? "e.g. ACK" : "{loc} {alt} {time} blood group 0+";
navTextEntry(te); 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; return;
} }
lv_obj_t* list = scrollList(navPopupPanel(row == R_CHANNELS ? "SOS channels" : "SOS contact", true)); 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) { void UITask::sosTextDone(bool ok) {
using namespace sosview; 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)); 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(); prefsSave();
} }
navClosePopup(); navClosePopup();
@@ -73,6 +73,7 @@ public:
void botSetGPS(bool on) override; void botSetGPS(bool on) override;
void applySoundPrefs() override; void applySoundPrefs() override;
void onSosRunning(bool running) override; void onSosRunning(bool running) override;
int battPercent() override;
bool cliCommand(const char* command, char* reply, int n) override; bool cliCommand(const char* command, char* reply, int n) override;
// ── Navigation (called from LVGL event callbacks) ──────────────────────── // ── 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; int _ch_sel = 0, _ch_scroll = 0, _ch_count = 0;
uint8_t _ch_idx[MAX_GROUP_CHANNELS]; 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; }; struct Row { Kind kind; const char* label; };
static const int ROW_COUNT = 11; static const int ROW_COUNT = 11;
static Row rows(int i) { static Row rows(int i) {
@@ -36,7 +36,7 @@ class SosScreen : public UIScreen {
{ K_DM2, "Contact 2" }, { K_DM2, "Contact 2" },
{ K_DM3, "Contact 3" }, { K_DM3, "Contact 3" },
{ K_ACK, "ACK word" }, { K_ACK, "ACK word" },
{ K_NOTE, "Note" }, { K_MSG, "Message" },
{ K_START, "Send SOS" }, { K_START, "Send SOS" },
}; };
return R[i]; return R[i];
@@ -72,7 +72,7 @@ class SosScreen : public UIScreen {
break; break;
} }
case K_ACK: snprintf(buf, n, "%s", _prefs->sos_ack[0] ? _prefs->sos_ack : "-"); 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; 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); } if (enter) { _picking = true; _task->pickSosContact(k - K_DM1); }
else { memset(_prefs->sos_dm[k - K_DM1], 0, NodePrefs::FAVOURITE_PREFIX_LEN); _dirty = true; } else { memset(_prefs->sos_dm[k - K_DM1], 0, NodePrefs::FAVOURITE_PREFIX_LEN); _dirty = true; }
break; break;
case K_ACK: case K_NOTE: case K_ACK: case K_MSG:
if (enter) { if (enter) {
bool ack = k == K_ACK; bool ack = k == K_ACK;
_kb_row = _sel; _kb_row = _sel;
_kb->begin(ack ? _prefs->sos_ack : _prefs->sos_note, (ack ? sizeof(_prefs->sos_ack) : sizeof(_prefs->sos_note)) - 1); _kb->begin(ack ? _prefs->sos_ack : _prefs->sos_msg, (ack ? sizeof(_prefs->sos_ack) : sizeof(_prefs->sos_msg)) - 1);
_kb->prompt = ack ? "ACK word" : "Note"; _kb->prompt = ack ? "ACK word" : "Message";
_kb->clearPlaceholders(); // sent as it is _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; break;
case K_START: case K_START:
@@ -190,8 +191,8 @@ public:
auto res = _kb->handleInput(c); auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) { if (res == KeyboardWidget::DONE) {
bool ack = rows(_kb_row).kind == K_ACK; bool ack = rows(_kb_row).kind == K_ACK;
char* dst = ack ? _prefs->sos_ack : _prefs->sos_note; char* dst = ack ? _prefs->sos_ack : _prefs->sos_msg;
snprintf(dst, ack ? sizeof(_prefs->sos_ack) : sizeof(_prefs->sos_note), "%s", _kb->buf); snprintf(dst, ack ? sizeof(_prefs->sos_ack) : sizeof(_prefs->sos_msg), "%s", _kb->buf);
_dirty = true; _dirty = true;
_kb_row = -1; _kb_row = -1;
} else if (res == KeyboardWidget::CANCELLED) { } 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); 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) { void UITask::onSosRunning(bool running) {
#ifdef PIN_BUZZER #ifdef PIN_BUZZER
if (!_node_prefs) return; if (!_node_prefs) return;
+1
View File
@@ -569,6 +569,7 @@ public:
void applyDisplayPrefs() override { applyBrightness(); applyUiScale(); _next_refresh = 0; } void applyDisplayPrefs() override { applyBrightness(); applyUiScale(); _next_refresh = 0; }
void applySoundPrefs() override; void applySoundPrefs() override;
void onSosRunning(bool running) override; void onSosRunning(bool running) override;
int battPercent() override;
bool cliCommand(const char* command, char* reply, int n) override; bool cliCommand(const char* command, char* reply, int n) override;
void setBuzzerVolumeLevel(uint8_t level); void setBuzzerVolumeLevel(uint8_t level);
void applyTxPower(); void applyTxPower();
@@ -63,7 +63,7 @@ void fill(NodePrefs& p) {
text(p.bot_reply_room, sizeof(p.bot_reply_room)); text(p.bot_reply_room, sizeof(p.bot_reply_room));
text(p.locator_label, sizeof(p.locator_label)); text(p.locator_label, sizeof(p.locator_label));
text(p.sos_ack, sizeof(p.sos_ack)); 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 (auto& m : p.custom_msgs) text(m, sizeof(m));
for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) { for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) {
auto& r = p.user_radio_presets[i]; auto& r = p.user_radio_presets[i];
+36 -23
View File
@@ -140,23 +140,35 @@ TEST(SosText, WordOnItsOwn) {
TEST(SosText, Compose) { TEST(SosText, Compose) {
char t[128]; char t[128];
int16_t alt = 212; int16_t alt = 212;
SosEngine::compose(t, sizeof(t), SosEngine::FIX, 50061430, 19936580, &alt, -1, "14:32", ""); const char* def = "{loc} {alt} {time}";
EXPECT_STREQ(t, "SOS 50.06143,19.93658 ALT 212m sent 14:32"); SosEngine::compose(t, sizeof(t), def, SosEngine::FIX, 50061430, 19936580, &alt, -1, "14:32", -1);
SosEngine::compose(t, sizeof(t), SosEngine::FIX, -33868800, 151209300, nullptr, -1, "", "broken leg"); EXPECT_STREQ(t, "[SOS] 50.06143,19.93658 212m 14:32");
EXPECT_STREQ(t, "SOS -33.86880,151.20930 broken leg"); SosEngine::compose(t, sizeof(t), def, SosEngine::FIX, -33868800, 151209300, nullptr, -1, "", -1);
SosEngine::compose(t, sizeof(t), SosEngine::LAST_KNOWN, 50061430, 19936580, &alt, 12, "14:32", ""); EXPECT_STREQ(t, "[SOS] -33.86880,151.20930"); // what has nothing to give takes its space
EXPECT_STREQ(t, "SOS last 50.06143,19.93658 12min ago sent 14:32"); SosEngine::compose(t, sizeof(t), def, SosEngine::LAST_KNOWN, 50061430, 19936580, &alt, 12, "14:32", -1);
SosEngine::compose(t, sizeof(t), SosEngine::LAST_KNOWN, 50061430, 19936580, nullptr, -1, "14:32", ""); EXPECT_STREQ(t, "[SOS] last 50.06143,19.93658 12min ago 14:32");
EXPECT_STREQ(t, "SOS last 50.06143,19.93658 sent 14:32"); SosEngine::compose(t, sizeof(t), def, SosEngine::LAST_KNOWN, 50061430, 19936580, nullptr, -1, "14:32", -1);
SosEngine::compose(t, sizeof(t), SosEngine::NOWHERE, 0, 0, nullptr, -1, "14:32", ""); EXPECT_STREQ(t, "[SOS] last 50.06143,19.93658 14:32");
EXPECT_STREQ(t, "SOS no GPS sent 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. // What a receiver's Navigate reads out of it, on a channel with the sender in front.
TEST(SosText, PositionCanBeReadBack) { TEST(SosText, PositionCanBeReadBack) {
char t[128], msg[160]; char t[128], msg[160];
int16_t alt = 212; 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); snprintf(msg, sizeof(msg), "Hiker: %s", t);
int32_t lat = 0, lon = 0; int32_t lat = 0, lon = 0;
ASSERT_TRUE(geo::parseLatLon(msg, lat, lon)); ASSERT_TRUE(geo::parseLatLon(msg, lat, lon));
@@ -166,14 +178,14 @@ TEST(SosText, PositionCanBeReadBack) {
TEST(SosText, OnlyAnSosWithAPositionIsTakenForOne) { TEST(SosText, OnlyAnSosWithAPositionIsTakenForOne) {
int32_t lat = 0, lon = 0; 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_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_EQ(lat, -33868800);
EXPECT_FALSE(geo::parseSos("SOS no GPS sent 14:32", lat, lon)); EXPECT_TRUE(geo::parseSos("[SOS] Help! 50.06143,19.93658 14:32", lat, lon));
EXPECT_FALSE(geo::parseSos("I got your SOS 50.06143,19.93658", lat, lon)); EXPECT_FALSE(geo::parseSos("[SOS] no GPS 14:32", lat, lon));
EXPECT_FALSE(geo::parseSos("SOS meet at 50.06143,19.93658", lat, lon)); EXPECT_FALSE(geo::parseSos("I got your [SOS] 50.06143,19.93658", lat, lon));
EXPECT_FALSE(geo::parseSos("SOSNA 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. // The [LOC] share every sender builds, and what the receiver reads back.
@@ -205,11 +217,12 @@ TEST(GeoText, TurnIsTheShortWayRound) {
TEST(SosText, NoGpsIsRecognisedOnItsOwn) { TEST(SosText, NoGpsIsRecognisedOnItsOwn) {
char t[64]; char t[64];
SosEngine::compose(t, sizeof(t), SosEngine::NOWHERE, 0, 0, nullptr, -1, "14:32", ""); int32_t lat = 0, lon = 0;
EXPECT_TRUE(geo::isSosNoGps(t)); SosEngine::compose(t, sizeof(t), "{loc} {time}", SosEngine::NOWHERE, 0, 0, nullptr, -1, "14:32", -1);
EXPECT_TRUE(geo::isSosNoGps("SOS no GPS")); EXPECT_TRUE(geo::isSos(t));
EXPECT_FALSE(geo::isSosNoGps("SOS no GPSfoo")); EXPECT_FALSE(geo::parseSos(t, lat, lon));
EXPECT_FALSE(geo::isSosNoGps("no SOS no GPS")); EXPECT_FALSE(geo::isSos("no [SOS]"));
EXPECT_FALSE(geo::isSos("SOS no GPS"));
} }
TEST(LiveTrack, SosOutlivesAShareAndIsEvictedLast) { TEST(LiveTrack, SosOutlivesAShareAndIsEvictedLast) {
@@ -230,7 +243,7 @@ TEST(LiveTrack, SosOutlivesAShareAndIsEvictedLast) {
TEST(SosText, ComposeNeverOverruns) { TEST(SosText, ComposeNeverOverruns) {
char t[12]; 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)); EXPECT_LT(strlen(t), sizeof(t));
} }