refactor(ui-core): move live location sharing into LiveShareEngine

Auto-[LOC] session (duration, movement/heartbeat gate, scoped send) and
the peers' LiveTrackStore + expiry move from UITask to
ui-core/LiveShareEngine.h. "Live share ended" becomes a
UiEventType::LiveShareEnded event; UITask keeps thin wrappers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-24 23:09:26 +02:00
co-authored by Claude Opus 5.5
parent 79a3a47ff1
commit 74baaddd0c
6 changed files with 165 additions and 112 deletions
@@ -0,0 +1,144 @@
#pragma once
// Live location sharing, both directions:
// • outgoing — while NodePrefs::loc_share_enabled, periodically broadcast my
// [LOC] to the configured target (channel or DM), movement-gated so a
// stationary device stays quiet unless a heartbeat is configured. A session
// always ends after the chosen duration (counted in RAM from enable / boot,
// so a reboot starts a fresh session) -> UiEventType::LiveShareEnded.
// • incoming — [LOC] shares heard from peers (MyMesh → Listener::
// onSharedLocation) kept in a LiveTrackStore for the Live view / map /
// locator, expired once a minute.
#include "../LiveTrack.h"
#include "../GeoUtils.h"
#include "UiEvents.h"
#include "CourseEngine.h"
class LiveShareEngine {
public:
void begin(NodePrefs* prefs, const CourseEngine* course, UiEventQueue* events) {
_prefs = prefs; _course = course; _events = events;
}
void loop() {
// Live-track housekeeping — drop shared positions that have gone stale, so
// the Live view / map don't show ghosts. Cheap; once a minute.
if ((int32_t)(millis() - _next_expire_ms) >= 0) {
_next_expire_ms = millis() + 60000UL;
_track.expire((uint32_t)rtc_clock.getCurrentTime());
}
if (!_prefs) return;
// A session always ends: switch off once the chosen duration has run out.
if (_prefs->loc_share_enabled && _was_enabled
&& (uint32_t)(millis() - _session_ms)
>= (uint32_t)NodePrefs::locShareDurationMins(_prefs->loc_share_duration_idx) * 60000UL) {
_prefs->loc_share_enabled = 0;
_was_enabled = false;
the_mesh.savePrefs();
_events->push(UiEventType::LiveShareEnded);
}
if (_prefs->loc_share_enabled && (int32_t)(millis() - _next_check_ms) >= 0) {
_next_check_ms = millis() + 2000UL;
if (!_was_enabled) {
_has_last = false; // re-announce on enable
_session_ms = millis();
}
_was_enabled = true;
int32_t lat, lon;
if (_course->currentLocation(lat, lon)) {
uint16_t move_m = NodePrefs::locShareMoveMeters(_prefs->loc_share_move_idx);
uint16_t gap_s = NodePrefs::locShareIntervalSecs(_prefs->loc_share_interval_idx);
uint16_t hb_s = NodePrefs::locShareHeartbeatSecs(_prefs->loc_share_heartbeat_idx);
uint32_t now = millis();
bool first = !_has_last;
float moved = first ? 1e9f
: geo::haversineKm(_last_lat, _last_lon, lat, lon) * 1000.0f;
bool gap_ok = first || (now - _last_ms) >= (uint32_t)gap_s * 1000UL;
bool hb_due = (hb_s > 0) && !first && (now - _last_ms) >= (uint32_t)hb_s * 1000UL;
if ((moved >= (float)move_m && gap_ok) || first || hb_due) {
if (send(lat, lon)) {
_last_lat = lat;
_last_lon = lon;
_last_ms = now;
_has_last = true;
}
}
}
} else if (!_prefs->loc_share_enabled) {
_was_enabled = false;
}
}
// Start the session afresh (next tick treats it as a new enable: re-announce
// + new duration clock).
void restartSession() { _was_enabled = false; }
// Restart only the session's duration clock -- unlike restartSession(), no
// re-announce. For a changed "Stop after" length, where position hasn't
// changed. No-op before a session has started (loop() sets the clock then).
void restartClock() { if (_was_enabled) _session_ms = millis(); }
// Send one [LOC] message to the configured live-share target. Returns false
// if the target can't be resolved (no such channel / contact).
bool send(int32_t lat, int32_t lon) {
if (!_prefs) return false;
// Live Share's own scope, if set: applies to these sends only (0 = follow the
// target's usual scope). The sends below are synchronous, so bracketing works.
// A value past the list's end (list shrunk other than via removeScope())
// follows the target, as LiveShareScreen shows it -- ScopeList::key() would
// otherwise clamp it to "*" and send unscoped.
struct ScopeGuard {
bool on;
explicit ScopeGuard(uint8_t v) : on(v != 0 && v <= the_mesh.scopeList().count + 1) {
if (on) the_mesh.setOneShotScope(v - 1);
}
~ScopeGuard() { if (on) the_mesh.clearOneShotScope(); }
} scope_guard(_prefs->loc_share_scope);
char text[80];
if (_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(_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(_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;
}
// A peer broadcast its position via a [LOC] message (parsed in MyMesh). Gated
// on the user preference so tracking stays opt-in.
void onSharedLocation(const uint8_t* pub_key, const char* name,
int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, bool verified) {
if (!_prefs || !_prefs->track_shared_loc) return;
_track.update(pub_key, name, lat_1e6, lon_1e6, ts, verified);
}
LiveTrackStore& track() { return _track; }
const LiveTrackStore& track() const { return _track; }
private:
NodePrefs* _prefs = nullptr;
const CourseEngine* _course = nullptr;
UiEventQueue* _events = nullptr;
LiveTrackStore _track;
uint32_t _next_expire_ms = 0;
uint32_t _next_check_ms = 0;
uint32_t _last_ms = 0;
int32_t _last_lat = 0, _last_lon = 0;
bool _has_last = false;
bool _was_enabled = false;
uint32_t _session_ms = 0; // when the current session began (RAM only)
};
@@ -13,6 +13,7 @@
#include "ClockEngine.h"
#include "PingEngine.h"
#include "CourseEngine.h"
#include "LiveShareEngine.h"
class UiCore {
public:
@@ -20,12 +21,14 @@ public:
clock.begin(prefs, &events);
ping.begin(prefs);
course.begin(sensors);
live_share.begin(prefs, &course, &events);
}
// Driven from the frontend's loop(), before it drains `events`.
void loop() {
clock.loop();
course.loop();
live_share.loop();
}
UiEventQueue events; // Core → frontend; drained by the frontend's loop()
@@ -34,6 +37,7 @@ public:
ClockEngine clock; // alarm / countdown / ring
PingEngine ping; // single in-flight ping + last result
CourseEngine course; // GPS position + course-over-ground ring
LiveShareEngine live_share; // [LOC] auto-share session + peers' shared positions
// ── Models ────────────────────────────────────────────────────────────────
MessageHistory history; // channel + DM rings, delivery state, channel unread
@@ -11,6 +11,7 @@ enum class UiEventType : uint8_t {
None = 0,
ClockAlert, // alarm / countdown fired: wake, show `text`, start the ring melody
ClockRingEnded, // ring window elapsed with no dismiss: stop melody, clear alert
LiveShareEnded, // live-share session reached its duration and switched itself off
};
struct UiEvent {
+9 -93
View File
@@ -1742,6 +1742,9 @@ void UITask::tickCore() {
stopMelody();
clearAlert();
break;
case UiEventType::LiveShareEnded:
showAlert("Live share ended", 2500);
break;
default:
break;
}
@@ -2922,58 +2925,6 @@ void UITask::loop() {
}
}
// Live-track housekeeping — drop shared positions that have gone stale, so
// the Nearby "Live" view / map don't show ghosts. Cheap; once a minute.
if ((int32_t)(millis() - _next_livetrack_expire_ms) >= 0) {
_next_livetrack_expire_ms = millis() + 60000UL;
_livetrack.expire((uint32_t)rtc_clock.getCurrentTime());
}
// A session always ends: switch off once the chosen duration has run out.
// Counted from enable / boot (RAM only), so a reboot starts a fresh session.
if (_node_prefs && _node_prefs->loc_share_enabled && _loc_share_was_enabled
&& (uint32_t)(millis() - _loc_share_session_ms)
>= (uint32_t)NodePrefs::locShareDurationMins(_node_prefs->loc_share_duration_idx) * 60000UL) {
_node_prefs->loc_share_enabled = 0;
_loc_share_was_enabled = false;
the_mesh.savePrefs();
showAlert("Live share ended", 2500);
}
// 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_session_ms = millis();
}
_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;
}
// 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) {
@@ -3004,7 +2955,7 @@ bool UITask::resolvePersonPos(const uint8_t* key, int32_t& lat, int32_t& lon,
if (ts) *ts = 0;
if (!key) return false;
const LiveTrackStore::Entry* e =
_livetrack.activeByKey(key, (uint32_t)rtc_clock.getCurrentTime());
_core->live_share.track().activeByKey(key, (uint32_t)rtc_clock.getCurrentTime());
if (e) {
lat = e->lat_1e6; lon = e->lon_1e6;
if (live) *live = true;
@@ -3239,51 +3190,16 @@ void UITask::locatorProximityBeeper() {
bool UITask::currentCourse(int& deg_out) const { return _core->course.currentCourse(deg_out); }
bool UITask::currentLocation(int32_t& lat, int32_t& lon) const { return _core->course.currentLocation(lat, lon); }
// 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);
_core->live_share.onSharedLocation(pub_key, name, lat_1e6, lon_1e6, ts, verified);
}
bool UITask::sendLocationShare(int32_t lat, int32_t lon) {
if (!_node_prefs) return false;
// Live Share's own scope, if set: applies to these sends only (0 = follow the
// target's usual scope). The sends below are synchronous, so bracketing works.
// A value past the list's end (list shrunk other than via removeScope())
// follows the target, as LiveShareScreen shows it -- ScopeList::key() would
// otherwise clamp it to "*" and send unscoped.
struct ScopeGuard {
bool on;
explicit ScopeGuard(uint8_t v) : on(v != 0 && v <= the_mesh.scopeList().count + 1) {
if (on) the_mesh.setOneShotScope(v - 1);
}
~ScopeGuard() { if (on) the_mesh.clearOneShotScope(); }
} scope_guard(_node_prefs->loc_share_scope);
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;
}
bool UITask::sendLocationShare(int32_t lat, int32_t lon) { return _core->live_share.send(lat, lon); }
void UITask::restartLocShareSession() { _core->live_share.restartSession(); }
void UITask::restartLocShareClock() { _core->live_share.restartClock(); }
LiveTrackStore& UITask::liveTrack() { return _core->live_share.track(); }
// 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
+5 -17
View File
@@ -118,17 +118,7 @@ class UITask : public AbstractUITask {
UIScreen* curr = nullptr;
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;
uint32_t _loc_share_session_ms = 0; // when the current auto-share session began (RAM only)
// 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
@@ -303,12 +293,10 @@ public:
void gotoDashboardConfig();
void gotoAutoAdvertScreen();
void gotoLiveShareScreen();
// Start the auto-share session clock afresh (next engine tick treats it as a new enable).
void restartLocShareSession() { _loc_share_was_enabled = false; }
// Restart only the session's duration clock -- unlike restartLocShareSession(),
// no re-announce. For a changed "Stop after" length, where position hasn't
// changed. No-op before the engine has started a session (it sets the clock then).
void restartLocShareClock() { if (_loc_share_was_enabled) _loc_share_session_ms = millis(); }
// Live share session control (ui-core/LiveShareEngine.h): restart = new
// session (re-announce + fresh clock); clock-only = changed "Stop after".
void restartLocShareSession();
void restartLocShareClock();
void gotoLocatorScreen();
// Re-arm the locator state machine so the next evaluation initialises
// silently (called by the Locator tool after the target/radius changes,
@@ -377,7 +365,7 @@ public:
void dismissRing();
TrailStore& trail() { return _trail; }
WaypointStore& waypoints() { return _waypoints; }
LiveTrackStore& liveTrack() { return _livetrack; }
LiveTrackStore& liveTrack();
// Shared on-screen keyboard — only one screen drives it at a time.
KeyboardWidget& keyboard() { return _kb; }
void saveWaypoints();