From 74baaddd0c1435fa35d7323e701f01366c3b4708 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:09:26 +0200 Subject: [PATCH] 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 --- docs/development/ui-core.md | 4 +- .../companion_radio/ui-core/LiveShareEngine.h | 144 ++++++++++++++++++ examples/companion_radio/ui-core/UiCore.h | 4 + examples/companion_radio/ui-core/UiEvents.h | 1 + examples/companion_radio/ui-new/UITask.cpp | 102 ++----------- examples/companion_radio/ui-new/UITask.h | 22 +-- 6 files changed, 165 insertions(+), 112 deletions(-) create mode 100644 examples/companion_radio/ui-core/LiveShareEngine.h diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index 94bd0705..5d6ecb6c 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -34,7 +34,7 @@ interleaved. Classified: **Engines — logic living inside `UITask.cpp` / `UITask.h`** - Unread tracking — DM unread table ✅ (`ui-core/DmUnreadTable.h`), room unread (still in `UITask`). - Notifications — `showAlert`, `notify`, `SoundNotifier`, LED (`userLedHandler`), wake-on-message (`checkDisplayOn`, auto-off). -- Live share — session timer, movement/heartbeat gate, `sendLocationShare`, scope guard, `onSharedLocation`/live-track expiry. +- Live share ✅ `ui-core/LiveShareEngine.h` — session timer, movement/heartbeat gate, send + scope guard, peers' `LiveTrackStore` + expiry. - Locator — geofence state machine, proximity beeper (`evaluateLocator`, `fireLocator`, targets). - Trail — sampling, auto-pause, low-battery auto-save. - Course over ground + current position ✅ `ui-core/CourseEngine.h`. @@ -151,7 +151,7 @@ checked in the sim plus on L1 hardware before the next one. 0. **Listener boundary** ✅ (upstream `MyMesh::Listener` ported; `MyMesh` has no UI calls left). 1. **Skeleton** ✅. `examples/companion_radio/ui-core/` with `UiCore.h` (facade), `MessageHistory.h`, `DmUnreadTable.h`. Header-only for now, reached from `ui-new/UITask.cpp` by relative include, so none of the 66 variant `platformio.ini` files that build `ui-new` change. `UITask` heap-allocates one `UiCore` in `begin()` (before the screens, as `MessagesScreen` used to own the history on the heap); `MessagesScreen` binds to `core().history` by reference. When the Core grows real `.cpp` files, compile them through a unity `.cpp` inside each frontend directory. -2. **Engines, one per commit.** Clock tools ✅ (also introduced `ui-core/UiEvents.h`, the Core → frontend event queue; `UITask::tickCore()` runs `UiCore::loop()` and drains it) → ping ✅ → course-over-ground ✅ → live share → locator → trail → notifications. `UITask` shrinks to screen management + drawing. +2. **Engines, one per commit.** Clock tools ✅ (also introduced `ui-core/UiEvents.h`, the Core → frontend event queue; `UITask::tickCore()` runs `UiCore::loop()` and drains it) → ping ✅ → course-over-ground ✅ → live share ✅ → locator → trail → notifications. `UITask` shrinks to screen management + drawing. 3. **Flip the interface.** `UiCore` implements `AbstractUITask`; `ui-new`'s `UITask` becomes a frontend fed by events. 4. **Settings schema.** Convert `SettingsScreen` section by section. 5. **`ui-lvgl` skeleton** for L2 + sim target: boot, home, message list/conversation, keyboard. Then screens by priority. diff --git a/examples/companion_radio/ui-core/LiveShareEngine.h b/examples/companion_radio/ui-core/LiveShareEngine.h new file mode 100644 index 00000000..a6593d02 --- /dev/null +++ b/examples/companion_radio/ui-core/LiveShareEngine.h @@ -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 ": ", 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) +}; diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index a0f5bca4..f8c59778 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -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 diff --git a/examples/companion_radio/ui-core/UiEvents.h b/examples/companion_radio/ui-core/UiEvents.h index e464e442..574478c3 100644 --- a/examples/companion_radio/ui-core/UiEvents.h +++ b/examples/companion_radio/ui-core/UiEvents.h @@ -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 { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index a9cf3182..8c48ae42 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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 ": ", 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 diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index cfc17b87..ae1b0f71 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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();