feat(ui): Trail phase 4 — Save/Load to flash + GPX export over USB

Single-slot persistence as agreed. Action popup grows three entries
(conditional on what makes sense for the current state):

- "Save trail"  — writes the current RAM ring to /trail in LittleFS.
- "Load trail"  — only shown when /trail exists; replaces the live ring
                  with the snapshot (any active session ends first).
- "Export GPX"  — dumps the trail as a minimal GPX 1.1 document over
                  Serial so the user can capture it from a USB host.

Plumbing:
- TrailStore gains writeTo / readFrom (template on the file handle, so
  the platform-specific File flavour stays out of Trail.h) plus
  exportGpx(Stream&) for the GPX serializer. Header carries magic
  "TRAL", a version byte, point count, and the accumulated active time
  so elapsed survives the round trip.
- DataStore::openWrite is exposed publicly so callers outside DataStore
  don't have to duplicate the platform open-mode dance.
- MyMesh gains a public getDataStore() accessor.

After Load, _pending_seg_break is set so any subsequent Start opens a
new segment instead of joining the loaded last point.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-26 07:49:17 +02:00
parent bb33a23543
commit 2671e3f764
5 changed files with 154 additions and 1 deletions

View File

@@ -155,6 +155,10 @@ File DataStore::openRead(FILESYSTEM* fs, const char* filename) {
#endif
}
File DataStore::openWrite(const char* filename) {
return ::openWrite(_fs, filename);
}
bool DataStore::removeFile(const char* filename) {
return _fs->remove(filename);
}

View File

@@ -45,6 +45,7 @@ public:
bool deleteBlobByKey(const uint8_t key[], int key_len);
File openRead(const char* filename);
File openRead(FILESYSTEM* fs, const char* filename);
File openWrite(const char* filename);
bool removeFile(const char* filename);
bool removeFile(FILESYSTEM* fs, const char* filename);
uint32_t getStorageUsedKb() const;

View File

@@ -182,6 +182,7 @@ protected:
public:
void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
void saveRTCTime() { _store->saveRTCTime(); }
DataStore* getDataStore() const { return _store; }
bool isAckPending(uint32_t expected_ack) const {
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++)

View File

