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
parent 29deaaad44
commit d78e74d4cd
14 changed files with 816 additions and 21 deletions

View File

@@ -63,5 +63,11 @@ public:
virtual void notify(UIEventType t = UIEventType::none) = 0;
virtual void addChannelMsg(uint8_t channel_idx, const char* text) {}
virtual void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0) {}
// A node shared its current position via a [LOC] message. pub_key is the
// sender's key prefix for a verified DM share, or null for a channel share
// (keyed by name, best-effort). Default no-op so UI variants opt in.
virtual void onSharedLocation(const uint8_t* pub_key, const char* name,
int32_t lat_1e6, int32_t lon_1e6,
uint32_t ts, bool verified) {}
virtual void loop() = 0;
};

View File

@@ -357,6 +357,24 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
rd(&_prefs.repeater_bw, sizeof(_prefs.repeater_bw));
rd(&_prefs.repeater_sf, sizeof(_prefs.repeater_sf));
rd(&_prefs.repeater_cr, sizeof(_prefs.repeater_cr));
// → 0xC0DE0011: track_shared_loc. Pre-0x11 files leave a stray sentinel byte
// here; clamp so upgraders fall back to "off".
rd(&_prefs.track_shared_loc, sizeof(_prefs.track_shared_loc));
if (_prefs.track_shared_loc > 1) _prefs.track_shared_loc = 0;
// → 0xC0DE0012: live location sharing. Pre-0x12 files leave stray bytes here;
// clamp each field back to its default so upgraders start with sharing off.
rd(&_prefs.loc_share_enabled, sizeof(_prefs.loc_share_enabled));
rd(&_prefs.loc_share_target_type, sizeof(_prefs.loc_share_target_type));
rd(&_prefs.loc_share_channel_idx, sizeof(_prefs.loc_share_channel_idx));
rd(_prefs.loc_share_dm_prefix, sizeof(_prefs.loc_share_dm_prefix));
rd(&_prefs.loc_share_move_idx, sizeof(_prefs.loc_share_move_idx));
rd(&_prefs.loc_share_interval_idx, sizeof(_prefs.loc_share_interval_idx));
rd(&_prefs.loc_share_heartbeat_idx, sizeof(_prefs.loc_share_heartbeat_idx));
if (_prefs.loc_share_enabled > 1) _prefs.loc_share_enabled = 0;
if (_prefs.loc_share_target_type > 1) _prefs.loc_share_target_type = 0;
if (_prefs.loc_share_move_idx >= NodePrefs::LOC_SHARE_MOVE_COUNT) _prefs.loc_share_move_idx = 1;
if (_prefs.loc_share_interval_idx >= NodePrefs::LOC_SHARE_INTERVAL_COUNT) _prefs.loc_share_interval_idx = 1;
if (_prefs.loc_share_heartbeat_idx >= NodePrefs::LOC_SHARE_HEARTBEAT_COUNT) _prefs.loc_share_heartbeat_idx = 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
@@ -536,6 +554,14 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.repeater_bw, sizeof(_prefs.repeater_bw));
file.write((uint8_t *)&_prefs.repeater_sf, sizeof(_prefs.repeater_sf));
file.write((uint8_t *)&_prefs.repeater_cr, sizeof(_prefs.repeater_cr));
file.write((uint8_t *)&_prefs.track_shared_loc, sizeof(_prefs.track_shared_loc));
file.write((uint8_t *)&_prefs.loc_share_enabled, sizeof(_prefs.loc_share_enabled));
file.write((uint8_t *)&_prefs.loc_share_target_type, sizeof(_prefs.loc_share_target_type));
file.write((uint8_t *)&_prefs.loc_share_channel_idx, sizeof(_prefs.loc_share_channel_idx));
file.write((uint8_t *)_prefs.loc_share_dm_prefix, sizeof(_prefs.loc_share_dm_prefix));
file.write((uint8_t *)&_prefs.loc_share_move_idx, sizeof(_prefs.loc_share_move_idx));
file.write((uint8_t *)&_prefs.loc_share_interval_idx, sizeof(_prefs.loc_share_interval_idx));
file.write((uint8_t *)&_prefs.loc_share_heartbeat_idx, sizeof(_prefs.loc_share_heartbeat_idx));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL.
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;

View File

@@ -109,4 +109,23 @@ static inline bool parseLatLon(const char* text, int32_t& lat_1e6, int32_t& lon_
return false;
}
// Tag marking a live position share inside a message: "[LOC]<lat>,<lon>".
// Distinct from WAYPOINT_MSG_TAG: a waypoint is a static point-of-interest to
// save, whereas this announces the *sender's own* current position so the
// receiver can update its bearing/track on that node. Both reuse parseLatLon
// for the coordinate, and both stay readable on firmware/apps that don't know
// the tag (it just looks like a coordinate).
#define LOCATION_MSG_TAG "[LOC]"
// 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
// bare coordinate, or for a [WAY] share — only an explicit [LOC] tag counts,
// so ordinary messages that happen to contain digits don't move anyone's pin.
static inline bool parseLocShare(const char* text, int32_t& lat_1e6, int32_t& lon_1e6) {
if (!text) return false;
const char* tag = strstr(text, LOCATION_MSG_TAG);
if (!tag) return false;
return parseLatLon(tag + strlen(LOCATION_MSG_TAG), lat_1e6, lon_1e6);
}
} // namespace geo

