feat(companion): live location sharing, Locator geofencing, trail auto-pause

Squash merge of feat/location-beacon-alerts-autopause (v1.21).

Features:
- Live Location Sharing — broadcast position as movement-gated [LOC]
  messages to a channel or contact; live shares show as map pins with
  distance/bearing in Nearby Nodes and a status-bar indicator. [LOC] is
  parsed in DMs, channel messages and room messages; DM shares name the
  sender.
- Locator (geofence) — arm a geofence around a saved waypoint or a person
  (live [LOC] or last-known position), alert on arrive/leave or near/far,
  with an optional homing beeper (gated to arrive/both modes). Arm from
  Tools > Locator, Nearby Nodes, or Waypoints; target picker lists
  favourites first, clearable via a "None" entry. Active target is drawn
  as a flag on the map.
- One active target shared across Locator / Navigate / Map via a single
  resolver (resolvePersonPos / activeTargetPos) that prefers a live
  [LOC] share over the last-advertised GPS fix.
- Follow live contacts — Navigate to a live-sharing contact follows them
  as they move and adds an ETA line; quick-share your own position from
  the Map.
- Map & status-bar upgrades — home mini-map gets a north marker, scale
  tick, and a connected trail line (was disconnected dots); status line
  shows tracked-node count, an arrow + distance to the active Locator/Nav
  target (falling back to the nearest live-tracked contact); GPS fix icon
  in the top status bar, shown only on GPS boards with GPS enabled.
- Trail auto-pause — recording freezes on stops (banking elapsed time,
  breaking the map line across the idle gap) and resumes on movement
  without ending the session.
- Streaming trail simplification — GPS points are simplified in-stream
  via a fixed-corridor (Reumann-Witkam) pass tuned for fidelity: straight
  runs collapse to their endpoints, curves stay bounded to the Min-dist
  tolerance, so the 512-point buffer covers a far longer route than a
  flat point budget would suggest.
- Collapsible Tools (Location / Comms / System sections, fold-in-place
  like Settings) and page-indicator icons on the home carousel.
- Waypoint coordinate editor — add a waypoint by scroll-editing lat/lon
  digit by digit.

Fixes:
- Critical: low-heap hang and contact loss on RAM-tight builds. Halved
  message-history scrollback rings (recovering ~14 KB free heap) and
  made contacts/channels/prefs persistence atomic (temp file + rename),
  so an interrupted save can no longer corrupt or wipe the store.
- Serial.write() bounded so a stalled USB host can't hang the device.
- Nearby Nodes: live [LOC] senders respect the type filter, sort by
  shared position, and the list refreshes so live shares bubble up.
- Map: live contacts are labelled before waypoints.
- GPS status icon hidden when GPS is off in Settings.
- Splash screen no longer truncates a pre-release tag's own dash (e.g.
  v1.21-rc1) when stripping the build's commit-hash suffix.
- Null-guarded the Locator target picker; clamped loc-share channel
  index on load.

