diff --git a/docs/solo/tools.md b/docs/solo/tools.md index da1f3394..4d77773c 100644 --- a/docs/solo/tools.md +++ b/docs/solo/tools.md @@ -50,14 +50,17 @@ and without opening a menu. Set it up before you need it: | --- | ---- | | **Hold Back** | Lets a hold of **Back** start it. Off by default | | **Countdown** | 5, 10 or 15 seconds to cancel in | -| **Repeat** | How often it's sent again: every 1, 2, 5 or 10 minutes, or **Off** for once | -| **Beep** | Off, or every 30 seconds, 1 or 5 minutes: `... --- ...` on the buzzer at full volume, so someone nearby can find you if you can't answer | | **Channels** | Which channels it goes to; **Enter** switches each one | | **Contact 1-3** | Up to three direct contacts. **Enter** picks one, **Left / Right** empties the row | -| **ACK word** | The word that means "received", `ACK` by default | | **Message** | What follows `[SOS]`, with placeholders, see below | +| **Repeat** | How often it's sent again: every 1, 2, 5 or 10 minutes, or **Off** for once | +| **Beep** | Off, or every 30 seconds, 1 or 5 minutes: `... --- ...` on the buzzer at full volume, so someone nearby can find you if you can't answer | +| **ACK word** | The word that means "received", `ACK` by default | | **Send SOS** | Starts it from here, on devices without a Back button too | +A change made while an SOS runs counts at once: a shorter **Repeat** or +**Beep** from the last send or beep, and Repeat switched back on sends again. + **Starting:** hold **Back** for 3 seconds. It works with the screen off and on a locked device, and needs no PIN. The device beeps every second and counts down, even with the buzzer off. **Any key** cancels. @@ -88,24 +91,29 @@ Without a fix `{loc}` gives the last position from the past hour, with its age. An older one could be far from where you are, so then it says `no GPS`. Either way one more goes as soon as the GPS gets a fix. -With a position, each send also goes out as a zero-hop advert carrying it at full -precision, whatever **Privacy** says for adverts: anyone in direct range sees -you on their map in any MeshCore app, even if they aren't on your list. +**The beacon:** each send also goes out as a zero-hop advert with the position +at full precision, whatever **Privacy** says for adverts, and so does one every +**Repeat** gap (5 minutes with Repeat off) until you end the SOS, after an +answer too. The messages are for people and stop once someone answers; the +beacon is for the map of whoever comes near: anyone in direct range sees you +in any MeshCore app, even if they aren't on your list. Without a position it +goes without one, and still tells someone nearby that the node is there. **Receiving one:** a banner names the sender, also when there's no position. A position shows on the map and in Nearby as `SOS name`, even with **Track loc** -off. It stays for 24 hours, -or until a restart. **Hold Enter** on the message navigates to it. Other +off; on the L2 as a red, pulsing dot, and the row in Nodes says `SOS`. Their beacon moves it as they move, while you're in its range. It stays for +24 hours, or until a restart. **Hold Enter** on the message navigates to it. Other firmware and the phone app show it as an ordinary message. **While it runs**, the status shows how many were sent and when the next goes. It's sent up to 12 times, then the device goes on listening, with one more -send if the GPS gets its first fix after that. **Beep** goes on, even with the -buzzer off, after an answer too, until you end the SOS. +send if the GPS gets its first fix after that. The beacon and **Beep** go on, +the beep even with the buzzer off, after an answer too, until you end the SOS. To save the battery while it runs, the screen goes off after 30 seconds whatever -your auto-off says. GPS power saving waits until nothing more will be sent, and -then a GPS the SOS turned on goes back off. The repeater (and WiFi on the L2) +your auto-off says. The GPS stays on, past its power saving, until the SOS ends, +so the beacon gives where you are now: end it once help has found you. A GPS +the SOS turned on goes back off then. The repeater (and WiFi on the L2) rests until the SOS ends, so the message goes out on your own network. During an SOS the bot refuses `!gps`. diff --git a/examples/companion_radio/LiveTrack.h b/examples/companion_radio/LiveTrack.h index 9c32145e..8948b25a 100644 --- a/examples/companion_radio/LiveTrack.h +++ b/examples/companion_radio/LiveTrack.h @@ -2,6 +2,7 @@ #include #include +#include #include // RAM-only table of positions other nodes have shared via [LOC] messages @@ -54,6 +55,25 @@ public: e.used = true; } + // An advert from someone whose SOS is on the table moves it: their beacon + // (UiCore::sosBeacon). Matched by key (a DM's SOS), or by "SOS " (a + // channel's, unverified like the message itself). False when there's none. + bool followSos(const uint8_t* key, const char* name, int32_t lat_1e6, int32_t lon_1e6, uint32_t ts) { + char label[NAME_LEN]; + snprintf(label, sizeof(label), "SOS %s", name ? name : ""); + bool moved = false; + for (int i = 0; i < CAPACITY; i++) { + Entry& e = _e[i]; + if (!e.used || !e.sos) continue; + if (e.verified ? !(key && memcmp(e.key, key, KEY_LEN) == 0) : strcmp(e.name, label) != 0) continue; + e.lat_1e6 = lat_1e6; + e.lon_1e6 = lon_1e6; + e.ts = ts; + moved = true; + } + return moved; + } + // Drop entries not refreshed within EXPIRY_SECS. `now` is RTC epoch seconds. void expire(uint32_t now) { for (int i = 0; i < CAPACITY; i++) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 47c427bf..79258300 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -401,6 +401,19 @@ void MyMesh::onDiscoveredAdvert(bool was_flood) { if (_listener) _listener->onAdvertHeard(was_flood); } +// The position an advert carries, when it carries one: a contact's stored one +// may be older than what an SOS already gave. Not from a replay of an older one. +void MyMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, + const uint8_t* app_data, size_t app_data_len) { + const ContactInfo* known = lookupContactByPubKey(id.pub_key, PUB_KEY_SIZE); + const bool fresh = !known || timestamp > known->last_advert_timestamp; + BaseChatMesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len); + if (!fresh || !_listener) return; + AdvertDataParser parser(app_data, app_data_len); + if (parser.isValid() && parser.hasLatLon()) + _listener->onAdvertPosition(id.pub_key, parser.getName(), parser.getIntLat(), parser.getIntLon()); +} + void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t* path) { if (_serial->isConnected()) { if (is_new) { @@ -3104,8 +3117,9 @@ bool MyMesh::advertFlood() { } } -bool MyMesh::advertSos(int32_t lat_1e6, int32_t lon_1e6) { - mesh::Packet* pkt = createSelfAdvert(_prefs.node_name, lat_1e6 / 1e6, lon_1e6 / 1e6); +bool MyMesh::advertSos(bool with_pos, int32_t lat_1e6, int32_t lon_1e6) { + mesh::Packet* pkt = with_pos ? createSelfAdvert(_prefs.node_name, lat_1e6 / 1e6, lon_1e6 / 1e6) + : createSelfAdvert(_prefs.node_name); if (!pkt) return false; sendZeroHop(pkt); return true; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 68ce35d6..733745d2 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -184,6 +184,8 @@ public: 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 fresh advert that carries a position (an SOS beacon among them). + virtual void onAdvertPosition(const uint8_t* pub_key, const char* name, int32_t lat_1e6, int32_t lon_1e6) {} // 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) { } @@ -227,9 +229,10 @@ public: // (manual, flood, auto, phone-app and contact export). mesh::Packet* createOwnAdvert(); bool advertFlood(); // flooded (default scope), as the app's "Advert" with flood - // Zero-hop, with this position at full precision whatever advert_loc_policy - // says: starting an SOS is the consent to give it. - bool advertSos(int32_t lat_1e6, int32_t lon_1e6); + // An SOS beacon: zero-hop, with this position (when there is one) at full + // precision whatever advert_loc_policy says: starting an SOS is the consent + // to give it. + bool advertSos(bool with_pos, int32_t lat_1e6, int32_t lon_1e6); void sendNodeDiscoverReq(); void enterCLIRescue(); @@ -331,6 +334,7 @@ protected: void onContactOverwrite(const uint8_t* pub_key) override; bool onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t* path) override; + void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; void onDiscoveredAdvert(bool was_flood) override; void onContactPathUpdated(const ContactInfo &contact) override; ContactInfo* processAck(const uint8_t *data) override; diff --git a/examples/companion_radio/ui-core/LiveShareEngine.h b/examples/companion_radio/ui-core/LiveShareEngine.h index b356111c..59d2546e 100644 --- a/examples/companion_radio/ui-core/LiveShareEngine.h +++ b/examples/companion_radio/ui-core/LiveShareEngine.h @@ -147,6 +147,11 @@ public: _track.update(pub_key, label, lat_1e6, lon_1e6, (uint32_t)rtc_clock.getCurrentTime(), verified, true); } + // An advert with a position: moves that node's SOS, if one is on the table. + void onAdvertPosition(const uint8_t* pub_key, const char* name, int32_t lat_1e6, int32_t lon_1e6) { + _track.followSos(pub_key, name, lat_1e6, lon_1e6, (uint32_t)rtc_clock.getCurrentTime()); + } + 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 index 3789a4cc..c91dd9a1 100644 --- a/examples/companion_radio/ui-core/SosEngine.h +++ b/examples/companion_radio/ui-core/SosEngine.h @@ -3,7 +3,8 @@ // 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 +// send. A beacon (a zero-hop advert with the position) and an optional beep +// keep 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. @@ -28,6 +29,7 @@ public: 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 + static const uint32_t BEACON_MS = 5UL * 60000UL; // the beacon's gap with Repeat off void begin(const NodePrefs* prefs) { _prefs = prefs; } @@ -37,12 +39,9 @@ public: 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 GPS must stay awake, past any duty cycle: the beacon gives the + // position until the end, and someone on the way needs it current. + bool gpsWanted() const { return _state != IDLE; } // 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; @@ -58,7 +57,7 @@ public: void stop() { _state = IDLE; _attempts = _sent = _responders = 0; - _acked = _with_fix = _next_on = _failed = false; + _acked = _with_fix = _failed = _beeped = false; } // Whole seconds left of the countdown, rounded up: 0 when not counting. @@ -70,7 +69,7 @@ public: // 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); + int32_t left = (int32_t)(_sent_at + nextGap() - now); return left <= 0 ? 0 : (left + 999) / 1000; } @@ -82,7 +81,7 @@ public: // `minutes` for a slow panel, where a line ticking every second is a refresh a second. void nextLine(char* out, int n, uint32_t now, bool minutes = false) const { const int next = nextSecs(now); - if (next < 0) snprintf(out, n, "No more repeats"); + if (next < 0) snprintf(out, n, "Advert every %u min", (unsigned)(beaconMs() / 60000UL)); else if (next < 60) snprintf(out, n, minutes ? "Next in <1 min" : "Next in %d s", next); else snprintf(out, n, "Next in %d min", (next + 59) / 60); } @@ -92,29 +91,36 @@ public: if (_state == COUNTDOWN) { if (now - _t0 < countdownMs()) return false; _state = ACTIVE; - _beep_next = now + BEEP_FIRST_MS; + _active_at = now; + _beeped = false; 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; + return repeating() && now - _sent_at >= nextGap(); } // 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. + // The gaps here are the settings' now: one changed while it runs counts at once. 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; + if (_state != ACTIVE || gap == 0) return false; + if (_beeped ? now - _beep_at < gap : now - _active_at < BEEP_FIRST_MS) return false; + _beep_at = now; + _beeped = true; return true; } + // True when the beacon is due: from the first send to the end, past the + // sends and an ACK too, the messages for people, it for the map of whoever + // is in range. The caller adverts and calls markBeacon(); a send does too. + bool beaconDue(uint32_t now) const { return _state == ACTIVE && _attempts && now - _beacon_at >= beaconMs(); } + void markBeacon(uint32_t now) { _beacon_at = now; } 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); + _sent_at = now; } // A message came in. True when it's an answer: one holding the ACK word, from @@ -244,13 +250,22 @@ private: 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; } + uint32_t repeatMs() const { + return _prefs ? (uint32_t)NodePrefs::sosRepeatMins(_prefs->sos_repeat_idx) * 60000UL : 0; + } + // A send that didn't go out is tried again soon; otherwise Repeat's gap (Off: none). + bool repeating() const { return !_acked && _attempts && _attempts < MAX_SENDS && (_failed || repeatMs()); } + uint32_t nextGap() const { return _failed ? RETRY_MS : repeatMs(); } + uint32_t beaconMs() const { + const uint32_t gap = repeatMs(); + return gap ? gap : BEACON_MS; + } const NodePrefs* _prefs = nullptr; State _state = IDLE; - uint32_t _t0 = 0, _next = 0, _beep_next = 0; + uint32_t _t0 = 0, _active_at = 0, _sent_at = 0, _beep_at = 0, _beacon_at = 0; // millis() of each last uint8_t _attempts = 0, _sent = 0; - bool _acked = false, _with_fix = false, _next_on = false, _failed = false; + bool _acked = false, _with_fix = false, _failed = false, _beeped = false; Responder _resp[RESPONDERS_MAX]; uint8_t _responders = 0; }; diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index 118b24e6..64117e30 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -247,6 +247,9 @@ public: void onACKRecv(uint32_t ack_crc) override { history.markDmDelivered(ack_crc); } void onAdvertHeard(bool was_flood) override { events.push(UiEventType::AdvertHeard, nullptr, was_flood); } + void onAdvertPosition(const uint8_t* pub_key, const char* name, int32_t lat_1e6, int32_t lon_1e6) override { + live_share.onAdvertPosition(pub_key, name, lat_1e6, lon_1e6); + } void onPacketHeard(const mesh::Packet* pkt) override { flow.onHeard(pkt); } void onPacketSent(const mesh::Packet* pkt) override { flow.onSent(pkt); } bool requestShutdown(bool restart) override { _host->shutdown(restart); return true; } @@ -419,9 +422,10 @@ 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; + else if (sos.beaconDue(millis())) { + int age_min; + const SosEngine::Where where = sosWhere(lat, lon, age_min); + sosBeacon(where, lat, lon); } } @@ -431,29 +435,44 @@ private: if (sos.onReply(from, text, millis())) events.push(UiEventType::SosAcked, from, first); } - // One round: every target, with the position as of now. + // Where to say we are, as of now. 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. + // age_min: how old a last fix is, -1 for a current one or one set by hand. + SosEngine::Where sosWhere(int32_t& lat, int32_t& lon, int& age_min) { + lat = lon = 0; + age_min = -1; + uint32_t age = 0; + if (course.currentLocation(lat, lon)) return SosEngine::FIX; + if (course.lastFix(lat, lon, age) && age <= SosEngine::LAST_MAX_SECS) { age_min = (int)(age / 60); return SosEngine::LAST_KNOWN; } + if (!gpsAvailable() && fixedPosition(lat, lon)) return SosEngine::LAST_KNOWN; + return SosEngine::NOWHERE; + } + + // The beacon: a zero-hop advert, so the mesh isn't flooded. With the + // position anyone in range sees it on their map, in any app; without one it + // still says the node is there, for whoever homes in on its signal. + void sosBeacon(SosEngine::Where where, int32_t lat, int32_t lon) { + the_mesh.advertSos(where != SosEngine::NOWHERE, lat, lon); + sos.markBeacon(millis()); + } + + // One round: every target, with the position as of now, and the beacon. void sendSos() { const uint32_t now = rtc_clock.getCurrentTime(); - int32_t lat = 0, lon = 0; + int32_t lat, lon; + int age_min; + const SosEngine::Where where = sosWhere(lat, lon, age_min); + const bool fix = where == SosEngine::FIX; 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), _prefs->sos_msg, where, lat, lon, has_alt ? &alt : nullptr, - recent ? (int)(age / 60) : -1, hhmm, _host->battPercent()); - // Anyone in range sees it on their map too, on any app, not only the - // targets: an advert, zero-hop so the mesh isn't flooded every round. - if (where != SosEngine::NOWHERE) the_mesh.advertSos(lat, lon); + age_min, hhmm, _host->battPercent()); bool ok = false; for (int i = 0; i < MAX_GROUP_CHANNELS; i++) @@ -465,6 +484,7 @@ private: } const bool first = ok && sos.sent() == 0; sos.markSent(millis(), fix, ok); + sosBeacon(where, lat, lon); events.push(UiEventType::SosSent, nullptr, first); } diff --git a/examples/companion_radio/ui-new/SosScreen.h b/examples/companion_radio/ui-new/SosScreen.h index f0a3b87a..a57bd88a 100644 --- a/examples/companion_radio/ui-new/SosScreen.h +++ b/examples/companion_radio/ui-new/SosScreen.h @@ -25,18 +25,20 @@ class SosScreen : public UIScreen { enum Kind : uint8_t { K_HOLD, K_COUNTDOWN, K_REPEAT, K_BEEP, K_CHANNELS, K_DM1, K_DM2, K_DM3, K_ACK, K_MSG, K_START }; struct Row { Kind kind; const char* label; }; static const int ROW_COUNT = 11; + // The L2's order: how it starts, who it goes to and what it says, then + // what happens while it runs. 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_MSG, "Message" }, + { K_REPEAT, "Repeat" }, + { K_BEEP, "Beep" }, + { K_ACK, "ACK word" }, { K_START, "Send SOS" }, }; return R[i]; diff --git a/test/test_sos_engine/test_sos_engine.cpp b/test/test_sos_engine/test_sos_engine.cpp index 62ebe023..70fd0430 100644 --- a/test/test_sos_engine/test_sos_engine.cpp +++ b/test/test_sos_engine/test_sos_engine.cpp @@ -225,6 +225,23 @@ TEST(SosText, NoGpsIsRecognisedOnItsOwn) { EXPECT_FALSE(geo::isSos("SOS no GPS")); } +// A beacon moves an SOS that's on the table, and nothing else. +TEST(LiveTrack, AnAdvertMovesOnlyAnSos) { + LiveTrackStore t; + const uint8_t ann[6] = { 1, 2, 3, 4, 5, 6 }, bob[6] = { 9, 9, 9, 9, 9, 9 }; + t.update(ann, "SOS Ann", 1, 2, 1000, true, true); // a DM's + t.update(nullptr, "SOS Cy", 1, 2, 1000, false, true); // a channel's + t.update(bob, "Bob", 1, 2, 1000, true); // a live share + EXPECT_TRUE(t.followSos(ann, "Ann", 30, 40, 2000)); + EXPECT_EQ(t.slotAt(0).lat_1e6, 30); + EXPECT_EQ(t.slotAt(0).ts, 2000u); + EXPECT_TRUE(t.followSos(nullptr, "Cy", 50, 60, 2000)); + EXPECT_EQ(t.slotAt(1).lon_1e6, 60); + EXPECT_FALSE(t.followSos(bob, "Bob", 70, 80, 2000)); + EXPECT_EQ(t.slotAt(2).lat_1e6, 1); + EXPECT_FALSE(t.followSos(bob, "Ann", 70, 80, 2000)); // a DM's SOS goes by key, not name +} + TEST(LiveTrack, SosOutlivesAShareAndIsEvictedLast) { LiveTrackStore t; t.update(nullptr, "SOS Ann", 1, 2, 1000, false, true); @@ -294,10 +311,8 @@ TEST_F(Sos, FirstFixPastTheLastSendStillGoesOut) { 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)); } @@ -374,23 +389,66 @@ TEST_F(Sos, ALongAnswerIsCutOnACharacter) { EXPECT_EQ((strlen(t) - 4) % 2, 0u); } -TEST_F(Sos, FinishedOnAnAnswer) { +// The messages stop at an answer, the beacon goes on at the Repeat gap. +TEST_F(Sos, TheBeaconOutlivesTheMessages) { + prefs.sos_repeat_idx = 1; // 1 min sos.start(now); + EXPECT_FALSE(sos.beaconDue(now + 600000)); // not in the countdown 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()); + ASSERT_TRUE(sos.loop(now, true)); + sos.markSent(now, true, true); + sos.markBeacon(now); + EXPECT_FALSE(sos.beaconDue(now + 59000)); + EXPECT_TRUE(sos.onReply("Bob", "ACK", now)); + now += 60000; + EXPECT_FALSE(sos.loop(now, true)); + EXPECT_TRUE(sos.beaconDue(now)); + sos.markBeacon(now); + EXPECT_FALSE(sos.beaconDue(now + 1000)); + sos.stop(); + EXPECT_FALSE(sos.beaconDue(now + 600000)); } -TEST_F(Sos, FinishedAfterTheOnlySendWithAPosition) { +TEST_F(Sos, TheBeaconWithRepeatOffEveryFiveMinutes) { 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()); + sos.markBeacon(now); + EXPECT_FALSE(sos.beaconDue(now + SosEngine::BEACON_MS - 1000)); + EXPECT_TRUE(sos.beaconDue(now + SosEngine::BEACON_MS)); + char b[32]; + sos.nextLine(b, sizeof(b), now); + EXPECT_STREQ(b, "Advert every 5 min"); +} + +// Settings changed while one runs count at once, from the last send / beep / +// advert: not only from the next one, and Repeat off -> on starts it again. +TEST_F(Sos, ASettingChangedWhileItRunsCountsAtOnce) { + activate(); + const uint32_t sent = now; + prefs.sos_repeat_idx = 1; // 5 min -> 1 min + EXPECT_FALSE(sos.loop(sent + 59000, true)); + EXPECT_TRUE(sos.loop(sent + 60000, true)); + sos.markSent(sent + 60000, true, true); + prefs.sos_repeat_idx = 0; // off: no more messages + EXPECT_FALSE(sos.loop(sent + 10UL * 60000UL, true)); + EXPECT_EQ(sos.nextSecs(sent + 10UL * 60000UL), -1); + prefs.sos_repeat_idx = 2; // on again, 2 min: overdue, so now + EXPECT_TRUE(sos.loop(sent + 10UL * 60000UL, true)); + + sos.markBeacon(sent); + prefs.sos_repeat_idx = 4; // 10 min: the advert follows it + EXPECT_FALSE(sos.beaconDue(sent + 9UL * 60000UL)); + prefs.sos_repeat_idx = 1; + EXPECT_TRUE(sos.beaconDue(sent + 60000)); + + prefs.sos_beep_idx = 3; // 5 min + EXPECT_TRUE(sos.beepDue(sent + SosEngine::BEEP_FIRST_MS)); + EXPECT_FALSE(sos.beepDue(sent + SosEngine::BEEP_FIRST_MS + 30000)); + prefs.sos_beep_idx = 1; // 30 s + EXPECT_TRUE(sos.beepDue(sent + SosEngine::BEEP_FIRST_MS + 30000)); } TEST_F(Sos, TheStatusLines) { @@ -414,10 +472,10 @@ TEST_F(Sos, TheStatusLines) { prefs.sos_repeat_idx = 0; sos.markSent(now, true, true); sos.nextLine(b, sizeof(b), now); - EXPECT_STREQ(b, "No more repeats"); + EXPECT_STREQ(b, "Advert every 5 min"); } -TEST_F(Sos, TheGpsIsWantedUntilNothingMoreGoesOut) { +TEST_F(Sos, TheGpsIsWantedUntilTheEnd) { EXPECT_FALSE(sos.gpsWanted()); sos.start(now); EXPECT_TRUE(sos.gpsWanted()); // the countdown already: a fix for the first send @@ -426,7 +484,7 @@ TEST_F(Sos, TheGpsIsWantedUntilNothingMoreGoesOut) { 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 + EXPECT_TRUE(sos.gpsWanted()); // answered: the beacon still gives the position sos.stop(); EXPECT_FALSE(sos.gpsWanted()); }