feat(sos): messages stop at an answer, the advert beacon goes on to the end; settings changed while it runs count at once

- A zero-hop advert with each send and every Repeat gap (5 min with Repeat
  off) until the SOS is ended, after an ACK too; a plain one without a position.
- The GPS stays on for the whole SOS.
- Receiving: a fresh advert carrying a position moves that sender's SOS entry
  (by key for a DM's, by "SOS name" for a channel's).
- The next send, beep and advert are timed from the last one with the
  settings as they are now: a shorter gap applies at once, Repeat back on
  resumes the messages.
- L1 Tools > SOS rows in the L2's order: start, who and what, while it runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-11-02 17:52:17 +01:00
co-authored by Claude Opus 5.5
parent a0c55c4cdb
commit c4ccc8c6db
9 changed files with 216 additions and 70 deletions
+20 -12
View File
@@ -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`.
+20
View File
@@ -2,6 +2,7 @@
#include <Arduino.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
// 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 <name>" (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++)
+16 -2
View File
@@ -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;
+7 -3
View File
@@ -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;
@@ -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; }
+35 -20
View File
@@ -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;
};
+37 -17
View File
@@ -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);
}
+5 -3
View File
@@ -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];
+71 -13
View File
@@ -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());
}