From d78e74d4cd1ad8592abfee3b7439128abb261035 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 22 Jun 2026 10:13:09 +0200 Subject: [PATCH] feat(companion): live location sharing via [LOC] messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- examples/companion_radio/AbstractUITask.h | 6 + examples/companion_radio/DataStore.cpp | 26 ++ examples/companion_radio/GeoUtils.h | 19 ++ examples/companion_radio/LiveTrack.h | 106 ++++++++ examples/companion_radio/MyMesh.cpp | 24 ++ examples/companion_radio/NodePrefs.h | 36 ++- .../companion_radio/ui-new/LiveShareScreen.h | 226 ++++++++++++++++++ .../companion_radio/ui-new/NearbyScreen.h | 79 +++++- .../companion_radio/ui-new/QuickMsgScreen.h | 38 +++ examples/companion_radio/ui-new/ToolsScreen.h | 13 +- examples/companion_radio/ui-new/TrailScreen.h | 73 +++++- examples/companion_radio/ui-new/UITask.cpp | 164 +++++++++++++ examples/companion_radio/ui-new/UITask.h | 21 ++ examples/companion_radio/ui-new/icons.h | 6 + 14 files changed, 816 insertions(+), 21 deletions(-) create mode 100644 examples/companion_radio/LiveTrack.h create mode 100644 examples/companion_radio/ui-new/LiveShareScreen.h diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index d09b6531..f2c9e2c9 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -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; }; diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index e6de092e..34086a13 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -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; diff --git a/examples/companion_radio/GeoUtils.h b/examples/companion_radio/GeoUtils.h index 45988f50..16f1f95d 100644 --- a/examples/companion_radio/GeoUtils.h +++ b/examples/companion_radio/GeoUtils.h @@ -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],". +// 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 diff --git a/examples/companion_radio/LiveTrack.h b/examples/companion_radio/LiveTrack.h new file mode 100644 index 00000000..b1fbdae9 --- /dev/null +++ b/examples/companion_radio/LiveTrack.h @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include + +// 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; + } +}; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 91259d3d..2f92b9c9 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,5 +1,6 @@ #include "MyMesh.h" #include "MsgExpand.h" +#include "GeoUtils.h" #include "Features.h" #include // 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. diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index f5adb32b..7b86da49 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -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 diff --git a/examples/companion_radio/ui-new/LiveShareScreen.h b/examples/companion_radio/ui-new/LiveShareScreen.h new file mode 100644 index 00000000..83e97052 --- /dev/null +++ b/examples/companion_radio/ui-new/LiveShareScreen.h @@ -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; + } +}; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index c6a0e155..85d9618e 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -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)); } diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index b99119e2..542ef07c 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -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; diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index f53f7251..7f78c6d5 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -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" }; diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 3ef4e7f5..03faa48f 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -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); + } }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 9f4821f6..abd3b9d2 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index c1d147bb..ed2bcaaa 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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); }; diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index e01e3cf4..3e42961f 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -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(".###."),