From d7952612dd1cd2b7912fa4fe6ff6be9e0b2a6c35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Sun, 4 Oct 2026 22:46:57 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- lib/gnss/src/track.cpp | 76 +++++++++++++++++++++++++++ lib/gnss/src/track.h | 40 ++++++++++++++ lib/storage_model/src/cleanup_plan.h | 1 + src/apps/gnss_app.cpp | 24 +++++++++ src/apps/gnss_app.h | 4 ++ src/main.cpp | 10 +++- src/services/gnss_service.cpp | 78 ++++++++++++++++++++++++++-- src/services/gnss_service.h | 20 ++++++- src/services/storage_service.cpp | 5 +- src/services/storage_service.h | 5 +- src/ui/widgets.cpp | 1 + src/ui/widgets.h | 4 +- test/test_track/test_track.cpp | 78 ++++++++++++++++++++++++++++ 13 files changed, 335 insertions(+), 11 deletions(-) create mode 100644 lib/gnss/src/track.cpp create mode 100644 lib/gnss/src/track.h create mode 100644 test/test_track/test_track.cpp diff --git a/lib/gnss/src/track.cpp b/lib/gnss/src/track.cpp new file mode 100644 index 0000000..0a791c5 --- /dev/null +++ b/lib/gnss/src/track.cpp @@ -0,0 +1,76 @@ +#include "track.h" + +#include +#include +#include + +namespace roro::gnss { + +namespace { + +constexpr double kEarthRadiusM = 6371000.0; + +double radians(double degrees) { return degrees * M_PI / 180.0; } + +// UTC fields from seconds since 1970 (gmtime_r works on the device and the PC alike). +struct tm utc(int64_t seconds) { + time_t t = static_cast(seconds); + struct tm out; + gmtime_r(&t, &out); + return out; +} + +} // namespace + +double distanceMeters(double lat1, double lon1, double lat2, double lon2) { + double dLat = radians(lat2 - lat1), dLon = radians(lon2 - lon1); + double h = std::sin(dLat / 2) * std::sin(dLat / 2) + + std::cos(radians(lat1)) * std::cos(radians(lat2)) * std::sin(dLon / 2) * std::sin(dLon / 2); + return 2 * kEarthRadiusM * std::asin(std::sqrt(h)); +} + +bool TrackRule::due(uint32_t nowMs, double lat, double lon) { + if (any_ && (nowMs - lastMs_ < kEveryMs || distanceMeters(lat_, lon_, lat, lon) < kMinMeters)) return false; + any_ = true; + lastMs_ = nowMs; + lat_ = lat; + lon_ = lon; + return true; +} + +std::string trackPath(int64_t utcSeconds) { + struct tm t = utc(utcSeconds); + char buf[48]; + std::snprintf(buf, sizeof buf, "/gnss/tracks/%04d%02d%02d-%02d%02d%02d.gpx", t.tm_year + 1900, t.tm_mon + 1, + t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec); + return buf; +} + +namespace gpx { + +std::string header(const std::string& name) { + return "\n" + "\n" + "" + name + ""; +} + +std::string point(double lat, double lon, bool hasElevation, float elevationM, int64_t utcSeconds) { + struct tm t = utc(utcSeconds); + char buf[160], ele[32] = ""; + if (hasElevation) std::snprintf(ele, sizeof ele, "%.1f", elevationM); + std::snprintf(buf, sizeof buf, + "%s", lat, lon, + ele, t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec); + return buf; +} + +std::string footer() { return ""; } + +bool finished(const std::string& tail) { + size_t end = tail.find_last_not_of(" \r\n\t"); + return end != std::string::npos && end + 1 >= 6 && tail.compare(end + 1 - 6, 6, "") == 0; +} + +} // namespace gpx + +} // namespace roro::gnss diff --git a/lib/gnss/src/track.h b/lib/gnss/src/track.h new file mode 100644 index 0000000..1e09bc7 --- /dev/null +++ b/lib/gnss/src/track.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +namespace roro::gnss { + +// Great-circle distance in metres (haversine, mean Earth radius). +double distanceMeters(double lat1, double lon1, double lat2, double lon2); + +// When a Track takes its next point (Q63): the first one always, then at least every 5 s and only +// after moving at least 5 m, so standing still doesn't fill the card. +class TrackRule { + public: + static constexpr uint32_t kEveryMs = 5000; + static constexpr double kMinMeters = 5.0; + + // True if this position should be recorded (and is then remembered as the last point). + bool due(uint32_t nowMs, double lat, double lon); + void reset() { any_ = false; } + + private: + bool any_ = false; + uint32_t lastMs_ = 0; + double lat_ = 0, lon_ = 0; +}; + +// "/gnss/tracks/YYYYMMDD-HHMMSS.gpx", from the UTC start time (Storage Clean-up reads its date). +std::string trackPath(int64_t utcSeconds); + +// GPX 1.1, written a line at a time: the header when a Track starts, a point per line, the footer +// when it stops. A file whose footer never came (power lost) is closed at the next boot. +namespace gpx { +std::string header(const std::string& name); +std::string point(double lat, double lon, bool hasElevation, float elevationM, int64_t utcSeconds); +std::string footer(); +bool finished(const std::string& tail); // the end of a file: does it close the GPX? +} // namespace gpx + +} // namespace roro::gnss diff --git a/lib/storage_model/src/cleanup_plan.h b/lib/storage_model/src/cleanup_plan.h index c548e7e..ec3f346 100644 --- a/lib/storage_model/src/cleanup_plan.h +++ b/lib/storage_model/src/cleanup_plan.h @@ -20,6 +20,7 @@ inline const CleanupCategory kCleanupCategories[] = { {"IRC logs", "/irc"}, {"Wi-Fi scan logs", "/wifi/scans"}, {"Wi-Fi captures", "/captures/wifi"}, + {"GNSS tracks", "/gnss/tracks"}, }; enum class CleanupAge { OneMonth, ThreeMonths, SixMonths, OneYear, Everything }; diff --git a/src/apps/gnss_app.cpp b/src/apps/gnss_app.cpp index 0c6299c..a92a381 100644 --- a/src/apps/gnss_app.cpp +++ b/src/apps/gnss_app.cpp @@ -46,6 +46,15 @@ bool GnssApp::onKey(const KeyEvent& e) { requestRedraw(); return true; } + if (e.key == Key::Char && (e.ch == 'r' || e.ch == 'R')) { // record a Track, or stop it + if (gnss_.tracking()) gnss_.stopTrack(); + else { + refusal_ = gnss_.startTrack(millis()); + refusalMs_ = millis(); + } + requestRedraw(); + return true; + } return false; // Back leaves the App } @@ -123,6 +132,21 @@ void GnssApp::drawPosition(Canvas& c) { row("HDOP", s.fix == FixType::None ? "-" : line); if (s.timeValid) std::snprintf(line, sizeof line, "%02d:%02d:%02d UTC", s.hour, s.minute, s.second); row("Time", s.timeValid ? line : "-"); + + // The Track line, at the bottom left. + c.setFont(&fonts::small); + if (gnss_.tracking()) { + c.setTextColor(theme::kWarning); + std::snprintf(line, sizeof line, "REC %d point%s, %s r: stop", gnss_.trackPoints(), + gnss_.trackPoints() == 1 ? "" : "s", duration(now - gnss_.trackStartMs()).c_str()); + } else if (!refusal_.empty() && now - refusalMs_ < 4000) { + c.setTextColor(theme::kWarning); + std::snprintf(line, sizeof line, "%s", refusal_.c_str()); + } else { + c.setTextColor(theme::kMuted); + std::snprintf(line, sizeof line, "r: record a Track"); + } + c.drawString(line, 4, area.y + area.h - 9); } void GnssApp::drawSky(Canvas& c) { diff --git a/src/apps/gnss_app.h b/src/apps/gnss_app.h index 67c833b..8ca2a9b 100644 --- a/src/apps/gnss_app.h +++ b/src/apps/gnss_app.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "app.h" #include "services/gnss_service.h" #include "settings.h" @@ -24,6 +26,8 @@ class GnssApp : public App { const Settings& settings_; bool sky_ = false; uint32_t lastDrawMs_ = 0; + std::string refusal_; // why a Track couldn't start, shown for a few seconds + uint32_t refusalMs_ = 0; }; } // namespace roro diff --git a/src/main.cpp b/src/main.cpp index 08edeea..3c56d0c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -99,6 +99,7 @@ static StatusInfo currentStatus() { : g.fix == gnss::FixType::TwoD ? StatusInfo::Gnss::TwoD : StatusInfo::Gnss::ThreeD; s.gnssSatellites = g.satellitesUsed; + s.tracking = gnssService->tracking(); } using WifiState = WifiController::State; switch (wifi->state()) { @@ -141,7 +142,7 @@ void setup() { storageService = new StorageService(bus); power = new PowerService(settings); clockService = new ClockService(settings, bus); - gnssService = new GnssService(settings, *clockService, bus); + gnssService = new GnssService(settings, *clockService, *storageService, bus); savedNetworks = new SavedNetworks(nvs); savedNetworks->load(); wifi = new WifiService(settings, *savedNetworks, *clockService); @@ -338,7 +339,7 @@ static const char* const kHelp = "reboot restart\n" "boot other restart into the other app slot (manual Rollback)\n" "log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n" - "gnss status | gnss restart | gnss nmea on|off | gnss send \n" + "gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send \n" "crash the last crash: firmware, reason, task, backtrace\n" "coredump erase forget the core dump in flash\n" "key press a key: up down left right select back home del tab, or one character\n" @@ -403,6 +404,11 @@ static void runCommand(String line) { if (line == "gnss status" && gnssService) gnssService->printStatus(console, millis()); if ((line == "gnss nmea on" || line == "gnss nmea off") && gnssService) gnssService->setEcho(line.endsWith("on")); if (line == "gnss restart" && gnssService) gnssService->restart(millis()); + if (line == "gnss track start" && gnssService) { + std::string why = gnssService->startTrack(millis()); + console.printf("gnss track: %s\n", why.empty() ? "recording" : why.c_str()); + } + if (line == "gnss track stop" && gnssService) gnssService->stopTrack(); if (line.startsWith("gnss send ") && gnssService) // the checksum is added console.println(gnssService->send(line.substring(10).c_str()) ? "gnss: sent" : "gnss: off"); if (line == "crash") crash_report::print(console, nvs); diff --git a/src/services/gnss_service.cpp b/src/services/gnss_service.cpp index 96ac751..82c9775 100644 --- a/src/services/gnss_service.cpp +++ b/src/services/gnss_service.cpp @@ -1,6 +1,7 @@ #include "services/gnss_service.h" #include +#include #include #include @@ -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(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(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(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()); diff --git a/src/services/gnss_service.h b/src/services/gnss_service.h index 9564446..50ba80a 100644 --- a/src/services/gnss_service.h +++ b/src/services/gnss_service.h @@ -8,7 +8,9 @@ #include "nmea_parser.h" #include "service.h" #include "services/clock_service.h" +#include "services/storage_service.h" #include "settings.h" +#include "track.h" namespace roro { @@ -25,7 +27,7 @@ class GnssService : public Service { // command wakes it. Standby is renewed while off, in case the duration is clamped. static constexpr uint32_t kStandbyRenewMs = 3600000; - GnssService(const Settings& settings, ClockService& clock, EventBus& bus); + GnssService(const Settings& settings, ClockService& clock, StorageService& storage, EventBus& bus); const char* name() const override { return "gnss"; } uint32_t tickIntervalMs() const override { return 50; } void start() override; @@ -42,6 +44,14 @@ class GnssService : public Service { void setEcho(bool echo) { echo_ = echo; } void printStatus(Print& out, uint32_t nowMs) const; bool send(const std::string& body); + // Tracks (Q63): GPX on the SD card, a point every 5 s once moved 5 m, recorded in the + // background whatever App is open. startTrack() returns why it can't, or "". + std::string startTrack(uint32_t nowMs); + void stopTrack(); + bool tracking() const { return !trackPath_.empty(); } + int trackPoints() const { return trackPoints_; } + uint32_t trackStartMs() const { return trackStartMs_; } + void restart(uint32_t nowMs) { close(); open(nowMs); @@ -51,16 +61,24 @@ class GnssService : public Service { void open(uint32_t nowMs); // the UART; the receiver's state follows the setting in tick() void close(); void wake(uint32_t nowMs); + void recordPoint(uint32_t nowMs); + void closeUnfinishedTracks(); // on the storage task + void notify(const std::string& text); void standby(uint32_t nowMs); const Settings& settings_; ClockService& clock_; + StorageService& storage_; EventBus& bus_; gnss::NmeaParser parser_; bool open_ = false, active_ = false, echo_ = false; uint32_t bytes_ = 0, standbyMs_ = 0; uint32_t openedMs_ = 0, lastLineMs_ = 0, firstFixMs_ = 0, clockSetMs_ = 0; gnss::FixType lastFix_ = gnss::FixType::None; + std::string trackPath_; + int trackPoints_ = 0; + uint32_t trackStartMs_ = 0; + gnss::TrackRule trackRule_; }; } // namespace roro diff --git a/src/services/storage_service.cpp b/src/services/storage_service.cpp index 16be963..012f099 100644 --- a/src/services/storage_service.cpp +++ b/src/services/storage_service.cpp @@ -32,10 +32,11 @@ StorageState StorageService::state() const { return copy; } -void StorageService::appendLine(const std::string& path, const std::string& line) { +void StorageService::appendLine(const std::string& path, const std::string& line, bool capture) { lock(); size_t bytes = path.size() + line.size(); - bool accept = monitor_.state().logsAllowed && pendingBytes_ + bytes <= kMaxPendingBytes; + bool allowed = capture ? monitor_.state().capturesAllowed : monitor_.state().logsAllowed; + bool accept = allowed && pendingBytes_ + bytes <= kMaxPendingBytes; if (accept) { pending_.emplace_back(path, line); pendingBytes_ += bytes; diff --git a/src/services/storage_service.h b/src/services/storage_service.h index 73c2bad..6f9327d 100644 --- a/src/services/storage_service.h +++ b/src/services/storage_service.h @@ -29,8 +29,9 @@ class StorageService : public Service { StorageState state() const; // Queues one line for a Log file (created with its folders as needed). Dropped while Logs are - // paused (over 90 % full, or no card) or if too much is already waiting. - void appendLine(const std::string& path, const std::string& line); + // paused (over 90 % full, or no card) or if too much is already waiting. A Capture (or a Track, + // something the user started) keeps going past 90 %, until the card is full. + void appendLine(const std::string& path, const std::string& line, bool capture = false); uint32_t droppedLines() const { return dropped_; } // Storage Clean-up: list every file of every category, then delete a selection. diff --git a/src/ui/widgets.cpp b/src/ui/widgets.cpp index 507984d..2afc3b1 100644 --- a/src/ui/widgets.cpp +++ b/src/ui/widgets.cpp @@ -48,6 +48,7 @@ void statusBar(Canvas& c, const StatusInfo& info) { case StatusInfo::Wifi::Monitoring: right("MON", kAccent); break; case StatusInfo::Wifi::None: break; } + if (info.tracking) right("REC", kWarning); switch (info.gnss) { // Q61: muted while searching, normal with a Fix, the count with a 3D Fix case StatusInfo::Gnss::Searching: right("G", kMuted); break; case StatusInfo::Gnss::TwoD: right("G", kText); break; diff --git a/src/ui/widgets.h b/src/ui/widgets.h index d30f24d..847e42f 100644 --- a/src/ui/widgets.h +++ b/src/ui/widgets.h @@ -26,11 +26,13 @@ struct StatusInfo { int unread = 0; // unread messages across Services enum class Gnss { None, Searching, TwoD, ThreeD } gnss = Gnss::None; // None: GNSS off (Q61) int gnssSatellites = 0; // used in the Fix + bool tracking = false; // a Track is recording (Q63) bool operator==(const StatusInfo& o) const { return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock && sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose && wifi == o.wifi && - wifiBars == o.wifiBars && unread == o.unread && gnss == o.gnss && gnssSatellites == o.gnssSatellites; + wifiBars == o.wifiBars && unread == o.unread && gnss == o.gnss && gnssSatellites == o.gnssSatellites && + tracking == o.tracking; } bool operator!=(const StatusInfo& o) const { return !(*this == o); } }; diff --git a/test/test_track/test_track.cpp b/test/test_track/test_track.cpp new file mode 100644 index 0000000..2709961 --- /dev/null +++ b/test/test_track/test_track.cpp @@ -0,0 +1,78 @@ +#include + +#include + +#include "track.h" + +using namespace roro::gnss; + +void setUp() {} +void tearDown() {} + +void test_distance_is_haversine() { + // One minute of latitude is one nautical mile. + TEST_ASSERT_FLOAT_WITHIN(2.0, 1852.0, distanceMeters(50.0, 4.0, 50.0 + 1.0 / 60, 4.0)); + TEST_ASSERT_FLOAT_WITHIN(0.01, 0.0, distanceMeters(50.0, 4.0, 50.0, 4.0)); + // A degree of longitude shrinks with latitude: cos(60°) = 0.5. + TEST_ASSERT_FLOAT_WITHIN(60.0, 55597.0, distanceMeters(60.0, 0.0, 60.0, 1.0)); +} + +void test_first_point_is_always_recorded() { + TrackRule rule; + TEST_ASSERT_TRUE(rule.due(1000, 50.0, 4.0)); +} + +void test_a_point_needs_5_seconds_and_5_metres() { + TrackRule rule; + rule.due(0, 50.0, 4.0); + double tenMetres = 10.0 / 111195.0; // degrees of latitude + TEST_ASSERT_FALSE(rule.due(4999, 50.0 + tenMetres, 4.0)); // too soon + TEST_ASSERT_FALSE(rule.due(5000, 50.0 + 1e-6, 4.0)); // too close (standing still) + TEST_ASSERT_TRUE(rule.due(5000, 50.0 + tenMetres, 4.0)); + TEST_ASSERT_FALSE(rule.due(6000, 50.0 + 3 * tenMetres, 4.0)); // 5 s from the last point + TEST_ASSERT_TRUE(rule.due(10000, 50.0 + 3 * tenMetres, 4.0)); +} + +void test_reset_starts_over() { + TrackRule rule; + rule.due(0, 50.0, 4.0); + rule.reset(); + TEST_ASSERT_TRUE(rule.due(100, 50.0, 4.0)); +} + +void test_file_name_from_utc() { + TEST_ASSERT_EQUAL_STRING("/gnss/tracks/20261004-203447.gpx", trackPath(1791146087LL).c_str()); // 20:34:47 UTC +} + +void test_gpx_header_point_and_footer() { + std::string h = gpx::header("20261004-203447"); + TEST_ASSERT_TRUE(h.find("") == 0); + TEST_ASSERT_TRUE(h.find("20261004-203447") != std::string::npos); + TEST_ASSERT_TRUE(h.find("") != std::string::npos); + TEST_ASSERT_EQUAL_STRING( + "42.5", + gpx::point(50.869203, -4.253507, true, 42.5f, 1791146087LL).c_str()); + TEST_ASSERT_EQUAL_STRING("", + gpx::point(50.869203, 4.253507, false, 0, 1791146087LL).c_str()); + TEST_ASSERT_EQUAL_STRING("", gpx::footer().c_str()); +} + +void test_an_unfinished_file_is_recognised() { + TEST_ASSERT_TRUE(gpx::finished("...\n\n")); + TEST_ASSERT_TRUE(gpx::finished("")); + TEST_ASSERT_FALSE(gpx::finished("...\n")); + TEST_ASSERT_FALSE(gpx::finished("")); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_distance_is_haversine); + RUN_TEST(test_first_point_is_always_recorded); + RUN_TEST(test_a_point_needs_5_seconds_and_5_metres); + RUN_TEST(test_reset_starts_over); + RUN_TEST(test_file_name_from_utc); + RUN_TEST(test_gpx_header_point_and_footer); + RUN_TEST(test_an_unfinished_file_is_recognised); + return UNITY_END(); +}