Files
roro9stack/src/apps/gnss_app.cpp
T
twislaandClaude Opus 5.5 d7952612dd 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
2026-10-04 22:46:57 +02:00

213 lines
7.6 KiB
C++

#include "gnss_app.h"
#include <Arduino.h>
#include <cmath>
#include <cstdio>
#include "geo_format.h"
#include "ui/canvas.h"
#include "ui/fonts.h"
#include "ui/theme.h"
namespace roro {
using namespace gnss;
namespace {
// One colour per constellation, readable in the 8-bit frame buffer.
uint16_t colorOf(Constellation c) {
switch (c) {
case Constellation::Gps: return theme::kAccent;
case Constellation::Glonass: return 0xF8C8; // red
case Constellation::Galileo: return theme::kMessage;
case Constellation::BeiDou: return 0xFEA0; // yellow
case Constellation::Qzss: return 0xC99F; // violet
default: return theme::kText;
}
}
const char* fixLabel(FixType f) { return f == FixType::ThreeD ? "3D Fix" : f == FixType::TwoD ? "2D Fix" : "No Fix"; }
std::string duration(uint32_t ms) {
uint32_t s = ms / 1000;
char buf[24];
if (s < 60) std::snprintf(buf, sizeof buf, "%lu s", (unsigned long)s);
else std::snprintf(buf, sizeof buf, "%lu min %02lu s", (unsigned long)(s / 60), (unsigned long)(s % 60));
return buf;
}
} // namespace
bool GnssApp::onKey(const KeyEvent& e) {
if (e.key == Key::Tab) {
sky_ = !sky_;
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
}
void GnssApp::update(uint32_t nowMs) {
if (nowMs - lastDrawMs_ >= 1000) { // the receiver reports once a second
lastDrawMs_ = nowMs;
requestRedraw();
}
}
void GnssApp::draw(Canvas& c) {
c.setTextDatum(top_left);
const auto& area = theme::kContent;
if (!gnss_.on()) {
c.setFont(&fonts::bold);
c.setTextColor(theme::kText);
c.drawString("GNSS is off", 4, area.y + 4);
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
c.drawString("Settings > GNSS turns it on.", 4, area.y + 22);
return;
}
sky_ ? drawSky(c) : drawPosition(c);
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.setTextDatum(top_right);
c.drawString(sky_ ? "Tab: position" : "Tab: sky", area.w - 3, area.y + area.h - 9);
c.setTextDatum(top_left);
}
void GnssApp::drawPosition(Canvas& c) {
const auto& area = theme::kContent;
const GnssState& s = gnss_.state();
uint32_t now = millis();
int used = 0;
for (auto& sat : s.satellites) used += sat.used;
char line[64];
c.setFont(&fonts::bold);
c.setTextColor(s.fix == FixType::None ? theme::kWarning : theme::kMessage);
if (!gnss_.receiving(now)) std::snprintf(line, sizeof line, "No data from the receiver");
else if (s.fix == FixType::None)
std::snprintf(line, sizeof line, "Searching: %u in view, %s", (unsigned)s.satellites.size(),
duration(now - gnss_.onSinceMs()).c_str());
else std::snprintf(line, sizeof line, "%s, %d of %u satellites", fixLabel(s.fix), s.satellitesUsed,
(unsigned)s.satellites.size());
c.drawString(line, 4, area.y + 2);
c.setFont(&fonts::body);
int y = area.y + 19;
auto row = [&](const char* label, const std::string& value) {
c.setTextColor(theme::kMuted);
c.drawString(label, 4, y);
c.setTextColor(theme::kText);
c.drawString(value.c_str(), 64, y);
y += theme::kLineHeight;
};
bool dms = settings_.getBool(Setting::CoordinatesDms);
if (s.positionValid) {
row("Lat", dms ? formatDms(s.latitude, true) : formatDecimal(s.latitude, true));
row("Lon", dms ? formatDms(s.longitude, false) : formatDecimal(s.longitude, false));
row("Locator", maidenhead(s.latitude, s.longitude));
std::snprintf(line, sizeof line, s.altitudeValid ? "%.0f m" : "-", s.altitudeM);
row("Altitude", line);
if (s.courseValid && s.speedKmh >= 1.0f)
std::snprintf(line, sizeof line, "%.1f km/h, %.0f\xC2\xB0", s.speedKmh, s.courseDeg);
else std::snprintf(line, sizeof line, "%.1f km/h", s.speedKmh);
row("Speed", line);
} else {
row("Lat", "-");
row("Lon", "-");
y += theme::kLineHeight * 3;
}
std::snprintf(line, sizeof line, "%.1f", s.hdop);
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) {
const auto& area = theme::kContent;
const GnssState& s = gnss_.state();
const int radius = (area.h - 6) / 2, cx = 4 + radius, cy = area.y + area.h / 2;
// The sky from above: horizon, 30° and 60° elevation rings, north up.
c.drawCircle(cx, cy, radius, theme::kMuted);
c.drawCircle(cx, cy, radius * 2 / 3, 0x4208);
c.drawCircle(cx, cy, radius / 3, 0x4208);
c.drawFastHLine(cx - radius, cy, 2 * radius, 0x4208);
c.drawFastVLine(cx, cy - radius, 2 * radius, 0x4208);
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.setTextDatum(middle_center);
auto compass = [&](const char* letter, int x, int y) {
c.fillRect(x - 3, y - 4, 7, 9, theme::kBackground); // off the lines, so it reads
c.drawString(letter, x, y);
};
compass("N", cx, cy - radius + 5);
compass("S", cx, cy + radius - 4);
compass("E", cx + radius - 4, cy);
compass("W", cx - radius + 4, cy);
c.setTextDatum(top_left);
int inView[8] = {}, usedBy[8] = {};
for (auto& sat : s.satellites) {
int k = static_cast<int>(sat.system);
inView[k]++;
usedBy[k] += sat.used;
SkyPoint p = skyPosition(sat.azimuth, sat.elevation, cx, cy, radius - 3);
uint16_t color = colorOf(sat.system);
if (sat.used) c.fillCircle(p.x, p.y, 3, color);
else c.drawCircle(p.x, p.y, 3, sat.snr < 0 ? theme::kMuted : color);
}
// Legend: used / in view per constellation, then the Fix.
int x = cx + radius + 12, y = area.y + 4;
c.setFont(&fonts::body);
for (int k = 1; k < 7; k++) {
if (!inView[k]) continue;
auto system = static_cast<Constellation>(k);
c.fillCircle(x + 3, y + 6, 3, colorOf(system));
char line[32];
std::snprintf(line, sizeof line, "%-8s%d/%d", constellationName(system), usedBy[k], inView[k]);
c.setTextColor(theme::kText);
c.drawString(line, x + 10, y);
y += theme::kLineHeight;
}
if (s.satellites.empty()) {
c.setTextColor(theme::kMuted);
c.drawString("No satellites", x, y);
y += theme::kLineHeight;
}
c.setTextColor(s.fix == FixType::None ? theme::kWarning : theme::kMessage);
c.drawString(fixLabel(s.fix), x, y + 4);
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString("filled: in use", x, area.y + area.h - 20);
}
} // namespace roro