mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-27 15:28:11 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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++)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user