View File

@@ -0,0 +1,106 @@
#pragma once
#include <Arduino.h>
#include <stdint.h>
#include <string.h>
// RAM-only table of positions other nodes have shared via [LOC] messages
// (see geo::parseLocShare / LOCATION_MSG_TAG). Lets the device show a live
// bearing/distance to people who broadcast their location on a DM or channel,
// independent of how often they advert.
//
// Keying:
// DM share → keyed by pubkey prefix, verified == true (reliable).
// Channel share → keyed by sender name, verified == false (best-effort:
// channel sender names are unsigned and not unique).
//
// The table never persists (positions go stale fast) and entries expire after
// EXPIRY_SECS without a fresh update. When full, the oldest entry is evicted.
class LiveTrackStore {
public:
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 int NAME_LEN = 16; // display name buffer (incl. NUL)
static const int KEY_LEN = 6; // pubkey prefix bytes (matches favourites)
struct Entry {
uint8_t key[KEY_LEN]; // pubkey prefix (verified DM); all-zero for channel entries
char name[NAME_LEN]; // display name, always set
int32_t lat_1e6, lon_1e6;
uint32_t ts; // RTC epoch seconds of the share
bool verified; // true = DM/pubkey, false = channel/name
bool used;
};
// 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).
void update(const uint8_t* key, const char* name,
int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, bool verified) {
int slot = find(key, name, verified);
if (slot < 0) slot = allocSlot();
Entry& e = _e[slot];
if (verified && key) memcpy(e.key, key, KEY_LEN);
else memset(e.key, 0, KEY_LEN);
if (name) { strncpy(e.name, name, NAME_LEN - 1); e.name[NAME_LEN - 1] = '\0'; }
else { e.name[0] = '\0'; }
e.lat_1e6 = lat_1e6;
e.lon_1e6 = lon_1e6;
e.ts = ts;
e.verified = verified;
e.used = true;
}
// Drop entries not refreshed within EXPIRY_SECS. `now` is RTC epoch seconds.
// Guarded against now < ts (RTC stepped backwards) so a clock fix can't wipe
// the table.
void expire(uint32_t now) {
for (int i = 0; i < CAPACITY; i++) {
if (_e[i].used && now > _e[i].ts && (now - _e[i].ts) > EXPIRY_SECS) {
_e[i].used = false;
}
}
}
void clear() { for (int i = 0; i < CAPACITY; i++) _e[i].used = false; }
// Slot-wise access (caller skips inactive slots via isActive()).
const Entry& slotAt(int i) const { return _e[i]; }
bool isActive(int i, uint32_t now) const {
const Entry& e = _e[i];
if (!e.used) return false;
return !(now > e.ts && (now - e.ts) > EXPIRY_SECS);
}
// Number of currently non-expired entries.
int active(uint32_t now) const {
int n = 0;
for (int i = 0; i < CAPACITY; i++) if (isActive(i, now)) n++;
return n;
}
private:
Entry _e[CAPACITY] = {};
// Match an existing entry: verified shares by key, channel shares by name.
int find(const uint8_t* key, const char* name, bool verified) const {
for (int i = 0; i < CAPACITY; i++) {
if (!_e[i].used) continue;
if (verified) {
if (_e[i].verified && key && memcmp(_e[i].key, key, KEY_LEN) == 0) return i;
} else {
if (!_e[i].verified && name && strncmp(_e[i].name, name, NAME_LEN - 1) == 0) return i;
}
}
return -1;
}
// First free slot, else evict the oldest (smallest ts).
int allocSlot() {
int oldest = 0;
for (int i = 0; i < CAPACITY; i++) {
if (!_e[i].used) return i;
if (_e[i].ts < _e[oldest].ts) oldest = i;
}
return oldest;
}
};

View File

