GNSS App: a Compass, with Places and a level #98

Merged
twisla merged 4 commits from compass into main 2026-10-11 15:17:18 +00:00
18 changed files with 1186 additions and 23 deletions
+11
View File
@@ -64,6 +64,17 @@ _Avoid_: lock, signal
A route recorded from GNSS positions to a GPX file on the SD card, started and stopped by the user.
_Avoid_: trace, log (a Log is recorded on its own)
**Compass**:
The third view of the GNSS App: a rose turned by the Heading, and the way to the target Place.
**Heading**:
Where the top of the device points, in degrees from true north. It is given by the course over ground while walking or by aiming at the sun, and held by the gyroscope in between.
_Avoid_: course (the course is the direction of travel, from the receiver), bearing (a bearing is the direction to somewhere else)
**Place**:
A named position the user marked, kept as a GPX waypoint on the SD card, that the Compass can lead back to.
_Avoid_: waypoint, POI
**Wi-Fi Service**:
The Service that owns the Wi-Fi radio. It's always in exactly one mode: *Off*, *Connected* (joined to a Saved Network) or *Monitoring* (passively observing). When Wi-Fi is enabled in Settings, it stays Connected whenever a Saved Network is in range. It goes Monitoring only while Wi-Fi Tools needs it, then reconnects.
_Avoid_: network manager
+2 -1
View File
@@ -7,7 +7,7 @@ A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262*
What it does today:
- **LoRa Scanner:** every packet it hears, with the Meshtastic or MeshCore header read; a spectrum Sweep; captures for Wireshark. It listens and never transmits: the mesh messenger is the next milestone.
- **GNSS:** position, sky view, tracks as GPX.
- **GNSS:** position, sky view, a compass that leads back to a marked place, tracks as GPX.
- **Gemini:** a browser, with bookmarks and pages saved for offline.
- **IRC:** over TLS, with logs on the card.
- **Wi-Fi Tools:** the networks around, sorted, filtered, logged.
@@ -241,6 +241,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
| `lora capture start` / `lora capture stop` | A LoRa Capture, as `c` in the App |
| `lora sweep on [from MHz] [to MHz] [step kHz]` / `lora sweep off` / `lora sweep dump` | Sweep a band (863 870 100 by default), with a summary every 2 s (floor, strongest, peaks), or print the latest pass |
| `gnss quiet on` / `gnss quiet off` | The "Pause GNSS for LoRa" setting |
| `imu` | What the motion sensor reads: g on its three axes, then degrees a second (the Compass, issue #16) |
| `gnss status` / `gnss restart` | The receiver's state, and a restart of it |
| `gnss track start` / `gnss track stop` | A Track, as `r` in the GNSS App (the reason is printed if it can't start) |
| `gnss nmea on` / `gnss nmea off` | Prints each NMEA sentence the receiver sends, as `nmea: …` |
+39
View File
@@ -0,0 +1,39 @@
# H1 — On-board hardware
**Status:** in progress. Built, not released yet: the Compass (issue #16). Not started: the IR Remote (#15), the Audio App (#14).
**Goal:** the hardware the device already carries and nothing used yet (the motion sensor, the infrared LED, the microphone and speaker) each gets something useful to do.
## The Compass (issue #16)
The device has a GNSS receiver and a motion sensor (a BMI270: an accelerometer and a gyroscope) but **no magnetometer**: standing still, nothing in it knows where north is. The receiver gives the direction of travel while walking; the gyroscope measures turns, but not from where.
### Decisions (design round 2026-10-10)
| # | Decision |
|---|---|
| Q267 | **A third view of the GNSS App,** after Position and Sky, not an App of its own: Tab goes round the three. |
| Q268 | **North comes from outside, and the gyroscope holds it.** Walking at 3 km/h or more, each course the receiver reports pulls the heading 30 % of the way to it. Standing, the gyroscope follows the turns, and the screen says how long ago north was last given. |
| Q269 | **The sun gives north too:** `s` with the top of the device aimed at the sun sets the heading to the sun's azimuth, worked out from the time and the position. The sun is drawn on the rose, as something to check north against. |
| Q270 | **The heading is where the top of the device points.** The turn that counts is the one around the vertical, which the accelerometer gives: it works however the device is held. Walking, the device is taken to point the way you walk. |
| Q271 | **Places:** `m` marks where you stand, under a name; `t` picks one as the target. The rose then carries an arrowhead to it, with its distance and bearing. Targets from Tracks, typed coordinates or Gemini links are for later. |
| Q272 | **Places are GPX waypoints** in `/gnss/places.gpx` on the SD card, 50 at most: a map program opens the file, and waypoints made by one are read here. The whole file is written at each change. |
| Q273 | **A level** from the accelerometer, next to the readouts: the heading is only as good as the device is flat when aiming at the sun. |
| Q274 | **Not now:** a magnetometer on the Grove port (and so declination), a faster GNSS rate, sunrise and sunset, an estimated time of arrival. |
### As built
- **`lib/gnss/src/compass.{h,cpp}`**, host-tested (13 tests): `HeadingFilter` (the turn around "up" from the gyroscope, the course and the sun as references, the gyroscope's offset learnt while the device lies still), `sunPosition` (the Astronomical Almanac's low-precision formulas), `bearingDegrees`, `cardinal`, `formatDistance`.
- **`lib/gnss/src/places.{h,cpp}`**, host-tested (5 tests): the Places as GPX waypoints, read and written.
- **The GNSS App** reads the sensor 100 times a second while it is in front, in any of its views, and forgets north when it is left: the turns made meanwhile were not followed. The Compass is drawn again when the heading moves by a degree or the level moves, at most 20 times a second.
- **`imu`** on the consoles prints what the sensor reads.
### Checked on the device (2026-10-11)
- The sensor answers as a BMI270 through M5Unified, and reads about 0.5 degrees a second on one axis lying still.
- Turning the device clockwise makes the heading go up, and the level's bubble goes to the edge that is lifted: the sensor's axes are as the code took them.
- Not checked yet: north from the course while walking, north from the sun against a real compass, and how far the heading drifts in the hand.
### What the sensor's offset costs
A gyroscope reads a little even when nothing turns (up to about 0.5 degrees a second for this one), which would turn the rose by itself. The filter takes what the sensor reads while it has been still for a second (under 2 degrees a second on all axes) as that offset. In the hand the device is rarely that still: lay it down for a few seconds first. After 3 minutes without north the screen shows the age of the heading in the warning colour.
+17 -1
View File
@@ -134,10 +134,26 @@ inline constexpr KeyHelp kWifiTracker[] = {
// gnss: GNSS
inline constexpr KeyHelp kGnss[] = {
{"Tab", "the position, or the sky"},
{"Tab", "position, sky, compass"},
{"r", "record a Track, or stop it"},
};
// gnss-compass: GNSS, the compass
inline constexpr KeyHelp kGnssCompass[] = {
{"Tab", "position, sky, compass"},
{"m", "mark this place"},
{"t", "the target: a place"},
{"s", "aimed at the sun: north"},
{"r", "record a Track, or stop it"},
};
// gnss-places: GNSS, the places
inline constexpr KeyHelp kGnssPlaces[] = {
{"; .", "up, down"},
{"Enter", "the target"},
{"d Del", "remove the place"},
};
// gemini: Gemini, a page
inline constexpr KeyHelp kGemini[] = {
{"Tab", "the next link"},
+123
View File
@@ -0,0 +1,123 @@
#include "compass.h"
#include <cmath>
#include <cstdio>
namespace roro::gnss {
namespace {
constexpr double kRad = M_PI / 180.0;
// The sensor's axes are right-handed, so a positive turn around "up" is anticlockwise seen from
// above, and the heading (clockwise) goes down. Measured on the device: issue #16.
constexpr float kTurnSign = -1.0f;
} // namespace
float wrap360(float degrees) {
float d = std::fmod(degrees, 360.0f);
return d < 0 ? d + 360.0f : d;
}
float angleDiff(float to, float from) {
float d = wrap360(to - from);
return d >= 180.0f ? d - 360.0f : d;
}
double bearingDegrees(double lat1, double lon1, double lat2, double lon2) {
double p1 = lat1 * kRad, p2 = lat2 * kRad, dl = (lon2 - lon1) * kRad;
double y = std::sin(dl) * std::cos(p2);
double x = std::cos(p1) * std::sin(p2) - std::sin(p1) * std::cos(p2) * std::cos(dl);
double b = std::atan2(y, x) / kRad;
return b < 0 ? b + 360.0 : b;
}
const char* cardinal(float degrees) {
static const char* const kPoints[16] = {"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"};
return kPoints[static_cast<int>(wrap360(degrees) / 22.5f + 0.5f) % 16];
}
std::string formatDistance(double meters) {
char buf[24];
if (meters < 999.5) std::snprintf(buf, sizeof buf, "%.0f m", meters);
else if (meters < 9950.0) std::snprintf(buf, sizeof buf, "%.1f km", meters / 1000.0);
else std::snprintf(buf, sizeof buf, "%.0f km", meters / 1000.0);
return buf;
}
SunPosition sunPosition(int64_t utcSeconds, double latitude, double longitude) {
double d = utcSeconds / 86400.0 - 10957.5; // days since 2000-01-01 12:00 UTC
double g = (357.529 + 0.98560028 * d) * kRad; // mean anomaly
double q = 280.459 + 0.98564736 * d; // mean longitude
double l = (q + 1.915 * std::sin(g) + 0.020 * std::sin(2 * g)) * kRad; // ecliptic longitude
double e = (23.439 - 0.00000036 * d) * kRad; // the ecliptic's tilt
double rightAscension = std::atan2(std::cos(e) * std::sin(l), std::cos(l));
double declination = std::asin(std::sin(e) * std::sin(l));
double siderealDeg = std::fmod(18.697374558 + 24.06570982441908 * d, 24.0) * 15.0; // at Greenwich
double hourAngle = (siderealDeg + longitude) * kRad - rightAscension;
double lat = latitude * kRad;
double elevation = std::asin(std::sin(lat) * std::sin(declination) +
std::cos(lat) * std::cos(declination) * std::cos(hourAngle));
double azimuth = std::atan2(-std::sin(hourAngle),
std::tan(declination) * std::cos(lat) - std::sin(lat) * std::cos(hourAngle));
return {wrap360(static_cast<float>(azimuth / kRad)), static_cast<float>(elevation / kRad)};
}
void HeadingFilter::motion(const float gyro[3], const float accel[3], float dt) {
if (dt <= 0) return;
// "Up" is where the accelerometer points at rest, smoothed so a shake doesn't move it.
float norm = std::sqrt(accel[0] * accel[0] + accel[1] * accel[1] + accel[2] * accel[2]);
if (norm > 0.5f && norm < 1.5f) {
float k = any_ ? std::fmin(1.0f, dt / kUpSeconds) : 1.0f;
float length = 0;
for (int i = 0; i < 3; i++) {
up_[i] += k * (accel[i] / norm - up_[i]);
length += up_[i] * up_[i];
}
length = std::sqrt(length);
if (length > 0.01f)
for (float& u : up_) u /= length;
any_ = true;
}
float rate[3], turning = 0;
for (int i = 0; i < 3; i++) {
rate[i] = gyro[i] - bias_[i];
turning += rate[i] * rate[i];
}
if (std::sqrt(turning) < kStillDps) {
stillFor_ += dt;
if (stillFor_ >= kStillSeconds) { // lying still: what the gyroscope still reads is its offset
float k = std::fmin(1.0f, dt / kBiasSeconds);
for (int i = 0; i < 3; i++) {
bias_[i] += k * rate[i];
rate[i] = gyro[i] - bias_[i];
}
}
} else stillFor_ = 0;
if (!known()) return;
float aroundUp = rate[0] * up_[0] + rate[1] * up_[1] + rate[2] * up_[2];
heading_ = wrap360(heading_ + kTurnSign * aroundUp * dt);
held_ += dt;
}
void HeadingFilter::course(float courseDeg, float speedKmh) {
if (speedKmh < kMinSpeedKmh) return;
heading_ = known() ? wrap360(heading_ + kCourseGain * angleDiff(courseDeg, heading_)) : wrap360(courseDeg);
source_ = Source::Course;
held_ = 0;
}
void HeadingFilter::sun(float azimuthDeg) {
heading_ = wrap360(azimuthDeg);
source_ = Source::Sun;
held_ = 0;
}
void HeadingFilter::forget() {
source_ = Source::None;
held_ = 0;
stillFor_ = 0;
}
} // namespace roro::gnss
+72
View File
@@ -0,0 +1,72 @@
#pragma once
#include <cstdint>
#include <string>
namespace roro::gnss {
// Angles are degrees from true north, clockwise, 0 up to 360.
float wrap360(float degrees);
// How far to turn from `from` to `to`: -180 up to 180, positive clockwise.
float angleDiff(float to, float from);
// The initial great-circle bearing from the first point to the second.
double bearingDegrees(double lat1, double lon1, double lat2, double lon2);
// One of the 16 points: "N", "NNE", "NE"...
const char* cardinal(float degrees);
// "340 m", "1.2 km", "12 km".
std::string formatDistance(double meters);
// Where the sun is in the sky, from the time and the place (the low-precision formulas of the
// Astronomical Almanac: within a hundredth of a degree until 2050, far finer than a hand can aim).
struct SunPosition {
float azimuthDeg;
float elevationDeg; // above the horizon; refraction is left out
};
SunPosition sunPosition(int64_t utcSeconds, double latitude, double longitude);
// The Compass's heading (issue #16): where the top of the device points. There is no
// magnetometer, so north comes from outside (the course over ground while walking, or the sun
// when the user aims at it) and the gyroscope follows the turns in between. Host-tested.
class HeadingFilter {
public:
enum class Source : uint8_t { None, Course, Sun };
// Below a walking pace the receiver's course is noise.
static constexpr float kMinSpeedKmh = 3.0f;
// Each course reading pulls the heading this much of the way: a few seconds to settle,
// without jumping at every reading.
static constexpr float kCourseGain = 0.3f;
// The gyroscope's own offset is learnt while the device lies still: turning slower than
// this for kStillSeconds counts as still.
static constexpr float kStillDps = 2.0f;
static constexpr float kStillSeconds = 1.0f;
static constexpr float kBiasSeconds = 4.0f;
// How fast the measured "up" follows the accelerometer.
static constexpr float kUpSeconds = 0.3f;
// One reading of the IMU: degrees a second and g, on the sensor's axes, `dt` seconds after
// the one before. Only the turn around the vertical counts, however the device is held.
void motion(const float gyro[3], const float accel[3], float dt);
// The receiver's course over ground, once a second: used when moving fast enough.
void course(float courseDeg, float speedKmh);
// The top of the device is aimed at the sun, which is at this azimuth.
void sun(float azimuthDeg);
void forget(); // the device was not watched for a while: north has to come again
bool known() const { return source_ != Source::None; }
float heading() const { return heading_; }
Source source() const { return source_; }
// Seconds the gyroscope has carried the heading alone since north was last given.
float heldSeconds() const { return held_; }
// "Up" along the sensor's x and y axes, -1 to 1: both 0 when the device lies flat.
float tiltX() const { return up_[0]; }
float tiltY() const { return up_[1]; }
bool still() const { return stillFor_ >= kStillSeconds; }
private:
float heading_ = 0, held_ = 0, stillFor_ = 0;
float up_[3] = {0, 0, 1}, bias_[3] = {0, 0, 0};
bool any_ = false;
Source source_ = Source::None;
};
} // namespace roro::gnss
+102
View File
@@ -0,0 +1,102 @@
#include "places.h"
#include <cstdio>
#include <cstdlib>
namespace roro::gnss {
namespace {
struct Entity {
const char* text;
char ch;
};
constexpr Entity kEntities[] = {{"&amp;", '&'}, {"&lt;", '<'}, {"&gt;", '>'}, {"&quot;", '"'}, {"&apos;", '\''}};
std::string escaped(const std::string& s) {
std::string out;
for (char c : s) {
const char* entity = nullptr;
for (auto& e : kEntities)
if (e.ch == c) entity = e.text;
if (entity) out += entity;
else out += c;
}
return out;
}
std::string unescaped(const std::string& s) {
std::string out;
for (size_t i = 0; i < s.size();) {
bool found = false;
if (s[i] == '&')
for (auto& e : kEntities) {
std::string text = e.text;
if (s.compare(i, text.size(), text) == 0) {
out += e.ch;
i += text.size();
found = true;
break;
}
}
if (!found) out += s[i++];
}
return out;
}
// The value of ` name="..."` in a tag; false if it isn't there or isn't a number.
bool attribute(const std::string& tag, const char* name, double& value) {
std::string key = std::string(" ") + name + "=";
size_t at = tag.find(key);
if (at == std::string::npos || at + key.size() >= tag.size()) return false;
at += key.size();
char quote = tag[at];
if (quote != '"' && quote != '\'') return false;
size_t end = tag.find(quote, at + 1);
if (end == std::string::npos) return false;
std::string text = tag.substr(at + 1, end - at - 1);
char* rest = nullptr;
value = std::strtod(text.c_str(), &rest);
return !text.empty() && rest && *rest == 0;
}
} // namespace
std::vector<Place> parsePlaces(const std::string& gpx) {
std::vector<Place> places;
size_t at = 0;
while (places.size() < kMaxPlaces && (at = gpx.find("<wpt", at)) != std::string::npos) {
size_t tagEnd = gpx.find('>', at);
if (tagEnd == std::string::npos) break;
std::string tag = gpx.substr(at, tagEnd - at);
bool alone = !tag.empty() && tag.back() == '/'; // <wpt .../>: no name
size_t close = alone ? tagEnd : gpx.find("</wpt>", tagEnd);
if (close == std::string::npos) break;
Place p;
if (attribute(tag, "lat", p.latitude) && attribute(tag, "lon", p.longitude) && p.latitude >= -90 &&
p.latitude <= 90 && p.longitude >= -180 && p.longitude <= 180) {
size_t name = alone ? std::string::npos : gpx.find("<name>", tagEnd);
if (name != std::string::npos && name < close) {
size_t nameEnd = gpx.find("</name>", name);
if (nameEnd != std::string::npos && nameEnd < close) p.name = unescaped(gpx.substr(name + 6, nameEnd - name - 6));
}
if (p.name.empty()) p.name = "Place " + std::to_string(places.size() + 1);
places.push_back(p);
}
at = close + 1;
}
return places;
}
std::string placesGpx(const std::vector<Place>& places) {
std::string out = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<gpx version=\"1.1\" creator=\"roro9stack\" xmlns=\"http://www.topografix.com/GPX/1/1\">\n";
for (auto& p : places) {
char buf[80];
std::snprintf(buf, sizeof buf, "<wpt lat=\"%.7f\" lon=\"%.7f\"><name>", p.latitude, p.longitude);
out += buf + escaped(p.name) + "</name></wpt>\n";
}
return out + "</gpx>\n";
}
} // namespace roro::gnss
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <cstddef>
#include <string>
#include <vector>
namespace roro::gnss {
// A Place (issue #16): somewhere the user marked, to be led back to by the Compass.
struct Place {
std::string name;
double latitude = 0, longitude = 0;
};
constexpr const char* kPlacesPath = "/gnss/places.gpx";
constexpr size_t kMaxPlaces = 50;
constexpr size_t kPlaceNameBytes = 24;
// The Places are kept as GPX waypoints, so a map program opens the file, and a file made by one
// is read here: every <wpt> with a latitude and a longitude, named by its <name>.
std::vector<Place> parsePlaces(const std::string& gpx);
std::string placesGpx(const std::vector<Place>& places);
} // namespace roro::gnss
+2
View File
@@ -26,6 +26,7 @@ lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)
lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100)
lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)
gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)
imu what the motion sensor reads: g on its three axes, then degrees a second
gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>
crash the last crash: firmware, reason, task, backtrace
coredump erase forget the core dump in flash
@@ -107,6 +108,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| `lora capture start` / `lora capture stop` | A LoRa Capture, as `c` in the App |
| `lora sweep on [from MHz] [to MHz] [step kHz]` / `lora sweep off` / `lora sweep dump` | Sweep a band (863 870 100 by default), with a summary every 2 s (floor, strongest, peaks), or print the latest pass |
| `gnss quiet on` / `gnss quiet off` | The "Pause GNSS for LoRa" setting |
| `imu` | What the motion sensor reads: g on its three axes, then degrees a second (the Compass, issue #16) |
| `gnss status` / `gnss restart` | The receiver's state, and a restart of it |
| `gnss track start` / `gnss track stop` | A Track, as `r` in the GNSS App (the reason is printed if it can't start) |
| `gnss nmea on` / `gnss nmea off` | Prints each NMEA sentence the receiver sends, as `nmea: …` |
+47
View File
@@ -0,0 +1,47 @@
+++
title = "On-board hardware"
description = "The hardware the device already carries and nothing used yet (the motion sensor, the infrared LED, the microphone and speaker) each gets something useful to do."
weight = 110
[extra]
docs = true
source = "docs/milestones/H1.md"
tag = "H1"
+++
**Status:** in progress. Built, not released yet: the Compass (issue #16). Not started: the IR Remote (#15), the Audio App (#14).
**Goal:** the hardware the device already carries and nothing used yet (the motion sensor, the infrared LED, the microphone and speaker) each gets something useful to do.
## The Compass (issue #16)
The device has a GNSS receiver and a motion sensor (a BMI270: an accelerometer and a gyroscope) but **no magnetometer**: standing still, nothing in it knows where north is. The receiver gives the direction of travel while walking; the gyroscope measures turns, but not from where.
### Decisions (design round 2026-10-10)
| # | Decision |
|---|---|
| Q267 | **A third view of the GNSS App,** after Position and Sky, not an App of its own: Tab goes round the three. |
| Q268 | **North comes from outside, and the gyroscope holds it.** Walking at 3 km/h or more, each course the receiver reports pulls the heading 30 % of the way to it. Standing, the gyroscope follows the turns, and the screen says how long ago north was last given. |
| Q269 | **The sun gives north too:** `s` with the top of the device aimed at the sun sets the heading to the sun's azimuth, worked out from the time and the position. The sun is drawn on the rose, as something to check north against. |
| Q270 | **The heading is where the top of the device points.** The turn that counts is the one around the vertical, which the accelerometer gives: it works however the device is held. Walking, the device is taken to point the way you walk. |
| Q271 | **Places:** `m` marks where you stand, under a name; `t` picks one as the target. The rose then carries an arrowhead to it, with its distance and bearing. Targets from Tracks, typed coordinates or Gemini links are for later. |
| Q272 | **Places are GPX waypoints** in `/gnss/places.gpx` on the SD card, 50 at most: a map program opens the file, and waypoints made by one are read here. The whole file is written at each change. |
| Q273 | **A level** from the accelerometer, next to the readouts: the heading is only as good as the device is flat when aiming at the sun. |
| Q274 | **Not now:** a magnetometer on the Grove port (and so declination), a faster GNSS rate, sunrise and sunset, an estimated time of arrival. |
### As built
- **`lib/gnss/src/compass.{h,cpp}`**, host-tested (13 tests): `HeadingFilter` (the turn around "up" from the gyroscope, the course and the sun as references, the gyroscope's offset learnt while the device lies still), `sunPosition` (the Astronomical Almanac's low-precision formulas), `bearingDegrees`, `cardinal`, `formatDistance`.
- **`lib/gnss/src/places.{h,cpp}`**, host-tested (5 tests): the Places as GPX waypoints, read and written.
- **The GNSS App** reads the sensor 100 times a second while it is in front, in any of its views, and forgets north when it is left: the turns made meanwhile were not followed. The Compass is drawn again when the heading moves by a degree or the level moves, at most 20 times a second.
- **`imu`** on the consoles prints what the sensor reads.
### Checked on the device (2026-10-11)
- The sensor answers as a BMI270 through M5Unified, and reads about 0.5 degrees a second on one axis lying still.
- Turning the device clockwise makes the heading go up, and the level's bubble goes to the edge that is lifted: the sensor's axes are as the code took them.
- Not checked yet: north from the course while walking, north from the sun against a real compass, and how far the heading drifts in the hand.
### What the sensor's offset costs
A gyroscope reads a little even when nothing turns (up to about 0.5 degrees a second for this one), which would turn the rose by itself. The filter takes what the sensor reads while it has been still for a second (under 2 degrees a second on all axes) as that offset. In the hand the device is rarely that still: lay it down for a few seconds first. After 3 minutes without north the screen shows the age of the heading in the warning colour.
+24 -3
View File
@@ -1,6 +1,6 @@
+++
title = "GNSS"
description = "Where you are and which satellites you can see, from the Cap's receiver, with Tracks saved to the SD card as GPX."
description = "Where you are, which satellites you can see, and a compass that leads back to a place you marked, from the Cap's receiver, with Tracks saved to the SD card as GPX."
weight = 3
[extra]
tag = "GNSS"
@@ -23,7 +23,28 @@ Once there is a fix, the device's clock follows it.
## Sky
<kbd>Tab</kbd> switches between Position and **Sky**: a circle with N, E, S and W, where each satellite is a dot, coloured by constellation, and filled when the receiver uses it. A legend counts, for each constellation (GPS, GLONASS, Galileo, BeiDou), the satellites used and in view.
<kbd>Tab</kbd> goes from Position to **Sky**: a circle with N, E, S and W, where each satellite is a dot, coloured by constellation, and filled when the receiver uses it. A legend counts, for each constellation (GPS, GLONASS, Galileo, BeiDou), the satellites used and in view.
## Compass
<kbd>Tab</kbd> once more shows the **Compass**: a rose that turns with the device, so that its red `N` and the bright half of its needle stay to the north. The mark at the top of the rose is the top of the device, and the number beside it is where that points, in degrees and as one of 16 points (`247° WSW`).
The device has **no magnetometer**, so it cannot find north by itself. It learns it in one of two ways, then follows your turns with its motion sensor:
- **Walk.** At a walking pace (3 km/h or more) the receiver knows which way you are going, and the Compass takes that as where the device points. Hold it pointing ahead of you.
- **Aim at the sun.** Hold the device flat, point its top edge at the sun, and press <kbd>s</kbd>: the Compass works out where the sun is from the time and your position. It needs a fix, and the sun above the horizon.
Until one of these has happened the rose is dim and the screen says `North is not known`. After that, the line under the heading says where north came from and how long ago (`the sun, 40 s ago`). The motion sensor drifts by a few degrees a minute, so north should be given again now and then: after three minutes the line changes colour. Laying the device down for a few seconds lets the sensor steady itself and drift less. Leaving the GNSS App forgets north.
A yellow dot on the rose is the **sun**, where it is now: if the dot is not towards the sun, north is off.
The circle at the bottom is a **level**: its bubble is in the middle, and green, when the device is flat.
### Places
<kbd>m</kbd> **marks** where you are standing as a **Place**, under a name you type (the car, the tent), and makes it the target. With a target, the rose carries a cyan arrowhead that points to it, and the screen shows its name, how far it is and its bearing (`340 m 285°`): turn until the arrowhead is at the top and walk.
<kbd>t</kbd> lists the Places with their distances, to pick another target, or none; <kbd>d</kbd> in that list removes one. The Places are kept on the SD card in `/gnss/places.gpx`, as GPX waypoints: a map program opens the file, and waypoints you put in it from one show up in the list (50 at most).
## Tracks
@@ -33,4 +54,4 @@ Once there is a fix, the device's clock follows it.
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["gnss"]) }}
{{ keys(scopes=["gnss", "gnss-compass", "gnss-places"]) }}
+21 -1
View File
@@ -142,10 +142,30 @@ rows = [
id = "gnss"
title = "GNSS"
rows = [
["Tab", "the position, or the sky"],
["Tab", "position, sky, compass"],
["r", "record a Track, or stop it"],
]
[[scope]]
id = "gnss-compass"
title = "GNSS, the compass"
rows = [
["Tab", "position, sky, compass"],
["m", "mark this place"],
["t", "the target: a place"],
["s", "aimed at the sun: north"],
["r", "record a Track, or stop it"],
]
[[scope]]
id = "gnss-places"
title = "GNSS, the places"
rows = [
["; .", "up, down"],
["Enter", "the target"],
["d Del", "remove the place"],
]
[[scope]]
id = "gemini"
title = "Gemini, a page"
+1 -1
View File
@@ -25,7 +25,7 @@ REPO = SITE.parent
OUT = SITE / "content" / "dev"
REPO_URL = re.search(r'repo\s*=\s*"([^"]+)"', (SITE / "config.toml").read_text()).group(1)
MILESTONES = ["OTA", "M2", "G1", "M3", "S1", "F1", "R1", "W1", "U1", "N1"] # in the order they were done
MILESTONES = ["OTA", "M2", "G1", "M3", "S1", "F1", "R1", "W1", "U1", "N1", "H1"] # in the order they were done
# Left out on purpose: docs/milestones/M0.md, M1.md and CONTEXT.md (the glossary) describe Wi-Fi monitoring, which this site does not publish.
# They stay in the repository.
+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
+10 -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});
@@ -684,6 +684,7 @@ static const char* const kHelp =
"lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100)\n"
"lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)\n"
"gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)\n"
"imu what the motion sensor reads: g on its three axes, then degrees a second\n"
"gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n"
"crash the last crash: firmware, reason, task, backtrace\n"
"coredump erase forget the core dump in flash\n"
@@ -1145,6 +1146,14 @@ static void runCommand(String line, bool fromSerial = false) {
settings.setBool(Setting::GnssQuietForLora, line.endsWith("on"));
console.printf("gnss quiet: %s\n", line.endsWith("on") ? "GNSS pauses while the LoRa radio listens" : "GNSS stays on");
}
if (line == "imu") { // the Compass's sensor (issue #16), as it reads now
if (!M5.Imu.update()) console.println("imu: no reading");
else {
auto d = M5.Imu.getImuData();
console.printf("imu: type %d, accel %.2f %.2f %.2f g, gyro %.1f %.1f %.1f deg/s\n", static_cast<int>(M5.Imu.getType()),
d.accel.x, d.accel.y, d.accel.z, d.gyro.x, d.gyro.y, d.gyro.z);
}
}
if (line == "gnss status" && gnssService) gnssService->printStatus(console, millis());
if ((line == "gnss nmea on" || line == "gnss nmea off") && gnssService) gnssService->setEcho(line.endsWith("on"));
if (line == "gnss restart" && gnssService) gnssService->restart(millis());
+194
View File
@@ -0,0 +1,194 @@
#include <unity.h>
#include <cmath>
#include <string>
#include "compass.h"
using namespace roro::gnss;
void setUp() {}
void tearDown() {}
namespace {
const float kFlat[3] = {0, 0, 1};
const float kNoTurn[3] = {0, 0, 0};
// `seconds` of readings at 100 Hz.
void run(HeadingFilter& f, const float gyro[3], const float accel[3], float seconds) {
for (int i = 0; i < static_cast<int>(seconds * 100 + 0.5f); i++) f.motion(gyro, accel, 0.01f);
}
} // namespace
void test_angles_wrap() {
TEST_ASSERT_FLOAT_WITHIN(0.001, 350.0, wrap360(-10.0f));
TEST_ASSERT_FLOAT_WITHIN(0.001, 10.0, wrap360(370.0f));
TEST_ASSERT_FLOAT_WITHIN(0.001, 20.0, angleDiff(10.0f, 350.0f));
TEST_ASSERT_FLOAT_WITHIN(0.001, -20.0, angleDiff(350.0f, 10.0f));
TEST_ASSERT_FLOAT_WITHIN(0.001, -180.0, angleDiff(180.0f, 0.0f));
}
void test_bearing_to_a_point() {
TEST_ASSERT_FLOAT_WITHIN(0.01, 0.0, bearingDegrees(50.0, 4.0, 51.0, 4.0));
TEST_ASSERT_FLOAT_WITHIN(0.01, 180.0, bearingDegrees(50.0, 4.0, 49.0, 4.0));
TEST_ASSERT_FLOAT_WITHIN(0.5, 90.0, bearingDegrees(50.0, 4.0, 50.0, 4.1));
TEST_ASSERT_FLOAT_WITHIN(0.5, 270.0, bearingDegrees(50.0, 4.0, 50.0, 3.9));
// Brussels to Paris: south-south-west.
TEST_ASSERT_FLOAT_WITHIN(1.0, 214.0, bearingDegrees(50.8503, 4.3517, 48.8566, 2.3522));
}
void test_cardinal_points() {
TEST_ASSERT_EQUAL_STRING("N", cardinal(0));
TEST_ASSERT_EQUAL_STRING("N", cardinal(359));
TEST_ASSERT_EQUAL_STRING("NNE", cardinal(22.5f));
TEST_ASSERT_EQUAL_STRING("E", cardinal(90));
TEST_ASSERT_EQUAL_STRING("WSW", cardinal(247));
TEST_ASSERT_EQUAL_STRING("NNW", cardinal(340));
TEST_ASSERT_EQUAL_STRING("N", cardinal(-5));
}
void test_distances_read_well() {
TEST_ASSERT_EQUAL_STRING("0 m", formatDistance(0.2).c_str());
TEST_ASSERT_EQUAL_STRING("340 m", formatDistance(340.4).c_str());
TEST_ASSERT_EQUAL_STRING("1.0 km", formatDistance(999.6).c_str());
TEST_ASSERT_EQUAL_STRING("1.2 km", formatDistance(1234).c_str());
TEST_ASSERT_EQUAL_STRING("12 km", formatDistance(12345).c_str());
}
void test_the_sun_at_an_equinox() {
// 2024-03-20 03:06 UTC, the March equinox: the sun is over the equator. Twelve hours of
// turning later, at 12:07:30 UTC (the equation of time is -7.5 min), it is over Greenwich.
int64_t noon = 1710936450;
SunPosition s = sunPosition(noon, 0.0, 0.0);
TEST_ASSERT_FLOAT_WITHIN(0.3, 90.0, s.elevationDeg);
s = sunPosition(noon, 50.0, 0.0); // from 50° north: due south, 40° up
TEST_ASSERT_FLOAT_WITHIN(0.5, 180.0, s.azimuthDeg);
TEST_ASSERT_FLOAT_WITHIN(0.3, 40.0, s.elevationDeg);
s = sunPosition(noon, -50.0, 0.0); // from 50° south: due north
TEST_ASSERT_TRUE(s.azimuthDeg < 1.0f || s.azimuthDeg > 359.0f);
// Six hours earlier it rises, due east at an equinox, anywhere.
s = sunPosition(noon - 6 * 3600, 50.0, 0.0);
TEST_ASSERT_FLOAT_WITHIN(1.0, 90.0, s.azimuthDeg);
TEST_ASSERT_FLOAT_WITHIN(1.0, 0.0, s.elevationDeg);
s = sunPosition(noon + 6 * 3600, 50.0, 0.0); // and sets due west
TEST_ASSERT_FLOAT_WITHIN(1.0, 270.0, s.azimuthDeg);
}
void test_the_sun_at_a_solstice() {
// 2024-06-20 20:51 UTC: 23.44° north of the equator. At noon in Brussels that day
// (11:44 UTC by the sun) it stands 90 - 50.85 + 23.44 = 62.6° up, in the south.
SunPosition s = sunPosition(1718883840, 50.85, 4.35);
TEST_ASSERT_FLOAT_WITHIN(1.0, 180.0, s.azimuthDeg);
TEST_ASSERT_FLOAT_WITHIN(0.3, 62.6, s.elevationDeg);
// At midnight it is under the horizon, in the north.
s = sunPosition(1718883840 + 12 * 3600, 50.85, 4.35);
TEST_ASSERT_TRUE(s.elevationDeg < 0);
TEST_ASSERT_TRUE(s.azimuthDeg < 3.0f || s.azimuthDeg > 357.0f);
}
void test_no_heading_until_north_is_given() {
HeadingFilter f;
const float turn[3] = {0, 0, 30};
run(f, turn, kFlat, 1);
TEST_ASSERT_FALSE(f.known());
f.course(120, 1.0f); // too slow: the course means nothing
TEST_ASSERT_FALSE(f.known());
f.course(120, 4.0f);
TEST_ASSERT_TRUE(f.known());
TEST_ASSERT_FLOAT_WITHIN(0.01, 120.0, f.heading());
TEST_ASSERT_TRUE(f.source() == HeadingFilter::Source::Course);
}
void test_the_gyroscope_follows_a_turn() {
HeadingFilter f;
f.course(100, 5.0f);
// A positive turn around "up" is anticlockwise from above: the heading goes down.
const float left[3] = {0, 0, 45};
run(f, left, kFlat, 2);
TEST_ASSERT_FLOAT_WITHIN(0.5, 10.0, f.heading());
const float right[3] = {0, 0, -45};
run(f, right, kFlat, 4);
TEST_ASSERT_FLOAT_WITHIN(0.5, 190.0, f.heading());
TEST_ASSERT_FLOAT_WITHIN(0.05, 6.0, f.heldSeconds());
}
void test_only_the_turn_around_the_vertical_counts() {
HeadingFilter f;
f.course(0, 5.0f);
// Held upright, the screen facing the user: "up" is along the sensor's y.
const float upright[3] = {0, 1, 0};
run(f, kNoTurn, upright, 1);
const float roll[3] = {0, 0, 90}; // around the screen's own axis: no change of heading
run(f, roll, upright, 1);
TEST_ASSERT_TRUE(std::fabs(angleDiff(f.heading(), 0)) < 0.5f);
const float yaw[3] = {0, -90, 0}; // around the vertical
run(f, yaw, upright, 1);
TEST_ASSERT_FLOAT_WITHIN(1.0, 90.0, f.heading());
}
void test_the_course_pulls_the_heading_gently() {
HeadingFilter f;
f.course(350, 5.0f);
f.course(10, 5.0f); // through north, the short way
TEST_ASSERT_FLOAT_WITHIN(0.01, 356.0, f.heading());
for (int i = 0; i < 20; i++) f.course(10, 5.0f);
TEST_ASSERT_FLOAT_WITHIN(0.1, 10.0, f.heading());
TEST_ASSERT_FLOAT_WITHIN(0.001, 0.0, f.heldSeconds());
}
void test_the_sun_sets_north() {
HeadingFilter f;
f.sun(203);
TEST_ASSERT_TRUE(f.known());
TEST_ASSERT_TRUE(f.source() == HeadingFilter::Source::Sun);
TEST_ASSERT_FLOAT_WITHIN(0.01, 203.0, f.heading());
f.forget();
TEST_ASSERT_FALSE(f.known());
}
void test_the_gyroscope_offset_is_learnt_at_rest() {
HeadingFilter f;
f.sun(90);
const float offset[3] = {0.3f, -0.2f, 0.8f}; // what a BMI270 reads lying still
run(f, offset, kFlat, 30);
TEST_ASSERT_TRUE(f.still());
float before = f.heading();
run(f, offset, kFlat, 60); // a minute later it has not drifted
TEST_ASSERT_FLOAT_WITHIN(0.2, before, f.heading());
// A real turn is still measured, with the offset taken off.
const float turn[3] = {0.3f, -0.2f, 0.8f - 90.0f};
run(f, turn, kFlat, 1);
TEST_ASSERT_FLOAT_WITHIN(0.5, before + 90.0f, f.heading());
TEST_ASSERT_FALSE(f.still());
}
void test_tilt_follows_the_accelerometer() {
HeadingFilter f;
run(f, kNoTurn, kFlat, 1);
TEST_ASSERT_FLOAT_WITHIN(0.01, 0.0, f.tiltX());
TEST_ASSERT_FLOAT_WITHIN(0.01, 0.0, f.tiltY());
const float tilted[3] = {0.5f, 0, 0.866f}; // 30° over
run(f, kNoTurn, tilted, 2);
TEST_ASSERT_FLOAT_WITHIN(0.01, 0.5, f.tiltX());
const float shaken[3] = {3, 3, 3}; // not gravity: ignored
run(f, kNoTurn, shaken, 1);
TEST_ASSERT_FLOAT_WITHIN(0.01, 0.5, f.tiltX());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_angles_wrap);
RUN_TEST(test_bearing_to_a_point);
RUN_TEST(test_cardinal_points);
RUN_TEST(test_distances_read_well);
RUN_TEST(test_the_sun_at_an_equinox);
RUN_TEST(test_the_sun_at_a_solstice);
RUN_TEST(test_no_heading_until_north_is_given);
RUN_TEST(test_the_gyroscope_follows_a_turn);
RUN_TEST(test_only_the_turn_around_the_vertical_counts);
RUN_TEST(test_the_course_pulls_the_heading_gently);
RUN_TEST(test_the_sun_sets_north);
RUN_TEST(test_the_gyroscope_offset_is_learnt_at_rest);
RUN_TEST(test_tilt_follows_the_accelerometer);
return UNITY_END();
}
+75
View File
@@ -0,0 +1,75 @@
#include <unity.h>
#include <cmath>
#include <string>
#include "places.h"
using namespace roro::gnss;
void setUp() {}
void tearDown() {}
void test_places_round_trip() {
std::vector<Place> places = {{"Car", 50.8503123, 4.3517456}, {"Tent <A&B>", -33.9, -151.2}};
std::string gpx = placesGpx(places);
TEST_ASSERT_NOT_EQUAL(std::string::npos, gpx.find("<wpt lat=\"50.8503123\" lon=\"4.3517456\"><name>Car</name></wpt>"));
TEST_ASSERT_NOT_EQUAL(std::string::npos, gpx.find("<name>Tent &lt;A&amp;B&gt;</name>"));
TEST_ASSERT_NOT_EQUAL(std::string::npos, gpx.find("</gpx>"));
std::vector<Place> back = parsePlaces(gpx);
TEST_ASSERT_EQUAL(2, back.size());
TEST_ASSERT_EQUAL_STRING("Car", back[0].name.c_str());
TEST_ASSERT_TRUE(std::fabs(back[0].latitude - (50.8503123)) < 1e-7);
TEST_ASSERT_TRUE(std::fabs(back[0].longitude - (4.3517456)) < 1e-7);
TEST_ASSERT_EQUAL_STRING("Tent <A&B>", back[1].name.c_str());
TEST_ASSERT_TRUE(std::fabs(back[1].longitude - (-151.2)) < 1e-7);
}
void test_an_empty_list_is_still_gpx() {
std::string gpx = placesGpx({});
TEST_ASSERT_NOT_EQUAL(std::string::npos, gpx.find("<gpx version=\"1.1\""));
TEST_ASSERT_EQUAL(0, parsePlaces(gpx).size());
TEST_ASSERT_EQUAL(0, parsePlaces("").size());
}
void test_waypoints_from_a_map_program() {
std::string gpx =
"<?xml version='1.0'?>\n<gpx>\n"
" <wpt lon='4.40' lat='51.22'>\n <ele>7</ele>\n <name>Antwerp</name>\n <sym>City</sym>\n </wpt>\n"
" <wpt lat=\"50.0\" lon=\"5.0\"/>\n"
" <wpt lat=\"95.0\" lon=\"5.0\"><name>Nowhere</name></wpt>\n"
" <wpt lat=\"x\" lon=\"5.0\"><name>Broken</name></wpt>\n"
" <wpt lat=\"50.5\" lon=\"5.5\"><desc>no name</desc></wpt>\n"
" <trk><trkseg><trkpt lat=\"1\" lon=\"2\"><name>not a place</name></trkpt></trkseg></trk>\n"
"</gpx>\n";
std::vector<Place> p = parsePlaces(gpx);
TEST_ASSERT_EQUAL(3, p.size());
TEST_ASSERT_EQUAL_STRING("Antwerp", p[0].name.c_str());
TEST_ASSERT_TRUE(std::fabs(p[0].latitude - (51.22)) < 1e-7);
TEST_ASSERT_TRUE(std::fabs(p[0].longitude - (4.40)) < 1e-7);
TEST_ASSERT_EQUAL_STRING("Place 2", p[1].name.c_str());
TEST_ASSERT_EQUAL_STRING("Place 3", p[2].name.c_str());
}
void test_a_cut_file_gives_what_is_whole() {
std::string gpx = placesGpx({{"A", 1, 2}, {"B", 3, 4}});
gpx.resize(gpx.find("<name>B"));
std::vector<Place> p = parsePlaces(gpx);
TEST_ASSERT_EQUAL(1, p.size());
TEST_ASSERT_EQUAL_STRING("A", p[0].name.c_str());
}
void test_no_more_than_the_limit() {
std::vector<Place> many(kMaxPlaces + 10, Place{"P", 1, 2});
TEST_ASSERT_EQUAL(kMaxPlaces, parsePlaces(placesGpx(many)).size());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_places_round_trip);
RUN_TEST(test_an_empty_list_is_still_gpx);
RUN_TEST(test_waypoints_from_a_map_program);
RUN_TEST(test_a_cut_file_gives_what_is_whole);
RUN_TEST(test_no_more_than_the_limit);
return UNITY_END();
}