mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-11-02 19:02:00 +00:00
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:
+20
-12
@@ -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 |
|
| **Hold Back** | Lets a hold of **Back** start it. Off by default |
|
||||||
| **Countdown** | 5, 10 or 15 seconds to cancel in |
|
| **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 |
|
| **Channels** | Which channels it goes to; **Enter** switches each one |
|
||||||
| **Contact 1-3** | Up to three direct contacts. **Enter** picks one, **Left / Right** empties the row |
|
| **Contact 1-3** | Up to three direct contacts. **Enter** picks one, **Left / Right** empties the row |
|
||||||
| **ACK word** | The word that means "received", `ACK` by default |
|
|
||||||
| **Message** | What follows `[SOS]`, with placeholders, see below |
|
| **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 |
|
| **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
|
**Starting:** hold **Back** for 3 seconds. It works with the screen off and on a
|
||||||
locked device, and needs no PIN. The device beeps every second and counts down,
|
locked device, and needs no PIN. The device beeps every second and counts down,
|
||||||
even with the buzzer off. **Any key** cancels.
|
even with the buzzer off. **Any key** cancels.
|
||||||
@@ -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
|
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.
|
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
|
**The beacon:** each send also goes out as a zero-hop advert with the position
|
||||||
precision, whatever **Privacy** says for adverts: anyone in direct range sees
|
at full precision, whatever **Privacy** says for adverts, and so does one every
|
||||||
you on their map in any MeshCore app, even if they aren't on your list.
|
**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.
|
**Receiving one:** a banner names the sender, also when there's no position.
|
||||||
A position shows on the map and in Nearby as `SOS name`, even with **Track loc**
|
A position shows on the map and in Nearby as `SOS name`, even with **Track loc**
|
||||||
off. It stays for 24 hours,
|
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
|
||||||
or until a restart. **Hold Enter** on the message navigates to it. Other
|
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.
|
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.
|
**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
|
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
|
send if the GPS gets its first fix after that. The beacon and **Beep** go on,
|
||||||
buzzer off, after an answer too, until you end the SOS.
|
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
|
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
|
your auto-off says. The GPS stays on, past its power saving, until the SOS ends,
|
||||||
then a GPS the SOS turned on goes back off. The repeater (and WiFi on the L2)
|
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
|
rests until the SOS ends, so the message goes out on your own network. During an
|
||||||
SOS the bot refuses `!gps`.
|
SOS the bot refuses `!gps`.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
// RAM-only table of positions other nodes have shared via [LOC] messages
|
// RAM-only table of positions other nodes have shared via [LOC] messages
|
||||||
@@ -54,6 +55,25 @@ public:
|
|||||||
e.used = true;
|
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.
|
// Drop entries not refreshed within EXPIRY_SECS. `now` is RTC epoch seconds.
|
||||||
void expire(uint32_t now) {
|
void expire(uint32_t now) {
|
||||||
for (int i = 0; i < CAPACITY; i++)
|
for (int i = 0; i < CAPACITY; i++)
|
||||||
|
|||||||
@@ -401,6 +401,19 @@ void MyMesh::onDiscoveredAdvert(bool was_flood) {
|
|||||||
if (_listener) _listener->onAdvertHeard(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) {
|
void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t* path) {
|
||||||
if (_serial->isConnected()) {
|
if (_serial->isConnected()) {
|
||||||
if (is_new) {
|
if (is_new) {
|
||||||
@@ -3104,8 +3117,9 @@ bool MyMesh::advertFlood() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool MyMesh::advertSos(int32_t lat_1e6, int32_t lon_1e6) {
|
bool MyMesh::advertSos(bool with_pos, int32_t lat_1e6, int32_t lon_1e6) {
|
||||||
mesh::Packet* pkt = createSelfAdvert(_prefs.node_name, lat_1e6 / 1e6, lon_1e6 / 1e6);
|
mesh::Packet* pkt = with_pos ? createSelfAdvert(_prefs.node_name, lat_1e6 / 1e6, lon_1e6 / 1e6)
|
||||||
|
: createSelfAdvert(_prefs.node_name);
|
||||||
if (!pkt) return false;
|
if (!pkt) return false;
|
||||||
sendZeroHop(pkt);
|
sendZeroHop(pkt);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -184,6 +184,8 @@ public:
|
|||||||
virtual void onSosPosition(const uint8_t* pub_key, const char* name,
|
virtual void onSosPosition(const uint8_t* pub_key, const char* name,
|
||||||
int32_t lat_1e6, int32_t lon_1e6, bool verified) {}
|
int32_t lat_1e6, int32_t lon_1e6, bool verified) {}
|
||||||
virtual void onSosNoPosition(const char* name) {}
|
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.
|
// A contact / channel slot is gone -- drop references to it.
|
||||||
virtual void onContactRemoved(const uint8_t* pub_key) { }
|
virtual void onContactRemoved(const uint8_t* pub_key) { }
|
||||||
virtual void onChannelRemoved(uint8_t channel_idx) { }
|
virtual void onChannelRemoved(uint8_t channel_idx) { }
|
||||||
@@ -227,9 +229,10 @@ public:
|
|||||||
// (manual, flood, auto, phone-app and contact export).
|
// (manual, flood, auto, phone-app and contact export).
|
||||||
mesh::Packet* createOwnAdvert();
|
mesh::Packet* createOwnAdvert();
|
||||||
bool advertFlood(); // flooded (default scope), as the app's "Advert" with flood
|
bool advertFlood(); // flooded (default scope), as the app's "Advert" with flood
|
||||||
// Zero-hop, with this position at full precision whatever advert_loc_policy
|
// An SOS beacon: zero-hop, with this position (when there is one) at full
|
||||||
// says: starting an SOS is the consent to give it.
|
// precision whatever advert_loc_policy says: starting an SOS is the consent
|
||||||
bool advertSos(int32_t lat_1e6, int32_t lon_1e6);
|
// to give it.
|
||||||
|
bool advertSos(bool with_pos, int32_t lat_1e6, int32_t lon_1e6);
|
||||||
void sendNodeDiscoverReq();
|
void sendNodeDiscoverReq();
|
||||||
void enterCLIRescue();
|
void enterCLIRescue();
|
||||||
|
|
||||||
@@ -331,6 +334,7 @@ protected:
|
|||||||
void onContactOverwrite(const uint8_t* pub_key) override;
|
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;
|
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 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 onDiscoveredAdvert(bool was_flood) override;
|
||||||
void onContactPathUpdated(const ContactInfo &contact) override;
|
void onContactPathUpdated(const ContactInfo &contact) override;
|
||||||
ContactInfo* processAck(const uint8_t *data) 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);
|
_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; }
|
LiveTrackStore& track() { return _track; }
|
||||||
const LiveTrackStore& track() const { return _track; }
|
const LiveTrackStore& track() const { return _track; }
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
// channels and contacts (UiCore::sendSos()), repeated until someone answers
|
// 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
|
// 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
|
// 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
|
// matching live here: no radio, no clock, so it's tested on the host
|
||||||
// (test/test_sos_engine). Not kept across a restart.
|
// (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 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 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 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; }
|
void begin(const NodePrefs* prefs) { _prefs = prefs; }
|
||||||
|
|
||||||
@@ -37,12 +39,9 @@ public:
|
|||||||
int responders() const { return _responders; }
|
int responders() const { return _responders; }
|
||||||
const Responder& responder(int i) const { return _resp[i]; } // the latest first
|
const Responder& responder(int i) const { return _resp[i]; } // the latest first
|
||||||
uint8_t sent() const { return _sent; } // sends that went out
|
uint8_t sent() const { return _sent; } // sends that went out
|
||||||
// Nothing more will go out: the GPS has done its part.
|
// The GPS must stay awake, past any duty cycle: the beacon gives the
|
||||||
bool finished() const {
|
// position until the end, and someone on the way needs it current.
|
||||||
return _state == ACTIVE && _sent && (_acked || (_with_fix && (_attempts >= MAX_SENDS || !_next_on)));
|
bool gpsWanted() const { return _state != IDLE; }
|
||||||
}
|
|
||||||
// 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.
|
// The screen's auto-off while one runs. ms: the user's, 0 = never.
|
||||||
uint32_t screenMs(uint32_t ms) const {
|
uint32_t screenMs(uint32_t ms) const {
|
||||||
if (_state != IDLE && (ms == 0 || ms > SCREEN_MS)) return SCREEN_MS;
|
if (_state != IDLE && (ms == 0 || ms > SCREEN_MS)) return SCREEN_MS;
|
||||||
@@ -58,7 +57,7 @@ public:
|
|||||||
void stop() {
|
void stop() {
|
||||||
_state = IDLE;
|
_state = IDLE;
|
||||||
_attempts = _sent = _responders = 0;
|
_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.
|
// 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.
|
// Seconds to the next send, -1 when there won't be one.
|
||||||
int nextSecs(uint32_t now) const {
|
int nextSecs(uint32_t now) const {
|
||||||
if (_state != ACTIVE || !repeating()) return -1;
|
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;
|
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.
|
// `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 {
|
void nextLine(char* out, int n, uint32_t now, bool minutes = false) const {
|
||||||
const int next = nextSecs(now);
|
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 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);
|
else snprintf(out, n, "Next in %d min", (next + 59) / 60);
|
||||||
}
|
}
|
||||||
@@ -92,29 +91,36 @@ public:
|
|||||||
if (_state == COUNTDOWN) {
|
if (_state == COUNTDOWN) {
|
||||||
if (now - _t0 < countdownMs()) return false;
|
if (now - _t0 < countdownMs()) return false;
|
||||||
_state = ACTIVE;
|
_state = ACTIVE;
|
||||||
_beep_next = now + BEEP_FIRST_MS;
|
_active_at = now;
|
||||||
|
_beeped = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (_state != ACTIVE || _acked) return false;
|
if (_state != ACTIVE || _acked) return false;
|
||||||
if (has_fix && !_with_fix && _sent && !_failed) return true; // the position they don't have yet
|
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
|
// 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 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) {
|
bool beepDue(uint32_t now) {
|
||||||
const uint32_t gap = _prefs ? (uint32_t)NodePrefs::sosBeepSecs(_prefs->sos_beep_idx) * 1000UL : 0;
|
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;
|
if (_state != ACTIVE || gap == 0) return false;
|
||||||
_beep_next = now + gap;
|
if (_beeped ? now - _beep_at < gap : now - _active_at < BEEP_FIRST_MS) return false;
|
||||||
|
_beep_at = now;
|
||||||
|
_beeped = true;
|
||||||
return 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) {
|
void markSent(uint32_t now, bool with_fix, bool ok) {
|
||||||
_attempts++;
|
_attempts++;
|
||||||
if (ok) _sent++;
|
if (ok) _sent++;
|
||||||
if (ok && with_fix) _with_fix = true;
|
if (ok && with_fix) _with_fix = true;
|
||||||
_failed = !ok;
|
_failed = !ok;
|
||||||
uint32_t gap = _prefs ? (uint32_t)NodePrefs::sosRepeatMins(_prefs->sos_repeat_idx) * 60000UL : 0;
|
_sent_at = now;
|
||||||
_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
|
// 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 {
|
uint32_t countdownMs() const {
|
||||||
return (uint32_t)NodePrefs::sosCountdownSecs(_prefs ? _prefs->sos_countdown_idx : 1) * 1000UL;
|
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;
|
const NodePrefs* _prefs = nullptr;
|
||||||
State _state = IDLE;
|
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;
|
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];
|
Responder _resp[RESPONDERS_MAX];
|
||||||
uint8_t _responders = 0;
|
uint8_t _responders = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -247,6 +247,9 @@ public:
|
|||||||
|
|
||||||
void onACKRecv(uint32_t ack_crc) override { history.markDmDelivered(ack_crc); }
|
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 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 onPacketHeard(const mesh::Packet* pkt) override { flow.onHeard(pkt); }
|
||||||
void onPacketSent(const mesh::Packet* pkt) override { flow.onSent(pkt); }
|
void onPacketSent(const mesh::Packet* pkt) override { flow.onSent(pkt); }
|
||||||
bool requestShutdown(bool restart) override { _host->shutdown(restart); return true; }
|
bool requestShutdown(bool restart) override { _host->shutdown(restart); return true; }
|
||||||
@@ -419,9 +422,10 @@ private:
|
|||||||
void sosLoop() {
|
void sosLoop() {
|
||||||
int32_t lat, lon;
|
int32_t lat, lon;
|
||||||
if (sos.loop(millis(), course.currentLocation(lat, lon))) sendSos();
|
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
|
else if (sos.beaconDue(millis())) {
|
||||||
setGpsEnabled(false, false);
|
int age_min;
|
||||||
_sos_gps_on = false;
|
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);
|
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() {
|
void sendSos() {
|
||||||
const uint32_t now = rtc_clock.getCurrentTime();
|
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;
|
int16_t alt = 0;
|
||||||
uint32_t age = 0;
|
|
||||||
const bool fix = course.currentLocation(lat, lon);
|
|
||||||
const bool has_alt = fix && course.currentAltitude(alt);
|
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] = "";
|
char hhmm[8] = "";
|
||||||
struct tm ti;
|
struct tm ti;
|
||||||
if (localTm(now, tzHoursAt(_prefs, now), ti)) snprintf(hhmm, sizeof(hhmm), "%02d:%02d", ti.tm_hour, ti.tm_min);
|
if (localTm(now, tzHoursAt(_prefs, now), ti)) snprintf(hhmm, sizeof(hhmm), "%02d:%02d", ti.tm_hour, ti.tm_min);
|
||||||
char text[MAX_TEXT_LEN + 1];
|
char text[MAX_TEXT_LEN + 1];
|
||||||
SosEngine::compose(text, sizeof(text), _prefs->sos_msg, where, lat, lon, has_alt ? &alt : nullptr,
|
SosEngine::compose(text, sizeof(text), _prefs->sos_msg, where, lat, lon, has_alt ? &alt : nullptr,
|
||||||
recent ? (int)(age / 60) : -1, hhmm, _host->battPercent());
|
age_min, hhmm, _host->battPercent());
|
||||||
// Anyone in range sees it on their map too, on any app, not only the
|
|
||||||
// targets: an advert, zero-hop so the mesh isn't flooded every round.
|
|
||||||
if (where != SosEngine::NOWHERE) the_mesh.advertSos(lat, lon);
|
|
||||||
|
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
for (int i = 0; i < MAX_GROUP_CHANNELS; i++)
|
for (int i = 0; i < MAX_GROUP_CHANNELS; i++)
|
||||||
@@ -465,6 +484,7 @@ private:
|
|||||||
}
|
}
|
||||||
const bool first = ok && sos.sent() == 0;
|
const bool first = ok && sos.sent() == 0;
|
||||||
sos.markSent(millis(), fix, ok);
|
sos.markSent(millis(), fix, ok);
|
||||||
|
sosBeacon(where, lat, lon);
|
||||||
events.push(UiEventType::SosSent, nullptr, first);
|
events.push(UiEventType::SosSent, nullptr, first);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
enum Kind : uint8_t { K_HOLD, K_COUNTDOWN, K_REPEAT, K_BEEP, K_CHANNELS, K_DM1, K_DM2, K_DM3, K_ACK, K_MSG, K_START };
|
||||||
struct Row { Kind kind; const char* label; };
|
struct Row { Kind kind; const char* label; };
|
||||||
static const int ROW_COUNT = 11;
|
static const int ROW_COUNT = 11;
|
||||||
|
// 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 Row rows(int i) {
|
||||||
static const Row R[ROW_COUNT] = {
|
static const Row R[ROW_COUNT] = {
|
||||||
{ K_HOLD, "Hold Back" },
|
{ K_HOLD, "Hold Back" },
|
||||||
{ K_COUNTDOWN, "Countdown" },
|
{ K_COUNTDOWN, "Countdown" },
|
||||||
{ K_REPEAT, "Repeat" },
|
|
||||||
{ K_BEEP, "Beep" },
|
|
||||||
{ K_CHANNELS, "Channels" },
|
{ K_CHANNELS, "Channels" },
|
||||||
{ K_DM1, "Contact 1" },
|
{ K_DM1, "Contact 1" },
|
||||||
{ K_DM2, "Contact 2" },
|
{ K_DM2, "Contact 2" },
|
||||||
{ K_DM3, "Contact 3" },
|
{ K_DM3, "Contact 3" },
|
||||||
{ K_ACK, "ACK word" },
|
|
||||||
{ K_MSG, "Message" },
|
{ K_MSG, "Message" },
|
||||||
|
{ K_REPEAT, "Repeat" },
|
||||||
|
{ K_BEEP, "Beep" },
|
||||||
|
{ K_ACK, "ACK word" },
|
||||||
{ K_START, "Send SOS" },
|
{ K_START, "Send SOS" },
|
||||||
};
|
};
|
||||||
return R[i];
|
return R[i];
|
||||||
|
|||||||
@@ -225,6 +225,23 @@ TEST(SosText, NoGpsIsRecognisedOnItsOwn) {
|
|||||||
EXPECT_FALSE(geo::isSos("SOS no GPS"));
|
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) {
|
TEST(LiveTrack, SosOutlivesAShareAndIsEvictedLast) {
|
||||||
LiveTrackStore t;
|
LiveTrackStore t;
|
||||||
t.update(nullptr, "SOS Ann", 1, 2, 1000, false, true);
|
t.update(nullptr, "SOS Ann", 1, 2, 1000, false, true);
|
||||||
@@ -294,10 +311,8 @@ TEST_F(Sos, FirstFixPastTheLastSendStillGoesOut) {
|
|||||||
now += 60000;
|
now += 60000;
|
||||||
}
|
}
|
||||||
EXPECT_FALSE(sos.loop(now, false));
|
EXPECT_FALSE(sos.loop(now, false));
|
||||||
EXPECT_FALSE(sos.finished()); // the GPS is still wanted
|
|
||||||
EXPECT_TRUE(sos.loop(now, true));
|
EXPECT_TRUE(sos.loop(now, true));
|
||||||
sos.markSent(now, true, true);
|
sos.markSent(now, true, true);
|
||||||
EXPECT_TRUE(sos.finished());
|
|
||||||
EXPECT_FALSE(sos.loop(now + 60000, true));
|
EXPECT_FALSE(sos.loop(now + 60000, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,23 +389,66 @@ TEST_F(Sos, ALongAnswerIsCutOnACharacter) {
|
|||||||
EXPECT_EQ((strlen(t) - 4) % 2, 0u);
|
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);
|
sos.start(now);
|
||||||
|
EXPECT_FALSE(sos.beaconDue(now + 600000)); // not in the countdown
|
||||||
now += 10000;
|
now += 10000;
|
||||||
ASSERT_TRUE(sos.loop(now, false));
|
ASSERT_TRUE(sos.loop(now, true));
|
||||||
sos.markSent(now, false, true);
|
sos.markSent(now, true, true);
|
||||||
EXPECT_FALSE(sos.finished());
|
sos.markBeacon(now);
|
||||||
sos.onReply("Bob", "ACK");
|
EXPECT_FALSE(sos.beaconDue(now + 59000));
|
||||||
EXPECT_TRUE(sos.finished());
|
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
|
prefs.sos_repeat_idx = 0; // once
|
||||||
sos.start(now);
|
sos.start(now);
|
||||||
now += 10000;
|
now += 10000;
|
||||||
ASSERT_TRUE(sos.loop(now, true));
|
ASSERT_TRUE(sos.loop(now, true));
|
||||||
sos.markSent(now, true, 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) {
|
TEST_F(Sos, TheStatusLines) {
|
||||||
@@ -414,10 +472,10 @@ TEST_F(Sos, TheStatusLines) {
|
|||||||
prefs.sos_repeat_idx = 0;
|
prefs.sos_repeat_idx = 0;
|
||||||
sos.markSent(now, true, true);
|
sos.markSent(now, true, true);
|
||||||
sos.nextLine(b, sizeof(b), now);
|
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());
|
EXPECT_FALSE(sos.gpsWanted());
|
||||||
sos.start(now);
|
sos.start(now);
|
||||||
EXPECT_TRUE(sos.gpsWanted()); // the countdown already: a fix for the first send
|
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);
|
sos.markSent(now, true, true);
|
||||||
EXPECT_TRUE(sos.gpsWanted());
|
EXPECT_TRUE(sos.gpsWanted());
|
||||||
EXPECT_TRUE(sos.onReply("Ann", "ACK", now));
|
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();
|
sos.stop();
|
||||||
EXPECT_FALSE(sos.gpsWanted());
|
EXPECT_FALSE(sos.gpsWanted());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user