diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 5d006f26..ddc55217 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -352,6 +352,9 @@ static void sanitize(NodePrefs& p) { flag(p.msg_wake, 1); if (p.msg_keep >= NodePrefs::MSG_KEEP_COUNT) p.msg_keep = 0; if (p.msg_expire >= NodePrefs::CONTACT_EXPIRY_COUNT) p.msg_expire = 0; + if (p.sos_countdown_idx >= NodePrefs::SOS_COUNTDOWN_COUNT) p.sos_countdown_idx = 1; + if (p.sos_repeat_idx >= NodePrefs::SOS_REPEAT_COUNT) p.sos_repeat_idx = 3; + if (p.sos_beep_idx >= NodePrefs::SOS_BEEP_COUNT) p.sos_beep_idx = 0; if (p.contact_expiry_idx >= NodePrefs::CONTACT_EXPIRY_COUNT) p.contact_expiry_idx = 0; if (p.dst_rule >= NodePrefs::DST_COUNT) p.dst_rule = NodePrefs::DST_OFF; if (p.display_brightness_pct > 100) p.display_brightness_pct = 0; diff --git a/examples/companion_radio/GeoUtils.h b/examples/companion_radio/GeoUtils.h index 930fe8a9..503c2c81 100644 --- a/examples/companion_radio/GeoUtils.h +++ b/examples/companion_radio/GeoUtils.h @@ -143,6 +143,22 @@ static inline bool parseLatLon(const char* text, int32_t& lat_1e6, int32_t& lon_ // the tag (it just looks like a coordinate). #define LOCATION_MSG_TAG "[LOC]" +// 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); +} + +// 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] == ' '); +} + // True if `text` carries a LOCATION_MSG_TAG share; fills lat/lon (1e6-scaled) // from the coordinate that follows it. Returns false for plain text, for a // bare coordinate, or for a [WAY] share — only an explicit [LOC] tag counts, diff --git a/examples/companion_radio/LiveTrack.h b/examples/companion_radio/LiveTrack.h index c27078aa..9c32145e 100644 --- a/examples/companion_radio/LiveTrack.h +++ b/examples/companion_radio/LiveTrack.h @@ -20,6 +20,7 @@ class LiveTrackStore { public: static const int CAPACITY = 16; // max simultaneously tracked nodes static const uint32_t EXPIRY_SECS = 20UL * 60UL; // drop a track 20 min after its last update + static const uint32_t SOS_EXPIRY_SECS = 24UL * 3600UL; // a call for help: a day, whatever came after it static const int NAME_LEN = 16; // display name buffer (incl. NUL) static const int KEY_LEN = 6; // pubkey prefix bytes (matches favourites) @@ -29,14 +30,16 @@ public: int32_t lat_1e6, lon_1e6; uint32_t ts; // RTC epoch seconds of the share bool verified; // true = DM/pubkey, false = channel/name + bool sos; // from an SOS: kept longer, evicted last bool used; }; // Insert or refresh a shared position. `key` may be null/ignored when not // verified. `name` is required (used as the channel key and for display). void update(const uint8_t* key, const char* name, - int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, bool verified) { + int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, bool verified, bool sos = false) { int slot = find(key, name, verified); + const bool was_sos = slot >= 0 && _e[slot].sos; if (slot < 0) slot = allocSlot(); Entry& e = _e[slot]; if (verified && key) memcpy(e.key, key, KEY_LEN); @@ -47,6 +50,7 @@ public: e.lon_1e6 = lon_1e6; e.ts = ts; e.verified = verified; + e.sos = sos || was_sos; e.used = true; } @@ -105,7 +109,7 @@ private: // Guarded against now < ts (RTC stepped backwards) so a clock fix can't // mass-expire the table. static bool expired(const Entry& e, uint32_t now) { - return now > e.ts && (now - e.ts) > EXPIRY_SECS; + return now > e.ts && (now - e.ts) > (e.sos ? SOS_EXPIRY_SECS : EXPIRY_SECS); } // Match an existing entry: verified shares by key, channel shares by name. @@ -121,12 +125,12 @@ private: return -1; } - // First free slot, else evict the oldest (smallest ts). + // First free slot, else evict the oldest (smallest ts), an SOS only if all are. int allocSlot() { int oldest = 0; for (int i = 0; i < CAPACITY; i++) { if (!_e[i].used) return i; - if (_e[i].ts < _e[oldest].ts) oldest = i; + if (_e[oldest].sos != _e[i].sos ? _e[oldest].sos : _e[i].ts < _e[oldest].ts) oldest = i; } return oldest; } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 129897ce..4bd400e3 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -679,6 +679,14 @@ void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk } +// Someone's SOS (SosEngine::compose()), `body` without the name in front. +void MyMesh::noteSos(const uint8_t* pub_key, const char* name, const char* body, bool verified) { + 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); +} + void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) { markConnectionActive(from); // in case this is from a server, and we have a connection @@ -688,6 +696,8 @@ void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t int32_t loc_lat, loc_lon; if (_listener && geo::parseLocShare(text, loc_lat, loc_lon)) { _listener->onSharedLocation(from.id.pub_key, from.name, loc_lat, loc_lon, sender_timestamp, true); + } else { + noteSos(from.id.pub_key, from.name, text, true); } // hop count of the received message. getPathHashCount() (low 6 bits of path_len) @@ -743,6 +753,9 @@ void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uin ContactInfo* sc = sender_prefix ? lookupContactByPubKey(sender_prefix, 4) : nullptr; const char* who = (sc && sc->name[0]) ? sc->name : from.name; _listener->onSharedLocation(nullptr, who, loc_lat, loc_lon, sender_timestamp, false); + } else { + ContactInfo* sc = sender_prefix ? lookupContactByPubKey(sender_prefix, 4) : nullptr; + noteSos(nullptr, (sc && sc->name[0]) ? sc->name : from.name, text, false); } // Room-server auto-reply bot — only ever fires for the room server contact @@ -822,6 +835,11 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe const char* msg; botChannelSenderSplit(text, sender, sizeof(sender), &msg); _listener->onSharedLocation(nullptr, sender, loc_lat, loc_lon, timestamp, false); + } else { + char sender[32]; + const char* msg; + botChannelSenderSplit(text, sender, sizeof(sender), &msg); + noteSos(nullptr, sender, msg, false); } // hop count for !hops (see onMessageRecv); not the wire path_len above. @@ -1467,6 +1485,9 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.bot_quiet_start = 0; _prefs.bot_quiet_end = 0; // start==end → quiet hours disabled _prefs.bot_actions_scope = 0; // actions by everyone + _prefs.sos_countdown_idx = 1; // 10 s + _prefs.sos_repeat_idx = 3; // every 5 minutes + strcpy(_prefs.sos_ack, "ACK"); _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 diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 27b63c49..08dbec23 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -180,6 +180,10 @@ public: // A [LOC] share (pub_key set = verified DM; null = channel/room, by name). virtual void onSharedLocation(const uint8_t* pub_key, const char* name, int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, bool verified) { } + // An SOS with a position: unlike a share, always kept (name as above). + virtual void onSosPosition(const uint8_t* pub_key, const char* name, + int32_t lat_1e6, int32_t lon_1e6, bool verified) {} + virtual void onSosNoPosition(const char* name) {} // A contact / channel slot is gone -- drop references to it. virtual void onContactRemoved(const uint8_t* pub_key) { } virtual void onChannelRemoved(uint8_t channel_idx) { } @@ -642,6 +646,7 @@ private: // the body only. `*msg_out` points into `text` (no copy); `sender_name` // defaults to "someone" if there's no ": " separator. void botChannelSenderSplit(const char* text, char* sender_name, int sender_name_len, const char** msg_out); + void noteSos(const uint8_t* pub_key, const char* name, const char* body, bool verified); void writeOKFrame(); void writeErrFrame(uint8_t err_code); diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index e7f46bdf..d95e3f9a 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -339,6 +339,19 @@ struct NodePrefsFields { // Channel options > Save: bit i = channel i isn't kept on flash, for one too // busy to be worth the room [del→onChannelRemoved] uint64_t ch_no_save; + // SOS (ui-core/SosEngine.h). The hold (Back, the L2's top button) works only with sos_enabled: + // a long press in a pocket mustn't call for help on a device set up for + // something else. Targets: bit i = channel i [del→onChannelRemoved], and up to + // SOS_DM_MAX contacts, an all-zero prefix = none [del→onContactRemoved]. + static const uint8_t SOS_DM_MAX = 3; + uint8_t sos_enabled; + uint8_t sos_countdown_idx; // index into sosCountdownSecs() + uint8_t sos_repeat_idx; // index into sosRepeatMins() + uint8_t sos_beep_idx; // index into sosBeepSecs(): a beep to be found by; 0 = off + 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 // 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; @@ -656,6 +669,21 @@ struct NodePrefsFields { static const char* L[CONTACT_EXPIRY_COUNT] = { "Off", "7d", "30d", "90d" }; return L[idx < CONTACT_EXPIRY_COUNT ? idx : 0]; } + static const uint8_t SOS_COUNTDOWN_COUNT = 3; + static uint8_t sosCountdownSecs(uint8_t idx) { + static const uint8_t S[SOS_COUNTDOWN_COUNT] = { 5, 10, 15 }; + return S[idx < SOS_COUNTDOWN_COUNT ? idx : 1]; + } + static const uint8_t SOS_REPEAT_COUNT = 5; + static uint8_t sosRepeatMins(uint8_t idx) { // 0 = send once + static const uint8_t M[SOS_REPEAT_COUNT] = { 0, 1, 2, 5, 10 }; + return M[idx < SOS_REPEAT_COUNT ? idx : 3]; + } + static const uint8_t SOS_BEEP_COUNT = 4; + static uint16_t sosBeepSecs(uint8_t idx) { // 0 = off + static const uint16_t S[SOS_BEEP_COUNT] = { 0, 30, 60, 300 }; + return S[idx < SOS_BEEP_COUNT ? idx : 0]; + } static const uint8_t BOT_GAP_COUNT = 4; static uint16_t botGapSecs(uint8_t idx) { static const uint16_t S[BOT_GAP_COUNT] = { 10, 30, 60, 300 }; @@ -923,6 +951,11 @@ class NodePrefs : public NodePrefsFields, public ConfigSerializer { s.def("fav_sort", fav_sort); s.def("expiry", contact_expiry_idx); s.def("msg_keep", msg_keep); s.def("msg_expire", msg_expire); s.hex("ch_no_save", ch_no_save); } + 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)); + } void gpioSolo(Section& s) { s.def("m1", gpio1_mode); s.def("m2", gpio2_mode); s.def("m3", gpio3_mode); s.def("m4", gpio4_mode); } @@ -946,7 +979,7 @@ class NodePrefs : public NodePrefsFields, public ConfigSerializer { s.sub("display", display_solo); s.sub("clock", clock_solo); s.sub("sound", sound_solo); s.sub("scope", scope_solo); s.sub("bot", bot_solo); s.sub("gps", gps_solo); s.sub("share", share_solo); s.sub("locator", locator_solo); s.sub("lists", lists_solo); - s.sub("gpio", gpio_solo); s.sub("msgs", msgs_solo); s.sub("lock", lock_solo); + s.sub("gpio", gpio_solo); s.sub("msgs", msgs_solo); s.sub("lock", lock_solo); s.sub("sos", sos_solo); } Section radio, gps, repeat, comp; @@ -955,7 +988,7 @@ class NodePrefs : public NodePrefsFields, public ConfigSerializer { #endif Section solo, radio_solo, preset[USER_RADIO_PRESET_MAX], repeater_solo, power_solo, display_solo, clock_solo, sound_solo, scope_solo, bot_solo, gps_solo, share_solo, locator_solo, - lists_solo, gpio_solo, msgs_solo, lock_solo; + lists_solo, gpio_solo, msgs_solo, lock_solo, sos_solo; FemPrefs fem; DynamicConfigSerializer custom; @@ -996,6 +1029,7 @@ public: share_solo(this, &NodePrefs::shareSolo), locator_solo(this, &NodePrefs::locatorSolo), lists_solo(this, &NodePrefs::listsSolo), gpio_solo(this, &NodePrefs::gpioSolo), msgs_solo(this, &NodePrefs::msgsSolo), lock_solo(this, &NodePrefs::lockSolo), + sos_solo(this, &NodePrefs::sosSolo), fem(this), custom(&fem) { clearFields(); } diff --git a/examples/companion_radio/ui-core/ChannelControl.h b/examples/companion_radio/ui-core/ChannelControl.h index daf8f674..5c980e38 100644 --- a/examples/companion_radio/ui-core/ChannelControl.h +++ b/examples/companion_radio/ui-core/ChannelControl.h @@ -281,7 +281,7 @@ static uint8_t scopeFromRow(int row) { // here, so a channel re-added at a freed slot can't inherit the old one's // settings. Adding such a field: clear it below (and mark it in NodePrefs.h). // Covers bot_channel_idx, loc_share_channel_idx, ch_notif_melody_*, -// ch_notif_override / ch_notif_muted, ch_fav_bitmask, ch_no_save, favourite dial +// ch_notif_override / ch_notif_muted, ch_fav_bitmask, ch_no_save, sos_ch_mask, favourite dial // slots, ch_scope_idx. True when something changed (caller saves). static bool onRemoved(NodePrefs* p, uint8_t idx) { if (!p) return false; @@ -294,6 +294,7 @@ static bool onRemoved(NodePrefs* p, uint8_t idx) { if (p->ch_notif_override & m) { p->ch_notif_override &= ~m; p->ch_notif_muted &= ~m; changed = true; } if (p->ch_fav_bitmask & m) { p->ch_fav_bitmask &= ~m; changed = true; } if (p->ch_no_save & m) { p->ch_no_save &= ~m; changed = true; } + if (p->sos_ch_mask & m) { p->sos_ch_mask &= ~m; changed = true; } if (idx < NodePrefs::MAX_SCOPED_CHANNELS && p->ch_scope_idx[idx] != NodePrefs::CH_SCOPE_DEFAULT) { p->ch_scope_idx[idx] = NodePrefs::CH_SCOPE_DEFAULT; // back to Default, as a new channel changed = true; diff --git a/examples/companion_radio/ui-core/CourseEngine.h b/examples/companion_radio/ui-core/CourseEngine.h index 3b4608d6..b07acf15 100644 --- a/examples/companion_radio/ui-core/CourseEngine.h +++ b/examples/companion_radio/ui-core/CourseEngine.h @@ -17,7 +17,10 @@ public: if ((int32_t)(millis() - _next_sample_ms) < 0) return; _next_sample_ms = millis() + 1000UL; int32_t lat, lon; - if (currentLocation(lat, lon)) pushFix(lat, lon); + if (currentLocation(lat, lon)) { + _fix_lat = lat; _fix_lon = lon; _fix_ms = millis(); _had_fix = true; + pushFix(lat, lon); + } } // Current GPS position (1e6-scaled degrees), false when there's no usable fix. @@ -31,6 +34,15 @@ public: return false; } + // The last fix since this start, and how old it is. Only RAM: a position + // saved on flash has no age, so it can't be told from one a thousand km away. + bool lastFix(int32_t& lat, int32_t& lon, uint32_t& age_secs) const { + if (!_had_fix) return false; + lat = _fix_lat; lon = _fix_lon; + age_secs = (uint32_t)(millis() - _fix_ms) / 1000UL; + return true; + } + // Current GPS altitude in metres, false without a fix. bool currentAltitude(int16_t& m) const { LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr; @@ -62,6 +74,9 @@ public: } private: + int32_t _fix_lat = 0, _fix_lon = 0; + uint32_t _fix_ms = 0; + bool _had_fix = false; // Insert a GPS fix into the ring, rejecting gross outliers (a jump implying // an impossible speed) so one bad fix can't swing the heading. void pushFix(int32_t lat, int32_t lon) { diff --git a/examples/companion_radio/ui-core/LiveShareEngine.h b/examples/companion_radio/ui-core/LiveShareEngine.h index a70fe14f..48f175de 100644 --- a/examples/companion_radio/ui-core/LiveShareEngine.h +++ b/examples/companion_radio/ui-core/LiveShareEngine.h @@ -134,6 +134,14 @@ public: _track.update(pub_key, name, lat_1e6, lon_1e6, ts, verified); } + // Someone's SOS. Not gated on Track loc, and named so it stands out on the map; + // the time is ours, as the sender's clock may never have been set. + void onSos(const uint8_t* pub_key, const char* name, int32_t lat_1e6, int32_t lon_1e6, bool verified) { + char label[LiveTrackStore::NAME_LEN]; + snprintf(label, sizeof(label), "SOS %s", name ? name : ""); + _track.update(pub_key, label, lat_1e6, lon_1e6, (uint32_t)rtc_clock.getCurrentTime(), verified, true); + } + LiveTrackStore& track() { return _track; } const LiveTrackStore& track() const { return _track; } diff --git a/examples/companion_radio/ui-core/SosEngine.h b/examples/companion_radio/ui-core/SosEngine.h new file mode 100644 index 00000000..b7b2eb1c --- /dev/null +++ b/examples/companion_radio/ui-core/SosEngine.h @@ -0,0 +1,216 @@ +#pragma once +// SOS: a countdown that can be cancelled, then a position message to the chosen +// channels and contacts (UiCore::sendSos()), repeated until someone answers +// with the ACK word, the sends run out or the user stops it. Once more as soon +// as the GPS gets a fix, if none has gone with one yet, even past the last +// send. An optional beep keeps going until it's stopped. Only the timing and the +// matching live here: no radio, no clock, so it's tested on the host +// (test/test_sos_engine). Not kept across a restart. + +#include +#include +#include +#include +#include +#include "../NodePrefs.h" + +class SosEngine { +public: + static const int RESPONDERS_MAX = 3; + struct Responder { + char name[24]; + char text[48]; // what they wrote; "" when it was only the ACK word + uint32_t at; // millis() of their last message + }; + static const uint8_t MAX_SENDS = 12; // a flood to every target: never without end + static const uint32_t RETRY_MS = 10000UL; // nothing went out: soon again, still counted + static const uint32_t LAST_MAX_SECS = 3600; // an older fix isn't sent: you could be anywhere by then + static const uint32_t SCREEN_MS = 30000UL; // while one runs the screen goes off by this, whatever auto-off says + static const uint32_t BEEP_FIRST_MS = 3000UL; // after the first send's own sound + + void begin(const NodePrefs* prefs) { _prefs = prefs; } + + bool idle() const { return _state == IDLE; } + bool counting() const { return _state == COUNTDOWN; } + bool acked() const { return _acked; } + int responders() const { return _responders; } + const Responder& responder(int i) const { return _resp[i]; } // the latest first + uint8_t sent() const { return _sent; } // sends that went out + // Nothing more will go out: the GPS has done its part. + bool finished() const { + return _state == ACTIVE && _sent && (_acked || (_with_fix && (_attempts >= MAX_SENDS || !_next_on))); + } + // The GPS must stay awake, past any duty cycle. + bool gpsWanted() const { return _state != IDLE && !finished(); } + // The screen's auto-off while one runs. ms: the user's, 0 = never. + uint32_t screenMs(uint32_t ms) const { + if (_state != IDLE && (ms == 0 || ms > SCREEN_MS)) return SCREEN_MS; + return ms; + } + + void start(uint32_t now) { + if (_state != IDLE) return; + stop(); + _state = COUNTDOWN; + _t0 = now; + } + void stop() { + _state = IDLE; + _attempts = _sent = _responders = 0; + _acked = _with_fix = _next_on = _failed = false; + } + + // Whole seconds left of the countdown, rounded up: 0 when not counting. + int secsLeft(uint32_t now) const { + if (_state != COUNTDOWN) return 0; + uint32_t total = countdownMs(), el = now - _t0; + return el >= total ? 0 : (int)((total - el + 999) / 1000); + } + // Seconds to the next send, -1 when there won't be one. + int nextSecs(uint32_t now) const { + if (_state != ACTIVE || !repeating()) return -1; + int32_t left = (int32_t)(_next - now); + return left <= 0 ? 0 : (left + 999) / 1000; + } + + // How it's going, for the status views. + void sentLine(char* out, int n) const { + if (_sent) snprintf(out, n, "Sent %u x", (unsigned)_sent); + else snprintf(out, n, "Not sent"); + } + void nextLine(char* out, int n, uint32_t now) const { + const int next = nextSecs(now); + if (next < 0) snprintf(out, n, "No more repeats"); + else if (next < 60) snprintf(out, n, "Next in %d s", next); + else snprintf(out, n, "Next in %d min", (next + 59) / 60); + } + + // True when a message is due: the caller sends it and calls markSent(). + bool loop(uint32_t now, bool has_fix) { + if (_state == COUNTDOWN) { + if (now - _t0 < countdownMs()) return false; + _state = ACTIVE; + _beep_next = now + BEEP_FIRST_MS; + return true; + } + if (_state != ACTIVE || _acked) return false; + if (has_fix && !_with_fix && _sent && !_failed) return true; // the position they don't have yet + return _attempts < MAX_SENDS && _next_on && (int32_t)(now - _next) >= 0; + } + // True when the beep is due, for whoever lies there unable to answer: past + // the sends and an ACK too, help still has to find you. Until it's stopped. + bool beepDue(uint32_t now) { + const uint32_t gap = _prefs ? (uint32_t)NodePrefs::sosBeepSecs(_prefs->sos_beep_idx) * 1000UL : 0; + if (_state != ACTIVE || gap == 0 || (int32_t)(now - _beep_next) < 0) return false; + _beep_next = now + gap; + return true; + } + void markSent(uint32_t now, bool with_fix, bool ok) { + _attempts++; + if (ok) _sent++; + if (ok && with_fix) _with_fix = true; + _failed = !ok; + uint32_t gap = _prefs ? (uint32_t)NodePrefs::sosRepeatMins(_prefs->sos_repeat_idx) * 60000UL : 0; + _next_on = _failed || gap > 0; + _next = now + (_failed ? RETRY_MS : gap); + } + + // A message came in. True when it's an answer: one holding the ACK word, from + // anyone, or anything further from someone who has answered, who is the one + // talking to you now. Repeats stop at the first, and the screen says who + // answered and what they said. + bool onReply(const char* from, const char* text, uint32_t now = 0) { + if (_state != ACTIVE || !_sent || !_prefs || !text) return false; + if (!from) from = ""; + int i = 0; + while (i < _responders && strncmp(_resp[i].name, from, sizeof(_resp[i].name) - 1) != 0) i++; + const bool known = i < _responders; + const bool word = hasWord(text, _prefs->sos_ack); + if (!known && !word) return false; + _acked = true; + if (!known) { + if (_responders < RESPONDERS_MAX) _responders++; + i = _responders - 1; // full: over the one quiet the longest + } + Responder r = _resp[i]; + for (; i > 0; i--) _resp[i] = _resp[i - 1]; // to the front + _resp[0] = r; + snprintf(_resp[0].name, sizeof(_resp[0].name), "%s", from); + const bool bare = word && strlen(text) == strlen(_prefs->sos_ack); + if (!bare || !known) { // the word alone adds nothing to what they said before + snprintf(_resp[0].text, sizeof(_resp[0].text), "%s", bare ? "" : text); + _resp[0].text[mesh::validUtf8PrefixLength(_resp[0].text, sizeof(_resp[0].text) - 1)] = '\0'; // cut off mid-character + } + _resp[0].at = now; + return true; + } + + // ── Text ────────────────────────────────────────────────────────────────── + // `word` on its own in `text`, whatever the case: ACK in "ack, on our way", + // not in "back". A byte past ASCII counts as a letter (UTF-8). + static bool hasWord(const char* text, const char* word) { + int n = word ? (int)strlen(word) : 0; + if (!text || n == 0) return false; + for (const char* p = text; *p; p++) { + if (p != text && isWordByte(p[-1])) continue; + if (strncasecmp(p, word, n) == 0 && !isWordByte(p[n])) return true; + } + return false; + } + + 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) { + 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); + } + + // Anywhere to send to. A channel or a contact that has gone clears its own + // target (chanctl::onRemoved, UiCore::cleanupContactPrefs). + static bool hasTargets(const NodePrefs* p) { + if (!p) return false; + return p->sos_ch_mask || contacts(p) > 0; + } + static int channels(const NodePrefs* p) { + int on = 0; + for (uint64_t m = p->sos_ch_mask; m; m &= m - 1) on++; + return on; + } + static int contacts(const NodePrefs* p) { + int on = 0; + for (int i = 0; i < NodePrefs::SOS_DM_MAX; i++) if (hasDm(p, i)) on++; + return on; + } + static bool hasDm(const NodePrefs* p, int slot) { + for (int b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++) if (p->sos_dm[slot][b]) return true; + return false; + } + +private: + enum State : uint8_t { IDLE, COUNTDOWN, ACTIVE }; + static bool isWordByte(char c) { return isalnum((uint8_t)c) || (uint8_t)c >= 0x80; } + uint32_t countdownMs() const { + return (uint32_t)NodePrefs::sosCountdownSecs(_prefs ? _prefs->sos_countdown_idx : 1) * 1000UL; + } + bool repeating() const { return !_acked && _attempts < MAX_SENDS && _next_on; } + + const NodePrefs* _prefs = nullptr; + State _state = IDLE; + uint32_t _t0 = 0, _next = 0, _beep_next = 0; + uint8_t _attempts = 0, _sent = 0; + bool _acked = false, _with_fix = false, _next_on = false, _failed = false; + Responder _resp[RESPONDERS_MAX]; + uint8_t _responders = 0; +}; diff --git a/examples/companion_radio/ui-core/SoundControl.h b/examples/companion_radio/ui-core/SoundControl.h index 4287fa47..bef27621 100644 --- a/examples/companion_radio/ui-core/SoundControl.h +++ b/examples/companion_radio/ui-core/SoundControl.h @@ -18,6 +18,7 @@ static const char* const MEL_ALARM = "alarm:d=8,o=6,b=125:c,c,c,c,p,c,c,c,c,p"; static const char* const MEL_ARRIVE = "locarr:d=8,o=6,b=140:c,e,g"; // arrival alert: reached the target static const char* const MEL_LEAVE = "loclv:d=8,o=6,b=140:g,e,c"; // ... left its radius static const char* const MEL_TICK = "locp:d=32,o=7,b=200:c"; // proximity beeper tick +static const char* const MEL_SOS = "sos:d=16,o=7,b=200:e,p,e,p,e,8p,8e,p,8e,p,8e,8p,e,p,e,p,e"; // ... --- ... : to be found by static const char* const MEL_VOLUME = "Vol:d=16,o=6,b=120:c"; // volume preview // ── Mode ────────────────────────────────────────────────────────────────────── diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index a0f77e6f..f2c03be9 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -35,6 +35,9 @@ #include "RoomSessions.h" #include "ContactControl.h" #include "HopRoute.h" // after ContactControl (hopName) +#include "RadioControl.h" +#include "RepeaterControl.h" +#include "SosEngine.h" class UiCore : public MyMesh::Listener { public: @@ -50,6 +53,7 @@ public: live_share.begin(prefs, &course, &events); locator.begin(prefs, &course, &live_share, &events); trail.begin(prefs, &course); + sos.begin(prefs); waypoints.begin(&locator); // loads /waypoints #ifdef HIST_FLASH history_log.begin(prefs, &history); @@ -72,6 +76,7 @@ public: trail.loop(); admin.loop(); rooms.loop(); + if (!sos.idle()) sosLoop(); } UiEventQueue events; // Core → frontend; drained by the frontend's loop() @@ -88,6 +93,7 @@ public: TrailEngine trail; // GPS trail store, sampling, auto-pause, shutdown save AdminSession admin; // remote repeater / room admin: login + CLI round trips RoomSessions rooms; // room server logins for posting (messages screens) + SosEngine sos; // countdown, repeats, the ACK word // ── Models ──────────────────────────────────────────────────────────────── MessageHistory history; // channel + DM rings, delivery state, channel unread @@ -149,6 +155,28 @@ public: return true; } + // SOS: the GPS on for the fix (back off afterwards if it was off), the repeater + // at rest (maybe on a profile of its own, and the message goes to the user's own + // network), then SosEngine's countdown. All of it ends with stopSos(). + void startSos() { + if (!sos.idle()) return; + _sos_gps_on = gpsAvailable() && !gpsEnabled(); + if (_sos_gps_on) setGpsEnabled(true, false); + _sos_repeat_was = _prefs && _prefs->client_repeat; + if (_sos_repeat_was) rptctl::setEnabled(_prefs, false); + sos.start(millis()); + _host->onSosRunning(true); + } + void stopSos() { + if (sos.idle()) return; + sos.stop(); + if (_sos_gps_on) setGpsEnabled(false, false); + _sos_gps_on = false; + if (_sos_repeat_was) rptctl::setEnabled(_prefs, true); + _sos_repeat_was = false; + _host->onSosRunning(false); + } + // GPS power: the sensor manager's "gps" setting, persisted as // NodePrefs::gps_enabled (MyMesh::applyGpsPrefs() restores it at boot). bool gpsAvailable() const { return gpsSettingIndex() >= 0; } @@ -262,6 +290,7 @@ public: } addDMMsg(from.id.pub_key, false, labeled, sender_timestamp, 0, 0, 0, pkt->path, (uint8_t)pkt->path_len); } + if (from.type == ADV_TYPE_CHAT) sosReply(from.name, text); } // Upstream-shaped entry point without the channel slot: MyMesh itself always @@ -279,6 +308,11 @@ public: if (strncmp(text, "Me: ", 4) == 0) { snprintf(quoted, sizeof(quoted), "\"Me\"%s", text + 2); text = quoted; } addChannelMsg(channel_idx, text, timestamp, pkt->path, (uint8_t)pkt->path_len); pushMessageArrived(UIEventType::channelMessage, channel_details.name, channel_idx); + if (_prefs && (_prefs->sos_ch_mask & (1ULL << channel_idx))) { // only where the SOS went + char from[32]; + const char* body = contactctl::splitRoomPost(text, from, sizeof(from)); // the same "Name: text" + sosReply(from[0] ? from : channel_details.name, body); + } } // Also the entry point for our own sends mirrored from the app / the bot @@ -332,6 +366,13 @@ public: live_share.onSharedLocation(pub_key, name, lat_1e6, lon_1e6, ts, verified); } + void onSosPosition(const uint8_t* pub_key, const char* name, int32_t lat_1e6, int32_t lon_1e6, + bool verified) override { + live_share.onSos(pub_key, name, lat_1e6, lon_1e6, verified); + events.push(UiEventType::SosHeard, name); + } + void onSosNoPosition(const char* name) override { events.push(UiEventType::SosHeard, name); } + // Login answers go to the admin session when it's the one waiting, else to // the room sessions (logins from the messages screen), then the frontend. void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) override { @@ -363,13 +404,67 @@ public: if (chanctl::onRemoved(_prefs, channel_idx)) the_mesh.savePrefs(); _host->onChannelRemoved(channel_idx); } - void botSetGPS(bool on) override { _host->botSetGPS(on); } + // The end of a !gps fix must not take the GPS from a running SOS: off afterwards. + void botSetGPS(bool on) override { + if (!on && !sos.idle()) { _sos_gps_on = gpsAvailable(); return; } + _host->botSetGPS(on); + } + bool botGpsHeld() override { return !sos.idle(); } void botBuzz(int seconds) override { _host->botBuzz(seconds); } bool botSetGPIO(int idx, bool on) override { return _host->botSetGPIO(idx, on); } bool botGetGPIO(int idx, bool& is_output, bool& value) override { return _host->botGetGPIO(idx, is_output, value); } bool botGetGPIOAnalog(int idx, int& millivolts) override { return _host->botGetGPIOAnalog(idx, millivolts); } private: + void sosLoop() { + int32_t lat, lon; + if (sos.loop(millis(), course.currentLocation(lat, lon))) sendSos(); + if (_sos_gps_on && sos.finished()) { // it only ran for the SOS: the battery has better use + setGpsEnabled(false, false); + _sos_gps_on = false; + } + } + + // After the message's own event, so the answer's sound is the one that plays. + void sosReply(const char* from, const char* text) { + const bool first = !sos.acked(); + if (sos.onReply(from, text, millis())) events.push(UiEventType::SosAcked, from, first); + } + + // One round: every target, with the position as of now. + void sendSos() { + const uint32_t now = rtc_clock.getCurrentTime(); + int32_t lat = 0, lon = 0; + int16_t alt = 0; + uint32_t age = 0; + const bool fix = course.currentLocation(lat, lon); + const bool has_alt = fix && course.currentAltitude(alt); + // A position nobody can tell the age of could be anywhere, and is worse than + // none: only a recent fix, with its age. Where there's no GPS at all, the + // position set by hand is the one the owner chose to give. + const bool recent = !fix && course.lastFix(lat, lon, age) && age <= SosEngine::LAST_MAX_SECS; + const bool set = !fix && !gpsAvailable() && fixedPosition(lat, lon); + SosEngine::Where where = fix ? SosEngine::FIX : recent || set ? SosEngine::LAST_KNOWN : SosEngine::NOWHERE; + char hhmm[8] = ""; + 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); + + bool ok = false; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) + if ((_prefs->sos_ch_mask & (1ULL << i)) && sendChannelText((uint8_t)i, text)) ok = true; + for (int i = 0; i < NodePrefs::SOS_DM_MAX; i++) { + if (!SosEngine::hasDm(_prefs, i)) continue; + ContactInfo* c = the_mesh.lookupContactByPubKey(_prefs->sos_dm[i], NodePrefs::FAVOURITE_PREFIX_LEN); + if (c && sendDirectText(*c, text)) ok = true; + } + const bool first = ok && sos.sent() == 0; + sos.markSent(millis(), fix, ok); + events.push(UiEventType::SosSent, nullptr, first); + } + UiEvent& pushMessageArrived(UIEventType kind, const char* name, int channel_idx) { UiEvent& ev = events.push(UiEventType::MessageArrived, name); ev.kind = kind; @@ -404,6 +499,11 @@ private: p->loc_share_enabled = 0; changed = true; } + for (int i = 0; i < NodePrefs::SOS_DM_MAX; i++) + if (memcmp(p->sos_dm[i], pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { + memset(p->sos_dm[i], 0, NodePrefs::FAVOURITE_PREFIX_LEN); + changed = true; + } if (p->bot_room_enabled && memcmp(p->bot_room_prefix, pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { p->bot_room_enabled = 0; changed = true; @@ -426,4 +526,6 @@ private: SensorManager* _sensors = nullptr; int _queue_len = 0; // last onQueueSizeChanged() int _room_unread = 0; + bool _sos_gps_on = false; // startSos() turned the GPS on + bool _sos_repeat_was = false; // ... and rested the repeater }; diff --git a/examples/companion_radio/ui-core/UiCoreHost.h b/examples/companion_radio/ui-core/UiCoreHost.h index 0729a30a..d6bf1adc 100644 --- a/examples/companion_radio/ui-core/UiCoreHost.h +++ b/examples/companion_radio/ui-core/UiCoreHost.h @@ -30,6 +30,9 @@ public: virtual void onContactRemoved(const uint8_t* pub_key) {} virtual void onChannelRemoved(uint8_t channel_idx) {} virtual void botSetGPS(bool on) {} + // 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) {} 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-core/UiEvents.h b/examples/companion_radio/ui-core/UiEvents.h index 4067bfba..62aa806f 100644 --- a/examples/companion_radio/ui-core/UiEvents.h +++ b/examples/companion_radio/ui-core/UiEvents.h @@ -18,6 +18,9 @@ enum class UiEventType : uint8_t { // room / channel message; `key` (flag = valid) = DM sender prefix // for per-contact sounds; `idx` = channel slot (-1 = unknown) AdvertHeard, // an advert was heard; flag = flood (else zero-hop) + SosSent, // a round of an SOS was tried; flag = the first that went out + SosAcked, // someone answered: text = who; flag = the first of this SOS + SosHeard, // someone else's SOS: text = who }; struct UiEvent { diff --git a/examples/companion_radio/ui-lvgl/DeviceScreen.h b/examples/companion_radio/ui-lvgl/DeviceScreen.h index 20342a9d..31a51f8d 100644 --- a/examples/companion_radio/ui-lvgl/DeviceScreen.h +++ b/examples/companion_radio/ui-lvgl/DeviceScreen.h @@ -136,6 +136,78 @@ void UITask::powerPopup(bool restart) { // ── Lock screen ─────────────────────────────────────────────────────────────── +// Grows to push what follows to the bottom of a flex column. +static void flexFill(lv_obj_t* parent) { + lv_obj_t* f = lv_obj_create(parent); + lv_obj_remove_style_all(f); + lv_obj_set_width(f, 1); + lv_obj_set_flex_grow(f, 1); +} + +static const int SLIDE_W = 264; +// A slide to confirm: a round knob in a track, `text` on it. Not clickable: +// slidePoll() drives it, so that a tap on the track doesn't jump to the end. +static lv_obj_t* slideBar(lv_obj_t* parent, const char* text, uint32_t knob) { + lv_obj_t* s = lv_slider_create(parent); + lv_obj_set_size(s, SLIDE_W, 48); + lv_slider_set_range(s, 0, 100); + lv_obj_set_style_anim_duration(s, 0, 0); // no lag behind the finger + lv_obj_set_style_bg_color(s, lv_color_hex(theme::SURFACE), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_radius(s, LV_RADIUS_CIRCLE, LV_PART_MAIN); + lv_obj_set_style_pad_hor(s, 24, LV_PART_MAIN); // half the track: the knob's centre stops short of its ends + lv_obj_set_style_bg_opa(s, LV_OPA_TRANSP, LV_PART_INDICATOR); + lv_obj_set_style_bg_color(s, lv_color_hex(knob), LV_PART_KNOB); + lv_obj_set_style_pad_all(s, -4, LV_PART_KNOB); // inside the track + lv_obj_set_style_shadow_width(s, 0, LV_PART_KNOB); + lv_obj_set_style_bg_image_src(s, LV_SYMBOL_RIGHT, LV_PART_KNOB); + lv_obj_set_style_text_font(s, THEME_FONT_BODY, LV_PART_KNOB); + lv_obj_set_style_text_color(s, lv_color_hex(0xFFFFFF), LV_PART_KNOB); + lv_obj_remove_flag(s, LV_OBJ_FLAG_CLICKABLE); + lv_obj_center(label(s, text, THEME_FONT_BODY, theme::TEXT_MUTED)); // fades as the knob comes (slidePoll) + return s; +} + +static void slideText(lv_obj_t* s, int32_t v) { + const int32_t opa = LV_OPA_COVER - v * LV_OPA_COVER / 60; + lv_obj_set_style_opa(lv_obj_get_child(s, 0), opa < 0 ? 0 : (lv_opa_t)opa, 0); +} + +// True once, when a drag that began on the knob reached the end and let go; +// short of the end the knob springs back. +struct SlideDrag { bool dragging = false, was_down = false; }; +static bool slidePoll(lv_obj_t* s, SlideDrag& d) { + lv_indev_t* in = lv_indev_get_next(NULL); + if (!s || !in) { d = SlideDrag(); return false; } + bool down = lv_indev_get_state(in) == LV_INDEV_STATE_PRESSED; + lv_point_t p; + lv_indev_get_point(in, &p); + lv_area_t a; + lv_obj_get_coords(s, &a); + int32_t knob = lv_area_get_height(&a); // round knob, as tall as the track + if (down && !d.was_down) { // a drag starts on the knob only + d.dragging = p.y >= a.y1 - 10 && p.y <= a.y2 + 10 && p.x <= a.x1 + knob + 16; + if (d.dragging) lv_anim_delete(s, anim::setSlider); + } + d.was_down = down; + if (!down) { + if (!d.dragging) return false; + d.dragging = false; + int32_t v = lv_slider_get_value(s); + if (v >= 100) return true; + if (v > 0) anim::run(s, anim::setSlider, v, 0, anim::SPRING_MS); + slideText(s, 0); + return false; + } + if (!d.dragging) return false; + int32_t span = lv_area_get_width(&a) - knob; + int32_t v = span > 0 ? (p.x - a.x1 - knob / 2) * 100 / span : 0; + v = v < 0 ? 0 : v >= 92 ? 100 : v; // snaps to the end: let go to confirm + lv_slider_set_value(s, v, LV_ANIM_OFF); + slideText(s, v); + return false; +} + // From sleep() when the lock is on. Built while the screen is dark, so waking // shows it straight away. void UITask::lockScreen() { @@ -181,26 +253,9 @@ void UITask::lockScreen() { s_lock_unread = label(s_lock, "", THEME_FONT_BODY, theme::ACCENT); lv_obj_set_style_pad_top(s_lock_unread, 8, 0); - lv_obj_t* track = lv_obj_create(s_lock); // grows to push the slider to the bottom - lv_obj_remove_style_all(track); - lv_obj_set_width(track, 1); - lv_obj_set_flex_grow(track, 1); - - s_lock_slider = lv_slider_create(s_lock); - lv_obj_set_size(s_lock_slider, 250, 44); + flexFill(s_lock); + s_lock_slider = slideBar(s_lock, "slide to unlock", theme::ACCENT); lv_obj_set_style_margin_bottom(s_lock_slider, 44, 0); // clear of the toast below - lv_slider_set_range(s_lock_slider, 0, 100); - lv_obj_set_style_anim_duration(s_lock_slider, 0, 0); // no lag behind the finger - lv_obj_set_style_bg_color(s_lock_slider, lv_color_hex(theme::SURFACE), LV_PART_MAIN); - lv_obj_set_style_bg_opa(s_lock_slider, LV_OPA_COVER, LV_PART_MAIN); - lv_obj_set_style_radius(s_lock_slider, LV_RADIUS_CIRCLE, LV_PART_MAIN); - lv_obj_set_style_bg_opa(s_lock_slider, LV_OPA_TRANSP, LV_PART_INDICATOR); - lv_obj_set_style_bg_color(s_lock_slider, lv_color_hex(theme::ACCENT), LV_PART_KNOB); - lv_obj_set_style_pad_all(s_lock_slider, 2, LV_PART_KNOB); - lv_obj_set_style_shadow_width(s_lock_slider, 0, LV_PART_KNOB); - lv_obj_remove_flag(s_lock_slider, LV_OBJ_FLAG_CLICKABLE); // lockPoll() drives it - lv_obj_t* hint = label(s_lock_slider, "slide to unlock " LV_SYMBOL_RIGHT, THEME_FONT_BODY, theme::TEXT_MUTED); - lv_obj_align(hint, LV_ALIGN_CENTER, 14, 0); if (_toast) lv_obj_move_foreground(_toast); // new-message toasts still show refreshLock(); @@ -238,34 +293,8 @@ bool UITask::locked() const { return devview::s_lock != nullptr; } // itself, like the Home page swipe -- LVGL stops feeding a pressed widget // once the finger has moved past its scroll threshold. void UITask::lockPoll() { - using namespace devview; - static bool s_dragging = false, s_was_down = false; - lv_indev_t* in = lv_indev_get_next(NULL); - if (!s_lock_slider || !in) { s_dragging = s_was_down = false; return; } - bool down = lv_indev_get_state(in) == LV_INDEV_STATE_PRESSED; - lv_point_t p; - lv_indev_get_point(in, &p); - lv_area_t a; - lv_obj_get_coords(s_lock_slider, &a); - int32_t knob = lv_area_get_height(&a); // round knob, as tall as the track - if (down && !s_was_down) { // a drag starts on the knob only - s_dragging = p.y >= a.y1 - 10 && p.y <= a.y2 + 10 && p.x <= a.x1 + knob + 16; - if (s_dragging) lv_anim_delete(s_lock_slider, anim::setSlider); - } - s_was_down = down; - if (!down) { - if (!s_dragging) return; - s_dragging = false; - int32_t v = lv_slider_get_value(s_lock_slider); - if (v >= 100) unlockScreen(); - else if (v > 0) anim::run(s_lock_slider, anim::setSlider, v, 0, anim::SPRING_MS); - return; - } - if (!s_dragging) return; - int32_t span = lv_area_get_width(&a) - knob; - int32_t v = span > 0 ? (p.x - a.x1 - knob / 2) * 100 / span : 0; - v = v < 0 ? 0 : v >= 92 ? 100 : v; // snaps to the end: let go to unlock - lv_slider_set_value(s_lock_slider, v, LV_ANIM_OFF); + static SlideDrag s_drag; + if (slidePoll(devview::s_lock_slider, s_drag)) unlockScreen(); } void UITask::refreshLock() { @@ -499,6 +528,29 @@ void UITask::favAction(uint8_t act) { favRefresh(); } +// A picker's contacts, favourites first with their star; `rooms`: rooms too. +// A row's user data is `base` + its number, its key s_pick_keys[number]. +// `selected` (a key's prefix) stands out. +static void pickContacts(lv_obj_t* list, bool rooms, lv_event_cb_t cb, int base, const uint8_t* selected = nullptr) { + using namespace devview; + int rows = 0, total = the_mesh.getNumContacts(); + for (int pass = 0; pass < 2; pass++) { + for (int i = 0; i < total && rows < PICK_MAX; i++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c)) continue; + if (c.type != ADV_TYPE_CHAT && !(rooms && c.type == ADV_TYPE_ROOM)) continue; + if (contactctl::favourite(c) != (pass == 0)) continue; + memcpy(s_pick_keys[rows], c.id.pub_key, PUB_KEY_SIZE); + char t[48]; + snprintf(t, sizeof(t), "%s%s%s", contactctl::favourite(c) ? UI_SYMBOL_STAR " " : "", c.name, + c.type == ADV_TYPE_ROOM ? " (room)" : ""); + lv_obj_t* row = listRow(list, t, NULL, cb, (void*)(uintptr_t)(base + rows)); + if (selected && memcmp(c.id.pub_key, selected, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) styleSelected(row, true); + rows++; + } + } +} + // What to put in a slot: channels, then contacts and rooms (favourites first). void UITask::favPickPopup(int slot) { using namespace devview; @@ -519,22 +571,7 @@ void UITask::favPickPopup(int slot) { listRow(list, ch.name, NULL, onFavPick, (void*)(uintptr_t)i); } sectionTitle(list, "CONTACTS AND ROOMS"); - int rows = 0, total = the_mesh.getNumContacts(); - const int MAX_ROWS = PICK_MAX; - for (int pass = 0; pass < 2; pass++) { - for (int i = 0; i < total && rows < MAX_ROWS; i++) { - ContactInfo c; - if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c)) continue; - if (c.type != ADV_TYPE_CHAT && c.type != ADV_TYPE_ROOM) continue; - if (contactctl::favourite(c) != (pass == 0)) continue; - memcpy(s_pick_keys[rows], c.id.pub_key, PUB_KEY_SIZE); - char t[48]; - snprintf(t, sizeof(t), "%s%s%s", contactctl::favourite(c) ? UI_SYMBOL_STAR " " : "", c.name, - c.type == ADV_TYPE_ROOM ? " (room)" : ""); - listRow(list, t, NULL, onFavPick, (void*)(uintptr_t)(PICK_CONTACT + rows)); - rows++; - } - } + pickContacts(list, true, onFavPick, PICK_CONTACT); } void UITask::favPick(int code) { diff --git a/examples/companion_radio/ui-lvgl/HomeScreen.h b/examples/companion_radio/ui-lvgl/HomeScreen.h index ea6e6cc4..ee7244f2 100644 --- a/examples/companion_radio/ui-lvgl/HomeScreen.h +++ b/examples/companion_radio/ui-lvgl/HomeScreen.h @@ -35,6 +35,7 @@ static const App APP_LIST[] = { { 'D', UI_SYMBOL_CHART, "Diagnostics", onOpenDiag, false }, { 'L', UI_SYMBOL_BULB, "Flashlight", onOpenFlashlight, false }, { 'P', LV_SYMBOL_IMAGE, "Gallery", onOpenGallery, false }, + { 'O', LV_SYMBOL_WARNING, "SOS", onOpenSos, false }, }; static const int APP_COUNT = sizeof(APP_LIST) / sizeof(APP_LIST[0]); static const int PER_PAGE = 6; diff --git a/examples/companion_radio/ui-lvgl/SosScreen.h b/examples/companion_radio/ui-lvgl/SosScreen.h new file mode 100644 index 00000000..cf8afc96 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/SosScreen.h @@ -0,0 +1,383 @@ +#pragma once +// SOS on the L2. The engine and the messages are ui-core (SosEngine, +// UiCore::sendSos); here: Home > SOS (the settings), and the view over +// everything, the lock screen and a screen PIN too, on lv_layer_top: +// - the sheet: holding the top button 5 s (Top button on) or Send SOS brings a +// slider, like a phone's emergency slider: a button in a pocket mustn't start one; +// - the countdown: big digits, Cancel; either button cancels too; +// - while it runs: how it's going, Hide, and a slider to stop it, so a pocket +// can't end a call for help. Hidden, the status bar and the title bar's +// clock turn red (refreshStatusBar()); holding the top button brings it back. +// +// Single-TU fragment: included by ui-lvgl/UITask.cpp after Flashlight.h. + +namespace sosview { + +enum Mode : uint8_t { NONE, SHEET, LIVE }; +static const uint32_t VIEW_MS = 30000; // the view stays this long untouched (not the countdown) + +static Mode s_mode = NONE; +static bool s_counting = false; // the LIVE view was built for the countdown +static bool s_wifi_was = false; // onSosRunning() rested it +static lv_obj_t* s_view = nullptr; +static lv_obj_t* s_slider = nullptr; +static lv_obj_t* s_title = nullptr; +static lv_obj_t* s_digits = nullptr; +static lv_obj_t* s_line[4] = { nullptr, nullptr, nullptr, nullptr }; +static lv_obj_t* s_ch_lbl = nullptr; // Channels' value, behind its popup +static SlideDrag s_drag; +static uint32_t s_until = 0, s_next = 0; +static int s_left = 0; // the countdown second last beeped +static int s_edit = -1; // the row a popup is editing + +enum Row : uint8_t { R_CHANNELS, R_DM1, R_DM2, R_DM3, R_ACK, R_NOTE }; +enum Pref : uint8_t { P_COUNTDOWN, P_REPEAT, P_BEEP }; + +static const char* COUNTDOWNS[NodePrefs::SOS_COUNTDOWN_COUNT + 1] = { "5s", "10s", "15s", "" }; +static const char* REPEATS[NodePrefs::SOS_REPEAT_COUNT + 1] = { "Off", "1m", "2m", "5m", "10m", "" }; +static const char* BEEPS[NodePrefs::SOS_BEEP_COUNT + 1] = { "Off", "30s", "1m", "5m", "" }; + +} // namespace sosview + +static void onOpenSos(lv_event_t* e) { (void)e; s_ui->showSos(); } +static void onSosButton(lv_event_t* e) { (void)e; s_ui->sosButton(); } +static void onSosSend(lv_event_t* e) { (void)e; s_ui->sosSheet(); } +static void onSosRow(lv_event_t* e) { s_ui->sosRow((int)(uintptr_t)lv_event_get_user_data(e)); } +static void onSosSeg(lv_event_t* e) { + s_ui->sosSet((int)(uintptr_t)lv_event_get_user_data(e), + (int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e))); +} +static void onSosChannel(lv_event_t* e) { + s_ui->sosChannel((int)(uintptr_t)lv_event_get_user_data(e), lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); +} +static void onSosContact(lv_event_t* e) { s_ui->sosContact((int)(intptr_t)lv_event_get_user_data(e)); } +static void onSosTextKb(lv_event_t* e) { s_ui->sosTextDone(lv_event_get_code(e) == LV_EVENT_READY); } + +// ── The view ────────────────────────────────────────────────────────────────── + +bool UITask::sosViewOpen() const { return sosview::s_mode != sosview::NONE; } + +void UITask::sosViewClose() { + using namespace sosview; + if (s_view) lv_obj_delete_async(s_view); // may be closing from its own button + s_view = s_slider = s_title = s_digits = nullptr; + s_line[0] = s_line[1] = s_line[2] = s_line[3] = nullptr; + s_drag = SlideDrag(); + s_mode = NONE; +} + +static lv_obj_t* sosPanel() { + lv_obj_t* v = lv_obj_create(lv_layer_top()); + lv_obj_remove_style_all(v); + lv_obj_set_size(v, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(v, lv_color_hex(theme::BG), 0); + lv_obj_set_style_bg_opa(v, LV_OPA_COVER, 0); + lv_obj_add_flag(v, LV_OBJ_FLAG_CLICKABLE); // swallows every touch but its own + lv_obj_remove_flag(v, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(v, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(v, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_top(v, 14, 0); + lv_obj_set_style_pad_bottom(v, 12, 0); + lv_obj_set_style_pad_hor(v, theme::PAD, 0); + lv_obj_set_style_pad_row(v, 6, 0); + return v; +} +static lv_obj_t* sosText(lv_obj_t* v, const char* text, const lv_font_t* font, uint32_t col) { + lv_obj_t* l = label(v, text, font, col); + lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); + lv_obj_set_width(l, LV_PCT(100)); + lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, 0); + return l; +} +static void sosWideButton(lv_obj_t* parent, const char* text, int32_t w, int32_t h, uint32_t bg, uint32_t col, lv_event_cb_t cb) { + lv_obj_t* b = lv_button_create(parent); + lv_obj_set_size(b, w, h); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_radius(b, theme::RADIUS, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(bg), 0); + lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, NULL); + lv_obj_center(label(b, text, THEME_FONT_BODY, col)); +} +static void sosFootButton(lv_obj_t* v, const char* text) { + sosWideButton(v, text, SLIDE_W, 40, theme::SURFACE_2, theme::TEXT, onSosButton); +} + +// The sheet: what will happen, and the slide that starts it. +void UITask::sosSheet() { + using namespace sosview; + if (!_prefs || !_core->sos.idle()) { sosOpen(); return; } + if (!SosEngine::hasTargets(_prefs)) { showToast("SOS: no targets"); return; } + flashlightClose(); + quickPanelClose(false); + sosViewClose(); + s_view = sosPanel(); + s_mode = SHEET; + s_until = millis() + VIEW_MS; + sosText(s_view, "Emergency SOS", THEME_FONT_LARGE, theme::FAIL); + const int ch = SosEngine::channels(_prefs), dm = SosEngine::contacts(_prefs); + char to[64]; + snprintf(to, sizeof(to), "Sends your position to %d channel%s and %d contact%s", ch, ch == 1 ? "" : "s", dm, dm == 1 ? "" : "s"); + sosText(s_view, to, THEME_FONT_BODY, theme::TEXT_MUTED); + flexFill(s_view); + s_slider = slideBar(s_view, "slide for SOS", theme::FAIL); + sosFootButton(s_view, "Cancel"); +} + +// The countdown, or how it's going. +void UITask::sosLive() { + using namespace sosview; + sosViewClose(); + s_view = sosPanel(); + s_mode = LIVE; + s_counting = _core->sos.counting(); + s_until = millis() + VIEW_MS; + s_next = 0; + s_title = sosText(s_view, "", THEME_FONT_LARGE, theme::FAIL); + if (s_counting) s_digits = sosText(s_view, "", THEME_FONT_CLOCK, theme::TEXT); + else for (int i = 0; i < 4; i++) { + // Name and when, then what they said, for the latest two who answered. + s_line[i] = sosText(s_view, "", THEME_FONT_BODY, i % 2 ? theme::TEXT_MUTED : theme::TEXT); + lv_label_set_long_mode(s_line[i], LV_LABEL_LONG_DOT); + lv_obj_set_height(s_line[i], 18); + } + flexFill(s_view); + lv_obj_set_style_pad_row(s_view, 2, 0); // room for the lines + if (!s_counting) s_slider = slideBar(s_view, _core->sos.acked() ? "slide to end" : "slide to stop", theme::FAIL); + sosFootButton(s_view, s_counting ? "Cancel" : "Hide"); + sosRefresh(); +} + +void UITask::sosRefresh() { + using namespace sosview; + SosEngine& s = _core->sos; + const uint32_t now = millis(); + char buf[48]; + if (s_counting) { + setText(s_title, "SOS in"); + snprintf(buf, sizeof(buf), "%d", s.secsLeft(now)); + setText(s_digits, buf); + return; + } + setText(s_title, s.acked() ? "Answered" : "SOS"); + setTextColor(s_title, s.acked() ? theme::OK : theme::FAIL); + if (s.acked()) { + for (int i = 0; i < 2; i++) { + const bool on = i < s.responders(); + buf[0] = '\0'; + if (on) { + const SosEngine::Responder& r = s.responder(i); + const uint32_t mins = (now - r.at) / 60000UL; + if (mins) snprintf(buf, sizeof(buf), "%s, %u min ago", r.name, (unsigned)mins); + else snprintf(buf, sizeof(buf), "%s, now", r.name); + } + setText(s_line[i * 2], buf); + setText(s_line[i * 2 + 1], on ? s.responder(i).text : ""); + } + return; + } + setText(s_line[3], ""); + s.sentLine(buf, sizeof(buf)); + setText(s_line[0], buf); + s.nextLine(buf, sizeof(buf), now); + setText(s_line[1], buf); + int32_t lat, lon; + setText(s_line[2], _core->course.currentLocation(lat, lon) ? "" : "No GPS fix"); +} + +// A screen that was dark comes on for it. +void UITask::sosOpen() { + if (_core->sos.idle()) return; + if (_asleep) wake(); + lv_display_trigger_activity(NULL); + if (!sosViewOpen() || sosview::s_mode == sosview::LIVE) sosLive(); +} + +// The top button held. +void UITask::sosButtonHeld() { + if (!_core->sos.idle()) { sosOpen(); return; } + if (!_prefs || !_prefs->sos_enabled || sosViewOpen()) return; + if (_asleep) wake(); + lv_display_trigger_activity(NULL); + playMelody(soundctl::MEL_TICK); // felt in a pocket: the hold got through + sosSheet(); +} + +// Cancel / Hide, or either physical button: anything ends the sheet and the +// countdown; after that it only hides the view. +void UITask::sosButton() { + if (_core->sos.counting()) { + _core->stopSos(); + showToast("SOS cancelled"); + } + sosViewClose(); +} + +// The sliders. Driven from the loop like the lock screen's. +void UITask::sosPoll() { + using namespace sosview; + if (!s_slider || !slidePoll(s_slider, s_drag)) return; + if (s_mode == SHEET) { + _core->startSos(); + s_left = 0; + sosLive(); + } else { + _core->stopSos(); + sosViewClose(); + showToast("SOS stopped"); + } +} + +void UITask::sosTick() { + using namespace sosview; + SosEngine& s = _core->sos; + const uint32_t now = millis(); + if (_sensors) _sensors->setGpsKeepAwake(s.gpsWanted()); // past a GPS duty cycle + if (s.beepDue(now)) playMelody(soundctl::MEL_SOS); + if (s.counting()) { + const int left = s.secsLeft(now); + if (left != s_left) { // a beep a second, past the buzzer's mute + s_left = left; + playMelody(soundctl::MEL_TICK); + } + } + if (s_mode == LIVE && s_counting != s.counting()) sosLive(); // the countdown ended + if (s_mode == LIVE && (int32_t)(now - s_next) >= 0) { + s_next = now + 200; + sosRefresh(); + } + if (s_mode != NONE && !s.counting()) { + if (lv_display_get_inactive_time(NULL) < 1000) s_until = now + VIEW_MS; + if ((int32_t)(now - s_until) >= 0) sosViewClose(); + } +} + +// The battery goes to the SOS: WiFi rests until it ends. Its switch is saved, so +// a restart mid-SOS leaves it off. +void UITask::onSosRunning(bool running) { + using namespace sosview; + if (running) { + s_wifi_was = lvport::wifiAllowed(); + if (s_wifi_was) wifiSetAllowed(false); + } else if (s_wifi_was) { + wifiSetAllowed(true); + } +#ifdef PIN_BUZZER + if (_prefs) _buzzer.setVolume(running && _prefs->sos_beep_idx ? 4 : _prefs->buzzer_volume); // to be heard, not to be polite +#endif + refreshStatusBar(); +} + +// ── Home > SOS ──────────────────────────────────────────────────────────────── + +void UITask::showSos() { + _screen = SCR_SOS; + buildSos(); +} + +static lv_obj_t* sosValueRow(lv_obj_t* g, const char* name, const char* hint, const char* val, int row) { + return rowPick(settingRow(g, name, hint), val, 150, onSosRow, (void*)(uintptr_t)row); +} + +void UITask::buildSos() { + using namespace sosview; + lv_obj_t* body = newScreen("SOS", true); + NodePrefs* p = _prefs; + if (!p) return; + lv_obj_t* list = scrollList(body); + lv_obj_t* g = group(list, nullptr); + + switchRow(g, "Top button", "Hold 5 s for the SOS slider", &p->sos_enabled); + lv_obj_t* seg = segmented(settingRow(g, "Countdown", nullptr), COUNTDOWNS, p->sos_countdown_idx, 130, 32); + lv_obj_add_event_cb(seg, onSosSeg, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)P_COUNTDOWN); + seg = segmented(settingRow(g, "Repeat", nullptr), REPEATS, p->sos_repeat_idx, 200, 32); + lv_obj_add_event_cb(seg, onSosSeg, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)P_REPEAT); + seg = segmented(settingRow(g, "Beep", "To be found by"), BEEPS, p->sos_beep_idx, 170, 32); + lv_obj_add_event_cb(seg, onSosSeg, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)P_BEEP); + + char val[40]; + snprintf(val, sizeof(val), "%d on", SosEngine::channels(p)); + s_ch_lbl = sosValueRow(g, "Channels", nullptr, val, R_CHANNELS); + for (int i = 0; i < NodePrefs::SOS_DM_MAX; i++) { + ContactInfo* c = SosEngine::hasDm(p, i) + ? the_mesh.lookupContactByPubKey(p->sos_dm[i], NodePrefs::FAVOURITE_PREFIX_LEN) : nullptr; + char name[12]; + snprintf(name, sizeof(name), "Contact %d", i + 1); + sosValueRow(g, name, nullptr, c ? c->name : "-", R_DM1 + i); + } + sosValueRow(g, "ACK word", "The reply that means received", p->sos_ack[0] ? p->sos_ack : "-", R_ACK); + sosValueRow(g, "Note", "Added to the message", p->sos_note[0] ? p->sos_note : "-", R_NOTE); + + sosWideButton(list, "Send SOS", LV_PCT(100), 44, theme::FAIL, 0xFFFFFF, onSosSend); +} + +void UITask::sosSet(int what, int val) { + using namespace sosview; + if (!_prefs) return; + if (what == P_COUNTDOWN && val >= 0 && val < NodePrefs::SOS_COUNTDOWN_COUNT) _prefs->sos_countdown_idx = (uint8_t)val; + else if (what == P_REPEAT && val >= 0 && val < NodePrefs::SOS_REPEAT_COUNT) _prefs->sos_repeat_idx = (uint8_t)val; + else if (what == P_BEEP && val >= 0 && val < NodePrefs::SOS_BEEP_COUNT) _prefs->sos_beep_idx = (uint8_t)val; + prefsSave(); +} + +void UITask::sosChannel(int idx, bool on) { + using namespace sosview; + if (!_prefs || idx < 0 || idx >= MAX_GROUP_CHANNELS) return; + if (on) _prefs->sos_ch_mask |= 1ULL << idx; + else _prefs->sos_ch_mask &= ~(1ULL << idx); + prefsSave(); + char val[16]; + snprintf(val, sizeof(val), "%d on", SosEngine::channels(_prefs)); + if (s_ch_lbl) lv_label_set_text(s_ch_lbl, val); +} + +void UITask::sosRow(int row) { + using namespace sosview; + NodePrefs* p = _prefs; + if (!p) return; + s_edit = row; + if (row == R_ACK || row == R_NOTE) { + TextEntry te = { row == R_ACK ? "ACK word" : "Note", onSosTextKb }; + te.text = row == R_ACK ? p->sos_ack : p->sos_note; + te.max_bytes = (row == R_ACK ? sizeof(p->sos_ack) : sizeof(p->sos_note)) - 1; + te.hint = row == R_ACK ? "e.g. ACK" : "e.g. blood group"; + navTextEntry(te); + return; + } + lv_obj_t* list = scrollList(navPopupPanel(row == R_CHANNELS ? "SOS channels" : "SOS contact", true)); + if (row == R_CHANNELS) { + lv_obj_t* g = group(list, nullptr); + int n = 0; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails ch; + if (!the_mesh.getChannel(i, ch) || !ch.name[0]) continue; + switchRowIdx(g, ch.name, nullptr, p->sos_ch_mask & (1ULL << i), onSosChannel, i); + n++; + } + if (n == 0) label(list, "No channels.", THEME_FONT_SMALL, theme::TEXT_MUTED); + return; + } + listRow(list, "(none)", NULL, onSosContact, (void*)(intptr_t)-1); + pickContacts(list, false, onSosContact, 0, p->sos_dm[row - R_DM1]); +} + +void UITask::sosContact(int idx) { + using namespace sosview; + if (!_prefs || s_edit < R_DM1 || s_edit > R_DM3 || idx >= devview::PICK_MAX) return; + uint8_t* slot = _prefs->sos_dm[s_edit - R_DM1]; + if (idx < 0) memset(slot, 0, NodePrefs::FAVOURITE_PREFIX_LEN); + else memcpy(slot, devview::s_pick_keys[idx], NodePrefs::FAVOURITE_PREFIX_LEN); + prefsSave(); + navClosePopup(); + buildSos(); +} + +void UITask::sosTextDone(bool ok) { + using namespace sosview; + if (ok && _nav_ta && _prefs && (s_edit == R_ACK || s_edit == R_NOTE)) { + if (s_edit == R_ACK) snprintf(_prefs->sos_ack, sizeof(_prefs->sos_ack), "%s", lv_textarea_get_text(_nav_ta)); + else snprintf(_prefs->sos_note, sizeof(_prefs->sos_note), "%s", lv_textarea_get_text(_nav_ta)); + prefsSave(); + } + navClosePopup(); + if (ok) buildSos(); +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index e05c123a..5b1c92f8 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -122,6 +122,8 @@ static bool s_sim_btn_click = false, s_sim_btn_hold = false, s_sim_wake = false; extern "C" EMSCRIPTEN_KEEPALIVE void sim_lcd_button() { s_sim_btn_click = true; } extern "C" EMSCRIPTEN_KEEPALIVE void sim_lcd_button_hold() { s_sim_btn_hold = true; } extern "C" EMSCRIPTEN_KEEPALIVE void sim_wake_button() { s_sim_wake = true; } +static bool s_sim_wake_hold = false; // the top button held 5 s +extern "C" EMSCRIPTEN_KEEPALIVE void sim_wake_hold() { s_sim_wake_hold = true; } static bool s_sim_shot = false; // the side + top button combo extern "C" EMSCRIPTEN_KEEPALIVE void sim_screenshot() { s_sim_shot = true; } // The speaker, polled every frame by the page's Web Audio oscillator. @@ -211,6 +213,7 @@ void UITask::toggleMute() { } static constexpr uint32_t LOCK_OFF_MS = 30000; // the lock screen's own auto-off cap +static constexpr uint32_t SOS_HOLD_MS = 5000; // the top button held: the SOS slider #include "PerfTest.h" // -D UI_PERF_TEST only: draw profiles and control timing @@ -244,6 +247,7 @@ void UITask::loop() { wake_press = down && !_wake_down; wake_release = !down && _wake_down; _wake_down = down; + if (wake_press) _wake_since_ms = millis(); } #endif // Side button held + top button: a screenshot (and neither does its own thing). @@ -261,13 +265,29 @@ void UITask::loop() { s_sim_shot = false; if (mute_armed) { mute_armed = false; mute = true; } // the page sends the hold on release #endif + // Top button held, alone: SOS. Its release then does nothing more. + bool sos_hold = false; +#if defined(SEEED_WIO_TRACKER_L2) + static bool sos_held = false; + if (wake_release) sos_held = false; + if (_wake_down && !sos_held && !user_btn.isPressed() && millis() - _wake_since_ms >= SOS_HOLD_MS) { + sos_held = true; + sos_hold = true; + } +#elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) + sos_hold = s_sim_wake_hold; + s_sim_wake_hold = false; +#endif + if (sos_hold) { wake_used = true; sosButtonHeld(); } if (wake_press && _core->clock.isRinging()) wake_used = true; const bool wake_now = wake_press && _asleep && !wake_used; if (wake_now) wake_used = true; // its release must not put it back to sleep - const bool sleep_now = wake_release && !wake_used && !_asleep; + const bool sos_press = sosViewOpen() && ((wake_release && !wake_used) || btn_click); // it takes the buttons + const bool sleep_now = wake_release && !wake_used && !_asleep && !sos_press; if (wake_release) wake_used = false; if (shot) { if (!_asleep) takeScreenshot(); } else if ((btn_click || btn_hold || wake_press) && _core->clock.isRinging()) dismissRing(); + else if (sos_press) sosButton(); else if (wake_now) wake(); else if (sleep_now) sleep(); else if (btn_hold && millis() - _ui_started_ms < 8000) { // held in the first 8 s: CLI rescue, as on the L1 @@ -294,11 +314,12 @@ void UITask::loop() { // even with auto-off at Never (a woken-up pocket, a boot PIN, a message). uint32_t aoff = autoOffMillis(); if (locked() && (aoff == 0 || aoff > LOCK_OFF_MS)) aoff = LOCK_OFF_MS; - if (aoff > 0 && lv_display_get_inactive_time(NULL) > aoff && !_core->clock.isRinging() && !otaBusy() && !flashlightOn()) sleep(); + if (aoff > 0 && lv_display_get_inactive_time(NULL) > aoff && !_core->clock.isRinging() && !otaBusy() && !flashlightOn() && !_core->sos.counting()) sleep(); } _core->loop(); drainCoreEvents(); + sosTick(); prefsFlush(); usbPoll(); #if defined(UI_HEAP_REPORT) && defined(ESP32) @@ -346,7 +367,8 @@ void UITask::loop() { refreshBattCurve(); if (quickPanelOpen()) quickPanelRefresh(); } - if (locked()) lockPoll(); + if (sosViewOpen()) sosPoll(); + else if (locked()) lockPoll(); else if (_screen == SCR_HOME) { homeSwipePoll(); homeMapLoop(); } else if (_screen == SCR_GALLERY) galleryTick(); if ((_screen == SCR_NEARBY || _screen == SCR_NODE) && (int32_t)(millis() - _next_nearby_ms) >= 0) { @@ -434,8 +456,7 @@ void UITask::checkLowBattery() { } uint32_t UITask::autoOffMillis() const { - if (!_prefs || _prefs->auto_off_secs == 0) return 0; - return (uint32_t)_prefs->auto_off_secs * 1000UL; + return _core->sos.screenMs(_prefs ? (uint32_t)_prefs->auto_off_secs * 1000UL : 0); } static void bannerDismiss(); // below, with the banner @@ -446,6 +467,7 @@ void UITask::sleep() { quickPanelClose(false); flashlightClose(); bannerDismiss(); + if (sosViewOpen()) sosViewClose(); if (_toast) { // same: the toast's timer won't run while asleep if (_toast_timer) lv_timer_pause(_toast_timer); lv_anim_delete(_toast, NULL); @@ -466,6 +488,7 @@ void UITask::wake() { refreshStatusBar(); if (_screen == SCR_HOME) refreshHome(); if (_screen == SCR_THREAD) { if (locked()) refreshThread(); else reopenThreadIfUnread(); } // locked: on unlocking + if (_core->sos.acked()) sosLive(); // who answered, and what: it mustn't be missed lv_obj_invalidate(lv_screen_active()); lv_refr_now(NULL); lvport::backlightFadeIn(); @@ -480,6 +503,22 @@ void UITask::drainCoreEvents() { case UiEventType::MessageArrived: onMessageArrived(ev); break; + case UiEventType::SosSent: + if (ev.flag) { + sosOpen(); + playMelody(soundctl::MEL_ARRIVE); + } + break; + case UiEventType::SosAcked: + sosOpen(); + playMelody(ev.flag ? soundctl::MEL_ALARM : soundctl::MEL_ARRIVE); + break; + case UiEventType::SosHeard: { + char text[48]; + snprintf(text, sizeof(text), "SOS: %s", ev.text); + showToast(text, 5000); + break; + } case UiEventType::ClockAlert: showRing(ev.text); playMelody(soundctl::MEL_ALARM); @@ -833,8 +872,16 @@ void UITask::refreshStatusBar() { // share, repeater, arrival alert -- in the accent colour. Right to left in // that order, next to the battery. struct Icon { const char* sym; uint32_t col; }; - Icon icons[11]; + Icon icons[12]; int n = 0; + static const char SOS_SYM[] = "SOS"; // text, not in the icon font + const bool sos = !_core->sos.idle(); + const uint32_t sos_col = _core->sos.acked() ? theme::OK : theme::FAIL; // green: someone has answered + if (sos) icons[n++] = { SOS_SYM, sos_col }; + if (_hdr_time) { // a screen with a title bar has no status bar: its clock takes the colour + lv_obj_set_style_bg_color(lv_obj_get_parent(_hdr_time), lv_color_hex(sos ? sos_col : theme::SURFACE), 0); + setTextColor(_hdr_time, sos ? 0xFFFFFF : theme::TEXT); + } if (isBluetoothEnabled()) icons[n++] = { LV_SYMBOL_BLUETOOTH, hasConnection() ? theme::ACCENT : theme::TEXT_MUTED }; if (lvport::wifiAllowed()) // WiFi switched on: accent while connected (map tiles, update) icons[n++] = { LV_SYMBOL_WIFI, lvport::netRadio() == lvport::NET_UP ? theme::ACCENT : theme::TEXT_MUTED }; @@ -860,7 +907,7 @@ void UITask::refreshStatusBar() { strcpy(_status_sig, sig); lv_obj_clean(_status_icons); for (int i = n - 1; i >= 0; i--) // the icon font gives each the same size and cell - label(_status_icons, icons[i].sym, THEME_FONT_ICONS, icons[i].col); + label(_status_icons, icons[i].sym, icons[i].sym == SOS_SYM ? THEME_FONT_BODY : THEME_FONT_ICONS, icons[i].col); } placeLeftOf(_status_icons, left_of, -5); } @@ -1236,7 +1283,7 @@ int UITask::screenDepth(Screen s) { switch (s) { case SCR_HOME: return 0; case SCR_CHATS: case SCR_NEARBY: case SCR_MAP: case SCR_SETTINGS: - case SCR_COMPASS: case SCR_GALLERY: case SCR_CLOCK: case SCR_BOT: case SCR_REPEATER: case SCR_ADMIN_PICK: + case SCR_COMPASS: case SCR_GALLERY: case SCR_CLOCK: case SCR_BOT: case SCR_SOS: case SCR_REPEATER: case SCR_ADMIN_PICK: case SCR_DIAG: case SCR_GPS: return 1; case SCR_THREAD: case SCR_CONTACTS: case SCR_CHANNEL_EDIT: case SCR_NODE: case SCR_SETTINGS_NAV: case SCR_ADMIN: return 2; @@ -1252,6 +1299,7 @@ void UITask::back() { case SCR_CONTACTS: showChats(); break; case SCR_CHANNEL_EDIT: showChats(); break; case SCR_BOT: if (_nav_overlay) navClosePopup(); else showHome(); break; + case SCR_SOS: if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_DIAG: if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_ADMIN_PICK: showHome(); break; case SCR_ROUTE: // back to the node; a trace or discovery in flight is dropped @@ -1346,6 +1394,7 @@ static void onOpenNearby(lv_event_t* e) { (void)e; s_ui->showNearby(); } static void onOpenNodesMap(lv_event_t* e) { (void)e; s_ui->openMap(false); } static void onOpenClock(lv_event_t* e); // ClockScreen.h static void onOpenBot(lv_event_t* e); // BotScreen.h +static void onOpenSos(lv_event_t* e); // SosScreen.h static void onOpenCompass(lv_event_t* e); // CompassScreen.h static void onOpenGallery(lv_event_t* e); // GalleryScreen.h static void onOpenFlashlight(lv_event_t* e); // Flashlight.h @@ -1392,6 +1441,7 @@ static void onOpenSchemaPage(lv_event_t* e); // Radio > Advanced #include "DiagScreen.h" #include "CompassScreen.h" #include "Flashlight.h" +#include "SosScreen.h" #include "BatteryScreen.h" #include "GpsScreen.h" #include "RadioExtras.h" @@ -1452,7 +1502,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) { { "home", &UITask::showHome }, { "chats", &UITask::showChats }, { "settings", &UITask::showSettings }, { "nodes", &UITask::showNearby }, { "ota", &UITask::showOta }, { "wifi", &UITask::showWifi }, { "clock", &UITask::showClock }, { "radio", &UITask::showRadio }, { "scopes", &UITask::showScopes }, - { "repeater", &UITask::showRepeater }, { "bot", &UITask::showBot }, { "storage", &UITask::showStorage }, + { "repeater", &UITask::showRepeater }, { "bot", &UITask::showBot }, { "sos", &UITask::showSos }, { "storage", &UITask::showStorage }, { "diag", &UITask::showDiag }, { "compass", &UITask::showCompass }, { "gallery", &UITask::showGallery }, { "admin", &UITask::showAdminPick }, { "quick", &UITask::showQuickMsgs }, { "contacts", &UITask::showContacts }, }; diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index a6bf2b56..2f532fca 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -72,6 +72,7 @@ public: void onViewedHistoryGrew(bool channel) override { (void)channel; _thread_dirty = true; } void botSetGPS(bool on) override; void applySoundPrefs() override; + void onSosRunning(bool running) override; bool cliCommand(const char* command, char* reply, int n) override; // ── Navigation (called from LVGL event callbacks) ──────────────────────── @@ -315,6 +316,15 @@ public: void botToggle(int row, bool on); void botCmd(int id, bool on); void botGap(int idx); + void showSos(); // SosScreen.h + void sosSheet(); // the slider that starts one + void sosOpen(); // the running one's view + void sosButton(); // Cancel / Hide, or a physical button + void sosRow(int row); + void sosSet(int what, int val); + void sosChannel(int idx, bool on); + void sosContact(int idx); + void sosTextDone(bool ok); void botPick(int idx); void botHour(uint8_t act); void botTextDone(bool ok); @@ -403,7 +413,7 @@ public: void statusBarSync(); // up or hidden, by the screen's title bar (UITask.cpp) private: - enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS, SCR_BATT, SCR_ROUTE, SCR_ROUTE_PICK, SCR_GALLERY }; + enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS, SCR_BATT, SCR_ROUTE, SCR_ROUTE_PICK, SCR_GALLERY, SCR_SOS }; void buildStatusBar(); void refreshStatusBar(); @@ -540,6 +550,14 @@ private: void lockScreen(); void refreshLock(); void lockPoll(); + bool sosViewOpen() const; + void sosViewClose(); + void sosLive(); + void sosRefresh(); + void sosButtonHeld(); + void sosPoll(); + void sosTick(); + void buildSos(); void favGrid(lv_obj_t* grid); void favRefresh(); int unreadTotal(); // DMs, channels and rooms @@ -571,6 +589,7 @@ private: uint32_t _next_wake_poll_ms = 0; uint32_t _next_touch_poll_ms = 0; // tap to wake, while the screen is off bool _wake_down = false; + uint32_t _wake_since_ms = 0; // when the top button went down uint32_t autoOffMillis() const; uint32_t idleMillis(uint32_t lv_next); bool cpuNeeded(); diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index 79aa19c1..678f9c11 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -81,6 +81,7 @@ class MessagesScreen : public UIScreen { char _pin_slot_labels[NodePrefs::FAVOURITES_COUNT][22]; // per-slot picker labels bool _pin_picker_active; // true while the slot-picker submenu is open int _pick_fav_slot = -1; // >=0 = browsing to fill that Favourites dial slot + int _pick_sos_slot = -1; // >=0 = choosing the contact for that SOS slot int _pin_slot_ch_idx = -1; // >=0 = that submenu is pinning this channel, not a contact bool _ch_delete_confirm_active = false; // true while the Delete/Cancel submenu is open bool _direct_entry; // entered a history view straight from the Favourites dial; @@ -1311,6 +1312,7 @@ public: _scope_pick_active = false; _ch_delete_confirm_active = false; _pick_fav_slot = -1; + _pick_sos_slot = -1; _direct_entry = false; _unread_at_entry = 0; _new_idx = -1; @@ -1421,6 +1423,24 @@ public: _task->gotoBotScreen(); } + // Tools > SOS: a contact to call for help, straight to the Direct list. + void startPickSos(int slot) { + reset(); + _pick_sos_slot = slot; + _contact_sel = _contact_scroll = 0; + _phase = CONTACT_PICK; + } + + void commitPickSos(const ContactInfo& ci) { + NodePrefs* p = _task->getNodePrefs(); + if (p) { + memcpy(p->sos_dm[_pick_sos_slot], ci.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN); + the_mesh.savePrefs(); + } + _pick_sos_slot = -1; + _task->gotoSosScreen(); + } + void commitPickTargetDM(const ContactInfo& ci) { // A room server can't be a live-share target — a [LOC] DM to it is never // reposted to the room's members. Reject the pick and keep the chooser open. @@ -2235,6 +2255,7 @@ public: } if (c == KEY_CANCEL) { if (_pick_bot_room) { _pick_bot_room = false; _room_mode = false; _task->gotoBotScreen(); return true; } + if (_pick_sos_slot >= 0) { _pick_sos_slot = -1; _task->gotoSosScreen(); return true; } _room_mode = false; _phase = MODE_SELECT; return true; @@ -2246,6 +2267,7 @@ public: if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { if (_pick_target) { commitPickTargetDM(_sel_contact); return true; } if (_pick_fav_slot >= 0) { commitPickFavContact(_sel_contact); return true; } + if (_pick_sos_slot >= 0) { commitPickSos(_sel_contact); return true; } if (_pick_bot_room) { if (!isRoomLoggedIn(_sel_contact.id.pub_key)) { // Known password, just not re-established this boot: the Core diff --git a/examples/companion_radio/ui-new/SosScreen.h b/examples/companion_radio/ui-new/SosScreen.h new file mode 100644 index 00000000..703be480 --- /dev/null +++ b/examples/companion_radio/ui-new/SosScreen.h @@ -0,0 +1,259 @@ +#pragma once +// Tools › SOS: what happens on a call for help, and the status while one runs. +// The status (renderStatus) isn't this screen's list: UITask draws it over +// whatever is showing, on a locked device too. Engine: ui-core/SosEngine.h. +// Included by UITask.cpp after LiveShareScreen.h. + +#include "InfoKit.h" +#include "../NodePrefs.h" +#include "icons.h" // drawList (shared scrolling-list helper) + +class SosScreen : public UIScreen { + UITask* _task; + NodePrefs* _prefs; + KeyboardWidget* _kb; + bool _dirty = false; + int _sel = 0; + int _scroll = 0; + int _kb_row = -1; // >=0: the keyboard is open on that row + + bool _ch_list = false; // the Channels list instead of the rows + int _ch_sel = 0, _ch_scroll = 0, _ch_count = 0; + uint8_t _ch_idx[MAX_GROUP_CHANNELS]; + + enum Kind : uint8_t { K_HOLD, K_COUNTDOWN, K_REPEAT, K_BEEP, K_CHANNELS, K_DM1, K_DM2, K_DM3, K_ACK, K_NOTE, K_START }; + struct Row { Kind kind; const char* label; }; + static const int ROW_COUNT = 11; + static Row rows(int i) { + static const Row R[ROW_COUNT] = { + { K_HOLD, "Hold Back" }, + { K_COUNTDOWN, "Countdown" }, + { K_REPEAT, "Repeat" }, + { K_BEEP, "Beep" }, + { K_CHANNELS, "Channels" }, + { K_DM1, "Contact 1" }, + { K_DM2, "Contact 2" }, + { K_DM3, "Contact 3" }, + { K_ACK, "ACK word" }, + { K_NOTE, "Note" }, + { K_START, "Send SOS" }, + }; + return R[i]; + } + + static void step(uint8_t& idx, int count, int dir) { + idx = (uint8_t)((idx + (dir >= 0 ? 1 : count - 1)) % count); + } + + void valueLabel(Kind k, char* buf, int n) { + switch (k) { + case K_HOLD: snprintf(buf, n, "%s", _prefs->sos_enabled ? "ON" : "OFF"); break; + case K_COUNTDOWN: snprintf(buf, n, "%u s", (unsigned)NodePrefs::sosCountdownSecs(_prefs->sos_countdown_idx)); break; + case K_REPEAT: { + unsigned m = NodePrefs::sosRepeatMins(_prefs->sos_repeat_idx); + if (m) snprintf(buf, n, "%u min", m); + else snprintf(buf, n, "Off"); + break; + } + case K_BEEP: { + unsigned s = NodePrefs::sosBeepSecs(_prefs->sos_beep_idx); + if (!s) snprintf(buf, n, "Off"); + else if (s < 60) snprintf(buf, n, "%u s", s); + else snprintf(buf, n, "%u min", s / 60); + break; + } + case K_CHANNELS: snprintf(buf, n, "%d on", SosEngine::channels(_prefs)); break; + case K_DM1: case K_DM2: case K_DM3: { + int slot = k - K_DM1; + ContactInfo* c = SosEngine::hasDm(_prefs, slot) + ? the_mesh.lookupContactByPubKey(_prefs->sos_dm[slot], NodePrefs::FAVOURITE_PREFIX_LEN) : nullptr; + snprintf(buf, n, "%s", c ? c->name : "-"); + break; + } + case K_ACK: snprintf(buf, n, "%s", _prefs->sos_ack[0] ? _prefs->sos_ack : "-"); break; + case K_NOTE: snprintf(buf, n, "%s", _prefs->sos_note[0] ? _prefs->sos_note : "-"); break; + case K_START: snprintf(buf, n, "%s", _task->core().sos.idle() ? "" : "Running"); break; + } + } + + void openChannels() { + _ch_count = 0; + ChannelDetails ch; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) + if (the_mesh.getChannel(i, ch) && ch.name[0]) _ch_idx[_ch_count++] = (uint8_t)i; + _ch_sel = _ch_scroll = 0; + _ch_list = true; + } + + int renderChannels(DisplayDriver& display) { + drawScreenHeader(display, "SOS channels"); + int mq_delay = 0; + drawList(display, _ch_count, _ch_sel, _ch_scroll, [&](int i, int y, bool sel, int reserve) { + drawRowSelection(display, y, sel, reserve); + ChannelDetails ch; + char name[32] = ""; + if (the_mesh.getChannel(_ch_idx[i], ch)) display.translateUTF8ToBlocks(name, ch.name, sizeof(name)); + bool on = _prefs->sos_ch_mask & (1ULL << _ch_idx[i]); + int mqr = info::listRow(display, y, name, on ? "ON" : "OFF", sel, reserve); + if (mqr > 0) mq_delay = mqr; + }); + return (mq_delay > 0 && mq_delay < 500) ? mq_delay : 500; + } + + bool channelsInput(char c) { + if (c == KEY_CANCEL) { _ch_list = false; return true; } + if (_ch_count == 0) return true; + if (c == KEY_UP) { _ch_sel = (_ch_sel + _ch_count - 1) % _ch_count; return true; } + if (c == KEY_DOWN) { _ch_sel = (_ch_sel + 1) % _ch_count; return true; } + if (c == KEY_ENTER || keyIsPrev(c) || keyIsNext(c)) { + _prefs->sos_ch_mask ^= 1ULL << _ch_idx[_ch_sel]; + _dirty = true; + return true; + } + return false; + } + + // Enter: LEFT/RIGHT only step the values, or empty a contact. + void activate(int dir, bool enter) { + Kind k = rows(_sel).kind; + switch (k) { + case K_HOLD: + _prefs->sos_enabled ^= 1; + _dirty = true; + if (_prefs->sos_enabled && !SosEngine::hasTargets(_prefs)) _task->showAlert("Choose who it goes to", 1500); + break; + case K_COUNTDOWN: + step(_prefs->sos_countdown_idx, NodePrefs::SOS_COUNTDOWN_COUNT, dir); + _dirty = true; break; + case K_REPEAT: + step(_prefs->sos_repeat_idx, NodePrefs::SOS_REPEAT_COUNT, dir); + _dirty = true; break; + case K_BEEP: + step(_prefs->sos_beep_idx, NodePrefs::SOS_BEEP_COUNT, dir); + _dirty = true; break; + case K_CHANNELS: + if (enter) openChannels(); + break; + case K_DM1: case K_DM2: case K_DM3: + if (enter) { _task->savePrefsIfDirty(_dirty); _task->pickSosContact(k - K_DM1); } + else { memset(_prefs->sos_dm[k - K_DM1], 0, NodePrefs::FAVOURITE_PREFIX_LEN); _dirty = true; } + break; + case K_ACK: case K_NOTE: + if (enter) { + bool ack = k == K_ACK; + _kb_row = _sel; + _kb->begin(ack ? _prefs->sos_ack : _prefs->sos_note, (ack ? sizeof(_prefs->sos_ack) : sizeof(_prefs->sos_note)) - 1); + _kb->prompt = ack ? "ACK word" : "Note"; + _kb->clearPlaceholders(); // sent as it is + } + break; + case K_START: + if (enter) { _task->savePrefsIfDirty(_dirty); _task->startSos(); } + break; + } + } + +public: + SosScreen(UITask* task, NodePrefs* prefs, KeyboardWidget* kb) : _task(task), _prefs(prefs), _kb(kb) {} + + void onShow() override { _dirty = false; _kb_row = -1; _ch_list = false; } // the row stays: the contact list comes back here + + int render(DisplayDriver& display) override { + if (_kb_row >= 0) return _kb->render(display); + display.setTextSize(1); + display.setColor(UIColor::primary_txt); + if (_ch_list) return renderChannels(display); + drawScreenHeader(display, "SOS"); + int mq_delay = 0; + drawList(display, ROW_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { + Row r = rows(i); + drawRowSelection(display, y, sel, reserve); + char val[64], shown[64]; + valueLabel(r.kind, val, sizeof(val)); + display.translateUTF8ToBlocks(shown, val, sizeof(shown)); + int mqr = info::listRow(display, y, r.label, shown, sel, reserve); + if (mqr > 0) mq_delay = mqr; + }); + return (mq_delay > 0 && mq_delay < 500) ? mq_delay : 500; + } + + bool handleInput(char c) override { + if (_kb_row >= 0) { + auto res = _kb->handleInput(c); + if (res == KeyboardWidget::DONE) { + bool ack = rows(_kb_row).kind == K_ACK; + char* dst = ack ? _prefs->sos_ack : _prefs->sos_note; + snprintf(dst, ack ? sizeof(_prefs->sos_ack) : sizeof(_prefs->sos_note), "%s", _kb->buf); + _dirty = true; + _kb_row = -1; + } else if (res == KeyboardWidget::CANCELLED) { + _kb_row = -1; + } + return true; + } + if (_ch_list) return channelsInput(c); + if (c == KEY_CANCEL) { + _task->savePrefsIfDirty(_dirty); + _sel = _scroll = 0; + _task->leaveTool(); + return true; + } + if (c == KEY_UP) { _sel = (_sel + ROW_COUNT - 1) % ROW_COUNT; return true; } + if (c == KEY_DOWN) { _sel = (_sel + 1) % ROW_COUNT; return true; } + if (keyIsPrev(c)) { activate(-1, false); return true; } + if (keyIsNext(c)) { activate(+1, false); return true; } + if (c == KEY_ENTER) { activate(+1, true); return true; } + return false; + } + + // The countdown, then what has been sent and who answered. Returns the delay + // to the next frame. + int renderStatus(DisplayDriver& d) { + SosEngine& s = _task->core().sos; + const uint32_t now = millis(); + const int cx = d.width() / 2; + const int foot = d.height() - d.getLineHeight() - 1; + char buf[40]; + d.setTextSize(1); + d.setColor(UIColor::primary_txt); + drawScreenHeader(d, s.counting() ? "SOS in" : s.acked() ? "Answered" : "SOS"); + + if (s.counting()) { + snprintf(buf, sizeof(buf), "%d", s.secsLeft(now)); + d.setTextSize(2); + d.drawTextCentered(cx, d.listStart() + 4, buf); + d.setTextSize(1); + d.drawTextCentered(cx, foot, "Any key: cancel"); + return 250; + } + + int y = d.listStart(); + if (s.acked()) { + const SosEngine::Responder& r = s.responder(0); + char line[64]; + const uint32_t mins = (now - r.at) / 60000UL; + if (s.responders() > 1) snprintf(line, sizeof(line), "%s +%d", r.name, s.responders() - 1); + else snprintf(line, sizeof(line), "%s", r.name); + d.translateUTF8ToBlocks(buf, line, sizeof(buf)); + d.drawTextEllipsized(2, y, d.width() - 4, buf, false); + y += d.lineStep(); + if (r.text[0]) { + d.translateUTF8ToBlocks(buf, r.text, sizeof(buf)); + d.drawTextEllipsized(2, y, d.width() - 4, buf, false); + y += d.lineStep(); + } + if (mins) { snprintf(buf, sizeof(buf), "%u min ago", (unsigned)mins); d.drawTextCentered(cx, y, buf); } + d.drawTextCentered(cx, foot, "Hold Enter: end"); + return 1000; + } + s.sentLine(buf, sizeof(buf)); + d.drawTextCentered(cx, y, buf); + y += d.lineStep(); + s.nextLine(buf, sizeof(buf), now); + d.drawTextCentered(cx, y, buf); + int32_t lat, lon; + if (!_task->currentLocation(lat, lon)) d.drawTextCentered(cx, y + d.lineStep(), "No GPS fix"); + d.drawTextCentered(cx, foot, "Hold Enter: stop"); + return 1000; + } +}; diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index 56ad8cae..ce378a63 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -13,7 +13,7 @@ class ToolsScreen : public UIScreen { public: enum Action { ACT_NEARBY, ACT_LIVESHARE, ACT_TRAIL, ACT_LOCATOR, ACT_COMPASS, - ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER, ACT_ADMIN, + ACT_SOS, ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER, ACT_ADMIN, ACT_CLOCK, ACT_RINGTONE, ACT_FLASHLIGHT #if defined(PIN_GPIO1) , ACT_GPIO @@ -45,6 +45,7 @@ public: case ACT_TRAIL: task->gotoTrailScreen(); break; case ACT_LOCATOR: task->gotoLocatorScreen(); break; case ACT_COMPASS: task->gotoCompassScreen(); break; + case ACT_SOS: task->gotoSosScreen(); break; case ACT_BOT: task->gotoBotScreen(); break; case ACT_AUTOADVERT: task->gotoAutoAdvertScreen(); break; case ACT_REPEATER: task->gotoRepeaterScreen(); break; @@ -128,6 +129,7 @@ const ToolsScreen::Tool ToolsScreen::LOCATION_TOOLS[] = { { "Compass", ACT_COMPASS }, }; const ToolsScreen::Tool ToolsScreen::COMMS_TOOLS[] = { + { "SOS", ACT_SOS }, { "Remote Bot", ACT_BOT }, { "Auto-Advert", ACT_AUTOADVERT }, { "Repeater", ACT_REPEATER }, @@ -145,6 +147,6 @@ const ToolsScreen::Tool ToolsScreen::SYSTEM_TOOLS[] = { }; const ToolsScreen::Section ToolsScreen::SECTIONS[] = { { "Location", LOCATION_TOOLS, (uint8_t)(sizeof(LOCATION_TOOLS)/sizeof(LOCATION_TOOLS[0])) }, - { "Comms", COMMS_TOOLS, 4 }, + { "Comms", COMMS_TOOLS, (uint8_t)(sizeof(COMMS_TOOLS)/sizeof(COMMS_TOOLS[0])) }, { "System", SYSTEM_TOOLS, (uint8_t)(sizeof(SYSTEM_TOOLS)/sizeof(SYSTEM_TOOLS[0])) }, }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 0135fcdc..762468c4 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -100,6 +100,7 @@ static const telemetry::Style L1_INFO = { "\xc2\xb0", true, false, 5 }; #include "AutoAdvertScreen.h" #include "BatteryCurveScreen.h" #include "LiveShareScreen.h" +#include "SosScreen.h" #include "LocatorScreen.h" #include "TrailScreen.h" #include "CompassScreen.h" @@ -229,6 +230,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no auto_advert_screen = new AutoAdvertScreen(this, node_prefs); batt_curve_screen = new BatteryCurveScreen(this, node_prefs); live_share_screen = new LiveShareScreen(this, node_prefs); + sos_screen = new SosScreen(this, node_prefs, &_kb); locator_screen = new LocatorScreen(this, node_prefs); trail_screen = new TrailScreen(this, &_core->trail.store()); compass_screen = new CompassScreen(this); @@ -292,6 +294,7 @@ void UITask::gotoGpioScreen() { #endif } void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); } +void UITask::gotoSosScreen() { setCurrScreen(sos_screen); } // ── Clock tools (engine in ui-core/ClockEngine.h) ─────────────────────────── // The melody (soundctl::MEL_ALARM) overrides mute (playMelody → @@ -321,6 +324,77 @@ void UITask::tickCore() { drainCoreEvents(); // Repeat the ring melody until dismissed or the ring window elapses. if (_core->clock.isRinging() && !isMelodyPlaying()) playMelody(soundctl::MEL_ALARM); + tickSos(); +} + +// ── SOS (engine in ui-core/SosEngine.h) ───────────────────────────────────── +static const uint32_t SOS_HOLD_MS = 3000; // Back held; the lock sequence is shorter (3 s for three presses) and uses Enter +static const uint32_t SOS_VIEW_MS = 30000; // the status stays this long, then the display goes back to its usual rules + +void UITask::openSosView(uint32_t ms) { + if (_display) _display->turnOn(); + if (_unlock_kb) cancelUnlockPrompt(); + _sos_view_until = (millis() + ms) | 1; + _next_refresh = 0; +} + +void UITask::startSos() { + if (!_core->sos.idle()) { openSosView(SOS_VIEW_MS); return; } + if (!SosEngine::hasTargets(_node_prefs)) { + if (_display) _display->turnOn(); + if (_locked) _lock_wake_until = millis() + 2000; + showAlert("SOS: no targets", 2000); + _next_refresh = 0; + return; + } + _core->startSos(); + _sos_left = 0; + openSosView(SOS_VIEW_MS); +} + +void UITask::onBackHeld() { + if (!_core->sos.idle() || (_node_prefs && _node_prefs->sos_enabled)) startSos(); +} + +// Countdown: any key cancels it. After it, Back only hides the status: stopping +// takes a hold, so a pocket can't end a call for help. +void UITask::sosKey(char c) { + if (_core->sos.counting() || c == KEY_CONTEXT_MENU) { + showAlert(_core->sos.counting() ? "SOS cancelled" : "SOS stopped", 2000); + if (_locked) _lock_wake_until = millis() + 2000; + _core->stopSos(); + closeSosView(); + } else if (c == KEY_CANCEL) { + closeSosView(); + } else { + openSosView(SOS_VIEW_MS); + } +} + +uint32_t UITask::autoOffMillis() const { + return _core->sos.screenMs(_node_prefs ? (uint32_t)_node_prefs->auto_off_secs * 1000UL : 0); +} + +void UITask::onSosRunning(bool running) { +#ifdef PIN_BUZZER + if (!_node_prefs) return; + buzzer.setVolume(running && _node_prefs->sos_beep_idx ? 4 : _node_prefs->buzzer_volume); // to be heard, not to be polite +#endif +} + +void UITask::tickSos() { + SosEngine& s = _core->sos; + if (s.beepDue(millis())) playMelody(soundctl::MEL_SOS); + if (s.counting()) { + int left = s.secsLeft(millis()); + if (left != _sos_left) { // a beep a second, past the buzzer's mute + _sos_left = left; + playMelody(soundctl::MEL_TICK); + _next_refresh = 0; + } + } else if (sosViewOpen() && (int32_t)(millis() - _sos_view_until) >= 0) { + closeSosView(); + } } void UITask::drainCoreEvents() { @@ -353,6 +427,21 @@ void UITask::drainCoreEvents() { case UiEventType::AdvertHeard: notify(ev.flag ? UIEventType::advertReceivedFlood : UIEventType::advertReceivedZeroHop); break; + case UiEventType::SosSent: + if (ev.flag) { openSosView(SOS_VIEW_MS); playMelody(soundctl::MEL_ARRIVE); } + _next_refresh = 0; + break; + case UiEventType::SosHeard: { + char text[48]; + snprintf(text, sizeof(text), "SOS: %s", ev.text); + showAlert(text, 5000); + _next_refresh = 0; + break; + } + case UiEventType::SosAcked: + openSosView(SOS_VIEW_MS); + playMelody(ev.flag ? soundctl::MEL_ALARM : soundctl::MEL_ARRIVE); + break; default: break; } @@ -529,6 +618,11 @@ void UITask::pickLocShareTarget() { setCurrScreen(messages_screen); } +void UITask::pickSosContact(int slot) { + ((MessagesScreen*)messages_screen)->startPickSos(slot); + setCurrScreen(messages_screen); +} + void UITask::pickFavouriteTarget(int slot) { ((MessagesScreen*)messages_screen)->startPickFavourite(slot); setCurrScreen(messages_screen); @@ -769,6 +863,22 @@ void UITask::renderMsgBadge() { if (msgBadgeLit()) drawMailCount(d, x, 0, h, _alert_count); } +// An SOS running behind whatever is showing: repeats go out and the GPS stays on +// until it's ended, so it mustn't be forgotten. In the header: at its left, clear +// of the centred title, and on Home in the middle, between the clock and the icons. +void UITask::renderSosBadge() { + DisplayDriver& d = *_display; + d.setTextSize(1); + const int pad = d.pixelScale(), w = d.getTextWidth("SOS") + 2 * pad, h = d.getLineHeight(); // Home has its page icons under the bar + const int x = (curr == home || _locked) ? (d.width() - w) / 2 : 0; + d.setColor(UIColor::primary_txt); + d.fillRect(x, 0, w, h); + d.setColor(UIColor::window_bkg); + d.setCursor(x + pad, 0); + d.print("SOS"); + d.setColor(UIColor::primary_txt); +} + void UITask::setCurrScreen(UIScreen* c) { // Fail safe on a null target: a screen pointer left uninitialised (member // declared + navigator wired, but the `new XScreen()` line forgotten in @@ -1346,6 +1456,16 @@ void UITask::loop() { if (_lock_seq_used && millis() - _lock_seq_ms > 5000) { _lock_seq_used = false; // safety reset if Back release event was missed } + // Back held on its own: past the button's long press, so no click comes of it. + // Enter during the hold is the lock sequence, not this. + if (!back_btn.isPressed() || user_btn.isPressed() || _lock_seq_count > 0 || _lock_seq_used) { + _back_held_at = back_btn.isPressed() ? 1 : 0; // 1: spoken for until released + } else if (_back_held_at == 0) { + _back_held_at = millis() | 2; + } else if (_back_held_at != 1 && millis() - _back_held_at >= SOS_HOLD_MS) { + _back_held_at = 1; + onBackHeld(); + } ev = back_btn.check(); if (ev == BUTTON_EVENT_CLICK) { if (_lock_seq_count > 0 || _lock_seq_used) { @@ -1512,6 +1632,15 @@ void UITask::loop() { if (_locked) _lock_wake_until = millis() + 5000; } + // Over everything else, locked or not. + if (_kq_head != _kq_tail && sosViewOpen()) { + char k; + while (sosViewOpen() && dequeueKey(k)) sosKey(k); + _kq_head = _kq_tail = 0; + { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } + _next_refresh = 0; + } + if (_kq_head != _kq_tail) { if (_unlock_kb) { // Lock-screen password keyboard: Consume every key press @@ -1558,7 +1687,14 @@ void UITask::loop() { cancelUnlockPrompt(); // Cancel unlock attempt on idle _next_refresh = 0; } - if (_locked && !_unlock_kb && (int32_t)(millis() - _lock_wake_until) >= 0) { + if (sosViewOpen()) { + if (refreshDue()) { + _display->startFrame(); + _badge_in_bar = false; + _next_refresh = millis() + ((SosScreen*)sos_screen)->renderStatus(*_display); + _display->endFrame(); + } + } else if (_locked && !_unlock_kb && (int32_t)(millis() - _lock_wake_until) >= 0) { _display->turnOff(); } else if (_locked && _unlock_kb && refreshDue()) { // While the prompt is up the password keyboard replaces the lockscreen view @@ -1583,6 +1719,7 @@ void UITask::loop() { } else { home->render(*_display); _next_refresh = millis() + Features::LOCKSCREEN_REFRESH_MS; + if (!_core->sos.idle()) renderSosBadge(); } // Alert overlay on top — without this a ringing alarm on a locked device // played its melody against a screen that never said what was ringing. @@ -1600,6 +1737,7 @@ void UITask::loop() { _kb.beginFrame(); int delay_millis = curr->render(*_display); if (tabbar::endFrame(*_display) && delay_millis > 15) delay_millis = 15; // a tab turn sliding + if (!_core->sos.idle() && !_kb.isVisible()) renderSosBadge(); // Skip the alert overlay (new-message toast) while the keyboard is the // thing actually on screen this frame -- it's shared across Messages/ // Bot/Settings/Admin/etc., so this covers every screen that uses it for @@ -1637,7 +1775,7 @@ void UITask::loop() { uint32_t aoff = autoOffMillis(); _auto_off = millis() + (aoff > 0 ? aoff : AUTO_OFF_MILLIS); } - if (!_locked && autoOffMillis() > 0 && (int32_t)(millis() - _auto_off) >= 0 && !isRinging()) { + if (!_locked && autoOffMillis() > 0 && (int32_t)(millis() - _auto_off) >= 0 && !isRinging() && !sosViewOpen()) { _display->turnOff(); #ifdef PIN_LED digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power @@ -1715,6 +1853,7 @@ void UITask::loop() { bool gps_needed_live = _core->trail.isRecording() || (_node_prefs && _node_prefs->loc_share_enabled) + || _core->sos.gpsWanted() || (_node_prefs && _node_prefs->locator_enabled && _node_prefs->locator_has_target) || curr == compass_screen || (curr == status_screen && ((StatusScreen*)status_screen)->wantsLiveGps()) diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index cb616d29..083c3a4a 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -131,6 +131,7 @@ class UITask : public UITaskBase, public UiCoreHost { UIScreen* auto_advert_screen = nullptr; UIScreen* batt_curve_screen = nullptr; UIScreen* live_share_screen = nullptr; + UIScreen* sos_screen = nullptr; UIScreen* locator_screen = nullptr; UIScreen* trail_screen = nullptr; UIScreen* compass_screen = nullptr; @@ -148,6 +149,15 @@ class UITask : public UITaskBase, public UiCoreHost { void tickCore(); void drainCoreEvents(); void onMessageArrived(const UiEvent& ev); // alert + wake + sound for an incoming message + void openSosView(uint32_t ms); + void closeSosView() { _sos_view_until = 0; _next_refresh = 0; } + bool sosViewOpen() const { return _sos_view_until != 0; } + void sosKey(char c); + void renderSosBadge(); + void tickSos(); + unsigned long _sos_view_until = 0; // 0 = closed + int _sos_left = 0; // countdown second last beeped + unsigned long _back_held_at = 0; // 0 = not held, or already acted on @@ -404,6 +414,12 @@ public: void gotoGpioScreen(); // no-op on boards without user GPIO pins (see PIN_GPIO1) void gotoClockTools(); // Alarm / Timer / Stopwatch (from the home Clock page) void gotoFlashlight(); + void gotoSosScreen(); + void pickSosContact(int slot); // open the Direct list for an SOS slot + // SOS: the full-screen status over everything, also on a locked device. Back + // held starts the SOS, or brings the status back while one is running. + void startSos(); + void onBackHeld(); // Wake the display for an alarm/timer ring (force an immediate refresh). void wakeForAlarm(); // Clear any active alert overlay early (alarm dismiss). @@ -552,6 +568,7 @@ public: // Settings changed through the schema (ui-core/SettingsSchema.h). void applyDisplayPrefs() override { applyBrightness(); applyUiScale(); _next_refresh = 0; } void applySoundPrefs() override; + void onSosRunning(bool running) override; bool cliCommand(const char* command, char* reply, int n) override; void setBuzzerVolumeLevel(uint8_t level); void applyTxPower(); @@ -566,10 +583,7 @@ public: void applyRotation(); void applyFullRefreshInterval(); void applyUiScale(); // Settings > Display > Text size / Icon size, from the prefs - uint32_t autoOffMillis() const { - if (!_node_prefs || _node_prefs->auto_off_secs == 0) return 0; - return (uint32_t)_node_prefs->auto_off_secs * 1000UL; - } + uint32_t autoOffMillis() const; // from UITaskBase 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 f636d0b3..112ae199 100644 --- a/test/test_companion_prefs_json/test_companion_prefs_json.cpp +++ b/test/test_companion_prefs_json/test_companion_prefs_json.cpp @@ -62,6 +62,8 @@ void fill(NodePrefs& p) { text(p.bot_trigger_room, sizeof(p.bot_trigger_room)); 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)); 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 new file mode 100644 index 00000000..0f6ea7ca --- /dev/null +++ b/test/test_sos_engine/test_sos_engine.cpp @@ -0,0 +1,416 @@ +#include + +#include +#include + +// The timing and matching of an SOS (ui-core/SosEngine.h): no radio, the test +// plays the caller's part and says when a round went out. +#include "../../examples/companion_radio/NodePrefs.h" +#include "../../examples/companion_radio/ui-core/SosEngine.h" +#include "../../examples/companion_radio/GeoUtils.h" +#include "../../examples/companion_radio/LiveTrack.h" + +namespace { + +class Sos : public ::testing::Test { +protected: + NodePrefs prefs; + SosEngine sos; + uint32_t now = 1000; + + void SetUp() override { + prefs.sos_countdown_idx = 1; // 10 s + prefs.sos_repeat_idx = 3; // 5 min + strcpy(prefs.sos_ack, "ACK"); + sos.begin(&prefs); + } + // Past the countdown, with the first round out. + void activate(bool fix = true, bool ok = true) { + sos.start(now); + now += 10000; + ASSERT_TRUE(sos.loop(now, fix)); + sos.markSent(now, fix, ok); + } +}; + +TEST_F(Sos, CountsDownWholeSecondsThenSends) { + sos.start(now); + EXPECT_TRUE(sos.counting()); + EXPECT_EQ(sos.secsLeft(now), 10); + EXPECT_EQ(sos.secsLeft(now + 1), 10); + EXPECT_EQ(sos.secsLeft(now + 9001), 1); + EXPECT_FALSE(sos.loop(now + 9999, true)); + EXPECT_TRUE(sos.loop(now + 10000, true)); + EXPECT_FALSE(sos.counting()); +} + +TEST_F(Sos, CancelledCountdownSendsNothing) { + sos.start(now); + sos.stop(); + EXPECT_TRUE(sos.idle()); + EXPECT_FALSE(sos.loop(now + 20000, true)); +} + +TEST_F(Sos, RepeatsAtTheIntervalUpToTheLimit) { + activate(); + EXPECT_FALSE(sos.loop(now + 299999, true)); + EXPECT_EQ(sos.nextSecs(now), 300); + int rounds = 1; + for (int i = 0; i < 40; i++) { + now += 300000; + if (sos.loop(now, true)) { sos.markSent(now, true, true); rounds++; } + } + EXPECT_EQ(rounds, (int)SosEngine::MAX_SENDS); + EXPECT_EQ(sos.nextSecs(now), -1); +} + +TEST_F(Sos, RepeatOffSendsOnce) { + prefs.sos_repeat_idx = 0; + activate(); + EXPECT_FALSE(sos.loop(now + 3600000UL, true)); + EXPECT_EQ(sos.nextSecs(now), -1); +} + +TEST_F(Sos, FirstFixSendsOnceMoreEvenWithRepeatOff) { + prefs.sos_repeat_idx = 0; + activate(false); + EXPECT_FALSE(sos.loop(now + 60000, false)); + now += 60000; + EXPECT_TRUE(sos.loop(now, true)); + sos.markSent(now, true, true); + EXPECT_FALSE(sos.loop(now + 1, true)); +} + +TEST_F(Sos, NothingWentOutRetriesSoonWithoutSpinning) { + activate(true, false); + EXPECT_EQ(sos.sent(), 0); + EXPECT_FALSE(sos.loop(now + 1, true)); + EXPECT_TRUE(sos.loop(now + (uint32_t)SosEngine::RETRY_MS, true)); +} + +TEST_F(Sos, FailedRoundAfterAGoodOneDoesNotSpinOnTheFix) { + activate(false); + now += 300000; + ASSERT_TRUE(sos.loop(now, true)); + sos.markSent(now, true, false); // had a fix, but nothing went out + EXPECT_FALSE(sos.loop(now + 1, true)); + EXPECT_TRUE(sos.loop(now + (uint32_t)SosEngine::RETRY_MS, true)); +} + +TEST_F(Sos, AckStopsRepeatsAndNamesWhoAnswered) { + activate(); + EXPECT_FALSE(sos.onReply("Ann", "on my way")); + EXPECT_TRUE(sos.onReply("Bob", "ack, on my way")); + EXPECT_TRUE(sos.onReply("Cid", "ACK")); // a second one counts too, the latest first + EXPECT_STREQ(sos.responder(0).name, "Cid"); + EXPECT_STREQ(sos.responder(1).name, "Bob"); + EXPECT_FALSE(sos.loop(now + 600000, true)); + EXPECT_EQ(sos.nextSecs(now), -1); +} + +TEST_F(Sos, NoAckBeforeAnythingWentOut) { + sos.start(now); + EXPECT_FALSE(sos.onReply("Ann", "ACK")); + now += 10000; + ASSERT_TRUE(sos.loop(now, true)); + sos.markSent(now, true, false); + EXPECT_FALSE(sos.onReply("Ann", "ACK")); +} + +TEST_F(Sos, StopClearsTheNextOne) { + activate(); + ASSERT_TRUE(sos.onReply("Ann", "ACK")); + sos.stop(); + EXPECT_FALSE(sos.acked()); + EXPECT_EQ(sos.responders(), 0); + EXPECT_EQ(sos.sent(), 0); +} + +TEST(SosText, WordOnItsOwn) { + EXPECT_TRUE(SosEngine::hasWord("ACK", "ACK")); + EXPECT_TRUE(SosEngine::hasWord("ok, Ack!", "ACK")); + EXPECT_FALSE(SosEngine::hasWord("back", "ACK")); + EXPECT_FALSE(SosEngine::hasWord("acknowledged", "ACK")); + EXPECT_FALSE(SosEngine::hasWord("ack\xC4\x85", "ACK")); + EXPECT_FALSE(SosEngine::hasWord("ACK", "")); + EXPECT_TRUE(SosEngine::hasWord("copied now", "copied")); + EXPECT_TRUE(SosEngine::hasWord("aack ack", "ACK")); +} + +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"); +} + +// 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", ""); + snprintf(msg, sizeof(msg), "Hiker: %s", t); + int32_t lat = 0, lon = 0; + ASSERT_TRUE(geo::parseLatLon(msg, lat, lon)); + EXPECT_EQ(lat, 50061430); + EXPECT_EQ(lon, -19936580); +} + +TEST(SosText, OnlyAnSosWithAPositionIsTakenForOne) { + int32_t lat = 0, lon = 0; + EXPECT_TRUE(geo::parseSos("SOS 50.06143,19.93658 ALT 212m", lat, lon)); + EXPECT_EQ(lat, 50061430); + 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)); +} + +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")); +} + +TEST(LiveTrack, SosOutlivesAShareAndIsEvictedLast) { + LiveTrackStore t; + t.update(nullptr, "SOS Ann", 1, 2, 1000, false, true); + uint32_t later = 1000 + LiveTrackStore::EXPIRY_SECS + 60; + EXPECT_TRUE(t.isActive(0, later)); + EXPECT_FALSE(t.isActive(0, 1000 + LiveTrackStore::SOS_EXPIRY_SECS + 1)); + t.update(nullptr, "SOS Ann", 1, 2, 2000, false); // a later update doesn't undo it + EXPECT_TRUE(t.slotAt(0).sos); + for (int i = 0; i < LiveTrackStore::CAPACITY + 3; i++) { + char n[8]; + snprintf(n, sizeof(n), "n%d", i); + t.update(nullptr, n, 1, 2, 3000 + i, false); + } + EXPECT_TRUE(t.slotAt(0).used && t.slotAt(0).sos); +} + +TEST(SosText, ComposeNeverOverruns) { + char t[12]; + SosEngine::compose(t, sizeof(t), SosEngine::NOWHERE, 0, 0, nullptr, -1, "14:32", "a long note"); + EXPECT_LT(strlen(t), sizeof(t)); +} + +TEST(SosTargets, ChannelsAndContacts) { + NodePrefs p; + EXPECT_FALSE(SosEngine::hasTargets(&p)); + p.sos_ch_mask = 1ULL << 39; + EXPECT_TRUE(SosEngine::hasTargets(&p)); + p.sos_ch_mask = 0; + p.sos_dm[2][5] = 1; + EXPECT_TRUE(SosEngine::hasTargets(&p)); + EXPECT_FALSE(SosEngine::hasDm(&p, 0)); +} + +TEST_F(Sos, BeepOffByDefault) { + sos.start(now); + now += 10000; + ASSERT_TRUE(sos.loop(now, false)); + now += 60000; + EXPECT_FALSE(sos.beepDue(now)); +} + +TEST_F(Sos, BeepKeepsGoingPastAnAck) { + prefs.sos_beep_idx = 1; // 30 s + sos.start(now); + EXPECT_FALSE(sos.beepDue(now)); // not in the countdown + now += 10000; + ASSERT_TRUE(sos.loop(now, true)); + sos.markSent(now, true, true); + EXPECT_FALSE(sos.beepDue(now)); + now += SosEngine::BEEP_FIRST_MS; + EXPECT_TRUE(sos.beepDue(now)); + EXPECT_FALSE(sos.beepDue(now + 1000)); + sos.onReply("Bob", "ack"); + EXPECT_TRUE(sos.acked()); + EXPECT_TRUE(sos.beepDue(now + 30000)); + sos.stop(); + EXPECT_FALSE(sos.beepDue(now + 90000)); +} + +TEST_F(Sos, FirstFixPastTheLastSendStillGoesOut) { + prefs.sos_repeat_idx = 1; // 1 min + sos.start(now); + now += 10000; + for (int i = 0; i < SosEngine::MAX_SENDS; i++) { + ASSERT_TRUE(sos.loop(now, false)) << i; + sos.markSent(now, false, true); + now += 60000; + } + EXPECT_FALSE(sos.loop(now, false)); + EXPECT_FALSE(sos.finished()); // the GPS is still wanted + EXPECT_TRUE(sos.loop(now, true)); + sos.markSent(now, true, true); + EXPECT_TRUE(sos.finished()); + EXPECT_FALSE(sos.loop(now + 60000, true)); +} + +TEST_F(Sos, AnswerKeepsWhoAndWhat) { + sos.start(now); + now += 10000; + ASSERT_TRUE(sos.loop(now, true)); + sos.markSent(now, true, true); + EXPECT_FALSE(sos.onReply("Bob", "hello there", now)); // no word, not yet someone who answered + EXPECT_EQ(sos.responders(), 0); + EXPECT_TRUE(sos.onReply("Bob", "ack, 20 min away", now)); + EXPECT_STREQ(sos.responder(0).name, "Bob"); + EXPECT_STREQ(sos.responder(0).text, "ack, 20 min away"); + EXPECT_EQ(sos.responder(0).at, now); +} + +TEST_F(Sos, TheWordAloneHasNoText) { + sos.start(now); + now += 10000; + ASSERT_TRUE(sos.loop(now, true)); + sos.markSent(now, true, true); + EXPECT_TRUE(sos.onReply("Bob", "ack", now)); + EXPECT_STREQ(sos.responder(0).text, ""); +} + +TEST_F(Sos, TheBareWordKeepsWhatTheySaidBefore) { + sos.start(now); + now += 10000; + ASSERT_TRUE(sos.loop(now, true)); + sos.markSent(now, true, true); + EXPECT_TRUE(sos.onReply("Bob", "ack, 20 min away", now)); + EXPECT_TRUE(sos.onReply("Bob", "ACK", now + 1000)); + EXPECT_STREQ(sos.responder(0).text, "ack, 20 min away"); + EXPECT_EQ(sos.responder(0).at, now + 1000); +} + +TEST_F(Sos, ThoseWhoAnsweredAreFollowedWithoutTheWord) { + sos.start(now); + now += 10000; + ASSERT_TRUE(sos.loop(now, true)); + sos.markSent(now, true, true); + EXPECT_TRUE(sos.onReply("Bob", "ack", now)); + EXPECT_TRUE(sos.onReply("Ann", "ACK coming too", now + 1000)); + EXPECT_EQ(sos.responders(), 2); + EXPECT_STREQ(sos.responder(0).name, "Ann"); + EXPECT_TRUE(sos.onReply("Bob", "5 minutes", now + 2000)); + EXPECT_EQ(sos.responders(), 2); + EXPECT_STREQ(sos.responder(0).name, "Bob"); + EXPECT_STREQ(sos.responder(0).text, "5 minutes"); + EXPECT_FALSE(sos.onReply("Eve", "hi", now + 3000)); + EXPECT_EQ(sos.responders(), 2); +} + +TEST_F(Sos, TheQuietestResponderMakesRoom) { + sos.start(now); + now += 10000; + ASSERT_TRUE(sos.loop(now, true)); + sos.markSent(now, true, true); + for (const char* n : { "A", "B", "C", "D" }) EXPECT_TRUE(sos.onReply(n, "ack", now)); + EXPECT_EQ(sos.responders(), (int)SosEngine::RESPONDERS_MAX); + EXPECT_STREQ(sos.responder(0).name, "D"); + EXPECT_STREQ(sos.responder(2).name, "B"); +} + +TEST_F(Sos, ALongAnswerIsCutOnACharacter) { + sos.start(now); + now += 10000; + ASSERT_TRUE(sos.loop(now, true)); + sos.markSent(now, true, true); + std::string msg = "ack "; + while (msg.size() < 80) msg += "\xC5\x82"; // l-stroke: 2 bytes + ASSERT_TRUE(sos.onReply("Bob", msg.c_str(), now)); + const char* t = sos.responder(0).text; + EXPECT_EQ((strlen(t) - 4) % 2, 0u); +} + +TEST_F(Sos, FinishedOnAnAnswer) { + sos.start(now); + now += 10000; + ASSERT_TRUE(sos.loop(now, false)); + sos.markSent(now, false, true); + EXPECT_FALSE(sos.finished()); + sos.onReply("Bob", "ACK"); + EXPECT_TRUE(sos.finished()); +} + +TEST_F(Sos, FinishedAfterTheOnlySendWithAPosition) { + prefs.sos_repeat_idx = 0; // once + sos.start(now); + now += 10000; + ASSERT_TRUE(sos.loop(now, true)); + sos.markSent(now, true, true); + EXPECT_TRUE(sos.finished()); +} + +TEST_F(Sos, TheStatusLines) { + char b[32]; + sos.start(now); + now += 10000; + ASSERT_TRUE(sos.loop(now, true)); + sos.sentLine(b, sizeof(b)); + EXPECT_STREQ(b, "Not sent"); + sos.markSent(now, true, true); + sos.sentLine(b, sizeof(b)); + EXPECT_STREQ(b, "Sent 1 x"); + sos.nextLine(b, sizeof(b), now); + EXPECT_STREQ(b, "Next in 5 min"); + sos.nextLine(b, sizeof(b), now + 5UL * 60000UL - 30000UL); + EXPECT_STREQ(b, "Next in 30 s"); + prefs.sos_repeat_idx = 0; + sos.markSent(now, true, true); + sos.nextLine(b, sizeof(b), now); + EXPECT_STREQ(b, "No more repeats"); +} + +TEST_F(Sos, TheGpsIsWantedUntilNothingMoreGoesOut) { + EXPECT_FALSE(sos.gpsWanted()); + sos.start(now); + EXPECT_TRUE(sos.gpsWanted()); // the countdown already: a fix for the first send + now += 10000; + ASSERT_TRUE(sos.loop(now, true)); + sos.markSent(now, true, true); + EXPECT_TRUE(sos.gpsWanted()); + EXPECT_TRUE(sos.onReply("Ann", "ACK", now)); + EXPECT_FALSE(sos.gpsWanted()); // answered: no more sends, the beep may go on + sos.stop(); + EXPECT_FALSE(sos.gpsWanted()); +} + +TEST_F(Sos, TheScreenGoesOffSoonerOnlyWhileOneRuns) { + EXPECT_EQ(sos.screenMs(0), 0u); + EXPECT_EQ(sos.screenMs(120000), 120000u); + sos.start(now); + EXPECT_EQ(sos.screenMs(0), (uint32_t)SosEngine::SCREEN_MS); + EXPECT_EQ(sos.screenMs(120000), (uint32_t)SosEngine::SCREEN_MS); + EXPECT_EQ(sos.screenMs(5000), 5000u); +} + +TEST(SosTargets, Counts) { + NodePrefs p; + EXPECT_FALSE(SosEngine::hasTargets(&p)); + p.sos_ch_mask = (1ULL << 0) | (1ULL << 7) | (1ULL << 63); + EXPECT_EQ(SosEngine::channels(&p), 3); + EXPECT_TRUE(SosEngine::hasTargets(&p)); + p.sos_ch_mask = 0; + p.sos_dm[1][3] = 0x42; + EXPECT_EQ(SosEngine::contacts(&p), 1); + EXPECT_TRUE(SosEngine::hasTargets(&p)); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}