From 79a3a47ff104ef65d104642c9ba620c5d6e25ad4 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:07:32 +0200 Subject: [PATCH] refactor(ui-core): move position + course over ground into CourseEngine COG ring sampling (every ~1 s from UiCore::loop), outlier rejection and currentCourse/currentLocation move from UITask to ui-core/CourseEngine.h. UITask keeps one-line wrappers; screens unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/development/ui-core.md | 4 +- .../companion_radio/ui-core/CourseEngine.h | 89 +++++++++++++++++++ examples/companion_radio/ui-core/UiCore.h | 6 +- .../companion_radio/ui-new/CompassScreen.h | 2 +- examples/companion_radio/ui-new/UITask.cpp | 68 +------------- examples/companion_radio/ui-new/UITask.h | 18 +--- 6 files changed, 102 insertions(+), 85 deletions(-) create mode 100644 examples/companion_radio/ui-core/CourseEngine.h diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index a99bfa76..94bd0705 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -37,7 +37,7 @@ interleaved. Classified: - Live share — session timer, movement/heartbeat gate, `sendLocationShare`, scope guard, `onSharedLocation`/live-track expiry. - Locator — geofence state machine, proximity beeper (`evaluateLocator`, `fireLocator`, targets). - Trail — sampling, auto-pause, low-battery auto-save. -- Course over ground — `pushCogFix`, `currentCourse`, `currentLocation`. +- Course over ground + current position ✅ `ui-core/CourseEngine.h`. - Clock tools — alarm / countdown / ring ✅ `ui-core/ClockEngine.h`. - Ping ✅ `ui-core/PingEngine.h` (timeout still decided by `NearbyScreen`). - Device controls — GPS on/off, GPIO (`setGpioMode`, bot GPIO), buzzer mode/volume, brightness, radio apply (`applyTxPower`, `applyApc`, `applyRadioParams`, …). @@ -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/CourseEngine.h b/examples/companion_radio/ui-core/CourseEngine.h new file mode 100644 index 00000000..f889d08e --- /dev/null +++ b/examples/companion_radio/ui-core/CourseEngine.h @@ -0,0 +1,89 @@ +#pragma once +// Position + course over ground. The single source of truth for "where am I" +// and "which way am I heading", shared by the nav / compass / map screens. +// +// Course-over-ground ring: a heading source independent of trail recording, +// sampled every ~1 s from the GPS whenever it has a fix. Heading = bearing +// across the window (oldest→newest) once the cumulative movement clears +// MIN_MOVE_M; gross GPS jumps are rejected on insert. + +#include "../GeoUtils.h" + +class CourseEngine { +public: + void begin(SensorManager* sensors) { _sensors = sensors; } + + void loop() { + if ((int32_t)(millis() - _next_sample_ms) < 0) return; + _next_sample_ms = millis() + 1000UL; + int32_t lat, lon; + if (currentLocation(lat, lon)) pushFix(lat, lon); + } + + // Current GPS position (1e6-scaled degrees), false when there's no usable fix. + bool currentLocation(int32_t& lat, int32_t& lon) const { + LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr; + if (loc && loc->isValid()) { + lat = (int32_t)loc->getLatitude(); + lon = (int32_t)loc->getLongitude(); + return true; + } + return false; + } + + // Current course over ground in degrees (0..359), or false if not enough + // recent movement to derive a stable heading. Holds the last good heading + // while standing still. + bool currentCourse(int& deg_out) const { + static const float MIN_MOVE_M = 6.0f; // window must span ≥ this to be a real heading + if (_count < 2) { + if (_deg >= 0) { deg_out = _deg; return true; } // hold last good + return false; + } + const Fix& oldest = _ring[_head]; + const Fix& newest = _ring[(_head + _count - 1) % RING]; + float span_m = geo::haversineKm(oldest.lat, oldest.lon, newest.lat, newest.lon) * 1000.0f; + if (span_m < MIN_MOVE_M) { + if (_deg >= 0) { deg_out = _deg; return true; } // standing still → hold last + return false; + } + _deg = geo::bearingDeg(oldest.lat, oldest.lon, newest.lat, newest.lon); + deg_out = _deg; + return true; + } + +private: + // Insert a GPS fix into the ring, rejecting gross outliers (a jump implying + // an impossible speed) so one bad fix can't swing the heading. + void pushFix(int32_t lat, int32_t lon) { + static const uint32_t MAX_GAP_MS = 15000; // GPS gap longer than this → window is stale + uint32_t now = millis(); + if (_count > 0) { + const Fix& prev = _ring[(_head + _count - 1) % RING]; + uint32_t dt = now - prev.ms; + if (dt > MAX_GAP_MS) { + // GPS was lost for a while: the old fixes are far in the past, so a + // window spanning them would imply a bogus "teleport" heading. Restart + // the ring from this fix (the last-good _deg is kept for display). + _head = 0; _count = 0; + } else if (dt > 0) { + float dist_m = geo::haversineKm(prev.lat, prev.lon, lat, lon) * 1000.0f; + float speed = dist_m / (dt / 1000.0f); // m/s + if (speed > 50.0f) return; // > 180 km/h between fixes → reject + } + } + int pos; + if (_count < RING) { pos = (_head + _count) % RING; _count++; } + else { pos = _head; _head = (_head + 1) % RING; } + _ring[pos].lat = lat; _ring[pos].lon = lon; _ring[pos].ms = now; + } + + static const int RING = 5; + struct Fix { int32_t lat, lon; uint32_t ms; }; + + SensorManager* _sensors = nullptr; + Fix _ring[RING]; + uint8_t _head = 0, _count = 0; + mutable int _deg = -1; // last good heading (cached by currentCourse), -1 = none yet + uint32_t _next_sample_ms = 0; +}; diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index 0b9a8251..a0f5bca4 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -12,17 +12,20 @@ #include "UiEvents.h" #include "ClockEngine.h" #include "PingEngine.h" +#include "CourseEngine.h" class UiCore { public: - void begin(NodePrefs* prefs) { + void begin(NodePrefs* prefs, SensorManager* sensors) { clock.begin(prefs, &events); ping.begin(prefs); + course.begin(sensors); } // Driven from the frontend's loop(), before it drains `events`. void loop() { clock.loop(); + course.loop(); } UiEventQueue events; // Core → frontend; drained by the frontend's loop() @@ -30,6 +33,7 @@ public: // ── Engines ─────────────────────────────────────────────────────────────── ClockEngine clock; // alarm / countdown / ring PingEngine ping; // single in-flight ping + last result + CourseEngine course; // GPS position + course-over-ground ring // ── Models ──────────────────────────────────────────────────────────────── MessageHistory history; // channel + DM rings, delivery state, channel unread diff --git a/examples/companion_radio/ui-new/CompassScreen.h b/examples/companion_radio/ui-new/CompassScreen.h index bd2f86ee..79327e4a 100644 --- a/examples/companion_radio/ui-new/CompassScreen.h +++ b/examples/companion_radio/ui-new/CompassScreen.h @@ -8,7 +8,7 @@ // A linear tape (no trig) reads well in the short vertical space of the OLED, // where a circular dial leaves only a few-pixel needle. // -// Reuses UITask's COG ring (currentCourse) — works whether or not a trail is +// Reuses the UI Core's COG ring (currentCourse) — works whether or not a trail is // being recorded. #include "../GeoUtils.h" diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index c78b8a71..a9cf3182 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1647,7 +1647,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no } _core = new UiCore(); // before any screen -- MessagesScreen binds to its history - _core->begin(node_prefs); + _core->begin(node_prefs, sensors); splash = new SplashScreen(this); home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); syncLockToHome(); // booted locked (e.g. cover closed) → home starts on the LOCK page @@ -2974,16 +2974,6 @@ void UITask::loop() { _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) { - _next_cog_sample_ms = millis() + 1000UL; - LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr; - if (loc && loc->isValid()) { - 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) { @@ -3246,60 +3236,8 @@ void UITask::locatorProximityBeeper() { _locator_beep_next_ms = millis() + interval; } -// Insert a GPS fix into the course-over-ground ring, rejecting gross outliers -// (a jump implying an impossible speed) so one bad fix can't swing the heading. -void UITask::pushCogFix(int32_t lat, int32_t lon) { - static const uint32_t COG_MAX_GAP_MS = 15000; // GPS gap longer than this → window is stale - uint32_t now = millis(); - if (_cog_count > 0) { - const CogFix& prev = _cog[(_cog_head + _cog_count - 1) % COG_RING]; - uint32_t dt = now - prev.ms; - if (dt > COG_MAX_GAP_MS) { - // GPS was lost for a while: the old fixes are far in the past, so a - // window spanning them would imply a bogus "teleport" heading. Restart - // the ring from this fix (the last-good _cog_deg is kept for display). - _cog_head = 0; _cog_count = 0; - } else if (dt > 0) { - float dist_m = geo::haversineKm(prev.lat, prev.lon, lat, lon) * 1000.0f; - float speed = dist_m / (dt / 1000.0f); // m/s - if (speed > 50.0f) return; // > 180 km/h between fixes → reject - } - } - int pos; - if (_cog_count < COG_RING) { pos = (_cog_head + _cog_count) % COG_RING; _cog_count++; } - else { pos = _cog_head; _cog_head = (_cog_head + 1) % COG_RING; } - _cog[pos].lat = lat; _cog[pos].lon = lon; _cog[pos].ms = now; -} - -bool UITask::currentCourse(int& deg_out) const { - static const float COG_MIN_MOVE_M = 6.0f; // window must span ≥ this to be a real heading - if (_cog_count < 2) { - if (_cog_deg >= 0) { deg_out = _cog_deg; return true; } // hold last good - return false; - } - const CogFix& oldest = _cog[_cog_head]; - const CogFix& newest = _cog[(_cog_head + _cog_count - 1) % COG_RING]; - float span_m = geo::haversineKm(oldest.lat, oldest.lon, newest.lat, newest.lon) * 1000.0f; - if (span_m < COG_MIN_MOVE_M) { - if (_cog_deg >= 0) { deg_out = _cog_deg; return true; } // standing still → hold last - return false; - } - // Cache as last-good (mutable-free: recompute is cheap, but keep _cog_deg fresh). - const_cast(this)->_cog_deg = - geo::bearingDeg(oldest.lat, oldest.lon, newest.lat, newest.lon); - deg_out = _cog_deg; - return true; -} - -bool UITask::currentLocation(int32_t& lat, int32_t& lon) const { - LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr; - if (loc && loc->isValid()) { - lat = (int32_t)loc->getLatitude(); - lon = (int32_t)loc->getLongitude(); - return true; - } - return false; -} +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 diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 5e5e6677..cfc17b87 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -156,17 +156,6 @@ class UITask : public AbstractUITask { // display wake). Driven from loop() regardless of the current screen. void tickCore(); - // Course-over-ground ring — a heading source independent of trail recording. - // Filled from the same periodic GPS poll regardless of _trail.isActive(). - // Heading = bearing across the window (oldest→newest) once the cumulative - // movement clears COG_MIN_MOVE_M; gross GPS jumps are rejected on insert. - static const int COG_RING = 5; - struct CogFix { int32_t lat, lon; uint32_t ms; }; - CogFix _cog[COG_RING]; - uint8_t _cog_head = 0, _cog_count = 0; - int _cog_deg = -1; // last good heading, -1 = none yet - uint32_t _next_cog_sample_ms = 0; - void pushCogFix(int32_t lat, int32_t lon); void userLedHandler(); @@ -396,12 +385,9 @@ public: // full" alert. Returns true on success. The ts-less overload uses current RTC time. bool addWaypoint(int32_t lat, int32_t lon, uint32_t ts, const char* label); bool addWaypoint(int32_t lat, int32_t lon, const char* label); - // Current course over ground in degrees (0..359), or false if not enough - // recent movement to derive a stable heading. Independent of trail logging. + // Position / course over ground (ui-core/CourseEngine.h) — shared by the nav + // / compass / map screens. Course is independent of trail logging. bool currentCourse(int& deg_out) const; - // Current GPS position (1e6-scaled degrees), false when there's no usable - // fix. Single source of truth for "where am I", shared by the nav / compass - // / map screens so the LocationProvider lookup isn't duplicated per screen. bool currentLocation(int32_t& lat, int32_t& lon) const; void playMelody(const char* melody); void stopMelody();