feat(trail): include waypoints in the GPX export

GPX dumps previously contained only the <trk>. Emit saved waypoints as
<wpt> elements (label → <name>, plus <time> when the RTC was set) before
the track, per GPX 1.1 ordering, in both live and saved-file exports. The
emitter is duck-typed over the waypoint store so Trail.h stays decoupled
from Waypoint.h; labels are XML-escaped. They now import as pins in OsmAnd /
Garmin / GPX Studio alongside the track.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-06-04 15:28:21 +02:00
parent 4f39203790
commit 26097d7d68
3 changed files with 63 additions and 11 deletions

View File

@@ -147,7 +147,7 @@ Then on the device: **Tools Trail** → **Hold Enter** → **Export (live)**
- **macOS/Linux** — `cat /dev/tty.usbmodem* > track.gpx` (stop with Ctrl-C after the dump finishes)
- **Windows** — PuTTY (Serial, 115200) or Arduino IDE Serial Monitor with no line ending; copy the text from `<?xml` to `</gpx>` into a `.gpx` file
Either way, the resulting file imports into OsmAnd, Garmin BaseCamp, GPX Studio, Google Earth, etc.
Saved **waypoints are included** in the export as GPX `<wpt>` elements (with their label as `<name>`), alongside the track — so they show as pins in OsmAnd, Garmin BaseCamp, GPX Studio, Google Earth, etc. Either way, the resulting file imports into all of those.
> [!NOTE]
> If the companion app is connected via **BLE**, the export is safe — BLE and USB operate independently. If connected via **USB**, disconnect the app before exporting.

View File

@@ -226,17 +226,65 @@ public:
// through the same formatting code.
template <typename S>
static size_t gpxHeader(S& out, const char* name) {
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);
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);
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;
@@ -271,10 +319,12 @@ public:
return n;
}
// Dump the live RAM ring as GPX. Returns bytes written.
template <typename S>
size_t exportGpx(S& out, const char* trk_name = "MeshCore Trail") {
size_t total = gpxHeader(out, trk_name);
// 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);
@@ -285,8 +335,8 @@ public:
// 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") {
template <typename F, typename S, typename WP>
static size_t exportGpxFromFile(F& file, S& out, WP& wpts, 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;
@@ -299,7 +349,9 @@ public:
file.read((uint8_t*)&accum, sizeof(accum));
if (ver != SAVE_VERSION || cnt > CAPACITY) return 0;
size_t total = gpxHeader(out, trk_name);
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;

View File

@@ -306,7 +306,7 @@ private:
}
void handleExport() {
if (!Serial) { _task->showAlert("Connect USB first", 1200); return; }
size_t n = _store->exportGpx(Serial);
size_t n = _store->exportGpx(Serial, _task->waypoints());
showExportAlert(n);
}
@@ -316,7 +316,7 @@ private:
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);
size_t n = TrailStore::exportGpxFromFile(f, Serial, _task->waypoints());
f.close();
if (n == 0) { _task->showAlert("Bad saved file", 1000); return; }
showExportAlert(n);