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