Public Access
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
472f13260f | ||
|
|
468dc0dc0d | ||
|
|
7e14e270df | ||
|
|
c3fd9909a1 | ||
|
|
8ba0102910 | ||
|
|
17651cb911 | ||
|
|
5af695f43a | ||
|
|
544bd726df | ||
|
|
65a9643910 | ||
|
|
a87c3016cc | ||
|
|
da311a11b6 | ||
|
|
fd534af971 |
+11
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -104,9 +104,10 @@ To install from the SD card instead, copy the `.ota` file from `.pio/build/cardp
|
||||
|
||||
With no PC and no card, the device can install the project's releases itself (docs/milestones/R1.md). In **Settings → System → Firmware**:
|
||||
|
||||
- **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.
|
||||
- **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. A download that breaks is carried on from where it was (issue #54): the device waits up to a minute for Wi-Fi, asks for the rest of the file, and gives up only after three tries in a row that bring nothing.
|
||||
- **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.
|
||||
|
||||
@@ -233,7 +234,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
|
||||
| `install <path>` | Update from SD with that `.ota` file, as Settings → System → Firmware does |
|
||||
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, break each connection of the next download after n bytes (it is carried on, issue #54) or damage one byte of it, run the daily check again |
|
||||
| `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor |
|
||||
| `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack |
|
||||
| `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
|
||||
@@ -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: …` |
|
||||
|
||||
@@ -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.
|
||||
+64
-1
@@ -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). Built, not released yet: carrying on a download that broke (#54). Not started: the Issues App (#4), release channels (#53).
|
||||
|
||||
**Goal:** a tag is a release, built the same way every time and published where a device can find it.
|
||||
|
||||
@@ -124,6 +124,69 @@ 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.
|
||||
|
||||
## Carrying on a download that broke (issue #54)
|
||||
|
||||
A release goes straight into the inactive slot (Q167), so a connection that broke meant starting again, and on a weak link a 2.5 MB file might never arrive whole.
|
||||
|
||||
### What the server gives (checked 2026-10-11)
|
||||
|
||||
A request with `Range: bytes=0-0` for a release's file gets `206`, `Content-Range: bytes 0-0/2510384` and a strong `ETag`.
|
||||
|
||||
### Decisions (2026-10-11)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q282 | **Carried on within the same install, in memory only.** The slot's write handle and the parser, with its hash so far, stay as they are while the connection is opened again with `Range: bytes=<where it was>-`. The parser never sees the break. |
|
||||
| Q283 | **Nothing is kept across a restart** (the issue's second question): the slot cannot be reopened at an offset, so a download cut by a power-off starts over. |
|
||||
| Q284 | **The answer must be the rest of the same file:** a `206` from that byte to the end, of the same total size, with the same `ETag` if both answers have one. The `ETag` goes in `If-Range`, so a file that changed comes back whole (a `200`), which is refused. What decides whether the whole is good is still the Update File's own signature and hash. |
|
||||
| Q285 | **How long:** before each try, up to a minute for Wi-Fi to come back. Tries go on while each brings more bytes, 20 at most in one download; three in a row that bring nothing end it. |
|
||||
| Q286 | **`update damage cut <n>`** now breaks each connection of the next download after n bytes, so both the carrying on and the giving up can be tried. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/release/src/resume.h`**, host-tested (5 tests with the head's new fields): `resumeRefusal`, `ifRangeFor`, `ResumePolicy`. `HttpHead` reads `ETag` and `Content-Range`.
|
||||
- **`HttpsGet::openFrom`** asks for a range; **`GiteaSource`** in the Update Service does the waiting and the trying inside its `read()`.
|
||||
- `update status` says how often the last download was carried on.
|
||||
|
||||
### Checked on the device (2026-10-11)
|
||||
|
||||
- **`update damage cut 800000`, then an install of v0.23.0** (2 510 384 bytes): it arrived over four connections, its hash matched, and the device restarted into it.
|
||||
- **`update damage cut 0`:** three connections that brought nothing, then it gave up (`carried on 2 time(s)`), with the running slot still the one to boot.
|
||||
- **Memory decided the first attempt.** Between two connections 78 KB was free, under the 80 KB a TLS connection asks for before it starts, so every try was refused and the download gave up. The install's own buffers (about 8 KB) are what is missing, and nothing else asks for memory while the screen is taken over: carrying on asks for 64 KB (the 52 KB peak and 12 KB). The lowest the heap went during the broken download was 20 KB, with a console client connected.
|
||||
- Not checked: a real Wi-Fi drop in the middle (the wait for Wi-Fi to come back), and a file that changed on the server.
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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[] = {{"&", '&'}, {"<", '<'}, {">", '>'}, {""", '"'}, {"'", '\''}};
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "http_head.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
@@ -58,6 +60,15 @@ void HttpHeadParser::line() {
|
||||
else if (name == "transfer-encoding") head_.chunked = lower(value).find("chunked") != std::string::npos;
|
||||
else if (name == "location") head_.location = value;
|
||||
else if (name == "content-type") head_.contentType = value;
|
||||
else if (name == "etag") head_.etag = value;
|
||||
else if (name == "content-range") {
|
||||
long from = -1, to = -1, total = -1;
|
||||
if (std::sscanf(lower(value).c_str(), "bytes %ld-%ld/%ld", &from, &to, &total) == 3 && from >= 0 && to >= from && total > to) {
|
||||
head_.rangeFrom = from;
|
||||
head_.rangeTo = to;
|
||||
head_.rangeTotal = total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
size_t ChunkedDecoder::decode(const uint8_t* in, size_t len, uint8_t* out) {
|
||||
|
||||
@@ -12,6 +12,9 @@ struct HttpHead {
|
||||
long contentLength = -1; // -1: not given
|
||||
bool chunked = false;
|
||||
std::string location, contentType;
|
||||
std::string etag; // as sent, quotes included; "" if none
|
||||
// "Content-Range: bytes 1000-2999/3000", the answer to a range request; -1: not given.
|
||||
long rangeFrom = -1, rangeTo = -1, rangeTotal = -1;
|
||||
};
|
||||
|
||||
class HttpHeadParser {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "http_head.h"
|
||||
|
||||
namespace roro::release {
|
||||
|
||||
// Carrying on a download that broke (issue #54): the connection is opened again with
|
||||
// "Range: bytes=<from>-", and what was already received stays where it is. The Update File's own
|
||||
// hash and signature are what decide whether the whole is good; these checks only keep the device
|
||||
// from carrying on with something that plainly is not the rest of the same file.
|
||||
|
||||
// The validator to send as "If-Range", so a file that changed comes back whole (a 200) instead of
|
||||
// in part: only a strong ETag may be used for that. "" for none.
|
||||
inline std::string ifRangeFor(const std::string& etag) {
|
||||
return etag.size() >= 2 && etag.front() == '"' && etag.back() == '"' ? etag : "";
|
||||
}
|
||||
|
||||
// Is this answer the rest of the file, from byte `from` of `total`? "" if so, or why not, in
|
||||
// words for the screen. `etag` is what the first answer gave ("" if nothing).
|
||||
inline std::string resumeRefusal(const HttpHead& head, long from, long total, const std::string& etag) {
|
||||
if (head.status == 200) return "The file changed on the server";
|
||||
if (head.status != 206) return "The server answered " + std::to_string(head.status);
|
||||
if (head.chunked || head.rangeFrom != from) return "The server sent another part of the file";
|
||||
if (head.rangeTotal != total || head.rangeTo != total - 1 || head.contentLength != total - from) return "The file changed size on the server";
|
||||
if (!etag.empty() && !head.etag.empty() && head.etag != etag) return "The file changed on the server";
|
||||
return "";
|
||||
}
|
||||
|
||||
// How long to keep trying. A break that came after new bytes is the link being poor, and is
|
||||
// worth another try; breaks with nothing in between mean it is not coming back.
|
||||
class ResumePolicy {
|
||||
public:
|
||||
static constexpr int kMaxResumes = 20; // in one download
|
||||
static constexpr int kMaxStuck = 3; // in a row without a byte
|
||||
|
||||
// The connection broke with `received` bytes of the file here: try again?
|
||||
bool again(long received) {
|
||||
stuck_ = received > last_ ? 0 : stuck_ + 1;
|
||||
last_ = received;
|
||||
if (resumes_ >= kMaxResumes || stuck_ >= kMaxStuck) return false;
|
||||
resumes_++;
|
||||
return true;
|
||||
}
|
||||
int resumes() const { return resumes_; }
|
||||
|
||||
private:
|
||||
long last_ = 0;
|
||||
int resumes_ = 0, stuck_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro::release
|
||||
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -46,9 +46,10 @@ To install from the SD card instead, copy the `.ota` file from `.pio/build/cardp
|
||||
|
||||
With no PC and no card, the device can install the project's releases itself (docs/milestones/R1.md). In **Settings → System → Firmware**:
|
||||
|
||||
- **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.
|
||||
- **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. A download that breaks is carried on from where it was (issue #54): the device waits up to a minute for Wi-Fi, asks for the rest of the file, and gives up only after three tries in a row that bring nothing.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -99,7 +100,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
|
||||
| `install <path>` | Update from SD with that `.ota` file, as Settings → System → Firmware does |
|
||||
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, break each connection of the next download after n bytes (it is carried on, issue #54) or damage one byte of it, run the daily check again |
|
||||
| `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor |
|
||||
| `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack |
|
||||
| `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
|
||||
@@ -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: …` |
|
||||
|
||||
@@ -83,7 +83,7 @@ An update that goes wrong is the case you most want to rehearse, and the firmwar
|
||||
update status # what the device runs, what failed here before, the daily check, heap
|
||||
update check | update list # look at the server: the latest release, or the last ten
|
||||
update pretend v0.9.0 # take the running version to be v0.9.0: the latest release now counts as "new"
|
||||
update damage cut 50000 # the next download is cut after 50000 bytes
|
||||
update damage cut 800000 # each connection of the next download breaks after 800000 bytes
|
||||
update damage flip 100000 # ...or has the byte at offset 100000 damaged
|
||||
update install v0.11.0 # try the install
|
||||
update probe git.twis.la # is that server's certificate accepted? (the two ISRG roots only)
|
||||
@@ -91,7 +91,8 @@ update daily # forget today's daily check: it runs again
|
||||
update pretend off # back to the real version
|
||||
```
|
||||
|
||||
- **A damaged download must be refused cleanly:** the Update Service checks the signature after the first 160 bytes and the image hash at the end, so a cut or a flipped byte must end in a refusal with **nothing switched**. Check `info` afterwards: both slots, and `update: confirmed`.
|
||||
- **A download that breaks is carried on:** with `damage cut 800000` a 2.5 MB release comes in four connections, each asking for the rest of the file, and installs; `update status` then says how often it was carried on. With `damage cut 0` no connection brings anything, and it gives up after three tries.
|
||||
- **A damaged download must be refused cleanly:** the Update Service checks the signature after the first 160 bytes and the image hash at the end, so a download that gave up or a flipped byte must end in a refusal with **nothing switched**. Check `info` afterwards: both slots, and `update: confirmed`.
|
||||
- **An undamaged install really installs** the release, into the other slot, and the device restarts into it. Your build stays in the slot it was in until the next update overwrites it, and the console's setting and token are untouched: the release has the console too.
|
||||
- The server is read by the device itself, with Wi-Fi up; the download is one TLS connection, about 52 KB of heap at its peak, so IRC steps aside (see [the memory limit](/howto/not-enough-memory/)). `status: heap` in the stream shows it happen.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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). Built, not released yet: carrying on a download that broke (#54). Not started: the Issues App (#4), release channels (#53).
|
||||
|
||||
**Goal:** a tag is a release, built the same way every time and published where a device can find it.
|
||||
|
||||
@@ -132,6 +132,69 @@ 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.
|
||||
|
||||
## Carrying on a download that broke (issue #54)
|
||||
|
||||
A release goes straight into the inactive slot (Q167), so a connection that broke meant starting again, and on a weak link a 2.5 MB file might never arrive whole.
|
||||
|
||||
### What the server gives (checked 2026-10-11)
|
||||
|
||||
A request with `Range: bytes=0-0` for a release's file gets `206`, `Content-Range: bytes 0-0/2510384` and a strong `ETag`.
|
||||
|
||||
### Decisions (2026-10-11)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q282 | **Carried on within the same install, in memory only.** The slot's write handle and the parser, with its hash so far, stay as they are while the connection is opened again with `Range: bytes=<where it was>-`. The parser never sees the break. |
|
||||
| Q283 | **Nothing is kept across a restart** (the issue's second question): the slot cannot be reopened at an offset, so a download cut by a power-off starts over. |
|
||||
| Q284 | **The answer must be the rest of the same file:** a `206` from that byte to the end, of the same total size, with the same `ETag` if both answers have one. The `ETag` goes in `If-Range`, so a file that changed comes back whole (a `200`), which is refused. What decides whether the whole is good is still the Update File's own signature and hash. |
|
||||
| Q285 | **How long:** before each try, up to a minute for Wi-Fi to come back. Tries go on while each brings more bytes, 20 at most in one download; three in a row that bring nothing end it. |
|
||||
| Q286 | **`update damage cut <n>`** now breaks each connection of the next download after n bytes, so both the carrying on and the giving up can be tried. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/release/src/resume.h`**, host-tested (5 tests with the head's new fields): `resumeRefusal`, `ifRangeFor`, `ResumePolicy`. `HttpHead` reads `ETag` and `Content-Range`.
|
||||
- **`HttpsGet::openFrom`** asks for a range; **`GiteaSource`** in the Update Service does the waiting and the trying inside its `read()`.
|
||||
- `update status` says how often the last download was carried on.
|
||||
|
||||
### Checked on the device (2026-10-11)
|
||||
|
||||
- **`update damage cut 800000`, then an install of v0.23.0** (2 510 384 bytes): it arrived over four connections, its hash matched, and the device restarted into it.
|
||||
- **`update damage cut 0`:** three connections that brought nothing, then it gave up (`carried on 2 time(s)`), with the running slot still the one to boot.
|
||||
- **Memory decided the first attempt.** Between two connections 78 KB was free, under the 80 KB a TLS connection asks for before it starts, so every try was refused and the download gave up. The install's own buffers (about 8 KB) are what is missing, and nothing else asks for memory while the screen is taken over: carrying on asks for 64 KB (the 52 KB peak and 12 KB). The lowest the heap went during the broken download was 20 KB, with a console client connected.
|
||||
- Not checked: a real Wi-Fi drop in the middle (the wait for Wi-Fi to come back), and a file that changed on the server.
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]) }}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -19,9 +19,23 @@ The page shows the **version** running, its **status**, the address to **push up
|
||||
|
||||
An install needs Wi-Fi if it is a download. The new firmware is checked before anything is written (the signature after the first 160 bytes) and again at the end (the image's hash). Then the device restarts. It will **wait up to 60 seconds** if you are typing, so that a restart never eats a note.
|
||||
|
||||
A download that **breaks** (the Wi-Fi drops, the link is poor) is not started over: the device waits up to a minute for Wi-Fi to come back, asks the server for the rest of the file, and carries on from where it was, as often as it takes while each try brings something more. It gives up after three tries in a row that bring nothing, and then nothing is changed. If the device is switched off in between, the download starts from the beginning next time.
|
||||
|
||||
## 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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+82
-5
@@ -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,10 @@ 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: the last download was carried on %d time(s) after a break\n", update->lastResumes());
|
||||
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 ")) {
|
||||
@@ -584,7 +595,8 @@ static void updateCommand(const String& args) {
|
||||
} else if (args.startsWith("damage ")) { // update damage cut <bytes> | flip <offset>: for the next download
|
||||
long n = args.substring(args.lastIndexOf(' ') + 1).toInt();
|
||||
args.startsWith("damage cut") ? update->damageNextDownload(n, -1) : update->damageNextDownload(-1, n);
|
||||
console.printf("update: the next download will be %s at byte %ld\n", args.startsWith("damage cut") ? "cut" : "damaged", n);
|
||||
if (args.startsWith("damage cut")) console.printf("update: each connection of the next download will break after %ld bytes\n", n);
|
||||
else console.printf("update: the next download will be damaged at byte %ld\n", n);
|
||||
|
||||
} else if (args == "daily") { // forget today's check: the daily one runs again at once, if it may
|
||||
update->forgetToday();
|
||||
@@ -684,6 +696,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 +861,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 +1207,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 +1595,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 +1611,7 @@ static void loopPass() {
|
||||
bus.dispatch();
|
||||
notifier->update(now);
|
||||
apps->update(now);
|
||||
autoUpdateStep(now);
|
||||
|
||||
if (now - lastLog > 10000) {
|
||||
lastLog = now;
|
||||
@@ -1585,10 +1657,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,20 @@ std::string whyNotConnected(NetworkClientSecure& tls, const std::string& host) {
|
||||
} // namespace
|
||||
|
||||
std::string HttpsGet::open(const std::string& host, const std::string& path, const char* accept) {
|
||||
std::string why = request(host, path, accept, "", kNeedFree);
|
||||
if (!why.empty()) return why;
|
||||
if (head_.status != 200) return host + " answered " + std::to_string(head_.status) + (head_.status / 100 == 3 ? " (a redirect)" : "");
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string HttpsGet::openFrom(const std::string& host, const std::string& path, const char* accept, long from, const std::string& ifRange) {
|
||||
return request(host, path, accept, "Range: bytes=" + std::to_string(from) + "-\r\n" + (ifRange.empty() ? "" : "If-Range: " + ifRange + "\r\n"), kNeedFreeToCarryOn);
|
||||
}
|
||||
|
||||
std::string HttpsGet::request(const std::string& host, const std::string& path, const char* accept, const std::string& more, size_t needFree) {
|
||||
close();
|
||||
if (time(nullptr) < kClockSetAfter) return "The clock isn't set: can't check certificates";
|
||||
if (esp_get_free_heap_size() < kNeedFree) return "Not enough memory for a secure connection";
|
||||
if (esp_get_free_heap_size() < needFree) return "Not enough memory for a secure connection";
|
||||
|
||||
tls_.setCACert(kTrustedRootsPem);
|
||||
tls_.setTimeout(15);
|
||||
@@ -38,7 +49,7 @@ std::string HttpsGet::open(const std::string& host, const std::string& path, con
|
||||
raw_.reset(new (std::nothrow) uint8_t[kRaw]);
|
||||
if (!raw_) return "Not enough memory";
|
||||
tls_.print(("GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\nUser-Agent: roro9stack/" + versionString() +
|
||||
"\r\nAccept: " + accept + "\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n")
|
||||
"\r\nAccept: " + accept + "\r\nAccept-Encoding: identity\r\n" + more + "Connection: close\r\n\r\n")
|
||||
.c_str());
|
||||
|
||||
release::HttpHeadParser parser;
|
||||
@@ -50,7 +61,6 @@ std::string HttpsGet::open(const std::string& host, const std::string& path, con
|
||||
if (parser.complete() && used < static_cast<size_t>(n)) early_.assign(reinterpret_cast<const char*>(raw_.get()) + used, n - used);
|
||||
}
|
||||
head_ = parser.head();
|
||||
if (head_.status != 200) return host + " answered " + std::to_string(head_.status) + (head_.status / 100 == 3 ? " (a redirect)" : "");
|
||||
left_ = head_.chunked ? -1 : head_.contentLength;
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -20,12 +20,22 @@ class HttpsGet {
|
||||
// checking the certificate); with Q86's 20 KB to spare, it starts with at least this much free.
|
||||
static constexpr size_t kNeedFree = 80 * 1024;
|
||||
|
||||
// Carrying on a download (issue #54) happens in the middle of an install, whose own buffers
|
||||
// (about 8 KB, measured: 78 KB free between two connections) are already taken out of what is
|
||||
// free, and with the screen taken over nothing else will ask for memory: the peak and 12 KB.
|
||||
static constexpr size_t kNeedFreeToCarryOn = 64 * 1024;
|
||||
|
||||
explicit HttpsGet(net::User user) : tls_(user) {}
|
||||
~HttpsGet() { close(); }
|
||||
|
||||
// Connects and reads the head. "" when a 200 is on its way, or why not, in words for the screen.
|
||||
std::string open(const std::string& host, const std::string& path, const char* accept);
|
||||
// The same, for the rest of a file from byte `from` (issue #54): asks for that range, with
|
||||
// `ifRange` (an ETag, or "") so that a file that changed comes whole. The answer, a 206 or
|
||||
// not, is for the caller to judge with head(): "" here only says a head was read.
|
||||
std::string openFrom(const std::string& host, const std::string& path, const char* accept, long from, const std::string& ifRange);
|
||||
long contentLength() const { return head_.contentLength; } // -1: not known
|
||||
const release::HttpHead& head() const { return head_; }
|
||||
|
||||
// Body bytes into `buf`: how many, 0 at the end, -1 when the connection broke or stalled
|
||||
// before the end.
|
||||
@@ -37,6 +47,7 @@ class HttpsGet {
|
||||
static constexpr uint32_t kStallMs = 10000;
|
||||
|
||||
int readRaw(uint8_t* into, size_t len); // from the connection, waiting up to kStallMs
|
||||
std::string request(const std::string& host, const std::string& path, const char* accept, const std::string& more, size_t needFree);
|
||||
|
||||
Counted<NetworkClientSecure> tls_;
|
||||
release::HttpHead head_;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <ctime>
|
||||
|
||||
#include "http_head.h"
|
||||
#include "resume.h"
|
||||
#include "platform/https_get.h"
|
||||
#include "platform/ota_device.h"
|
||||
#include "platform/system_info.h"
|
||||
@@ -64,21 +65,61 @@ class FileSource : public UpdateSource {
|
||||
File& f_;
|
||||
};
|
||||
|
||||
// A release being downloaded from Gitea: the same bytes a push or a card would give.
|
||||
// A release being downloaded from Gitea: the same bytes a push or a card would give. When the
|
||||
// connection breaks, it is opened again for the rest of the file (issue #54): what was written
|
||||
// to the slot stays, and the parser never sees the break.
|
||||
class GiteaSource : public UpdateSource {
|
||||
public:
|
||||
GiteaSource(HttpsGet& get, long cutAfter, long flipAt) : get_(get), cut_(cutAfter), flip_(flipAt) {}
|
||||
static constexpr uint32_t kWifiWaitMs = 60000; // for Wi-Fi to come back, before each try
|
||||
static constexpr uint32_t kPauseMs = 2000;
|
||||
|
||||
GiteaSource(HttpsGet& get, const release::Url& url, WifiService& wifi, long cutAfter, long flipAt)
|
||||
: get_(get), url_(url), wifi_(wifi), total_(get.contentLength()), etag_(get.head().etag), cut_(cutAfter), flip_(flipAt) {}
|
||||
|
||||
int read(uint8_t* buf, size_t len) override {
|
||||
if (cut_ >= 0 && pos_ >= cut_) return -1; // Debug Builds: the connection "breaks" here
|
||||
int n = get_.read(buf, len);
|
||||
if (n > 0 && flip_ >= pos_ && flip_ < pos_ + n) buf[flip_ - pos_] ^= 1; // and a byte "arrives wrong"
|
||||
if (n > 0) pos_ += n;
|
||||
return n;
|
||||
for (;;) {
|
||||
// Debug: each connection "breaks" after cut_ bytes.
|
||||
int n = cut_ >= 0 && pos_ - openedAt_ >= cut_ ? -1 : get_.read(buf, len);
|
||||
if (n > 0 && flip_ >= pos_ && flip_ < pos_ + n) buf[flip_ - pos_] ^= 1; // and a byte "arrives wrong"
|
||||
if (n > 0) pos_ += n;
|
||||
if (n >= 0) return n;
|
||||
if (!resume()) return -1;
|
||||
}
|
||||
}
|
||||
int resumes() const { return policy_.resumes(); }
|
||||
const std::string& why() const { return why_; } // why it gave up, when it did
|
||||
|
||||
private:
|
||||
bool resume() {
|
||||
// Without a length there is no knowing what "the rest" is.
|
||||
while (total_ > 0 && pos_ < total_ && policy_.again(pos_)) {
|
||||
get_.close();
|
||||
delay(kPauseMs);
|
||||
uint32_t since = millis();
|
||||
while (wifi_.state() != WifiController::State::Connected && millis() - since < kWifiWaitMs) delay(250);
|
||||
if (wifi_.state() != WifiController::State::Connected) {
|
||||
why_ = "Wi-Fi did not come back";
|
||||
continue;
|
||||
}
|
||||
why_ = get_.openFrom(url_.host, url_.path, "application/octet-stream", pos_, release::ifRangeFor(etag_));
|
||||
if (!why_.empty()) continue; // no connection yet: another try, while the policy allows
|
||||
why_ = release::resumeRefusal(get_.head(), pos_, total_, etag_);
|
||||
if (!why_.empty()) return false; // it answered, and it is not the rest of this file
|
||||
openedAt_ = pos_;
|
||||
ESP_LOGW("update", "carrying on from byte %ld of %ld (try %d)", pos_, total_, policy_.resumes());
|
||||
return true;
|
||||
}
|
||||
if (why_.empty()) why_ = "The download broke";
|
||||
return false;
|
||||
}
|
||||
|
||||
HttpsGet& get_;
|
||||
long cut_, flip_, pos_ = 0;
|
||||
release::Url url_;
|
||||
WifiService& wifi_;
|
||||
long total_;
|
||||
std::string etag_, why_;
|
||||
release::ResumePolicy policy_;
|
||||
long cut_, flip_, pos_ = 0, openedAt_ = 0;
|
||||
};
|
||||
|
||||
constexpr time_t kClockSetAfter = 1700000000; // anything earlier is the clock's default
|
||||
@@ -94,6 +135,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 +169,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 +205,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 +286,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 +353,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
|
||||
@@ -325,9 +404,12 @@ void UpdateService::installFromGitea(const release::Release& r) {
|
||||
notify("Update refused: " + why, NotificationLevel::Warning);
|
||||
return;
|
||||
}
|
||||
GiteaSource source(get, damageCut_, damageFlip_);
|
||||
GiteaSource source(get, url, wifi_, damageCut_, damageFlip_);
|
||||
damageCut_ = damageFlip_ = -1;
|
||||
install(source, "Gitea");
|
||||
resumes_ = source.resumes();
|
||||
// What the parser says after a break is only that the file was short: say why it broke.
|
||||
if (phase_ != Phase::Installed && !source.why().empty()) notify("Download gave up: " + source.why(), NotificationLevel::Warning);
|
||||
}
|
||||
|
||||
void UpdateService::taskEntry(void* self) { static_cast<UpdateService*>(self)->listen(); }
|
||||
|
||||
@@ -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.
|
||||
@@ -66,6 +71,7 @@ class UpdateService : public Service {
|
||||
// (is its certificate accepted?), and a download cut short or with one byte flipped.
|
||||
void requestProbe(const std::string& host, const std::string& path) { probeHost_ = host; probePath_ = path; probeResult_ = "..."; request_ = Request::Probe; }
|
||||
std::string probeResult() const { return probeResult_; }
|
||||
int lastResumes() const { return resumes_; } // how often the last download from Gitea was carried on (issue #54)
|
||||
void damageNextDownload(long cutAfter, long flipAt) { damageCut_ = cutAfter; damageFlip_ = flipAt; }
|
||||
void forgetToday() { store_.putInt("rel_day", 0); nextCheckMs_ = 0; announced_.clear(); } // the daily check runs at the next tick
|
||||
|
||||
@@ -85,6 +91,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,11 +110,13 @@ 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_;
|
||||
volatile long damageCut_ = -1, damageFlip_ = -1;
|
||||
volatile int resumes_ = 0;
|
||||
std::string probeResult_;
|
||||
};
|
||||
|
||||
|
||||
+3
-2
@@ -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
@@ -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_; }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <string>
|
||||
|
||||
#include "http_head.h"
|
||||
#include "resume.h"
|
||||
|
||||
using namespace roro::release;
|
||||
|
||||
@@ -42,6 +43,92 @@ void test_a_download_head() {
|
||||
TEST_ASSERT_EQUAL_STRING("https://elsewhere.example/x", r.head().location.c_str());
|
||||
}
|
||||
|
||||
void test_a_part_of_a_file() {
|
||||
std::string head = "HTTP/1.1 206 Partial Content\r\nContent-Range: bytes 1000-2510383/2510384\r\n"
|
||||
"ETag: \"ac948b3f\"\r\nContent-Length: 2509384\r\n\r\n";
|
||||
HttpHeadParser p;
|
||||
p.feed(head.data(), head.size());
|
||||
TEST_ASSERT_EQUAL_INT(206, p.head().status);
|
||||
TEST_ASSERT_EQUAL_INT(1000, p.head().rangeFrom);
|
||||
TEST_ASSERT_EQUAL_INT(2510383, p.head().rangeTo);
|
||||
TEST_ASSERT_EQUAL_INT(2510384, p.head().rangeTotal);
|
||||
TEST_ASSERT_EQUAL_STRING("\"ac948b3f\"", p.head().etag.c_str());
|
||||
// A range that makes no sense is no range.
|
||||
for (const char* bad : {"bytes */2510384", "bytes 5-2/10", "bytes 0-10/10", "items 0-1/2", ""}) {
|
||||
std::string h = std::string("HTTP/1.1 206 Partial Content\r\nContent-Range: ") + bad + "\r\n\r\n";
|
||||
HttpHeadParser q;
|
||||
q.feed(h.data(), h.size());
|
||||
TEST_ASSERT_EQUAL_INT(-1, q.head().rangeFrom);
|
||||
}
|
||||
}
|
||||
|
||||
static HttpHead part(long from, long total, const char* etag = "\"abc\"") {
|
||||
HttpHead h;
|
||||
h.status = 206;
|
||||
h.rangeFrom = from;
|
||||
h.rangeTo = total - 1;
|
||||
h.rangeTotal = total;
|
||||
h.contentLength = total - from;
|
||||
h.etag = etag;
|
||||
return h;
|
||||
}
|
||||
|
||||
void test_the_rest_of_the_same_file_is_taken() {
|
||||
TEST_ASSERT_EQUAL_STRING("", resumeRefusal(part(1000, 5000), 1000, 5000, "\"abc\"").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", resumeRefusal(part(1000, 5000, ""), 1000, 5000, "\"abc\"").c_str()); // no ETag this time
|
||||
TEST_ASSERT_EQUAL_STRING("", resumeRefusal(part(1000, 5000), 1000, 5000, "").c_str()); // none the first time
|
||||
}
|
||||
|
||||
void test_anything_else_is_refused() {
|
||||
HttpHead whole;
|
||||
whole.status = 200; // If-Range did not match: the file is another one now
|
||||
whole.contentLength = 5000;
|
||||
TEST_ASSERT_EQUAL_STRING("The file changed on the server", resumeRefusal(whole, 1000, 5000, "\"abc\"").c_str());
|
||||
HttpHead gone;
|
||||
gone.status = 404;
|
||||
TEST_ASSERT_EQUAL_STRING("The server answered 404", resumeRefusal(gone, 1000, 5000, "").c_str());
|
||||
TEST_ASSERT_FALSE(resumeRefusal(part(0, 5000), 1000, 5000, "\"abc\"").empty()); // from the start
|
||||
TEST_ASSERT_FALSE(resumeRefusal(part(1000, 6000), 1000, 5000, "\"abc\"").empty()); // another size
|
||||
TEST_ASSERT_FALSE(resumeRefusal(part(1000, 5000, "\"xyz\""), 1000, 5000, "\"abc\"").empty());
|
||||
HttpHead shortPart = part(1000, 5000);
|
||||
shortPart.rangeTo = 2000; // not up to the end
|
||||
TEST_ASSERT_FALSE(resumeRefusal(shortPart, 1000, 5000, "\"abc\"").empty());
|
||||
HttpHead chunked = part(1000, 5000);
|
||||
chunked.chunked = true;
|
||||
TEST_ASSERT_FALSE(resumeRefusal(chunked, 1000, 5000, "\"abc\"").empty());
|
||||
}
|
||||
|
||||
void test_only_a_strong_etag_goes_in_if_range() {
|
||||
TEST_ASSERT_EQUAL_STRING("\"abc\"", ifRangeFor("\"abc\"").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", ifRangeFor("W/\"abc\"").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", ifRangeFor("").c_str());
|
||||
}
|
||||
|
||||
void test_how_long_a_download_is_tried() {
|
||||
ResumePolicy poor; // a poor link: each try brings some more
|
||||
for (int i = 0; i < ResumePolicy::kMaxResumes; i++) TEST_ASSERT_TRUE(poor.again(1000 * (i + 1)));
|
||||
TEST_ASSERT_FALSE(poor.again(1000000));
|
||||
TEST_ASSERT_EQUAL_INT(ResumePolicy::kMaxResumes, poor.resumes());
|
||||
|
||||
ResumePolicy dead; // the link is gone: nothing more comes
|
||||
TEST_ASSERT_TRUE(dead.again(5000));
|
||||
TEST_ASSERT_TRUE(dead.again(5000));
|
||||
TEST_ASSERT_TRUE(dead.again(5000));
|
||||
TEST_ASSERT_FALSE(dead.again(5000));
|
||||
|
||||
ResumePolicy fromNothing; // not even a first byte
|
||||
TEST_ASSERT_TRUE(fromNothing.again(0));
|
||||
TEST_ASSERT_TRUE(fromNothing.again(0));
|
||||
TEST_ASSERT_FALSE(fromNothing.again(0));
|
||||
|
||||
ResumePolicy recovering; // stuck twice, then bytes again: the count starts over
|
||||
recovering.again(5000);
|
||||
recovering.again(5000);
|
||||
recovering.again(5000);
|
||||
TEST_ASSERT_TRUE(recovering.again(9000));
|
||||
TEST_ASSERT_TRUE(recovering.again(9000));
|
||||
}
|
||||
|
||||
void test_a_head_that_is_not_http() {
|
||||
HttpHeadParser p;
|
||||
std::string junk = "\x16\x03\x01 not http at all\r\n\r\n";
|
||||
@@ -114,6 +201,11 @@ int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_a_head_in_pieces);
|
||||
RUN_TEST(test_a_download_head);
|
||||
RUN_TEST(test_a_part_of_a_file);
|
||||
RUN_TEST(test_the_rest_of_the_same_file_is_taken);
|
||||
RUN_TEST(test_anything_else_is_refused);
|
||||
RUN_TEST(test_only_a_strong_etag_goes_in_if_range);
|
||||
RUN_TEST(test_how_long_a_download_is_tried);
|
||||
RUN_TEST(test_a_head_that_is_not_http);
|
||||
RUN_TEST(test_chunked_bodies);
|
||||
RUN_TEST(test_urls);
|
||||
|
||||
@@ -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 <A&B></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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user