@@ -1,5 +1,6 @@
#include "MyMesh.h"
#include "MsgExpand.h"
#include "GeoUtils.h"
#include "Features.h"
#include <Arduino.h> // needed for PlatformIO
@@ -615,6 +616,12 @@ void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t
markConnectionActive(from); // in case this is from a server, and we have a connection
queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text);
// Live position share: a verified DM, so key the track by the sender's pubkey.
int32_t loc_lat, loc_lon;
if (_ui && geo::parseLocShare(text, loc_lat, loc_lon)) {
_ui->onSharedLocation(from.id.pub_key, from.name, loc_lat, loc_lon, sender_timestamp, true);
}
// hop count of the received message. getPathHashCount() (low 6 bits of path_len)
// is the number of repeaters traversed — the same value the mesh uses for flood
// retransmit priority. 0 = heard directly. (Raw path_len is a size/count
@@ -689,6 +696,23 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
channel_name = channel_details.name;
}
if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len, 0);
// Live position share on a channel. The sender's identity here is only the
// unsigned "name: msg" prefix (no pubkey), so track it by name — best-effort
// and unverified. parseLocShare requires an explicit [LOC] tag, so ordinary
// chatter is ignored.
int32_t loc_lat, loc_lon;
if (_ui && geo::parseLocShare(text, loc_lat, loc_lon)) {
char sender[32] = {0};
const char* sep = strstr(text, ": ");
if (sep && sep > text) {
int n = (int)(sep - text);
if (n > (int)sizeof(sender) - 1) n = sizeof(sender) - 1;
memcpy(sender, text, n);
sender[n] = '\0';
}
_ui->onSharedLocation(nullptr, sender[0] ? sender : "?", loc_lat, loc_lon, timestamp, false);
}
#endif
// hop count for !hops (see onMessageRecv); not the wire path_len above.

View File

