diff --git a/docs/solo/tools.md b/docs/solo/tools.md index e4b533d8..da1f3394 100644 --- a/docs/solo/tools.md +++ b/docs/solo/tools.md @@ -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 diff --git a/examples/companion_radio/GeoUtils.h b/examples/companion_radio/GeoUtils.h index 50b86fa1..62a8a914 100644 --- a/examples/companion_radio/GeoUtils.h +++ b/examples/companion_radio/GeoUtils.h @@ -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] " +// (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) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4bd400e3..47c427bf 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 08dbec23..68ce35d6 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -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(); diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index d95e3f9a..9edcfb44 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -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); diff --git a/examples/companion_radio/ui-core/SosEngine.h b/examples/companion_radio/ui-core/SosEngine.h index 88ef06af..3789a4cc 100644 --- a/examples/companion_radio/ui-core/SosEngine.h +++ b/examples/companion_radio/ui-core/SosEngine.h @@ -13,6 +13,7 @@ #include #include #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 diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index 66a26ed8..118b24e6 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -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++) diff --git a/examples/companion_radio/ui-core/UiCoreHost.h b/examples/companion_radio/ui-core/UiCoreHost.h index d6bf1adc..4d7af5df 100644 --- a/examples/companion_radio/ui-core/UiCoreHost.h +++ b/examples/companion_radio/ui-core/UiCoreHost.h @@ -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; } diff --git a/examples/companion_radio/ui-lvgl/BotScreen.h b/examples/companion_radio/ui-lvgl/BotScreen.h index e34962fa..a6794234 100644 --- a/examples/companion_radio/ui-lvgl/BotScreen.h +++ b/examples/companion_radio/ui-lvgl/BotScreen.h @@ -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 diff --git a/examples/companion_radio/ui-lvgl/SosScreen.h b/examples/companion_radio/ui-lvgl/SosScreen.h index a83e2ae1..ed0980c4 100644 --- a/examples/companion_radio/ui-lvgl/SosScreen.h +++ b/examples/companion_radio/ui-lvgl/SosScreen.h @@ -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(); diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index fc472a01..1788b9bf 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -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) ──────────────────────── diff --git a/examples/companion_radio/ui-new/SosScreen.h b/examples/companion_radio/ui-new/SosScreen.h index 50b14244..f0a3b87a 100644 --- a/examples/companion_radio/ui-new/SosScreen.h +++ b/examples/companion_radio/ui-new/SosScreen.h @@ -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) { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 5406df37..f9ddfc5a 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 89590ffe..f4deaa4f 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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(); diff --git a/test/test_companion_prefs_json/test_companion_prefs_json.cpp b/test/test_companion_prefs_json/test_companion_prefs_json.cpp index 112ae199..250671cc 100644 --- a/test/test_companion_prefs_json/test_companion_prefs_json.cpp +++ b/test/test_companion_prefs_json/test_companion_prefs_json.cpp @@ -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]; diff --git a/test/test_sos_engine/test_sos_engine.cpp b/test/test_sos_engine/test_sos_engine.cpp index df995c78..62ebe023 100644 --- a/test/test_sos_engine/test_sos_engine.cpp +++ b/test/test_sos_engine/test_sos_engine.cpp @@ -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)); }