diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index fc9dfff4..aa2bd5fe 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -36,7 +36,7 @@ interleaved. Classified: - Notifications — `showAlert`, `notify`, `SoundNotifier`, LED (`userLedHandler`), wake-on-message (`checkDisplayOn`, auto-off). - Live share ✅ `ui-core/LiveShareEngine.h` — session timer, movement/heartbeat gate, send + scope guard, peers' `LiveTrackStore` + expiry. - Locator ✅ `ui-core/LocatorEngine.h` — active target, geofence state machine, proximity beeper. -- Trail — sampling, auto-pause, low-battery auto-save. +- Trail ✅ `ui-core/TrailEngine.h` — store, sampling, auto-pause, low-battery auto-save. - 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`). @@ -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/TrailEngine.h b/examples/companion_radio/ui-core/TrailEngine.h new file mode 100644 index 00000000..13009804 --- /dev/null +++ b/examples/companion_radio/ui-core/TrailEngine.h @@ -0,0 +1,79 @@ +#pragma once +// GPS trail: the RAM TrailStore plus its background sampling (runs while the +// trail is active, independent of which screen is shown), auto-pause, and the +// pre-shutdown auto-save. Recording control (start/stop/clear/save/load) is +// still done by TrailScreen directly on the store. + +#include "../Trail.h" +#include "../GeoUtils.h" +#include "CourseEngine.h" + +class TrailEngine { +public: + void begin(NodePrefs* prefs, const CourseEngine* course) { _prefs = prefs; _course = course; } + + // Skips silently if no GPS fix; the min-delta gate inside addPoint() avoids + // near-stationary spam. + void loop() { + if (!_trail.isActive()) _pause_has_ref = false; // fresh ref on next start + if (!_trail.isActive() || _prefs == NULL + || (int32_t)(millis() - _next_sample_ms) < 0) return; + _next_sample_ms = millis() + (uint32_t)TrailStore::SAMPLING_SECS * 1000UL; + int32_t la, lo; + if (!_course->currentLocation(la, lo)) return; + uint16_t md = TrailStore::minDeltaMeters(_prefs->trail_min_delta_idx, _prefs->units_imperial); + // 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(_prefs->trail_autopause_idx); + if (ap > 0) { + uint32_t now = millis(); + float moved = _pause_has_ref + ? geo::haversineKm(_pause_ref_lat, _pause_ref_lon, la, lo) * 1000.0f + : 1e9f; + if (!_pause_has_ref || moved >= (float)NodePrefs::TRAIL_AUTOPAUSE_MOVE_M) { + _pause_ref_lat = la; _pause_ref_lon = lo; + _pause_has_ref = true; + _last_move_ms = now; + if (_trail.isPaused()) _trail.setPaused(false); + } else if (!_trail.isPaused() && (now - _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); + } + + // True while a trail is recording and not auto-paused (needs live GPS). + bool isRecording() const { return _trail.isActive() && !_trail.isPaused(); } + + // Auto-save the live trail before power-off when the user enabled it + // (Tools › Trail › Settings › Auto-save). This covers the low-battery + // auto-shutdown, which otherwise loses the whole route. Overwrites /trail + // (same file as the manual Trail › Save); guarded on count()>0 so an empty + // trail can't wipe a previously saved one. + void onShutdown() { + if (!_prefs || !_prefs->trail_autosave_lowbatt || _trail.count() == 0) return; + DataStore* ds = the_mesh.getDataStore(); + if (!ds) return; + File f = ds->openWrite("/trail"); + if (f) { _trail.writeTo(f); f.close(); } + } + + TrailStore& store() { return _trail; } + +private: + NodePrefs* _prefs = nullptr; + const CourseEngine* _course = nullptr; + TrailStore _trail; + uint32_t _next_sample_ms = 0; + // Auto-pause: _pause_ref is the last position the device was considered + // "at"; if it doesn't move beyond TRAIL_AUTOPAUSE_MOVE_M for the configured + // delay, the trail is auto-paused. + int32_t _pause_ref_lat = 0, _pause_ref_lon = 0; + bool _pause_has_ref = false; + uint32_t _last_move_ms = 0; +}; diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index 02b8aa21..c6ab69d6 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -15,6 +15,7 @@ #include "CourseEngine.h" #include "LiveShareEngine.h" #include "LocatorEngine.h" +#include "TrailEngine.h" class UiCore { public: @@ -24,6 +25,7 @@ public: course.begin(sensors); live_share.begin(prefs, &course, &events); locator.begin(prefs, &course, &live_share, &events); + trail.begin(prefs, &course); } // Driven from the frontend's loop(), before it drains `events`. @@ -32,6 +34,7 @@ public: course.loop(); live_share.loop(); locator.loop(); + trail.loop(); } UiEventQueue events; // Core → frontend; drained by the frontend's loop() @@ -42,6 +45,7 @@ public: CourseEngine course; // GPS position + course-over-ground ring LiveShareEngine live_share; // [LOC] auto-share session + peers' shared positions LocatorEngine locator; // active target, geofence crossings, proximity beeper + TrailEngine trail; // GPS trail store, sampling, auto-pause, shutdown save // ── Models ──────────────────────────────────────────────────────────────── MessageHistory history; // channel + DM rings, delivery state, channel unread diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 4d5b2899..34db4fdf 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1662,7 +1662,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no 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); + trail_screen = new TrailScreen(this, &_core->trail.store()); compass_screen = new CompassScreen(this); diag_screen = new DiagnosticsScreen(this); repeater_screen = new RepeaterScreen(this); @@ -2095,18 +2095,8 @@ void UITask::shutdown(bool restart){ the_mesh.saveRTCTime(); the_mesh.flushDirtyContacts(); - // Auto-save the live GPS trail before power-off when the user enabled it - // (Tools › Trail › Settings › Auto-save). This covers the low-battery - // auto-shutdown, which otherwise loses the whole route. Overwrites /trail - // (same file as the manual Trail › Save); guarded on count()>0 so an empty - // trail can't wipe a previously saved one. - if (_node_prefs && _node_prefs->trail_autosave_lowbatt && _trail.count() > 0) { - DataStore* ds = the_mesh.getDataStore(); - if (ds) { - File f = ds->openWrite("/trail"); - if (f) { _trail.writeTo(f); f.close(); } - } - } + // Auto-save the live GPS trail if enabled (covers low-battery auto-shutdown). + _core->trail.onShutdown(); #ifdef PIN_BUZZER /* note: we have a choice here - @@ -2876,7 +2866,7 @@ void UITask::loop() { // and catches up whenever GPS is awake for any other reason. if (_sensors) { bool gps_needed_live = - (_trail.isActive() && !_trail.isPaused()) + _core->trail.isRecording() || (_node_prefs && _node_prefs->loc_share_enabled) || (_node_prefs && _node_prefs->locator_enabled && _node_prefs->locator_has_target) || curr == compass_screen @@ -2891,47 +2881,7 @@ void UITask::loop() { if (_sensors->consumeGpsWakeEvent()) resetLocator(); } - // 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); - // 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); - } - } - - - // UI Core engines (alarm + countdown, COG, live share, locator, …) run + // UI Core engines (alarm + countdown, COG, live share, locator, trail, …) run // regardless of the current screen / display state. Last in the loop, after // the GPS keep-awake check above (a fresh GPS wake re-seeds the locator first). tickCore(); @@ -3062,6 +3012,8 @@ void UITask::onChannelRemoved(uint8_t channel_idx) { // 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). +TrailStore& UITask::trail() { return _core->trail.store(); } + 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); } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index ebed2792..239aa616 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -116,16 +116,7 @@ class UITask : public AbstractUITask { UIScreen* gpio_screen = nullptr; #endif UIScreen* curr = nullptr; - TrailStore _trail; WaypointStore _waypoints; - uint32_t _next_trail_sample_ms = 0; - - // 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; // Runs the UI Core engines and reacts to their events (alert overlay, buzzer, // display wake). Driven from loop() regardless of the current screen. @@ -348,7 +339,7 @@ public: uint32_t timerRemainingMs() const; bool isRinging() const; void dismissRing(); - TrailStore& trail() { return _trail; } + TrailStore& trail(); WaypointStore& waypoints() { return _waypoints; } LiveTrackStore& liveTrack(); // Shared on-screen keyboard — only one screen drives it at a time.