From adef2fceb90f45e24c233fd359017884f1a64b54 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 25 May 2026 08:22:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20GPS=20breadcrumb=20phase=201=20?= =?UTF-8?q?=E2=80=94=20storage,=20sampling,=20Summary=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Tools › Breadcrumb. Enter on the screen toggles tracking; while active, UITask::loop samples GPS at the cadence chosen in settings (default 1 min) and drops the point into a 256-entry RAM ring guarded by a min-distance gate (default 25 m). A "G" indicator joins "A" in the status bar with the same blink convention. New storage: - examples/companion_radio/Breadcrumb.h — BreadcrumbStore class - 256 × 12 B = 3 KB RAM (survives auto-off, lost on reboot — explicit flash-slot save comes in phase 4) - Haversine min-delta gate, total distance, elapsed seconds, current km/h, bounding-box helper New screen: - examples/companion_radio/ui-new/BreadcrumbScreen.h — Summary view with Status, Points, Dist (m or km), Time (h:mm), Speed; Enter toggles tracking, Esc returns to Tools Schema bump 0xC0DE0002 → 0xC0DE0003 for two new NodePrefs bytes: breadcrumb_interval_idx (1min default) and breadcrumb_min_delta_idx (25m default). Older saves leave both at zero, which matches the new defaults. DataStore::loadPrefsInt/savePrefs read/write the pair. Logging on/off is a runtime flag inside BreadcrumbStore — not a preference — so reboot returns to the off state cleanly; the user will be able to persist a snapshot to a slot in phase 4. Phase 2 (map view), 3 (point list), 4 (slot save/load, GPX export over USB), and 5 (settings UI) follow incrementally. Co-Authored-By: Claude Sonnet 4.6 --- FEATURES.md | 16 +- examples/companion_radio/Breadcrumb.h | 140 ++++++++++++++++++ examples/companion_radio/DataStore.cpp | 4 + examples/companion_radio/NodePrefs.h | 7 +- .../companion_radio/ui-new/BreadcrumbScreen.h | 80 ++++++++++ examples/companion_radio/ui-new/ToolsScreen.h | 5 +- examples/companion_radio/ui-new/UITask.cpp | 38 +++++ examples/companion_radio/ui-new/UITask.h | 6 + 8 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 examples/companion_radio/Breadcrumb.h create mode 100644 examples/companion_radio/ui-new/BreadcrumbScreen.h diff --git a/FEATURES.md b/FEATURES.md index db39bcd2..fab35400 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -58,12 +58,19 @@ Render layout (250×122 landscape e-ink): Joystick navigation is natural with 6 tiles (UP/DOWN between rows, LEFT/RIGHT within row). -### 📋 GPS breadcrumb - -📋 planned +### 🚧 GPS breadcrumb Tools › Breadcrumb. Periodically samples `(lat, lon, ts)` into a RAM ring buffer; user explicitly saves snapshots to flash. +**Logging is a runtime state**, not a settings value. User starts/stops from the +Tools › Breadcrumb screen. Once active, sampling continues in the background +regardless of which screen is shown, and a `G` indicator appears in the status +bar (analogous to `A` for auto-advert). A reboot resets the active state to +off; the RAM trail is also lost on reboot unless saved to a flash slot first. + +Settings only control sampling cadence and the min-distance gate — they don't +enable/disable the feature. + **Storage model — RAM ring with explicit save** Rationale: auto-off only blanks the display, the firmware keeps running, so the RAM trail survives every idle scenario. Typical use is a single trip start→stop while wearing the device; persisting across reboots is rarely wanted. RAM-only avoids ~1400 flash writes/day and the LittleFS wear that comes with continuous logging. @@ -98,8 +105,9 @@ Statistics computed on the fly walking the ring: - Current speed: dist(last, prev) / (ts[last] - ts[prev]) Settings: -- Settings › GPS › Breadcrumb interval: off / 30 s / 1 min / 5 min / 15 min (default 1 min) +- Settings › GPS › Breadcrumb interval: 30 s / 1 min / 5 min / 15 min (default 1 min) — only the cadence; logging on/off is a Tools toggle - Settings › GPS › Breadcrumb min delta: 5 m / 25 m / 100 m (skip near-stationary samples to keep the ring densely populated with real movement) +- Export format: GPX (standard for GPS tracks; OSMAnd / Garmin compatible) Schema impact: new prefs fields `uint8_t breadcrumb_interval_idx`, `uint8_t breadcrumb_min_delta_idx`. Sentinel bump. The slot files are separate from prefs. diff --git a/examples/companion_radio/Breadcrumb.h b/examples/companion_radio/Breadcrumb.h new file mode 100644 index 00000000..4c142df6 --- /dev/null +++ b/examples/companion_radio/Breadcrumb.h @@ -0,0 +1,140 @@ +#pragma once + +#include +#include +#include + +// RAM-only GPS breadcrumb ring buffer. +// Storage cost: 256 × 12 B = 3 KB. 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. + +struct BreadcrumbPoint { + int32_t lat_1e6; + int32_t lon_1e6; + uint32_t ts; // epoch seconds (RTC) +}; + +class BreadcrumbStore { +public: + static const int CAPACITY = 256; + + // Interval options (seconds) and their settings labels. Index 0 default = 1 min. + static const uint8_t INTERVAL_COUNT = 4; + static uint16_t intervalSecs(uint8_t idx) { + static const uint16_t OPTS[INTERVAL_COUNT] = { 60, 30, 300, 900 }; + return OPTS[idx < INTERVAL_COUNT ? idx : 0]; + } + static const char* intervalLabel(uint8_t idx) { + static const char* L[INTERVAL_COUNT] = { "1 min", "30 s", "5 min", "15 min" }; + return L[idx < INTERVAL_COUNT ? idx : 0]; + } + + // Min-delta (metres) gates samples too close to the previous one. + static const uint8_t MIN_DELTA_COUNT = 3; + static uint16_t minDeltaMeters(uint8_t idx) { + static const uint16_t OPTS[MIN_DELTA_COUNT] = { 25, 5, 100 }; + return OPTS[idx < MIN_DELTA_COUNT ? idx : 0]; + } + static const char* minDeltaLabel(uint8_t idx) { + static const char* L[MIN_DELTA_COUNT] = { "25 m", "5 m", "100 m" }; + return L[idx < MIN_DELTA_COUNT ? idx : 0]; + } + + bool isActive() const { return _active; } + void setActive(bool a) { _active = a; } + + int count() const { return _count; } + bool empty() const { return _count == 0; } + + // i = 0 → oldest entry, i = count()-1 → newest. + const BreadcrumbPoint& at(int i) const { return _buf[(_head + i) % CAPACITY]; } + const BreadcrumbPoint& first() const { return at(0); } + const BreadcrumbPoint& last() const { return at(_count - 1); } + + void clear() { _head = 0; _count = 0; } + + // Returns true if the point was stored (passed the min-delta gate). + bool addPoint(int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, uint16_t min_delta_m) { + if (_count > 0) { + float d = haversineMeters(last().lat_1e6, last().lon_1e6, lat_1e6, lon_1e6); + if (d < (float)min_delta_m) return false; + } + 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; + return true; + } + + // Sum of pairwise Haversine deltas across the whole ring. + uint32_t totalDistanceMeters() const { + float d = 0; + for (int i = 1; i < _count; i++) { + d += haversineMeters(at(i - 1).lat_1e6, at(i - 1).lon_1e6, + at(i).lat_1e6, at(i).lon_1e6); + } + return (uint32_t)d; + } + + // Seconds between the first and last sample (0 if fewer than 2 points). + uint32_t elapsedSeconds() const { + if (_count < 2) return 0; + return last().ts - first().ts; + } + + // Speed in km/h from the last pair of samples; 0 if only one point. + uint16_t currentSpeedKmh() const { + if (_count < 2) return 0; + const auto& a = at(_count - 2); + const auto& b = last(); + float dt = (float)(b.ts - a.ts); + if (dt <= 0) return 0; + float d = haversineMeters(a.lat_1e6, a.lon_1e6, b.lat_1e6, b.lon_1e6); + return (uint16_t)(d / dt * 3.6f); + } + + // Compute bounding box across all points. Returns false if empty. + bool boundingBox(int32_t& min_lat, int32_t& min_lon, + int32_t& max_lat, int32_t& max_lon) const { + if (_count == 0) return false; + min_lat = max_lat = first().lat_1e6; + min_lon = max_lon = first().lon_1e6; + for (int i = 1; i < _count; i++) { + const auto& p = at(i); + if (p.lat_1e6 < min_lat) min_lat = p.lat_1e6; + if (p.lat_1e6 > max_lat) max_lat = p.lat_1e6; + if (p.lon_1e6 < min_lon) min_lon = p.lon_1e6; + if (p.lon_1e6 > max_lon) max_lon = p.lon_1e6; + } + return true; + } + + // Approximate great-circle distance in metres (Haversine). + static float haversineMeters(int32_t la1, int32_t lo1, int32_t la2, int32_t lo2) { + const float R = 6371000.0f; + const float D2R = (float)M_PI / 180.0f; + float lat1 = (la1 / 1.0e6f) * D2R; + float lat2 = (la2 / 1.0e6f) * D2R; + float dlat = ((la2 - la1) / 1.0e6f) * D2R; + float dlon = ((lo2 - lo1) / 1.0e6f) * D2R; + float sdl = sinf(dlat * 0.5f); + float sdo = sinf(dlon * 0.5f); + float a = sdl * sdl + cosf(lat1) * cosf(lat2) * sdo * sdo; + float c = 2.0f * atan2f(sqrtf(a), sqrtf(1.0f - a)); + return R * c; + } + +private: + BreadcrumbPoint _buf[CAPACITY]; + int _head = 0; + int _count = 0; + bool _active = false; +}; diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 961f1748..02689662 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -305,6 +305,8 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no } rd(_prefs.favourite_contacts, sizeof(_prefs.favourite_contacts)); + rd(&_prefs.breadcrumb_interval_idx, sizeof(_prefs.breadcrumb_interval_idx)); + rd(&_prefs.breadcrumb_min_delta_idx, sizeof(_prefs.breadcrumb_min_delta_idx)); // Schema sentinel: bumped on layout changes. Mismatch means an older file // (or a different schema); rd() already zero-inits any fields not present, @@ -403,6 +405,8 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.eink_full_refresh_every, sizeof(_prefs.eink_full_refresh_every)); file.write((uint8_t *)&_prefs.page_order_set, sizeof(_prefs.page_order_set)); file.write((uint8_t *)_prefs.favourite_contacts, sizeof(_prefs.favourite_contacts)); + file.write((uint8_t *)&_prefs.breadcrumb_interval_idx, sizeof(_prefs.breadcrumb_interval_idx)); + file.write((uint8_t *)&_prefs.breadcrumb_min_delta_idx, sizeof(_prefs.breadcrumb_min_delta_idx)); // Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 280939e9..62d6e08e 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -99,11 +99,16 @@ struct NodePrefs { // persisted to file static const uint8_t FAVOURITE_PREFIX_LEN = 6; uint8_t favourite_contacts[FAVOURITES_COUNT][FAVOURITE_PREFIX_LEN]; + // GPS breadcrumb cadence — indices into BreadcrumbStore::intervalSecs / minDeltaMeters. + // Logging on/off is a runtime state (Tools › Breadcrumb), not a persisted preference. + uint8_t breadcrumb_interval_idx; // 0..3: 1min / 30s / 5min / 15min + uint8_t breadcrumb_min_delta_idx; // 0..2: 25m / 5m / 100m + // Tail sentinel written at the end of /new_prefs. Bump the low byte when // adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so // older saves are detected on load and skipped (zero-init defaults kept). // High 24 bits identify the file format; low byte is the schema revision. - static const uint32_t SCHEMA_SENTINEL = 0xC0DE0002; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE0003; // Bit-index for each home page. Used by page_order (entries store bit+1) and // by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage diff --git a/examples/companion_radio/ui-new/BreadcrumbScreen.h b/examples/companion_radio/ui-new/BreadcrumbScreen.h new file mode 100644 index 00000000..52e8f8b1 --- /dev/null +++ b/examples/companion_radio/ui-new/BreadcrumbScreen.h @@ -0,0 +1,80 @@ +#pragma once +// GPS breadcrumb viewer. Tools › Breadcrumb. +// Phase 1: Summary view only. Phases 2/3 add Map and List views. +// Included by UITask.cpp after Breadcrumb store + ToolsScreen. + +#include "../Breadcrumb.h" + +class BreadcrumbScreen : public UIScreen { + UITask* _task; + BreadcrumbStore* _store; + +public: + BreadcrumbScreen(UITask* task, BreadcrumbStore* store) : _task(task), _store(store) {} + + void enter() { /* nothing to reset; live trail stays */ } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width() / 2, 0, "BREADCRUMB"); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); + + const int y0 = display.listStart(); + const int step = display.lineStep(); + + char buf[28]; + + // Status line: ON/OFF and point count. + snprintf(buf, sizeof(buf), "Status: %s", + _store->isActive() ? "tracking" : "stopped"); + display.setCursor(2, y0); + display.print(buf); + + snprintf(buf, sizeof(buf), "Points: %d / %d", _store->count(), BreadcrumbStore::CAPACITY); + display.setCursor(2, y0 + step); + display.print(buf); + + // Distance — show m below 1 km, km otherwise. + uint32_t dist = _store->totalDistanceMeters(); + if (dist < 1000) snprintf(buf, sizeof(buf), "Dist: %lu m", (unsigned long)dist); + else snprintf(buf, sizeof(buf), "Dist: %lu.%02lu km", + (unsigned long)(dist / 1000), + (unsigned long)((dist % 1000) / 10)); + display.setCursor(2, y0 + step * 2); + display.print(buf); + + // Elapsed — h:mm format. + uint32_t es = _store->elapsedSeconds(); + snprintf(buf, sizeof(buf), "Time: %lu:%02lu", + (unsigned long)(es / 3600), + (unsigned long)((es % 3600) / 60)); + display.setCursor(2, y0 + step * 3); + display.print(buf); + + // Speed — current km/h. + snprintf(buf, sizeof(buf), "Speed: %u km/h", (unsigned)_store->currentSpeedKmh()); + display.setCursor(2, y0 + step * 4); + display.print(buf); + + // Hint at the bottom. + int hint_y = display.height() - step; + display.setCursor(2, hint_y); + display.print(_store->isActive() ? "[Ent] stop [Esc] back" : "[Ent] start [Esc] back"); + + return _store->isActive() ? 2000 : 5000; + } + + bool handleInput(char c) override { + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { + _task->gotoToolsScreen(); + return true; + } + if (c == KEY_ENTER) { + _store->setActive(!_store->isActive()); + _task->showAlert(_store->isActive() ? "Tracking started" : "Tracking stopped", 800); + return true; + } + return false; + } +}; diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index 05ffea86..dd60d08a 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -6,7 +6,7 @@ class ToolsScreen : public UIScreen { UITask* _task; int _sel; - static const int ITEM_COUNT = 4; + static const int ITEM_COUNT = 5; static const char* ITEMS[ITEM_COUNT]; public: @@ -44,8 +44,9 @@ public: if (_sel == 1) { _task->gotoBotScreen(); return true; } if (_sel == 2) { _task->gotoNearbyScreen(); return true; } if (_sel == 3) { _task->gotoAutoAdvertScreen(); return true; } + if (_sel == 4) { _task->gotoBreadcrumbScreen(); return true; } } return false; } }; -const char* ToolsScreen::ITEMS[4] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert" }; +const char* ToolsScreen::ITEMS[5] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert", "Breadcrumb" }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 9cc4123c..b11e991d 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -116,6 +116,7 @@ static const int QUICK_MSGS_MAX = 10; #include "NearbyScreen.h" #include "DashboardConfigScreen.h" #include "AutoAdvertScreen.h" +#include "BreadcrumbScreen.h" #include "ToolsScreen.h" // ── HomeScreen ──────────────────────────────────────────────────────────────── @@ -401,6 +402,21 @@ class HomeScreen : public UIScreen { } leftmostX = aX - 1; } + + // "G" indicator — GPS breadcrumb logging active. Same blink convention. + if (_task->breadcrumb().isActive()) { + int gX = leftmostX - ind; + bool show_g = Features::BLINK_INDICATORS ? ((millis() % 4000) < 2000) : true; + if (show_g) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(gX, 0, ind, ind_h); + display.setColor(DisplayDriver::DARK); + display.setCursor(gX + 1, 0); + display.print("G"); + display.setColor(DisplayDriver::LIGHT); + } + leftmostX = gX - 1; + } } return leftmostX; } @@ -1072,6 +1088,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no nearby_screen = new NearbyScreen(this); dashboard_config = new DashboardConfigScreen(this, node_prefs); auto_advert_screen = new AutoAdvertScreen(this, node_prefs); + breadcrumb_screen = new BreadcrumbScreen(this, &_breadcrumb); applyBrightness(); applyFont(); applyRotation(); @@ -1108,6 +1125,11 @@ void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); } +void UITask::gotoBreadcrumbScreen() { + ((BreadcrumbScreen*)breadcrumb_screen)->enter(); + setCurrScreen(breadcrumb_screen); +} + void UITask::gotoAutoAdvertScreen() { ((AutoAdvertScreen*)auto_advert_screen)->enter(); setCurrScreen(auto_advert_screen); @@ -1724,6 +1746,22 @@ void UITask::loop() { } next_batt_chck = millis() + 8000; } + + // GPS breadcrumb 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 (_breadcrumb.isActive() && _node_prefs != NULL + && (int32_t)(millis() - _next_breadcrumb_sample_ms) >= 0) { + uint16_t interval_s = BreadcrumbStore::intervalSecs(_node_prefs->breadcrumb_interval_idx); + _next_breadcrumb_sample_ms = millis() + (uint32_t)interval_s * 1000UL; + LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr; + if (loc && loc->isValid()) { + uint16_t md = BreadcrumbStore::minDeltaMeters(_node_prefs->breadcrumb_min_delta_idx); + _breadcrumb.addPoint((int32_t)loc->getLatitude(), + (int32_t)loc->getLongitude(), + (uint32_t)rtc_clock.getCurrentTime(), md); + } + } } char UITask::checkDisplayOn(char c) { diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index c41bcf07..96d13f18 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -22,6 +22,7 @@ #include "../AbstractUITask.h" #include "../NodePrefs.h" +#include "../Breadcrumb.h" class UITask : public AbstractUITask { DisplayDriver* _display; @@ -73,8 +74,11 @@ class UITask : public AbstractUITask { UIScreen* nearby_screen; UIScreen* dashboard_config; UIScreen* auto_advert_screen; + UIScreen* breadcrumb_screen; UIScreen* curr; CayenneLPP _dash_lpp; + BreadcrumbStore _breadcrumb; + uint32_t _next_breadcrumb_sample_ms = 0; void userLedHandler(); @@ -117,6 +121,8 @@ public: void gotoNearbyScreen(); void gotoDashboardConfig(); void gotoAutoAdvertScreen(); + void gotoBreadcrumbScreen(); + BreadcrumbStore& breadcrumb() { return _breadcrumb; } void playMelody(const char* melody); void stopMelody(); bool isMelodyPlaying();