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:
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user