mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
feat(companion): live location sharing, Locator geofencing, trail auto-pause
Squash merge of feat/location-beacon-alerts-autopause (v1.21). Features: - Live Location Sharing — broadcast position as movement-gated [LOC] messages to a channel or contact; live shares show as map pins with distance/bearing in Nearby Nodes and a status-bar indicator. [LOC] is parsed in DMs, channel messages and room messages; DM shares name the sender. - Locator (geofence) — arm a geofence around a saved waypoint or a person (live [LOC] or last-known position), alert on arrive/leave or near/far, with an optional homing beeper (gated to arrive/both modes). Arm from Tools > Locator, Nearby Nodes, or Waypoints; target picker lists favourites first, clearable via a "None" entry. Active target is drawn as a flag on the map. - One active target shared across Locator / Navigate / Map via a single resolver (resolvePersonPos / activeTargetPos) that prefers a live [LOC] share over the last-advertised GPS fix. - Follow live contacts — Navigate to a live-sharing contact follows them as they move and adds an ETA line; quick-share your own position from the Map. - Map & status-bar upgrades — home mini-map gets a north marker, scale tick, and a connected trail line (was disconnected dots); status line shows tracked-node count, an arrow + distance to the active Locator/Nav target (falling back to the nearest live-tracked contact); GPS fix icon in the top status bar, shown only on GPS boards with GPS enabled. - Trail auto-pause — recording freezes on stops (banking elapsed time, breaking the map line across the idle gap) and resumes on movement without ending the session. - Streaming trail simplification — GPS points are simplified in-stream via a fixed-corridor (Reumann-Witkam) pass tuned for fidelity: straight runs collapse to their endpoints, curves stay bounded to the Min-dist tolerance, so the 512-point buffer covers a far longer route than a flat point budget would suggest. - Collapsible Tools (Location / Comms / System sections, fold-in-place like Settings) and page-indicator icons on the home carousel. - Waypoint coordinate editor — add a waypoint by scroll-editing lat/lon digit by digit. Fixes: - Critical: low-heap hang and contact loss on RAM-tight builds. Halved message-history scrollback rings (recovering ~14 KB free heap) and made contacts/channels/prefs persistence atomic (temp file + rename), so an interrupted save can no longer corrupt or wipe the store. - Serial.write() bounded so a stalled USB host can't hang the device. - Nearby Nodes: live [LOC] senders respect the type filter, sort by shared position, and the list refreshes so live shares bubble up. - Map: live contacts are labelled before waypoints. - GPS status icon hidden when GPS is off in Settings. - Splash screen no longer truncates a pre-release tag's own dash (e.g. v1.21-rc1) when stripping the build's commit-hash suffix. - Null-guarded the Locator target picker; clamped loc-share channel index on load. Under the hood: - -Os size optimisation on the e-ink and GAT562 30S solo envs. 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;
|
||||
};
|
||||
|
||||
@@ -44,6 +44,21 @@ static File openWrite(FILESYSTEM* fs, const char* filename) {
|
||||
#endif
|
||||
}
|
||||
|
||||
// Atomically swap a fully-written temp file over its final path. LittleFS
|
||||
// (nRF52/STM32) rename replaces an existing destination atomically, so a crash
|
||||
// leaves either the old file or the new one intact — never a truncated mix.
|
||||
// Other Arduino filesystems can't rename onto an existing file, so the
|
||||
// destination is dropped first (a metadata-only window, vs. the record-by-record
|
||||
// write window of a direct overwrite). Returns false if the swap fails.
|
||||
static bool commitTempFile(FILESYSTEM* fs, const char* tmp, const char* final_path) {
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
return fs->rename(tmp, final_path);
|
||||
#else
|
||||
fs->remove(final_path);
|
||||
return fs->rename(tmp, final_path);
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
static uint32_t _ContactsChannelsTotalBlocks = 0;
|
||||
#endif
|
||||
@@ -357,6 +372,48 @@ 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_channel_idx >= MAX_GROUP_CHANNELS) _prefs.loc_share_channel_idx = 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;
|
||||
// → 0xC0DE0013: locator + trail auto-pause. Pre-0x13 files leave stray bytes
|
||||
// here; clamp each back to its default so upgraders start with both off.
|
||||
rd(&_prefs.locator_enabled, sizeof(_prefs.locator_enabled));
|
||||
rd(&_prefs.locator_has_target, sizeof(_prefs.locator_has_target));
|
||||
rd(&_prefs.locator_radius_idx, sizeof(_prefs.locator_radius_idx));
|
||||
rd(&_prefs.locator_mode, sizeof(_prefs.locator_mode));
|
||||
rd(&_prefs.locator_lat_1e6, sizeof(_prefs.locator_lat_1e6));
|
||||
rd(&_prefs.locator_lon_1e6, sizeof(_prefs.locator_lon_1e6));
|
||||
rd(_prefs.locator_label, sizeof(_prefs.locator_label));
|
||||
rd(&_prefs.trail_autopause_idx, sizeof(_prefs.trail_autopause_idx));
|
||||
if (_prefs.locator_enabled > 1) _prefs.locator_enabled = 0;
|
||||
if (_prefs.locator_has_target > 1) _prefs.locator_has_target = 0;
|
||||
if (_prefs.locator_radius_idx >= NodePrefs::LOCATOR_RADIUS_COUNT) _prefs.locator_radius_idx = 1;
|
||||
if (_prefs.locator_mode >= NodePrefs::LOCATOR_MODE_COUNT) _prefs.locator_mode = 0;
|
||||
if (_prefs.trail_autopause_idx >= NodePrefs::TRAIL_AUTOPAUSE_COUNT) _prefs.trail_autopause_idx = 0;
|
||||
_prefs.locator_label[sizeof(_prefs.locator_label) - 1] = '\0';
|
||||
// → 0xC0DE0014: locator proximity beeper.
|
||||
rd(&_prefs.locator_beeper, sizeof(_prefs.locator_beeper));
|
||||
if (_prefs.locator_beeper > 1) _prefs.locator_beeper = 0;
|
||||
// → 0xC0DE0015: locator can target a live contact (kind + pubkey prefix).
|
||||
rd(&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind));
|
||||
rd(_prefs.locator_key, sizeof(_prefs.locator_key));
|
||||
if (_prefs.locator_target_kind > 1) _prefs.locator_target_kind = 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
|
||||
@@ -432,7 +489,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
}
|
||||
|
||||
void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) {
|
||||
File file = ::openWrite(_fs, "/new_prefs");
|
||||
// Atomic temp-then-rename (see commitTempFile) so an interrupted save can't
|
||||
// wipe settings; loadPrefs() still validates the tail sentinel on read.
|
||||
File file = ::openWrite(_fs, "/new_prefs.tmp");
|
||||
if (file) {
|
||||
uint8_t pad[8];
|
||||
memset(pad, 0, sizeof(pad));
|
||||
@@ -536,12 +595,38 @@ 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));
|
||||
file.write((uint8_t *)&_prefs.locator_enabled, sizeof(_prefs.locator_enabled));
|
||||
file.write((uint8_t *)&_prefs.locator_has_target, sizeof(_prefs.locator_has_target));
|
||||
file.write((uint8_t *)&_prefs.locator_radius_idx, sizeof(_prefs.locator_radius_idx));
|
||||
file.write((uint8_t *)&_prefs.locator_mode, sizeof(_prefs.locator_mode));
|
||||
file.write((uint8_t *)&_prefs.locator_lat_1e6, sizeof(_prefs.locator_lat_1e6));
|
||||
file.write((uint8_t *)&_prefs.locator_lon_1e6, sizeof(_prefs.locator_lon_1e6));
|
||||
file.write((uint8_t *)_prefs.locator_label, sizeof(_prefs.locator_label));
|
||||
file.write((uint8_t *)&_prefs.trail_autopause_idx, sizeof(_prefs.trail_autopause_idx));
|
||||
file.write((uint8_t *)&_prefs.locator_beeper, sizeof(_prefs.locator_beeper));
|
||||
file.write((uint8_t *)&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind));
|
||||
file.write((uint8_t *)_prefs.locator_key, sizeof(_prefs.locator_key));
|
||||
|
||||
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL.
|
||||
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is
|
||||
// the one we check: once the flash fills, writes return 0, so a good
|
||||
// sentinel write means the whole record fit. Only then swap it in.
|
||||
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;
|
||||
file.write((uint8_t *)&sentinel, sizeof(sentinel));
|
||||
bool ok = (file.write((uint8_t *)&sentinel, sizeof(sentinel)) == sizeof(sentinel));
|
||||
|
||||
file.close();
|
||||
if (ok) {
|
||||
commitTempFile(_fs, "/new_prefs.tmp", "/new_prefs");
|
||||
} else {
|
||||
_fs->remove("/new_prefs.tmp"); // keep the previous good /new_prefs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,35 +682,48 @@ File file = openRead(_getContactsChannelsFS(), "/contacts3");
|
||||
}
|
||||
|
||||
void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) {
|
||||
File file = ::openWrite(_getContactsChannelsFS(), "/contacts3");
|
||||
if (file) {
|
||||
uint32_t idx = 0;
|
||||
ContactInfo c;
|
||||
uint8_t unused = 0;
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
// Write to a temp file, then atomically rename it over /contacts3 only once
|
||||
// every record has written cleanly. The old code truncated /contacts3 up
|
||||
// front and wrote in place, so a crash, reset or full flash mid-save wiped
|
||||
// the entire contact list. Now an interrupted save leaves the previous good
|
||||
// file untouched.
|
||||
File file = ::openWrite(fs, "/contacts3.tmp");
|
||||
if (!file) return;
|
||||
|
||||
while (host->getContactForSave(idx, c)) {
|
||||
if (filter && !filter(c)) {
|
||||
idx++; // advance to next contact
|
||||
continue;
|
||||
}
|
||||
bool success = (file.write(c.id.pub_key, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
|
||||
success = success && (file.write(&c.type, 1) == 1);
|
||||
success = success && (file.write(&c.flags, 1) == 1);
|
||||
success = success && (file.write(&unused, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
|
||||
success = success && (file.write(c.out_path, 64) == 64);
|
||||
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
|
||||
|
||||
if (!success) break; // write failed
|
||||
bool ok = true;
|
||||
uint32_t idx = 0;
|
||||
ContactInfo c;
|
||||
uint8_t unused = 0;
|
||||
|
||||
while (host->getContactForSave(idx, c)) {
|
||||
if (filter && !filter(c)) {
|
||||
idx++; // advance to next contact
|
||||
continue;
|
||||
}
|
||||
file.close();
|
||||
bool success = (file.write(c.id.pub_key, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
|
||||
success = success && (file.write(&c.type, 1) == 1);
|
||||
success = success && (file.write(&c.flags, 1) == 1);
|
||||
success = success && (file.write(&unused, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
|
||||
success = success && (file.write(c.out_path, 64) == 64);
|
||||
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
|
||||
|
||||
if (!success) { ok = false; break; } // write failed (e.g. flash full)
|
||||
|
||||
idx++; // advance to next contact
|
||||
}
|
||||
file.close();
|
||||
|
||||
if (ok) {
|
||||
commitTempFile(fs, "/contacts3.tmp", "/contacts3");
|
||||
} else {
|
||||
fs->remove("/contacts3.tmp"); // keep the previous good /contacts3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,29 +775,39 @@ void DataStore::loadChannels(DataStoreHost* host) {
|
||||
}
|
||||
|
||||
void DataStore::saveChannels(DataStoreHost* host) {
|
||||
File file = ::openWrite(_getContactsChannelsFS(), "/channels2");
|
||||
if (file) {
|
||||
uint8_t channel_idx = 0;
|
||||
ChannelDetails ch;
|
||||
uint8_t unused[4];
|
||||
memset(unused, 0, 4);
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
// Same atomic temp-then-rename pattern as saveContacts() — never truncate the
|
||||
// live /channels2 before the new copy is fully written.
|
||||
File file = ::openWrite(fs, "/channels2.tmp");
|
||||
if (!file) return;
|
||||
|
||||
while (host->getChannelForSave(channel_idx, ch)) {
|
||||
channel_idx++;
|
||||
// getChannelForSave() returns every slot up to MAX_GROUP_CHANNELS, so skip
|
||||
// the unused ones (all-zero secret) rather than writing all 40 — otherwise
|
||||
// the file is always ~2.7 KB and wears the flash needlessly. loadChannels()
|
||||
// already compacts empty entries on read, so the loaded result is identical.
|
||||
bool empty = true;
|
||||
for (int b = 0; b < 32; b++) if (ch.channel.secret[b]) { empty = false; break; }
|
||||
if (empty) continue;
|
||||
bool ok = true;
|
||||
uint8_t channel_idx = 0;
|
||||
ChannelDetails ch;
|
||||
uint8_t unused[4];
|
||||
memset(unused, 0, 4);
|
||||
|
||||
bool success = (file.write(unused, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)ch.name, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32);
|
||||
if (!success) break; // write failed
|
||||
}
|
||||
file.close();
|
||||
while (host->getChannelForSave(channel_idx, ch)) {
|
||||
channel_idx++;
|
||||
// getChannelForSave() returns every slot up to MAX_GROUP_CHANNELS, so skip
|
||||
// the unused ones (all-zero secret) rather than writing all 40 — otherwise
|
||||
// the file is always ~2.7 KB and wears the flash needlessly. loadChannels()
|
||||
// already compacts empty entries on read, so the loaded result is identical.
|
||||
bool empty = true;
|
||||
for (int b = 0; b < 32; b++) if (ch.channel.secret[b]) { empty = false; break; }
|
||||
if (empty) continue;
|
||||
|
||||
bool success = (file.write(unused, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)ch.name, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32);
|
||||
if (!success) { ok = false; break; } // write failed
|
||||
}
|
||||
file.close();
|
||||
|
||||
if (ok) {
|
||||
commitTempFile(fs, "/channels2.tmp", "/channels2");
|
||||
} else {
|
||||
fs->remove("/channels2.tmp"); // keep the previous good /channels2
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,19 @@ static inline void fmtDist(char* buf, int n, float km, bool imperial) {
|
||||
}
|
||||
}
|
||||
|
||||
// Compact age tag for a timestamp, e.g. "12s" / "5m" / "3h" / "2d" — sized to
|
||||
// sit inline after a name (unlike a full "X ago" sentence). Empty string for
|
||||
// an unknown (0) or future timestamp. Takes `now` rather than reading the RTC
|
||||
// itself, so this stays a pure function like the rest of this file.
|
||||
static inline void fmtAgeShort(char* buf, int n, uint32_t now, uint32_t lastmod) {
|
||||
if (lastmod == 0 || now < lastmod) { buf[0] = '\0'; return; }
|
||||
uint32_t age = now - lastmod;
|
||||
if (age < 60) snprintf(buf, n, "%us", (unsigned)age);
|
||||
else if (age < 3600) snprintf(buf, n, "%um", (unsigned)(age / 60));
|
||||
else if (age < 86400) snprintf(buf, n, "%uh", (unsigned)(age / 3600));
|
||||
else snprintf(buf, n, "%ud", (unsigned)(age / 86400));
|
||||
}
|
||||
|
||||
// Tag marking a shared waypoint inside a message: "[WAY]<lat>,<lon> <label>".
|
||||
// A plain {loc} expansion ("<lat>,<lon>") parses too — the tag just adds intent
|
||||
// and a label, and keeps the text readable on apps/firmware that don't know it.
|
||||
@@ -109,4 +122,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
|
||||
|
||||
119
examples/companion_radio/LiveTrack.h
Normal file
119
examples/companion_radio/LiveTrack.h
Normal file
@@ -0,0 +1,119 @@
|
||||
#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;
|
||||
}
|
||||
|
||||
// Latest active position for a verified (DM/pubkey) share, or null if that
|
||||
// node hasn't shared recently. Used by the locator engine to follow a
|
||||
// moving contact.
|
||||
const Entry* activeByKey(const uint8_t* key, uint32_t now) const {
|
||||
if (!key) return nullptr;
|
||||
for (int i = 0; i < CAPACITY; i++) {
|
||||
if (!isActive(i, now)) continue;
|
||||
const Entry& e = _e[i];
|
||||
if (e.verified && memcmp(e.key, key, KEY_LEN) == 0) return &e;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -484,7 +485,7 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
|
||||
bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
|
||||
if (should_display && _ui) {
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len, from.type, from.id.pub_key);
|
||||
_ui->notify(UIEventType::contactMessage);
|
||||
_ui->notify(from.type == ADV_TYPE_ROOM ? UIEventType::roomMessage : UIEventType::contactMessage);
|
||||
// Add to the on-device conversation history. Room servers (ADV_TYPE_ROOM) are
|
||||
// viewed through the same history list as chat contacts (keyed by the server's
|
||||
// pubkey), so their posts must be stored too — otherwise an incoming room
|
||||
@@ -636,6 +637,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
|
||||
@@ -657,6 +664,17 @@ void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uin
|
||||
// from.sync_since change needs to be persisted
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
queueMessage(from, TXT_TYPE_SIGNED_PLAIN, pkt, sender_timestamp, sender_prefix, 4, text);
|
||||
|
||||
// Live position share inside a room (mirrors the DM and channel paths). The
|
||||
// post's author is the signed sender_prefix, not the room server `from`, so
|
||||
// resolve that 4-byte prefix to a contact name and track by name. Unverified:
|
||||
// we only hold a 4-byte prefix here, not the full pubkey LiveTrack keys on.
|
||||
int32_t loc_lat, loc_lon;
|
||||
if (_ui && geo::parseLocShare(text, loc_lat, loc_lon)) {
|
||||
ContactInfo* sc = sender_prefix ? lookupContactByPubKey(sender_prefix, 4) : nullptr;
|
||||
const char* who = (sc && sc->name[0]) ? sc->name : from.name;
|
||||
_ui->onSharedLocation(nullptr, who, loc_lat, loc_lon, sender_timestamp, false);
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp,
|
||||
@@ -710,6 +728,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,103 @@ 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)
|
||||
|
||||
// Locator — a single geofence around a saved point. When enabled the device
|
||||
// watches its own GPS fix and beeps + shows an alert when it crosses into
|
||||
// (arrive) or out of (leave) the radius. The target coordinate/label is a
|
||||
// snapshot of a chosen waypoint, so the alert survives the waypoint being
|
||||
// edited or deleted. Configured from Tools › Locator.
|
||||
uint8_t locator_enabled; // 0=off (default), 1=armed
|
||||
uint8_t locator_has_target; // 0=no target chosen yet, 1=target set
|
||||
uint8_t locator_radius_idx; // index into locatorRadiusMeters
|
||||
uint8_t locator_mode; // 0=arrive, 1=leave, 2=both
|
||||
int32_t locator_lat_1e6; // target latitude (1e6-scaled; last-known for a contact)
|
||||
int32_t locator_lon_1e6; // target longitude (1e6-scaled; last-known for a contact)
|
||||
char locator_label[12]; // target name for the alert text (WAYPOINT_LABEL_LEN)
|
||||
// Target can be a static waypoint or a live contact: for a contact the engine
|
||||
// re-reads the latest [LOC] position each evaluation (keyed by pubkey prefix),
|
||||
// so the geofence follows a moving person ("alert when my friend is near").
|
||||
uint8_t locator_target_kind; // 0=waypoint (static), 1=live contact
|
||||
uint8_t locator_key[6]; // contact pubkey prefix when target_kind==1
|
||||
|
||||
// Trail auto-pause — when tracking, automatically freeze the trail (timer +
|
||||
// sampling) after the device has sat still for this long, and resume on the
|
||||
// next real movement. 0 = off. Index into trailAutoPauseSecs.
|
||||
uint8_t trail_autopause_idx;
|
||||
|
||||
// Locator proximity beeper — when on (and the alert is armed with a target),
|
||||
// the device ticks while inside the radius and shortens the gap between ticks
|
||||
// the closer it gets to the target, like a homing beeper. Independent of the
|
||||
// discrete arrive/leave alert (locator_mode).
|
||||
uint8_t locator_beeper; // 0=off (default), 1=on
|
||||
|
||||
// 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];
|
||||
}
|
||||
|
||||
// Locator option tables (shared by the Tools › Locator UI labels and the
|
||||
// evaluation engine in UITask).
|
||||
static const uint8_t LOCATOR_RADIUS_COUNT = 5;
|
||||
static uint16_t locatorRadiusMeters(uint8_t idx) {
|
||||
static const uint16_t R[LOCATOR_RADIUS_COUNT] = { 50, 100, 250, 500, 1000 };
|
||||
return R[idx < LOCATOR_RADIUS_COUNT ? idx : 1];
|
||||
}
|
||||
static const uint8_t LOCATOR_MODE_COUNT = 3; // 0=arrive, 1=leave, 2=both
|
||||
static const char* locatorModeLabel(uint8_t m) {
|
||||
static const char* L[LOCATOR_MODE_COUNT] = { "Arrive", "Leave", "Both" };
|
||||
return L[m < LOCATOR_MODE_COUNT ? m : 0];
|
||||
}
|
||||
|
||||
// Trail auto-pause delays (seconds). 0 = off.
|
||||
static const uint8_t TRAIL_AUTOPAUSE_COUNT = 4;
|
||||
static uint16_t trailAutoPauseSecs(uint8_t idx) {
|
||||
static const uint16_t S[TRAIL_AUTOPAUSE_COUNT] = { 0, 60, 120, 300 }; // off / 1 / 2 / 5 min
|
||||
return S[idx < TRAIL_AUTOPAUSE_COUNT ? idx : 0];
|
||||
}
|
||||
static const char* trailAutoPauseLabel(uint8_t idx) {
|
||||
static const char* L[TRAIL_AUTOPAUSE_COUNT] = { "Off", "1m", "2m", "5m" };
|
||||
return L[idx < TRAIL_AUTOPAUSE_COUNT ? idx : 0];
|
||||
}
|
||||
// Movement under this many metres counts as "stationary" for auto-pause.
|
||||
// Deliberately coarser than the trail min-delta gate (and independent of it)
|
||||
// so GPS jitter while parked doesn't keep resetting the idle timer. Engine
|
||||
// tuning only — not persisted.
|
||||
static const uint16_t TRAIL_AUTOPAUSE_MOVE_M = 15;
|
||||
|
||||
// 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 = 0xC0DE0015;
|
||||
|
||||
// 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
|
||||
|
||||
@@ -9,9 +9,19 @@
|
||||
|
||||
// RAM-only GPS trail ring buffer.
|
||||
// Storage cost: CAPACITY(512) × sizeof(TrailPoint)(16 B, padded) = 8 KB,
|
||||
// always resident (UITask::_trail member). The trail survives auto-off (only
|
||||
// always resident in .bss (UITask::_trail, and ui_task is a global object —
|
||||
// see main.cpp). Because the nRF52 heap region starts just above .bss, every
|
||||
// byte added here is a byte taken from the heap: at 1024 points (16 KB) free
|
||||
// heap fell to ~4 KB and the input/menu path started failing its allocations
|
||||
// while the periodic redraw kept running. Keep this conservative. The trail
|
||||
// survives auto-off (only
|
||||
// the display blanks) but is lost on reboot — user explicitly snapshots to a
|
||||
// LittleFS slot before powering down to keep it.
|
||||
//
|
||||
// Samples are simplified in-stream as they arrive (see addPoint()): a straight
|
||||
// stretch is stored as just its two endpoints, no matter how long, while a
|
||||
// turn still gets a vertex roughly every min-delta of deviation — so CAPACITY
|
||||
// covers a much longer route than CAPACITY × min-delta would suggest.
|
||||
|
||||
struct TrailPoint {
|
||||
int32_t lat_1e6;
|
||||
@@ -73,6 +83,7 @@ public:
|
||||
if (a && !_active) {
|
||||
// off → on: start a new session timer.
|
||||
_session_start_ms = millis();
|
||||
_paused = false;
|
||||
} else if (_active && !a) {
|
||||
// on → off: bank the elapsed of this session and arm a segment break
|
||||
// so the renderer doesn't draw a straight line through the dead time.
|
||||
@@ -80,11 +91,34 @@ public:
|
||||
_accumulated_ms += millis() - _session_start_ms;
|
||||
_session_start_ms = 0;
|
||||
}
|
||||
flushPending();
|
||||
_pending_seg_break = true;
|
||||
_paused = false;
|
||||
}
|
||||
_active = a;
|
||||
}
|
||||
|
||||
// Auto-pause: freeze the active trail without ending the session. Banks the
|
||||
// running session time (so elapsedSeconds() stops advancing) and arms a
|
||||
// segment break so the map doesn't draw a line across the idle gap; resuming
|
||||
// restarts the session timer. Distinct from setActive() — the trail stays
|
||||
// "on" (UI shows "paused", home-screen blink keeps going). No-op if inactive.
|
||||
bool isPaused() const { return _paused; }
|
||||
void setPaused(bool p) {
|
||||
if (!_active || p == _paused) return;
|
||||
if (p) {
|
||||
if (_session_start_ms != 0) {
|
||||
_accumulated_ms += millis() - _session_start_ms;
|
||||
_session_start_ms = 0;
|
||||
}
|
||||
flushPending();
|
||||
_pending_seg_break = true;
|
||||
} else {
|
||||
_session_start_ms = millis(); // resume timing from now
|
||||
}
|
||||
_paused = p;
|
||||
}
|
||||
|
||||
int count() const { return _count; }
|
||||
bool empty() const { return _count == 0; }
|
||||
|
||||
@@ -98,36 +132,83 @@ public:
|
||||
_pending_seg_break = false;
|
||||
_accumulated_ms = 0;
|
||||
_session_start_ms = 0;
|
||||
_paused = false;
|
||||
_has_pending = false;
|
||||
}
|
||||
|
||||
// Returns true if the point was stored (passed the min-delta gate).
|
||||
// First point of the ring and the first point after a stop/start cycle
|
||||
// get flagged TRAIL_FLAG_SEG_START so the map renderer breaks the line.
|
||||
// Returns true if the sample was accepted (passed the min-delta gate) —
|
||||
// whether that landed it as a new committed vertex right away, or just
|
||||
// extended the pending candidate (see below). First point of the ring and
|
||||
// the first point after a stop/start cycle get flagged TRAIL_FLAG_SEG_START
|
||||
// so the map renderer breaks the line.
|
||||
//
|
||||
// Straight-run simplification (a fixed-corridor / Reumann–Witkam pass): a
|
||||
// sample is only ever a *candidate* vertex (_pending) until a later one
|
||||
// proves the run has bent. The corridor is the straight line anchored at the
|
||||
// last committed vertex and aimed at the first sample of this run (_dir);
|
||||
// each new sample is tested against *that fixed line*. While it stays within
|
||||
// CORRIDOR_FACTOR x min_delta_m of the corridor the run is still "straight
|
||||
// enough", so drop the intermediate and extend; once a sample leaves the
|
||||
// corridor the previous in-corridor sample (_pending) was the last good
|
||||
// vertex, so commit it and open a fresh corridor from there.
|
||||
//
|
||||
// The corridor is deliberately tighter than the min-delta gate itself
|
||||
// (CORRIDOR_FACTOR < 1): the gate is the noise floor for *rejecting* samples
|
||||
// outright, while the corridor decides when a run has bent enough to need a
|
||||
// new vertex. Field data showed plenty of headroom (4 km at a 10 m gate cost
|
||||
// just 38 of the 512-point capacity), so a tighter corridor trades some of
|
||||
// that headroom for a polyline that hugs the real track more closely.
|
||||
//
|
||||
// The fixed direction is the whole point: testing the newest sample against
|
||||
// a line that re-aims at the newest sample (or testing the previous
|
||||
// candidate, which always sits right beside that moving endpoint where the
|
||||
// cross-track is near zero) lets a long gentle curve slip through one
|
||||
// sub-min_delta step at a time and collapse to a single chord that deviates
|
||||
// arbitrarily far. Anchoring the direction bounds the stored polyline to
|
||||
// ~CORRIDOR_FACTOR x min_delta_m of the real track, while a straight road
|
||||
// still costs just its two endpoints no matter how long it is.
|
||||
// How much tighter the simplification corridor is than the min-delta gate
|
||||
// (see addPoint() above). 1.0 would track the gate exactly; lowering this
|
||||
// commits more vertices per route (more fidelity, less reduction).
|
||||
static constexpr float CORRIDOR_FACTOR = 0.5f;
|
||||
|
||||
bool addPoint(int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, uint16_t min_delta_m) {
|
||||
if (_count > 0 && !_pending_seg_break) {
|
||||
float d = haversineMeters(last().lat_1e6, last().lon_1e6, lat_1e6, lon_1e6);
|
||||
const TrailPoint* ref = _has_pending ? &_pending : (_count > 0 ? &last() : nullptr);
|
||||
if (ref && !_pending_seg_break) {
|
||||
float d = haversineMeters(ref->lat_1e6, ref->lon_1e6, lat_1e6, lon_1e6);
|
||||
if (d < (float)min_delta_m) return false;
|
||||
}
|
||||
uint8_t flags = (_count == 0 || _pending_seg_break) ? TRAIL_FLAG_SEG_START : 0;
|
||||
_pending_seg_break = false;
|
||||
|
||||
int pos;
|
||||
if (_count < CAPACITY) {
|
||||
pos = (_head + _count) % CAPACITY;
|
||||
_count++;
|
||||
} else {
|
||||
pos = _head;
|
||||
_head = (_head + 1) % CAPACITY;
|
||||
if (_count == 0 || _pending_seg_break) {
|
||||
flushPending(); // shouldn't normally have one here, but never lose real distance
|
||||
commitPoint(lat_1e6, lon_1e6, ts, TRAIL_FLAG_SEG_START);
|
||||
_pending_seg_break = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
TrailPoint sample{ lat_1e6, lon_1e6, ts, 0 };
|
||||
if (!_has_pending) {
|
||||
_pending = sample; // last in-corridor sample (the commit candidate)
|
||||
_dir = sample; // fixes the corridor direction: last() → _dir
|
||||
_has_pending = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (crossTrackMeters(last(), _dir, sample) <= (float)min_delta_m * CORRIDOR_FACTOR) {
|
||||
_pending = sample; // within corridor — extend
|
||||
} else {
|
||||
commitPoint(_pending.lat_1e6, _pending.lon_1e6, _pending.ts, 0); // left corridor — keep last good
|
||||
_pending = sample; // the exiting sample opens the next run...
|
||||
_dir = sample; // ...and fixes its corridor direction from the just-committed vertex
|
||||
}
|
||||
_buf[pos].lat_1e6 = lat_1e6;
|
||||
_buf[pos].lon_1e6 = lon_1e6;
|
||||
_buf[pos].ts = ts;
|
||||
_buf[pos].flags = flags;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sum of pairwise Haversine deltas across the whole ring, skipping segment
|
||||
// boundaries (a SEG_START point isn't reached from its predecessor).
|
||||
// boundaries (a SEG_START point isn't reached from its predecessor). The
|
||||
// uncommitted candidate (_pending) is counted too, so the live total doesn't
|
||||
// stall over a long straight run — where simplification holds the whole
|
||||
// stretch as one pending point until a bend forces a commit (see addPoint()).
|
||||
uint32_t totalDistanceMeters() const {
|
||||
float d = 0;
|
||||
for (int i = 1; i < _count; i++) {
|
||||
@@ -135,6 +216,9 @@ public:
|
||||
d += haversineMeters(at(i - 1).lat_1e6, at(i - 1).lon_1e6,
|
||||
at(i).lat_1e6, at(i).lon_1e6);
|
||||
}
|
||||
if (_has_pending && _count > 0)
|
||||
d += haversineMeters(last().lat_1e6, last().lon_1e6,
|
||||
_pending.lat_1e6, _pending.lon_1e6);
|
||||
return (uint32_t)d;
|
||||
}
|
||||
|
||||
@@ -182,6 +266,7 @@ public:
|
||||
// cleanly. The file is left open for the caller to close.
|
||||
template <typename F>
|
||||
bool writeTo(F& file) {
|
||||
flushPending(); // the snapshot should include the latest position, not lag behind it
|
||||
if (!persist::writeHeader(file, SAVE_MAGIC, SAVE_VERSION, (uint16_t)_count)) return false;
|
||||
uint32_t accum = currentAccumulatedMs();
|
||||
if (file.write((uint8_t*)&accum, sizeof(accum)) != sizeof(accum)) return false;
|
||||
@@ -202,6 +287,8 @@ public:
|
||||
_active = false;
|
||||
_session_start_ms = 0;
|
||||
}
|
||||
_paused = false;
|
||||
_has_pending = false; // any candidate belonged to the session being replaced
|
||||
_head = 0;
|
||||
_count = 0;
|
||||
for (int i = 0; i < cnt; i++) {
|
||||
@@ -367,7 +454,58 @@ private:
|
||||
int _head = 0;
|
||||
int _count = 0;
|
||||
bool _active = false;
|
||||
bool _paused = false; // auto-paused (active but timer/sampling frozen)
|
||||
bool _pending_seg_break = false; // next addPoint flags itself SEG_START
|
||||
uint32_t _accumulated_ms = 0; // banked active time across previous sessions
|
||||
uint32_t _session_start_ms = 0; // millis() of the current active session, 0 if none
|
||||
|
||||
// Streaming simplification: a sample that passed the min-delta gate but
|
||||
// hasn't been committed as a real vertex yet — see addPoint(). _dir is the
|
||||
// first sample of the current run; last()→_dir fixes the corridor direction
|
||||
// every later sample of the run is tested against.
|
||||
bool _has_pending = false;
|
||||
TrailPoint _pending;
|
||||
TrailPoint _dir;
|
||||
|
||||
void commitPoint(int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, uint8_t flags) {
|
||||
int pos;
|
||||
if (_count < CAPACITY) {
|
||||
pos = (_head + _count) % CAPACITY;
|
||||
_count++;
|
||||
} else {
|
||||
pos = _head;
|
||||
_head = (_head + 1) % CAPACITY;
|
||||
}
|
||||
_buf[pos].lat_1e6 = lat_1e6;
|
||||
_buf[pos].lon_1e6 = lon_1e6;
|
||||
_buf[pos].ts = ts;
|
||||
_buf[pos].flags = flags;
|
||||
}
|
||||
|
||||
// Commit the pending candidate (if any) as a real vertex. Called before a
|
||||
// segment break (stop/pause/save) so the last stretch of a straight run is
|
||||
// never silently dropped just because no bend came along to force a commit.
|
||||
void flushPending() {
|
||||
if (!_has_pending) return;
|
||||
commitPoint(_pending.lat_1e6, _pending.lon_1e6, _pending.ts, 0);
|
||||
_has_pending = false;
|
||||
}
|
||||
|
||||
// Perpendicular ("cross-track") distance from q to the line through a→b, in
|
||||
// metres. Planar approximation (equirectangular, centred at a) — accurate
|
||||
// enough at trail scale, where consecutive points are metres to a few km
|
||||
// apart. Falls back to a straight distance to a if a and b coincide.
|
||||
static float crossTrackMeters(const TrailPoint& a, const TrailPoint& b, const TrailPoint& q) {
|
||||
static const float M_PER_DEG = 111320.0f; // metres per degree of latitude
|
||||
float lat_rad = (a.lat_1e6 * 1e-6f) * ((float)M_PI / 180.0f);
|
||||
float lon_scale = cosf(lat_rad);
|
||||
float bx = (b.lon_1e6 - a.lon_1e6) * 1e-6f * M_PER_DEG * lon_scale;
|
||||
float by = (b.lat_1e6 - a.lat_1e6) * 1e-6f * M_PER_DEG;
|
||||
float ab_len = sqrtf(bx * bx + by * by);
|
||||
if (ab_len < 0.01f) return haversineMeters(a.lat_1e6, a.lon_1e6, q.lat_1e6, q.lon_1e6);
|
||||
float qx = (q.lon_1e6 - a.lon_1e6) * 1e-6f * M_PER_DEG * lon_scale;
|
||||
float qy = (q.lat_1e6 - a.lat_1e6) * 1e-6f * M_PER_DEG;
|
||||
float cross = bx * qy - by * qx;
|
||||
return fabsf(cross) / ab_len;
|
||||
}
|
||||
};
|
||||
|
||||
97
examples/companion_radio/ui-new/AccordionList.h
Normal file
97
examples/companion_radio/ui-new/AccordionList.h
Normal file
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
// Shared collapsible section list (accordion) — a Settings-style list whose
|
||||
// rows are grouped under headers that expand/collapse in place. Owns the
|
||||
// collapse bitmask, the flattened visible-row list, selection and scroll; the
|
||||
// host screen only supplies the section sizes and two row painters.
|
||||
//
|
||||
// Reusable UI element (cf. drawList / DigitEditor / NavView): any screen that
|
||||
// wants grouped, foldable rows drives it the same way —
|
||||
// _acc.begin(sizes, n); // once, on enter()
|
||||
// _acc.render(display, headerFn, itemFn); // in render()
|
||||
// switch (_acc.handleInput(c)) { ... } // in handleInput()
|
||||
//
|
||||
// Relies on drawList() + the KEY_* codes already pulled in by icons.h /
|
||||
// UIScreen.h before this header (same include-order contract as the screens).
|
||||
#include "icons.h"
|
||||
|
||||
class AccordionList {
|
||||
public:
|
||||
// A visible row is either a section header (item < 0) or the item-th entry
|
||||
// within section `sec`.
|
||||
struct Row { int8_t sec; int8_t item; };
|
||||
|
||||
enum Result { IGNORED, HANDLED, ACTIVATED };
|
||||
|
||||
static const int MAX_SECTIONS = 8; // collapse bitmask is 8 bits
|
||||
static const int MAX_ROWS = 64;
|
||||
|
||||
// (Re)initialise from per-section item counts. `collapsed` sets the initial
|
||||
// fold state for every section (Settings opens folded, so default true).
|
||||
void begin(const uint8_t* section_sizes, int section_count, bool collapsed = true) {
|
||||
_count = section_count > MAX_SECTIONS ? MAX_SECTIONS : section_count;
|
||||
for (int i = 0; i < _count; i++) _sizes[i] = section_sizes[i];
|
||||
_collapsed = collapsed ? 0xFF : 0x00;
|
||||
_sel = 0; _scroll = 0;
|
||||
rebuild();
|
||||
}
|
||||
|
||||
int visibleCount() const { return _vis_count; }
|
||||
bool collapsed(int sec) const { return (_collapsed >> sec) & 1; }
|
||||
const Row& selected() const { return _rows[_sel]; }
|
||||
bool onHeader() const { return _vis_count && _rows[_sel].item < 0; }
|
||||
|
||||
// Paint via the shared drawList skeleton. `header(sec, y, sel, reserve,
|
||||
// collapsed)` and `item(sec, item, y, sel, reserve)` each draw one row
|
||||
// (including its own selection bar, as drawList's callers do).
|
||||
template <class HeaderFn, class ItemFn>
|
||||
void render(DisplayDriver& d, HeaderFn header, ItemFn item) {
|
||||
drawList(d, _vis_count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
|
||||
const Row& r = _rows[idx];
|
||||
if (r.item < 0) header(r.sec, y, sel, reserve, collapsed(r.sec));
|
||||
else item(r.sec, r.item, y, sel, reserve);
|
||||
});
|
||||
}
|
||||
|
||||
// Standard navigation. Returns ACTIVATED when Enter lands on an item (the
|
||||
// host then acts on selected()); HANDLED for nav / fold toggles; IGNORED
|
||||
// otherwise so the host can treat the key itself (e.g. Cancel).
|
||||
Result handleInput(char c) {
|
||||
if (c == KEY_UP) { if (_vis_count) _sel = (_sel > 0) ? _sel - 1 : _vis_count - 1; return HANDLED; }
|
||||
if (c == KEY_DOWN) { if (_vis_count) _sel = (_sel + 1 < _vis_count) ? _sel + 1 : 0; return HANDLED; }
|
||||
if (c == KEY_ENTER) {
|
||||
if (!_vis_count) return HANDLED;
|
||||
if (_rows[_sel].item < 0) { toggle(_rows[_sel].sec); return HANDLED; }
|
||||
return ACTIVATED;
|
||||
}
|
||||
return IGNORED;
|
||||
}
|
||||
|
||||
// Fold/unfold a section, keeping that section's header selected afterwards so
|
||||
// the cursor never jumps to an unrelated row when items appear/disappear.
|
||||
void toggle(int sec) {
|
||||
if (sec < 0 || sec >= _count) return;
|
||||
_collapsed ^= (uint8_t)(1u << sec);
|
||||
rebuild();
|
||||
for (int i = 0; i < _vis_count; i++)
|
||||
if (_rows[i].sec == sec && _rows[i].item < 0) { _sel = i; break; }
|
||||
}
|
||||
|
||||
private:
|
||||
uint8_t _sizes[MAX_SECTIONS];
|
||||
int _count = 0;
|
||||
uint8_t _collapsed = 0xFF;
|
||||
Row _rows[MAX_ROWS];
|
||||
int _vis_count = 0;
|
||||
int _sel = 0, _scroll = 0;
|
||||
|
||||
void rebuild() {
|
||||
_vis_count = 0;
|
||||
for (int s = 0; s < _count; s++) {
|
||||
if (_vis_count < MAX_ROWS) _rows[_vis_count++] = { (int8_t)s, (int8_t)-1 };
|
||||
if (!collapsed(s))
|
||||
for (int it = 0; it < _sizes[s] && _vis_count < MAX_ROWS; it++)
|
||||
_rows[_vis_count++] = { (int8_t)s, (int8_t)it };
|
||||
}
|
||||
if (_sel >= _vis_count) _sel = _vis_count ? _vis_count - 1 : 0;
|
||||
}
|
||||
};
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <stdlib.h> // abs
|
||||
#include "../Trail.h"
|
||||
|
||||
namespace gfx {
|
||||
|
||||
@@ -37,4 +38,25 @@ static inline void drawCircle(DisplayDriver& d, int cx, int cy, int r) {
|
||||
}
|
||||
}
|
||||
|
||||
// Walks a TrailStore and renders it as a connected polyline via drawLine(),
|
||||
// breaking the line at each TRAIL_FLAG_SEG_START point (trail paused/
|
||||
// restarted) instead of bridging the dead-time gap. Shared by the full Trail
|
||||
// map and the Home screen's mini-map preview, which only differ in how they
|
||||
// project lat/lon to screen coords and what (if anything) they draw at a
|
||||
// break. `project(lat, lon, x, y)` fills the screen coords for one point;
|
||||
// `on_break(x_prev_end, y_prev_end, x_new_start, y_new_start)` runs at each
|
||||
// break instead of drawLine() — pass a no-op lambda for a silent gap.
|
||||
template <typename Project, typename OnBreak>
|
||||
static void drawTrail(DisplayDriver& d, TrailStore& tr, Project project, OnBreak on_break) {
|
||||
if (tr.count() == 0) return;
|
||||
int x0, y0; project(tr.at(0).lat_1e6, tr.at(0).lon_1e6, x0, y0);
|
||||
for (int i = 1; i < tr.count(); i++) {
|
||||
const TrailPoint& pt = tr.at(i);
|
||||
int x1, y1; project(pt.lat_1e6, pt.lon_1e6, x1, y1);
|
||||
if (pt.flags & TRAIL_FLAG_SEG_START) on_break(x0, y0, x1, y1);
|
||||
else drawLine(d, x0, y0, x1, y1);
|
||||
x0 = x1; y0 = y1;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace gfx
|
||||
|
||||
175
examples/companion_radio/ui-new/LiveShareScreen.h
Normal file
175
examples/companion_radio/ui-new/LiveShareScreen.h
Normal file
@@ -0,0 +1,175 @@
|
||||
#pragma once
|
||||
// Live location sharing config tool. Tools › Live Share.
|
||||
// One place for both directions of position sharing:
|
||||
// Track loc — receive [LOC] shares from others (LiveTrackStore).
|
||||
// Auto share — periodically broadcast my own [LOC] to a channel/contact
|
||||
// while moving (movement-gated engine in UITask).
|
||||
// Independent of (and able to run alongside) the 0-hop Auto-Advert. The
|
||||
// one-shot "Share my pos" lives on the Map screen.
|
||||
// Included by UITask.cpp after AutoAdvertScreen.h.
|
||||
|
||||
#include "../NodePrefs.h"
|
||||
#include "icons.h" // drawList (shared scrolling-list helper)
|
||||
|
||||
class LiveShareScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
bool _dirty = false;
|
||||
int _sel = 0;
|
||||
int _scroll = 0;
|
||||
|
||||
enum Kind : uint8_t { K_TRACK, K_AUTO, K_TARGET, K_MOVE, K_GAP, K_HB };
|
||||
struct Row { Kind kind; const char* label; };
|
||||
static const int ROW_COUNT = 6;
|
||||
static Row rows(int i) {
|
||||
static const Row R[ROW_COUNT] = {
|
||||
{ K_TRACK, "Track loc" },
|
||||
{ K_AUTO, "Auto share" },
|
||||
{ K_TARGET, "To" },
|
||||
{ K_MOVE, "Move" },
|
||||
{ K_GAP, "Min gap" },
|
||||
{ K_HB, "Heartbeat" },
|
||||
};
|
||||
return R[i];
|
||||
}
|
||||
|
||||
public:
|
||||
LiveShareScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
|
||||
|
||||
void enter() { _dirty = false; _sel = 0; _scroll = 0; }
|
||||
|
||||
// Resolve the configured target's display name (channel name or contact name).
|
||||
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?)");
|
||||
}
|
||||
}
|
||||
|
||||
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 valx = display.width() / 2 + 6;
|
||||
drawList(display, ROW_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
|
||||
Row r = rows(i);
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||||
display.setCursor(4, y);
|
||||
display.print(r.label);
|
||||
char val[24];
|
||||
valueLabel(r.kind, val, sizeof(val));
|
||||
if (val[0]) display.drawTextEllipsized(valx, y, display.width() - valx - reserve, val);
|
||||
});
|
||||
return 500;
|
||||
}
|
||||
|
||||
void moveSel(int dir) { _sel = (_sel + dir + ROW_COUNT) % ROW_COUNT; }
|
||||
|
||||
// Cycle/act on the selected row. `enter` opens the target picker (vs LEFT/RIGHT
|
||||
// value cycling).
|
||||
void activate(int dir, bool enter) {
|
||||
if (!_prefs) return;
|
||||
switch (rows(_sel).kind) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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; }
|
||||
ContactInfo* fc = the_mesh.lookupContactByPubKey(pre, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
// Skip room servers: a [LOC] DM to a room server isn't reposted to the
|
||||
// room's members, so it can't serve as a live-share broadcast target.
|
||||
if (empty || !fc || fc->type == ADV_TYPE_ROOM) 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
310
examples/companion_radio/ui-new/LocatorScreen.h
Normal file
310
examples/companion_radio/ui-new/LocatorScreen.h
Normal file
@@ -0,0 +1,310 @@
|
||||
#pragma once
|
||||
// Locator config tool. Tools › Locator.
|
||||
// A single geofence whose target is either a saved waypoint (a place) or a
|
||||
// person — a favourite/contact or a live [LOC] sender, keyed by pubkey prefix.
|
||||
// When armed the device beeps / alerts as it crosses into (arrive/near) or out
|
||||
// of (leave/away) the radius. A waypoint target is snapshotted (coord + label);
|
||||
// a person target follows their latest shared position. The crossing engine
|
||||
// lives in UITask::evaluateLocator(). The Target row's Enter opens a picker
|
||||
// ("None" first — the only way to unset a target once chosen — then
|
||||
// favourites, offered even with no known position yet so you can arm ahead
|
||||
// of time, then any other contact with a currently-known position:
|
||||
// live-sharing or just last-advertised, e.g. a repeater; then waypoints).
|
||||
// LEFT/RIGHT quick-cycles the same set. The same active target can also be set
|
||||
// directly from Nearby Nodes' or Waypoints' own "Set as target" menu item
|
||||
// (UITask::setTargetNow()), bypassing this picker entirely.
|
||||
// Included by UITask.cpp after LiveShareScreen.h.
|
||||
|
||||
#include "../NodePrefs.h"
|
||||
#include "../Waypoint.h"
|
||||
#include "../LiveTrack.h"
|
||||
#include "../GeoUtils.h"
|
||||
#include "icons.h" // drawList (shared scrolling-list helper)
|
||||
|
||||
class LocatorScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
bool _dirty = false;
|
||||
int _sel = 0;
|
||||
int _scroll = 0;
|
||||
|
||||
// Target picker (Enter on the Target row): a flat selectable list of people
|
||||
// and places, rebuilt from favourites / known-position contacts / waypoints
|
||||
// on open. `live` + `ts` are picker-display-only (freshness), never written
|
||||
// to prefs — a person target is re-resolved by key at evaluation time.
|
||||
bool _picking = false;
|
||||
int _pick_sel = 0, _pick_scroll = 0;
|
||||
struct Target {
|
||||
uint8_t kind; // 0=waypoint 1=person 2=none (clear target)
|
||||
int32_t lat, lon;
|
||||
uint8_t key[6];
|
||||
char name[20];
|
||||
uint32_t ts; // last position update (0 = unknown), for the age tag
|
||||
bool live; // true = an active [LOC] share right now
|
||||
};
|
||||
static const int TARGET_MAX = 40;
|
||||
Target _targets[TARGET_MAX];
|
||||
int _target_n = 0;
|
||||
|
||||
enum Kind : uint8_t { K_ENABLE, K_TARGET, K_RADIUS, K_MODE, K_BEEPER };
|
||||
struct Row { Kind kind; const char* label; };
|
||||
static const int ROW_COUNT = 5;
|
||||
static Row rows(int i) {
|
||||
static const Row R[ROW_COUNT] = {
|
||||
{ K_ENABLE, "Alert" },
|
||||
{ K_TARGET, "Target" },
|
||||
{ K_RADIUS, "Radius" },
|
||||
{ K_MODE, "Mode" },
|
||||
{ K_BEEPER, "Beeper" },
|
||||
};
|
||||
return R[i];
|
||||
}
|
||||
|
||||
// The homing Beeper only makes sense when "arrive" is part of the alert mode —
|
||||
// it guides you toward the target while inside the radius. In pure "leave"
|
||||
// mode (1) it's contradictory (silent outside, slows as you near the exit), so
|
||||
// hide its row. Beeper is the last row, so this is just a shorter visible count;
|
||||
// the beeper engine (UITask::locatorProximityBeeper) is gated on the mode too.
|
||||
int visibleRows() const {
|
||||
return (_prefs && _prefs->locator_mode == 1) ? ROW_COUNT - 1 : ROW_COUNT;
|
||||
}
|
||||
|
||||
public:
|
||||
LocatorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
|
||||
|
||||
void enter() { _dirty = false; _sel = 0; _scroll = 0; _picking = false; }
|
||||
|
||||
void valueLabel(Kind k, char* buf, int n) {
|
||||
switch (k) {
|
||||
case K_ENABLE:
|
||||
snprintf(buf, n, "%s", (_prefs && _prefs->locator_enabled) ? "ON" : "OFF");
|
||||
break;
|
||||
case K_TARGET:
|
||||
if (_prefs && _prefs->locator_has_target) {
|
||||
const char* nm = _prefs->locator_label[0] ? _prefs->locator_label : "(unnamed)";
|
||||
// '@' prefix marks a live contact target (a moving person) vs a waypoint.
|
||||
if (_prefs->locator_target_kind == 1) snprintf(buf, n, "@%s", nm);
|
||||
else snprintf(buf, n, "%s", nm);
|
||||
} else {
|
||||
snprintf(buf, n, "none");
|
||||
}
|
||||
break;
|
||||
case K_RADIUS: {
|
||||
uint16_t r = NodePrefs::locatorRadiusMeters(_prefs ? _prefs->locator_radius_idx : 1);
|
||||
if (r < 1000) snprintf(buf, n, "%um", (unsigned)r);
|
||||
else snprintf(buf, n, "%.1fkm", r / 1000.0f);
|
||||
break;
|
||||
}
|
||||
case K_MODE:
|
||||
snprintf(buf, n, "%s", NodePrefs::locatorModeLabel(_prefs ? _prefs->locator_mode : 0));
|
||||
break;
|
||||
case K_BEEPER:
|
||||
snprintf(buf, n, "%s", (_prefs && _prefs->locator_beeper) ? "ON" : "OFF");
|
||||
break;
|
||||
default: buf[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
if (_picking) { renderPicker(display); return 400; }
|
||||
display.drawCenteredHeader("LOCATOR");
|
||||
|
||||
const int rc = visibleRows();
|
||||
if (_sel >= rc) _sel = rc - 1; // beeper row may have just been hidden
|
||||
const int valx = display.width() / 2 + 6;
|
||||
drawList(display, rc, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
|
||||
Row r = rows(i);
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||||
display.setCursor(4, y);
|
||||
display.print(r.label);
|
||||
char val[24];
|
||||
valueLabel(r.kind, val, sizeof(val));
|
||||
if (val[0]) display.drawTextEllipsized(valx, y, display.width() - valx - reserve, val);
|
||||
});
|
||||
return 500;
|
||||
}
|
||||
|
||||
void moveSel(int dir) { int rc = visibleRows(); _sel = (_sel + dir + rc) % rc; }
|
||||
|
||||
void activate(int dir) {
|
||||
if (!_prefs) return;
|
||||
switch (rows(_sel).kind) {
|
||||
case K_ENABLE:
|
||||
_prefs->locator_enabled ^= 1;
|
||||
if (_prefs->locator_enabled && !_prefs->locator_has_target)
|
||||
_task->showAlert("Pick a target", 1200);
|
||||
_dirty = true;
|
||||
break;
|
||||
case K_TARGET:
|
||||
cycleTarget(dir);
|
||||
break;
|
||||
case K_RADIUS:
|
||||
_prefs->locator_radius_idx = (uint8_t)((_prefs->locator_radius_idx
|
||||
+ (dir >= 0 ? 1 : NodePrefs::LOCATOR_RADIUS_COUNT - 1)) % NodePrefs::LOCATOR_RADIUS_COUNT);
|
||||
_dirty = true;
|
||||
break;
|
||||
case K_MODE:
|
||||
_prefs->locator_mode = (uint8_t)((_prefs->locator_mode
|
||||
+ (dir >= 0 ? 1 : NodePrefs::LOCATOR_MODE_COUNT - 1)) % NodePrefs::LOCATOR_MODE_COUNT);
|
||||
_dirty = true;
|
||||
break;
|
||||
case K_BEEPER:
|
||||
_prefs->locator_beeper ^= 1;
|
||||
_dirty = true;
|
||||
break;
|
||||
}
|
||||
// Re-seed the crossing engine after any change so editing the target/radius
|
||||
// while armed can't fire a stale arrive/leave before the screen is closed.
|
||||
_task->resetLocator();
|
||||
}
|
||||
|
||||
// Add a person candidate to _targets, deduped by pubkey prefix. Freshness is
|
||||
// resolved here for display only: an active [LOC] share wins (live=true),
|
||||
// else the contact's last-advertised position if it has one — the same
|
||||
// precedence UITask::locatorDistance() uses at evaluation time. When
|
||||
// `require_position` is false (favourites), a contact with neither is still
|
||||
// added with no position — "arm ahead of time", per the existing feature.
|
||||
bool addPersonTarget(const uint8_t* key, const char* name, bool require_position) {
|
||||
if (_target_n >= TARGET_MAX) return false;
|
||||
for (int j = 0; j < _target_n; j++)
|
||||
if (_targets[j].kind == 1 && memcmp(_targets[j].key, key, 6) == 0) return false; // already added
|
||||
|
||||
int32_t lat = 0, lon = 0; uint32_t ts = 0; bool live = false;
|
||||
bool has_pos = _task->resolvePersonPos(key, lat, lon, &live, &ts); // same precedence as the engine
|
||||
if (require_position && !has_pos) return false; // nothing to navigate to yet
|
||||
|
||||
Target& t = _targets[_target_n++];
|
||||
t.kind = 1; t.lat = lat; t.lon = lon; t.ts = ts; t.live = live;
|
||||
memcpy(t.key, key, 6);
|
||||
snprintf(t.name, sizeof(t.name), "%s", name);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Build the selectable target set into _targets: "None" first (clears the
|
||||
// target — the only way to unset it once chosen), then favourites (the quick
|
||||
// path you pin ahead of time, offered even with no known position yet), then
|
||||
// any other contact with a currently-known position — live-sharing or just
|
||||
// last-advertised (a repeater, a room, or someone who shared a fix once) —
|
||||
// then saved waypoints. A person is keyed by pubkey prefix so the engine
|
||||
// re-resolves their position each evaluation rather than trusting a snapshot.
|
||||
void buildTargets() {
|
||||
_target_n = 0;
|
||||
Target& none = _targets[_target_n++];
|
||||
none.kind = 2; none.lat = 0; none.lon = 0; none.ts = 0; none.live = false;
|
||||
memset(none.key, 0, 6);
|
||||
snprintf(none.name, sizeof(none.name), "(none)");
|
||||
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) {
|
||||
const uint8_t* pre = _prefs->favourite_contacts[i];
|
||||
bool empty = true;
|
||||
for (int b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++) if (pre[b]) { empty = false; break; }
|
||||
if (empty) continue;
|
||||
ContactInfo* c = the_mesh.lookupContactByPubKey(pre, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
if (!c) continue;
|
||||
addPersonTarget(pre, c->name, /*require_position=*/false);
|
||||
}
|
||||
for (int idx = 0; _target_n < TARGET_MAX; idx++) {
|
||||
ContactInfo c;
|
||||
if (!the_mesh.getContactByIdx(idx, c)) break;
|
||||
addPersonTarget(c.id.pub_key, c.name, /*require_position=*/true);
|
||||
}
|
||||
WaypointStore& wp = _task->waypoints();
|
||||
for (int i = 0; i < wp.count() && _target_n < TARGET_MAX; i++) {
|
||||
const Waypoint& w = wp.at(i);
|
||||
Target& t = _targets[_target_n++];
|
||||
t.kind = 0; t.lat = w.lat_1e6; t.lon = w.lon_1e6;
|
||||
memset(t.key, 0, 6);
|
||||
snprintf(t.name, sizeof(t.name), "%s", w.label);
|
||||
}
|
||||
}
|
||||
|
||||
// Index of the currently-configured target within _targets, or -1.
|
||||
// No target set at all maps to the synthetic "None" entry (always index 0).
|
||||
int currentTargetIndex() const {
|
||||
if (!_prefs->locator_has_target) return 0;
|
||||
for (int i = 0; i < _target_n; i++) {
|
||||
if (_targets[i].kind == 1 && _prefs->locator_target_kind == 1) {
|
||||
if (memcmp(_targets[i].key, _prefs->locator_key, 6) == 0) return i;
|
||||
} else if (_targets[i].kind == 0 && _prefs->locator_target_kind == 0) {
|
||||
if (_targets[i].lat == _prefs->locator_lat_1e6 &&
|
||||
_targets[i].lon == _prefs->locator_lon_1e6) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void applyTarget(const Target& t) {
|
||||
if (t.kind == 2) { _task->clearTarget(); _dirty = true; return; }
|
||||
// Same target definition as every other entry point (UITask::setTarget),
|
||||
// but the save is deferred to screen exit (_dirty) so LEFT/RIGHT cycling
|
||||
// through candidates doesn't write flash on each step.
|
||||
_task->setTarget(t.kind, t.kind == 1 ? t.key : nullptr, t.lat, t.lon, t.name);
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
// LEFT/RIGHT quick-cycle over the same set the picker shows (includes "None").
|
||||
void cycleTarget(int dir) {
|
||||
buildTargets();
|
||||
int cur = currentTargetIndex();
|
||||
int nx = (cur < 0) ? (dir >= 0 ? 0 : _target_n - 1)
|
||||
: ((cur + (dir >= 0 ? 1 : _target_n - 1)) % _target_n);
|
||||
applyTarget(_targets[nx]);
|
||||
}
|
||||
|
||||
void openPicker() {
|
||||
if (!_prefs) return;
|
||||
buildTargets();
|
||||
int cur = currentTargetIndex();
|
||||
_pick_sel = (cur >= 0) ? cur : 0;
|
||||
_pick_scroll = 0;
|
||||
_picking = true;
|
||||
}
|
||||
|
||||
void renderPicker(DisplayDriver& display) {
|
||||
display.drawCenteredHeader("PICK TARGET");
|
||||
uint32_t now = rtc_clock.getCurrentTime();
|
||||
drawList(display, _target_n, _pick_sel, _pick_scroll, [&](int i, int y, bool sel, int reserve) {
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||||
const Target& t = _targets[i];
|
||||
char row[36];
|
||||
if (t.kind != 1) {
|
||||
snprintf(row, sizeof(row), "%s", t.name); // a plain name is a waypoint
|
||||
} else if (t.live) {
|
||||
snprintf(row, sizeof(row), "@%s", t.name); // '@' marks a person; live needs no age tag
|
||||
} else if (t.ts) {
|
||||
char age[8];
|
||||
geo::fmtAgeShort(age, sizeof(age), now, t.ts);
|
||||
snprintf(row, sizeof(row), "@%s (%s)", t.name, age); // last-advertised, not actively sharing
|
||||
} else {
|
||||
snprintf(row, sizeof(row), "@%s", t.name); // favourite, no position known yet
|
||||
}
|
||||
display.drawTextEllipsized(2, y, display.width() - 2 - reserve, row);
|
||||
});
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// Target picker takes all input while open.
|
||||
if (_picking) {
|
||||
if (c == KEY_UP) { _pick_sel = (_pick_sel > 0) ? _pick_sel - 1 : _target_n - 1; return true; }
|
||||
if (c == KEY_DOWN) { _pick_sel = (_pick_sel < _target_n - 1) ? _pick_sel + 1 : 0; return true; }
|
||||
if (c == KEY_ENTER) { applyTarget(_targets[_pick_sel]); _picking = false; return true; }
|
||||
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _picking = false; return true; }
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
|
||||
if (_dirty) { the_mesh.savePrefs(); _dirty = false; } // engine re-seeded per edit
|
||||
_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); return true; }
|
||||
if (keyIsNext(c)) { activate(+1); return true; }
|
||||
if (c == KEY_ENTER) {
|
||||
if (rows(_sel).kind == K_TARGET) { openPicker(); return true; } // full list picker
|
||||
activate(+1); return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -12,14 +12,42 @@
|
||||
|
||||
namespace navview {
|
||||
|
||||
// Closing-speed / ETA estimator. The caller keeps one instance per navigation
|
||||
// session and passes it to draw(), which feeds it the live target distance each
|
||||
// frame. Speed is the rate the gap closes (smoothed), so it works for both a
|
||||
// fixed waypoint and a moving live contact; ETA = remaining distance / closing
|
||||
// speed, shown only while actually approaching.
|
||||
struct EtaTracker {
|
||||
float prev_km = -1.0f;
|
||||
uint32_t prev_ms = 0;
|
||||
float closing_kmh = 0.0f; // > 0 = gap shrinking (approaching)
|
||||
bool have = false;
|
||||
void reset() { prev_km = -1.0f; have = false; closing_kmh = 0.0f; }
|
||||
void update(float dist_km, uint32_t now_ms) {
|
||||
if (prev_km >= 0.0f && now_ms > prev_ms) {
|
||||
float dt_h = (now_ms - prev_ms) / 3600000.0f;
|
||||
if (dt_h > 0.0f) {
|
||||
float inst = (prev_km - dist_km) / dt_h; // +approaching, -receding
|
||||
closing_kmh = have ? (closing_kmh * 0.6f + inst * 0.4f) : inst; // light EMA
|
||||
have = true;
|
||||
}
|
||||
}
|
||||
prev_km = dist_km;
|
||||
prev_ms = now_ms;
|
||||
}
|
||||
};
|
||||
|
||||
// own_valid : is there a usable GPS fix for the device right now
|
||||
// cog_valid : is there a stable course-over-ground (UITask::currentCourse)
|
||||
// eta : optional closing-speed/ETA tracker; when supplied a third line is
|
||||
// shown with the approach speed and estimated time of arrival
|
||||
inline void draw(DisplayDriver& d,
|
||||
bool own_valid, int32_t own_lat, int32_t own_lon,
|
||||
int32_t tgt_lat, int32_t tgt_lon,
|
||||
const char* label,
|
||||
bool cog_valid, int cog_deg,
|
||||
bool imperial) {
|
||||
bool imperial,
|
||||
EtaTracker* eta = nullptr) {
|
||||
const int cx = d.width() / 2;
|
||||
const int hdr = d.headerH();
|
||||
|
||||
@@ -52,6 +80,23 @@ inline void draw(DisplayDriver& d,
|
||||
if (cog_valid) snprintf(line, sizeof(line), "Hdg: %d %s", cog_deg, geo::bearingCardinal(cog_deg));
|
||||
else snprintf(line, sizeof(line), "Hdg: --");
|
||||
d.drawTextLeftAlign(2, y, line); y += step;
|
||||
|
||||
// Optional ETA / closing-speed line. Approaching only — a receding or
|
||||
// stationary target shows "ETA: --" rather than a misleading time.
|
||||
if (eta) {
|
||||
eta->update(dist_km, millis());
|
||||
if (eta->have && eta->closing_kmh > 0.3f) {
|
||||
int secs = (int)(dist_km / eta->closing_kmh * 3600.0f);
|
||||
float spd = imperial ? eta->closing_kmh * 0.621371f : eta->closing_kmh;
|
||||
char eta_s[12];
|
||||
if (secs < 3600) snprintf(eta_s, sizeof(eta_s), "%dm", (secs + 59) / 60);
|
||||
else snprintf(eta_s, sizeof(eta_s), "%dh%02dm", secs / 3600, (secs % 3600) / 60);
|
||||
snprintf(line, sizeof(line), "ETA: %s @%d%s", eta_s, (int)(spd + 0.5f), imperial ? "mph" : "kmh");
|
||||
} else {
|
||||
snprintf(line, sizeof(line), "ETA: --");
|
||||
}
|
||||
d.drawTextLeftAlign(2, y, line); y += step;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace navview
|
||||
|
||||
@@ -28,7 +28,7 @@ class NearbyScreen : public UIScreen {
|
||||
enum Source : uint8_t { SRC_STORED, SRC_SCAN };
|
||||
|
||||
// ── action-menu actions (matched by id, not by row index) ────────────────────
|
||||
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_SORT, ACT_SCAN };
|
||||
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_LOCATOR, ACT_SORT, ACT_SCAN };
|
||||
|
||||
// ── unified list entry ───────────────────────────────────────────────────────
|
||||
struct Entry {
|
||||
@@ -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;
|
||||
@@ -53,6 +58,7 @@ class NearbyScreen : public UIScreen {
|
||||
int _scroll;
|
||||
bool _detail;
|
||||
bool _nav = false; // full-screen navigate-to-node view (over detail)
|
||||
navview::EtaTracker _nav_eta; // closing-speed/ETA for the navigate view
|
||||
int32_t _own_lat, _own_lon;
|
||||
bool _own_gps;
|
||||
|
||||
@@ -63,6 +69,7 @@ class NearbyScreen : public UIScreen {
|
||||
unsigned long _detail_refresh_ms;
|
||||
unsigned long _list_refresh_ms = 0;
|
||||
static const unsigned long DETAIL_REFRESH_MS = 10000UL;
|
||||
static const unsigned long NAV_REFRESH_MS = 1000UL; // navigate view tracks a moving target
|
||||
static const unsigned long TIME_LIST_REFRESH_MS = 3000UL;
|
||||
|
||||
// ── live-scan state ──────────────────────────────────────────────────────────
|
||||
@@ -178,12 +185,95 @@ 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();
|
||||
}
|
||||
|
||||
// Rebuild the stored list (re-merge live shares + re-sort) while keeping the
|
||||
// currently highlighted node selected across the rebuild — by contact index
|
||||
// for a stored node, or by name for a non-contact live sender. Returns false
|
||||
// if that node is no longer in the list (caller decides whether to drop the
|
||||
// detail/nav view). Shared by the list, detail and navigate refresh paths.
|
||||
bool refreshKeepingSelection() {
|
||||
int saved_idx = (_sel < _count) ? _entries[_sel].contact_idx : -1;
|
||||
bool saved_live = (_sel < _count) && _entries[_sel].is_live && saved_idx < 0;
|
||||
char saved_name[sizeof(_entries[0].name)]; saved_name[0] = '\0';
|
||||
if (_sel < _count) {
|
||||
strncpy(saved_name, _entries[_sel].name, sizeof(saved_name) - 1);
|
||||
saved_name[sizeof(saved_name) - 1] = '\0';
|
||||
}
|
||||
refreshStored();
|
||||
if (saved_idx >= 0) {
|
||||
for (int i = 0; i < _count; i++)
|
||||
if (_entries[i].contact_idx == saved_idx) { _sel = i; return true; }
|
||||
} else if (saved_live && saved_name[0]) {
|
||||
for (int i = 0; i < _count; i++)
|
||||
if (_entries[i].is_live && strncmp(_entries[i].name, saved_name, sizeof(saved_name) - 1) == 0)
|
||||
{ _sel = i; return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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];
|
||||
// A [LOC] share is an explicit "here I am now", so it defines the pin and
|
||||
// the distance (used for proximity sorting). Recency for the time sort is
|
||||
// the most recent of the advert and the share.
|
||||
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;
|
||||
if (s.ts > e.lastmod) e.lastmod = s.ts;
|
||||
e.is_live = true;
|
||||
e.live_verified = s.verified;
|
||||
} else if (_count < MAX_NEARBY && typeMatchesFilter(ADV_TYPE_CHAT, 0, false)) {
|
||||
// A sender we don't have as a contact: treat it as a companion (the only
|
||||
// node type that shares position), so the type filter still applies —
|
||||
// e.g. it must not appear under the Repeater/Room/Sensor filters.
|
||||
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 +313,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 +459,7 @@ class NearbyScreen : public UIScreen {
|
||||
|
||||
buildSortLabel();
|
||||
_menu_action_count = 0;
|
||||
_menu.begin("Options", 5);
|
||||
_menu.begin("Options", 7);
|
||||
auto add = [&](const char* label, Action a) {
|
||||
_menu.addItem(label);
|
||||
_menu_actions[_menu_action_count++] = a;
|
||||
@@ -376,6 +468,9 @@ class NearbyScreen : public UIScreen {
|
||||
if (has_gps) add("Navigate", ACT_NAV);
|
||||
if (has_key) add("Ping", ACT_PING);
|
||||
if (has_gps) add("Save waypoint", ACT_WAYPOINT);
|
||||
// Needs both a position and a stable identity — a person target is keyed
|
||||
// by pubkey prefix, so a name-only live-scan/channel row can't offer this.
|
||||
if (has_gps && has_key) add("Set as target", ACT_LOCATOR);
|
||||
if (stored) add(_sort_label, ACT_SORT); // sort is meaningless for live-scan rows
|
||||
add(stored ? "Discover scan" : "Rescan", ACT_SCAN);
|
||||
}
|
||||
@@ -384,7 +479,7 @@ class NearbyScreen : public UIScreen {
|
||||
switch (a) {
|
||||
case ACT_NAV: {
|
||||
const Entry* e = selected();
|
||||
if (e && (e->lat_e6 != 0 || e->lon_e6 != 0)) _nav = true;
|
||||
if (e && (e->lat_e6 != 0 || e->lon_e6 != 0)) { _nav = true; _nav_eta.reset(); }
|
||||
else _task->showAlert("No node GPS", 1000);
|
||||
break;
|
||||
}
|
||||
@@ -396,6 +491,12 @@ class NearbyScreen : public UIScreen {
|
||||
break;
|
||||
}
|
||||
case ACT_WAYPOINT: saveSelectedWaypoint(); break;
|
||||
case ACT_LOCATOR: {
|
||||
const Entry* e = selected();
|
||||
if (e && e->has_key && (e->lat_e6 != 0 || e->lon_e6 != 0))
|
||||
_task->setTargetNow(1, e->pub_key, e->lat_e6, e->lon_e6, e->name);
|
||||
break;
|
||||
}
|
||||
case ACT_SORT: break; // adjusted in-place via LEFT/RIGHT, not ENTER
|
||||
case ACT_SCAN: enterScan(); break;
|
||||
}
|
||||
@@ -428,7 +529,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), "Sharing pos: %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);
|
||||
}
|
||||
|
||||
@@ -507,16 +611,16 @@ public:
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
|
||||
// periodic refresh in stored-detail — preserve selected contact by idx
|
||||
if (_detail && _source == SRC_STORED &&
|
||||
millis() - _detail_refresh_ms >= DETAIL_REFRESH_MS) {
|
||||
int saved = (_sel < _count) ? _entries[_sel].contact_idx : -1;
|
||||
refreshStored();
|
||||
bool found = false;
|
||||
if (saved >= 0)
|
||||
for (int i = 0; i < _count; i++)
|
||||
if (_entries[i].contact_idx == saved) { _sel = i; found = true; break; }
|
||||
if (!found) { _detail = false; _nav = false; } // contact gone — drop both views
|
||||
// Periodic refresh of the selected entry while in detail or navigate view,
|
||||
// preserving the selection across the list rebuild. Navigate refreshes
|
||||
// faster so a moving live target (a contact sharing [LOC]) tracks smoothly.
|
||||
// Re-selection keys on contact index for stored nodes, and on name for a
|
||||
// non-contact live sender — without the latter, navigating to someone who
|
||||
// only shares on a channel would drop out on the first refresh.
|
||||
unsigned long refresh_due = _nav ? NAV_REFRESH_MS : DETAIL_REFRESH_MS;
|
||||
if ((_detail || _nav) && _source == SRC_STORED &&
|
||||
millis() - _detail_refresh_ms >= refresh_due) {
|
||||
if (!refreshKeepingSelection()) { _detail = false; _nav = false; } // node/share gone
|
||||
_detail_refresh_ms = millis();
|
||||
}
|
||||
|
||||
@@ -525,7 +629,7 @@ public:
|
||||
const Entry& e = _entries[_sel];
|
||||
int cog; bool cogv = _task->currentCourse(cog);
|
||||
navview::draw(display, _own_gps, _own_lat, _own_lon,
|
||||
e.lat_e6, e.lon_e6, e.name, cogv, cog, useImperial());
|
||||
e.lat_e6, e.lon_e6, e.name, cogv, cog, useImperial(), &_nav_eta);
|
||||
return 1000;
|
||||
}
|
||||
|
||||
@@ -541,9 +645,11 @@ public:
|
||||
if (_source == SRC_SCAN) {
|
||||
refreshScan();
|
||||
if (_scanning && millis() - _scan_started_ms >= SCAN_DURATION_MS) _scanning = false;
|
||||
} else if (_sort == SORT_TIME && millis() - _list_refresh_ms >= TIME_LIST_REFRESH_MS) {
|
||||
// re-sort periodically so newly-heard contacts bubble up
|
||||
refreshStored();
|
||||
} else if (millis() - _list_refresh_ms >= TIME_LIST_REFRESH_MS) {
|
||||
// Re-merge + re-sort periodically so newly-heard contacts and fresh live
|
||||
// [LOC] shares bubble to the right spot under either sort (distances move
|
||||
// as you and they do, not just recency). Keep the highlighted node put.
|
||||
refreshKeepingSelection();
|
||||
_list_refresh_ms = millis();
|
||||
}
|
||||
|
||||
@@ -589,11 +695,20 @@ public:
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
|
||||
|
||||
char filt[32];
|
||||
int tx = 2;
|
||||
if (e.is_live) {
|
||||
// Diamond marker = this node is broadcasting its position ([LOC]),
|
||||
// the same marker the map uses for a live-tracked contact. DM-verified
|
||||
// vs channel-only is spelled out in the detail view.
|
||||
int iw = ICON_MAP_CONTACT.w * miniIconScale(display);
|
||||
miniIconDrawCentered(display, 2 + iw / 2, y + display.getLineHeight() / 2 - 1, ICON_MAP_CONTACT);
|
||||
tx = 2 + iw + 2;
|
||||
}
|
||||
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));
|
||||
}
|
||||
display.drawTextEllipsized(2, y, dist_col - 4, filt);
|
||||
display.drawTextEllipsized(tx, y, dist_col - tx - 2, filt);
|
||||
|
||||
display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT);
|
||||
char right[10];
|
||||
|
||||
@@ -45,11 +45,12 @@ class QuickMsgScreen : public UIScreen {
|
||||
FullscreenMsgView _fs;
|
||||
int _unread_at_entry; // _ch_unread value when entering CHANNEL_HIST
|
||||
int _viewing_max_seen; // highest _hist_sel reached in current session
|
||||
// Shared ring for all channels combined. Sized to comfortably exceed
|
||||
// typical inter-visit accumulation; addChannelMsg() decrements the
|
||||
// Shared ring for all channels combined. addChannelMsg() decrements the
|
||||
// matching _ch_unread when an entry is evicted so the badge can't claim
|
||||
// unread messages that no longer exist in the ring.
|
||||
static const int CH_HIST_MAX = 96;
|
||||
// unread messages that no longer exist in the ring. Each slot carries a full
|
||||
// MSG_TEXT_BUF, so this ring dominates the screen's RAM footprint — kept
|
||||
// modest to leave heap headroom (see DM_HIST_MAX).
|
||||
static const int CH_HIST_MAX = 48;
|
||||
|
||||
// KEYBOARD
|
||||
KeyboardWidget* _kb;
|
||||
@@ -84,6 +85,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
|
||||
@@ -123,7 +127,8 @@ class QuickMsgScreen : public UIScreen {
|
||||
uint8_t attempt; // last attempt number sent (outgoing); next resend = attempt+1
|
||||
uint8_t resends_left; // remaining auto-resends before the marker shows ✗
|
||||
};
|
||||
static const int DM_HIST_MAX = 64;
|
||||
// Each slot carries a full MSG_TEXT_BUF; kept modest to bound heap use.
|
||||
static const int DM_HIST_MAX = 32;
|
||||
DmHistEntry _dm_hist[DM_HIST_MAX];
|
||||
int _dm_hist_head, _dm_hist_count;
|
||||
int _dm_hist_sel, _dm_hist_scroll;
|
||||
@@ -834,6 +839,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;
|
||||
@@ -881,6 +887,44 @@ 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) {
|
||||
// A room server can't be a live-share target — a [LOC] DM to it is never
|
||||
// reposted to the room's members. Reject the pick and keep the chooser open.
|
||||
if (ci.type == ADV_TYPE_ROOM) {
|
||||
_task->showAlert("Rooms not supported", 1400);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
@@ -1500,6 +1544,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;
|
||||
@@ -1590,6 +1635,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;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "../RadioPresets.h"
|
||||
#include "RadioParamsEditor.h"
|
||||
#include "RadioPresetPicker.h"
|
||||
#include "AccordionList.h"
|
||||
|
||||
class SettingsScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
@@ -76,15 +77,20 @@ class SettingsScreen : public UIScreen {
|
||||
Count
|
||||
};
|
||||
|
||||
int _selected;
|
||||
int _scroll;
|
||||
int _visible; // items fitting on screen; updated each render, used by handleInput
|
||||
int _reserve = 0; // right-edge px reserved for the scrollbar (0 when list fits)
|
||||
bool _dirty;
|
||||
uint8_t _collapsed = 0x7F; // bit N set = section N collapsed (Display=0..Messages=6)
|
||||
static const int MAX_VIS = 60;
|
||||
uint8_t _vis[MAX_VIS]; // filtered list of visible SettingItem values
|
||||
int _vis_count = 0;
|
||||
// Cursor + scroll, fold state and the flattened visible list are owned by the
|
||||
// shared AccordionList helper. We keep only the section→SettingItem mapping it
|
||||
// needs (sections are walked once from the enum, honouring the #if guards).
|
||||
int _selected = 0; // SettingItem under the cursor, resolved per input/render
|
||||
int _reserve = 0; // right-edge px reserved for the scrollbar (0 when list fits)
|
||||
bool _dirty = false;
|
||||
|
||||
AccordionList _acc;
|
||||
static const int NUM_SECTIONS = 7;
|
||||
static const int MAX_PER_SEC = 16;
|
||||
uint8_t _sec_items[NUM_SECTIONS][MAX_PER_SEC]; // SettingItem per (section, row)
|
||||
uint8_t _sec_count[NUM_SECTIONS];
|
||||
uint8_t _sec_header[NUM_SECTIONS]; // the SECTION_* enum for each section
|
||||
int _num_sections = 0;
|
||||
|
||||
#if AUTO_OFF_MILLIS > 0
|
||||
static const uint16_t AUTO_OFF_OPTS[5];
|
||||
@@ -176,36 +182,33 @@ class SettingsScreen : public UIScreen {
|
||||
return "";
|
||||
}
|
||||
|
||||
int sectionIndex(int item) const {
|
||||
if (item == SECTION_DISPLAY) return 0;
|
||||
if (item == SECTION_SOUND) return 1;
|
||||
if (item == SECTION_HOME_PAGES) return 2;
|
||||
if (item == SECTION_RADIO) return 3;
|
||||
if (item == SECTION_SYSTEM) return 4;
|
||||
if (item == SECTION_CONTACTS) return 5;
|
||||
if (item == SECTION_MESSAGES) return 6;
|
||||
return -1;
|
||||
}
|
||||
|
||||
int visIndexOf(int item) const {
|
||||
for (int i = 0; i < _vis_count; i++)
|
||||
if (_vis[i] == (uint8_t)item) return i;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void buildVis() {
|
||||
_vis_count = 0;
|
||||
int cur_sec = -1;
|
||||
bool cur_collapsed = false;
|
||||
// Walk the SettingItem enum once, bucketing items under their section header.
|
||||
// #if-guarded items need no special handling — they simply aren't in the enum.
|
||||
void buildSections() {
|
||||
int cur = -1;
|
||||
for (int i = 0; i < (int)Count; i++) {
|
||||
if (isSection(i)) {
|
||||
cur_sec++;
|
||||
cur_collapsed = (_collapsed >> cur_sec) & 1;
|
||||
if (_vis_count < MAX_VIS) _vis[_vis_count++] = (uint8_t)i;
|
||||
} else if (!cur_collapsed && _vis_count < MAX_VIS) {
|
||||
_vis[_vis_count++] = (uint8_t)i;
|
||||
if (++cur >= NUM_SECTIONS) break;
|
||||
_sec_header[cur] = (uint8_t)i;
|
||||
_sec_count[cur] = 0;
|
||||
} else if (cur >= 0 && _sec_count[cur] < MAX_PER_SEC) {
|
||||
_sec_items[cur][_sec_count[cur]++] = (uint8_t)i;
|
||||
}
|
||||
}
|
||||
_num_sections = cur + 1;
|
||||
}
|
||||
|
||||
// (Re)load the section sizes into the accordion (folds all, resets the cursor).
|
||||
void resetList() {
|
||||
uint8_t sizes[NUM_SECTIONS];
|
||||
for (int i = 0; i < _num_sections; i++) sizes[i] = _sec_count[i];
|
||||
_acc.begin(sizes, _num_sections);
|
||||
}
|
||||
|
||||
// Resolve the accordion's selected row to a SettingItem (header → SECTION_*).
|
||||
int currentItem() const {
|
||||
const AccordionList::Row& r = _acc.selected();
|
||||
return (r.item < 0) ? _sec_header[r.sec] : _sec_items[r.sec][r.item];
|
||||
}
|
||||
|
||||
bool isHomePage(int item) const {
|
||||
@@ -416,23 +419,9 @@ class SettingsScreen : public UIScreen {
|
||||
return item - MSG_SLOT_0;
|
||||
}
|
||||
|
||||
void renderItem(DisplayDriver& display, int item, int y) {
|
||||
void renderItem(DisplayDriver& display, int item, int y, bool sel) {
|
||||
NodePrefs* p = _task->getNodePrefs();
|
||||
|
||||
if (isSection(item)) {
|
||||
int si = sectionIndex(item);
|
||||
bool collapsed = (_collapsed >> si) & 1;
|
||||
bool sel = (item == _selected);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawSelectionRow(0, y - 1, display.width() - _reserve, display.lineStep() - 1, sel);
|
||||
display.setCursor(2, y);
|
||||
display.print(collapsed ? "+" : "-");
|
||||
display.print(" ");
|
||||
display.print(sectionName(item));
|
||||
return;
|
||||
}
|
||||
|
||||
bool sel = (item == _selected);
|
||||
display.drawSelectionRow(0, y - 1, display.width() - _reserve, display.lineStep() - 1, sel);
|
||||
|
||||
display.setCursor(2, y);
|
||||
@@ -641,7 +630,7 @@ class SettingsScreen : public UIScreen {
|
||||
}
|
||||
|
||||
// Keyboard state for editing message slots
|
||||
int _edit_slot; // -1 = not editing, 0..9 = slot being edited
|
||||
int _edit_slot = -1; // -1 = not editing, 0..9 = slot being edited
|
||||
KeyboardWidget* _kb;
|
||||
|
||||
// Radio preset picker — names are too long for the value column, so Enter on
|
||||
@@ -656,17 +645,15 @@ class SettingsScreen : public UIScreen {
|
||||
|
||||
public:
|
||||
SettingsScreen(UITask* task, KeyboardWidget* kb)
|
||||
: _task(task), _kb(kb), _selected(SECTION_DISPLAY), _scroll(0), _visible(4), _dirty(false), _edit_slot(-1) {
|
||||
buildVis();
|
||||
: _task(task), _kb(kb) {
|
||||
buildSections();
|
||||
resetList();
|
||||
}
|
||||
|
||||
|
||||
void markClean() {
|
||||
_dirty = false;
|
||||
_collapsed = 0x7F;
|
||||
_selected = SECTION_DISPLAY;
|
||||
buildVis();
|
||||
_scroll = 0;
|
||||
resetList();
|
||||
_editor.freq.active = false;
|
||||
}
|
||||
|
||||
@@ -677,18 +664,24 @@ public:
|
||||
return _kb->render(display);
|
||||
}
|
||||
|
||||
int item_h = display.lineStep();
|
||||
int start_y = display.listStart();
|
||||
_visible = display.listVisible(item_h);
|
||||
_reserve = scrollIndicatorReserve(display, _vis_count, _visible);
|
||||
|
||||
display.drawCenteredHeader("SETTINGS");
|
||||
|
||||
for (int i = 0; i < _visible && (_scroll + i) < _vis_count; i++) {
|
||||
renderItem(display, _vis[_scroll + i], start_y + i * item_h);
|
||||
}
|
||||
|
||||
drawScrollIndicator(display, start_y, _visible * item_h, _vis_count, _visible, _scroll);
|
||||
_acc.render(display,
|
||||
// Section header: "[+/-] Name"
|
||||
[&](int sec, int y, bool sel, int reserve, bool collapsed) {
|
||||
_reserve = reserve;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||||
display.setCursor(2, y);
|
||||
display.print(collapsed ? "+" : "-");
|
||||
display.print(" ");
|
||||
display.print(sectionName(_sec_header[sec]));
|
||||
},
|
||||
// Item row
|
||||
[&](int sec, int item, int y, bool sel, int reserve) {
|
||||
_reserve = reserve;
|
||||
renderItem(display, _sec_items[sec][item], y, sel);
|
||||
});
|
||||
|
||||
if (_picker.menu.active) _picker.menu.render(display);
|
||||
|
||||
@@ -760,42 +753,23 @@ public:
|
||||
return true;
|
||||
}
|
||||
|
||||
if (c == KEY_UP && _vis_count > 0) {
|
||||
int vi = visIndexOf(_selected);
|
||||
vi = (vi > 0) ? vi - 1 : _vis_count - 1;
|
||||
_selected = _vis[vi];
|
||||
if (vi < _scroll) _scroll = vi;
|
||||
else if (vi >= _scroll + _visible) _scroll = vi - _visible + 1;
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_DOWN && _vis_count > 0) {
|
||||
int vi = visIndexOf(_selected);
|
||||
vi = (vi + 1 < _vis_count) ? vi + 1 : 0;
|
||||
_selected = _vis[vi];
|
||||
if (vi >= _scroll + _visible) _scroll = vi - _visible + 1;
|
||||
else if (vi < _scroll) _scroll = vi;
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CANCEL) {
|
||||
if (_dirty) the_mesh.savePrefs();
|
||||
_task->gotoHomeScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Up/down navigation and section fold/unfold live in the shared helper.
|
||||
// Enter on an item returns ACTIVATED and falls through to the per-item logic
|
||||
// below; left/right are ignored by the helper and likewise fall through.
|
||||
AccordionList::Result ar = _acc.handleInput(c);
|
||||
if (ar == AccordionList::HANDLED) return true;
|
||||
_selected = currentItem();
|
||||
|
||||
bool right = keyIsNext(c);
|
||||
bool left = keyIsPrev(c);
|
||||
bool enter = (c == KEY_ENTER);
|
||||
|
||||
if (enter && isSection(_selected)) {
|
||||
int si = sectionIndex(_selected);
|
||||
_collapsed ^= (1 << si);
|
||||
buildVis();
|
||||
int vi = visIndexOf(_selected);
|
||||
if (vi < _scroll) _scroll = vi;
|
||||
if (_visible > 0 && vi >= _scroll + _visible) _scroll = vi - _visible + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
#if FEAT_BRIGHTNESS_SETTING
|
||||
if (_selected == BRIGHTNESS) {
|
||||
uint8_t lvl = _task->getBrightnessLevel();
|
||||
|
||||
@@ -1,46 +1,132 @@
|
||||
#pragma once
|
||||
// Custom screen — not part of upstream UITask.cpp
|
||||
// Included by UITask.cpp just before HomeScreen.
|
||||
//
|
||||
// The flat 10-item list grew crowded, so tools are grouped into collapsible
|
||||
// sections (Location / Comms / System) via the shared AccordionList helper —
|
||||
// the same fold-in-place model as Settings. Each row carries a mini-icon;
|
||||
// section headers show their cog/marker plus a fold indicator.
|
||||
|
||||
#include "AccordionList.h"
|
||||
|
||||
class ToolsScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
int _sel;
|
||||
int _scroll = 0;
|
||||
|
||||
static const int ITEM_COUNT = 8;
|
||||
static const char* ITEMS[ITEM_COUNT];
|
||||
enum Action {
|
||||
ACT_NEARBY, ACT_LIVESHARE, ACT_TRAIL, ACT_LOCATOR, ACT_COMPASS,
|
||||
ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER,
|
||||
ACT_RINGTONE, ACT_DIAGNOSTICS
|
||||
};
|
||||
struct Tool { const char* label; const MiniIcon* icon; Action action; };
|
||||
struct Section { const char* name; const MiniIcon* icon; const Tool* tools; uint8_t count; };
|
||||
|
||||
static const Tool LOCATION_TOOLS[];
|
||||
static const Tool COMMS_TOOLS[];
|
||||
static const Tool SYSTEM_TOOLS[];
|
||||
static const Section SECTIONS[];
|
||||
static const int SECTION_COUNT = 3;
|
||||
|
||||
AccordionList _acc;
|
||||
|
||||
// Fixed icon gutter so labels line up whether or not a row has a glyph. Sized
|
||||
// for the widest icon (the 7px cog) at the current font scale.
|
||||
static int gutter(DisplayDriver& d) { return 7 * miniIconScale(d) + 2; }
|
||||
|
||||
static void drawIcon(DisplayDriver& d, int x, int y, const MiniIcon* ic) {
|
||||
if (!ic) return;
|
||||
const int s = miniIconScale(d);
|
||||
const int top = (y - 1) + ((d.lineStep() - 1) - ic->h * s) / 2;
|
||||
miniIconDrawTop(d, x, top, *ic);
|
||||
}
|
||||
|
||||
void dispatch(Action a) {
|
||||
switch (a) {
|
||||
case ACT_NEARBY: _task->gotoNearbyScreen(); break;
|
||||
case ACT_LIVESHARE: _task->gotoLiveShareScreen(); break;
|
||||
case ACT_TRAIL: _task->gotoTrailScreen(); break;
|
||||
case ACT_LOCATOR: _task->gotoLocatorScreen(); break;
|
||||
case ACT_COMPASS: _task->gotoCompassScreen(); break;
|
||||
case ACT_BOT: _task->gotoBotScreen(); break;
|
||||
case ACT_AUTOADVERT: _task->gotoAutoAdvertScreen(); break;
|
||||
case ACT_REPEATER: _task->gotoRepeaterScreen(); break;
|
||||
case ACT_RINGTONE: _task->gotoRingtoneEditor(); break;
|
||||
case ACT_DIAGNOSTICS: _task->gotoDiagnosticsScreen(); break;
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
ToolsScreen(UITask* task) : _task(task), _sel(0) {}
|
||||
ToolsScreen(UITask* task) : _task(task) {}
|
||||
|
||||
// Open folded at the section list each time Tools is entered from Home.
|
||||
void enter() {
|
||||
static uint8_t sizes[SECTION_COUNT];
|
||||
for (int i = 0; i < SECTION_COUNT; i++) sizes[i] = SECTIONS[i].count;
|
||||
_acc.begin(sizes, SECTION_COUNT);
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawCenteredHeader("TOOLS");
|
||||
|
||||
drawList(display, ITEM_COUNT, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||||
display.setCursor(2, y);
|
||||
display.print(ITEMS[idx]);
|
||||
});
|
||||
const int cw = display.getCharWidth();
|
||||
const int g = gutter(display);
|
||||
|
||||
_acc.render(display,
|
||||
// Section header: "[+/-] <icon> Name"
|
||||
[&](int sec, int y, bool sel, int reserve, bool collapsed) {
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||||
display.setCursor(2, y);
|
||||
display.print(collapsed ? "+" : "-");
|
||||
const int icon_x = 2 + cw + 2;
|
||||
// drawIcon(display, icon_x, y, SECTIONS[sec].icon); // icons disabled for now, don't fit visually
|
||||
display.setCursor(icon_x + g, y);
|
||||
display.print(SECTIONS[sec].name);
|
||||
},
|
||||
// Item: indented "<icon> Label"
|
||||
[&](int sec, int item, int y, bool sel, int reserve) {
|
||||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||||
const int icon_x = 2 + cw + 2; // align item icons under the header icon
|
||||
// drawIcon(display, icon_x, y, SECTIONS[sec].tools[item].icon); // icons disabled for now, don't fit visually
|
||||
display.setCursor(icon_x + g, y);
|
||||
display.print(SECTIONS[sec].tools[item].label);
|
||||
});
|
||||
return 500;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : ITEM_COUNT - 1; return true; }
|
||||
if (c == KEY_DOWN) { _sel = (_sel < ITEM_COUNT - 1) ? _sel + 1 : 0; return true; }
|
||||
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoHomeScreen(); return true; }
|
||||
if (c == KEY_ENTER) {
|
||||
if (_sel == 0) { _task->gotoRingtoneEditor(); return true; }
|
||||
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; }
|
||||
switch (_acc.handleInput(c)) {
|
||||
case AccordionList::ACTIVATED: {
|
||||
const AccordionList::Row& r = _acc.selected();
|
||||
dispatch(SECTIONS[r.sec].tools[r.item].action);
|
||||
return true;
|
||||
}
|
||||
case AccordionList::HANDLED: return true;
|
||||
case AccordionList::IGNORED: return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const char* ToolsScreen::ITEMS[8] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert", "Trail", "Compass", "Diagnostics", "Repeater" };
|
||||
|
||||
const ToolsScreen::Tool ToolsScreen::LOCATION_TOOLS[] = {
|
||||
{ "Nearby Nodes", &ICON_MAP_CONTACT, ACT_NEARBY },
|
||||
{ "Live Share", &ICON_GPS, ACT_LIVESHARE },
|
||||
{ "Trail", &ICON_TRAIL, ACT_TRAIL },
|
||||
{ "Locator", &ICON_MAP_WAYPOINT, ACT_LOCATOR },
|
||||
{ "Compass", &ICON_MAP_NORTH, ACT_COMPASS },
|
||||
};
|
||||
const ToolsScreen::Tool ToolsScreen::COMMS_TOOLS[] = {
|
||||
{ "Auto-Reply Bot", &ICON_BOT, ACT_BOT },
|
||||
{ "Auto-Advert", &ICON_ADVERT, ACT_AUTOADVERT },
|
||||
{ "Repeater", &ICON_REPEATER, ACT_REPEATER },
|
||||
};
|
||||
const ToolsScreen::Tool ToolsScreen::SYSTEM_TOOLS[] = {
|
||||
{ "Ringtone Editor", &ICON_NOTE, ACT_RINGTONE },
|
||||
{ "Diagnostics", &ICON_CHART, ACT_DIAGNOSTICS },
|
||||
};
|
||||
const ToolsScreen::Section ToolsScreen::SECTIONS[] = {
|
||||
{ "Location", &ICON_MAP_CONTACT, LOCATION_TOOLS, 5 },
|
||||
{ "Comms", &ICON_ADVERT, COMMS_TOOLS, 3 },
|
||||
{ "System", &ICON_GEAR, SYSTEM_TOOLS, 2 },
|
||||
};
|
||||
|
||||
@@ -11,8 +11,41 @@
|
||||
#include "icons.h" // scalable mini-icons (map markers, north arrow, grid dots)
|
||||
#include "NavView.h"
|
||||
#include "WaypointsView.h"
|
||||
#include <helpers/StreamUtils.h>
|
||||
#include <math.h>
|
||||
|
||||
// Bounds every Serial write the GPX export makes. A full trail dump is tens
|
||||
// of KB across hundreds of individual write() calls (one or more per point) —
|
||||
// without this, a serial monitor that's open but not actively reading (DTR
|
||||
// asserted, nothing draining the USB-CDC FIFO) would block each one, and the
|
||||
// main loop with it, indefinitely (see StreamUtils.h). Duck-typed to the
|
||||
// handful of Print methods Trail.h's GPX writers actually call, so it slots
|
||||
// into their `S& out` template parameter without any change there.
|
||||
// __FlashStringHelper content is plain flash on nRF52/ARM (no
|
||||
// Harvard-architecture PROGMEM split), so it's safe to read like any other
|
||||
// char*.
|
||||
//
|
||||
// The first stalled write gives up for the rest of the export (_gave_up),
|
||||
// rather than re-arming a fresh timeout on every one of those hundreds of
|
||||
// calls — a host that's truly stuck would otherwise cost calls × timeout_ms
|
||||
// in total instead of one bounded stall.
|
||||
struct BoundedSerialPrint {
|
||||
uint32_t timeout_ms;
|
||||
bool _gave_up;
|
||||
BoundedSerialPrint(uint32_t t) : timeout_ms(t), _gave_up(false) {}
|
||||
size_t write(const uint8_t* buf, size_t len) {
|
||||
if (_gave_up) return 0;
|
||||
size_t sent = streamWriteUntil(Serial, buf, len, millis() + timeout_ms);
|
||||
if (sent < len) _gave_up = true;
|
||||
return sent;
|
||||
}
|
||||
size_t print(const char* s) { return write((const uint8_t*)s, strlen(s)); }
|
||||
size_t print(const __FlashStringHelper* s) {
|
||||
const char* p = reinterpret_cast<const char*>(s);
|
||||
return write((const uint8_t*)p, strlen(p));
|
||||
}
|
||||
};
|
||||
|
||||
class TrailScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
TrailStore* _store;
|
||||
@@ -28,6 +61,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 +72,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_AUTOPAUSE, 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;
|
||||
@@ -50,6 +87,7 @@ class TrailScreen : public UIScreen {
|
||||
char _act_min_dist_label[24];
|
||||
char _act_units_label[24];
|
||||
char _act_grid_label[16];
|
||||
char _act_autopause_label[24];
|
||||
char _act_toggle_label[20];
|
||||
|
||||
static const int SUMMARY_ITEM_COUNT = 5;
|
||||
@@ -65,9 +103,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 +144,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 +155,14 @@ 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_GRID:
|
||||
case ACT_AUTOPAUSE: 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 +182,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; }
|
||||
@@ -207,7 +252,8 @@ private:
|
||||
}
|
||||
void handleExport() {
|
||||
if (!Serial) { _task->showAlert("Connect USB first", 1200); return; }
|
||||
size_t n = _store->exportGpx(Serial, _task->waypoints());
|
||||
BoundedSerialPrint out{300};
|
||||
size_t n = _store->exportGpx(out, _task->waypoints());
|
||||
showExportAlert(n);
|
||||
}
|
||||
|
||||
@@ -217,7 +263,8 @@ private:
|
||||
if (!ds) { _task->showAlert("FS unavailable", 800); return; }
|
||||
File f = ds->openRead(TRAIL_FILE);
|
||||
if (!f) { _task->showAlert("No saved trail", 800); return; }
|
||||
size_t n = TrailStore::exportGpxFromFile(f, Serial, _task->waypoints());
|
||||
BoundedSerialPrint out{300};
|
||||
size_t n = TrailStore::exportGpxFromFile(f, out, _task->waypoints());
|
||||
f.close();
|
||||
if (n == 0) { _task->showAlert("Bad saved file", 1000); return; }
|
||||
showExportAlert(n);
|
||||
@@ -249,6 +296,8 @@ private:
|
||||
"Readout: %s", (p && p->trail_show_pace) ? "Pace" : "Speed");
|
||||
snprintf(_act_grid_label, sizeof(_act_grid_label),
|
||||
"Grid: %s", _map_grid ? "ON" : "OFF");
|
||||
snprintf(_act_autopause_label, sizeof(_act_autopause_label),
|
||||
"Auto-pause: %s", NodePrefs::trailAutoPauseLabel(p ? p->trail_autopause_idx : 0));
|
||||
snprintf(_act_toggle_label, sizeof(_act_toggle_label),
|
||||
"%s tracking", _store->isActive() ? "Stop" : "Start");
|
||||
}
|
||||
@@ -276,6 +325,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...");
|
||||
}
|
||||
@@ -299,8 +349,9 @@ private:
|
||||
refreshActionLabels();
|
||||
_menu_level = ML_SETTINGS;
|
||||
_act_count = 0;
|
||||
_action_menu.begin("Settings", 3);
|
||||
pushAction(ACT_MIN_DIST, _act_min_dist_label);
|
||||
_action_menu.begin("Settings", 4);
|
||||
pushAction(ACT_MIN_DIST, _act_min_dist_label);
|
||||
pushAction(ACT_AUTOPAUSE, _act_autopause_label);
|
||||
if (_view == V_SUMMARY) pushAction(ACT_UNITS, _act_units_label);
|
||||
if (_view == V_MAP) pushAction(ACT_GRID, _act_grid_label);
|
||||
}
|
||||
@@ -311,6 +362,7 @@ private:
|
||||
if (act == ACT_MIN_DIST && p) { cycleMinDelta(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
|
||||
if (act == ACT_UNITS && p) { cycleUnits(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
|
||||
if (act == ACT_GRID) { _map_grid = !_map_grid; refreshActionLabels(); return true; }
|
||||
if (act == ACT_AUTOPAUSE && p){ cycleAutoPause(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -346,6 +398,24 @@ private:
|
||||
// The trail "Readout" row toggles speed vs pace; the metric/imperial unit
|
||||
// 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; }
|
||||
void cycleAutoPause(NodePrefs* p, int dir) {
|
||||
uint8_t idx = p->trail_autopause_idx;
|
||||
if (idx >= NodePrefs::TRAIL_AUTOPAUSE_COUNT) idx = 0;
|
||||
idx = (dir > 0) ? (idx + 1) % NodePrefs::TRAIL_AUTOPAUSE_COUNT
|
||||
: (idx + NodePrefs::TRAIL_AUTOPAUSE_COUNT - 1) % NodePrefs::TRAIL_AUTOPAUSE_COUNT;
|
||||
p->trail_autopause_idx = idx;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -376,6 +446,7 @@ private:
|
||||
case 0: {
|
||||
const char* st;
|
||||
if (!_store->isActive()) st = "stopped";
|
||||
else if (_store->isPaused()) st = "paused";
|
||||
else if (_store->empty()) st = "waiting fix";
|
||||
else st = "tracking";
|
||||
snprintf(buf, n, "Status: %s", st);
|
||||
@@ -523,6 +594,15 @@ 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);
|
||||
}
|
||||
// Fold in the active target too, so a destination you set never sits off-frame.
|
||||
{ int32_t tla, tlo; if (_task->activeTargetPos(tla, tlo)) fold(tla, tlo); }
|
||||
return init;
|
||||
}
|
||||
|
||||
@@ -534,6 +614,25 @@ private:
|
||||
const int area_w = proj.area_w, area_h = proj.area_h;
|
||||
WaypointStore& wp = _task->waypoints();
|
||||
const int wp_x_max = area_x + area_w - 1, wp_y_max = area_y + area_h - 1;
|
||||
// Reserved rectangles of labels already placed this frame, so a dense
|
||||
// cluster of points doesn't smear their labels on top of one another.
|
||||
// Live-tracked contacts are labelled FIRST so a moving person's name wins a
|
||||
// slot over a nearby static waypoint (on a live map the person matters most).
|
||||
LblRect occ[40]; int nocc = 0;
|
||||
{
|
||||
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[3] = { s.name[0], s.name[0] ? s.name[1] : (char)0, 0 };
|
||||
drawLabelAvoiding(display, s2, cx, cy, area_x, area_y, area_w, area_h, occ, nocc, 40);
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < wp.count(); i++) {
|
||||
const Waypoint& w = wp.at(i);
|
||||
int wx, wy; proj.project(w.lat_1e6, w.lon_1e6, wx, wy);
|
||||
@@ -541,8 +640,9 @@ private:
|
||||
if (wy < area_y) wy = area_y; else if (wy > wp_y_max) wy = wp_y_max;
|
||||
drawWaypointMarker(display, wx, wy);
|
||||
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);
|
||||
drawLabelAvoiding(display, s, wx, wy, area_x, area_y, area_w, area_h, occ, nocc, 40);
|
||||
}
|
||||
|
||||
if (have_gps) {
|
||||
int mx, my; proj.project(my_lat, my_lon, mx, my);
|
||||
drawCurrentMarker(display, mx, my);
|
||||
@@ -550,6 +650,16 @@ private:
|
||||
int ex, ey; proj.project(_store->last().lat_1e6, _store->last().lon_1e6, ex, ey);
|
||||
drawCurrentMarker(display, ex, ey);
|
||||
}
|
||||
|
||||
// Active target flag, last so it stays legible atop any waypoint/contact it
|
||||
// coincides with. Clamped to the frame like the other off-edge markers.
|
||||
int32_t tla, tlo;
|
||||
if (_task->activeTargetPos(tla, tlo)) {
|
||||
int tx, ty; proj.project(tla, tlo, tx, ty);
|
||||
if (tx < area_x) tx = area_x; else if (tx > wp_x_max) tx = wp_x_max;
|
||||
if (ty < area_y) ty = area_y; else if (ty > wp_y_max) ty = wp_y_max;
|
||||
drawTargetMarker(display, tx, ty);
|
||||
}
|
||||
}
|
||||
|
||||
void renderMap(DisplayDriver& display) {
|
||||
@@ -564,8 +674,11 @@ 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;
|
||||
int32_t tgt_lat, tgt_lon;
|
||||
const bool have_tgt = _task->activeTargetPos(tgt_lat, tgt_lon);
|
||||
|
||||
if (!have_trail && nwp == 0 && !have_gps) {
|
||||
if (!have_trail && nwp == 0 && !have_gps && !have_live && !have_tgt) {
|
||||
display.drawTextCentered(display.width() / 2, (top + bottom) / 2, "No GPS / no trail");
|
||||
return;
|
||||
}
|
||||
@@ -595,6 +708,7 @@ private:
|
||||
drawWaypointLabel(display, s, ccx, ccy, area_x, area_y, area_w, area_h);
|
||||
}
|
||||
if (have_gps || have_trail) drawCurrentMarker(display, ccx, ccy);
|
||||
{ int32_t tla, tlo; if (_task->activeTargetPos(tla, tlo)) drawTargetMarker(display, ccx, ccy); }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -626,19 +740,9 @@ private:
|
||||
int x0, y0;
|
||||
proj.project(_store->at(0).lat_1e6, _store->at(0).lon_1e6, x0, y0);
|
||||
miniIconDrawCentered(display, x0, y0, ICON_MAP_DOT);
|
||||
for (int i = 1; i < _store->count(); i++) {
|
||||
const TrailPoint& pt = _store->at(i);
|
||||
int x1, y1;
|
||||
proj.project(pt.lat_1e6, pt.lon_1e6, x1, y1);
|
||||
if (pt.flags & TRAIL_FLAG_SEG_START) {
|
||||
drawFilledDot(display, x0, y0);
|
||||
drawOpenDot(display, x1, y1);
|
||||
} else {
|
||||
gfx::drawLine(display, x0, y0, x1, y1);
|
||||
}
|
||||
x0 = x1;
|
||||
y0 = y1;
|
||||
}
|
||||
gfx::drawTrail(display, *_store,
|
||||
[&](int32_t la, int32_t lo, int& x, int& y) { proj.project(la, lo, x, y); },
|
||||
[&](int xa, int ya, int xb, int yb) { drawFilledDot(display, xa, ya); drawOpenDot(display, xb, yb); });
|
||||
int sx, sy;
|
||||
proj.project(_store->first().lat_1e6, _store->first().lon_1e6, sx, sy);
|
||||
drawStartMarker(display, sx, sy);
|
||||
@@ -787,6 +891,38 @@ private:
|
||||
d.print(s);
|
||||
}
|
||||
|
||||
// Map label collision avoidance. Each placed label reserves a rectangle; a new
|
||||
// label tries up to four offsets around its marker (upper-right, upper-left,
|
||||
// lower-right, lower-left) and is skipped — the marker still shows — if none
|
||||
// fit on-frame without overlapping an already-placed one. On a dense map this
|
||||
// drops a few labels instead of smearing them into an unreadable blob.
|
||||
struct LblRect { int x, y, w, h; };
|
||||
static bool rectsOverlap(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh) {
|
||||
return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah;
|
||||
}
|
||||
static void drawLabelAvoiding(DisplayDriver& d, const char* s, int wx, int wy,
|
||||
int ax, int ay, int aw, int ah,
|
||||
LblRect* occ, int& nocc, int occ_max) {
|
||||
if (!s[0]) return;
|
||||
int tw = (int)d.getTextWidth(s);
|
||||
int ch = d.getLineHeight();
|
||||
const int cand[4][2] = { { wx + 4, wy - 3 }, { wx - 4 - tw, wy - 3 },
|
||||
{ wx + 4, wy + 3 }, { wx - 4 - tw, wy + 3 } };
|
||||
for (int c = 0; c < 4; c++) {
|
||||
int lx = cand[c][0], ly = cand[c][1];
|
||||
if (lx < ax || lx + tw > ax + aw || ly < ay || ly + ch > ay + ah) continue;
|
||||
bool clash = false;
|
||||
for (int k = 0; k < nocc; k++)
|
||||
if (rectsOverlap(lx, ly, tw, ch, occ[k].x, occ[k].y, occ[k].w, occ[k].h)) { clash = true; break; }
|
||||
if (clash) continue;
|
||||
d.setCursor(lx, ly);
|
||||
d.print(s);
|
||||
if (nocc < occ_max) occ[nocc++] = { lx, ly, tw, ch };
|
||||
return;
|
||||
}
|
||||
// No free slot — skip the label; the marker alone still conveys the point.
|
||||
}
|
||||
|
||||
static void drawFilledDot(DisplayDriver& d, int cx, int cy) {
|
||||
miniIconDrawCentered(d, cx, cy, ICON_MAP_DOT);
|
||||
}
|
||||
@@ -804,4 +940,13 @@ 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);
|
||||
}
|
||||
// Flag — the active Locator/Nav target. Drawn last (on top) so it stays
|
||||
// legible even when it lands on the same spot as a waypoint or contact.
|
||||
static void drawTargetMarker(DisplayDriver& d, int cx, int cy) {
|
||||
miniIconDrawCentered(d, cx, cy, ICON_MAP_TARGET);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
#endif
|
||||
|
||||
#include "icons.h"
|
||||
#include "GfxUtils.h" // gfx::drawLine — connects trail points on the Home map preview
|
||||
|
||||
// Blinking status indicators: on for the first half of a 4 s cycle, but e-ink
|
||||
// can't repaint fast enough to blink, so it shows them steadily.
|
||||
@@ -53,9 +54,12 @@ public:
|
||||
strncpy(_version_info, MESHCORE_VERSION, sizeof(_version_info) - 1);
|
||||
_version_info[sizeof(_version_info) - 1] = '\0';
|
||||
|
||||
// Solo firmware version: strip commit hash suffix (v1.15-solo.1-abcdef -> v1.15)
|
||||
// Solo firmware version: strip the commit-hash suffix build.sh always
|
||||
// appends as the LAST dash-segment (v1.15-solo.1-abcdef -> v1.15-solo.1).
|
||||
// Must be the last dash, not the first: a tag like v1.21-rc1 has a dash
|
||||
// of its own before the commit hash gets appended.
|
||||
const char *ver = FIRMWARE_VERSION;
|
||||
const char *dash = strchr(ver, '-');
|
||||
const char *dash = strrchr(ver, '-');
|
||||
int plen = dash ? (int)(dash - ver) : (int)strlen(ver);
|
||||
if (plen >= (int)sizeof(_solo_ver)) plen = sizeof(_solo_ver) - 1;
|
||||
memcpy(_solo_ver, ver, plen);
|
||||
@@ -123,6 +127,8 @@ static const int QUICK_MSGS_MAX = 10;
|
||||
#include "NearbyScreen.h"
|
||||
#include "DashboardConfigScreen.h"
|
||||
#include "AutoAdvertScreen.h"
|
||||
#include "LiveShareScreen.h"
|
||||
#include "LocatorScreen.h"
|
||||
#include "TrailScreen.h"
|
||||
#include "CompassScreen.h"
|
||||
#include "DiagnosticsScreen.h"
|
||||
@@ -240,6 +246,7 @@ class HomeScreen : public UIScreen {
|
||||
SENSORS,
|
||||
#endif
|
||||
SETTINGS,
|
||||
MAP,
|
||||
TOOLS,
|
||||
QUICK_MSG,
|
||||
SHUTDOWN,
|
||||
@@ -472,6 +479,16 @@ class HomeScreen : public UIScreen {
|
||||
leftmostX = aX - 1;
|
||||
}
|
||||
|
||||
// Live location sharing active. Same blink convention — another
|
||||
// "leave it on and forget" broadcast, like auto-advert above. Reuses
|
||||
// the diamond the map uses for a live-tracked contact, so the glyph
|
||||
// already means "sharing position" elsewhere in the UI.
|
||||
if (_node_prefs && _node_prefs->loc_share_enabled) {
|
||||
int lsX = leftmostX - ind;
|
||||
if (blinkOn()) drawBoxedIcon(display, lsX, ind, ind_h, ICON_MAP_CONTACT);
|
||||
leftmostX = lsX - 1;
|
||||
}
|
||||
|
||||
// GPS trail logging active. Same blink convention.
|
||||
if (_task->trail().isActive()) {
|
||||
int gX = leftmostX - ind;
|
||||
@@ -488,6 +505,18 @@ class HomeScreen : public UIScreen {
|
||||
leftmostX = rX - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// GPS fix status — boxed (lit) when the receiver has a valid fix, plain
|
||||
// glyph while searching. Hidden entirely on boards with no GPS hardware
|
||||
// and while the GPS setting itself is off, so it doesn't sit there as a
|
||||
// permanently-empty slot or imply a search that isn't happening.
|
||||
LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr;
|
||||
if (loc && _node_prefs && _node_prefs->gps_enabled) {
|
||||
int gX = leftmostX - ind - ind_gap;
|
||||
if (loc->isValid()) drawBoxedIcon(display, gX, ind, ind_h, ICON_GPS);
|
||||
else drawSlotIcon(display, gX, ind, ind_h, ICON_GPS);
|
||||
leftmostX = gX - 1;
|
||||
}
|
||||
return leftmostX;
|
||||
}
|
||||
|
||||
@@ -527,13 +556,165 @@ 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 (no grid/labels, no break markers) 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);
|
||||
int32_t tla, tlo;
|
||||
bool have_tgt = _task->activeTargetPos(tla, tlo); // active Locator/Nav target
|
||||
if (have_tgt) fold(tla, tlo);
|
||||
if (!init) return false;
|
||||
|
||||
// North marker — top-right, the same mini-icon as the full Trail map.
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
{
|
||||
const int ns = miniIconScale(display);
|
||||
miniIconDrawTop(display, ax + aw - ICON_MAP_NORTH.w * ns - 1, ay + 1, ICON_MAP_NORTH);
|
||||
}
|
||||
|
||||
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);
|
||||
if (have_tgt) miniIconDrawCentered(display, cx, cy, ICON_MAP_TARGET); // highlight on top
|
||||
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 a connected line, matching the full Trail map (shared helper —
|
||||
// see gfx::drawTrail); no break marker here, just a silent gap.
|
||||
gfx::drawTrail(display, tr, project, [](int, int, int, int) {});
|
||||
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); }
|
||||
// Active target flag drawn last so it stays legible even atop a contact/own dot.
|
||||
if (have_tgt) { int px, py; project(tla, tlo, px, py); miniIconDrawCentered(display, px, py, ICON_MAP_TARGET); }
|
||||
|
||||
// Bottom-left scale reference, always shown — distance to the active
|
||||
// target (or else the nearest live-tracked contact) now lives on the
|
||||
// status line below instead (see statusDistanceKm() / render()), so this
|
||||
// corner is free for it.
|
||||
{
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
int ty = ay + ah - display.getLineHeight();
|
||||
static const float M_PER_1E6 = 0.11132f; // metres per 1e-6° lat
|
||||
float ppm = scale / M_PER_1E6; // pixels per metre
|
||||
if (ppm > 0.0f) {
|
||||
bool imp = _task->useImperial();
|
||||
static const float MET_M[] = { 5,10,25,50,100,250,500,1000,2000,5000,10000,25000,50000 };
|
||||
static const char* MET_L[] = { "5m","10m","25m","50m","100m","250m","500m","1km","2km","5km","10km","25km","50km" };
|
||||
static const float IMP_M[] = { 4.572f,15.24f,30.48f,76.2f,152.4f,402.34f,804.67f,1609.34f,4828.0f,16093.4f,80467.2f };
|
||||
static const char* IMP_L[] = { "15ft","50ft","100ft","250ft","500ft","1/4mi","1/2mi","1mi","3mi","10mi","50mi" };
|
||||
const float* M = imp ? IMP_M : MET_M;
|
||||
const char* const* L = imp ? IMP_L : MET_L;
|
||||
int N = imp ? (int)(sizeof(IMP_M) / sizeof(IMP_M[0])) : (int)(sizeof(MET_M) / sizeof(MET_M[0]));
|
||||
float target = 8.0f / ppm; // short reference tick, not 1/3 of the width
|
||||
int sel = 0;
|
||||
for (int i = N - 1; i >= 0; i--) if (M[i] <= target) { sel = i; break; }
|
||||
int barpx = (int)(M[sel] * ppm + 0.5f);
|
||||
if (barpx < 5) barpx = 5;
|
||||
if (barpx > aw / 4) barpx = aw / 4;
|
||||
int bx = ax + 1, mid = ty + display.getLineHeight() / 2;
|
||||
display.fillRect(bx, mid, barpx, 1); // single tick, on the text baseline
|
||||
display.setCursor(bx + barpx + 2, ty);
|
||||
display.print(L[sel]);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Distance shown on the MAP status line. The active Locator/Nav target
|
||||
// takes priority — that's what the flag on the mini-map is pointing at,
|
||||
// and it's the only way a waypoint target ever gets a distance readout
|
||||
// here (a waypoint isn't a live-tracked contact). Falls back to the
|
||||
// nearest live-tracked ([LOC]-sharing) contact when no target is set.
|
||||
// -1 when we don't have a fix or nothing to measure against.
|
||||
float statusDistanceKm() {
|
||||
int32_t mla, mlo;
|
||||
if (!_task->currentLocation(mla, mlo)) return -1.0f;
|
||||
int32_t tla, tlo;
|
||||
if (_task->activeTargetPos(tla, tlo)) return geo::haversineKm(mla, mlo, tla, tlo);
|
||||
LiveTrackStore& lt = _task->liveTrack();
|
||||
uint32_t now = rtc_clock.getCurrentTime();
|
||||
float nearest_km = -1.0f;
|
||||
for (int i = 0; i < LiveTrackStore::CAPACITY; i++) {
|
||||
if (!lt.isActive(i, now)) continue;
|
||||
float d = geo::haversineKm(mla, mlo, lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6);
|
||||
if (nearest_km < 0.0f || d < nearest_km) nearest_km = d;
|
||||
}
|
||||
return nearest_km;
|
||||
}
|
||||
|
||||
// Small 5x5 glyph shown in the page-indicator row for each HomePage.
|
||||
static const MiniIcon* pageIcon(int page) {
|
||||
switch (page) {
|
||||
case CLOCK: return &ICON_PG_CLOCK;
|
||||
case FAVOURITES: return &ICON_PG_STAR;
|
||||
case RECENT: return &ICON_PG_RECENT;
|
||||
case RADIO: return &ICON_PG_RADIO;
|
||||
case BLUETOOTH: return &ICON_PG_BT;
|
||||
case ADVERT: return &ICON_PG_ADVERT;
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
case GPS: return &ICON_PG_GPS;
|
||||
#endif
|
||||
#if UI_SENSORS_PAGE == 1
|
||||
case SENSORS: return &ICON_PG_SENSORS;
|
||||
#endif
|
||||
case SETTINGS: return &ICON_PG_SETTINGS;
|
||||
case MAP: return &ICON_PG_MAP;
|
||||
case TOOLS: return &ICON_PG_TOOLS;
|
||||
case QUICK_MSG: return &ICON_PG_MSG;
|
||||
case SHUTDOWN: return &ICON_PG_POWER;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
char tmp[80];
|
||||
display.setTextSize(1);
|
||||
const int lh = display.getLineHeight(); // line height at sz1
|
||||
const int step = display.lineStep(); // lh + 2
|
||||
const int dots_y = lh + 4; // page-dot row: just below header
|
||||
const int content_y = dots_y + 6; // first content row (6px gap keeps dots visible)
|
||||
// Page-indicator row: small (5px) page icons replace the old dots. Centre and
|
||||
// gap scale with the font so the band clears the header above and content
|
||||
// below (identical to the old lh+4 / +6 dots layout at 1x).
|
||||
const int pg_half = (5 * miniIconScale(display) + 1) / 2;
|
||||
const int dots_y = lh + pg_half + 1; // icon-row centre, below the header
|
||||
const int content_y = dots_y + pg_half + 3; // first content row, below the icons
|
||||
|
||||
// node name + battery — hidden on CLOCK page (full screen used for dashboard)
|
||||
if (_page != CLOCK) {
|
||||
@@ -560,17 +741,26 @@ public:
|
||||
// ensure current page is visible (e.g. after settings change)
|
||||
if (!isPageVisible(_page)) _page = navPage(_page, +1);
|
||||
|
||||
// curr page indicator — hidden on CLOCK page (full screen used for dashboard)
|
||||
// curr page indicator — a row of small page icons, one per visible page, with
|
||||
// the current page underlined. Hidden on CLOCK (full screen used for dashboard).
|
||||
if (_page != CLOCK) {
|
||||
int order[(int)Count]; int n = buildVisibleOrder(order);
|
||||
int curr_vis = 0;
|
||||
for (int i = 0; i < n; i++) if (order[i] == _page) { curr_vis = i; break; }
|
||||
int x = display.width() / 2 - 5 * (n - 1);
|
||||
const int s = miniIconScale(display);
|
||||
const int icon_w = 5 * s;
|
||||
int pitch = icon_w + 5 * s; // comfortable spacing
|
||||
if (n > 1) { // shrink to fit if many pages
|
||||
int fit = (display.width() - icon_w) / (n - 1);
|
||||
if (fit < pitch) pitch = fit;
|
||||
}
|
||||
int x = display.width() / 2 - pitch * (n - 1) / 2;
|
||||
for (int i = 0; i < n; i++) {
|
||||
int ds = display.isLandscape() ? 2 : 1;
|
||||
if (i == curr_vis) display.fillRect(x-ds, dots_y-ds, 2*ds+1, 2*ds+1);
|
||||
else display.fillRect(x-ds+1, dots_y-ds+1, 2*ds-1, 2*ds-1);
|
||||
x += 10;
|
||||
const MiniIcon* ic = pageIcon(order[i]);
|
||||
if (ic) miniIconDrawCentered(display, x, dots_y, *ic);
|
||||
if (i == curr_vis) // underline the current page
|
||||
display.fillRect(x - icon_w / 2, dots_y + pg_half + 1, icon_w, s);
|
||||
x += pitch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -891,6 +1081,43 @@ 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 left[20], right[16] = {0};
|
||||
uint32_t now_m = rtc_clock.getCurrentTime();
|
||||
LiveTrackStore& lt = _task->liveTrack();
|
||||
int trk = lt.active(now_m);
|
||||
// Fix state lives in the top-bar GPS icon. Track count plus an arrow +
|
||||
// distance (to the active target, else the nearest live-tracked
|
||||
// contact) share this one status line.
|
||||
snprintf(left, sizeof(left), "Track:%d", trk);
|
||||
float nearest_km = statusDistanceKm();
|
||||
if (nearest_km >= 0.0f) geo::fmtDist(right, sizeof(right), nearest_km, _task->useImperial());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
if (!drew)
|
||||
display.drawTextCentered(display.width() / 2, content_y + area_h / 2, "No GPS / no trail");
|
||||
if (right[0]) {
|
||||
// Manual layout (not drawTextCentered) so the arrow mini-icon sits
|
||||
// inline between the two text runs.
|
||||
const int s = miniIconScale(display);
|
||||
const int gap = 3;
|
||||
int lw = display.getTextWidth(left);
|
||||
int iw = ICON_MAP_ARROW.w * s;
|
||||
int rw = display.getTextWidth(right);
|
||||
int x = display.width() / 2 - (lw + gap + iw + gap + rw) / 2;
|
||||
display.setCursor(x, info_y);
|
||||
display.print(left);
|
||||
miniIconDrawTop(display, x + lw + gap, info_y + (lh - ICON_MAP_ARROW.h * s) / 2, ICON_MAP_ARROW);
|
||||
display.setCursor(x + lw + gap + iw + gap, info_y);
|
||||
display.print(right);
|
||||
} else {
|
||||
display.drawTextCentered(display.width() / 2, info_y, left);
|
||||
}
|
||||
} else if (_page == HomePage::TOOLS) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(1);
|
||||
@@ -1008,8 +1235,10 @@ public:
|
||||
// Any blinking status-bar indicator needs a 1 s refresh to animate evenly —
|
||||
// but the status bar (and its icons) is hidden on the CLOCK page, so don't
|
||||
// pay the 1 s cadence there for icons that aren't drawn.
|
||||
bool repeating = _node_prefs && _node_prefs->client_repeat;
|
||||
bool need_blink = (_page != HomePage::CLOCK) && (auto_adv || _task->trail().isActive() || repeating);
|
||||
bool repeating = _node_prefs && _node_prefs->client_repeat;
|
||||
bool loc_sharing = _node_prefs && _node_prefs->loc_share_enabled;
|
||||
bool need_blink = (_page != HomePage::CLOCK) &&
|
||||
(auto_adv || _task->trail().isActive() || repeating || loc_sharing);
|
||||
if (Features::IS_EINK) {
|
||||
// slow display: poll every 30 s; inbound msgs force immediate refresh via notify()
|
||||
return Features::HOME_REFRESH_MS;
|
||||
@@ -1127,6 +1356,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;
|
||||
@@ -1143,6 +1376,10 @@ public:
|
||||
_task->gotoDashboardConfig();
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CONTEXT_MENU && _page == HomePage::MAP) {
|
||||
_task->quickShareMyLocation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1212,6 +1449,8 @@ 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);
|
||||
locator_screen = new LocatorScreen(this, node_prefs);
|
||||
trail_screen = new TrailScreen(this, &_trail);
|
||||
compass_screen = new CompassScreen(this);
|
||||
diag_screen = new DiagnosticsScreen(this);
|
||||
@@ -1229,6 +1468,7 @@ void UITask::gotoSettingsScreen() {
|
||||
}
|
||||
|
||||
void UITask::gotoToolsScreen() {
|
||||
((ToolsScreen*)tools_screen)->enter();
|
||||
setCurrScreen(tools_screen);
|
||||
}
|
||||
|
||||
@@ -1257,6 +1497,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 +1516,16 @@ void UITask::gotoRepeaterScreen() {
|
||||
setCurrScreen(repeater_screen);
|
||||
}
|
||||
|
||||
void UITask::gotoLiveShareScreen() {
|
||||
((LiveShareScreen*)live_share_screen)->enter();
|
||||
setCurrScreen(live_share_screen);
|
||||
}
|
||||
|
||||
void UITask::gotoLocatorScreen() {
|
||||
((LocatorScreen*)locator_screen)->enter();
|
||||
setCurrScreen(locator_screen);
|
||||
}
|
||||
|
||||
void UITask::gotoAutoAdvertScreen() {
|
||||
((AutoAdvertScreen*)auto_advert_screen)->enter();
|
||||
setCurrScreen(auto_advert_screen);
|
||||
@@ -1364,6 +1619,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);
|
||||
}
|
||||
@@ -1414,6 +1674,11 @@ void UITask::notify(UIEventType t) {
|
||||
sn.playCH(_last_notif_ch_idx);
|
||||
_last_notif_ch_idx = -1;
|
||||
break;
|
||||
case UIEventType::roomMessage:
|
||||
// Rooms have many authors and no per-room melody pref, so use the default DM
|
||||
// notification (no per-sender melody/mute lookup — the author varies per post).
|
||||
sn.playDM(false, nullptr);
|
||||
break;
|
||||
case UIEventType::advertReceivedFlood:
|
||||
case UIEventType::advertReceivedZeroHop:
|
||||
sn.playAD(t == UIEventType::advertReceivedFlood);
|
||||
@@ -1421,7 +1686,6 @@ void UITask::notify(UIEventType t) {
|
||||
case UIEventType::ack:
|
||||
buzzer.play("ack:d=32,o=8,b=120:c");
|
||||
break;
|
||||
case UIEventType::roomMessage:
|
||||
case UIEventType::none:
|
||||
default:
|
||||
break;
|
||||
@@ -1918,19 +2182,81 @@ void UITask::loop() {
|
||||
// GPS trail sampling — runs in the background while the trail is
|
||||
// active, independent of which screen is shown. Skips silently if no GPS
|
||||
// fix; min-delta gate inside addPoint() avoids near-stationary spam.
|
||||
if (!_trail.isActive()) _trail_pause_has_ref = false; // fresh ref on next start
|
||||
if (_trail.isActive() && _node_prefs != NULL
|
||||
&& (int32_t)(millis() - _next_trail_sample_ms) >= 0) {
|
||||
_next_trail_sample_ms = millis() + (uint32_t)TrailStore::SAMPLING_SECS * 1000UL;
|
||||
LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr;
|
||||
if (loc && loc->isValid()) {
|
||||
int32_t la = (int32_t)loc->getLatitude();
|
||||
int32_t lo = (int32_t)loc->getLongitude();
|
||||
uint16_t md = TrailStore::minDeltaMeters(_node_prefs->trail_min_delta_idx,
|
||||
_node_prefs->units_imperial);
|
||||
_trail.addPoint((int32_t)loc->getLatitude(),
|
||||
(int32_t)loc->getLongitude(),
|
||||
(uint32_t)rtc_clock.getCurrentTime(), md);
|
||||
// Auto-pause: freeze the trail once the device has stayed within
|
||||
// TRAIL_AUTOPAUSE_MOVE_M of one spot for the configured delay; resume on
|
||||
// the next real move. Its own coarse gate (not the trail min-delta) so
|
||||
// GPS jitter while parked doesn't keep the idle timer alive.
|
||||
uint16_t ap = NodePrefs::trailAutoPauseSecs(_node_prefs->trail_autopause_idx);
|
||||
if (ap > 0) {
|
||||
uint32_t now = millis();
|
||||
float moved = _trail_pause_has_ref
|
||||
? geo::haversineKm(_trail_pause_ref_lat, _trail_pause_ref_lon, la, lo) * 1000.0f
|
||||
: 1e9f;
|
||||
if (!_trail_pause_has_ref || moved >= (float)NodePrefs::TRAIL_AUTOPAUSE_MOVE_M) {
|
||||
_trail_pause_ref_lat = la; _trail_pause_ref_lon = lo;
|
||||
_trail_pause_has_ref = true;
|
||||
_trail_last_move_ms = now;
|
||||
if (_trail.isPaused()) _trail.setPaused(false);
|
||||
} else if (!_trail.isPaused() && (now - _trail_last_move_ms) >= (uint32_t)ap * 1000UL) {
|
||||
_trail.setPaused(true);
|
||||
}
|
||||
} else if (_trail.isPaused()) {
|
||||
_trail.setPaused(false); // feature turned off → resume
|
||||
}
|
||||
if (!_trail.isPaused())
|
||||
_trail.addPoint(la, lo, (uint32_t)rtc_clock.getCurrentTime(), md);
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -1940,6 +2266,158 @@ void UITask::loop() {
|
||||
pushCogFix((int32_t)loc->getLatitude(), (int32_t)loc->getLongitude());
|
||||
}
|
||||
}
|
||||
|
||||
// Locator — beep + alert when the device crosses into / out of the armed
|
||||
// geofence. Cheap; a few seconds of latency at the boundary is fine.
|
||||
if ((int32_t)(millis() - _next_locator_ms) >= 0) {
|
||||
_next_locator_ms = millis() + 3000UL;
|
||||
evaluateLocator();
|
||||
}
|
||||
|
||||
// Locator proximity beeper — ticks faster the closer to the target. Runs on
|
||||
// its own short cadence (the crossing check above is too coarse for this).
|
||||
locatorProximityBeeper();
|
||||
}
|
||||
|
||||
// Evaluate the single geofence against the current GPS fix. Crossing the radius
|
||||
// fires fireLocator() according to the configured mode; a hysteresis band on
|
||||
// the "leave" edge stops it chattering at the boundary, and the first reading
|
||||
// after arming only seeds the inside/outside state (no spurious alert).
|
||||
// Distance (m) from the current GPS fix to the locator target, plus the
|
||||
// configured radius (m). Returns false when no target is set or there's no fix
|
||||
// — the single place the target-distance maths lives, shared by the crossing
|
||||
// evaluator and the proximity beeper.
|
||||
// One precedence for a person's position — an active [LOC] live share wins,
|
||||
// else the last-advertised GPS fix. Not everyone keeps live-sharing on, so the
|
||||
// fallback lets a rarely-updating but stationary node (a repeater, or someone
|
||||
// who shared a fix once) still work as a target.
|
||||
bool UITask::resolvePersonPos(const uint8_t* key, int32_t& lat, int32_t& lon,
|
||||
bool* live, uint32_t* ts) const {
|
||||
if (live) *live = false;
|
||||
if (ts) *ts = 0;
|
||||
if (!key) return false;
|
||||
const LiveTrackStore::Entry* e =
|
||||
_livetrack.activeByKey(key, (uint32_t)rtc_clock.getCurrentTime());
|
||||
if (e) {
|
||||
lat = e->lat_1e6; lon = e->lon_1e6;
|
||||
if (live) *live = true;
|
||||
if (ts) *ts = e->ts;
|
||||
return true;
|
||||
}
|
||||
ContactInfo* c = the_mesh.lookupContactByPubKey(key, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
if (c && (c->gps_lat != 0 || c->gps_lon != 0)) {
|
||||
lat = c->gps_lat; lon = c->gps_lon;
|
||||
if (ts) *ts = c->lastmod;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool UITask::activeTargetPos(int32_t& lat, int32_t& lon) const {
|
||||
if (!_node_prefs || !_node_prefs->locator_has_target) return false;
|
||||
if (_node_prefs->locator_target_kind == 1)
|
||||
return resolvePersonPos(_node_prefs->locator_key, lat, lon);
|
||||
lat = _node_prefs->locator_lat_1e6;
|
||||
lon = _node_prefs->locator_lon_1e6;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UITask::locatorDistance(float& dist_m, float& radius_m) const {
|
||||
int32_t tlat, tlon;
|
||||
if (!activeTargetPos(tlat, tlon)) return false;
|
||||
int32_t lat, lon;
|
||||
if (!currentLocation(lat, lon)) return false;
|
||||
dist_m = geo::haversineKm(lat, lon, tlat, tlon) * 1000.0f;
|
||||
radius_m = (float)NodePrefs::locatorRadiusMeters(_node_prefs->locator_radius_idx);
|
||||
return true;
|
||||
}
|
||||
|
||||
void UITask::evaluateLocator() {
|
||||
if (!_node_prefs || !_node_prefs->locator_enabled || !_node_prefs->locator_has_target) {
|
||||
_locator_known = false;
|
||||
return;
|
||||
}
|
||||
float dist, r;
|
||||
if (!locatorDistance(dist, r)) return; // armed but no fix yet — keep state
|
||||
bool inside;
|
||||
if (!_locator_known) inside = dist <= r; // seed state
|
||||
else if (_locator_inside) inside = dist <= r * 1.25f; // leave past band
|
||||
else inside = dist <= r; // arrive at edge
|
||||
|
||||
if (_locator_known && inside != _locator_inside) {
|
||||
uint8_t mode = _node_prefs->locator_mode; // 0=arrive,1=leave,2=both
|
||||
bool fire = inside ? (mode == 0 || mode == 2) : (mode == 1 || mode == 2);
|
||||
if (fire) fireLocator(inside);
|
||||
}
|
||||
_locator_inside = inside;
|
||||
_locator_known = true;
|
||||
}
|
||||
|
||||
void UITask::fireLocator(bool arrived) {
|
||||
const char* lbl = _node_prefs->locator_label[0] ? _node_prefs->locator_label : "target";
|
||||
bool person = _node_prefs->locator_target_kind == 1;
|
||||
char msg[40];
|
||||
// "Near/Away" reads naturally for a moving person; "Arrived/Left" for a place.
|
||||
snprintf(msg, sizeof(msg),
|
||||
arrived ? (person ? "Near: %s" : "Arrived: %s")
|
||||
: (person ? "Away: %s" : "Left: %s"), lbl);
|
||||
showAlert(msg, 3000);
|
||||
if (!isBuzzerQuiet())
|
||||
playMelody(arrived ? "locarr:d=8,o=6,b=140:c,e,g" : "loclv:d=8,o=6,b=140:g,e,c");
|
||||
}
|
||||
|
||||
void UITask::setTarget(uint8_t kind, const uint8_t* key, int32_t lat, int32_t lon, const char* name) {
|
||||
if (!_node_prefs) return;
|
||||
_node_prefs->locator_target_kind = kind;
|
||||
if (kind == 1 && key) memcpy(_node_prefs->locator_key, key, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
_node_prefs->locator_lat_1e6 = lat;
|
||||
_node_prefs->locator_lon_1e6 = lon;
|
||||
snprintf(_node_prefs->locator_label, sizeof(_node_prefs->locator_label), "%s", name);
|
||||
_node_prefs->locator_has_target = 1;
|
||||
resetLocator(); // re-seed the crossing engine so the change can't fire on a stale state
|
||||
}
|
||||
|
||||
void UITask::setTargetNow(uint8_t kind, const uint8_t* key, int32_t lat, int32_t lon, const char* name) {
|
||||
if (!_node_prefs) return;
|
||||
setTarget(kind, key, lat, lon, name);
|
||||
the_mesh.savePrefs();
|
||||
showAlert("Target set", 1200);
|
||||
}
|
||||
|
||||
void UITask::clearTarget() {
|
||||
if (!_node_prefs) return;
|
||||
_node_prefs->locator_has_target = 0;
|
||||
resetLocator();
|
||||
}
|
||||
|
||||
// Homing beeper: while armed with a target and inside the radius, emit a short
|
||||
// tick whose interval shrinks linearly with distance — slow at the edge, rapid
|
||||
// near the centre. Polls distance a few times a second; silent outside the
|
||||
// radius. The beeper has its own toggle (locator_beeper), so turning it on is
|
||||
// an explicit "I want to hear this" — it deliberately overrides the global
|
||||
// buzzer mute (playMelody → buzzer.playForced ignores the quiet flag).
|
||||
void UITask::locatorProximityBeeper() {
|
||||
static const uint32_t BEEP_MIN_MS = 150; // fastest cadence (at the target)
|
||||
static const uint32_t BEEP_MAX_MS = 2000; // slowest cadence (at the edge)
|
||||
if (!_node_prefs || !_node_prefs->locator_enabled || !_node_prefs->locator_beeper
|
||||
|| !_node_prefs->locator_has_target || _node_prefs->locator_mode == 1) { // leave-only mode: no homing
|
||||
return;
|
||||
}
|
||||
if ((int32_t)(millis() - _locator_beep_check_ms) < 0) return;
|
||||
_locator_beep_check_ms = millis() + 250UL;
|
||||
|
||||
float dist, r;
|
||||
if (!locatorDistance(dist, r)) return;
|
||||
if (dist > r) { // outside the zone: stay quiet, beep on re-entry
|
||||
_locator_beep_next_ms = millis();
|
||||
return;
|
||||
}
|
||||
if ((int32_t)(millis() - _locator_beep_next_ms) < 0) return;
|
||||
float frac = (r > 0) ? dist / r : 0; // 0 at centre, 1 at edge
|
||||
if (frac < 0) frac = 0; else if (frac > 1) frac = 1;
|
||||
uint32_t interval = BEEP_MIN_MS + (uint32_t)(frac * (BEEP_MAX_MS - BEEP_MIN_MS));
|
||||
playMelody("locp:d=32,o=7,b=200:c");
|
||||
_locator_beep_next_ms = millis() + interval;
|
||||
}
|
||||
|
||||
// Insert a GPS fix into the course-over-ground ring, rejecting gross outliers
|
||||
@@ -1997,6 +2475,56 @@ 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[80];
|
||||
if (_node_prefs->loc_share_target_type == 0) {
|
||||
// Channel: sendGroupMessage prepends "<name>: ", so the payload already
|
||||
// names the sender — keep the [LOC] text bare.
|
||||
snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f", lat / 1e6, lon / 1e6);
|
||||
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));
|
||||
}
|
||||
// DM carries no per-message sender prefix, so embed the name in the text — the
|
||||
// share is then self-describing in any chat client (a trailing token after the
|
||||
// coordinate, which parseLocShare ignores on the receiving side).
|
||||
ContactInfo* c = the_mesh.lookupContactByPubKey(_node_prefs->loc_share_dm_prefix,
|
||||
NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
if (!c) return false;
|
||||
snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f %s",
|
||||
lat / 1e6, lon / 1e6, the_mesh.getNodeName());
|
||||
uint32_t expected_ack = 0, est_timeout = 0;
|
||||
return the_mesh.sendMessage(*c, rtc_clock.getCurrentTime(), 0, text, expected_ack, est_timeout) > 0;
|
||||
}
|
||||
|
||||
// One-shot "share my position" from the home Map page (Hold Enter). When live
|
||||
// sharing is already on, push an immediate [LOC] to the same target; otherwise
|
||||
// hand a [LOC] message to the recipient picker so the user chooses where it
|
||||
// goes (no accidental broadcast to a default channel).
|
||||
void UITask::quickShareMyLocation() {
|
||||
int32_t lat, lon;
|
||||
if (!currentLocation(lat, lon)) { showAlert("No GPS fix", 1000); return; }
|
||||
if (_node_prefs && _node_prefs->loc_share_enabled && sendLocationShare(lat, lon)) {
|
||||
showAlert("Position shared", 900);
|
||||
return;
|
||||
}
|
||||
char text[40];
|
||||
snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f", lat / 1e6, lon / 1e6);
|
||||
shareToMessage(text);
|
||||
}
|
||||
|
||||
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,8 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* nearby_screen;
|
||||
UIScreen* dashboard_config;
|
||||
UIScreen* auto_advert_screen;
|
||||
UIScreen* live_share_screen;
|
||||
UIScreen* locator_screen;
|
||||
UIScreen* trail_screen;
|
||||
UIScreen* compass_screen;
|
||||
UIScreen* diag_screen;
|
||||
@@ -85,7 +88,38 @@ 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;
|
||||
|
||||
// Trail auto-pause engine state. _trail_pause_ref is the last position the
|
||||
// device was considered "at"; if it doesn't move beyond the trail min-delta
|
||||
// gate for the configured delay, the trail is auto-paused.
|
||||
int32_t _trail_pause_ref_lat = 0, _trail_pause_ref_lon = 0;
|
||||
bool _trail_pause_has_ref = false;
|
||||
uint32_t _trail_last_move_ms = 0;
|
||||
|
||||
// Locator engine state. _locator_known guards the first evaluation after
|
||||
// arming (initialise inside/outside silently, fire only on later crossings).
|
||||
uint32_t _next_locator_ms = 0;
|
||||
bool _locator_inside = false;
|
||||
bool _locator_known = false;
|
||||
// Proximity beeper: ticks while inside the radius, faster the nearer the
|
||||
// target. _locator_beep_check_ms throttles the distance poll; _locator_beep_next_ms
|
||||
// is when the next tick is due.
|
||||
uint32_t _locator_beep_check_ms = 0;
|
||||
uint32_t _locator_beep_next_ms = 0;
|
||||
bool locatorDistance(float& dist_m, float& radius_m) const;
|
||||
void evaluateLocator();
|
||||
void fireLocator(bool arrived);
|
||||
void locatorProximityBeeper();
|
||||
|
||||
// Course-over-ground ring — a heading source independent of trail recording.
|
||||
// Filled from the same periodic GPS poll regardless of _trail.isActive().
|
||||
@@ -145,6 +179,8 @@ public:
|
||||
void gotoQuickMsgScreen();
|
||||
void openContactDM(const ContactInfo& ci);
|
||||
void shareToMessage(const char* text); // open Messages pre-loaded to share `text`
|
||||
void quickShareMyLocation(); // Home Map Hold-Enter: one-shot position share
|
||||
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 +188,45 @@ public:
|
||||
void gotoNearbyScreen();
|
||||
void gotoDashboardConfig();
|
||||
void gotoAutoAdvertScreen();
|
||||
void gotoLiveShareScreen();
|
||||
void gotoLocatorScreen();
|
||||
// Re-arm the locator state machine so the next evaluation initialises
|
||||
// silently (called by the Locator tool after the target/radius changes,
|
||||
// so re-entering the zone doesn't fire on a stale inside/outside state).
|
||||
void resetLocator() { _locator_known = false; }
|
||||
// The one "active target" the device tracks — shared by the Locator geofence,
|
||||
// the Nav bearing/ETA view and (future) the map focus, so every entry point
|
||||
// sets the same thing. kind 0 = waypoint (key ignored), 1 = person (key
|
||||
// required, 6-byte prefix). setTarget() only *defines* the target (fields +
|
||||
// re-arm); the caller decides when to persist. Two commit policies, by
|
||||
// context: a screen with an exit hook (LocatorScreen) batches the save so
|
||||
// LEFT/RIGHT cycling doesn't thrash flash, while a per-item popup with no
|
||||
// such hook uses setTargetNow() to save + confirm on the spot.
|
||||
void setTarget(uint8_t kind, const uint8_t* key, int32_t lat, int32_t lon, const char* name);
|
||||
void setTargetNow(uint8_t kind, const uint8_t* key, int32_t lat, int32_t lon, const char* name);
|
||||
// Unset the active target (locator_has_target = 0). Distinct from setTarget()
|
||||
// because there's no "kind" for nothing — clearing is its own operation.
|
||||
void clearTarget();
|
||||
// Resolve a person target (6-byte pubkey prefix) to a current position:
|
||||
// prefers an active [LOC] live share, falls back to their last-advertised
|
||||
// GPS fix. Returns false when neither is known. Optional live/ts report
|
||||
// freshness for the picker's age tag. One precedence, used by both the
|
||||
// Locator engine (locatorDistance) and the target picker.
|
||||
bool resolvePersonPos(const uint8_t* key, int32_t& lat, int32_t& lon,
|
||||
bool* live = nullptr, uint32_t* ts = nullptr) const;
|
||||
// Resolved position of the active target — a waypoint's coords, or a person
|
||||
// via resolvePersonPos(). Gated only on a target being set, independent of
|
||||
// whether the Locator alert is enabled, so a destination you set still shows
|
||||
// on the map. Used by locatorDistance() and the map renderers.
|
||||
bool activeTargetPos(int32_t& lat, int32_t& lon) const;
|
||||
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 +345,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);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "KeyboardWidget.h"
|
||||
#include "PopupMenu.h"
|
||||
#include "NavView.h"
|
||||
#include "DigitEditor.h"
|
||||
|
||||
class WaypointsView {
|
||||
UITask* _task;
|
||||
@@ -30,14 +31,18 @@ class WaypointsView {
|
||||
int32_t _mark_lat = 0, _mark_lon = 0; // pending new-mark position
|
||||
uint32_t _mark_ts = 0;
|
||||
|
||||
// ADD form — type a waypoint by coordinates. The keyboard has no comma or
|
||||
// minus, so lat/lon are entered as magnitude + a hemisphere toggle (LEFT/RIGHT).
|
||||
char _add_lat[12] = "", _add_lon[12] = ""; // magnitude strings
|
||||
// ADD form — enter a waypoint by coordinates. Lat/Lon are numeric, so they use
|
||||
// the digit-by-digit scroll editor (same widget as the radio frequency field)
|
||||
// rather than the text keyboard: Enter opens the editor on the magnitude,
|
||||
// LEFT/RIGHT on the row toggles the hemisphere (N/S, E/W). Only the Label field
|
||||
// uses the keyboard.
|
||||
float _add_lat_mag = 0.0f, _add_lon_mag = 0.0f; // magnitude (degrees)
|
||||
char _add_label[WAYPOINT_LABEL_LEN] = "";
|
||||
bool _add_lat_neg = false; // true = S
|
||||
bool _add_lon_neg = false; // true = W
|
||||
int _add_sel = 0; // 0=Lat 1=Lon 2=Label 3=Save
|
||||
int _kb_field = -1; // which ADD field the keyboard edits (-1 = label/rename)
|
||||
int _kb_field = -1; // ADD label edit (>=0) vs mark/rename (-1)
|
||||
DigitEditor _add_editor; // active while editing a Lat/Lon magnitude
|
||||
|
||||
bool useImperial() const { return _task && _task->useImperial(); }
|
||||
bool ownPos(int32_t& lat, int32_t& lon) const { return _task->currentLocation(lat, lon); }
|
||||
@@ -53,38 +58,23 @@ class WaypointsView {
|
||||
// Add a waypoint by coordinates (no GPS fix needed). Opens the ADD form.
|
||||
void openAddForm() {
|
||||
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
|
||||
_add_lat[0] = _add_lon[0] = _add_label[0] = '\0';
|
||||
_add_lat_mag = _add_lon_mag = 0.0f;
|
||||
_add_label[0] = '\0';
|
||||
_add_lat_neg = _add_lon_neg = false;
|
||||
_add_sel = 0;
|
||||
_add_editor.active = false;
|
||||
_mode = ADD;
|
||||
}
|
||||
|
||||
// Store the just-typed value into the focused ADD field.
|
||||
// Store the just-typed label (the only ADD field that still uses the keyboard).
|
||||
void applyAddField(const char* buf) {
|
||||
char* dst = (_kb_field == 0) ? _add_lat
|
||||
: (_kb_field == 1) ? _add_lon : _add_label;
|
||||
int cap = (_kb_field == 2) ? WAYPOINT_LABEL_LEN : (int)sizeof(_add_lat);
|
||||
strncpy(dst, buf, cap - 1);
|
||||
dst[cap - 1] = '\0';
|
||||
}
|
||||
|
||||
// Parse a numeric field: true only when the whole string is a number (so a
|
||||
// stray letter from the full keyboard can't silently read as 0).
|
||||
static bool parseNum(const char* s, double& out) {
|
||||
if (!s || !s[0]) return false;
|
||||
char* end = nullptr;
|
||||
out = strtod(s, &end);
|
||||
if (end == s) return false; // nothing numeric parsed
|
||||
while (*end == ' ') end++;
|
||||
return *end == '\0'; // no trailing non-numeric chars
|
||||
strncpy(_add_label, buf, sizeof(_add_label) - 1);
|
||||
_add_label[sizeof(_add_label) - 1] = '\0';
|
||||
}
|
||||
|
||||
// Validate the ADD form and save the waypoint.
|
||||
void commitAddForm() {
|
||||
double la, lo;
|
||||
if (!parseNum(_add_lat, la) || !parseNum(_add_lon, lo)) {
|
||||
_task->showAlert("Lat/Lon invalid", 1200); return;
|
||||
}
|
||||
double la = _add_lat_mag, lo = _add_lon_mag;
|
||||
if (_add_lat_neg) la = -la;
|
||||
if (_add_lon_neg) lo = -lo;
|
||||
if (la < -90.0 || la > 90.0 || lo < -180.0 || lo > 180.0) {
|
||||
@@ -134,12 +124,22 @@ class WaypointsView {
|
||||
int y = top + i * step;
|
||||
bool sel = (i == _add_sel);
|
||||
display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel);
|
||||
char row[28];
|
||||
if (i == 0) snprintf(row, sizeof(row), "Lat: %c %s", _add_lat_neg ? 'S' : 'N', _add_lat[0] ? _add_lat : "--");
|
||||
else if (i == 1) snprintf(row, sizeof(row), "Lon: %c %s", _add_lon_neg ? 'W' : 'E', _add_lon[0] ? _add_lon : "--");
|
||||
else if (i == 2) snprintf(row, sizeof(row), "Label: %s", _add_label[0] ? _add_label : "(auto)");
|
||||
else snprintf(row, sizeof(row), "[Save]");
|
||||
display.setCursor(2, y); display.print(row);
|
||||
// While editing a magnitude, draw the row prefix + the digit editor so the
|
||||
// place under the cursor is highlighted; otherwise draw the plain value.
|
||||
if ((i == 0 || i == 1) && sel && _add_editor.active) {
|
||||
char prefix[10];
|
||||
if (i == 0) snprintf(prefix, sizeof(prefix), "Lat: %c ", _add_lat_neg ? 'S' : 'N');
|
||||
else snprintf(prefix, sizeof(prefix), "Lon: %c ", _add_lon_neg ? 'W' : 'E');
|
||||
display.setCursor(2, y); display.print(prefix);
|
||||
_add_editor.render(display, 2 + (int)strlen(prefix) * display.getCharWidth(), y);
|
||||
} else {
|
||||
char row[28];
|
||||
if (i == 0) snprintf(row, sizeof(row), "Lat: %c %.5f", _add_lat_neg ? 'S' : 'N', _add_lat_mag);
|
||||
else if (i == 1) snprintf(row, sizeof(row), "Lon: %c %.5f", _add_lon_neg ? 'W' : 'E', _add_lon_mag);
|
||||
else if (i == 2) snprintf(row, sizeof(row), "Label: %s", _add_label[0] ? _add_label : "(auto)");
|
||||
else snprintf(row, sizeof(row), "[Save]");
|
||||
display.setCursor(2, y); display.print(row);
|
||||
}
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
}
|
||||
@@ -274,7 +274,7 @@ public:
|
||||
if (_sel >= wpListCount()) _sel = wpListCount() - 1;
|
||||
if (_sel < 0) _sel = 0;
|
||||
}
|
||||
} else { // Send (share in a message)
|
||||
} else if (sel == 2) { // Send (share in a message)
|
||||
if (wi >= 0 && wi < _task->waypoints().count()) {
|
||||
const Waypoint& w = _task->waypoints().at(wi);
|
||||
double lat = w.lat_1e6 / 1000000.0, lon = w.lon_1e6 / 1000000.0;
|
||||
@@ -283,13 +283,27 @@ public:
|
||||
else snprintf(text, sizeof(text), WAYPOINT_MSG_TAG "%.5f,%.5f", lat, lon);
|
||||
_task->shareToMessage(text); // hands off to the Messages screen
|
||||
}
|
||||
} else { // Set as target
|
||||
if (wi >= 0 && wi < _task->waypoints().count()) {
|
||||
const Waypoint& w = _task->waypoints().at(wi);
|
||||
_task->setTargetNow(0, nullptr, w.lat_1e6, w.lon_1e6, w.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ADD form: type lat/lon (magnitude) + hemisphere toggle + label.
|
||||
// ADD form: scroll-edit lat/lon (magnitude) + hemisphere toggle + label.
|
||||
if (_mode == ADD) {
|
||||
// The digit editor, while open, consumes all input (UP/DOWN change the
|
||||
// digit, LEFT/RIGHT move the cursor, Enter commits, Cancel aborts).
|
||||
if (_add_editor.active) {
|
||||
if (_add_editor.handleInput(c) == DigitEditor::DONE) {
|
||||
if (_add_sel == 0) _add_lat_mag = _add_editor.value;
|
||||
else _add_lon_mag = _add_editor.value;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CANCEL) { _mode = OFF; return true; }
|
||||
if (c == KEY_UP) { _add_sel = (_add_sel > 0) ? _add_sel - 1 : 3; return true; }
|
||||
if (c == KEY_DOWN) { _add_sel = (_add_sel < 3) ? _add_sel + 1 : 0; return true; }
|
||||
@@ -299,8 +313,8 @@ public:
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER) {
|
||||
if (_add_sel == 0) { _kb_field = 0; openKb(_add_lat, 11); }
|
||||
else if (_add_sel == 1) { _kb_field = 1; openKb(_add_lon, 11); }
|
||||
if (_add_sel == 0) _add_editor.begin(_add_lat_mag, 0.0f, 90.0f, 2, 5);
|
||||
else if (_add_sel == 1) _add_editor.begin(_add_lon_mag, 0.0f, 180.0f, 3, 5);
|
||||
else if (_add_sel == 2) { _kb_field = 2; openKb(_add_label, WAYPOINT_LABEL_LEN - 1); }
|
||||
else { commitAddForm(); }
|
||||
return true;
|
||||
@@ -327,13 +341,14 @@ public:
|
||||
else _mode = NAV; // a waypoint / Trail-start row
|
||||
return true;
|
||||
}
|
||||
// Rename/Delete/Send apply to saved waypoints only — not Trail-start or Add.
|
||||
// Rename/Delete/Send/Locator apply to saved waypoints only — not Trail-start or Add.
|
||||
if (c == KEY_CONTEXT_MENU && !selIsStart() && _sel != n &&
|
||||
wpIndex() >= 0 && wpIndex() < _task->waypoints().count()) {
|
||||
_ctx.begin("Waypoint", 3);
|
||||
_ctx.begin("Waypoint", 4);
|
||||
_ctx.addItem("Rename");
|
||||
_ctx.addItem("Delete");
|
||||
_ctx.addItem("Send");
|
||||
_ctx.addItem("Set as target");
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -165,6 +165,127 @@ MINI_ICON(ICON_REPEATER, 6, // » double chevron — relaying/forwarding (repe
|
||||
packRow(".#..#."),
|
||||
packRow("#..#.."));
|
||||
|
||||
MINI_ICON(ICON_GPS, 5, // reticle — GPS fix status (boxed = fix, plain = searching)
|
||||
packRow(".###."),
|
||||
packRow("#...#"),
|
||||
packRow("#.#.#"),
|
||||
packRow("#...#"),
|
||||
packRow(".###."));
|
||||
|
||||
// Tools-menu glyphs (auto-reply bot, ringtone editor, diagnostics, system).
|
||||
MINI_ICON(ICON_BOT, 5, // robot head: antenna + eyes + grille (auto-reply bot)
|
||||
packRow("..#.."),
|
||||
packRow("#####"),
|
||||
packRow("#.#.#"),
|
||||
packRow("#####"),
|
||||
packRow("#.#.#"),
|
||||
packRow("#####"));
|
||||
MINI_ICON(ICON_NOTE, 5, // ♪ quaver — ringtone editor
|
||||
packRow("...##"),
|
||||
packRow("...##"),
|
||||
packRow("...#."),
|
||||
packRow("...#."),
|
||||
packRow("...#."),
|
||||
packRow("####."),
|
||||
packRow("####."));
|
||||
MINI_ICON(ICON_CHART, 5, // ascending bars — diagnostics / stats
|
||||
packRow("....#"),
|
||||
packRow("..#.#"),
|
||||
packRow("..#.#"),
|
||||
packRow("#.#.#"),
|
||||
packRow("#.#.#"),
|
||||
packRow("#####"));
|
||||
MINI_ICON(ICON_GEAR, 7, // ⚙ cog with hub hole — system
|
||||
packRow("..#.#.."),
|
||||
packRow(".#####."),
|
||||
packRow("#######"),
|
||||
packRow(".##.##."),
|
||||
packRow("#######"),
|
||||
packRow(".#####."),
|
||||
packRow("..#.#.."));
|
||||
|
||||
// Home-carousel page glyphs — a uniform 5x5 set, deliberately smaller than the
|
||||
// menu/status icons above, used in place of the page-indicator dots. One per
|
||||
// HomePage; see UITask HomeScreen::pageIcon().
|
||||
MINI_ICON(ICON_PG_CLOCK, 5, // clock face + hands
|
||||
packRow(".###."),
|
||||
packRow("#.#.#"),
|
||||
packRow("#.###"),
|
||||
packRow("#...#"),
|
||||
packRow(".###."));
|
||||
MINI_ICON(ICON_PG_STAR, 5, // favourites
|
||||
packRow("..#.."),
|
||||
packRow("#####"),
|
||||
packRow(".###."),
|
||||
packRow("##.##"),
|
||||
packRow("#...#"));
|
||||
MINI_ICON(ICON_PG_RECENT, 5, // stacked lines — recent list
|
||||
packRow("#####"),
|
||||
packRow("....."),
|
||||
packRow("#####"),
|
||||
packRow("....."),
|
||||
packRow("#####"));
|
||||
MINI_ICON(ICON_PG_RADIO, 5, // antenna tower — radio params
|
||||
packRow("..#.."),
|
||||
packRow(".###."),
|
||||
packRow("..#.."),
|
||||
packRow(".#.#."),
|
||||
packRow("#...#"));
|
||||
MINI_ICON(ICON_PG_BT, 5, // bluetooth (compact)
|
||||
packRow("..#.."),
|
||||
packRow("#.##."),
|
||||
packRow(".###."),
|
||||
packRow("#.##."),
|
||||
packRow("..#.."));
|
||||
MINI_ICON(ICON_PG_ADVERT, 5, // mast + radiating waves — advert
|
||||
packRow("#...#"),
|
||||
packRow(".#.#."),
|
||||
packRow("..#.."),
|
||||
packRow("..#.."),
|
||||
packRow("..#.."));
|
||||
MINI_ICON(ICON_PG_GPS, 5, // location pin — GPS
|
||||
packRow(".###."),
|
||||
packRow("#.#.#"),
|
||||
packRow("#.#.#"),
|
||||
packRow(".#.#."),
|
||||
packRow("..#.."));
|
||||
MINI_ICON(ICON_PG_SENSORS, 5, // thermometer/gauge — sensors
|
||||
packRow("..#.."),
|
||||
packRow(".#.#."),
|
||||
packRow(".#.#."),
|
||||
packRow(".###."),
|
||||
packRow(".###."));
|
||||
MINI_ICON(ICON_PG_SETTINGS, 5, // small cog — settings
|
||||
packRow(".#.#."),
|
||||
packRow("#####"),
|
||||
packRow("##.##"),
|
||||
packRow("#####"),
|
||||
packRow(".#.#."));
|
||||
MINI_ICON(ICON_PG_MAP, 5, // folded map — map page
|
||||
packRow("#####"),
|
||||
packRow("#.#.#"),
|
||||
packRow("#.#.#"),
|
||||
packRow("#.#.#"),
|
||||
packRow("#####"));
|
||||
MINI_ICON(ICON_PG_TOOLS, 5, // wrench (open jaw + handle) — tools
|
||||
packRow(".#.#."),
|
||||
packRow(".###."),
|
||||
packRow("..#.."),
|
||||
packRow("..#.."),
|
||||
packRow("..#.."));
|
||||
MINI_ICON(ICON_PG_MSG, 5, // speech bubble — quick messages
|
||||
packRow("#####"),
|
||||
packRow("#...#"),
|
||||
packRow("#...#"),
|
||||
packRow("#####"),
|
||||
packRow(".#..."));
|
||||
MINI_ICON(ICON_PG_POWER, 5, // power symbol — shutdown
|
||||
packRow("..#.."),
|
||||
packRow("#.#.#"),
|
||||
packRow("#...#"),
|
||||
packRow("#...#"),
|
||||
packRow(".###."));
|
||||
|
||||
// Trail-map markers — centred on a point (see miniIconDrawCentered) rather
|
||||
// than anchored to a text line.
|
||||
MINI_ICON(ICON_MAP_DOT, 3, // ● filled trail point
|
||||
@@ -193,6 +314,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(".###."),
|
||||
@@ -201,6 +328,18 @@ MINI_ICON(ICON_MAP_NORTH, 5, // "N" with a peaked roof — compass north ma
|
||||
packRow("#.#.#"),
|
||||
packRow("#..##"),
|
||||
packRow("#...#"));
|
||||
MINI_ICON(ICON_MAP_ARROW, 5, // → distance-to-nearest-tracked-contact indicator
|
||||
packRow("..#.."),
|
||||
packRow("...#."),
|
||||
packRow("#####"),
|
||||
packRow("...#."),
|
||||
packRow("..#.."));
|
||||
MINI_ICON(ICON_MAP_TARGET, 5, // ⚑ flag on a pole — the active Locator/Nav target
|
||||
packRow("####."),
|
||||
packRow("#..#."),
|
||||
packRow("####."),
|
||||
packRow("#...."),
|
||||
packRow("#...."));
|
||||
|
||||
// Keyboard special-key glyphs.
|
||||
MINI_ICON(ICON_SHIFT, 7, // ⇧ caps
|
||||
|
||||
@@ -43,10 +43,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
_display->turnOn();
|
||||
}
|
||||
|
||||
// strip off dash and commit hash by changing dash to null terminator
|
||||
// e.g: v1.2.3-abcdef -> v1.2.3
|
||||
// strip off the commit-hash suffix build.sh always appends as the LAST
|
||||
// dash-segment, e.g: v1.2.3-abcdef -> v1.2.3, v1.21-rc1-abcdef -> v1.21-rc1
|
||||
// (must use the last dash, not the first, since a tag itself may contain one)
|
||||
char *version = strdup(FIRMWARE_VERSION);
|
||||
char *dash = strchr(version, '-');
|
||||
char *dash = strrchr(version, '-');
|
||||
if (dash) {
|
||||
*dash = 0;
|
||||
}
|
||||
|
||||
@@ -39,10 +39,11 @@ class SplashScreen : public UIScreen {
|
||||
|
||||
public:
|
||||
SplashScreen(UITask* task) : _task(task) {
|
||||
// strip off dash and commit hash by changing dash to null terminator
|
||||
// e.g: v1.2.3-abcdef -> v1.2.3
|
||||
// strip off the commit-hash suffix build.sh always appends as the LAST
|
||||
// dash-segment, e.g: v1.2.3-abcdef -> v1.2.3, v1.21-rc1-abcdef -> v1.21-rc1
|
||||
// (must use the last dash, not the first, since a tag itself may contain one)
|
||||
const char *ver = FIRMWARE_VERSION;
|
||||
const char *dash = strchr(ver, '-');
|
||||
const char *dash = strrchr(ver, '-');
|
||||
|
||||
int len = dash ? dash - ver : strlen(ver);
|
||||
if (len >= sizeof(_version_info)) len = sizeof(_version_info) - 1;
|
||||
|
||||
Reference in New Issue
Block a user