M2 step 6: Tracks, recorded to GPX in the background

`r` in the GNSS App (or `gnss track start|stop`) records a Track to
/gnss/tracks/YYYYMMDD-HHMMSS.gpx: a point every 5 s once moved 5 m
(lib/gnss/track, 7 tests: haversine, the rule, GPX text, the file name).
It keeps recording with the App closed, shows REC in the Status Bar, and
announces start and stop with a Toast. It needs a card and the time;
switching GNSS off stops it. Tracks are written like Captures: past 90 %
card usage, until full. Storage Clean-up gets a GNSS tracks category.

A Track cut short by a reset or power loss has no GPX footer; the GNSS
Service closes such files at the next boot.

Verified on the device: a recorded Track and one interrupted by a reset
both parse as GPX 1.1 on the PC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-04 22:46:57 +02:00
co-authored by Claude Opus 5.5
parent 463ed2dda8
commit d7952612dd
13 changed files with 335 additions and 11 deletions
+75 -3
View File
@@ -1,6 +1,7 @@
#include "services/gnss_service.h"
#include <Arduino.h>
#include <SD.h>
#include <algorithm>
#include <cstdio>
@@ -15,15 +16,18 @@ const char* fixName(gnss::FixType f) {
}
} // namespace
GnssService::GnssService(const Settings& settings, ClockService& clock, EventBus& bus)
: settings_(settings), clock_(clock), bus_(bus) {
GnssService::GnssService(const Settings& settings, ClockService& clock, StorageService& storage, EventBus& bus)
: settings_(settings), clock_(clock), storage_(storage), bus_(bus) {
parser_.onLine = [this](const std::string& line) {
lastLineMs_ = millis();
if (echo_) console.printf("nmea: %s\n", line.c_str());
};
}
void GnssService::start() { open(millis()); }
void GnssService::start() {
open(millis());
storage_.runJob([this]() { closeUnfinishedTracks(); });
}
void GnssService::stop() { close(); }
@@ -46,6 +50,7 @@ void GnssService::wake(uint32_t nowMs) {
}
void GnssService::standby(uint32_t nowMs) {
if (tracking()) stopTrack();
send("PCAS12,65535");
if (active_) console.println("gnss: off (receiver in standby)");
active_ = false;
@@ -83,6 +88,7 @@ void GnssService::tick(uint32_t nowMs) {
console.printf("gnss: Fix %s, %d satellites used\n", fixName(s.fix), s.satellitesUsed);
lastFix_ = s.fix;
}
recordPoint(nowMs);
// GNSS time is the most trusted source (ClockModel): set it with a Fix, then now and then.
if (s.timeValid && s.fix != gnss::FixType::None && (!clockSetMs_ || nowMs - clockSetMs_ >= kClockEveryMs)) {
if (clock_.set(s.utcSeconds(), TimeSource::Gnss) && !clockSetMs_) console.println("gnss: clock set");
@@ -90,6 +96,69 @@ void GnssService::tick(uint32_t nowMs) {
}
}
void GnssService::notify(const std::string& text) {
bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(NotificationLevel::Info)));
}
std::string GnssService::startTrack(uint32_t nowMs) {
if (tracking()) return "";
if (!active_) return "GNSS is off";
if (!storage_.state().present) return "No SD card";
int64_t now = clock_.utcNow();
if (now < 0) return "Waiting for the time (a Fix or Wi-Fi)";
trackPath_ = gnss::trackPath(now);
std::string name = trackPath_.substr(trackPath_.rfind('/') + 1);
name = name.substr(0, name.size() - 4); // without ".gpx"
storage_.appendLine(trackPath_, gnss::gpx::header(name), true);
trackPoints_ = 0;
trackStartMs_ = nowMs;
trackRule_.reset();
console.printf("gnss: Track %s started\n", trackPath_.c_str());
notify("Track started");
return "";
}
void GnssService::stopTrack() {
if (!tracking()) return;
storage_.appendLine(trackPath_, gnss::gpx::footer(), true);
console.printf("gnss: Track %s saved, %d points\n", trackPath_.c_str(), trackPoints_);
notify("Track saved: " + std::to_string(trackPoints_) + (trackPoints_ == 1 ? " point" : " points"));
trackPath_.clear();
}
void GnssService::recordPoint(uint32_t nowMs) {
const gnss::GnssState& s = parser_.state();
if (!tracking() || !s.positionValid || !s.timeValid) return;
if (!trackRule_.due(nowMs, s.latitude, s.longitude)) return;
storage_.appendLine(trackPath_,
gnss::gpx::point(s.latitude, s.longitude, s.altitudeValid, s.altitudeM, s.utcSeconds()), true);
trackPoints_++;
}
// A Track cut short by a power loss or a crash has no footer: GPX readers refuse it. Close it.
void GnssService::closeUnfinishedTracks() {
File dir = SD.open("/gnss/tracks");
if (!dir || !dir.isDirectory()) return;
int closed = 0;
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
std::string name = f.name();
if (f.isDirectory() || name.size() < 4 || name.compare(name.size() - 4, 4, ".gpx") != 0) continue;
char tail[32] = {};
size_t size = f.size();
f.seek(size > sizeof tail - 1 ? size - (sizeof tail - 1) : 0);
f.read(reinterpret_cast<uint8_t*>(tail), sizeof tail - 1);
f.close();
if (gnss::gpx::finished(tail)) continue;
File out = SD.open(("/gnss/tracks/" + name).c_str(), FILE_APPEND);
if (out) {
out.print(("\n" + gnss::gpx::footer() + "\n").c_str());
out.close();
closed++;
}
}
if (closed) console.printf("gnss: closed %d unfinished Track(s)\n", closed);
}
bool GnssService::send(const std::string& body) {
if (!open_) return false;
uint8_t sum = 0;
@@ -123,6 +192,9 @@ void GnssService::printStatus(Print& out, uint32_t nowMs) const {
out.printf(" %s %d (%d)", gnss::constellationName(static_cast<gnss::Constellation>(c)), perSystem[c],
usedPerSystem[c]);
out.println();
if (tracking())
out.printf("gnss: recording %s, %d points, %lu s\n", trackPath_.c_str(), trackPoints_,
(unsigned long)((nowMs - trackStartMs_) / 1000));
out.printf("gnss: up %lu s, first Fix %s, %lu bytes, %lu sentences, %lu bad\n", (unsigned long)((nowMs - openedMs_) / 1000),
firstFixMs_ ? (std::to_string(firstFixMs_ / 1000) + " s").c_str() : "not yet", (unsigned long)bytes_,
(unsigned long)parser_.goodSentences(), (unsigned long)parser_.badSentences());