@@ -157,6 +157,107 @@ public:
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) {
uint32_t magic = SAVE_MAGIC;
if (file.write((uint8_t*)&magic, sizeof(magic)) != sizeof(magic)) return false;
uint8_t ver = SAVE_VERSION;
uint8_t res = 0;
uint16_t cnt = (uint16_t)_count;
file.write(&ver, 1);
file.write(&res, 1);
file.write((uint8_t*)&cnt, sizeof(cnt));
uint32_t accum = currentAccumulatedMs();
file.write((uint8_t*)&accum, sizeof(accum));
for (int i = 0; i < _count; i++) {
file.write((uint8_t*)&at(i), sizeof(TrailPoint));
}
return true;
}
template <typename F>
bool readFrom(F& file) {
uint32_t magic = 0;
if (file.read((uint8_t*)&magic, sizeof(magic)) != (int)sizeof(magic)) return false;
if (magic != SAVE_MAGIC) return false;
uint8_t ver = 0, res = 0;
uint16_t cnt = 0;
uint32_t accum = 0;
file.read(&ver, 1);
file.read(&res, 1);
file.read((uint8_t*)&cnt, sizeof(cnt));
file.read((uint8_t*)&accum, sizeof(accum));
if (ver != SAVE_VERSION || 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;
}
// Dump the trail as a minimal GPX <trk>/<trkseg> document over the given
// Stream. Segments split on SEG_START flags. Returns bytes written.
// Caller must ensure the stream is ready (e.g. USB serial connected).
template <typename S>
size_t exportGpx(S& out, const char* trk_name = "MeshCore Trail") {
size_t total = 0;
total += out.print(F("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"));
total += out.print(F("<gpx version=\"1.1\" creator=\"MeshCore\" "
"xmlns=\"http://www.topografix.com/GPX/1/1\">\n"));
total += out.print(F("<trk><name>"));
total += out.print(trk_name);
total += out.print(F("</name>\n"));
bool in_segment = false;
for (int i = 0; i < _count; i++) {
const auto& p = at(i);
bool seg_start = (i == 0) || (p.flags & TRAIL_FLAG_SEG_START);
if (seg_start) {
if (in_segment) total += out.print(F("</trkseg>\n"));
total += out.print(F("<trkseg>\n"));
in_segment = true;
}
char buf[120];
// RFC 3339 timestamp from UNIX seconds.
time_t t = (time_t)p.ts;
struct tm* gt = gmtime(&t);
int n = 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);
total += out.write((const uint8_t*)buf, n);
}
if (in_segment) total += out.print(F("</trkseg>\n"));
total += out.print(F("</trk></gpx>\n"));
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 (Haversine).
static float haversineMeters(int32_t la1, int32_t lo1, int32_t la2, int32_t lo2) {
const float R = 6371000.0f;

View File

@@ -23,7 +23,7 @@ class TrailScreen : public UIScreen {
// (Start/Stop tracking, Reset). PopupMenu stores label pointers verbatim, so
// the labels live in member buffers that get refreshed in openActionMenu()
// and after every LEFT/RIGHT cycle.
enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_TOGGLE, ACT_RESET };
enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT };
PopupMenu _action_menu;
uint8_t _act_map[8];
uint8_t _act_count = 0;
@@ -87,7 +87,10 @@ public:
if (sel >= 0 && sel < _act_count) {
ActionId act = (ActionId)_act_map[sel];
if (act == ACT_TOGGLE) { handleToggle(); }
else if (act == ACT_SAVE) { handleSave(); }
else if (act == ACT_LOAD) { handleLoad(); }
else if (act == ACT_RESET) { handleReset(); }
else if (act == ACT_EXPORT) { handleExport(); }
else /* MIN_DIST / UNITS */ { reopenAt(sel); return true; } // re-open keeping focus
}
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
@@ -143,6 +146,33 @@ private:
_store->clear();
_task->showAlert("Trail reset", 800);
}
void handleSave() {
DataStore* ds = the_mesh.getDataStore();
if (!ds) { _task->showAlert("FS unavailable", 800); return; }
File f = ds->openWrite(TRAIL_FILE);
if (!f) { _task->showAlert("Save failed", 800); return; }
bool ok = _store->writeTo(f);
f.close();
_task->showAlert(ok ? "Trail saved" : "Save failed", 800);
}
void handleLoad() {
DataStore* ds = the_mesh.getDataStore();
if (!ds) { _task->showAlert("FS unavailable", 800); return; }
File f = ds->openRead(TRAIL_FILE);
if (!f) { _task->showAlert("No saved trail", 800); return; }
bool ok = _store->readFrom(f);
f.close();
_task->showAlert(ok ? "Trail loaded" : "Load failed", 800);
}
void handleExport() {
if (!Serial) { _task->showAlert("Connect USB first", 1000); return; }
size_t n = _store->exportGpx(Serial);
char alert[24];
snprintf(alert, sizeof(alert), "GPX %u B sent", (unsigned)n);
_task->showAlert(alert, 1200);
}
static constexpr const char* TRAIL_FILE = "/trail";
// Refresh the three settings/action labels in-place. Pointer identity is
// preserved, so PopupMenu picks up the new text on the next render.
@@ -164,10 +194,26 @@ private:
_act_map[_act_count++] = ACT_UNITS; _action_menu.addItem(_act_units_label);
_act_map[_act_count++] = ACT_TOGGLE; _action_menu.addItem(_act_toggle_label);
if (!_store->empty()) {
_act_map[_act_count++] = ACT_SAVE; _action_menu.addItem("Save trail");
}
if (savedTrailExists()) {
_act_map[_act_count++] = ACT_LOAD; _action_menu.addItem("Load trail");
}
if (!_store->empty()) {
_act_map[_act_count++] = ACT_EXPORT; _action_menu.addItem("Export GPX");
_act_map[_act_count++] = ACT_RESET; _action_menu.addItem("Reset trail");
}
}
static bool savedTrailExists() {
DataStore* ds = the_mesh.getDataStore();
if (!ds) return false;
File f = ds->openRead(TRAIL_FILE);
bool ok = (bool)f;
if (f) f.close();
return ok;
}
// After Enter on a settings row, the popup auto-closes per PopupMenu's
// semantics. Re-open it with focus restored to that row so the user can
// continue cycling.