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
+50
View File
@@ -0,0 +1,50 @@
#include "geo_format.h"
#include <cmath>
#include <cstdio>
namespace roro::gnss {
namespace {
const char* hemisphere(double degrees, bool latitude) {
return latitude ? (degrees < 0 ? "S" : "N") : (degrees < 0 ? "W" : "E");
}
} // namespace
std::string formatDecimal(double degrees, bool latitude) {
char buf[32];
std::snprintf(buf, sizeof buf, "%.5f\xC2\xB0 %s", std::fabs(degrees), hemisphere(degrees, latitude));
return buf;
}
std::string formatDms(double degrees, bool latitude) {
// Work in tenths of a second, so rounding carries into minutes and degrees.
long tenths = std::lround(std::fabs(degrees) * 36000.0);
long d = tenths / 36000, m = tenths / 600 % 60, s10 = tenths % 600;
char buf[40];
std::snprintf(buf, sizeof buf, "%ld\xC2\xB0 %02ld' %02ld.%ld\" %s", d, m, s10 / 10, s10 % 10,
hemisphere(degrees, latitude));
return buf;
}
std::string maidenhead(double latitude, double longitude) {
double lon = std::fmin(std::fmax(longitude + 180.0, 0.0), 359.999999);
double lat = std::fmin(std::fmax(latitude + 90.0, 0.0), 179.999999);
std::string out;
out += static_cast<char>('A' + static_cast<int>(lon / 20));
out += static_cast<char>('A' + static_cast<int>(lat / 10));
out += static_cast<char>('0' + static_cast<int>(std::fmod(lon, 20) / 2));
out += static_cast<char>('0' + static_cast<int>(std::fmod(lat, 10)));
out += static_cast<char>('a' + static_cast<int>(std::fmod(lon, 2) * 12));
out += static_cast<char>('a' + static_cast<int>(std::fmod(lat, 1) * 24));
return out;
}
SkyPoint skyPosition(int azimuthDeg, int elevationDeg, int cx, int cy, int radius) {
int elevation = elevationDeg < 0 ? 0 : elevationDeg > 90 ? 90 : elevationDeg;
double r = radius * (90 - elevation) / 90.0;
double az = azimuthDeg * M_PI / 180.0;
return {cx + static_cast<int>(std::lround(r * std::sin(az))), cy - static_cast<int>(std::lround(r * std::cos(az)))};
}
} // namespace roro::gnss
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <string>
namespace roro::gnss {
// "50.86920° N": five decimals, about a metre.
std::string formatDecimal(double degrees, bool latitude);
// "50° 52' 09.1\" N" (Settings → Coordinates, Q64).
std::string formatDms(double degrees, bool latitude);
// The 6-character Maidenhead locator ("JO20ef"), as radio amateurs give their square.
std::string maidenhead(double latitude, double longitude);
// Where a satellite goes on the Sky view: the zenith in the centre, the horizon on the circle,
// north up and east right (as seen looking at the sky from above, like a map).
struct SkyPoint {
int x, y;
};
SkyPoint skyPosition(int azimuthDeg, int elevationDeg, int cx, int cy, int radius);
} // namespace roro::gnss
+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 "app_manager.h"
#include "apps/demo_app.h" #include "apps/demo_app.h"
#include "apps/gnss_app.h"
#include "apps/irc_app.h" #include "apps/irc_app.h"
#include "apps/launcher_app.h" #include "apps/launcher_app.h"
#include "apps/settings_app.h" #include "apps/settings_app.h"
@@ -165,6 +166,7 @@ void setup() {
launcher.setManager(*apps); launcher.setManager(*apps);
apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)}); 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({"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, apps->registerApp({"settings", "Settings", false,
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update})}); new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update})});
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)}); 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 on() const { return active_; }
bool receiving(uint32_t nowMs) const { return active_ && lastLineMs_ && nowMs - lastLineMs_ < kSilentAfterMs; } bool receiving(uint32_t nowMs) const { return active_ && lastLineMs_ && nowMs - lastLineMs_ < kSilentAfterMs; }
const gnss::GnssState& state() const { return parser_.state(); } 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 // Debug aids (Q68): raw sentences to the console, a status report, and sending a command
// ("PCAS10,0" becomes "$PCAS10,0*1C\r\n"). // ("PCAS10,0" becomes "$PCAS10,0*1C\r\n").
+64
View File
@@ -0,0 +1,64 @@
#include <unity.h>
#include "geo_format.h"
using namespace roro::gnss;
void setUp() {}
void tearDown() {}
void test_decimal_degrees_with_hemisphere() {
TEST_ASSERT_EQUAL_STRING("50.86920° N", formatDecimal(50.8692034, true).c_str());
TEST_ASSERT_EQUAL_STRING("33.50000° S", formatDecimal(-33.5, true).c_str());
TEST_ASSERT_EQUAL_STRING("4.25350° E", formatDecimal(4.2535, false).c_str());
TEST_ASSERT_EQUAL_STRING("151.21000° W", formatDecimal(-151.21, false).c_str());
}
void test_degrees_minutes_seconds() {
TEST_ASSERT_EQUAL_STRING("50° 52' 09.1\" N", formatDms(50.8692034, true).c_str());
TEST_ASSERT_EQUAL_STRING("4° 15' 12.6\" E", formatDms(4.2535, false).c_str());
TEST_ASSERT_EQUAL_STRING("33° 30' 00.0\" S", formatDms(-33.5, true).c_str());
}
void test_seconds_that_round_up_carry_over() {
TEST_ASSERT_EQUAL_STRING("11° 00' 00.0\" N", formatDms(10.9999999, true).c_str());
}
void test_maidenhead_matches_known_locators() {
TEST_ASSERT_EQUAL_STRING("FN31pr", maidenhead(41.714775, -72.727260).c_str()); // W1AW
TEST_ASSERT_EQUAL_STRING("JN58td", maidenhead(48.14666, 11.60833).c_str()); // Munich
TEST_ASSERT_EQUAL_STRING("JJ00aa", maidenhead(0, 0).c_str());
TEST_ASSERT_EQUAL_STRING("AA00aa", maidenhead(-90, -180).c_str());
}
void test_maidenhead_stays_in_range_at_the_edges() {
TEST_ASSERT_EQUAL_STRING("RR99xx", maidenhead(90, 180).c_str());
}
void test_sky_projection() {
SkyPoint zenith = skyPosition(123, 90, 50, 60, 40);
TEST_ASSERT_EQUAL(50, zenith.x);
TEST_ASSERT_EQUAL(60, zenith.y);
SkyPoint north = skyPosition(0, 0, 50, 60, 40);
TEST_ASSERT_EQUAL(50, north.x);
TEST_ASSERT_EQUAL(20, north.y); // up
SkyPoint east = skyPosition(90, 0, 50, 60, 40);
TEST_ASSERT_EQUAL(90, east.x);
TEST_ASSERT_EQUAL(60, east.y);
SkyPoint south45 = skyPosition(180, 45, 50, 60, 40);
TEST_ASSERT_EQUAL(50, south45.x);
TEST_ASSERT_EQUAL(80, south45.y);
SkyPoint below = skyPosition(270, -5, 50, 60, 40); // just below the horizon: on the edge
TEST_ASSERT_EQUAL(10, below.x);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_decimal_degrees_with_hemisphere);
RUN_TEST(test_degrees_minutes_seconds);
RUN_TEST(test_seconds_that_round_up_carry_over);
RUN_TEST(test_maidenhead_matches_known_locators);
RUN_TEST(test_maidenhead_stays_in_range_at_the_edges);
RUN_TEST(test_sky_projection);
return UNITY_END();
}