feat(companion): follow live contacts — nav, geo-alert, ETA, quick-share

Builds on live location sharing:
- Nearby "Navigate" now follows a moving live target: the navigate view
  re-reads the shared position every second and re-locates the entry by
  name for non-contact senders, so it no longer freezes or drops out
  (previously only refreshed for stored contacts entered via detail).
- NavView gains a closing-speed / ETA line (EtaTracker), shown in the
  Nearby navigate view while approaching.
- Geo Alert target can be a live contact, not just a waypoint: the engine
  re-reads the contact's latest [LOC] position each evaluation (keyed by
  pubkey prefix) so the geofence follows a moving person; alert wording
  switches to Near/Away. The picker cycles waypoints + verified live
  contacts (shown with an '@' prefix). Schema bumped 0xC0DE0014 -> 0x15.
- Nearby marks a position-broadcasting node with the map's ♦ diamond
  (was a cryptic '*'/'~' prefix) and spells out "Sharing pos:" in detail.
- Home Map page Hold-Enter is a one-shot position share: pushes to the
  Live Share target while auto-sharing is on, else opens the picker.

Verified: WioTrackerL1_companion_solo_dual builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MarekZegare4
2026-06-22 13:09:51 +02:00
parent 44e522eec9
commit dcc4379f63
8 changed files with 194 additions and 45 deletions

View File

@@ -394,6 +394,10 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
// → 0xC0DE0014: geo-alert proximity beeper.
rd(&_prefs.geo_alert_beeper, sizeof(_prefs.geo_alert_beeper));
if (_prefs.geo_alert_beeper > 1) _prefs.geo_alert_beeper = 0;
// → 0xC0DE0015: geo-alert can target a live contact (kind + pubkey prefix).
rd(&_prefs.geo_alert_target_kind, sizeof(_prefs.geo_alert_target_kind));
rd(_prefs.geo_alert_key, sizeof(_prefs.geo_alert_key));
if (_prefs.geo_alert_target_kind > 1) _prefs.geo_alert_target_kind = 0;
// Pre-0x10 files leave stray sentinel bytes here, same as a never-configured
// device. Either way there's no valid saved profile, so default to a profile
// in the same band as the companion's own network (_prefs.freq, already read
@@ -590,6 +594,8 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)_prefs.geo_alert_label, sizeof(_prefs.geo_alert_label));
file.write((uint8_t *)&_prefs.trail_autopause_idx, sizeof(_prefs.trail_autopause_idx));
file.write((uint8_t *)&_prefs.geo_alert_beeper, sizeof(_prefs.geo_alert_beeper));
file.write((uint8_t *)&_prefs.geo_alert_target_kind, sizeof(_prefs.geo_alert_target_kind));
file.write((uint8_t *)_prefs.geo_alert_key, sizeof(_prefs.geo_alert_key));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL.
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;

View File

@@ -78,6 +78,19 @@ public:
return n;
}
// Latest active position for a verified (DM/pubkey) share, or null if that
// node hasn't shared recently. Used by the geo-alert engine to follow a
// moving contact.
const Entry* activeByKey(const uint8_t* key, uint32_t now) const {
if (!key) return nullptr;
for (int i = 0; i < CAPACITY; i++) {
if (!isActive(i, now)) continue;
const Entry& e = _e[i];
if (e.verified && memcmp(e.key, key, KEY_LEN) == 0) return &e;
}
return nullptr;
}
private:
Entry _e[CAPACITY] = {};

View File