@@ -219,11 +219,45 @@ struct NodePrefs { // persisted to file
uint8_t repeater_sf;
uint8_t repeater_cr;
// Track positions shared by other nodes via [LOC] messages (LiveTrackStore).
// 0 = ignore shared positions (default), 1 = parse incoming DM/channel [LOC]
// shares and update the live-track table (Nearby "Live" view / map).
uint8_t track_shared_loc;
// Live location sharing — a message-based "beacon". When enabled, the device
// periodically sends a [LOC] message to the chosen target while it moves.
// Configured from the Map (Trail screen) "Live share" menu.
uint8_t loc_share_enabled; // 0=off (default), 1=auto-sharing on
uint8_t loc_share_target_type; // 0=channel, 1=DM contact
uint8_t loc_share_channel_idx; // target channel index (when target_type==0)
uint8_t loc_share_dm_prefix[6]; // target contact pubkey prefix (when target_type==1)
uint8_t loc_share_move_idx; // movement gate level (index into locShareMoveMeters)
uint8_t loc_share_interval_idx; // min send interval (index into locShareIntervalSecs)
uint8_t loc_share_heartbeat_idx; // stationary heartbeat (index into locShareHeartbeatSecs)
// Single source of truth for the live-share option tables (shared by the Map
// UI labels and the auto-send engine in UITask).
static const uint8_t LOC_SHARE_MOVE_COUNT = 4;
static uint16_t locShareMoveMeters(uint8_t idx) {
static const uint16_t M[LOC_SHARE_MOVE_COUNT] = { 50, 100, 250, 500 };
return M[idx < LOC_SHARE_MOVE_COUNT ? idx : 1];
}
static const uint8_t LOC_SHARE_INTERVAL_COUNT = 4;
static uint16_t locShareIntervalSecs(uint8_t idx) {
static const uint16_t S[LOC_SHARE_INTERVAL_COUNT] = { 30, 60, 120, 300 };
return S[idx < LOC_SHARE_INTERVAL_COUNT ? idx : 1];
}
static const uint8_t LOC_SHARE_HEARTBEAT_COUNT = 3;
static uint16_t locShareHeartbeatSecs(uint8_t idx) {
static const uint16_t H[LOC_SHARE_HEARTBEAT_COUNT] = { 0, 300, 900 }; // off / 5 min / 15 min
return H[idx < LOC_SHARE_HEARTBEAT_COUNT ? idx : 0];
}
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
// 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 = 0xC0DE0010;
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0012;
// 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

@@ -0,0 +1,226 @@
#pragma once
// Live location sharing config tool. Tools Live Share.
// Periodically broadcasts a [LOC] message to a chosen channel/contact while
// moving. Independent of (and able to run alongside) the 0-hop Auto-Advert.
// Included by UITask.cpp after AutoAdvertScreen.h.
#include "../GeoUtils.h" // LOCATION_MSG_TAG
#include "../NodePrefs.h"
class LiveShareScreen : public UIScreen {
UITask* _task;
NodePrefs* _prefs;
bool _dirty = false;
int _sel = 0;
int _scroll = 0;
// Row model — headers are non-selectable section separators.
enum Kind : uint8_t { K_HEADER, K_TRACK, K_AUTO, K_TARGET, K_MOVE, K_GAP, K_HB, K_SHARE_NOW };
struct Row { Kind kind; const char* label; };
static const int ROW_COUNT = 12;
static Row rows(int i) {
static const Row R[ROW_COUNT] = {
{ K_HEADER, "Receive" },
{ K_TRACK, "Track loc" },
{ K_HEADER, "Send" },
{ K_AUTO, "Auto share" },
{ K_HEADER, "Target" },
{ K_TARGET, "To" },
{ K_HEADER, "Triggers" },
{ K_MOVE, "Move" },
{ K_GAP, "Min gap" },
{ K_HB, "Heartbeat" },
{ K_HEADER, "" },
{ K_SHARE_NOW, "Share now" },
};
return R[i];
}
static bool selectable(Kind k) { return k != K_HEADER; }
public:
LiveShareScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
void enter() {
_dirty = false;
if (!selectable(rows(_sel).kind)) _sel = firstSelectable();
}
int firstSelectable() const {
for (int i = 0; i < ROW_COUNT; i++) if (selectable(rows(i).kind)) return i;
return 0;
}
// ── target helpers ─────────────────────────────────────────────────────────
void currentTargetName(char* buf, int n) {
if (!_prefs) { snprintf(buf, n, "none"); return; }
if (_prefs->loc_share_target_type == 0) {
ChannelDetails ch;
if (the_mesh.getChannel(_prefs->loc_share_channel_idx, ch) && ch.name[0])
snprintf(buf, n, "%s", ch.name);
else
snprintf(buf, n, "Ch %d", (int)_prefs->loc_share_channel_idx);
} else {
ContactInfo* c = the_mesh.lookupContactByPubKey(_prefs->loc_share_dm_prefix, NodePrefs::FAVOURITE_PREFIX_LEN);
if (c) snprintf(buf, n, "%s", c->name);
else snprintf(buf, n, "(contact?)");
}
}
// ── value label for a row ──────────────────────────────────────────────────
void valueLabel(Kind k, char* buf, int n) {
switch (k) {
case K_TRACK:
snprintf(buf, n, "%s", (_prefs && _prefs->track_shared_loc) ? "ON" : "OFF");
break;
case K_AUTO:
snprintf(buf, n, "%s", (_prefs && _prefs->loc_share_enabled) ? "ON" : "OFF");
break;
case K_TARGET:
currentTargetName(buf, n);
break;
case K_MOVE:
snprintf(buf, n, "%um", (unsigned)NodePrefs::locShareMoveMeters(_prefs ? _prefs->loc_share_move_idx : 1));
break;
case K_GAP: {
uint16_t s = NodePrefs::locShareIntervalSecs(_prefs ? _prefs->loc_share_interval_idx : 1);
if (s < 60) snprintf(buf, n, "%us", (unsigned)s);
else snprintf(buf, n, "%um", (unsigned)(s / 60));
break;
}
case K_HB: {
uint16_t s = NodePrefs::locShareHeartbeatSecs(_prefs ? _prefs->loc_share_heartbeat_idx : 0);
if (s == 0) snprintf(buf, n, "OFF");
else snprintf(buf, n, "%um", (unsigned)(s / 60));
break;
}
default: buf[0] = '\0';
}
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader("LIVE SHARE");
const int y0 = display.listStart();
const int step = display.lineStep();
const int valx = display.width() / 2 + 6;
// Scroll window: keep the selected row visible.
int vis = (display.height() - y0) / step;
if (vis < 1) vis = 1;
if (_sel < _scroll) _scroll = _sel;
if (_sel >= _scroll + vis) _scroll = _sel - vis + 1;
if (_scroll < 0) _scroll = 0;
if (_scroll > ROW_COUNT - vis) _scroll = (ROW_COUNT > vis) ? ROW_COUNT - vis : 0;
for (int i = _scroll; i < ROW_COUNT && i < _scroll + vis; i++) {
int y = y0 + (i - _scroll) * step;
Row r = rows(i);
if (r.kind == K_HEADER) {
if (r.label[0]) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(2, y);
display.print(r.label);
}
continue;
}
bool sel = (i == _sel);
display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel);
display.setCursor(4, y);
display.print(r.label);
char val[24];
valueLabel(r.kind, val, sizeof(val));
if (val[0]) {
display.setCursor(valx, y);
display.print(val);
}
}
return 500;
}
void moveSel(int dir) {
int i = _sel;
for (int step = 0; step < ROW_COUNT; step++) {
i = (i + dir + ROW_COUNT) % ROW_COUNT;
if (selectable(rows(i).kind)) { _sel = i; return; }
}
}
// Cycle/act on the selected row. `enter` distinguishes ENTER (which opens the
// target picker / fires Share now) from LEFT/RIGHT (value cycling only).
void activate(int dir, bool enter) {
if (!_prefs) return;
Kind k = rows(_sel).kind;
switch (k) {
case K_TRACK: _prefs->track_shared_loc ^= 1; _dirty = true; break;
case K_AUTO: _prefs->loc_share_enabled ^= 1; _dirty = true; break;
case K_TARGET:
if (enter) { _task->pickLocShareTarget(); return; } // full chooser
cycleTarget(dir); _dirty = true; // L/R quick cycle
break;
case K_MOVE:
_prefs->loc_share_move_idx = (uint8_t)((_prefs->loc_share_move_idx + (dir >= 0 ? 1 : NodePrefs::LOC_SHARE_MOVE_COUNT - 1)) % NodePrefs::LOC_SHARE_MOVE_COUNT);
_dirty = true; break;
case K_GAP:
_prefs->loc_share_interval_idx = (uint8_t)((_prefs->loc_share_interval_idx + (dir >= 0 ? 1 : NodePrefs::LOC_SHARE_INTERVAL_COUNT - 1)) % NodePrefs::LOC_SHARE_INTERVAL_COUNT);
_dirty = true; break;
case K_HB:
_prefs->loc_share_heartbeat_idx = (uint8_t)((_prefs->loc_share_heartbeat_idx + (dir >= 0 ? 1 : NodePrefs::LOC_SHARE_HEARTBEAT_COUNT - 1)) % NodePrefs::LOC_SHARE_HEARTBEAT_COUNT);
_dirty = true; break;
case K_SHARE_NOW: shareNow(); break;
default: break;
}
}
void cycleTarget(int dir) {
struct T { uint8_t type, ch; uint8_t prefix[NodePrefs::FAVOURITE_PREFIX_LEN]; } list[24];
int n = 0;
ChannelDetails ch;
for (int i = 0; i < 64 && n < 24; i++) {
if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue;
list[n].type = 0; list[n].ch = (uint8_t)i; n++;
}
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT && n < 24; i++) {
const uint8_t* pre = _prefs->favourite_contacts[i];
bool empty = true;
for (int b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++) if (pre[b]) { empty = false; break; }
if (empty || !the_mesh.lookupContactByPubKey(pre, NodePrefs::FAVOURITE_PREFIX_LEN)) continue;
list[n].type = 1; list[n].ch = 0;
memcpy(list[n].prefix, pre, NodePrefs::FAVOURITE_PREFIX_LEN); n++;
}
if (n == 0) { _task->showAlert("No channels/favs", 1200); return; }
int cur = -1;
for (int i = 0; i < n; i++) {
if (list[i].type != _prefs->loc_share_target_type) continue;
if (list[i].type == 0) { if (list[i].ch == _prefs->loc_share_channel_idx) { cur = i; break; } }
else if (memcmp(list[i].prefix, _prefs->loc_share_dm_prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { cur = i; break; }
}
int nx = (cur < 0) ? 0 : ((cur + (dir >= 0 ? 1 : n - 1)) % n);
_prefs->loc_share_target_type = list[nx].type;
if (list[nx].type == 0) _prefs->loc_share_channel_idx = list[nx].ch;
else memcpy(_prefs->loc_share_dm_prefix, list[nx].prefix, NodePrefs::FAVOURITE_PREFIX_LEN);
}
void shareNow() {
int32_t lat, lon;
if (!_task->currentLocation(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; }
char text[40];
snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f", lat / 1e6, lon / 1e6);
_task->shareToMessage(text);
}
bool handleInput(char c) override {
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
if (_dirty) { the_mesh.savePrefs(); _dirty = false; }
_task->gotoToolsScreen();
return true;
}
if (c == KEY_UP) { moveSel(-1); return true; }
if (c == KEY_DOWN) { moveSel(+1); 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;
}
};

View File

@@ -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;
@@ -178,12 +183,68 @@ 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();
}
// 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];
// Only move the pin if the share is at least as recent as the contact's
// advert position, so a stale share can't override a fresher advert.
if (s.ts >= e.lastmod) {
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.is_live = true;
e.live_verified = s.verified;
} else if (_count < MAX_NEARBY) {
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 +284,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 +430,7 @@ class NearbyScreen : public UIScreen {
buildSortLabel();
_menu_action_count = 0;
_menu.begin("Options", 5);
_menu.begin("Options", 6);
auto add = [&](const char* label, Action a) {
_menu.addItem(label);
_menu_actions[_menu_action_count++] = a;
@@ -428,7 +491,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), "Loc: %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);
}
@@ -589,7 +655,14 @@ public:
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
char filt[32];
display.translateUTF8ToBlocks(filt, e.name, sizeof(filt));
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));
}
if (_source == SRC_SCAN && !e.name[0]) { // unknown node → "[Type]"
snprintf(filt, sizeof(filt), "[%s]", typeName(e.type));
}

