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
+76
View File
@@ -0,0 +1,76 @@
#include "track.h"
#include <cmath>
#include <cstdio>
#include <ctime>
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<time_t>(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 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<gpx version=\"1.1\" creator=\"roro9stack\" xmlns=\"http://www.topografix.com/GPX/1/1\">\n"
"<trk><name>" + name + "</name><trkseg>";
}
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, "<ele>%.1f</ele>", elevationM);
std::snprintf(buf, sizeof buf,
"<trkpt lat=\"%.7f\" lon=\"%.7f\">%s<time>%04d-%02d-%02dT%02d:%02d:%02dZ</time></trkpt>", 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 "</trkseg></trk></gpx>"; }
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, "</gpx>") == 0;
}
} // namespace gpx
} // namespace roro::gnss
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <cstdint>
#include <string>
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
+1
View File
@@ -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 };
+24
View File
@@ -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) {
+4
View File
@@ -1,5 +1,7 @@
#pragma once
#include <string>
#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
+8 -2
View File
@@ -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 <sentence without $ and checksum>\n"
"gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n"
"crash the last crash: firmware, reason, task, backtrace\n"
"coredump erase forget the core dump in flash\n"
"key <name|char> 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);
+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());
+19 -1
View File
@@ -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
+3 -2
View File
@@ -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;
+3 -2
View File
@@ -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.
+1
View File
@@ -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;
+3 -1
View File
@@ -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); }
};
+78
View File
@@ -0,0 +1,78 @@
#include <unity.h>
#include <string>
#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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>") == 0);
TEST_ASSERT_TRUE(h.find("<gpx version=\"1.1\" creator=\"roro9stack\"") != std::string::npos);
TEST_ASSERT_TRUE(h.find("<name>20261004-203447</name>") != std::string::npos);
TEST_ASSERT_TRUE(h.find("<trkseg>") != std::string::npos);
TEST_ASSERT_EQUAL_STRING(
"<trkpt lat=\"50.8692030\" lon=\"-4.2535070\"><ele>42.5</ele><time>2026-10-04T20:34:47Z</time></trkpt>",
gpx::point(50.869203, -4.253507, true, 42.5f, 1791146087LL).c_str());
TEST_ASSERT_EQUAL_STRING("<trkpt lat=\"50.8692030\" lon=\"4.2535070\"><time>2026-10-04T20:34:47Z</time></trkpt>",
gpx::point(50.869203, 4.253507, false, 0, 1791146087LL).c_str());
TEST_ASSERT_EQUAL_STRING("</trkseg></trk></gpx>", gpx::footer().c_str());
}
void test_an_unfinished_file_is_recognised() {
TEST_ASSERT_TRUE(gpx::finished("...<time>x</time></trkpt>\n</trkseg></trk></gpx>\n"));
TEST_ASSERT_TRUE(gpx::finished("</trkseg></trk></gpx>"));
TEST_ASSERT_FALSE(gpx::finished("...<time>x</time></trkpt>\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();
}