mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 23:08:11 +00:00
feat(companion): widen Locator targeting beyond favourites/live-share
Locator's person target now resolves any known contact's position, not just an active live [LOC] share: locatorDistance() falls back to the contact's last-advertised gps_lat/gps_lon when there's no current live share, so a rarely-updating but stationary node (a repeater, or someone who shared a fix once) still works as a target. LocatorScreen's target picker is widened to match (favourites always offered, plus every other contact with a currently resolvable position), and shows a compact age tag for non-live entries so staleness is visible. Also adds a quick "Set Locator target" action directly from Nearby Nodes' and Waypoints' own popup menus (UITask::setLocatorTarget()), so picking a target doesn't require a detour through Tools > Locator. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,19 @@ static inline void fmtDist(char* buf, int n, float km, bool imperial) {
|
||||
}
|
||||
}
|
||||
|
||||
// Compact age tag for a timestamp, e.g. "12s" / "5m" / "3h" / "2d" — sized to
|
||||
// sit inline after a name (unlike a full "X ago" sentence). Empty string for
|
||||
// an unknown (0) or future timestamp. Takes `now` rather than reading the RTC
|
||||
// itself, so this stays a pure function like the rest of this file.
|
||||
static inline void fmtAgeShort(char* buf, int n, uint32_t now, uint32_t lastmod) {
|
||||
if (lastmod == 0 || now < lastmod) { buf[0] = '\0'; return; }
|
||||
uint32_t age = now - lastmod;
|
||||
if (age < 60) snprintf(buf, n, "%us", (unsigned)age);
|
||||
else if (age < 3600) snprintf(buf, n, "%um", (unsigned)(age / 60));
|
||||
else if (age < 86400) snprintf(buf, n, "%uh", (unsigned)(age / 3600));
|
||||
else snprintf(buf, n, "%ud", (unsigned)(age / 86400));
|
||||
}
|
||||
|
||||
// Tag marking a shared waypoint inside a message: "[WAY]<lat>,<lon> <label>".
|
||||
// A plain {loc} expansion ("<lat>,<lon>") parses too — the tag just adds intent
|
||||
// and a label, and keeps the text readable on apps/firmware that don't know it.
|
||||
|
||||
@@ -6,13 +6,18 @@
|
||||
// of (leave/away) the radius. A waypoint target is snapshotted (coord + label);
|
||||
// a person target follows their latest shared position. The crossing engine
|
||||
// lives in UITask::evaluateLocator(). The Target row's Enter opens a picker
|
||||
// (favourites first, then active live senders, then waypoints); LEFT/RIGHT
|
||||
// quick-cycles the same set.
|
||||
// (favourites first — offered even with no known position yet, so you can arm
|
||||
// ahead of time — then any other contact with a currently-known position:
|
||||
// live-sharing or just last-advertised, e.g. a repeater; then waypoints).
|
||||
// LEFT/RIGHT quick-cycles the same set. A target can also be set directly from
|
||||
// Nearby Nodes' or Waypoints' own menu (UITask::setLocatorTarget()), bypassing
|
||||
// this picker entirely.
|
||||
// Included by UITask.cpp after LiveShareScreen.h.
|
||||
|
||||
#include "../NodePrefs.h"
|
||||
#include "../Waypoint.h"
|
||||
#include "../LiveTrack.h"
|
||||
#include "../GeoUtils.h"
|
||||
#include "icons.h" // drawList (shared scrolling-list helper)
|
||||
|
||||
class LocatorScreen : public UIScreen {
|
||||
@@ -23,11 +28,20 @@ class LocatorScreen : public UIScreen {
|
||||
int _scroll = 0;
|
||||
|
||||
// Target picker (Enter on the Target row): a flat selectable list of people
|
||||
// and places, rebuilt from favourites / live senders / waypoints on open.
|
||||
// and places, rebuilt from favourites / known-position contacts / waypoints
|
||||
// on open. `live` + `ts` are picker-display-only (freshness), never written
|
||||
// to prefs — a person target is re-resolved by key at evaluation time.
|
||||
bool _picking = false;
|
||||
int _pick_sel = 0, _pick_scroll = 0;
|
||||
struct Target { uint8_t kind; int32_t lat, lon; uint8_t key[6]; char name[20]; }; // kind 0=waypoint 1=person
|
||||
static const int TARGET_MAX = 24;
|
||||
struct Target {
|
||||
uint8_t kind; // 0=waypoint 1=person
|
||||
int32_t lat, lon;
|
||||
uint8_t key[6];
|
||||
char name[20];
|
||||
uint32_t ts; // last position update (0 = unknown), for the age tag
|
||||
bool live; // true = an active [LOC] share right now
|
||||
};
|
||||
static const int TARGET_MAX = 40;
|
||||
Target _targets[TARGET_MAX];
|
||||
int _target_n = 0;
|
||||
|
||||
@@ -134,37 +148,55 @@ public:
|
||||
_task->resetLocator();
|
||||
}
|
||||
|
||||
// Add a person candidate to _targets, deduped by pubkey prefix. Freshness is
|
||||
// resolved here for display only: an active [LOC] share wins (live=true),
|
||||
// else the contact's last-advertised position if it has one — the same
|
||||
// precedence UITask::locatorDistance() uses at evaluation time. When
|
||||
// `require_position` is false (favourites), a contact with neither is still
|
||||
// added with no position — "arm ahead of time", per the existing feature.
|
||||
bool addPersonTarget(const uint8_t* key, const char* name, bool require_position) {
|
||||
if (_target_n >= TARGET_MAX) return false;
|
||||
for (int j = 0; j < _target_n; j++)
|
||||
if (_targets[j].kind == 1 && memcmp(_targets[j].key, key, 6) == 0) return false; // already added
|
||||
|
||||
int32_t lat = 0, lon = 0; uint32_t ts = 0; bool live = false;
|
||||
const LiveTrackStore::Entry* e = _task->liveTrack().activeByKey(key, rtc_clock.getCurrentTime());
|
||||
if (e) {
|
||||
live = true; ts = e->ts; lat = e->lat_1e6; lon = e->lon_1e6;
|
||||
} else {
|
||||
ContactInfo* c = the_mesh.lookupContactByPubKey(key, 6);
|
||||
if (c && (c->gps_lat || c->gps_lon)) { ts = c->lastmod; lat = c->gps_lat; lon = c->gps_lon; }
|
||||
}
|
||||
if (require_position && !live && ts == 0) return false; // nothing to navigate to yet
|
||||
|
||||
Target& t = _targets[_target_n++];
|
||||
t.kind = 1; t.lat = lat; t.lon = lon; t.ts = ts; t.live = live;
|
||||
memcpy(t.key, key, 6);
|
||||
snprintf(t.name, sizeof(t.name), "%s", name);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Build the selectable target set into _targets: favourites first (the quick
|
||||
// path you pin ahead of time), then active verified live senders not already
|
||||
// pinned, then saved waypoints. A person is keyed by pubkey prefix so the
|
||||
// engine follows their live position even before/independent of a [LOC].
|
||||
// path you pin ahead of time, offered even with no known position yet), then
|
||||
// any other contact with a currently-known position — live-sharing or just
|
||||
// last-advertised (a repeater, a room, or someone who shared a fix once) —
|
||||
// then saved waypoints. A person is keyed by pubkey prefix so the engine
|
||||
// re-resolves their position each evaluation rather than trusting a snapshot.
|
||||
void buildTargets() {
|
||||
_target_n = 0;
|
||||
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT && _target_n < TARGET_MAX; i++) {
|
||||
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT; 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) continue;
|
||||
ContactInfo* c = the_mesh.lookupContactByPubKey(pre, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
if (!c) continue;
|
||||
Target& t = _targets[_target_n++];
|
||||
t.kind = 1; t.lat = t.lon = 0;
|
||||
memcpy(t.key, pre, 6);
|
||||
snprintf(t.name, sizeof(t.name), "%s", c->name);
|
||||
addPersonTarget(pre, c->name, /*require_position=*/false);
|
||||
}
|
||||
LiveTrackStore& lt = _task->liveTrack();
|
||||
uint32_t now = rtc_clock.getCurrentTime();
|
||||
for (int i = 0; i < LiveTrackStore::CAPACITY && _target_n < TARGET_MAX; i++) {
|
||||
if (!lt.isActive(i, now) || !lt.slotAt(i).verified) continue;
|
||||
const LiveTrackStore::Entry& e = lt.slotAt(i);
|
||||
bool dup = false;
|
||||
for (int j = 0; j < _target_n; j++)
|
||||
if (_targets[j].kind == 1 && memcmp(_targets[j].key, e.key, 6) == 0) { dup = true; break; }
|
||||
if (dup) continue;
|
||||
Target& t = _targets[_target_n++];
|
||||
t.kind = 1; t.lat = e.lat_1e6; t.lon = e.lon_1e6;
|
||||
memcpy(t.key, e.key, 6);
|
||||
snprintf(t.name, sizeof(t.name), "%s", e.name);
|
||||
for (int idx = 0; ; idx++) {
|
||||
ContactInfo c;
|
||||
if (!the_mesh.getContactByIdx(idx, c)) break;
|
||||
addPersonTarget(c.id.pub_key, c.name, /*require_position=*/true);
|
||||
}
|
||||
WaypointStore& wp = _task->waypoints();
|
||||
for (int i = 0; i < wp.count() && _target_n < TARGET_MAX; i++) {
|
||||
@@ -203,7 +235,7 @@ public:
|
||||
// LEFT/RIGHT quick-cycle over the same set the picker shows.
|
||||
void cycleTarget(int dir) {
|
||||
buildTargets();
|
||||
if (_target_n == 0) { _task->showAlert("No favs / waypoints", 1400); return; }
|
||||
if (_target_n == 0) { _task->showAlert("No targets available", 1400); return; }
|
||||
int cur = currentTargetIndex();
|
||||
int nx = (cur < 0) ? (dir >= 0 ? 0 : _target_n - 1)
|
||||
: ((cur + (dir >= 0 ? 1 : _target_n - 1)) % _target_n);
|
||||
@@ -213,7 +245,7 @@ public:
|
||||
void openPicker() {
|
||||
if (!_prefs) return;
|
||||
buildTargets();
|
||||
if (_target_n == 0) { _task->showAlert("No favs / waypoints", 1400); return; }
|
||||
if (_target_n == 0) { _task->showAlert("No targets available", 1400); return; }
|
||||
int cur = currentTargetIndex();
|
||||
_pick_sel = (cur >= 0) ? cur : 0;
|
||||
_pick_scroll = 0;
|
||||
@@ -222,12 +254,22 @@ public:
|
||||
|
||||
void renderPicker(DisplayDriver& display) {
|
||||
display.drawCenteredHeader("PICK TARGET");
|
||||
uint32_t now = rtc_clock.getCurrentTime();
|
||||
drawList(display, _target_n, _pick_sel, _pick_scroll, [&](int i, int y, bool sel, int reserve) {
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||||
char row[28];
|
||||
// '@' marks a person; a plain name is a waypoint.
|
||||
if (_targets[i].kind == 1) snprintf(row, sizeof(row), "@%s", _targets[i].name);
|
||||
else snprintf(row, sizeof(row), "%s", _targets[i].name);
|
||||
const Target& t = _targets[i];
|
||||
char row[36];
|
||||
if (t.kind != 1) {
|
||||
snprintf(row, sizeof(row), "%s", t.name); // a plain name is a waypoint
|
||||
} else if (t.live) {
|
||||
snprintf(row, sizeof(row), "@%s", t.name); // '@' marks a person; live needs no age tag
|
||||
} else if (t.ts) {
|
||||
char age[8];
|
||||
geo::fmtAgeShort(age, sizeof(age), now, t.ts);
|
||||
snprintf(row, sizeof(row), "@%s (%s)", t.name, age); // last-advertised, not actively sharing
|
||||
} else {
|
||||
snprintf(row, sizeof(row), "@%s", t.name); // favourite, no position known yet
|
||||
}
|
||||
display.drawTextEllipsized(2, y, display.width() - 2 - reserve, row);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class NearbyScreen : public UIScreen {
|
||||
enum Source : uint8_t { SRC_STORED, SRC_SCAN };
|
||||
|
||||
// ── action-menu actions (matched by id, not by row index) ────────────────────
|
||||
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_SORT, ACT_SCAN };
|
||||
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_LOCATOR, ACT_SORT, ACT_SCAN };
|
||||
|
||||
// ── unified list entry ───────────────────────────────────────────────────────
|
||||
struct Entry {
|
||||
@@ -459,7 +459,7 @@ class NearbyScreen : public UIScreen {
|
||||
|
||||
buildSortLabel();
|
||||
_menu_action_count = 0;
|
||||
_menu.begin("Options", 6);
|
||||
_menu.begin("Options", 7);
|
||||
auto add = [&](const char* label, Action a) {
|
||||
_menu.addItem(label);
|
||||
_menu_actions[_menu_action_count++] = a;
|
||||
@@ -468,6 +468,9 @@ class NearbyScreen : public UIScreen {
|
||||
if (has_gps) add("Navigate", ACT_NAV);
|
||||
if (has_key) add("Ping", ACT_PING);
|
||||
if (has_gps) add("Save waypoint", ACT_WAYPOINT);
|
||||
// Needs both a position and a stable identity — a person target is keyed
|
||||
// by pubkey prefix, so a name-only live-scan/channel row can't offer this.
|
||||
if (has_gps && has_key) add("Set Locator target", ACT_LOCATOR);
|
||||
if (stored) add(_sort_label, ACT_SORT); // sort is meaningless for live-scan rows
|
||||
add(stored ? "Discover scan" : "Rescan", ACT_SCAN);
|
||||
}
|
||||
@@ -488,6 +491,12 @@ class NearbyScreen : public UIScreen {
|
||||
break;
|
||||
}
|
||||
case ACT_WAYPOINT: saveSelectedWaypoint(); break;
|
||||
case ACT_LOCATOR: {
|
||||
const Entry* e = selected();
|
||||
if (e && e->has_key && (e->lat_e6 != 0 || e->lon_e6 != 0))
|
||||
_task->setLocatorTarget(1, e->pub_key, e->lat_e6, e->lon_e6, e->name);
|
||||
break;
|
||||
}
|
||||
case ACT_SORT: break; // adjusted in-place via LEFT/RIGHT, not ENTER
|
||||
case ACT_SCAN: enterScan(); break;
|
||||
}
|
||||
|
||||
@@ -2264,12 +2264,19 @@ bool UITask::locatorDistance(float& dist_m, float& radius_m) const {
|
||||
if (!_node_prefs || !_node_prefs->locator_has_target) return false;
|
||||
int32_t tlat, tlon;
|
||||
if (_node_prefs->locator_target_kind == 1) {
|
||||
// Live contact: follow the latest [LOC] position. No recent share → no
|
||||
// distance to evaluate (engine waits rather than using a stale fix).
|
||||
// Live contact: prefer the latest [LOC] position. With no active share —
|
||||
// not everyone keeps live-sharing on — fall back to their last-advertised
|
||||
// position, so a rarely-updating but stationary node (a repeater, or a
|
||||
// contact who simply shared a fix once) still works as a target.
|
||||
const LiveTrackStore::Entry* e =
|
||||
_livetrack.activeByKey(_node_prefs->locator_key, (uint32_t)rtc_clock.getCurrentTime());
|
||||
if (!e) return false;
|
||||
tlat = e->lat_1e6; tlon = e->lon_1e6;
|
||||
if (e) {
|
||||
tlat = e->lat_1e6; tlon = e->lon_1e6;
|
||||
} else {
|
||||
ContactInfo* c = the_mesh.lookupContactByPubKey(_node_prefs->locator_key, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
if (!c || (c->gps_lat == 0 && c->gps_lon == 0)) return false;
|
||||
tlat = c->gps_lat; tlon = c->gps_lon;
|
||||
}
|
||||
} else {
|
||||
tlat = _node_prefs->locator_lat_1e6; tlon = _node_prefs->locator_lon_1e6;
|
||||
}
|
||||
@@ -2314,6 +2321,19 @@ void UITask::fireLocator(bool arrived) {
|
||||
playMelody(arrived ? "locarr:d=8,o=6,b=140:c,e,g" : "loclv:d=8,o=6,b=140:g,e,c");
|
||||
}
|
||||
|
||||
void UITask::setLocatorTarget(uint8_t kind, const uint8_t* key, int32_t lat, int32_t lon, const char* name) {
|
||||
if (!_node_prefs) return;
|
||||
_node_prefs->locator_target_kind = kind;
|
||||
if (kind == 1) memcpy(_node_prefs->locator_key, key, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
_node_prefs->locator_lat_1e6 = lat;
|
||||
_node_prefs->locator_lon_1e6 = lon;
|
||||
snprintf(_node_prefs->locator_label, sizeof(_node_prefs->locator_label), "%s", name);
|
||||
_node_prefs->locator_has_target = 1;
|
||||
the_mesh.savePrefs();
|
||||
resetLocator();
|
||||
showAlert("Locator target set", 1200);
|
||||
}
|
||||
|
||||
// Homing beeper: while armed with a target and inside the radius, emit a short
|
||||
// tick whose interval shrinks linearly with distance — slow at the edge, rapid
|
||||
// near the centre. Polls distance a few times a second; silent outside the
|
||||
|
||||
@@ -194,6 +194,13 @@ public:
|
||||
// silently (called by the Locator tool after the target/radius changes,
|
||||
// so re-entering the zone doesn't fire on a stale inside/outside state).
|
||||
void resetLocator() { _locator_known = false; }
|
||||
// Quick one-shot "make this my Locator target" — used by Nearby Nodes' and
|
||||
// Waypoints' per-item menus so picking a target doesn't require a detour
|
||||
// through Tools › Locator. Unlike LocatorScreen's own picker (which batches
|
||||
// edits and saves on exit), this saves and re-arms immediately, with an
|
||||
// on-screen confirmation, since there's no screen-exit point to hook here.
|
||||
// kind 0 = waypoint (key ignored), 1 = person (key required, 6-byte prefix).
|
||||
void setLocatorTarget(uint8_t kind, const uint8_t* key, int32_t lat, int32_t lon, const char* name);
|
||||
void gotoTrailScreen();
|
||||
void gotoMapScreen(); // opens the Trail screen directly in its Map view
|
||||
void gotoCompassScreen();
|
||||
|
||||
@@ -274,7 +274,7 @@ public:
|
||||
if (_sel >= wpListCount()) _sel = wpListCount() - 1;
|
||||
if (_sel < 0) _sel = 0;
|
||||
}
|
||||
} else { // Send (share in a message)
|
||||
} else if (sel == 2) { // Send (share in a message)
|
||||
if (wi >= 0 && wi < _task->waypoints().count()) {
|
||||
const Waypoint& w = _task->waypoints().at(wi);
|
||||
double lat = w.lat_1e6 / 1000000.0, lon = w.lon_1e6 / 1000000.0;
|
||||
@@ -283,6 +283,11 @@ public:
|
||||
else snprintf(text, sizeof(text), WAYPOINT_MSG_TAG "%.5f,%.5f", lat, lon);
|
||||
_task->shareToMessage(text); // hands off to the Messages screen
|
||||
}
|
||||
} else { // Set Locator target
|
||||
if (wi >= 0 && wi < _task->waypoints().count()) {
|
||||
const Waypoint& w = _task->waypoints().at(wi);
|
||||
_task->setLocatorTarget(0, nullptr, w.lat_1e6, w.lon_1e6, w.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -336,13 +341,14 @@ public:
|
||||
else _mode = NAV; // a waypoint / Trail-start row
|
||||
return true;
|
||||
}
|
||||
// Rename/Delete/Send apply to saved waypoints only — not Trail-start or Add.
|
||||
// Rename/Delete/Send/Locator apply to saved waypoints only — not Trail-start or Add.
|
||||
if (c == KEY_CONTEXT_MENU && !selIsStart() && _sel != n &&
|
||||
wpIndex() >= 0 && wpIndex() < _task->waypoints().count()) {
|
||||
_ctx.begin("Waypoint", 3);
|
||||
_ctx.begin("Waypoint", 4);
|
||||
_ctx.addItem("Rename");
|
||||
_ctx.addItem("Delete");
|
||||
_ctx.addItem("Send");
|
||||
_ctx.addItem("Locator target");
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user