View File

@@ -84,6 +84,9 @@ class QuickMsgScreen : public UIScreen {
// in the keyboard to confirm/edit before sending.
bool _share_mode = false;
char _share_text[160] = "";
// Live-share target picker: reuse the recipient chooser to set the persistent
// auto-share target (channel / DM) instead of composing a message.
bool _pick_target = false;
// History text holds a full received message. Channel messages carry the
// sender embedded as "Name: body" in the payload, so a message can be up to
@@ -807,6 +810,7 @@ public:
_ctx_dirty = false;
_nav_active = false;
_share_mode = false;
_pick_target = false;
_pin_picker_active = false;
_dm_direct_entry = false;
_unread_at_entry = 0;
@@ -854,6 +858,38 @@ public:
_phase = MODE_SELECT;
}
// Open the recipient chooser to set the live-share target (channel/DM). On
// selection the target is stored in NodePrefs and the Map screen is restored.
void startPickTarget() {
reset();
_pick_target = true;
_phase = MODE_SELECT;
}
void commitPickTargetChannel(int ch_idx) {
NodePrefs* p = _task->getNodePrefs();
if (p) {
p->loc_share_target_type = 0;
p->loc_share_channel_idx = (uint8_t)ch_idx;
the_mesh.savePrefs();
}
_pick_target = false;
_task->showAlert("Share target set", 1200);
_task->gotoLiveShareScreen();
}
void commitPickTargetDM(const ContactInfo& ci) {
NodePrefs* p = _task->getNodePrefs();
if (p) {
p->loc_share_target_type = 1;
memcpy(p->loc_share_dm_prefix, ci.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
the_mesh.savePrefs();
}
_pick_target = false;
_task->showAlert("Share target set", 1200);
_task->gotoLiveShareScreen();
}
void enterDM(const ContactInfo& ci) {
_sel_contact = ci;
_task->clearDMUnread(ci.id.pub_key);
@@ -1465,6 +1501,7 @@ public:
if (c == KEY_DOWN && _num_contacts > 0) { _contact_sel = (_contact_sel < _num_contacts - 1) ? _contact_sel + 1 : 0; return true; }
if (c == KEY_ENTER && _num_contacts > 0) {
if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) {
if (_pick_target) { commitPickTargetDM(_sel_contact); return true; }
_task->clearDMUnread(_sel_contact.id.pub_key);
_dm_hist_sel = -1;
_dm_hist_scroll = 0;
@@ -1555,6 +1592,7 @@ public:
if (c == KEY_DOWN && _num_channels > 0) { _channel_sel = (_channel_sel < _num_channels - 1) ? _channel_sel + 1 : 0; return true; }
if (c == KEY_ENTER && _num_channels > 0) {
_sel_channel_idx = _channel_indices[_channel_sel];
if (_pick_target) { commitPickTargetChannel(_sel_channel_idx); return true; }
int hc = histCountForChannel(_sel_channel_idx);
_unread_at_entry = (int)_ch_unread[_sel_channel_idx];
_hist_scroll = 0;

View File

@@ -7,7 +7,7 @@ class ToolsScreen : public UIScreen {
int _sel;
int _scroll = 0;
static const int ITEM_COUNT = 8;
static const int ITEM_COUNT = 9;
static const char* ITEMS[ITEM_COUNT];
public:
@@ -35,12 +35,13 @@ public:
if (_sel == 1) { _task->gotoBotScreen(); return true; }
if (_sel == 2) { _task->gotoNearbyScreen(); return true; }
if (_sel == 3) { _task->gotoAutoAdvertScreen(); return true; }
if (_sel == 4) { _task->gotoTrailScreen(); return true; }
if (_sel == 5) { _task->gotoCompassScreen(); return true; }
if (_sel == 6) { _task->gotoDiagnosticsScreen(); return true; }
if (_sel == 7) { _task->gotoRepeaterScreen(); return true; }
if (_sel == 4) { _task->gotoLiveShareScreen(); return true; }
if (_sel == 5) { _task->gotoTrailScreen(); return true; }
if (_sel == 6) { _task->gotoCompassScreen(); return true; }
if (_sel == 7) { _task->gotoDiagnosticsScreen(); return true; }
if (_sel == 8) { _task->gotoRepeaterScreen(); return true; }
}
return false;
}
};
const char* ToolsScreen::ITEMS[8] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert", "Trail", "Compass", "Diagnostics", "Repeater" };
const char* ToolsScreen::ITEMS[9] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert", "Live Share", "Trail", "Compass", "Diagnostics", "Repeater" };

View File

@@ -28,6 +28,7 @@ class TrailScreen : public UIScreen {
int _list_max_scroll = 0;
bool _cfg_dirty = false;
bool _map_grid = true; // show scale grid in map view (Enter to toggle)
bool _return_home = false; // entered via Home "Map" page → KEY_CANCEL returns to Home
// Waypoint management UI (list / nav / add / mark / rename / delete / send)
// lives in its own component; TrailScreen delegates to it while it's active.
@@ -38,10 +39,13 @@ class TrailScreen : public UIScreen {
// (Start/Stop tracking, Reset). PopupMenu stores label pointers verbatim, so
// the labels live in member buffers that get refreshed in openActionMenu()
// and after every LEFT/RIGHT cycle.
enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS };
// The action popup is two-level: a short main menu, plus "Trail file…" and
enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS,
ACT_SHARE_NOW };
// The action popup is multi-level: a short main menu, plus "Trail file…" and
// "Settings…" submenus. _menu_level tracks which is open so input is routed
// correctly (settings rows cycle with LEFT/RIGHT; everything else is Enter).
// Live-share *config* lives in its own tool (Tools Live Share); the map only
// keeps the one-shot "Share my pos" action.
enum MenuLevel { ML_MAIN, ML_FILE, ML_SETTINGS };
PopupMenu _action_menu;
uint8_t _menu_level = ML_MAIN;
@@ -65,9 +69,14 @@ public:
_cfg_dirty = false;
_action_menu.active = false;
_menu_level = ML_MAIN;
_return_home = false;
_wp.reset();
}
// Enter straight into the Map view (used by the Home "Map" page). Remembers
// that we came from Home so KEY_CANCEL returns there, not to Tools.
void enterMap() { enter(); _view = V_MAP; _return_home = true; }
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
@@ -101,11 +110,10 @@ public:
// LEFT/RIGHT cycles the focused value — only meaningful in the Settings
// submenu; the popup stays open so the user can keep tapping.
if (c == KEY_LEFT || c == KEY_RIGHT || c == KEY_PREV || c == KEY_NEXT) {
if (_menu_level == ML_SETTINGS) {
int idx = _action_menu.selectedIndex();
if (idx >= 0 && idx < _act_count)
cycleSetting((ActionId)_act_map[idx], (c == KEY_RIGHT || c == KEY_NEXT) ? 1 : -1);
}
int dir = (c == KEY_RIGHT || c == KEY_NEXT) ? 1 : -1;
int idx = _action_menu.selectedIndex();
if (idx >= 0 && idx < _act_count && _menu_level == ML_SETTINGS)
cycleSetting((ActionId)_act_map[idx], dir);
return true; // swallow elsewhere
}
auto res = _action_menu.handleInput(c);
@@ -113,12 +121,13 @@ public:
int sel = _action_menu.selectedIndex();
ActionId act = (sel >= 0 && sel < _act_count) ? (ActionId)_act_map[sel] : ACT_TOGGLE;
switch (act) {
case ACT_FILE: buildFileMenu(); return true; // descend into submenu
case ACT_SETTINGS: buildSettingsMenu(); return true;
case ACT_FILE: buildFileMenu(); return true; // descend into submenu
case ACT_SETTINGS: buildSettingsMenu(); return true;
// Settings rows: Enter advances/toggles the value and keeps focus.
case ACT_MIN_DIST:
case ACT_UNITS:
case ACT_GRID: cycleSetting(act, 1); reopenSettingsAt(sel); return true;
case ACT_SHARE_NOW: shareMyLocationNow(); break;
case ACT_TOGGLE: handleToggle(); break;
case ACT_MARK: _wp.markHere(); break;
case ACT_WAYPOINTS: _wp.openList(); break;
@@ -138,7 +147,8 @@ public:
if (c == KEY_CANCEL) {
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
_task->gotoToolsScreen();
if (_return_home) _task->gotoHomeScreen();
else _task->gotoToolsScreen();
return true;
}
if (c == KEY_CONTEXT_MENU) { openActionMenu(); return true; }
@@ -276,6 +286,7 @@ private:
// "Waypoints" opens the nav list — always available; it hosts the list,
// backtrack (Trail-start row) and the "+ Add by coords" entry.
pushAction(ACT_WAYPOINTS, "Waypoints...");
pushAction(ACT_SHARE_NOW, "Share my pos");
if (fileMenuHasItems()) pushAction(ACT_FILE, "Trail file...");
pushAction(ACT_SETTINGS, "Settings...");
}
@@ -347,6 +358,17 @@ private:
// comes from the global Settings preference, so this is a plain on/off flip.
void cycleUnits(NodePrefs* p, int /*dir*/) { p->trail_show_pace ^= 1; }
// One-shot manual share: build "[LOC]lat,lon" and hand it to the Messages
// screen, where the user picks a DM or channel recipient. (Auto live-share
// config lives in Tools Live Share.)
void shareMyLocationNow() {
int32_t lat, lon;
if (!_task->currentLocation(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; }
char text[40];
snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f", lat / 1e6, lon / 1e6);
_task->shareToMessage(text);
}
// Render the average speed (or pace) in the globally-selected unit system.
void formatAvgPaceOrSpeed(char* buf, size_t n, NodePrefs* p) const {
bool imperial = p && p->units_imperial;
@@ -523,6 +545,13 @@ private:
for (int i = 0; i < wp.count(); i++) fold(wp.at(i).lat_1e6, wp.at(i).lon_1e6);
}
if (have_gps) fold(my_lat, my_lon);
// Fold in live-tracked contacts ([LOC] shares) so they stay in frame.
{
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);
}
return init;
}
@@ -543,6 +572,23 @@ private:
char s[3] = { w.label[0], w.label[0] ? w.label[1] : (char)0, 0 };
drawWaypointLabel(display, s, wx, wy, area_x, area_y, area_w, area_h);
}
// Live-tracked contacts ([LOC] shares): filled diamond + name initial,
// clamped to the frame like waypoints.
{
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 cx, cy; proj.project(s.lat_1e6, s.lon_1e6, cx, cy);
if (cx < area_x) cx = area_x; else if (cx > wp_x_max) cx = wp_x_max;
if (cy < area_y) cy = area_y; else if (cy > wp_y_max) cy = wp_y_max;
drawContactMarker(display, cx, cy);
char s2[2] = { s.name[0], 0 };
drawWaypointLabel(display, s2, cx, cy, area_x, area_y, area_w, area_h);
}
}
if (have_gps) {
int mx, my; proj.project(my_lat, my_lon, mx, my);
drawCurrentMarker(display, mx, my);
@@ -564,8 +610,9 @@ private:
// when no trail is being recorded.
int32_t my_lat, my_lon;
const bool have_gps = ownPos(my_lat, my_lon);
const bool have_live = _task->liveTrack().active(rtc_clock.getCurrentTime()) > 0;
if (!have_trail && nwp == 0 && !have_gps) {
if (!have_trail && nwp == 0 && !have_gps && !have_live) {
display.drawTextCentered(display.width() / 2, (top + bottom) / 2, "No GPS / no trail");
return;
}
@@ -804,4 +851,8 @@ private:
static void drawCurrentMarker(DisplayDriver& d, int cx, int cy) {
miniIconDrawCentered(d, cx, cy, ICON_MAP_CURRENT);
}
// Filled diamond — a contact whose position arrived via a [LOC] share.
static void drawContactMarker(DisplayDriver& d, int cx, int cy) {
miniIconDrawCentered(d, cx, cy, ICON_MAP_CONTACT);
}
};

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;

View File

@@ -24,6 +24,7 @@
#include "../NodePrefs.h"
#include "../Trail.h"
#include "../Waypoint.h"
#include "../LiveTrack.h"
#include "KeyboardWidget.h"
class UITask : public AbstractUITask {
@@ -77,6 +78,7 @@ class UITask : public AbstractUITask {
UIScreen* nearby_screen;
UIScreen* dashboard_config;
UIScreen* auto_advert_screen;
UIScreen* live_share_screen;
UIScreen* trail_screen;
UIScreen* compass_screen;
UIScreen* diag_screen;
@@ -85,7 +87,16 @@ class UITask : public AbstractUITask {
CayenneLPP _dash_lpp;
TrailStore _trail;
WaypointStore _waypoints;
LiveTrackStore _livetrack;
uint32_t _next_trail_sample_ms = 0;
uint32_t _next_livetrack_expire_ms = 0;
// Live location sharing engine state (auto [LOC] broadcast while moving).
uint32_t _next_loc_share_check_ms = 0;
uint32_t _loc_share_last_ms = 0;
int32_t _loc_share_last_lat = 0, _loc_share_last_lon = 0;
bool _loc_share_has_last = false;
bool _loc_share_was_enabled = false;
// Course-over-ground ring — a heading source independent of trail recording.
// Filled from the same periodic GPS poll regardless of _trail.isActive().
@@ -145,6 +156,7 @@ public:
void gotoQuickMsgScreen();
void openContactDM(const ContactInfo& ci);
void shareToMessage(const char* text); // open Messages pre-loaded to share `text`
void pickLocShareTarget(); // open Messages to choose the live-share target
int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const;
void gotoToolsScreen();
void gotoRingtoneEditor(int slot = 0);
@@ -152,12 +164,15 @@ public:
void gotoNearbyScreen();
void gotoDashboardConfig();
void gotoAutoAdvertScreen();
void gotoLiveShareScreen();
void gotoTrailScreen();
void gotoMapScreen(); // opens the Trail screen directly in its Map view
void gotoCompassScreen();
void gotoDiagnosticsScreen();
void gotoRepeaterScreen();
TrailStore& trail() { return _trail; }
WaypointStore& waypoints() { return _waypoints; }
LiveTrackStore& liveTrack() { return _livetrack; }
// Shared on-screen keyboard — only one screen drives it at a time.
KeyboardWidget& keyboard() { return _kb; }
void saveWaypoints();
@@ -276,7 +291,13 @@ public:
void msgRead(int msgcount) override;
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type = 0, const uint8_t* pub_key = nullptr) override;
void notify(UIEventType t = UIEventType::none) override;
void onSharedLocation(const uint8_t* pub_key, const char* name,
int32_t lat_1e6, int32_t lon_1e6,
uint32_t ts, bool verified) override;
void loop() override;
// Send one [LOC] message to the configured live-share target. Returns false
// if the target can't be resolved (no such channel / contact).
bool sendLocationShare(int32_t lat, int32_t lon);
void shutdown(bool restart = false);
};

View File

@@ -193,6 +193,12 @@ MINI_ICON(ICON_MAP_CURRENT, 5, // ✕ live position / last trail point
packRow("..#.."),
packRow(".#.#."),
packRow("#...#"));
MINI_ICON(ICON_MAP_CONTACT, 5, // ◆ filled diamond — a live-tracked contact ([LOC] share)
packRow("..#.."),
packRow(".###."),
packRow("#####"),
packRow(".###."),
packRow("..#.."));
MINI_ICON(ICON_MAP_NORTH, 5, // "N" with a peaked roof — compass north marker
packRow("..#.."),
packRow(".###."),