mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 23:08:11 +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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -109,4 +109,23 @@ static inline bool parseLatLon(const char* text, int32_t& lat_1e6, int32_t& lon_
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tag marking a live position share inside a message: "[LOC]<lat>,<lon>".
|
||||
// Distinct from WAYPOINT_MSG_TAG: a waypoint is a static point-of-interest to
|
||||
// save, whereas this announces the *sender's own* current position so the
|
||||
// receiver can update its bearing/track on that node. Both reuse parseLatLon
|
||||
// for the coordinate, and both stay readable on firmware/apps that don't know
|
||||
// the tag (it just looks like a coordinate).
|
||||
#define LOCATION_MSG_TAG "[LOC]"
|
||||
|
||||
// True if `text` carries a LOCATION_MSG_TAG share; fills lat/lon (1e6-scaled)
|
||||
// from the coordinate that follows it. Returns false for plain text, for a
|
||||
// bare coordinate, or for a [WAY] share — only an explicit [LOC] tag counts,
|
||||
// so ordinary messages that happen to contain digits don't move anyone's pin.
|
||||
static inline bool parseLocShare(const char* text, int32_t& lat_1e6, int32_t& lon_1e6) {
|
||||
if (!text) return false;
|
||||
const char* tag = strstr(text, LOCATION_MSG_TAG);
|
||||
if (!tag) return false;
|
||||
return parseLatLon(tag + strlen(LOCATION_MSG_TAG), lat_1e6, lon_1e6);
|
||||
}
|
||||
|
||||
} // namespace geo
|
||||
|
||||
106
examples/companion_radio/LiveTrack.h
Normal file
106
examples/companion_radio/LiveTrack.h
Normal file
@@ -0,0 +1,106 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
// RAM-only table of positions other nodes have shared via [LOC] messages
|
||||
// (see geo::parseLocShare / LOCATION_MSG_TAG). Lets the device show a live
|
||||
// bearing/distance to people who broadcast their location on a DM or channel,
|
||||
// independent of how often they advert.
|
||||
//
|
||||
// Keying:
|
||||
// DM share → keyed by pubkey prefix, verified == true (reliable).
|
||||
// Channel share → keyed by sender name, verified == false (best-effort:
|
||||
// channel sender names are unsigned and not unique).
|
||||
//
|
||||
// The table never persists (positions go stale fast) and entries expire after
|
||||
// EXPIRY_SECS without a fresh update. When full, the oldest entry is evicted.
|
||||
class LiveTrackStore {
|
||||
public:
|
||||
static const int CAPACITY = 16; // max simultaneously tracked nodes
|
||||
static const uint32_t EXPIRY_SECS = 20UL * 60UL; // drop a track 20 min after its last update
|
||||
static const int NAME_LEN = 16; // display name buffer (incl. NUL)
|
||||
static const int KEY_LEN = 6; // pubkey prefix bytes (matches favourites)
|
||||
|
||||
struct Entry {
|
||||
uint8_t key[KEY_LEN]; // pubkey prefix (verified DM); all-zero for channel entries
|
||||
char name[NAME_LEN]; // display name, always set
|
||||
int32_t lat_1e6, lon_1e6;
|
||||
uint32_t ts; // RTC epoch seconds of the share
|
||||
bool verified; // true = DM/pubkey, false = channel/name
|
||||
bool used;
|
||||
};
|
||||
|
||||
// Insert or refresh a shared position. `key` may be null/ignored when not
|
||||
// verified. `name` is required (used as the channel key and for display).
|
||||
void update(const uint8_t* key, const char* name,
|
||||
int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, bool verified) {
|
||||
int slot = find(key, name, verified);
|
||||
if (slot < 0) slot = allocSlot();
|
||||
Entry& e = _e[slot];
|
||||
if (verified && key) memcpy(e.key, key, KEY_LEN);
|
||||
else memset(e.key, 0, KEY_LEN);
|
||||
if (name) { strncpy(e.name, name, NAME_LEN - 1); e.name[NAME_LEN - 1] = '\0'; }
|
||||
else { e.name[0] = '\0'; }
|
||||
e.lat_1e6 = lat_1e6;
|
||||
e.lon_1e6 = lon_1e6;
|
||||
e.ts = ts;
|
||||
e.verified = verified;
|
||||
e.used = true;
|
||||
}
|
||||
|
||||
// Drop entries not refreshed within EXPIRY_SECS. `now` is RTC epoch seconds.
|
||||
// Guarded against now < ts (RTC stepped backwards) so a clock fix can't wipe
|
||||
// the table.
|
||||
void expire(uint32_t now) {
|
||||
for (int i = 0; i < CAPACITY; i++) {
|
||||
if (_e[i].used && now > _e[i].ts && (now - _e[i].ts) > EXPIRY_SECS) {
|
||||
_e[i].used = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void clear() { for (int i = 0; i < CAPACITY; i++) _e[i].used = false; }
|
||||
|
||||
// Slot-wise access (caller skips inactive slots via isActive()).
|
||||
const Entry& slotAt(int i) const { return _e[i]; }
|
||||
bool isActive(int i, uint32_t now) const {
|
||||
const Entry& e = _e[i];
|
||||
if (!e.used) return false;
|
||||
return !(now > e.ts && (now - e.ts) > EXPIRY_SECS);
|
||||
}
|
||||
|
||||
// Number of currently non-expired entries.
|
||||
int active(uint32_t now) const {
|
||||
int n = 0;
|
||||
for (int i = 0; i < CAPACITY; i++) if (isActive(i, now)) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
private:
|
||||
Entry _e[CAPACITY] = {};
|
||||
|
||||
// Match an existing entry: verified shares by key, channel shares by name.
|
||||
int find(const uint8_t* key, const char* name, bool verified) const {
|
||||
for (int i = 0; i < CAPACITY; i++) {
|
||||
if (!_e[i].used) continue;
|
||||
if (verified) {
|
||||
if (_e[i].verified && key && memcmp(_e[i].key, key, KEY_LEN) == 0) return i;
|
||||
} else {
|
||||
if (!_e[i].verified && name && strncmp(_e[i].name, name, NAME_LEN - 1) == 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// First free slot, else evict the oldest (smallest ts).
|
||||
int allocSlot() {
|
||||
int oldest = 0;
|
||||
for (int i = 0; i < CAPACITY; i++) {
|
||||
if (!_e[i].used) return i;
|
||||
if (_e[i].ts < _e[oldest].ts) oldest = i;
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "MyMesh.h"
|
||||
#include "MsgExpand.h"
|
||||
#include "GeoUtils.h"
|
||||
#include "Features.h"
|
||||
|
||||
#include <Arduino.h> // needed for PlatformIO
|
||||
@@ -615,6 +616,12 @@ void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t
|
||||
markConnectionActive(from); // in case this is from a server, and we have a connection
|
||||
queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text);
|
||||
|
||||
// Live position share: a verified DM, so key the track by the sender's pubkey.
|
||||
int32_t loc_lat, loc_lon;
|
||||
if (_ui && geo::parseLocShare(text, loc_lat, loc_lon)) {
|
||||
_ui->onSharedLocation(from.id.pub_key, from.name, loc_lat, loc_lon, sender_timestamp, true);
|
||||
}
|
||||
|
||||
// hop count of the received message. getPathHashCount() (low 6 bits of path_len)
|
||||
// is the number of repeaters traversed — the same value the mesh uses for flood
|
||||
// retransmit priority. 0 = heard directly. (Raw path_len is a size/count
|
||||
@@ -689,6 +696,23 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
|
||||
channel_name = channel_details.name;
|
||||
}
|
||||
if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len, 0);
|
||||
|
||||
// Live position share on a channel. The sender's identity here is only the
|
||||
// unsigned "name: msg" prefix (no pubkey), so track it by name — best-effort
|
||||
// and unverified. parseLocShare requires an explicit [LOC] tag, so ordinary
|
||||
// chatter is ignored.
|
||||
int32_t loc_lat, loc_lon;
|
||||
if (_ui && geo::parseLocShare(text, loc_lat, loc_lon)) {
|
||||
char sender[32] = {0};
|
||||
const char* sep = strstr(text, ": ");
|
||||
if (sep && sep > text) {
|
||||
int n = (int)(sep - text);
|
||||
if (n > (int)sizeof(sender) - 1) n = sizeof(sender) - 1;
|
||||
memcpy(sender, text, n);
|
||||
sender[n] = '\0';
|
||||
}
|
||||
_ui->onSharedLocation(nullptr, sender[0] ? sender : "?", loc_lat, loc_lon, timestamp, false);
|
||||
}
|
||||
#endif
|
||||
|
||||
// hop count for !hops (see onMessageRecv); not the wire path_len above.
|
||||
|
||||
@@ -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
|
||||
|
||||
226
examples/companion_radio/ui-new/LiveShareScreen.h
Normal file
226
examples/companion_radio/ui-new/LiveShareScreen.h
Normal file
@@ -0,0 +1,226 @@
|
||||
#pragma once
|
||||
// Live location sharing config tool. Tools › Live Share.
|
||||
// Periodically broadcasts a [LOC] message to a chosen channel/contact while
|
||||
// moving. Independent of (and able to run alongside) the 0-hop Auto-Advert.
|
||||
// Included by UITask.cpp after AutoAdvertScreen.h.
|
||||
|
||||
#include "../GeoUtils.h" // LOCATION_MSG_TAG
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
class LiveShareScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
bool _dirty = false;
|
||||
int _sel = 0;
|
||||
int _scroll = 0;
|
||||
|
||||
// Row model — headers are non-selectable section separators.
|
||||
enum Kind : uint8_t { K_HEADER, K_TRACK, K_AUTO, K_TARGET, K_MOVE, K_GAP, K_HB, K_SHARE_NOW };
|
||||
struct Row { Kind kind; const char* label; };
|
||||
static const int ROW_COUNT = 12;
|
||||
static Row rows(int i) {
|
||||
static const Row R[ROW_COUNT] = {
|
||||
{ K_HEADER, "Receive" },
|
||||
{ K_TRACK, "Track loc" },
|
||||
{ K_HEADER, "Send" },
|
||||
{ K_AUTO, "Auto share" },
|
||||
{ K_HEADER, "Target" },
|
||||
{ K_TARGET, "To" },
|
||||
{ K_HEADER, "Triggers" },
|
||||
{ K_MOVE, "Move" },
|
||||
{ K_GAP, "Min gap" },
|
||||
{ K_HB, "Heartbeat" },
|
||||
{ K_HEADER, "" },
|
||||
{ K_SHARE_NOW, "Share now" },
|
||||
};
|
||||
return R[i];
|
||||
}
|
||||
static bool selectable(Kind k) { return k != K_HEADER; }
|
||||
|
||||
public:
|
||||
LiveShareScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
|
||||
|
||||
void enter() {
|
||||
_dirty = false;
|
||||
if (!selectable(rows(_sel).kind)) _sel = firstSelectable();
|
||||
}
|
||||
|
||||
int firstSelectable() const {
|
||||
for (int i = 0; i < ROW_COUNT; i++) if (selectable(rows(i).kind)) return i;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── target helpers ─────────────────────────────────────────────────────────
|
||||
void currentTargetName(char* buf, int n) {
|
||||
if (!_prefs) { snprintf(buf, n, "none"); return; }
|
||||
if (_prefs->loc_share_target_type == 0) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(_prefs->loc_share_channel_idx, ch) && ch.name[0])
|
||||
snprintf(buf, n, "%s", ch.name);
|
||||
else
|
||||
snprintf(buf, n, "Ch %d", (int)_prefs->loc_share_channel_idx);
|
||||
} else {
|
||||
ContactInfo* c = the_mesh.lookupContactByPubKey(_prefs->loc_share_dm_prefix, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
if (c) snprintf(buf, n, "%s", c->name);
|
||||
else snprintf(buf, n, "(contact?)");
|
||||
}
|
||||
}
|
||||
|
||||
// ── value label for a row ──────────────────────────────────────────────────
|
||||
void valueLabel(Kind k, char* buf, int n) {
|
||||
switch (k) {
|
||||
case K_TRACK:
|
||||
snprintf(buf, n, "%s", (_prefs && _prefs->track_shared_loc) ? "ON" : "OFF");
|
||||
break;
|
||||
case K_AUTO:
|
||||
snprintf(buf, n, "%s", (_prefs && _prefs->loc_share_enabled) ? "ON" : "OFF");
|
||||
break;
|
||||
case K_TARGET:
|
||||
currentTargetName(buf, n);
|
||||
break;
|
||||
case K_MOVE:
|
||||
snprintf(buf, n, "%um", (unsigned)NodePrefs::locShareMoveMeters(_prefs ? _prefs->loc_share_move_idx : 1));
|
||||
break;
|
||||
case K_GAP: {
|
||||
uint16_t s = NodePrefs::locShareIntervalSecs(_prefs ? _prefs->loc_share_interval_idx : 1);
|
||||
if (s < 60) snprintf(buf, n, "%us", (unsigned)s);
|
||||
else snprintf(buf, n, "%um", (unsigned)(s / 60));
|
||||
break;
|
||||
}
|
||||
case K_HB: {
|
||||
uint16_t s = NodePrefs::locShareHeartbeatSecs(_prefs ? _prefs->loc_share_heartbeat_idx : 0);
|
||||
if (s == 0) snprintf(buf, n, "OFF");
|
||||
else snprintf(buf, n, "%um", (unsigned)(s / 60));
|
||||
break;
|
||||
}
|
||||
default: buf[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawCenteredHeader("LIVE SHARE");
|
||||
|
||||
const int y0 = display.listStart();
|
||||
const int step = display.lineStep();
|
||||
const int valx = display.width() / 2 + 6;
|
||||
|
||||
// Scroll window: keep the selected row visible.
|
||||
int vis = (display.height() - y0) / step;
|
||||
if (vis < 1) vis = 1;
|
||||
if (_sel < _scroll) _scroll = _sel;
|
||||
if (_sel >= _scroll + vis) _scroll = _sel - vis + 1;
|
||||
if (_scroll < 0) _scroll = 0;
|
||||
if (_scroll > ROW_COUNT - vis) _scroll = (ROW_COUNT > vis) ? ROW_COUNT - vis : 0;
|
||||
|
||||
for (int i = _scroll; i < ROW_COUNT && i < _scroll + vis; i++) {
|
||||
int y = y0 + (i - _scroll) * step;
|
||||
Row r = rows(i);
|
||||
if (r.kind == K_HEADER) {
|
||||
if (r.label[0]) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(2, y);
|
||||
display.print(r.label);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
bool sel = (i == _sel);
|
||||
display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel);
|
||||
display.setCursor(4, y);
|
||||
display.print(r.label);
|
||||
char val[24];
|
||||
valueLabel(r.kind, val, sizeof(val));
|
||||
if (val[0]) {
|
||||
display.setCursor(valx, y);
|
||||
display.print(val);
|
||||
}
|
||||
}
|
||||
return 500;
|
||||
}
|
||||
|
||||
void moveSel(int dir) {
|
||||
int i = _sel;
|
||||
for (int step = 0; step < ROW_COUNT; step++) {
|
||||
i = (i + dir + ROW_COUNT) % ROW_COUNT;
|
||||
if (selectable(rows(i).kind)) { _sel = i; return; }
|
||||
}
|
||||
}
|
||||
|
||||
// Cycle/act on the selected row. `enter` distinguishes ENTER (which opens the
|
||||
// target picker / fires Share now) from LEFT/RIGHT (value cycling only).
|
||||
void activate(int dir, bool enter) {
|
||||
if (!_prefs) return;
|
||||
Kind k = rows(_sel).kind;
|
||||
switch (k) {
|
||||
case K_TRACK: _prefs->track_shared_loc ^= 1; _dirty = true; break;
|
||||
case K_AUTO: _prefs->loc_share_enabled ^= 1; _dirty = true; break;
|
||||
case K_TARGET:
|
||||
if (enter) { _task->pickLocShareTarget(); return; } // full chooser
|
||||
cycleTarget(dir); _dirty = true; // L/R quick cycle
|
||||
break;
|
||||
case K_MOVE:
|
||||
_prefs->loc_share_move_idx = (uint8_t)((_prefs->loc_share_move_idx + (dir >= 0 ? 1 : NodePrefs::LOC_SHARE_MOVE_COUNT - 1)) % NodePrefs::LOC_SHARE_MOVE_COUNT);
|
||||
_dirty = true; break;
|
||||
case K_GAP:
|
||||
_prefs->loc_share_interval_idx = (uint8_t)((_prefs->loc_share_interval_idx + (dir >= 0 ? 1 : NodePrefs::LOC_SHARE_INTERVAL_COUNT - 1)) % NodePrefs::LOC_SHARE_INTERVAL_COUNT);
|
||||
_dirty = true; break;
|
||||
case K_HB:
|
||||
_prefs->loc_share_heartbeat_idx = (uint8_t)((_prefs->loc_share_heartbeat_idx + (dir >= 0 ? 1 : NodePrefs::LOC_SHARE_HEARTBEAT_COUNT - 1)) % NodePrefs::LOC_SHARE_HEARTBEAT_COUNT);
|
||||
_dirty = true; break;
|
||||
case K_SHARE_NOW: shareNow(); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
void cycleTarget(int dir) {
|
||||
struct T { uint8_t type, ch; uint8_t prefix[NodePrefs::FAVOURITE_PREFIX_LEN]; } list[24];
|
||||
int n = 0;
|
||||
ChannelDetails ch;
|
||||
for (int i = 0; i < 64 && n < 24; i++) {
|
||||
if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue;
|
||||
list[n].type = 0; list[n].ch = (uint8_t)i; n++;
|
||||
}
|
||||
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT && n < 24; i++) {
|
||||
const uint8_t* pre = _prefs->favourite_contacts[i];
|
||||
bool empty = true;
|
||||
for (int b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++) if (pre[b]) { empty = false; break; }
|
||||
if (empty || !the_mesh.lookupContactByPubKey(pre, NodePrefs::FAVOURITE_PREFIX_LEN)) continue;
|
||||
list[n].type = 1; list[n].ch = 0;
|
||||
memcpy(list[n].prefix, pre, NodePrefs::FAVOURITE_PREFIX_LEN); n++;
|
||||
}
|
||||
if (n == 0) { _task->showAlert("No channels/favs", 1200); return; }
|
||||
int cur = -1;
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (list[i].type != _prefs->loc_share_target_type) continue;
|
||||
if (list[i].type == 0) { if (list[i].ch == _prefs->loc_share_channel_idx) { cur = i; break; } }
|
||||
else if (memcmp(list[i].prefix, _prefs->loc_share_dm_prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { cur = i; break; }
|
||||
}
|
||||
int nx = (cur < 0) ? 0 : ((cur + (dir >= 0 ? 1 : n - 1)) % n);
|
||||
_prefs->loc_share_target_type = list[nx].type;
|
||||
if (list[nx].type == 0) _prefs->loc_share_channel_idx = list[nx].ch;
|
||||
else memcpy(_prefs->loc_share_dm_prefix, list[nx].prefix, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
}
|
||||
|
||||
void shareNow() {
|
||||
int32_t lat, lon;
|
||||
if (!_task->currentLocation(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; }
|
||||
char text[40];
|
||||
snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f", lat / 1e6, lon / 1e6);
|
||||
_task->shareToMessage(text);
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
|
||||
if (_dirty) { the_mesh.savePrefs(); _dirty = false; }
|
||||
_task->gotoToolsScreen();
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_UP) { moveSel(-1); return true; }
|
||||
if (c == KEY_DOWN) { moveSel(+1); return true; }
|
||||
if (keyIsPrev(c)) { activate(-1, false); return true; }
|
||||
if (keyIsNext(c)) { activate(+1, false); return true; }
|
||||
if (c == KEY_ENTER) { activate(+1, true); return true; }
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -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