Compare commits

...
Author SHA1 Message Date
twislaandClaude Opus 5.5 c3fd9909a1 Updates: a newer release installs by itself, after a question
CI / build (pull_request) Successful in 2m8s
Site / build (pull_request) Successful in 11s
With Settings > System > Install updates on By itself (issue #52), a
release found by the daily check is offered once no key was pressed for
2 minutes and nothing would be cut by a restart. The question goes over
whatever App is open, with Cancel and Accept, and installs after 30 s
without an answer. Each update that held, and each one undone, is a
dated line in /system/updates.log on the card.

Tried on the device: the question, Cancel, and an install of v0.23.0
left unanswered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 17:49:09 +02:00
twislaandClaude Opus 5.5 8ba0102910 Settings: Install updates, when asked or by itself
Off by default: what runs on the device changes only when its owner
said it may (issue #52).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 17:49:09 +02:00
twislaandClaude Opus 5.5 17651cb911 Updates: when a release may install by itself, as a state machine
Issue #52: once a newer release is known, wait for a quiet moment, ask
for 30 seconds, and install unless the answer is no. The caller says
what busy and idle are. Host-tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 17:49:09 +02:00
twisla 5af695f43a Merge pull request 'GNSS App: a Compass, with Places and a level' (#98) from compass into main
CI / build (push) Successful in 1m6s
Site / build (push) Successful in 14s
2026-10-11 15:17:16 +00:00
twislaandClaude Opus 5.5 544bd726df 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
2026-10-11 17:01:57 +02:00
twislaandClaude Opus 5.5 65a9643910 Console: imu prints what the motion sensor reads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 17:01:57 +02:00
twislaandClaude Opus 5.5 a87c3016cc GNSS: Places, kept as GPX waypoints
A Place is a named position the user marked (issue #16). The list is
read from and written to a GPX file, so a map program opens it and
waypoints made by one are read here. Host-tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 17:01:57 +02:00
twislaandClaude Opus 5.5 da311a11b6 GNSS: a heading from the gyroscope, the course and the sun
The device has no magnetometer (issue #16): north is given by the course
over ground while walking, or by aiming at the sun, and the gyroscope
follows the turn around the vertical in between. With the sun's position
from the time and the place, the bearing to a point, the 16 points and
distances as text. Host-tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 17:01:57 +02:00
twisla fd534af971 Merge pull request 'Launcher grid with icons, themes, Settings in groups, the website's colours' (#97) from launcher-grid into main
Site / build (push) Successful in 12s
CI / build (push) Successful in 3m11s
2026-10-09 22:25:49 +00:00
37 changed files with 1638 additions and 40 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
+3 -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.
@@ -107,6 +107,7 @@ With no PC and no card, the device can install the project's releases itself (do
- **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update.
- **Older releases** lists the last ten, newest first. Opening an older one offers to go back to it, with a different question.
- **Settings → System → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device.
- **Settings → System → Install updates** (`When asked` by default; `By itself`, issue #52): a newer release found by the daily check is installed without being asked for. The device waits until no key was pressed for 2 minutes and nothing would be cut by a restart (a Track, a Capture, a copy, an upload, an SSH session, a web share), then asks on the screen for 30 seconds, lighting it if it was off: Cancel leaves it until the next day's check; Accept, or no answer, installs it and restarts. What was installed and when, and what was undone, is written in `/system/updates.log` on the card.
The connection is checked against the two ISRG roots Let's Encrypt chains end in (ADR 0009), not the usual bundle of about 130 authorities. Whatever the connection, the Update File's own signature is what decides what gets installed.
@@ -241,6 +242,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.
+33 -1
View File
@@ -1,6 +1,6 @@
# R1 — Releases
**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Not started: the Issues App (#4), automatic installs (#52), release channels (#53), resuming a download (#54).
**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Built, not released yet: installing a release by itself (#52). Not started: the Issues App (#4), release channels (#53), resuming a download (#54).
**Goal:** a tag is a release, built the same way every time and published where a device can find it.
@@ -124,6 +124,38 @@ The device looks at the project's Gitea for a newer release, says so, and instal
**Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure.
## Installing a release by itself (issue #52)
For a device that sits on a desk, "always on the latest" may be what is wanted. Until now the daily check only said that a release was out.
### Decisions (2026-10-11)
| # | Decision |
|---|---|
| Q275 | **A setting, off by default:** Settings > System > Install updates, `When asked` or `By itself`. It changes what runs on the device without anyone looking, so it is never on unless the owner said so. |
| Q276 | **The owner is asked, and silence is yes:** a question over whatever is open, `Cancel` or `Accept`, that answers itself after 30 seconds by installing. The screen lights up for it if it was off. (The owner's own answer on the issue.) |
| Q277 | **The question comes before the download,** not before the restart: the download already takes the screen over and may take IRC offline for its memory, so that is the moment to ask. |
| Q278 | **Only in a quiet moment:** no key for 2 minutes, and nothing a restart would cut: a Track, a Capture, a file operation, an upload, an scp transfer, an SSH session, a web share, a noise test, another update. If one of them starts while the question is up, the question goes and comes back later with a full count. |
| Q279 | **Any newer release,** not only patch releases, and with no delay after it is published: Probation and Rollback are what protect against a bad one, and a version that rolled back here is never offered again. |
| Q280 | **Cancel lasts a day:** the release is offered again at the next daily check, for as long as it is the newest. |
| Q281 | **A record:** each update that held (and whether it installed by itself), and each one that was undone, is a dated line in `/system/updates.log` on the card. |
### As built
- **`lib/release/src/auto_update.h`**, host-tested (10 tests): the waiting, the question and its count, as a state machine that is told what "busy" and "idle" are.
- **The Update Service** offers the release at each background check when the setting is on (`shouldInstall`), and writes the record when Probation confirms a version or a rollback is reported.
- **The main loop** decides what busy is, puts the question up through `Screen::render`, and gives it every key while it is up.
- `update status` says whether a release is waiting for its quiet moment.
### Checked on the device (2026-10-11)
With the setting on, `update pretend v0.22.0` and `update daily`:
- The question came up over the Settings App, counting down each second. **Cancel** took it down and nothing was installed.
- Left unanswered, the device downloaded v0.23.0, installed it and restarted into it.
- `/system/updates.log` got its line at each confirmed update.
- Not checked: "by itself" in the record (the release installed did not have this code yet), the 2 minutes without a key and the busy cases (host tests only), and a screen that was off lighting up. Keys sent through the Debug Console do not count as keys.
## One firmware: the Debug Console in every build (issue #68)
The issue asked for a token that could be set, so that Debug Builds could be published. The design round ended somewhere simpler: no Debug Builds. The console is in every firmware, off until switched on, with a token that belongs to the device (ADR 0010, which supersedes part of ADR 0004).
+1 -1
View File
@@ -130,7 +130,7 @@ Settings had grown to 21 rows in one list. It now opens on five groups, each a s
| Display | Brightness, Dim after, Screen off after, Theme, Light or dark, Launcher |
| GNSS and radio | GNSS, Pause GNSS for LoRa, Coordinates, Probe MACs |
| Network | Wi-Fi, VPN |
| System | Check for updates, Firmware, Debug Console, About |
| System | Check for updates, Install updates, Firmware, Debug Console, About |
- Enter opens a group, Back returns to the groups with the selection on the one just left, and Back from the groups leaves Settings. An open group has its name over its rows.
- `SettingsMenu` (`lib/apps_model`) holds the groups and which one is open; every index is into what is listed. Host-tested: every setting is in exactly one group, and no group has more rows than fit under its name.
+3
View File
@@ -48,6 +48,7 @@ const Member kRows[] = {
{3, {Row::Wifi, Kind::Page, "Wi-Fi"}},
{3, {Row::Vpn, Kind::Page, "VPN"}},
{4, {Row::CheckUpdates, Kind::Toggle, "Check for updates"}},
{4, {Row::InstallUpdates, Kind::Toggle, "Install updates"}},
{4, {Row::Firmware, Kind::Page, "Firmware"}},
{4, {Row::DebugConsole, Kind::Page, "Debug Console"}},
{4, {Row::About, Kind::Page, "About"}},
@@ -152,6 +153,7 @@ std::string SettingsMenu::value(int i) const {
case Row::Gnss: return settings_.getBool(Setting::GnssEnabled) ? "On" : "Off";
case Row::GnssQuiet: return settings_.getBool(Setting::GnssQuietForLora) ? "On" : "Off";
case Row::CheckUpdates: return settings_.getBool(Setting::CheckUpdates) ? "Daily" : "Off";
case Row::InstallUpdates: return settings_.getBool(Setting::InstallUpdates) ? "By itself" : "When asked";
case Row::Coordinates: return settings_.getBool(Setting::CoordinatesDms) ? "Deg min sec" : "Decimal";
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
@@ -210,6 +212,7 @@ void SettingsMenu::toggle(int i) {
if (row(i) == Row::Gnss) settings_.setBool(Setting::GnssEnabled, !settings_.getBool(Setting::GnssEnabled));
if (row(i) == Row::GnssQuiet) settings_.setBool(Setting::GnssQuietForLora, !settings_.getBool(Setting::GnssQuietForLora));
if (row(i) == Row::CheckUpdates) settings_.setBool(Setting::CheckUpdates, !settings_.getBool(Setting::CheckUpdates));
if (row(i) == Row::InstallUpdates) settings_.setBool(Setting::InstallUpdates, !settings_.getBool(Setting::InstallUpdates));
if (row(i) == Row::Coordinates) settings_.setBool(Setting::CoordinatesDms, !settings_.getBool(Setting::CoordinatesDms));
if (row(i) == Row::ProbeMacs) settings_.setBool(Setting::ProbeMacRaw, !settings_.getBool(Setting::ProbeMacRaw));
}
+1 -1
View File
@@ -12,7 +12,7 @@ namespace roro {
// Rendering and navigation live in the App. Every index is into what is listed now.
class SettingsMenu {
public:
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Launcher, Theme, ThemeLight, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About,
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Launcher, Theme, ThemeLight, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, InstallUpdates, Firmware, Vpn, DebugConsole, About,
GroupDevice, GroupDisplay, GroupPosition, GroupNetwork, GroupSystem };
enum class Kind { Text, Choice, Toggle, Slider, Page, Group };
+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
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include <cstdint>
#include <string>
namespace roro::release {
// Installing a release without being asked to (issue #52): once a newer one is known, wait for a
// quiet moment (nothing recording, nothing written, no key for a while), then say so on the
// screen for 30 seconds. Unless someone answers no, it is installed. Host-tested; the caller says
// what "quiet" is and does the installing.
class AutoUpdate {
public:
static constexpr uint32_t kIdleMs = 120000; // no key for this long: nobody is using the device
static constexpr uint32_t kAskMs = 30000; // how long the question stays before it answers itself
enum class Action : uint8_t {
None,
Ask, // the question goes up
Dismiss, // the question comes down, nothing is installed
Install, // the question comes down, install tag()
};
// A newer release is known. A second offer while one is waiting or asked replaces its tag.
void offer(const std::string& tag) {
tag_ = tag;
if (state_ == State::Idle) state_ = State::Waiting;
}
// The setting went off, or the release is no longer wanted.
void withdraw() { withdrawn_ = true; }
// The user's answer to the question.
void answer(bool install) {
if (state_ != State::Asking) return;
answered_ = true;
accepted_ = install;
}
// Call often. `busy`: something is recording or being written, and a restart would cut it.
// `idleMs`: how long since the last key.
Action step(uint32_t nowMs, bool busy, uint32_t idleMs) {
bool withdrawn = withdrawn_;
withdrawn_ = false;
switch (state_) {
case State::Idle: return Action::None;
case State::Waiting:
if (withdrawn) return state_ = State::Idle, Action::None;
if (busy || idleMs < kIdleMs) return Action::None;
state_ = State::Asking;
askedMs_ = nowMs;
answered_ = false;
return Action::Ask;
case State::Asking:
if (withdrawn) return state_ = State::Idle, Action::Dismiss;
if (answered_) return state_ = State::Idle, accepted_ ? Action::Install : Action::Dismiss;
// Something started meanwhile (a Track from a console): the question goes, and comes back later.
if (busy) return state_ = State::Waiting, Action::Dismiss;
if (nowMs - askedMs_ >= kAskMs) return state_ = State::Idle, Action::Install;
return Action::None;
}
return Action::None;
}
bool waiting() const { return state_ == State::Waiting; }
bool asking() const { return state_ == State::Asking; }
const std::string& tag() const { return tag_; }
// Whole seconds until the question answers itself, rounded up.
int secondsLeft(uint32_t nowMs) const {
uint32_t gone = nowMs - askedMs_;
return !asking() || gone >= kAskMs ? 0 : static_cast<int>((kAskMs - gone + 999) / 1000);
}
private:
enum class State : uint8_t { Idle, Waiting, Asking };
State state_ = State::Idle;
std::string tag_;
uint32_t askedMs_ = 0;
bool answered_ = false, accepted_ = false, withdrawn_ = false;
};
} // namespace roro::release
+6
View File
@@ -24,6 +24,12 @@ inline bool shouldAnnounce(const Release& latest, const std::string& running, co
return isNewer(latest, running) && latest.tag != failed && latest.tag != announced;
}
// Should the background check offer to install it by itself (issue #52)? Any newer release, each
// day it is still the newest; never a version that rolled back on this device.
inline bool shouldInstall(const Release& latest, const std::string& running, const std::string& failed) {
return isNewer(latest, running) && latest.tag != failed;
}
// Is `url` a download this device takes from the project's server, for this repository? Whatever
// the API says, the device only fetches from where it asked (a redirected or rewritten answer
// can't send it elsewhere).
+1
View File
@@ -56,6 +56,7 @@ const Definition kDefinitions[] = {
{"launcher_list", Kind::Bool, 0, nullptr, 0, 1}, // off: the grid
{"theme", Kind::Int, 0, nullptr, 0, 6}, // roro9stack's own; ui::kThemeCount of them
{"theme_light", Kind::Bool, 0, nullptr, 0, 1},
{"auto_install", Kind::Bool, 0, nullptr, 0, 1}, // off: what runs here changes only when someone says so
};
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
"every Setting needs a definition");
+1
View File
@@ -44,6 +44,7 @@ enum class Setting : uint8_t {
LauncherList, // bool: the Launcher is a list of names instead of a grid of icons (issue #9)
Theme, // int: the colour theme, an index into ui::palette (issue #10)
ThemeLight, // bool: its light side instead of its dark one
InstallUpdates, // bool: a newer release found by the daily check is installed without being asked for (issue #52)
Count
};
+1
View File
@@ -49,6 +49,7 @@ With no PC and no card, the device can install the project's releases itself (do
- **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update.
- **Older releases** lists the last ten, newest first. Opening an older one offers to go back to it, with a different question.
- **Settings → System → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device.
- **Settings → System → Install updates** (`When asked` by default; `By itself`, issue #52): a newer release found by the daily check is installed without being asked for. The device waits until no key was pressed for 2 minutes and nothing would be cut by a restart (a Track, a Capture, a copy, an upload, an SSH session, a web share), then asks on the screen for 30 seconds, lighting it if it was off: Cancel leaves it until the next day's check; Accept, or no answer, installs it and restarts. What was installed and when, and what was undone, is written in `/system/updates.log` on the card.
The connection is checked against the two ISRG roots Let's Encrypt chains end in (ADR 0009), not the usual bundle of about 130 authorities. Whatever the connection, the Update File's own signature is what decides what gets installed.
+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.
+33 -1
View File
@@ -8,7 +8,7 @@ docs = true
source = "docs/milestones/R1.md"
tag = "R1"
+++
**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Not started: the Issues App (#4), automatic installs (#52), release channels (#53), resuming a download (#54).
**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Built, not released yet: installing a release by itself (#52). Not started: the Issues App (#4), release channels (#53), resuming a download (#54).
**Goal:** a tag is a release, built the same way every time and published where a device can find it.
@@ -132,6 +132,38 @@ The device looks at the project's Gitea for a newer release, says so, and instal
**Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure.
## Installing a release by itself (issue #52)
For a device that sits on a desk, "always on the latest" may be what is wanted. Until now the daily check only said that a release was out.
### Decisions (2026-10-11)
| # | Decision |
|---|---|
| Q275 | **A setting, off by default:** Settings > System > Install updates, `When asked` or `By itself`. It changes what runs on the device without anyone looking, so it is never on unless the owner said so. |
| Q276 | **The owner is asked, and silence is yes:** a question over whatever is open, `Cancel` or `Accept`, that answers itself after 30 seconds by installing. The screen lights up for it if it was off. (The owner's own answer on the issue.) |
| Q277 | **The question comes before the download,** not before the restart: the download already takes the screen over and may take IRC offline for its memory, so that is the moment to ask. |
| Q278 | **Only in a quiet moment:** no key for 2 minutes, and nothing a restart would cut: a Track, a Capture, a file operation, an upload, an scp transfer, an SSH session, a web share, a noise test, another update. If one of them starts while the question is up, the question goes and comes back later with a full count. |
| Q279 | **Any newer release,** not only patch releases, and with no delay after it is published: Probation and Rollback are what protect against a bad one, and a version that rolled back here is never offered again. |
| Q280 | **Cancel lasts a day:** the release is offered again at the next daily check, for as long as it is the newest. |
| Q281 | **A record:** each update that held (and whether it installed by itself), and each one that was undone, is a dated line in `/system/updates.log` on the card. |
### As built
- **`lib/release/src/auto_update.h`**, host-tested (10 tests): the waiting, the question and its count, as a state machine that is told what "busy" and "idle" are.
- **The Update Service** offers the release at each background check when the setting is on (`shouldInstall`), and writes the record when Probation confirms a version or a rollback is reported.
- **The main loop** decides what busy is, puts the question up through `Screen::render`, and gives it every key while it is up.
- `update status` says whether a release is waiting for its quiet moment.
### Checked on the device (2026-10-11)
With the setting on, `update pretend v0.22.0` and `update daily`:
- The question came up over the Settings App, counting down each second. **Cancel** took it down and nothing was installed.
- Left unanswered, the device downloaded v0.23.0, installed it and restarted into it.
- `/system/updates.log` got its line at each confirmed update.
- Not checked: "by itself" in the record (the release installed did not have this code yet), the 2 minutes without a key and the busy cases (host tests only), and a screen that was off lighting up. Keys sent through the Debug Console do not count as keys.
## One firmware: the Debug Console in every build (issue #68)
The issue asked for a token that could be set, so that Debug Builds could be published. The design round ended somewhere simpler: no Debug Builds. The console is in every firmware, off until switched on, with a token that belongs to the device (ADR 0010, which supersedes part of ADR 0004).
+1 -1
View File
@@ -138,7 +138,7 @@ Settings had grown to 21 rows in one list. It now opens on five groups, each a s
| Display | Brightness, Dim after, Screen off after, Theme, Light or dark, Launcher |
| GNSS and radio | GNSS, Pause GNSS for LoRa, Coordinates, Probe MACs |
| Network | Wi-Fi, VPN |
| System | Check for updates, Firmware, Debug Console, About |
| System | Check for updates, Install updates, Firmware, Debug Console, About |
- Enter opens a group, Back returns to the groups with the selection on the one just left, and Back from the groups leaves Settings. An open group has its name over its rows.
- `SettingsMenu` (`lib/apps_model`) holds the groups and which one is open; every index is into what is listed. Host-tested: every setting is in exactly one group, and no group has more rows than fit under its name.
+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"]) }}
+1
View File
@@ -50,6 +50,7 @@ Inside a group, move with the arrows. On a toggle, a choice or a slider, left an
| Setting | What it is |
|---|---|
| **Check for updates** | Once a day, see [Updates](/guide/updates/) |
| **Install updates** | `When asked` (the default), or `By itself`: a newer release is installed in a quiet moment, after a question that answers itself in 30 seconds. See [Updates](/guide/updates/) |
| **Firmware** | The page described in [Updates](/guide/updates/) |
| **Debug Console** | Off unless you switch it on. It lets a PC on the same network read the device's console and drive it, with a token shown on this page: see [the developer docs](/dev/debug/switch-it-on/). Leave it off if that means nothing to you |
| **About** | The firmware version, the node number, battery, memory, uptime, clock and licence |
+13 -1
View File
@@ -21,7 +21,19 @@ An install needs Wi-Fi if it is a download. The new firmware is checked before a
## Check for updates
**Settings → System → Check for updates** is on by default. Once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs **nothing** by itself, and it does not announce a version that already failed and rolled back on this device.
**Settings → System → Check for updates** is on by default. Once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs **nothing** by itself unless you ask for that (below), and it does not announce a version that already failed and rolled back on this device.
## Install updates by itself
**Settings → System → Install updates** is `When asked` by default. Set to `By itself`, a newer release found by the daily check is installed without your going to the Firmware page:
1. The device waits for a **quiet moment**: no key pressed for 2 minutes, and nothing a restart would cut short (a Track or a Capture recording, files being copied or received, an SSH session, a web share).
2. It then asks on the screen, over whatever is open, and lights the screen if it was off: `Update to v0.24.0?`, with a count from **30 seconds**.
3. **Cancel** (or <kbd>`</kbd>) leaves things as they are until the next day's check. **Accept**, or no answer when the count ends, downloads the release, installs it and restarts.
Everything else is as for an install you ask for: the signature is checked before anything is written, the new firmware runs on Probation, and a version that rolled back on this device is not offered again. It needs **Check for updates** to be on.
Each update that held, whoever started it, and each one that was undone, is written with its date in `/system/updates.log` on the SD card.
## Probation and Rollback
+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
+79 -4
View File
@@ -20,6 +20,7 @@
#include "services/web_share.h"
#include "apps/gemini_app.h"
#include "apps/gnss_app.h"
#include "auto_update.h"
#include "apps/irc_app.h"
#include "apps/launcher_app.h"
#include "apps/lora_scanner_app.h"
@@ -81,6 +82,12 @@ static Screen screen;
static BatteryService* battery;
static StorageService* storageService;
static FileOps* fileOps;
static WebShare* webShare;
// Installing a release by itself (issue #52): see autoUpdateStep().
static release::AutoUpdate autoUpdate;
static std::unique_ptr<DialogModel> autoDialog;
static uint32_t lastKeyMs = 0;
static bool autoRedraw = false;
static std::vector<std::string> filesInUse(const std::string& path, bool folder);
static PowerService* power;
static ClockService* clockService;
@@ -235,10 +242,10 @@ 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});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *(webShare = new WebShare(*storageService, *fileOps, *wifi))), icons::storage});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power), icons::notes});
sshService = new SshService(settings);
sshApp = new SshApp(*sshService, settings, *storageService);
@@ -569,6 +576,9 @@ static void updateCommand(const String& args) {
console.printf("update: running %s, failed here before: %s, daily check %s, heap %u\n", update->runningVersion().c_str(),
update->failedVersion().empty() ? "none" : update->failedVersion().c_str(),
settings.getBool(Setting::CheckUpdates) ? "on" : "off", (unsigned)ESP.getFreeHeap());
console.printf("update: installing by itself %s%s%s\n", settings.getBool(Setting::InstallUpdates) ? "on" : "off",
autoUpdate.waiting() ? ", waiting for a quiet moment to offer " : autoUpdate.asking() ? ", asking about " : "",
autoUpdate.waiting() || autoUpdate.asking() ? autoUpdate.tag().c_str() : "");
console.printf("update: gitea %s %s\n", g.status() == GiteaReleases::Status::Done ? "done" : g.status() == GiteaReleases::Status::Failed ? "failed" : g.status() == GiteaReleases::Status::Busy ? "busy" : "never asked", g.error().c_str());
printReleases(false);
} else if (args.startsWith("install ")) {
@@ -684,6 +694,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"
@@ -848,8 +859,57 @@ static void screenshotKey() {
}
// Every key goes through here, from the keyboard or from a console's `key`.
// Installing a release by itself (issue #52, Settings > System > Install updates): once the daily
// check has found one, wait until nothing would be cut by a restart and no key was pressed for a
// while, then ask on the screen, over whatever is open. Left unanswered for 30 s, it is installed.
// Would a restart now cut something short?
static bool busyForRestart() {
return gnssService->tracking() || loraCapture->capturing() || upload.active() || fileOps->busy() ||
(scpService && scpService->busy()) || (sshService && sshService->active()) || (webShare && webShare->running()) ||
(noiseTest && noiseTest->running()) || update->phase() != UpdateService::Phase::Idle || update->onProbation() ||
update->giteaBusy();
}
static void autoUpdateStep(uint32_t now) {
static int shownSeconds = -1;
if (!settings.getBool(Setting::InstallUpdates) && (autoUpdate.waiting() || autoUpdate.asking())) autoUpdate.withdraw();
std::string tag;
if (update->takeOffer(tag)) autoUpdate.offer(tag);
if (autoDialog && autoDialog->result() != DialogModel::kPending) autoUpdate.answer(autoDialog->result() == 1);
switch (autoUpdate.step(now, busyForRestart(), now - lastKeyMs)) {
case release::AutoUpdate::Action::None: break;
case release::AutoUpdate::Action::Ask:
autoDialog.reset(new DialogModel({"Cancel", "Accept"}));
power->onNotification(now, now + release::AutoUpdate::kAskMs); // a screen that is off lights up for it
autoRedraw = true;
break;
case release::AutoUpdate::Action::Dismiss:
autoDialog.reset();
autoRedraw = true;
break;
case release::AutoUpdate::Action::Install: {
autoDialog.reset();
autoRedraw = true;
std::string why = update->requestInstall(autoUpdate.tag(), true);
if (!why.empty())
bus.publish(Event::withText(EventType::Notification, ("Update not installed: " + why).c_str(), static_cast<int32_t>(NotificationLevel::Warning)));
break;
}
}
int seconds = autoUpdate.asking() ? autoUpdate.secondsLeft(now) : -1;
if (seconds != shownSeconds) {
shownSeconds = seconds;
autoRedraw = true;
}
}
static void handleKey(const KeyEvent& e) {
if (e.key == Key::Screenshot) return screenshotKey();
if (autoDialog) { // the question takes every key until it is answered
autoDialog->onKey(e);
return;
}
apps->handleKey(e);
}
@@ -1145,6 +1205,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());
@@ -1525,6 +1593,7 @@ static void loopPass() {
printListingWhenReady();
M5Cardputer.update();
if (M5Cardputer.Keyboard.isChange()) {
lastKeyMs = now;
bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen
keyMapper.setTextEntry(apps->foreground().textEntryActive());
auto events = keyMapper.update(readKeys()); // always, so held keys are tracked
@@ -1540,6 +1609,7 @@ static void loopPass() {
bus.dispatch();
notifier->update(now);
apps->update(now);
autoUpdateStep(now);
if (now - lastLog > 10000) {
lastLog = now;
@@ -1585,10 +1655,15 @@ static void loopPass() {
screen.invalidate();
redrawAfterUpdate = true;
}
if (apps->takeRedraw() || toastChanged || status != lastStatus || redrawAfterUpdate) {
if (apps->takeRedraw() || toastChanged || status != lastStatus || redrawAfterUpdate || autoRedraw) {
lastStatus = status;
redrawAfterUpdate = false;
screen.render(*apps, status, toast);
autoRedraw = false;
if (autoDialog) {
Screen::Question question{"Update to " + autoUpdate.tag() + "?",
"It installs in " + std::to_string(autoUpdate.secondsLeft(now)) + " s unless you cancel, and the device restarts.", autoDialog.get()};
screen.render(*apps, status, toast, &question);
} else screen.render(*apps, status, toast);
update->firstFrameDrawn();
}
}
+40 -2
View File
@@ -94,6 +94,26 @@ void UpdateService::notify(const std::string& text, NotificationLevel level) {
bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(level)));
}
// A line in the record of updates on the card (issue #52): the firmware can change without
// anyone looking, so what was installed and when is written down.
void UpdateService::record(const std::string& what) {
time_t now = time(nullptr);
char when[24] = "undated";
if (now >= kClockSetAfter) {
struct tm t;
gmtime_r(&now, &t);
strftime(when, sizeof when, "%Y-%m-%d %H:%M UTC", &t);
}
storage_.appendLine(kRecordPath, std::string(when) + " " + what);
}
bool UpdateService::takeOffer(std::string& tag) {
if (!offer_) return false;
offer_ = false;
tag = offered_;
return true;
}
std::string UpdateService::incomingVersion() const { return incoming_; }
void UpdateService::start() {
@@ -108,6 +128,8 @@ void UpdateService::start() {
store_.getString("ota_from", from);
if (!pending.empty() && pending != versionString()) {
notify("Update to " + pending + " failed, back on " + versionString(), NotificationLevel::Warning);
record(pending + " failed and was undone: back on " + versionString());
store_.putString("ota_auto", "");
store_.putString("ota_failed", pending); // not announced again until there's a newer one (Q168)
store_.putString("ota_pending", "");
}
@@ -142,6 +164,14 @@ void UpdateService::tick(uint32_t nowMs) {
store_.putString("ota_pending", "");
store_.putInt("ota_attempts", 0);
notify(std::string("Updated to ") + versionString(), NotificationLevel::Info);
{
std::string from, automatic;
store_.getString("ota_from", from);
store_.getString("ota_auto", automatic);
record(std::string(versionString()) + " installed" + (automatic == versionString() ? " by itself" : "") +
(from.empty() ? "" : ", from " + from));
store_.putString("ota_auto", "");
}
break;
case Probation::Verdict::RollBack:
// ota_pending still names this version: the previous firmware will report the failure.
@@ -215,12 +245,13 @@ std::string UpdateService::failedVersion() const {
return failed;
}
std::string UpdateService::requestInstall(const std::string& tag) {
std::string UpdateService::requestInstall(const std::string& tag, bool automatic) {
if (phase_ != Phase::Idle || giteaBusy()) return "Busy with something else";
if (wifi_.state() != WifiController::State::Connected) return "No Wi-Fi";
release::Release r;
if (!gitea_.find(tag, r)) return "Look for releases first";
if (!release::trustedAssetUrl(r.otaUrl)) return "That download isn't on the project's server";
store_.putString("ota_auto", automatic ? tag : "");
installTag_ = tag;
request_ = Request::Install;
return "";
@@ -281,7 +312,14 @@ void UpdateService::serve() {
time_t now = time(nullptr);
if (now >= kClockSetAfter) store_.putInt("rel_day", static_cast<int32_t>(now / 86400));
release::Release latest;
if (r == Request::BackgroundCheck && gitea_.latest(latest) &&
// Installing by itself (issue #52): offered at each daily check while it is the newest,
// so a "not now" lasts a day. The main loop asks on the screen when the moment is quiet.
if (r == Request::BackgroundCheck && gitea_.latest(latest) && settings_.getBool(Setting::InstallUpdates) &&
release::shouldInstall(latest, runningVersion(), failedVersion())) {
announced_ = latest.tag;
offered_ = latest.tag;
offer_ = true;
} else if (r == Request::BackgroundCheck && gitea_.latest(latest) &&
release::shouldAnnounce(latest, runningVersion(), failedVersion(), announced_)) {
announced_ = latest.tag;
notify(latest.tag + " is out: Settings > System > Firmware", NotificationLevel::Info); // 48 bytes at most
+9 -2
View File
@@ -51,7 +51,12 @@ class UpdateService : public Service {
void requestList() { request_ = Request::List; }
// Downloads `tag` (a release known from the last check or list) into the inactive slot and
// installs it. "" when it started, or why not.
std::string requestInstall(const std::string& tag);
// `automatic`: nobody asked for it (issue #52), and the record of updates says so.
std::string requestInstall(const std::string& tag, bool automatic = false);
// A release the daily check found that may be installed without being asked for (the setting
// is on): true once for each find, with its tag. The main loop picks the moment.
bool takeOffer(std::string& tag);
static constexpr const char* kRecordPath = "/system/updates.log"; // what was installed, and when
bool giteaBusy() const { return request_ != Request::None || serving_; }
// Asked to make room for a TLS connection (R1, Q172): IRC steps aside while the Update Service
// talks to Gitea. Set by the main loop; `true` to step aside, `false` to come back.
@@ -85,6 +90,7 @@ class UpdateService : public Service {
void listen();
void install(class UpdateSource& source, const char* via);
void notify(const std::string& text, NotificationLevel level);
void record(const std::string& what);
KeyValueStore& store_;
WifiService& wifi_;
@@ -103,7 +109,8 @@ class UpdateService : public Service {
volatile Request request_ = Request::None;
volatile bool serving_ = false;
bool held_ = false; // IRC was asked to step aside, on this task
std::string installTag_, fakeVersion_, announced_;
std::string installTag_, fakeVersion_, announced_, offered_;
volatile bool offer_ = false;
bool dailyCheck_ = false;
uint32_t nextCheckMs_ = 90000; // not before the device has settled
std::string probeHost_, probePath_;
+3 -2
View File
@@ -12,12 +12,12 @@ bool Screen::begin() {
return canvas_.createSprite(theme::kWidth, theme::kHeight) != nullptr;
}
void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast) {
void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast, const Question* question) {
const auto& area = theme::kContent;
App& app = apps.foreground();
// An App that keeps what it drew: only the Status Bar is cleared, unless the App has changed
// or a Toast or the help panel, drawn over it, has just gone.
bool overlay = apps.help().isOpen() || toast, retains = app.retainsContent();
bool overlay = apps.help().isOpen() || toast || question, retains = app.retainsContent();
if (overlayWas_ && !overlay) lost_ = true;
if (retains && &app == lastApp_ && !lost_) {
canvas_.fillRect(0, 0, theme::kWidth, area.y, theme::kBackground);
@@ -31,6 +31,7 @@ void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toa
canvas_.setClipRect(area.x, area.y, area.w, area.h);
app.draw(canvas_);
if (apps.help().isOpen()) widgets::help(canvas_, apps.help()); // over the App, under the Status Bar
if (question) widgets::dialog(canvas_, question->title, question->message, *question->dialog);
canvas_.clearClipRect();
widgets::statusBar(canvas_, status);
if (toast) widgets::toast(canvas_, *toast);
+6 -1
View File
@@ -10,7 +10,12 @@ namespace roro {
class Screen {
public:
bool begin(); // allocates the frame buffer (~32 KB at 8-bit colour)
void render(AppManager& apps, const StatusInfo& status, const Toast* toast);
// A question the device itself asks, over whatever App is open (issue #52).
struct Question {
std::string title, message;
const DialogModel* dialog;
};
void render(AppManager& apps, const StatusInfo& status, const Toast* toast, const Question* question = nullptr);
// Takes over the screen while a Firmware Update is received or about to restart.
void renderUpdate(const std::string& title, const std::string& detail, int percent);
Canvas& canvas() { return canvas_; }
+137
View File
@@ -0,0 +1,137 @@
#include <unity.h>
#include "auto_update.h"
#include "update_check.h"
using namespace roro::release;
using Action = AutoUpdate::Action;
void setUp() {}
void tearDown() {}
namespace {
constexpr uint32_t kIdle = AutoUpdate::kIdleMs, kAsk = AutoUpdate::kAskMs;
Release release(const char* tag) {
Release r;
r.tag = tag;
r.otaUrl = "https://git.twis.la/twisla/roro9stack/releases/download/x/x.ota";
r.otaSize = 1;
return r;
}
} // namespace
void test_nothing_happens_without_an_offer() {
AutoUpdate a;
TEST_ASSERT_TRUE(a.step(1000, false, kIdle) == Action::None);
TEST_ASSERT_FALSE(a.waiting());
TEST_ASSERT_FALSE(a.asking());
}
void test_it_waits_for_a_quiet_moment() {
AutoUpdate a;
a.offer("v1.2.0");
TEST_ASSERT_TRUE(a.waiting());
TEST_ASSERT_TRUE(a.step(1000, false, kIdle - 1) == Action::None); // a key not long ago
TEST_ASSERT_TRUE(a.step(2000, true, kIdle * 10) == Action::None); // a Track is recording
TEST_ASSERT_TRUE(a.step(3000, false, kIdle) == Action::Ask);
TEST_ASSERT_TRUE(a.asking());
TEST_ASSERT_EQUAL_STRING("v1.2.0", a.tag().c_str());
}
void test_unanswered_it_installs_after_30_seconds() {
AutoUpdate a;
a.offer("v1.2.0");
a.step(5000, false, kIdle);
TEST_ASSERT_EQUAL(30, a.secondsLeft(5000));
TEST_ASSERT_EQUAL(30, a.secondsLeft(5001));
TEST_ASSERT_EQUAL(1, a.secondsLeft(5000 + kAsk - 1));
TEST_ASSERT_TRUE(a.step(5000 + kAsk - 1, false, kIdle) == Action::None);
TEST_ASSERT_TRUE(a.step(5000 + kAsk, false, kIdle) == Action::Install);
TEST_ASSERT_FALSE(a.asking());
TEST_ASSERT_TRUE(a.step(5000 + kAsk + 1, false, kIdle) == Action::None); // once
}
void test_accept_installs_at_once() {
AutoUpdate a;
a.offer("v1.2.0");
a.step(0, false, kIdle);
a.answer(true);
TEST_ASSERT_TRUE(a.step(100, false, 0) == Action::Install);
}
void test_cancel_drops_it_until_the_next_offer() {
AutoUpdate a;
a.offer("v1.2.0");
a.step(0, false, kIdle);
a.answer(false);
TEST_ASSERT_TRUE(a.step(100, false, 0) == Action::Dismiss);
TEST_ASSERT_TRUE(a.step(kAsk * 2, false, kIdle * 2) == Action::None);
a.offer("v1.2.0"); // the next day's check
TEST_ASSERT_TRUE(a.step(kAsk * 3, false, kIdle) == Action::Ask);
}
void test_something_starting_takes_the_question_down() {
AutoUpdate a;
a.offer("v1.2.0");
a.step(0, false, kIdle);
TEST_ASSERT_TRUE(a.step(1000, true, kIdle) == Action::Dismiss);
TEST_ASSERT_TRUE(a.waiting());
TEST_ASSERT_TRUE(a.step(2000, true, kIdle) == Action::None);
TEST_ASSERT_TRUE(a.step(90000, false, kIdle) == Action::Ask); // and it comes back, with a full count
TEST_ASSERT_EQUAL(30, a.secondsLeft(90000));
}
void test_the_setting_going_off_withdraws_it() {
AutoUpdate a;
a.offer("v1.2.0");
a.withdraw();
TEST_ASSERT_TRUE(a.step(0, false, kIdle) == Action::None);
TEST_ASSERT_FALSE(a.waiting());
a.offer("v1.2.0");
a.step(0, false, kIdle);
a.withdraw();
TEST_ASSERT_TRUE(a.step(10, false, kIdle) == Action::Dismiss);
TEST_ASSERT_FALSE(a.asking());
}
void test_an_answer_is_only_for_a_question() {
AutoUpdate a;
a.offer("v1.2.0");
a.answer(true); // nothing was asked yet
TEST_ASSERT_TRUE(a.step(0, false, kIdle) == Action::Ask);
TEST_ASSERT_TRUE(a.step(1, false, kIdle) == Action::None);
}
void test_a_newer_offer_replaces_the_tag() {
AutoUpdate a;
a.offer("v1.2.0");
a.offer("v1.3.0");
a.step(0, false, kIdle);
TEST_ASSERT_EQUAL_STRING("v1.3.0", a.tag().c_str());
}
void test_what_is_offered() {
// Newer than what runs, and not a version that rolled back here.
TEST_ASSERT_TRUE(shouldInstall(release("v1.2.0"), "v1.1.0", ""));
TEST_ASSERT_FALSE(shouldInstall(release("v1.1.0"), "v1.1.0", ""));
TEST_ASSERT_FALSE(shouldInstall(release("v1.0.0"), "v1.1.0", ""));
TEST_ASSERT_FALSE(shouldInstall(release("v1.2.0"), "v1.1.0", "v1.2.0"));
Release noFile = release("v1.2.0");
noFile.otaUrl.clear();
TEST_ASSERT_FALSE(shouldInstall(noFile, "v1.1.0", ""));
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_nothing_happens_without_an_offer);
RUN_TEST(test_it_waits_for_a_quiet_moment);
RUN_TEST(test_unanswered_it_installs_after_30_seconds);
RUN_TEST(test_accept_installs_at_once);
RUN_TEST(test_cancel_drops_it_until_the_next_offer);
RUN_TEST(test_something_starting_takes_the_question_down);
RUN_TEST(test_the_setting_going_off_withdraws_it);
RUN_TEST(test_an_answer_is_only_for_a_question);
RUN_TEST(test_a_newer_offer_replaces_the_tag);
RUN_TEST(test_what_is_offered);
return UNITY_END();
}
+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();
}
+12 -1
View File
@@ -67,7 +67,7 @@ void test_every_setting_is_in_one_group() {
total += f.menu.count();
f.menu.close();
}
TEST_ASSERT_EQUAL(21, total);
TEST_ASSERT_EQUAL(22, total);
int i = -1;
TEST_ASSERT_TRUE(f.menu.reveal(SettingsMenu::Row::Vpn, i));
TEST_ASSERT_EQUAL_STRING("Network", f.menu.title().c_str());
@@ -204,6 +204,16 @@ void test_pause_gnss_for_lora_is_off_by_default() {
// R1, Q165: the device looks for a newer release once a day. It installs nothing by itself, so it
// is on unless switched off.
void test_installing_updates_by_itself_is_off_by_default() {
Fixture f;
int row = f.row(SettingsMenu::Row::InstallUpdates);
TEST_ASSERT_EQUAL_STRING("Install updates", f.menu.label(row).c_str());
TEST_ASSERT_EQUAL_STRING("When asked", f.menu.value(row).c_str());
f.menu.toggle(row);
TEST_ASSERT_TRUE(f.settings.getBool(Setting::InstallUpdates));
TEST_ASSERT_EQUAL_STRING("By itself", f.menu.value(row).c_str());
}
void test_check_for_updates_is_on_by_default() {
Fixture f;
int row = f.row(SettingsMenu::Row::CheckUpdates);
@@ -245,6 +255,7 @@ int main() {
RUN_TEST(test_names_are_text_rows_with_their_byte_limits);
RUN_TEST(test_pause_gnss_for_lora_is_off_by_default);
RUN_TEST(test_check_for_updates_is_on_by_default);
RUN_TEST(test_installing_updates_by_itself_is_off_by_default);
RUN_TEST(test_gnss_and_coordinate_rows_toggle);
RUN_TEST(test_the_debug_console_is_a_page_that_says_off);
return UNITY_END();