mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-08-06 20:26:12 +00:00
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:
co-authored by
Claude Opus 4.8
parent
29deaaad44
commit
d78e74d4cd
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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(".###."),
|
||||
|
||||
Reference in New Issue
Block a user