Under the hood:
- -Os size optimisation on the e-ink and GAT562 30S solo envs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MarekZegare4
2026-06-25 19:04:21 +02:00
co-authored by Claude Opus 4.8
parent c935287627
commit 57774d41f3
80 changed files with 2885 additions and 339 deletions
+134 -19
View File
@@ -28,7 +28,7 @@ class NearbyScreen : public UIScreen {
enum Source : uint8_t { SRC_STORED, SRC_SCAN };
// ── action-menu actions (matched by id, not by row index) ────────────────────
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_SORT, ACT_SCAN };
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_LOCATOR, ACT_SORT, ACT_SCAN };
// ── unified list entry ───────────────────────────────────────────────────────
struct Entry {
@@ -44,6 +44,11 @@ class NearbyScreen : public UIScreen {
// scan-source fields
int8_t rssi, snr_x4, remote_snr_x4;
bool is_known;
// live-track ([LOC] share) overlay: this row's position/age came from a
// shared-location message. live_verified == true for a DM (pubkey) share,
// false for a channel (name, best-effort) share.
bool is_live;
bool live_verified;
};
static const int MAX_NEARBY = 32;
@@ -53,6 +58,7 @@ class NearbyScreen : public UIScreen {
int _scroll;
bool _detail;
bool _nav = false; // full-screen navigate-to-node view (over detail)
navview::EtaTracker _nav_eta; // closing-speed/ETA for the navigate view
int32_t _own_lat, _own_lon;
bool _own_gps;
@@ -63,6 +69,7 @@ class NearbyScreen : public UIScreen {
unsigned long _detail_refresh_ms;
unsigned long _list_refresh_ms = 0;
static const unsigned long DETAIL_REFRESH_MS = 10000UL;
static const unsigned long NAV_REFRESH_MS = 1000UL; // navigate view tracks a moving target
static const unsigned long TIME_LIST_REFRESH_MS = 3000UL;
// ── live-scan state ──────────────────────────────────────────────────────────
@@ -178,12 +185,95 @@ class NearbyScreen : public UIScreen {
e.contact_idx = i;
e.lastmod = ci.lastmod;
e.is_known = true;
e.is_live = false;
e.live_verified = false;
}
mergeLiveTrack();
sortStored();
clampSelection();
}
// Rebuild the stored list (re-merge live shares + re-sort) while keeping the
// currently highlighted node selected across the rebuild — by contact index
// for a stored node, or by name for a non-contact live sender. Returns false
// if that node is no longer in the list (caller decides whether to drop the
// detail/nav view). Shared by the list, detail and navigate refresh paths.
bool refreshKeepingSelection() {
int saved_idx = (_sel < _count) ? _entries[_sel].contact_idx : -1;
bool saved_live = (_sel < _count) && _entries[_sel].is_live && saved_idx < 0;
char saved_name[sizeof(_entries[0].name)]; saved_name[0] = '\0';
if (_sel < _count) {
strncpy(saved_name, _entries[_sel].name, sizeof(saved_name) - 1);
saved_name[sizeof(saved_name) - 1] = '\0';
}
refreshStored();
if (saved_idx >= 0) {
for (int i = 0; i < _count; i++)
if (_entries[i].contact_idx == saved_idx) { _sel = i; return true; }
} else if (saved_live && saved_name[0]) {
for (int i = 0; i < _count; i++)
if (_entries[i].is_live && strncmp(_entries[i].name, saved_name, sizeof(saved_name) - 1) == 0)
{ _sel = i; return true; }
}
return false;
}
// Overlay live [LOC] shares onto the stored list: refresh a matching contact
// with the fresher shared position, and append senders who aren't contacts so
// a group sharing on a channel still shows up. DM shares match by pubkey
// prefix (verified); channel shares match by name (best-effort).
void mergeLiveTrack() {
if (!_task) return;
LiveTrackStore& lt = _task->liveTrack();
uint32_t now = rtc_clock.getCurrentTime();
for (int i = 0; i < LiveTrackStore::CAPACITY; i++) {
if (!lt.isActive(i, now)) continue;
const LiveTrackStore::Entry& s = lt.slotAt(i);
int m = -1;
for (int j = 0; j < _count; j++) {
if (s.verified && _entries[j].has_key
&& memcmp(_entries[j].pub_key, s.key, LiveTrackStore::KEY_LEN) == 0) { m = j; break; }
if (!s.verified && strncmp(_entries[j].name, s.name, sizeof(_entries[j].name) - 1) == 0) { m = j; break; }
}
if (m >= 0) {
Entry& e = _entries[m];
// A [LOC] share is an explicit "here I am now", so it defines the pin and
// the distance (used for proximity sorting). Recency for the time sort is
// the most recent of the advert and the share.
e.lat_e6 = s.lat_1e6;
e.lon_e6 = s.lon_1e6;
e.dist_km = _own_gps ? geo::haversineKm(_own_lat, _own_lon, s.lat_1e6, s.lon_1e6) : -1.0f;
if (s.ts > e.lastmod) e.lastmod = s.ts;
e.is_live = true;
e.live_verified = s.verified;
} else if (_count < MAX_NEARBY && typeMatchesFilter(ADV_TYPE_CHAT, 0, false)) {
// A sender we don't have as a contact: treat it as a companion (the only
// node type that shares position), so the type filter still applies —
// e.g. it must not appear under the Repeater/Room/Sensor filters.
Entry& e = _entries[_count++];
memset(&e, 0, sizeof(e));
strncpy(e.name, s.name, sizeof(e.name) - 1);
e.name[sizeof(e.name) - 1] = '\0';
// We only keep a key *prefix* for shares, not the full pubkey, so Ping
// and the base64 key view (which need 32 bytes) stay unavailable for a
// non-contact live entry. Navigate / Save-waypoint work off lat/lon.
e.has_key = false;
e.type = ADV_TYPE_CHAT;
e.lat_e6 = s.lat_1e6;
e.lon_e6 = s.lon_1e6;
e.dist_km = _own_gps ? geo::haversineKm(_own_lat, _own_lon, s.lat_1e6, s.lon_1e6) : -1.0f;
e.lastmod = s.ts;
e.contact_idx = -1;
e.is_known = false;
e.is_live = true;
e.live_verified = s.verified;
}
}
}
void sortStored() {
uint32_t now_ts = rtc_clock.getCurrentTime();
for (int i = 0; i < _count - 1; i++) {
@@ -223,6 +313,8 @@ class NearbyScreen : public UIScreen {
e.dist_km = -1.0f;
e.lastmod = 0;
e.contact_idx = -1;
e.is_live = false;
e.live_verified = false;
}
// strongest first
for (int i = 0; i < _count - 1; i++) {
@@ -367,7 +459,7 @@ class NearbyScreen : public UIScreen {
buildSortLabel();
_menu_action_count = 0;
_menu.begin("Options", 5);
_menu.begin("Options", 7);
auto add = [&](const char* label, Action a) {
_menu.addItem(label);
_menu_actions[_menu_action_count++] = a;
@@ -376,6 +468,9 @@ class NearbyScreen : public UIScreen {
if (has_gps) add("Navigate", ACT_NAV);
if (has_key) add("Ping", ACT_PING);
if (has_gps) add("Save waypoint", ACT_WAYPOINT);
// Needs both a position and a stable identity — a person target is keyed
// by pubkey prefix, so a name-only live-scan/channel row can't offer this.
if (has_gps && has_key) add("Set as target", ACT_LOCATOR);
if (stored) add(_sort_label, ACT_SORT); // sort is meaningless for live-scan rows
add(stored ? "Discover scan" : "Rescan", ACT_SCAN);
}
@@ -384,7 +479,7 @@ class NearbyScreen : public UIScreen {
switch (a) {
case ACT_NAV: {
const Entry* e = selected();
if (e && (e->lat_e6 != 0 || e->lon_e6 != 0)) _nav = true;
if (e && (e->lat_e6 != 0 || e->lon_e6 != 0)) { _nav = true; _nav_eta.reset(); }
else _task->showAlert("No node GPS", 1000);
break;
}
@@ -396,6 +491,12 @@ class NearbyScreen : public UIScreen {
break;
}
case ACT_WAYPOINT: saveSelectedWaypoint(); break;
case ACT_LOCATOR: {
const Entry* e = selected();
if (e && e->has_key && (e->lat_e6 != 0 || e->lon_e6 != 0))
_task->setTargetNow(1, e->pub_key, e->lat_e6, e->lon_e6, e->name);
break;
}
case ACT_SORT: break; // adjusted in-place via LEFT/RIGHT, not ENTER
case ACT_SCAN: enterScan(); break;
}
@@ -428,7 +529,10 @@ class NearbyScreen : public UIScreen {
display.setCursor(2, hdr + step * 3); display.print(buf);
char age[16];
fmtAge(age, sizeof(age), e.lastmod);
snprintf(buf, sizeof(buf), "Seen: %s", age);
// For a live [LOC] row, label the timestamp as a position share and note
// whether the sender's identity is verified (DM) or name-only (channel).
if (e.is_live) snprintf(buf, sizeof(buf), "Sharing pos: %s %s", age, e.live_verified ? "(DM)" : "(chan)");
else snprintf(buf, sizeof(buf), "Seen: %s", age);
display.drawTextEllipsized(2, hdr + step * 4, display.width() - 4, buf);
}
@@ -507,16 +611,16 @@ public:
int render(DisplayDriver& display) override {
display.setTextSize(1);
// periodic refresh in stored-detail — preserve selected contact by idx
if (_detail && _source == SRC_STORED &&
millis() - _detail_refresh_ms >= DETAIL_REFRESH_MS) {
int saved = (_sel < _count) ? _entries[_sel].contact_idx : -1;
refreshStored();
bool found = false;
if (saved >= 0)
for (int i = 0; i < _count; i++)
if (_entries[i].contact_idx == saved) { _sel = i; found = true; break; }
if (!found) { _detail = false; _nav = false; } // contact gone — drop both views
// Periodic refresh of the selected entry while in detail or navigate view,
// preserving the selection across the list rebuild. Navigate refreshes
// faster so a moving live target (a contact sharing [LOC]) tracks smoothly.
// Re-selection keys on contact index for stored nodes, and on name for a
// non-contact live sender — without the latter, navigating to someone who
// only shares on a channel would drop out on the first refresh.
unsigned long refresh_due = _nav ? NAV_REFRESH_MS : DETAIL_REFRESH_MS;
if ((_detail || _nav) && _source == SRC_STORED &&
millis() - _detail_refresh_ms >= refresh_due) {
if (!refreshKeepingSelection()) { _detail = false; _nav = false; } // node/share gone
_detail_refresh_ms = millis();
}
@@ -525,7 +629,7 @@ public:
const Entry& e = _entries[_sel];
int cog; bool cogv = _task->currentCourse(cog);
navview::draw(display, _own_gps, _own_lat, _own_lon,
e.lat_e6, e.lon_e6, e.name, cogv, cog, useImperial());
e.lat_e6, e.lon_e6, e.name, cogv, cog, useImperial(), &_nav_eta);
return 1000;
}
@@ -541,9 +645,11 @@ public:
if (_source == SRC_SCAN) {
refreshScan();
if (_scanning && millis() - _scan_started_ms >= SCAN_DURATION_MS) _scanning = false;
} else if (_sort == SORT_TIME && millis() - _list_refresh_ms >= TIME_LIST_REFRESH_MS) {
// re-sort periodically so newly-heard contacts bubble up
refreshStored();
} else if (millis() - _list_refresh_ms >= TIME_LIST_REFRESH_MS) {
// Re-merge + re-sort periodically so newly-heard contacts and fresh live
// [LOC] shares bubble to the right spot under either sort (distances move
// as you and they do, not just recency). Keep the highlighted node put.
refreshKeepingSelection();
_list_refresh_ms = millis();
}
@@ -589,11 +695,20 @@ public:
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
char filt[32];
int tx = 2;
if (e.is_live) {
// Diamond marker = this node is broadcasting its position ([LOC]),
// the same marker the map uses for a live-tracked contact. DM-verified
// vs channel-only is spelled out in the detail view.
int iw = ICON_MAP_CONTACT.w * miniIconScale(display);
miniIconDrawCentered(display, 2 + iw / 2, y + display.getLineHeight() / 2 - 1, ICON_MAP_CONTACT);
tx = 2 + iw + 2;
}
display.translateUTF8ToBlocks(filt, e.name, sizeof(filt));
if (_source == SRC_SCAN && !e.name[0]) { // unknown node → "[Type]"
snprintf(filt, sizeof(filt), "[%s]", typeName(e.type));
}
display.drawTextEllipsized(2, y, dist_col - 4, filt);
display.drawTextEllipsized(tx, y, dist_col - tx - 2, filt);
display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT);
char right[10];