@@ -244,9 +244,14 @@ struct NodePrefs { // persisted to file
uint8_t geo_alert_has_target; // 0=no target chosen yet, 1=target set
uint8_t geo_alert_radius_idx; // index into geoAlertRadiusMeters
uint8_t geo_alert_mode; // 0=arrive, 1=leave, 2=both
int32_t geo_alert_lat_1e6; // target latitude (1e6-scaled)
int32_t geo_alert_lon_1e6; // target longitude (1e6-scaled)
int32_t geo_alert_lat_1e6; // target latitude (1e6-scaled; last-known for a contact)
int32_t geo_alert_lon_1e6; // target longitude (1e6-scaled; last-known for a contact)
char geo_alert_label[12]; // target name for the alert text (WAYPOINT_LABEL_LEN)
// Target can be a static waypoint or a live contact: for a contact the engine
// re-reads the latest [LOC] position each evaluation (keyed by pubkey prefix),
// so the geofence follows a moving person ("alert when my friend is near").
uint8_t geo_alert_target_kind; // 0=waypoint (static), 1=live contact
uint8_t geo_alert_key[6]; // contact pubkey prefix when target_kind==1
// Trail auto-pause — when tracking, automatically freeze the trail (timer +
// sampling) after the device has sat still for this long, and resume on the
@@ -310,7 +315,7 @@ struct NodePrefs { // persisted to file
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
// older saves are detected on load and skipped (zero-init defaults kept).
// High 24 bits identify the file format; low byte is the schema revision.
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0014;
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0015;
// Bit-index for each home page. Used by page_order (entries store bit+1) and
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage

View File

@@ -9,6 +9,7 @@
#include "../NodePrefs.h"
#include "../Waypoint.h"
#include "../LiveTrack.h"
#include "icons.h" // drawList (shared scrolling-list helper)
class GeoAlertScreen : public UIScreen {
@@ -43,12 +44,14 @@ public:
snprintf(buf, n, "%s", (_prefs && _prefs->geo_alert_enabled) ? "ON" : "OFF");
break;
case K_TARGET:
if (_prefs && _prefs->geo_alert_has_target && _prefs->geo_alert_label[0])
snprintf(buf, n, "%s", _prefs->geo_alert_label);
else if (_prefs && _prefs->geo_alert_has_target)
snprintf(buf, n, "(unnamed)");
else
if (_prefs && _prefs->geo_alert_has_target) {
const char* nm = _prefs->geo_alert_label[0] ? _prefs->geo_alert_label : "(unnamed)";
// '@' prefix marks a live contact target (a moving person) vs a waypoint.
if (_prefs->geo_alert_target_kind == 1) snprintf(buf, n, "@%s", nm);
else snprintf(buf, n, "%s", nm);
} else {
snprintf(buf, n, "none");
}
break;
case K_RADIUS: {
uint16_t r = NodePrefs::geoAlertRadiusMeters(_prefs ? _prefs->geo_alert_radius_idx : 1);
@@ -118,27 +121,48 @@ public:
_task->resetGeoAlert();
}
// Cycle the target through the saved waypoints, snapshotting the chosen one's
// coordinate + label into prefs so the alert is independent of later edits.
// Cycle the target through saved waypoints (static) and verified live contacts
// (a person sharing [LOC] over a DM), snapshotting the chosen one into prefs.
// A waypoint stores coord+label; a contact stores the pubkey prefix so the
// engine can follow its live position, plus the name and last-known coord.
void cycleTarget(int dir) {
WaypointStore& wp = _task->waypoints();
int total = wp.count();
if (total == 0) { _task->showAlert("Mark a waypoint first", 1400); return; }
WaypointStore& wp = _task->waypoints();
LiveTrackStore& lt = _task->liveTrack();
uint32_t now = rtc_clock.getCurrentTime();
// Locate the current target among the waypoints (by coordinate match).
struct T { uint8_t kind; int idx; }; // kind 0=waypoint, 1=live contact (slot)
T list[32]; int n = 0;
for (int i = 0; i < wp.count() && n < 32; i++) list[n++] = { 0, i };
for (int i = 0; i < LiveTrackStore::CAPACITY && n < 32; i++)
if (lt.isActive(i, now) && lt.slotAt(i).verified) list[n++] = { 1, i };
if (n == 0) { _task->showAlert("No waypoints / live", 1400); return; }
// Locate the current target in the combined list.
int cur = -1;
if (_prefs->geo_alert_has_target) {
for (int i = 0; i < total; i++)
if (wp.at(i).lat_1e6 == _prefs->geo_alert_lat_1e6 &&
wp.at(i).lon_1e6 == _prefs->geo_alert_lon_1e6) { cur = i; break; }
for (int i = 0; i < n; i++) {
if (list[i].kind == 0 && _prefs->geo_alert_target_kind == 0) {
const Waypoint& w = wp.at(list[i].idx);
if (w.lat_1e6 == _prefs->geo_alert_lat_1e6 && w.lon_1e6 == _prefs->geo_alert_lon_1e6) { cur = i; break; }
} else if (list[i].kind == 1 && _prefs->geo_alert_target_kind == 1) {
if (memcmp(lt.slotAt(list[i].idx).key, _prefs->geo_alert_key, LiveTrackStore::KEY_LEN) == 0) { cur = i; break; }
}
}
int nx = (cur < 0) ? (dir >= 0 ? 0 : total - 1)
: ((cur + (dir >= 0 ? 1 : total - 1)) % total);
int nx = (cur < 0) ? (dir >= 0 ? 0 : n - 1) : ((cur + (dir >= 0 ? 1 : n - 1)) % n);
const Waypoint& w = wp.at(nx);
_prefs->geo_alert_lat_1e6 = w.lat_1e6;
_prefs->geo_alert_lon_1e6 = w.lon_1e6;
snprintf(_prefs->geo_alert_label, sizeof(_prefs->geo_alert_label), "%s", w.label);
if (list[nx].kind == 0) {
const Waypoint& w = wp.at(list[nx].idx);
_prefs->geo_alert_target_kind = 0;
_prefs->geo_alert_lat_1e6 = w.lat_1e6;
_prefs->geo_alert_lon_1e6 = w.lon_1e6;
snprintf(_prefs->geo_alert_label, sizeof(_prefs->geo_alert_label), "%s", w.label);
} else {
const LiveTrackStore::Entry& e = lt.slotAt(list[nx].idx);
_prefs->geo_alert_target_kind = 1;
memcpy(_prefs->geo_alert_key, e.key, LiveTrackStore::KEY_LEN);
_prefs->geo_alert_lat_1e6 = e.lat_1e6;
_prefs->geo_alert_lon_1e6 = e.lon_1e6;
snprintf(_prefs->geo_alert_label, sizeof(_prefs->geo_alert_label), "%s", e.name);
}
_prefs->geo_alert_has_target = 1;
_dirty = true;
}

View File

@@ -12,14 +12,42 @@
namespace navview {
// Closing-speed / ETA estimator. The caller keeps one instance per navigation
// session and passes it to draw(), which feeds it the live target distance each
// frame. Speed is the rate the gap closes (smoothed), so it works for both a
// fixed waypoint and a moving live contact; ETA = remaining distance / closing
// speed, shown only while actually approaching.
struct EtaTracker {
float prev_km = -1.0f;
uint32_t prev_ms = 0;
float closing_kmh = 0.0f; // > 0 = gap shrinking (approaching)
bool have = false;
void reset() { prev_km = -1.0f; have = false; closing_kmh = 0.0f; }
void update(float dist_km, uint32_t now_ms) {
if (prev_km >= 0.0f && now_ms > prev_ms) {
float dt_h = (now_ms - prev_ms) / 3600000.0f;
if (dt_h > 0.0f) {
float inst = (prev_km - dist_km) / dt_h; // +approaching, -receding
closing_kmh = have ? (closing_kmh * 0.6f + inst * 0.4f) : inst; // light EMA
have = true;
}
}
prev_km = dist_km;
prev_ms = now_ms;
}
};
// own_valid : is there a usable GPS fix for the device right now
// cog_valid : is there a stable course-over-ground (UITask::currentCourse)
// eta : optional closing-speed/ETA tracker; when supplied a third line is
// shown with the approach speed and estimated time of arrival
inline void draw(DisplayDriver& d,
bool own_valid, int32_t own_lat, int32_t own_lon,
int32_t tgt_lat, int32_t tgt_lon,
const char* label,
bool cog_valid, int cog_deg,
bool imperial) {
bool imperial,
EtaTracker* eta = nullptr) {
const int cx = d.width() / 2;
const int hdr = d.headerH();
@@ -52,6 +80,23 @@ inline void draw(DisplayDriver& d,
if (cog_valid) snprintf(line, sizeof(line), "Hdg: %d %s", cog_deg, geo::bearingCardinal(cog_deg));
else snprintf(line, sizeof(line), "Hdg: --");
d.drawTextLeftAlign(2, y, line); y += step;
// Optional ETA / closing-speed line. Approaching only — a receding or
// stationary target shows "ETA: --" rather than a misleading time.
if (eta) {
eta->update(dist_km, millis());
if (eta->have && eta->closing_kmh > 0.3f) {
int secs = (int)(dist_km / eta->closing_kmh * 3600.0f);
float spd = imperial ? eta->closing_kmh * 0.621371f : eta->closing_kmh;
char eta_s[12];
if (secs < 3600) snprintf(eta_s, sizeof(eta_s), "%dm", (secs + 59) / 60);
else snprintf(eta_s, sizeof(eta_s), "%dh%02dm", secs / 3600, (secs % 3600) / 60);
snprintf(line, sizeof(line), "ETA: %s @%d%s", eta_s, (int)(spd + 0.5f), imperial ? "mph" : "kmh");
} else {
snprintf(line, sizeof(line), "ETA: --");
}
d.drawTextLeftAlign(2, y, line); y += step;
}
}
} // namespace navview

View File

@@ -58,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;
@@ -68,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 ──────────────────────────────────────────────────────────
@@ -447,7 +449,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;
}
@@ -493,7 +495,7 @@ class NearbyScreen : public UIScreen {
fmtAge(age, sizeof(age), e.lastmod);
// 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), "Loc: %s %s", age, e.live_verified ? "(DM)" : "(chan~)");
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);
}
@@ -573,16 +575,33 @@ 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;
// 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) {
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();
bool found = false;
if (saved >= 0)
if (saved_idx >= 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
if (_entries[i].contact_idx == saved_idx) { _sel = i; found = true; break; }
} 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; found = true; break; }
}
if (!found) { _detail = false; _nav = false; } // node/share gone — drop both views
_detail_refresh_ms = millis();
}
@@ -591,7 +610,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;
}
@@ -655,18 +674,20 @@ public:
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
char filt[32];
int tx = 2;
if (e.is_live) {
// Mark a live [LOC] row: '*' = verified DM share, '~' = channel share.
char nm[34];
snprintf(nm, sizeof(nm), "%s%s", e.live_verified ? "*" : "~", e.name);
display.translateUTF8ToBlocks(filt, nm, sizeof(filt));
} else {
display.translateUTF8ToBlocks(filt, e.name, sizeof(filt));
// 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];

View File

@@ -1225,6 +1225,10 @@ public:
_task->gotoDashboardConfig();
return true;
}
if (c == KEY_CONTEXT_MENU && _page == HomePage::MAP) {
_task->quickShareMyLocation();
return true;
}
return false;
}
};
@@ -2129,10 +2133,20 @@ void UITask::loop() {
// evaluator and the proximity beeper.
bool UITask::geoAlertDistance(float& dist_m, float& radius_m) const {
if (!_node_prefs || !_node_prefs->geo_alert_has_target) return false;
int32_t tlat, tlon;
if (_node_prefs->geo_alert_target_kind == 1) {
// Live contact: follow the latest [LOC] position. No recent share → no
// distance to evaluate (engine waits rather than using a stale fix).
const LiveTrackStore::Entry* e =
_livetrack.activeByKey(_node_prefs->geo_alert_key, (uint32_t)rtc_clock.getCurrentTime());
if (!e) return false;
tlat = e->lat_1e6; tlon = e->lon_1e6;
} else {
tlat = _node_prefs->geo_alert_lat_1e6; tlon = _node_prefs->geo_alert_lon_1e6;
}
int32_t lat, lon;
if (!currentLocation(lat, lon)) return false;
dist_m = geo::haversineKm(lat, lon, _node_prefs->geo_alert_lat_1e6,
_node_prefs->geo_alert_lon_1e6) * 1000.0f;
dist_m = geo::haversineKm(lat, lon, tlat, tlon) * 1000.0f;
radius_m = (float)NodePrefs::geoAlertRadiusMeters(_node_prefs->geo_alert_radius_idx);
return true;
}
@@ -2160,8 +2174,12 @@ void UITask::evaluateGeoAlert() {
void UITask::fireGeoAlert(bool arrived) {
const char* lbl = _node_prefs->geo_alert_label[0] ? _node_prefs->geo_alert_label : "target";
bool person = _node_prefs->geo_alert_target_kind == 1;
char msg[40];
snprintf(msg, sizeof(msg), arrived ? "Arrived: %s" : "Left: %s", lbl);
// "Near/Away" reads naturally for a moving person; "Arrived/Left" for a place.
snprintf(msg, sizeof(msg),
arrived ? (person ? "Near: %s" : "Arrived: %s")
: (person ? "Away: %s" : "Left: %s"), lbl);
showAlert(msg, 3000);
if (!isBuzzerQuiet())
playMelody(arrived ? "geoarr:d=8,o=6,b=140:c,e,g" : "geolv:d=8,o=6,b=140:g,e,c");
@@ -2277,6 +2295,22 @@ bool UITask::sendLocationShare(int32_t lat, int32_t lon) {
return the_mesh.sendMessage(*c, rtc_clock.getCurrentTime(), 0, text, expected_ack, est_timeout) > 0;
}
// One-shot "share my position" from the home Map page (Hold Enter). When live
// sharing is already on, push an immediate [LOC] to the same target; otherwise
// hand a [LOC] message to the recipient picker so the user chooses where it
// goes (no accidental broadcast to a default channel).
void UITask::quickShareMyLocation() {
int32_t lat, lon;
if (!currentLocation(lat, lon)) { showAlert("No GPS fix", 1000); return; }
if (_node_prefs && _node_prefs->loc_share_enabled && sendLocationShare(lat, lon)) {
showAlert("Position shared", 900);
return;
}
char text[40];
snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f", lat / 1e6, lon / 1e6);
shareToMessage(text);
}
void UITask::saveWaypoints() {
DataStore* ds = the_mesh.getDataStore();
if (!ds) return;

View File

@@ -179,6 +179,7 @@ public:
void gotoQuickMsgScreen();
void openContactDM(const ContactInfo& ci);
void shareToMessage(const char* text); // open Messages pre-loaded to share `text`
void quickShareMyLocation(); // Home Map Hold-Enter: one-shot position share
void pickLocShareTarget(); // open Messages to choose the live-share target
int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const;
void gotoToolsScreen();