diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index f2913f42..b2edb34e 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -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); } diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 1b69e190..227c202c 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -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; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 1d6b8373..47378d76 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -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++) diff --git a/examples/companion_radio/Trail.h b/examples/companion_radio/Trail.h index f6140b4d..8dce4f98 100644 --- a/examples/companion_radio/Trail.h +++ b/examples/companion_radio/Trail.h @@ -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 + 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 + 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 / 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 + 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")); + 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); + } + if (in_segment) total += out.print(F("\n")); + total += out.print(F("\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; diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 318725e2..81699448 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_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.