M2 step 5: GNSS App, with the Position and Sky views

Position: the Fix line (or how long it has been searching), latitude and
longitude in decimal degrees or degrees-minutes-seconds (Settings ->
Coordinates), the Maidenhead locator, altitude, speed and course, HDOP
and UTC. Sky: the satellites by azimuth and elevation, coloured by
constellation, filled when used in the Fix, with used/in-view counts.
Tab switches. lib/gnss/geo_format holds the formatting, the locator and
the sky projection, host-tested (6 tests; locators checked against
FN31pr and JN58td).

Verified on the device by a window: 3D Fix from GPS, GLONASS, Galileo
and BeiDou.

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:38:06 +02:00
co-authored by Claude Opus 5.5
parent d408bada07
commit 463ed2dda8
7 changed files with 355 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
#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;
}
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 : "-");
}
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
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include "app.h"
#include "services/gnss_service.h"
#include "settings.h"
namespace roro {
// GNSS (see docs/milestones/M2.md, Q59): the Position view, and the Sky view with every satellite
// in view placed by direction and elevation. Tab switches between them.
class GnssApp : public App {
public:
GnssApp(GnssService& gnss, const Settings& settings) : gnss_(gnss), settings_(settings) {}
void onEnter() override { requestRedraw(); }
bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
private:
void drawPosition(Canvas& c);
void drawSky(Canvas& c);
GnssService& gnss_;
const Settings& settings_;
bool sky_ = false;
uint32_t lastDrawMs_ = 0;
};
} // namespace roro
+2
View File
@@ -8,6 +8,7 @@
#include "app_manager.h"
#include "apps/demo_app.h"
#include "apps/gnss_app.h"
#include "apps/irc_app.h"
#include "apps/launcher_app.h"
#include "apps/settings_app.h"
@@ -165,6 +166,7 @@ void setup() {
launcher.setManager(*apps);
apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)});
apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService)});
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)});
apps->registerApp({"settings", "Settings", false,
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update})});
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});
+1
View File
@@ -35,6 +35,7 @@ class GnssService : public Service {
bool on() const { return active_; }
bool receiving(uint32_t nowMs) const { return active_ && lastLineMs_ && nowMs - lastLineMs_ < kSilentAfterMs; }
const gnss::GnssState& state() const { return parser_.state(); }
uint32_t onSinceMs() const { return openedMs_; } // when the receiver was last woken
// Debug aids (Q68): raw sentences to the console, a status report, and sending a command
// ("PCAS10,0" becomes "$PCAS10,0*1C\r\n").