mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
feat(ui): trail — export saved trail straight from flash without Load
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 <noreply@anthropic.com>
This commit is contained in:
@@ -215,40 +215,89 @@ public:
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dump the trail as a minimal GPX <trk>/<trkseg> document over the given
|
// GPX writers — shared helpers so we can dump from RAM and from flash
|
||||||
// Stream. Segments split on SEG_START flags. Returns bytes written.
|
// through the same formatting code.
|
||||||
// Caller must ensure the stream is ready (e.g. USB serial connected).
|
|
||||||
|
template <typename S>
|
||||||
|
static size_t gpxHeader(S& out, const char* name) {
|
||||||
|
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"));
|
||||||
|
n += out.print(F("<trk><name>"));
|
||||||
|
n += out.print(name);
|
||||||
|
n += out.print(F("</name>\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);
|
||||||
|
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);
|
||||||
|
n += out.write((const uint8_t*)buf, len);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dump the live RAM ring as GPX. Returns bytes written.
|
||||||
template <typename S>
|
template <typename S>
|
||||||
size_t exportGpx(S& out, const char* trk_name = "MeshCore Trail") {
|
size_t exportGpx(S& out, const char* trk_name = "MeshCore Trail") {
|
||||||
size_t total = 0;
|
size_t total = gpxHeader(out, trk_name);
|
||||||
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;
|
bool in_segment = false;
|
||||||
for (int i = 0; i < _count; i++) {
|
for (int i = 0; i < _count; i++) {
|
||||||
const auto& p = at(i);
|
total += gpxPoint(out, at(i), i == 0, in_segment);
|
||||||
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 += gpxFooter(out, in_segment);
|
||||||
total += out.print(F("</trk></gpx>\n"));
|
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>
|
||||||
|
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;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class TrailScreen : public UIScreen {
|
|||||||
// (Start/Stop tracking, Reset). PopupMenu stores label pointers verbatim, so
|
// (Start/Stop tracking, Reset). PopupMenu stores label pointers verbatim, so
|
||||||
// the labels live in member buffers that get refreshed in openActionMenu()
|
// the labels live in member buffers that get refreshed in openActionMenu()
|
||||||
// and after every LEFT/RIGHT cycle.
|
// 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;
|
PopupMenu _action_menu;
|
||||||
uint8_t _act_map[8];
|
uint8_t _act_map[8];
|
||||||
uint8_t _act_count = 0;
|
uint8_t _act_count = 0;
|
||||||
@@ -90,8 +90,9 @@ public:
|
|||||||
else if (act == ACT_SAVE) { handleSave(); }
|
else if (act == ACT_SAVE) { handleSave(); }
|
||||||
else if (act == ACT_LOAD) { handleLoad(); }
|
else if (act == ACT_LOAD) { handleLoad(); }
|
||||||
else if (act == ACT_RESET) { handleReset(); }
|
else if (act == ACT_RESET) { handleReset(); }
|
||||||
else if (act == ACT_EXPORT) { handleExport(); }
|
else if (act == ACT_EXPORT) { handleExport(); }
|
||||||
else /* MIN_DIST / UNITS */ { reopenAt(sel); return true; } // re-open keeping focus
|
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; }
|
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
|
||||||
} else if (res == PopupMenu::CANCELLED) {
|
} else if (res == PopupMenu::CANCELLED) {
|
||||||
@@ -166,18 +167,30 @@ private:
|
|||||||
}
|
}
|
||||||
void handleExport() {
|
void handleExport() {
|
||||||
if (!Serial) { _task->showAlert("Connect USB first", 1200); return; }
|
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.
|
// The companion app multiplexes BLE + USB on the same frame protocol.
|
||||||
// When BLE is connected, USB-receive is ignored and the dump can't
|
// 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
|
// collide. Without a BLE link the app might be on USB itself, so warn
|
||||||
// which case the raw GPX would land mid-frame and confuse it — warn
|
// the user — they may need to reconnect their app afterwards.
|
||||||
// the user but proceed (they may have no app attached at all).
|
|
||||||
size_t n = _store->exportGpx(Serial);
|
|
||||||
char alert[28];
|
char alert[28];
|
||||||
if (_task->hasConnection()) {
|
if (_task->hasConnection()) snprintf(alert, sizeof(alert), "GPX %u B (USB)", (unsigned)n);
|
||||||
snprintf(alert, sizeof(alert), "GPX %u B (USB)", (unsigned)n);
|
else snprintf(alert, sizeof(alert), "GPX %u B - disc. app", (unsigned)n);
|
||||||
} else {
|
|
||||||
snprintf(alert, sizeof(alert), "GPX %u B - disc. app", (unsigned)n);
|
|
||||||
}
|
|
||||||
_task->showAlert(alert, 1500);
|
_task->showAlert(alert, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,12 +218,18 @@ private:
|
|||||||
if (!_store->empty()) {
|
if (!_store->empty()) {
|
||||||
_act_map[_act_count++] = ACT_SAVE; _action_menu.addItem("Save trail");
|
_act_map[_act_count++] = ACT_SAVE; _action_menu.addItem("Save trail");
|
||||||
}
|
}
|
||||||
if (savedTrailExists()) {
|
bool saved = savedTrailExists();
|
||||||
_act_map[_act_count++] = ACT_LOAD; _action_menu.addItem("Load trail");
|
if (saved) {
|
||||||
|
_act_map[_act_count++] = ACT_LOAD; _action_menu.addItem("Load trail");
|
||||||
}
|
}
|
||||||
if (!_store->empty()) {
|
if (!_store->empty()) {
|
||||||
_act_map[_act_count++] = ACT_EXPORT; _action_menu.addItem("Export GPX");
|
_act_map[_act_count++] = ACT_EXPORT; _action_menu.addItem("Export GPX");
|
||||||
_act_map[_act_count++] = ACT_RESET; _action_menu.addItem("Reset trail");
|
}
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user