Files
MeshCore-Solo/examples/companion_radio/Trail.h
MarekZegare4 b8a7b5dcc0 refactor: dedup persistence header, harden action menu, tidy small items
- Persist.h: shared writeHeader()/readHeader() for the magic+version+count
  on-disk header; Waypoint and Trail snapshots now share one implementation
  (byte layout unchanged; each keeps its own count clamp/reject + extra fields).
- TrailScreen: pushAction() guards _act_map / PopupMenu capacity, so adding a
  new action can't silently overrun the fixed array.
- PopupMenu: add count()/setSelected() accessors; replace the remaining direct
  _sel/_count field pokes in NearbyScreen, TrailScreen and QuickMsgScreen.
- Trail summary: unit-suffixed time ("1h 05m" / "5m 03s") so hours can't be
  misread as minutes.
- Trail::haversineMeters delegates to geo::haversineKm — single Haversine
  implementation (also trims a little flash).

Verified: GAT562 (SSD1306) and WioTrackerL1Eink (GxEPD) solo builds compile clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 13:37:03 +02:00

371 lines
14 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#pragma once
#include <Arduino.h>
#include <math.h>
#include <stdint.h>
#include <time.h>
#include "Persist.h"
#include "GeoUtils.h"
// RAM-only GPS trail ring buffer.
// Storage cost: CAPACITY(512) × sizeof(TrailPoint)(16 B, padded) = 8 KB,
// always resident (UITask::_trail member). 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 TrailPoint {
int32_t lat_1e6;
int32_t lon_1e6;
uint32_t ts; // epoch seconds (RTC)
uint8_t flags; // bit 0 = SEG_START (don't draw a line from the previous point)
};
static const uint8_t TRAIL_FLAG_SEG_START = 0x01;
class TrailStore {
public:
static const int CAPACITY = 512;
// Fixed sampling cadence — matches the sensor manager's default GPS update
// rate (1 s). Density is controlled by the min-delta gate (settings) rather
// than by throttling the GPS poll. The NodePrefs::trail_interval_idx field
// is retained as reserved for backwards compatibility but no longer used.
static const uint16_t SAMPLING_SECS = 1;
// Min-delta (metres) gates samples too close to the previous one.
// Default 5 m: keeps walking jitter out, dense enough for a visible trail.
// The index is a unit-agnostic "level" (0=finest … 3=coarsest); the actual
// gate distance and its label follow the global metric/imperial preference.
static const uint8_t MIN_DELTA_COUNT = 4;
static uint16_t minDeltaMeters(uint8_t idx, bool imperial) {
static const uint16_t MET[MIN_DELTA_COUNT] = { 5, 10, 25, 100 };
static const uint16_t IMP[MIN_DELTA_COUNT] = { 5, 9, 23, 91 }; // ≈ 15/30/75/300 ft
if (idx >= MIN_DELTA_COUNT) idx = 0;
return imperial ? IMP[idx] : MET[idx];
}
static const char* minDeltaLabel(uint8_t idx, bool imperial) {
static const char* MET[MIN_DELTA_COUNT] = { "5 m", "10 m", "25 m", "100 m" };
static const char* IMP[MIN_DELTA_COUNT] = { "15 ft", "30 ft", "75 ft", "300 ft" };
if (idx >= MIN_DELTA_COUNT) idx = 0;
return imperial ? IMP[idx] : MET[idx];
}
// Speed / pace display units. UNITS_KMH / UNITS_MPH show speed; UNITS_PACE_KM
// / UNITS_PACE_MI show time per distance ("pace"). Index 0 = km/h default.
enum Units : uint8_t {
UNITS_KMH = 0,
UNITS_MPH = 1,
UNITS_PACE_KM = 2,
UNITS_PACE_MI = 3,
};
static const uint8_t UNITS_COUNT = 4;
static const char* unitLabel(uint8_t idx) {
static const char* L[UNITS_COUNT] = { "km/h", "mph", "min/km", "min/mi" };
return L[idx < UNITS_COUNT ? idx : 0];
}
static bool unitIsPace(uint8_t idx) { return idx == UNITS_PACE_KM || idx == UNITS_PACE_MI; }
bool isActive() const { return _active; }
void setActive(bool a) {
if (a && !_active) {
// off → on: start a new session timer.
_session_start_ms = millis();
} else if (_active && !a) {
// on → off: bank the elapsed of this session and arm a segment break
// so the renderer doesn't draw a straight line through the dead time.
if (_session_start_ms != 0) {
_accumulated_ms += millis() - _session_start_ms;
_session_start_ms = 0;
}
_pending_seg_break = true;
}
_active = a;
}
int count() const { return _count; }
bool empty() const { return _count == 0; }
// i = 0 → oldest entry, i = count()-1 → newest.
const TrailPoint& at(int i) const { return _buf[(_head + i) % CAPACITY]; }
const TrailPoint& first() const { return at(0); }
const TrailPoint& last() const { return at(_count - 1); }
void clear() {
_head = 0; _count = 0;
_pending_seg_break = false;
_accumulated_ms = 0;
_session_start_ms = 0;
}
// Returns true if the point was stored (passed the min-delta gate).
// First point of the ring and the first point after a stop/start cycle
// get flagged TRAIL_FLAG_SEG_START so the map renderer breaks the line.
bool addPoint(int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, uint16_t min_delta_m) {
if (_count > 0 && !_pending_seg_break) {
float d = haversineMeters(last().lat_1e6, last().lon_1e6, lat_1e6, lon_1e6);
if (d < (float)min_delta_m) return false;
}
uint8_t flags = (_count == 0 || _pending_seg_break) ? TRAIL_FLAG_SEG_START : 0;
_pending_seg_break = 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;
_buf[pos].flags = flags;
return true;
}
// Sum of pairwise Haversine deltas across the whole ring, skipping segment
// boundaries (a SEG_START point isn't reached from its predecessor).
uint32_t totalDistanceMeters() const {
float d = 0;
for (int i = 1; i < _count; i++) {
if (at(i).flags & TRAIL_FLAG_SEG_START) continue;
d += haversineMeters(at(i - 1).lat_1e6, at(i - 1).lon_1e6,
at(i).lat_1e6, at(i).lon_1e6);
}
return (uint32_t)d;
}
// Cumulative active tracking time across all start→stop sessions, in
// seconds. Counts ticks while the trail is on, freezes while it's off.
// millis()-based so it doesn't depend on RTC sync.
uint32_t elapsedSeconds() const {
uint32_t ms = _accumulated_ms;
if (_active && _session_start_ms != 0) ms += millis() - _session_start_ms;
return ms / 1000;
}
// Average speed in km/h = total distance / cumulative active time.
uint16_t avgSpeedKmh() const {
uint32_t es = elapsedSeconds();
if (es == 0) return 0;
return (uint16_t)((float)totalDistanceMeters() / (float)es * 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;
}
// Persistent snapshot — single slot at the given filesystem path.
// Layout: 4-byte magic "TRAL", uint8 version, uint8 reserved, uint16 count,
// uint32 accumulated_ms, then `count` raw TrailPoint records. count is
// clamped to CAPACITY on load.
static const uint32_t SAVE_MAGIC = 0x4C415254; // "TRAL"
static const uint8_t SAVE_VERSION = 1;
// Caller supplies an opened, writable File (the FS-open call is
// platform-specific). Returns true if the header and every point wrote
// cleanly. The file is left open for the caller to close.
template <typename F>
bool writeTo(F& file) {
if (!persist::writeHeader(file, SAVE_MAGIC, SAVE_VERSION, (uint16_t)_count)) return false;
uint32_t accum = currentAccumulatedMs();
if (file.write((uint8_t*)&accum, sizeof(accum)) != sizeof(accum)) return false;
for (int i = 0; i < _count; i++) {
if (file.write((uint8_t*)&at(i), sizeof(TrailPoint)) != sizeof(TrailPoint)) return false;
}
return true;
}
template <typename F>
bool readFrom(F& file) {
uint16_t cnt = 0;
uint32_t accum = 0;
if (!persist::readHeader(file, SAVE_MAGIC, SAVE_VERSION, cnt)) return false;
if (file.read((uint8_t*)&accum, sizeof(accum)) != (int)sizeof(accum)) return false;
if (cnt > CAPACITY) return false;
if (_active) {
_active = false;
_session_start_ms = 0;
}
_head = 0;
_count = 0;
for (int i = 0; i < cnt; i++) {
TrailPoint p;
int n = file.read((uint8_t*)&p, sizeof(TrailPoint));
if (n != (int)sizeof(TrailPoint)) break;
_buf[_count++] = p;
}
_accumulated_ms = accum;
_pending_seg_break = true;
return true;
}
// GPX writers — shared helpers so we can dump from RAM and from flash
// through the same formatting code.
template <typename S>
static size_t gpxHeader(S& out) {
size_t n = 0;
n += out.print(F("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"));
n += out.print(F("<gpx version=\"1.1\" creator=\"MeshCore\" "
"xmlns=\"http://www.topografix.com/GPX/1/1\">\n"));
return n;
}
template <typename S>
static size_t gpxTrackOpen(S& out, const char* name) {
size_t n = 0;
n += out.print(F("<trk><name>"));
n += out.print(name);
n += out.print(F("</name>\n"));
return n;
}
// Emit saved waypoints as <wpt> elements. In GPX 1.1 these must precede the
// <trk>. Duck-typed over any store exposing count()/at(i) whose entries have
// lat_1e6 / lon_1e6 / ts / label, so Trail.h stays decoupled from Waypoint.h.
template <typename S, typename WP>
static size_t gpxWaypoints(S& out, WP& store) {
size_t n = 0;
for (int i = 0; i < store.count(); i++) {
const auto& w = store.at(i);
// XML-escape the user label (&, <, > only).
char esc[64]; int e = 0;
for (const char* p = w.label; *p && e < (int)sizeof(esc) - 6; p++) {
if (*p == '&') { memcpy(esc + e, "&amp;", 5); e += 5; }
else if (*p == '<') { memcpy(esc + e, "&lt;", 4); e += 4; }
else if (*p == '>') { memcpy(esc + e, "&gt;", 4); e += 4; }
else esc[e++] = *p;
}
esc[e] = '\0';
char buf[160];
int len = snprintf(buf, sizeof(buf),
"<wpt lat=\"%.6f\" lon=\"%.6f\"><name>%s</name>",
w.lat_1e6 / 1.0e6, w.lon_1e6 / 1.0e6, esc);
if (len > 0) {
if ((size_t)len >= sizeof(buf)) len = sizeof(buf) - 1; // truncated: emit chars only, not the NUL
n += out.write((const uint8_t*)buf, (size_t)len);
}
if (w.ts > 1000000000UL) { // append <time> when the RTC was set
time_t t = (time_t)w.ts;
struct tm* gt = ::gmtime(&t);
if (gt) {
len = snprintf(buf, sizeof(buf),
"<time>%04d-%02d-%02dT%02d:%02d:%02dZ</time>",
gt->tm_year + 1900, gt->tm_mon + 1, gt->tm_mday,
gt->tm_hour, gt->tm_min, gt->tm_sec);
if (len > 0) { if ((size_t)len >= sizeof(buf)) len = sizeof(buf) - 1;
n += out.write((const uint8_t*)buf, (size_t)len); }
}
}
n += out.print(F("</wpt>\n"));
}
return n;
}
template <typename S>
static size_t gpxFooter(S& out, bool in_segment) {
size_t n = 0;
if (in_segment) n += out.print(F("</trkseg>\n"));
n += out.print(F("</trk></gpx>\n"));
return n;
}
// Emit a single <trkpt>; opens a <trkseg> on a segment boundary. Updates
// `in_segment` to track open/close pairing.
template <typename S>
static size_t gpxPoint(S& out, const TrailPoint& p, bool first, bool& in_segment) {
size_t n = 0;
bool seg_start = first || (p.flags & TRAIL_FLAG_SEG_START);
if (seg_start) {
if (in_segment) n += out.print(F("</trkseg>\n"));
n += out.print(F("<trkseg>\n"));
in_segment = true;
}
char buf[120];
time_t t = (time_t)p.ts;
struct tm* gt = ::gmtime(&t);
if (!gt) return n; // defensive: skip malformed timestamps
int len = snprintf(buf, sizeof(buf),
"<trkpt lat=\"%.6f\" lon=\"%.6f\"><time>%04d-%02d-%02dT%02d:%02d:%02dZ</time></trkpt>\n",
p.lat_1e6 / 1.0e6, p.lon_1e6 / 1.0e6,
gt->tm_year + 1900, gt->tm_mon + 1, gt->tm_mday,
gt->tm_hour, gt->tm_min, gt->tm_sec);
if (len < 0) return n;
if ((size_t)len >= sizeof(buf)) len = sizeof(buf) - 1; // truncated: emit chars only, not the NUL
n += out.write((const uint8_t*)buf, (size_t)len);
return n;
}
// Dump the live RAM ring as GPX (with saved waypoints). Returns bytes written.
template <typename S, typename WP>
size_t exportGpx(S& out, WP& wpts, const char* trk_name = "MeshCore Trail") {
size_t total = gpxHeader(out);
total += gpxWaypoints(out, wpts);
total += gpxTrackOpen(out, trk_name);
bool in_segment = false;
for (int i = 0; i < _count; i++) {
total += gpxPoint(out, at(i), i == 0, in_segment);
}
total += gpxFooter(out, in_segment);
return total;
}
// Stream a saved trail straight from the open file as GPX without
// touching the live RAM ring. Returns 0 on format mismatch.
template <typename F, typename S, typename WP>
static size_t exportGpxFromFile(F& file, S& out, WP& wpts, const char* trk_name = "MeshCore Trail") {
uint16_t cnt = 0;
uint32_t accum = 0;
if (!persist::readHeader(file, SAVE_MAGIC, SAVE_VERSION, cnt)) return 0;
if (file.read((uint8_t*)&accum, sizeof(accum)) != (int)sizeof(accum)) return 0;
if (cnt > CAPACITY) return 0;
size_t total = gpxHeader(out);
total += gpxWaypoints(out, wpts);
total += gpxTrackOpen(out, trk_name);
bool in_segment = false;
for (uint16_t i = 0; i < cnt; i++) {
TrailPoint p;
int n = file.read((uint8_t*)&p, sizeof(TrailPoint));
if (n != (int)sizeof(TrailPoint)) break;
total += gpxPoint(out, p, i == 0, in_segment);
}
total += gpxFooter(out, in_segment);
return total;
}
uint32_t currentAccumulatedMs() const {
uint32_t ms = _accumulated_ms;
if (_active && _session_start_ms != 0) ms += millis() - _session_start_ms;
return ms;
}
// Approximate great-circle distance in metres. Delegates to the shared
// geo:: Haversine (km) so there is a single implementation.
static float haversineMeters(int32_t la1, int32_t lo1, int32_t la2, int32_t lo2) {
return geo::haversineKm(la1, lo1, la2, lo2) * 1000.0f;
}
private:
TrailPoint _buf[CAPACITY];
int _head = 0;
int _count = 0;
bool _active = false;
bool _pending_seg_break = false; // next addPoint flags itself SEG_START
uint32_t _accumulated_ms = 0; // banked active time across previous sessions
uint32_t _session_start_ms = 0; // millis() of the current active session, 0 if none
};