From 463ed2dda86a89993fc22bffa188e537aff5d603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Sun, 4 Oct 2026 22:38:06 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- lib/gnss/src/geo_format.cpp | 50 ++++++ lib/gnss/src/geo_format.h | 21 +++ src/apps/gnss_app.cpp | 188 +++++++++++++++++++++++ src/apps/gnss_app.h | 29 ++++ src/main.cpp | 2 + src/services/gnss_service.h | 1 + test/test_geo_format/test_geo_format.cpp | 64 ++++++++ 7 files changed, 355 insertions(+) create mode 100644 lib/gnss/src/geo_format.cpp create mode 100644 lib/gnss/src/geo_format.h create mode 100644 src/apps/gnss_app.cpp create mode 100644 src/apps/gnss_app.h create mode 100644 test/test_geo_format/test_geo_format.cpp diff --git a/lib/gnss/src/geo_format.cpp b/lib/gnss/src/geo_format.cpp new file mode 100644 index 0000000..955aee6 --- /dev/null +++ b/lib/gnss/src/geo_format.cpp @@ -0,0 +1,50 @@ +#include "geo_format.h" + +#include +#include + +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('A' + static_cast(lon / 20)); + out += static_cast('A' + static_cast(lat / 10)); + out += static_cast('0' + static_cast(std::fmod(lon, 20) / 2)); + out += static_cast('0' + static_cast(std::fmod(lat, 10))); + out += static_cast('a' + static_cast(std::fmod(lon, 2) * 12)); + out += static_cast('a' + static_cast(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(std::lround(r * std::sin(az))), cy - static_cast(std::lround(r * std::cos(az)))}; +} + +} // namespace roro::gnss diff --git a/lib/gnss/src/geo_format.h b/lib/gnss/src/geo_format.h new file mode 100644 index 0000000..da8d2ce --- /dev/null +++ b/lib/gnss/src/geo_format.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +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 diff --git a/src/apps/gnss_app.cpp b/src/apps/gnss_app.cpp new file mode 100644 index 0000000..0c6299c --- /dev/null +++ b/src/apps/gnss_app.cpp @@ -0,0 +1,188 @@ +#include "gnss_app.h" + +#include + +#include +#include + +#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(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(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 diff --git a/src/apps/gnss_app.h b/src/apps/gnss_app.h new file mode 100644 index 0000000..67c833b --- /dev/null +++ b/src/apps/gnss_app.h @@ -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 diff --git a/src/main.cpp b/src/main.cpp index 814420d..08edeea 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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)}); diff --git a/src/services/gnss_service.h b/src/services/gnss_service.h index ae72532..9564446 100644 --- a/src/services/gnss_service.h +++ b/src/services/gnss_service.h @@ -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"). diff --git a/test/test_geo_format/test_geo_format.cpp b/test/test_geo_format/test_geo_format.cpp new file mode 100644 index 0000000..f1a3fec --- /dev/null +++ b/test/test_geo_format/test_geo_format.cpp @@ -0,0 +1,64 @@ +#include + +#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(); +}