feat(ui): GPS breadcrumb phase 1 — storage, sampling, Summary view

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 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-25 08:22:12 +02:00
parent ccf45f0c0c
commit adef2fceb9
8 changed files with 289 additions and 7 deletions

View File

@@ -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.

View File

@@ -0,0 +1,140 @@
#pragma once
#include <Arduino.h>
#include <math.h>
#include <stdint.h>
// 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;
};

View File

@@ -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;

View File

@@ -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

View File

@@ -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;
}
};

View File

@@ -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" };

View File

@@ -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) {

View File

@@ -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();