From 129af614a2d100c748bc78b3efac24514af36c86 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 26 May 2026 08:02:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20trail=20=E2=80=94=20export=20saved?= =?UTF-8?q?=20trail=20straight=20from=20flash=20without=20Load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Export saved" item in the action popup (shown when /trail exists). Streams GPX directly from the saved file to USB Serial, skipping the RAM ring entirely — useful for dumping an old recording after reboot when the live ring is empty (or busy with another recording you don't want to overwrite via Load). Refactors exportGpx into three helpers (gpxHeader, gpxPoint, gpxFooter) shared by the RAM and file paths so the markup stays consistent. The file path validates the same TRAL magic + version it accepts on Load. handleExport and the new handleExportSaved both route through showExportAlert so the "GPX N B (USB)" / "GPX N B - disc. app" hint keeps reflecting the BLE-app-collision state described last time. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/Trail.h | 107 +++++++++++++----- examples/companion_radio/ui-new/TrailScreen.h | 51 ++++++--- 2 files changed, 113 insertions(+), 45 deletions(-) diff --git a/examples/companion_radio/Trail.h b/examples/companion_radio/Trail.h index 8dce4f98..cbf6a317 100644 --- a/examples/companion_radio/Trail.h +++ b/examples/companion_radio/Trail.h @@ -215,40 +215,89 @@ public: return true; } - // Dump the trail as a minimal GPX / 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). + // GPX writers — shared helpers so we can dump from RAM and from flash + // through the same formatting code. + + template + static size_t gpxHeader(S& out, const char* name) { + size_t n = 0; + n += out.print(F("\n")); + n += out.print(F("\n")); + n += out.print(F("")); + n += out.print(name); + n += out.print(F("\n")); + return n; + } + + template + static size_t gpxFooter(S& out, bool in_segment) { + size_t n = 0; + if (in_segment) n += out.print(F("\n")); + n += out.print(F("\n")); + return n; + } + + // Emit a single ; opens a on a segment boundary. Updates + // `in_segment` to track open/close pairing. + template + 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("\n")); + n += out.print(F("\n")); + in_segment = true; + } + char buf[120]; + time_t t = (time_t)p.ts; + struct tm* gt = gmtime(&t); + int len = snprintf(buf, sizeof(buf), + "\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); + n += out.write((const uint8_t*)buf, len); + return n; + } + + // Dump the live RAM ring as GPX. Returns bytes written. template size_t exportGpx(S& out, const char* trk_name = "MeshCore Trail") { - size_t total = 0; - total += out.print(F("\n")); - total += out.print(F("\n")); - total += out.print(F("")); - total += out.print(trk_name); - total += out.print(F("\n")); + size_t total = gpxHeader(out, trk_name); 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("\n")); - total += out.print(F("\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), - "\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); + total += gpxPoint(out, at(i), i == 0, in_segment); } - if (in_segment) total += out.print(F("\n")); - total += out.print(F("\n")); + 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 + static size_t exportGpxFromFile(F& file, S& out, const char* trk_name = "MeshCore Trail") { + uint32_t magic = 0; + if (file.read((uint8_t*)&magic, sizeof(magic)) != (int)sizeof(magic)) return 0; + if (magic != SAVE_MAGIC) return 0; + 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 0; + + size_t total = gpxHeader(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; } diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 87a34f5e..fbfb888d 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -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_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT }; + enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED }; PopupMenu _action_menu; uint8_t _act_map[8]; uint8_t _act_count = 0; @@ -90,8 +90,9 @@ public: 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 + else if (act == ACT_EXPORT) { handleExport(); } + else if (act == ACT_EXPORT_SAVED) { handleExportSaved(); } + else /* MIN_DIST / UNITS */ { reopenAt(sel); return true; } // re-open keeping focus } if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; } } else if (res == PopupMenu::CANCELLED) { @@ -166,18 +167,30 @@ private: } void handleExport() { if (!Serial) { _task->showAlert("Connect USB first", 1200); return; } + size_t n = _store->exportGpx(Serial); + showExportAlert(n); + } + + void handleExportSaved() { + if (!Serial) { _task->showAlert("Connect USB first", 1200); return; } + 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; } + size_t n = TrailStore::exportGpxFromFile(f, Serial); + f.close(); + if (n == 0) { _task->showAlert("Bad saved file", 1000); return; } + showExportAlert(n); + } + + void showExportAlert(size_t n) { // The companion app multiplexes BLE + USB on the same frame protocol. // When BLE is connected, USB-receive is ignored and the dump can't - // collide. Without a BLE link the app might be on USB itself, in - // which case the raw GPX would land mid-frame and confuse it — warn - // the user but proceed (they may have no app attached at all). - size_t n = _store->exportGpx(Serial); + // collide. Without a BLE link the app might be on USB itself, so warn + // the user — they may need to reconnect their app afterwards. char alert[28]; - if (_task->hasConnection()) { - snprintf(alert, sizeof(alert), "GPX %u B (USB)", (unsigned)n); - } else { - snprintf(alert, sizeof(alert), "GPX %u B - disc. app", (unsigned)n); - } + if (_task->hasConnection()) snprintf(alert, sizeof(alert), "GPX %u B (USB)", (unsigned)n); + else snprintf(alert, sizeof(alert), "GPX %u B - disc. app", (unsigned)n); _task->showAlert(alert, 1500); } @@ -205,12 +218,18 @@ private: 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"); + bool saved = savedTrailExists(); + if (saved) { + _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"); + _act_map[_act_count++] = ACT_EXPORT; _action_menu.addItem("Export GPX"); + } + if (saved) { + _act_map[_act_count++] = ACT_EXPORT_SAVED; _action_menu.addItem("Export saved"); + } + if (!_store->empty()) { + _act_map[_act_count++] = ACT_RESET; _action_menu.addItem("Reset trail"); } }