feat(companion): live location sharing via [LOC] messages

Share and track positions over the mesh through chat messages, separate
from (and parallel to) the 0-hop auto-advert.

Receive / tracking:
- geo::parseLocShare + [LOC] tag (sibling to [WAY]); LiveTrackStore (16
  slots, DM=pubkey/verified, channel=name/best-effort, 20-min expiry).
- MyMesh parses [LOC] on DM + channel receive → UITask live-track table.
- NearbyScreen overlays shares onto contacts (fresher pin) and lists
  non-contact senders; markers (*=DM, ~=chan) + "Loc:" age/verified line.

Send:
- Map menu "Share my pos" (one-shot) → Messages recipient picker.
- New Tools > Live Share tool: Receive (Track loc) + Send (Auto, Target,
  Move/Min gap/Heartbeat, Share now). Target chosen via the Messages
  picker (channel/DM). Movement-gated auto-send engine in UITask loop.

Map / navigation:
- Trail map shows tracked contacts as ◆ markers (ICON_MAP_CONTACT).
- Home "Map" page: mini-map preview + status line; ENTER opens the tool,
  Cancel returns Home.

Prefs: track_shared_loc + loc_share_* (NodePrefs schema 0xC0DE0012).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MarekZegare4
2026-06-22 10:13:09 +02:00
co-authored by Claude Opus 4.8
parent 29deaaad44
commit d78e74d4cd
14 changed files with 816 additions and 21 deletions
+164
View File
@@ -123,6 +123,7 @@ static const int QUICK_MSGS_MAX = 10;
#include "NearbyScreen.h"
#include "DashboardConfigScreen.h"
#include "AutoAdvertScreen.h"
#include "LiveShareScreen.h"
#include "TrailScreen.h"
#include "CompassScreen.h"
#include "DiagnosticsScreen.h"
@@ -240,6 +241,7 @@ class HomeScreen : public UIScreen {
SENSORS,
#endif
SETTINGS,
MAP,
TOOLS,
QUICK_MSG,
SHUTDOWN,
@@ -527,6 +529,65 @@ public:
}
}
// Compact map preview for the Home "Map" page: own position, the GPS trail,
// and live-tracked contacts (◆) folded into one auto-scaled box. A simplified
// cousin of TrailScreen's map (dots, no grid/labels) so the home carousel
// stays light. Returns false (and draws nothing) when there's nothing to show.
bool drawMapPreview(DisplayDriver& display, int ax, int ay, int aw, int ah) {
if (aw < 8 || ah < 8) return false;
bool init = false;
int32_t mnla = 0, mxla = 0, mnlo = 0, mxlo = 0;
auto fold = [&](int32_t la, int32_t lo) {
if (!init) { mnla = mxla = la; mnlo = mxlo = lo; init = true; }
else { if (la < mnla) mnla = la; if (la > mxla) mxla = la;
if (lo < mnlo) mnlo = lo; if (lo > mxlo) mxlo = lo; }
};
TrailStore& tr = _task->trail();
if (!tr.empty()) { int32_t a, b, c, d; tr.boundingBox(a, b, c, d); fold(a, b); fold(c, d); }
LiveTrackStore& lt = _task->liveTrack();
uint32_t now = rtc_clock.getCurrentTime();
for (int i = 0; i < LiveTrackStore::CAPACITY; i++)
if (lt.isActive(i, now)) fold(lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6);
int32_t mla, mlo;
bool have_gps = _task->currentLocation(mla, mlo);
if (have_gps) fold(mla, mlo);
if (!init) return false;
int cx = ax + aw / 2, cy = ay + ah / 2;
// Degenerate: one coincident point — just centre the markers.
if (mnla == mxla && mnlo == mxlo) {
for (int i = 0; i < LiveTrackStore::CAPACITY; i++)
if (lt.isActive(i, now)) { miniIconDrawCentered(display, cx, cy, ICON_MAP_CONTACT); break; }
if (have_gps || !tr.empty()) miniIconDrawCentered(display, cx, cy, ICON_MAP_CURRENT);
return true;
}
float avg_lat_rad = ((mnla + mxla) / 2.0e6f) * (float)M_PI / 180.0f;
float lon_scale = cosf(avg_lat_rad); if (lon_scale < 0.05f) lon_scale = 0.05f;
float lat_span = (float)(mxla - mnla);
float lon_span = (float)(mxlo - mnlo) * lon_scale;
float slat = (float)ah / (lat_span > 0 ? lat_span : 1.0f);
float slon = (float)aw / (lon_span > 0 ? lon_span : 1.0f);
float scale = (slat < slon) ? slat : slon;
int off_x = ax + (aw - (int)(lon_span * scale)) / 2;
int off_y = ay + (ah - (int)(lat_span * scale)) / 2;
auto project = [&](int32_t la, int32_t lo, int& px, int& py) {
px = off_x + (int)((float)(lo - mnlo) * lon_scale * scale);
py = off_y + (int)((float)(mxla - la) * scale);
};
// Trail as dots (no line — gfx isn't available this early in the TU).
for (int i = 0; i < tr.count(); i++) {
int px, py; project(tr.at(i).lat_1e6, tr.at(i).lon_1e6, px, py);
display.fillRect(px, py, 1, 1);
}
for (int i = 0; i < LiveTrackStore::CAPACITY; i++) {
if (!lt.isActive(i, now)) continue;
int px, py; project(lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6, px, py);
miniIconDrawCentered(display, px, py, ICON_MAP_CONTACT);
}
if (have_gps) { int px, py; project(mla, mlo, px, py); miniIconDrawCentered(display, px, py, ICON_MAP_CURRENT); }
return true;
}
int render(DisplayDriver& display) override {
char tmp[80];
display.setTextSize(1);
@@ -891,6 +952,23 @@ public:
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, content_y, "Settings");
display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open");
} else if (_page == HomePage::MAP) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
// Mini-map preview filling the page, with one status line at the bottom.
int info_y = display.height() - step;
int area_h = info_y - content_y - 2;
bool drew = drawMapPreview(display, 2, content_y, display.width() - 4, area_h);
char info[40];
int32_t la, lo;
bool fix = _task->currentLocation(la, lo);
int trk = _task->liveTrack().active(rtc_clock.getCurrentTime());
snprintf(info, sizeof(info), "Fix:%s Track:%d %s",
fix ? "y" : "n", trk, PRESS_LABEL);
display.setColor(DisplayDriver::LIGHT);
if (!drew)
display.drawTextCentered(display.width() / 2, content_y + area_h / 2, "No GPS / no trail");
display.drawTextCentered(display.width() / 2, info_y, info);
} else if (_page == HomePage::TOOLS) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
@@ -1127,6 +1205,10 @@ public:
_task->gotoSettingsScreen();
return true;
}
if (c == KEY_ENTER && _page == HomePage::MAP) {
_task->gotoMapScreen();
return true;
}
if (c == KEY_ENTER && _page == HomePage::TOOLS) {
_task->gotoToolsScreen();
return true;
@@ -1212,6 +1294,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
nearby_screen = new NearbyScreen(this);
dashboard_config = new DashboardConfigScreen(this, node_prefs);
auto_advert_screen = new AutoAdvertScreen(this, node_prefs);
live_share_screen = new LiveShareScreen(this, node_prefs);
trail_screen = new TrailScreen(this, &_trail);
compass_screen = new CompassScreen(this);
diag_screen = new DiagnosticsScreen(this);
@@ -1257,6 +1340,11 @@ void UITask::gotoTrailScreen() {
setCurrScreen(trail_screen);
}
void UITask::gotoMapScreen() {
((TrailScreen*)trail_screen)->enterMap();
setCurrScreen(trail_screen);
}
void UITask::gotoCompassScreen() {
((CompassScreen*)compass_screen)->enter();
setCurrScreen(compass_screen);
@@ -1271,6 +1359,11 @@ void UITask::gotoRepeaterScreen() {
setCurrScreen(repeater_screen);
}
void UITask::gotoLiveShareScreen() {
((LiveShareScreen*)live_share_screen)->enter();
setCurrScreen(live_share_screen);
}
void UITask::gotoAutoAdvertScreen() {
((AutoAdvertScreen*)auto_advert_screen)->enter();
setCurrScreen(auto_advert_screen);
@@ -1364,6 +1457,11 @@ void UITask::shareToMessage(const char* text) {
setCurrScreen(quick_msg);
}
void UITask::pickLocShareTarget() {
((QuickMsgScreen*)quick_msg)->startPickTarget();
setCurrScreen(quick_msg);
}
int UITask::getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const {
return ((QuickMsgScreen*)quick_msg)->getRecentDMContacts(out, max);
}
@@ -1931,6 +2029,45 @@ void UITask::loop() {
}
}
// Live-track housekeeping — drop shared positions that have gone stale, so
// the Nearby "Live" view / map don't show ghosts. Cheap; once a minute.
if ((int32_t)(millis() - _next_livetrack_expire_ms) >= 0) {
_next_livetrack_expire_ms = millis() + 60000UL;
_livetrack.expire((uint32_t)rtc_clock.getCurrentTime());
}
// Live location sharing — periodically broadcast my [LOC] to the configured
// target while moving (Map Live share). Movement-gated so a stationary
// device stays quiet unless a heartbeat is configured.
if (_node_prefs && _node_prefs->loc_share_enabled
&& (int32_t)(millis() - _next_loc_share_check_ms) >= 0) {
_next_loc_share_check_ms = millis() + 2000UL;
if (!_loc_share_was_enabled) _loc_share_has_last = false; // re-announce on enable
_loc_share_was_enabled = true;
int32_t lat, lon;
if (currentLocation(lat, lon)) {
uint16_t move_m = NodePrefs::locShareMoveMeters(_node_prefs->loc_share_move_idx);
uint16_t gap_s = NodePrefs::locShareIntervalSecs(_node_prefs->loc_share_interval_idx);
uint16_t hb_s = NodePrefs::locShareHeartbeatSecs(_node_prefs->loc_share_heartbeat_idx);
uint32_t now = millis();
bool first = !_loc_share_has_last;
float moved = first ? 1e9f
: geo::haversineKm(_loc_share_last_lat, _loc_share_last_lon, lat, lon) * 1000.0f;
bool gap_ok = first || (now - _loc_share_last_ms) >= (uint32_t)gap_s * 1000UL;
bool hb_due = (hb_s > 0) && !first && (now - _loc_share_last_ms) >= (uint32_t)hb_s * 1000UL;
if ((moved >= (float)move_m && gap_ok) || first || hb_due) {
if (sendLocationShare(lat, lon)) {
_loc_share_last_lat = lat;
_loc_share_last_lon = lon;
_loc_share_last_ms = now;
_loc_share_has_last = true;
}
}
}
} else if (_node_prefs && !_node_prefs->loc_share_enabled) {
_loc_share_was_enabled = false;
}
// Course-over-ground sampling — every ~1 s regardless of trail state, so the
// heading is available to navigation even when not recording a trail.
if ((int32_t)(millis() - _next_cog_sample_ms) >= 0) {
@@ -1997,6 +2134,33 @@ bool UITask::currentLocation(int32_t& lat, int32_t& lon) const {
return false;
}
// A peer broadcast its position via a [LOC] message (parsed in MyMesh). Record
// it in the live-track table for the Nearby "Live" view / map. Gated on the
// user preference so it stays opt-in.
void UITask::onSharedLocation(const uint8_t* pub_key, const char* name,
int32_t lat_1e6, int32_t lon_1e6,
uint32_t ts, bool verified) {
if (!_node_prefs || !_node_prefs->track_shared_loc) return;
_livetrack.update(pub_key, name, lat_1e6, lon_1e6, ts, verified);
}
bool UITask::sendLocationShare(int32_t lat, int32_t lon) {
if (!_node_prefs) return false;
char text[40];
snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f", lat / 1e6, lon / 1e6);
if (_node_prefs->loc_share_target_type == 0) {
ChannelDetails ch;
if (!the_mesh.getChannel(_node_prefs->loc_share_channel_idx, ch)) return false;
return the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel,
the_mesh.getNodeName(), text, strlen(text));
}
ContactInfo* c = the_mesh.lookupContactByPubKey(_node_prefs->loc_share_dm_prefix,
NodePrefs::FAVOURITE_PREFIX_LEN);
if (!c) return false;
uint32_t expected_ack = 0, est_timeout = 0;
return the_mesh.sendMessage(*c, rtc_clock.getCurrentTime(), 0, text, expected_ack, est_timeout) > 0;
}
void UITask::saveWaypoints() {
DataStore* ds = the_mesh.getDataStore();
if (!ds) return;