GNSS App: a Compass, with Places to be led back to and a level
CI / build (pull_request) Successful in 2m20s
Site / build (pull_request) Successful in 10s

Tab goes on from the Sky to a third view (issue #16): a rose that turns
with the device, the heading, where north came from and how long ago,
the sun on the rose, and a level. m marks where you stand as a Place,
t picks one as the target: an arrowhead on the rose, its distance and
bearing. The sensor's directions were checked by hand on the device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
This commit is contained in:
2026-10-11 17:01:57 +02:00
co-authored by Claude Opus 5.5
parent 65a9643910
commit 544bd726df
11 changed files with 584 additions and 23 deletions
+377 -9
View File
@@ -2,14 +2,19 @@
#include "gnss_app.h"
#include <Arduino.h>
#include <M5Unified.h>
#include <SD.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include "geo_format.h"
#include "track.h"
#include "ui/canvas.h"
#include "ui/fonts.h"
#include "ui/theme.h"
#include "ui/widgets.h"
namespace roro {
@@ -39,27 +44,201 @@ std::string duration(uint32_t ms) {
return buf;
}
// How the IMU sits in the device, checked by hand (issue #16): which way the sensor's x and y run on the
// screen, for the level's bubble, which goes to the side that is higher.
constexpr float kLevelX = 1.0f, kLevelY = -1.0f;
constexpr uint32_t kImuEveryMs = 10;
constexpr uint32_t kCompassFrameMs = 50;
constexpr uint32_t kSayMs = 4000;
} // namespace
void GnssApp::onEnter() {
mode_ = Mode::View;
dialog_.reset();
heading_.forget(); // the turns made while the App was closed were not followed
imuUs_ = 0;
courseSecond_ = -1;
requestRedraw();
}
void GnssApp::say(const std::string& text) {
refusal_ = text;
refusalMs_ = millis();
requestRedraw();
}
bool GnssApp::onKey(const KeyEvent& e) {
requestRedraw();
if (mode_ != Mode::View) return placesKey(e);
if (e.key == Key::Tab) {
sky_ = !sky_;
requestRedraw();
view_ = view_ == View::Position ? View::Sky : view_ == View::Sky ? View::Compass : View::Position;
if (view_ == View::Compass) loadPlaces();
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();
else say(gnss_.startTrack(millis()));
return true;
}
if (view_ == View::Compass && gnss_.on()) return compassKey(e);
return false; // Back leaves the App
}
bool GnssApp::compassKey(const KeyEvent& e) {
if (e.key != Key::Char) return false;
switch (e.ch) {
case 'm': case 'M': mark(); return true;
case 's': case 'S': aimAtSun(); return true;
case 't': case 'T':
loadPlaces();
list_.setCount(static_cast<int>(places_.size()) + 1);
list_.select(target_ + 1);
mode_ = Mode::Places;
return true;
default: return false;
}
}
bool GnssApp::placesKey(const KeyEvent& e) {
if (dialog_) { // remove this Place?
dialog_->onKey(e);
int result = dialog_->result();
if (result == DialogModel::kPending) return true;
dialog_.reset();
int at = list_.selected() - 1;
if (result == 1 && at >= 0 && at < static_cast<int>(places_.size())) {
places_.erase(places_.begin() + at);
if (target_ == at) target_ = -1;
else if (target_ > at) target_--;
savePlaces();
list_.setCount(static_cast<int>(places_.size()) + 1);
}
return true;
}
if (mode_ == Mode::Name) {
switch (e.key) {
case Key::Char: name_.insert(e.ch); break;
case Key::Delete: name_.backspace(); break;
case Key::Left: name_.left(); break;
case Key::Right: name_.right(); break;
case Key::Back: mode_ = Mode::View; break;
case Key::Select: {
const GnssState& s = gnss_.state();
mode_ = Mode::View;
if (!s.positionValid) {
say("The Fix was lost");
break;
}
std::string name = name_.text();
if (name.empty()) name = "Place " + std::to_string(places_.size() + 1);
places_.push_back({name, s.latitude, s.longitude});
target_ = static_cast<int>(places_.size()) - 1;
savePlaces();
say("Marked");
break;
}
default: break;
}
return true;
}
switch (e.key) {
case Key::Up: list_.up(); break;
case Key::Down: list_.down(); break;
case Key::Back: mode_ = Mode::View; break;
case Key::Select:
target_ = list_.selected() - 1;
mode_ = Mode::View;
break;
case Key::Delete:
if (list_.selected() > 0) dialog_.reset(new DialogModel({"Cancel", "Remove"}));
break;
case Key::Char:
if ((e.ch == 'd' || e.ch == 'D') && list_.selected() > 0) dialog_.reset(new DialogModel({"Cancel", "Remove"}));
break;
default: break;
}
return true;
}
void GnssApp::mark() {
loadPlaces();
if (!gnss_.state().positionValid) return say("No Fix yet");
if (!storage_.state().present) return say("No SD card");
if (places_.size() >= kMaxPlaces) return say("Too many places");
name_.setText("");
mode_ = Mode::Name;
}
void GnssApp::aimAtSun() {
const GnssState& s = gnss_.state();
if (!s.positionValid || !s.timeValid) return say("No Fix yet");
SunPosition sun = sunPosition(s.utcSeconds(), s.latitude, s.longitude);
if (sun.elevationDeg < 0) return say("The sun is down");
heading_.sun(sun.azimuthDeg);
say("North from the sun");
}
// The Places are few and the file is small: read whole once, written whole at each change.
void GnssApp::loadPlaces() {
if (placesLoaded_ || !storage_.state().present) return;
std::string text;
bool ran = storage_.runAndWait([&]() {
File f = SD.open(kPlacesPath, FILE_READ);
if (!f) return;
size_t size = std::min<size_t>(f.size(), 16 * 1024);
text.resize(size);
text.resize(f.read(reinterpret_cast<uint8_t*>(&text[0]), size));
f.close();
});
if (!ran) return;
places_ = parsePlaces(text);
placesLoaded_ = true;
target_ = -1;
}
void GnssApp::savePlaces() {
std::string text = placesGpx(places_);
storage_.runJob([text]() {
if (!SD.exists("/gnss")) SD.mkdir("/gnss");
File f = SD.open(kPlacesPath, FILE_WRITE);
if (!f) return;
f.write(reinterpret_cast<const uint8_t*>(text.data()), text.size());
f.close();
});
}
void GnssApp::readImu() {
if (!M5.Imu.update()) return;
auto data = M5.Imu.getImuData();
uint32_t us = micros();
float dt = imuUs_ ? (us - imuUs_) / 1e6f : 0;
imuUs_ = us;
if (dt > 0.2f) dt = 0; // the loop was held up: a turn that long ago is not known
const float gyro[3] = {data.gyro.x, data.gyro.y, data.gyro.z};
const float accel[3] = {data.accel.x, data.accel.y, data.accel.z};
heading_.motion(gyro, accel, dt);
}
void GnssApp::update(uint32_t nowMs) {
// The heading is followed in every view, so the Compass is right when Tab comes back to it.
if (nowMs - imuMs_ >= kImuEveryMs) {
imuMs_ = nowMs;
readImu();
}
const GnssState& s = gnss_.state();
if (gnss_.on() && s.timeValid && s.second != courseSecond_) { // a new report from the receiver
courseSecond_ = s.second;
if (s.positionValid && s.courseValid) heading_.course(s.courseDeg, s.speedKmh);
}
if (view_ == View::Compass && mode_ == Mode::View && nowMs - lastDrawMs_ >= kCompassFrameMs) {
bool turned = heading_.known() && std::fabs(angleDiff(heading_.heading(), shownHeading_)) >= 1.0f;
bool tilted = std::fabs(heading_.tiltX() - shownTiltX_) > 0.01f || std::fabs(heading_.tiltY() - shownTiltY_) > 0.01f;
if (turned || tilted) {
lastDrawMs_ = nowMs;
requestRedraw();
}
}
if (nowMs - lastDrawMs_ >= 1000) { // the receiver reports once a second
lastDrawMs_ = nowMs;
requestRedraw();
@@ -80,13 +259,28 @@ void GnssApp::draw(Canvas& c) {
if (held) c.drawString("Settings pauses GNSS for it.", 4, area.y + 22 + theme::kLineHeight);
return;
}
sky_ ? drawSky(c) : drawPosition(c);
if (mode_ != Mode::View) return drawPlaces(c);
switch (view_) {
case View::Position: drawPosition(c); break;
case View::Sky: drawSky(c); break;
case View::Compass: drawCompass(c); break;
}
}
void GnssApp::help(std::vector<KeyHelp>& out) const {
if (dialog_) return keys::add(out, keys::kDialog);
if (mode_ == Mode::Name) return keys::add(out, keys::kText);
if (mode_ == Mode::Places) return keys::add(out, keys::kGnssPlaces);
if (view_ == View::Compass) return keys::add(out, keys::kGnssCompass);
keys::add(out, keys::kGnss);
}
const char* GnssApp::helpTitle() const {
if (mode_ == Mode::Name) return "GNSS: a new place";
if (mode_ == Mode::Places) return "GNSS: the places";
return view_ == View::Compass ? "GNSS: the compass" : nullptr;
}
void GnssApp::drawPosition(Canvas& c) {
const auto& area = theme::kContent;
const GnssState& s = gnss_.state();
@@ -141,7 +335,7 @@ void GnssApp::drawPosition(Canvas& c) {
c.setTextColor(theme::kWarning);
std::snprintf(line, sizeof line, "REC %d point%s, %s", gnss_.trackPoints(),
gnss_.trackPoints() == 1 ? "" : "s", duration(now - gnss_.trackStartMs()).c_str());
} else if (!refusal_.empty() && now - refusalMs_ < 4000) {
} else if (!refusal_.empty() && now - refusalMs_ < kSayMs) {
c.setTextColor(theme::kWarning);
std::snprintf(line, sizeof line, "%s", refusal_.c_str());
} else line[0] = 0;
@@ -208,4 +402,178 @@ void GnssApp::drawSky(Canvas& c) {
c.drawString("filled: in use", x, area.y + area.h - 20);
}
void GnssApp::drawCompass(Canvas& c) {
const auto& area = theme::kContent;
const GnssState& s = gnss_.state();
const int radius = 54, cx = 4 + radius + 2, cy = area.y + area.h / 2 + 2;
bool known = heading_.known();
float heading = known ? heading_.heading() : 0; // north up until north is known
shownHeading_ = heading;
shownTiltX_ = heading_.tiltX();
shownTiltY_ = heading_.tiltY();
// A point of the rose: `bearing` from north, `r` from the centre. The top of the screen is
// where the top of the device points.
auto at = [&](float bearing, float r, int& x, int& y) {
float a = (bearing - heading) * static_cast<float>(M_PI) / 180.0f;
x = cx + static_cast<int>(std::lround(r * std::sin(a)));
y = cy - static_cast<int>(std::lround(r * std::cos(a)));
};
c.drawCircle(cx, cy, radius, known ? theme::kMuted : theme::kFaint);
for (int b = 0; b < 360; b += 10) {
int x0, y0, x1, y1;
bool major = b % 30 == 0;
at(b, radius - (major ? 6 : 3), x0, y0);
at(b, radius - 1, x1, y1);
c.drawLine(x0, y0, x1, y1, major && known ? theme::kMuted : theme::kFaint);
}
if (known) { // the needle: its bright half to the north
int nx, ny, sx, sy, lx, ly, rx, ry;
at(0, radius - 24, nx, ny);
at(180, radius - 24, sx, sy);
at(90, 5, rx, ry);
at(270, 5, lx, ly);
c.fillTriangle(sx, sy, lx, ly, rx, ry, theme::kFaint);
c.fillTriangle(nx, ny, lx, ly, rx, ry, theme::kRed);
}
c.setTextDatum(middle_center);
static const char* const kLetters[4] = {"N", "E", "S", "W"};
for (int i = 0; i < 4; i++) {
int x, y;
at(i * 90.0f, radius - 14, x, y);
c.setFont(i == 0 ? &fonts::bold : &fonts::body);
c.setTextColor(!known ? theme::kFaint : i == 0 ? theme::kRed : theme::kText);
c.drawString(kLetters[i], x, y);
}
bool placed = s.positionValid;
if (known && placed && s.timeValid) { // the sun, where it is now: something to check north against
SunPosition sun = sunPosition(s.utcSeconds(), s.latitude, s.longitude);
if (sun.elevationDeg > 0) {
int x, y;
at(sun.azimuthDeg, radius - 4, x, y);
c.fillCircle(x, y, 3, theme::kYellow);
}
}
const Place* target = target_ >= 0 && target_ < static_cast<int>(places_.size()) ? &places_[static_cast<size_t>(target_)] : nullptr;
double bearing = 0, distance = 0;
if (target && placed) {
bearing = bearingDegrees(s.latitude, s.longitude, target->latitude, target->longitude);
distance = distanceMeters(s.latitude, s.longitude, target->latitude, target->longitude);
if (known) { // an arrowhead on the rim, the way to go
int tx, ty, ax, ay, bx, by;
at(static_cast<float>(bearing), radius + 1, tx, ty);
at(static_cast<float>(bearing) - 7, radius - 12, ax, ay);
at(static_cast<float>(bearing) + 7, radius - 12, bx, by);
c.fillTriangle(tx, ty, ax, ay, bx, by, theme::kAccent);
}
}
// The mark the rose is read against: the top of the device.
c.fillTriangle(cx, cy - radius + 2, cx - 4, cy - radius - 5, cx + 4, cy - radius - 5, theme::kText);
// The readouts, to the right.
const int x = cx + radius + 10;
char line[48];
c.setTextDatum(top_left);
c.setFont(&fonts::large);
c.setTextColor(known ? theme::kText : theme::kMuted);
if (known) std::snprintf(line, sizeof line, "%03d\xC2\xB0 %s", static_cast<int>(std::lround(heading)) % 360, cardinal(heading));
else std::snprintf(line, sizeof line, "---\xC2\xB0");
c.drawString(line, x, area.y + 3);
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
if (!known) {
c.drawString("North is not known:", x, area.y + 22);
c.drawString("walk, or aim at the", x, area.y + 31);
c.drawString("sun and press s", x, area.y + 40);
} else {
const char* from = heading_.source() == HeadingFilter::Source::Sun ? "the sun" : "the course";
int held = static_cast<int>(heading_.heldSeconds());
if (held < 3) std::snprintf(line, sizeof line, "from %s", from);
else if (held < 120) std::snprintf(line, sizeof line, "%s, %d s ago", from, held);
else std::snprintf(line, sizeof line, "%s, %d min ago", from, held / 60);
// The gyroscope drifts: after a few minutes without north, say it is a guess.
c.setTextColor(held >= 180 ? theme::kWarning : theme::kMuted);
c.drawString(line, x, area.y + 22);
c.setTextColor(theme::kMuted);
if (placed) {
std::snprintf(line, sizeof line, "%.1f km/h", s.speedKmh);
c.drawString(line, x, area.y + 31);
}
}
c.setFont(&fonts::bold);
int y = area.y + 54;
if (target) {
c.setTextColor(theme::kAccent);
c.drawString(target->name.c_str(), x, y);
c.setFont(&fonts::body);
c.setTextColor(theme::kText);
if (placed) {
std::snprintf(line, sizeof line, "%s %03d\xC2\xB0", formatDistance(distance).c_str(), static_cast<int>(std::lround(bearing)) % 360);
c.drawString(line, x, y + theme::kLineHeight);
} else c.drawString("No Fix", x, y + theme::kLineHeight);
} else {
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString("No target: t", x, y);
c.drawString("m marks this place", x, y + 9);
}
// The level: the bubble goes to the higher side, and is in the middle when the device is flat.
const int lr = 17, bubble = 5, lx = x + lr, ly = area.y + area.h - lr - 3;
float bx = kLevelX * heading_.tiltX(), by = kLevelY * heading_.tiltY();
bool flat = std::sqrt(bx * bx + by * by) < 0.035f; // within 2 degrees
const float kFull = 0.26f; // 15 degrees puts the bubble at the edge
float reach = std::sqrt(bx * bx + by * by) / kFull;
if (reach > 1.0f) {
bx /= reach;
by /= reach;
}
c.drawCircle(lx, ly, lr, theme::kMuted);
c.drawCircle(lx, ly, bubble + 2, theme::kFaint);
const float travel = lr - bubble - 1;
c.fillCircle(lx + static_cast<int>(std::lround(bx / kFull * travel)), ly + static_cast<int>(std::lround(by / kFull * travel)), bubble,
flat ? theme::kGood : theme::kText);
c.setFont(&fonts::small);
if (!refusal_.empty() && millis() - refusalMs_ < kSayMs) {
c.setTextColor(theme::kWarning);
c.drawString(refusal_.c_str(), lx + lr + 5, ly - 3);
} else {
c.setTextColor(theme::kMuted);
c.drawString(flat ? "flat" : "tilted", lx + lr + 5, ly - 3);
}
}
void GnssApp::drawPlaces(Canvas& c) {
const auto& area = theme::kContent;
const GnssState& s = gnss_.state();
c.setFont(&fonts::bold);
c.setTextColor(theme::kText);
if (mode_ == Mode::Name) {
c.drawString("A name for this place", 4, area.y + 4);
widgets::lineEditor(c, name_, {4, area.y + 24, area.w - 8, 0});
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
c.drawString("The Compass will lead back to it.", 4, area.y + 46);
c.drawString("Left empty, it gets a number.", 4, area.y + 46 + theme::kLineHeight);
return;
}
c.drawString("Where to?", 4, area.y + 2);
theme::Rect rows{0, area.y + 18, area.w, list_.visibleRows() * theme::kLineHeight};
widgets::list(
c, list_, rows,
[&](int i) { return i == 0 ? std::string("No target") : places_[static_cast<size_t>(i - 1)].name; },
[&](int i) {
if (i == 0 || !s.positionValid) return std::string();
const Place& p = places_[static_cast<size_t>(i - 1)];
return formatDistance(distanceMeters(s.latitude, s.longitude, p.latitude, p.longitude));
});
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString(places_.empty() ? "No place yet: m in the compass marks one" : "Enter: go there d: remove it", 4, area.y + area.h - 9);
if (dialog_ && list_.selected() > 0) widgets::dialog(c, "Remove this place?", places_[static_cast<size_t>(list_.selected() - 1)].name, *dialog_);
}
} // namespace roro
+45 -6
View File
@@ -1,35 +1,74 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include "app.h"
#include "compass.h"
#include "dialog_model.h"
#include "line_editor.h"
#include "list_model.h"
#include "places.h"
#include "services/gnss_service.h"
#include "services/storage_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.
// GNSS (see docs/milestones/M2.md, Q59): the Position view, the Sky view with every satellite in
// view placed by direction and elevation, and the Compass (issue #16, docs/milestones/H1.md): a
// rose that turns with the device, and the way to a Place the user marked. Tab goes round them.
class GnssApp : public App {
public:
GnssApp(GnssService& gnss, const Settings& settings) : gnss_(gnss), settings_(settings) {}
void onEnter() override { requestRedraw(); }
GnssApp(GnssService& gnss, const Settings& settings, StorageService& storage)
: gnss_(gnss), settings_(settings), storage_(storage) {}
void onEnter() override;
bool onKey(const KeyEvent& e) override;
bool textEntryActive() const override { return mode_ == Mode::Name; }
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
bool badge() const override { return gnss_.tracking(); }
private:
enum class View { Position, Sky, Compass };
// Over the Compass: the list of Places, and the name of a new one.
enum class Mode { View, Places, Name };
void drawPosition(Canvas& c);
void drawSky(Canvas& c);
void drawCompass(Canvas& c);
void drawPlaces(Canvas& c);
bool compassKey(const KeyEvent& e);
bool placesKey(const KeyEvent& e);
void readImu();
void loadPlaces();
void savePlaces();
void mark();
void aimAtSun();
void say(const std::string& text);
GnssService& gnss_;
const Settings& settings_;
bool sky_ = false;
StorageService& storage_;
View view_ = View::Position;
Mode mode_ = Mode::View;
uint32_t lastDrawMs_ = 0;
std::string refusal_; // why a Track couldn't start, shown for a few seconds
std::string refusal_; // why something couldn't be done, or that it was: shown for a few seconds
uint32_t refusalMs_ = 0;
gnss::HeadingFilter heading_;
uint32_t imuUs_ = 0, imuMs_ = 0;
int courseSecond_ = -1; // the Fix whose course was last given to the filter
float shownHeading_ = -1, shownTiltX_ = 0, shownTiltY_ = 0;
std::vector<gnss::Place> places_;
bool placesLoaded_ = false;
int target_ = -1; // in places_; -1: none
ListModel list_{6};
LineEditor name_{gnss::kPlaceNameBytes};
std::unique_ptr<DialogModel> dialog_;
};
} // namespace roro
+1 -1
View File
@@ -235,7 +235,7 @@ void setup() {
launcher.setSettings(settings);
apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus), icons::irc});
apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService), icons::wifi_tools});
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings), icons::gnss});
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings, *storageService), icons::gnss});
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService), icons::gemini});
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService), icons::lora});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *new WebShare(*storageService, *fileOps, *wifi)), icons::storage});