Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fff28af961 | ||
|
|
4e439410cd | ||
|
|
db265fb757 | ||
|
|
578a39d3c1 | ||
|
|
d7952612dd | ||
|
|
463ed2dda8 | ||
|
|
d408bada07 | ||
|
|
988253ef02 | ||
|
|
c9b1ecb772 | ||
|
|
f812c62a3d | ||
|
|
9c30d47982 | ||
|
|
634a20c339 |
@@ -4,3 +4,8 @@
|
||||
|
||||
# Private signing keys never belong in the repository (ADR 0003)
|
||||
*key.pem
|
||||
|
||||
# Generated by pioarduino's hybrid compile from custom_sdkconfig (platformio.ini)
|
||||
.dummy/
|
||||
managed_components/
|
||||
sdkconfig.*
|
||||
|
||||
+13
-1
@@ -40,6 +40,18 @@ _Avoid_: DM (in docs), private message
|
||||
Rebroadcasting another Node's packet so it travels further across the mesh.
|
||||
_Avoid_: forwarding, repeating
|
||||
|
||||
**GNSS Service**:
|
||||
The Service that owns the GNSS receiver on the Cap: it reads its NMEA sentences in the background and holds the current Fix, position, time and satellites.
|
||||
_Avoid_: GPS (GPS is one constellation among several)
|
||||
|
||||
**Fix**:
|
||||
What the receiver currently knows: none, 2D (position without altitude) or 3D (with altitude), from how many satellites, at what HDOP.
|
||||
_Avoid_: lock, signal
|
||||
|
||||
**Track**:
|
||||
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)
|
||||
|
||||
**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
|
||||
@@ -49,7 +61,7 @@ A Wi-Fi network the device may join on its own (name, password). When several ar
|
||||
_Avoid_: profile, known network
|
||||
|
||||
**IRC Service**:
|
||||
The Service that keeps the IRC connection alive in the background once the IRC App has started it, until the user disconnects. It reconnects after drops, and pauses while the Wi-Fi Service is Monitoring. It does not start by itself after a reboot.
|
||||
The Service that keeps the IRC connection alive in the background once the IRC App has started it, until the user stops it (`/quit`, or `irc stop` on the console). Once stopped by hand, opening the App again doesn't reconnect; typing a line does. It reconnects after drops, and pauses while the Wi-Fi Service is Monitoring. It does not start by itself after a reboot.
|
||||
_Avoid_: IRC client (that's the App)
|
||||
|
||||
**Buffer**:
|
||||
|
||||
@@ -18,6 +18,8 @@ scripts/ci.sh
|
||||
|
||||
This runs the host-side unit tests (`test/`, `native` environment), then builds the firmware. The output is `.pio/build/cardputer-adv/firmware.factory.bin`.
|
||||
|
||||
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 4 minutes; later builds take under a minute.
|
||||
|
||||
## Flash
|
||||
|
||||
1. Connect the Cardputer by USB-C.
|
||||
@@ -67,6 +69,7 @@ To install from the SD card instead, copy the `.ota` file from `.pio/build/cardp
|
||||
| `sd list` | Lists the files of each Storage Clean-up category |
|
||||
| `cat <path>` | Prints the first ~1.2 KB of a file on the SD card |
|
||||
| `irc start` | Starts the IRC Service (normally done by opening the IRC App) |
|
||||
| `irc stop` | Stops it, as `/quit` does: QUIT if connected, no more retries, and the App stays disconnected until you type |
|
||||
| `irc say <buffer> <text>` | Types into a Buffer, commands included (`irc say 0 /join #test`) |
|
||||
| `irc dump` | Prints IRC status, memory, and the last lines of each Buffer |
|
||||
| `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap |
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x5000,
|
||||
otadata, data, ota, 0xe000, 0x2000,
|
||||
app0, app, ota_0, 0x10000, 0x330000,
|
||||
app1, app, ota_1, 0x340000,0x330000,
|
||||
spiffs, data, spiffs, 0x670000,0x180000,
|
||||
coredump, data, coredump,0x7F0000,0x10000,
|
||||
|
@@ -9,3 +9,7 @@ A fork would give full compatibility on day one, but its architecture is built a
|
||||
- We accept partial Meshtastic compatibility at first: text on channels, Direct Messages, node list, position and relaying.
|
||||
- The phone-app (BLE) API and PKI-encrypted Direct Messages are deferred, and we must re-implement protocol details ourselves.
|
||||
- Multi-boot with stock Meshtastic via a launcher was rejected: it gives none of our own UX.
|
||||
|
||||
## Note (2026-10-04, M2)
|
||||
|
||||
NMEA is parsed by our own small, host-tested parser instead of TinyGPSPlus: the GNSS App's Sky view needs the satellite list (GSV) across several constellations, which TinyGPSPlus doesn't track. See docs/milestones/M2.md, Q66.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# A Debug Console over Wi-Fi, in Debug Builds only
|
||||
|
||||
The goal of Firmware Updates is to manage the device without a cable, and that includes finding out what went wrong. So a **Debug Build** (`cardputer-adv-debug`, `-DRORO_DEBUG`, version suffix `+debug`) adds a **Debug Console** on TCP 2323: the serial console, over Wi-Fi. A client sends a token as its first line, then gets the last 6 KB of console output (boot messages included), every new line live, and runs the same commands as the serial port, plus a few that only make sense remotely. ESP-IDF's own log lines are teed into it.
|
||||
The goal of Firmware Updates is to manage the device without a cable, and that includes finding out what went wrong. So a **Debug Build** (`cardputer-adv-debug`, `-DRORO_DEBUG`, version suffix `+debug`) adds a **Debug Console** on TCP 2323: the serial console, over Wi-Fi. A client sends a token as its first line, then gets the last 4 KB of console output (boot messages included), every new line live, and runs the same commands as the serial port, plus a few that only make sense remotely. ESP-IDF's own log lines are teed into it.
|
||||
|
||||
It's compiled out of release builds entirely, rather than switched off by a setting. A console that runs commands is a remote control: in a release build, nothing listens.
|
||||
|
||||
@@ -9,7 +9,7 @@ It's compiled out of release builds entirely, rather than switched off by a sett
|
||||
- **Console, not Serial.** All human-readable output goes through `console`, which writes to the USB port and, in a Debug Build, to a ring buffer the Debug Console drains. Writes never wait for USB: a host that's attached but not reading used to stall the main loop for up to 2 s per line.
|
||||
- **Commands run on the main loop.** The socket lives on the Debug Console's own task, which only queues command lines. The main loop runs them, as it does serial commands, so they touch Apps and Services from the one task allowed to.
|
||||
- **The token** is 128 random bits in `~/.config/roro9stack/debug-token`, made by the first build and passed into the container. It's never committed; a Debug Build refuses to compile without one. Like the OTA key, it guards against the network, not against someone holding the device.
|
||||
- **One client at a time**, to keep memory flat (about 6 KB for the ring, 6 KB of task stack).
|
||||
- **One client at a time**, to keep memory flat (4 KB for the ring since M2, 6 KB of task stack).
|
||||
- **Binary commands are answered on the console's own task**, not queued: `get`/`put` (SD card files, run as one Storage Service job each so card access stays on the storage task, with TCP doing the flow control), `screenshot` (the 32 KB RGB332 frame the UI composes into, read as it stands, so it may tear), `coredump get` and `reset`. These keep working when the main loop is stuck. A failed `put` closes the connection, so the rest of the file is never read as commands.
|
||||
|
||||
## Keep a Debug Build in the fallback slot
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# The framework is rebuilt with our own SDK settings, for smaller TLS buffers
|
||||
|
||||
Arduino-ESP32 ships its ESP-IDF libraries prebuilt, with one `sdkconfig` for every ESP32-S3 board. Its TLS settings give every connection a 16 KB receive buffer and a 16 KB send buffer for its whole life. On a device with no PSRAM and about 340 KB of RAM, an IRC connection over TLS left a 12.6 KB low in M2, against a 40 KB floor.
|
||||
|
||||
Those settings are compiled into the libraries, so changing them means rebuilding them. pioarduino supports this as a "hybrid compile": `custom_sdkconfig` in `platformio.ini` lists the settings, and the build regenerates the framework's libraries from ESP-IDF (the same 5.5.5 the prebuilt ones come from) before building the app. We set:
|
||||
|
||||
- `MBEDTLS_ASYMMETRIC_CONTENT_LEN`, with 16 KB to receive (servers send full TLS records) and **4 KB to send** (IRC lines are short): 12 KB less per connection.
|
||||
- `MBEDTLS_DYNAMIC_BUFFER`, `DYNAMIC_FREE_CONFIG_DATA`, `DYNAMIC_FREE_CA_CERT`: buffers allocated when needed, and handshake-only data (the CA chain) freed once connected.
|
||||
|
||||
The rebuild also follows the board definition instead of the generic one: PSRAM support is off (the Cardputer ADV has none) and the flash size is 8 MB.
|
||||
|
||||
## Consequences
|
||||
|
||||
- With IRC connected over TLS, a Debug Build has 78 KB free and a 59 KB low (it was 31 KB and 12.6 KB), and 46 KB at the lowest under the heaviest combined load measured (IRC, two refused installs, a 1.6 MB put and get).
|
||||
- The first build after a fresh checkout, or after changing `custom_sdkconfig`, takes about 4 minutes instead of 45 s: it downloads ESP-IDF into the PlatformIO volume and compiles it. Later builds reuse it.
|
||||
- Everything the firmware depends on was checked in the regenerated `sdkconfig`: app rollback, core dumps to flash (ELF), the 5 s task watchdog, FreeRTOS run-time stats, the certificate bundle.
|
||||
- The project now owns its partition table (`default_8MB.csv`, identical to the framework's), which the hybrid build requires. Changing it would break updates over the air: the app slots must stay where they are.
|
||||
- Generated files (`sdkconfig.*`, `managed_components/`, `.dummy/`) are ignored by git.
|
||||
- A TLS server that sends records over 16 KB would still fail, as before; one that needs us to send records over 4 KB would now fail. Neither happens with IRC.
|
||||
@@ -0,0 +1,59 @@
|
||||
# M2 — GNSS
|
||||
|
||||
**Status:** done on the device (branch `m2`): every "Done when" item below is met.
|
||||
|
||||
## Measured
|
||||
|
||||
- **Cold start** (`$PCAS10,2`) to a 3D Fix, by a window: **73 s**, 5 satellites used of 8 in view. A restart of the ESP32 alone keeps the receiver's Fix (the Cap stays powered).
|
||||
- By a window: 3D Fix from GPS, GLONASS, Galileo and BeiDou, up to 14 of 17 satellites used, HDOP 1.0–1.3.
|
||||
- **Heap, Debug Build, GNSS on, IRC on TLS** (floor 40 KB; v0.2.1 had a 79 KB low):
|
||||
|
||||
| | Free | Lowest |
|
||||
|---|---|---|
|
||||
| Start of M2 | 31 KB | 12.6 KB |
|
||||
| Stacks and buffers trimmed by measurement | 46 KB | 18 KB |
|
||||
| mDNS removed | 54 KB | 34 KB |
|
||||
| Framework rebuilt with smaller TLS buffers (ADR 0006) | **78 KB** | **59 KB** |
|
||||
|
||||
Under the heaviest combined load measured (IRC, two refused installs, a 1.6 MB put and get), the low is 46 KB. The cost had come mostly from the OTA and Debug Build work, not GNSS. Stacks were set to measured peak plus about 2 KB (loop 6 KB, update 5, storage 6, irc 6); after a TLS handshake the irc task has 1.7 KB left. IRC can now be stopped by hand (`/quit` in any state, `irc stop`), which frees its TLS memory.
|
||||
|
||||
**Goal:** the device knows where it is and what time it is without a network: a GNSS Service in the background, a GNSS App with the position and a sky view of the satellites, the clock set from satellites when there's no NTP, and Tracks recorded to the SD card.
|
||||
|
||||
**Hardware:** the Cap LoRa-1262 carries an ATGM336H-6N (AT6668), multi-constellation (GPS, BeiDou, Galileo, GLONASS, QZSS), with a ceramic antenna. NMEA over UART, 115200 8N1. **Measured (step 1, `gnss probe`): RX GPIO 15, TX GPIO 13, 115200 8N1**, as in Meshtastic's board file; M5Stack's page names GPIO 8 and 9, which are the internal I2C bus the keyboard controller sits on. Output is NMEA 4.10 style: `GN` RMC, VTG and GGA, one GSA per constellation with the system ID (1 GPS, 2 GLONASS, 3 Galileo, 4 BeiDou, 5 QZSS) in its last field, and a GSV sequence per constellation and signal (`GP`, `GL`, `GA`, …) with the signal ID last. RMC carries a time even without a Fix (status `V`), so only a Fix makes it trustworthy.
|
||||
|
||||
## Decisions (design round 2026-10-04)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q58 | Settings has a GNSS On/Off switch, **On by default**. Off puts the receiver in standby. *Measured:* `$PCAS12,<seconds>` (CASIC) stops its output within a second, for up to at least 65535 s, and any command wakes it within a second; Off sends `PCAS12,65535` (renewed hourly), On sends a hot start, `PCAS10,0`. |
|
||||
| Q59 | The **GNSS App** has two views, switched with Tab. *Position*: latitude, longitude, altitude, speed, course, Fix (none / 2D / 3D), satellites used and in view, HDOP, UTC time. *Sky*: the satellites placed by azimuth and elevation, coloured by constellation, filled when used in the Fix. |
|
||||
| Q60 | "Radar" in M2 means the Sky view. A radar of other Nodes by distance and bearing needs the mesh: M4. |
|
||||
| Q61 | The **Status Bar** shows a GNSS mark: absent when off, muted while searching, normal with a 2D Fix, with the satellite count with a 3D Fix. |
|
||||
| Q62 | GNSS time **sets the clock once there's a Fix**, and refreshes it every 10 minutes. *Revised in step 3:* the clock's trust order from M0 (Mesh < NTP < GNSS) already ranks GNSS above NTP, which is right: GNSS time is at least as accurate. So GNSS also corrects a clock NTP set, not only an unset one. |
|
||||
| Q63 | A **Track** is started and stopped in the GNSS App. It's written as GPX to `/gnss/tracks/<YYYYMMDD-HHMMSS>.gpx`, a point every 5 s when the position moved more than 5 m. It keeps recording with the App closed, with a Toast on start and stop and a Status Bar mark, and gets its own Storage Clean-up category. |
|
||||
| Q64 | Coordinates in **decimal degrees plus the Maidenhead locator**; a Settings switch for degrees, minutes and seconds. Metric units only. |
|
||||
| Q65 | **The position never leaves the device in M2.** Sharing it over the mesh, and at what precision, is decided in M4. |
|
||||
| Q66 | **Our own NMEA parser**, host-tested: RMC, GGA, GSA and GSV, with each talker ID mapped to its constellation. TinyGPSPlus (named in ADR 0001) doesn't track the satellite list across constellations, which the Sky view needs. |
|
||||
| Q67 | The receiver keeps its **defaults** (all constellations, 1 Hz). No receiver settings. Time to first fix is measured and recorded here. |
|
||||
| Q68 | Debug aids: `gnss status`, and `gnss nmea on/off` to stream the raw sentences to the console (USB serial and Debug Console). Raw NMEA is never written to the card. |
|
||||
|
||||
**Lesson (step 3):** the first probe also tried the pins swapped, driving the receiver's output line from the ESP32 for about a second. The receiver then went silent until a full power cycle (an ESP32 restart doesn't cut the Cap's power). Never drive GPIO 15.
|
||||
|
||||
## Done when
|
||||
|
||||
- The GNSS Service reads NMEA in the background whatever App is on screen, and a 3D Fix appears outdoors.
|
||||
- The GNSS App shows the Position and Sky views, both live.
|
||||
- The Status Bar shows the GNSS mark per Q61.
|
||||
- With no Wi-Fi, the clock is set from GNSS after the first Fix.
|
||||
- A Track records while the App is closed, survives the screen turning off, and opens as valid GPX on the PC.
|
||||
- Settings → GNSS Off stops it (and the Status Bar mark goes away); On brings it back.
|
||||
- Free heap stays above about 40 KB with GNSS, Wi-Fi, IRC on TLS and the UI running.
|
||||
|
||||
## Work breakdown
|
||||
|
||||
1. **Hardware check:** a `gnss probe` command reads the candidate UART pins and reports which carries NMEA, at what baud rate, and which talker IDs. Then the standby command (Q58) and a first time to first fix.
|
||||
2. **NMEA parser** (host-tested): checksum, RMC, GGA, GSA, GSV across constellations, merged into one GNSS state (Fix, position, time, satellites).
|
||||
3. **GNSS Service:** UART on its own task, the parser, `gnss status` and `gnss nmea`, Settings On/Off.
|
||||
4. **Clock from GNSS** (Q62), and the Status Bar mark (Q61).
|
||||
5. **GNSS App:** Position view, Maidenhead and coordinate formats (host-tested), then the Sky view.
|
||||
6. **Tracks:** the 5 s / 5 m rule and GPX writing (host-tested), background recording, Clean-up category.
|
||||
@@ -8,7 +8,7 @@
|
||||
|---|---|
|
||||
| Q52 | Two sources: **push over Wi-Fi** from the PC, and **from the SD card**. Pulling from Gitea releases is deferred. |
|
||||
| Q53 | **Signed Update Files** (ECDSA P-256 over SHA-256). The private key stays in `~/.config/roro9stack/`, and the firmware embeds the public key (ADR 0003). |
|
||||
| Q54 | The device **always listens** for pushes on the LAN while Wi-Fi is Connected, announced as `roro9stack-<id>.local`. |
|
||||
| Q54 | The device **always listens** for pushes on the LAN while Wi-Fi is Connected. *Revised in M2:* it was announced over mDNS as `roro9stack-<id>.local`; mDNS was removed to save RAM (it never crossed the dev box's routed network anyway). Pushes go to the IP shown in Settings → Firmware. |
|
||||
| Q55 | New firmware runs on **Probation**. It's confirmed once booted, UI drawn, Services started, 30 s without a crash, and Wi-Fi connected (if configured). Otherwise **Rollback**. A Toast reports either outcome. |
|
||||
| Q56 | **Downgrades are allowed**, with "older than the installed version" shown. |
|
||||
| Q57 | A valid push **installs right away**: progress screen, then reboot. The reboot waits for Text Entry to end, 60 s at most. |
|
||||
|
||||
@@ -20,6 +20,7 @@ const RowDef kRows[] = {
|
||||
{Row::Region, Kind::Choice, "Region"}, {Row::Timezone, Kind::Choice, "Timezone"},
|
||||
{Row::Brightness, Kind::Slider, "Brightness"}, {Row::DimTimeout, Kind::Choice, "Dim after"},
|
||||
{Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Sound, Kind::Toggle, "Sound & LED"},
|
||||
{Row::Gnss, Kind::Toggle, "GNSS"}, {Row::Coordinates, Kind::Toggle, "Coordinates"},
|
||||
{Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"},
|
||||
{Row::Storage, Kind::Page, "Storage"},
|
||||
{Row::Firmware, Kind::Page, "Firmware"},
|
||||
@@ -79,6 +80,8 @@ std::string SettingsMenu::value(int i) const {
|
||||
case Row::DimTimeout: return formatSeconds(settings_.getInt(Setting::DimTimeoutS));
|
||||
case Row::OffTimeout: return formatSeconds(settings_.getInt(Setting::OffTimeoutS));
|
||||
case Row::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off";
|
||||
case Row::Gnss: return settings_.getBool(Setting::GnssEnabled) ? "On" : "Off";
|
||||
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";
|
||||
default: return "";
|
||||
@@ -122,6 +125,8 @@ std::string SettingsMenu::choose(int i, int c) {
|
||||
|
||||
void SettingsMenu::toggle(int i) {
|
||||
if (row(i) == Row::Sound) settings_.setBool(Setting::Sound, !settings_.getBool(Setting::Sound));
|
||||
if (row(i) == Row::Gnss) settings_.setBool(Setting::GnssEnabled, !settings_.getBool(Setting::GnssEnabled));
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace roro {
|
||||
// values, choice lists and validation messages. Rendering and navigation live in the App.
|
||||
class SettingsMenu {
|
||||
public:
|
||||
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, ProbeMacs, Wifi, Storage, Firmware, About };
|
||||
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, Coordinates, ProbeMacs, Wifi, Storage, Firmware, About };
|
||||
enum class Kind { Text, Choice, Toggle, Slider, Page };
|
||||
|
||||
explicit SettingsMenu(Settings& settings) : settings_(settings) {}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#include "geo_format.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
namespace roro::gnss {
|
||||
|
||||
namespace {
|
||||
const char* hemisphere(double degrees, bool latitude) {
|
||||
return latitude ? (degrees < 0 ? "S" : "N") : (degrees < 0 ? "W" : "E");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string formatDecimal(double degrees, bool latitude) {
|
||||
char buf[32];
|
||||
std::snprintf(buf, sizeof buf, "%.5f\xC2\xB0 %s", std::fabs(degrees), hemisphere(degrees, latitude));
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string formatDms(double degrees, bool latitude) {
|
||||
// Work in tenths of a second, so rounding carries into minutes and degrees.
|
||||
long tenths = std::lround(std::fabs(degrees) * 36000.0);
|
||||
long d = tenths / 36000, m = tenths / 600 % 60, s10 = tenths % 600;
|
||||
char buf[40];
|
||||
std::snprintf(buf, sizeof buf, "%ld\xC2\xB0 %02ld' %02ld.%ld\" %s", d, m, s10 / 10, s10 % 10,
|
||||
hemisphere(degrees, latitude));
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string maidenhead(double latitude, double longitude) {
|
||||
double lon = std::fmin(std::fmax(longitude + 180.0, 0.0), 359.999999);
|
||||
double lat = std::fmin(std::fmax(latitude + 90.0, 0.0), 179.999999);
|
||||
std::string out;
|
||||
out += static_cast<char>('A' + static_cast<int>(lon / 20));
|
||||
out += static_cast<char>('A' + static_cast<int>(lat / 10));
|
||||
out += static_cast<char>('0' + static_cast<int>(std::fmod(lon, 20) / 2));
|
||||
out += static_cast<char>('0' + static_cast<int>(std::fmod(lat, 10)));
|
||||
out += static_cast<char>('a' + static_cast<int>(std::fmod(lon, 2) * 12));
|
||||
out += static_cast<char>('a' + static_cast<int>(std::fmod(lat, 1) * 24));
|
||||
return out;
|
||||
}
|
||||
|
||||
SkyPoint skyPosition(int azimuthDeg, int elevationDeg, int cx, int cy, int radius) {
|
||||
int elevation = elevationDeg < 0 ? 0 : elevationDeg > 90 ? 90 : elevationDeg;
|
||||
double r = radius * (90 - elevation) / 90.0;
|
||||
double az = azimuthDeg * M_PI / 180.0;
|
||||
return {cx + static_cast<int>(std::lround(r * std::sin(az))), cy - static_cast<int>(std::lround(r * std::cos(az)))};
|
||||
}
|
||||
|
||||
} // namespace roro::gnss
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace roro::gnss {
|
||||
|
||||
// "50.86920° N": five decimals, about a metre.
|
||||
std::string formatDecimal(double degrees, bool latitude);
|
||||
// "50° 52' 09.1\" N" (Settings → Coordinates, Q64).
|
||||
std::string formatDms(double degrees, bool latitude);
|
||||
// The 6-character Maidenhead locator ("JO20ef"), as radio amateurs give their square.
|
||||
std::string maidenhead(double latitude, double longitude);
|
||||
|
||||
// Where a satellite goes on the Sky view: the zenith in the centre, the horizon on the circle,
|
||||
// north up and east right (as seen looking at the sky from above, like a map).
|
||||
struct SkyPoint {
|
||||
int x, y;
|
||||
};
|
||||
SkyPoint skyPosition(int azimuthDeg, int elevationDeg, int cx, int cy, int radius);
|
||||
|
||||
} // namespace roro::gnss
|
||||
@@ -0,0 +1,234 @@
|
||||
#include "nmea_parser.h"
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
namespace roro::gnss {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr double kKmhPerKnot = 1.852;
|
||||
|
||||
int hexValue(char c) {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
Constellation fromTalker(const std::string& t) {
|
||||
if (t == "GP") return Constellation::Gps;
|
||||
if (t == "GL") return Constellation::Glonass;
|
||||
if (t == "GA") return Constellation::Galileo;
|
||||
if (t == "GB" || t == "BD") return Constellation::BeiDou;
|
||||
if (t == "GQ" || t == "QZ") return Constellation::Qzss;
|
||||
if (t == "GI") return Constellation::Navic;
|
||||
return Constellation::Unknown; // GN: combined
|
||||
}
|
||||
|
||||
// GSA's system ID field (NMEA 4.10).
|
||||
Constellation fromSystemId(int id) {
|
||||
switch (id) {
|
||||
case 1: return Constellation::Gps;
|
||||
case 2: return Constellation::Glonass;
|
||||
case 3: return Constellation::Galileo;
|
||||
case 4: return Constellation::BeiDou;
|
||||
case 5: return Constellation::Qzss;
|
||||
case 6: return Constellation::Navic;
|
||||
default: return Constellation::Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
bool number(const std::string& s, double& out) {
|
||||
if (s.empty()) return false;
|
||||
char* end;
|
||||
out = std::strtod(s.c_str(), &end);
|
||||
return *end == '\0';
|
||||
}
|
||||
|
||||
int integer(const std::string& s, int fallback = 0) {
|
||||
double v;
|
||||
return number(s, v) ? static_cast<int>(v) : fallback;
|
||||
}
|
||||
|
||||
// "ddmm.mmmm" / "dddmm.mmmm" with its hemisphere letter.
|
||||
bool coordinate(const std::string& value, const std::string& hemisphere, double& out) {
|
||||
double raw;
|
||||
if (!number(value, raw) || hemisphere.empty()) return false;
|
||||
int degrees = static_cast<int>(raw / 100);
|
||||
out = degrees + (raw - degrees * 100) / 60.0;
|
||||
if (hemisphere == "S" || hemisphere == "W") out = -out;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm).
|
||||
int64_t daysFromCivil(int y, int m, int d) {
|
||||
y -= m <= 2;
|
||||
const int64_t era = (y >= 0 ? y : y - 399) / 400;
|
||||
const unsigned yoe = static_cast<unsigned>(y - era * 400);
|
||||
const unsigned doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
|
||||
const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
return era * 146097 + static_cast<int64_t>(doe) - 719468;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char* constellationName(Constellation c) {
|
||||
switch (c) {
|
||||
case Constellation::Gps: return "GPS";
|
||||
case Constellation::Glonass: return "GLONASS";
|
||||
case Constellation::Galileo: return "Galileo";
|
||||
case Constellation::BeiDou: return "BeiDou";
|
||||
case Constellation::Qzss: return "QZSS";
|
||||
case Constellation::Navic: return "NavIC";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
int64_t GnssState::utcSeconds() const {
|
||||
return daysFromCivil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second;
|
||||
}
|
||||
|
||||
void NmeaParser::feed(const char* data, size_t len) {
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
char c = data[i];
|
||||
if (c == '\n') {
|
||||
if (!overflow_ && !line_.empty()) {
|
||||
if (onLine) onLine(line_);
|
||||
sentence(line_);
|
||||
}
|
||||
line_.clear();
|
||||
overflow_ = false;
|
||||
} else if (c != '\r') {
|
||||
if (line_.size() >= kMaxLine) overflow_ = true;
|
||||
else line_ += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool NmeaParser::sentence(const std::string& line) {
|
||||
size_t star = line.rfind('*');
|
||||
if (line.size() < 7 || line[0] != '$' || star == std::string::npos || star + 3 != line.size()) {
|
||||
bad_++;
|
||||
return false;
|
||||
}
|
||||
uint8_t sum = 0;
|
||||
for (size_t i = 1; i < star; i++) sum ^= static_cast<uint8_t>(line[i]);
|
||||
int hi = hexValue(line[star + 1]), lo = hexValue(line[star + 2]);
|
||||
if (hi < 0 || lo < 0 || sum != (hi << 4 | lo)) {
|
||||
bad_++;
|
||||
return false;
|
||||
}
|
||||
good_++;
|
||||
|
||||
Fields f;
|
||||
size_t start = 1;
|
||||
for (size_t i = 1; i <= star; i++)
|
||||
if (i == star || line[i] == ',') {
|
||||
f.push_back(line.substr(start, i - start));
|
||||
start = i + 1;
|
||||
}
|
||||
if (f[0].size() < 5) return true;
|
||||
std::string talker = f[0].substr(0, 2), type = f[0].substr(f[0].size() - 3);
|
||||
if (type == "RMC") rmc(f);
|
||||
else if (type == "GGA") gga(f);
|
||||
else if (type == "GSA") gsa(f, fromTalker(talker));
|
||||
else if (type == "GSV") gsv(f, fromTalker(talker));
|
||||
return true;
|
||||
}
|
||||
|
||||
void NmeaParser::rmc(const Fields& f) {
|
||||
if (f.size() < 10) return;
|
||||
bool active = f[2] == "A";
|
||||
double lat, lon;
|
||||
state_.positionValid = active && coordinate(f[3], f[4], lat) && coordinate(f[5], f[6], lon);
|
||||
if (state_.positionValid) {
|
||||
state_.latitude = lat;
|
||||
state_.longitude = lon;
|
||||
}
|
||||
double knots, course;
|
||||
state_.speedKmh = active && number(f[7], knots) ? static_cast<float>(knots * kKmhPerKnot) : 0;
|
||||
state_.courseValid = active && number(f[8], course);
|
||||
if (state_.courseValid) state_.courseDeg = static_cast<float>(course);
|
||||
|
||||
const std::string &t = f[1], &d = f[9];
|
||||
state_.timeValid = active && t.size() >= 6 && d.size() == 6;
|
||||
if (state_.timeValid) {
|
||||
state_.hour = integer(t.substr(0, 2));
|
||||
state_.minute = integer(t.substr(2, 2));
|
||||
state_.second = integer(t.substr(4, 2));
|
||||
state_.day = integer(d.substr(0, 2));
|
||||
state_.month = integer(d.substr(2, 2));
|
||||
state_.year = 2000 + integer(d.substr(4, 2));
|
||||
}
|
||||
}
|
||||
|
||||
void NmeaParser::gga(const Fields& f) {
|
||||
if (f.size() < 10) return;
|
||||
int quality = integer(f[6]);
|
||||
double lat, lon, hdop, alt;
|
||||
state_.positionValid = quality > 0 && coordinate(f[2], f[3], lat) && coordinate(f[4], f[5], lon);
|
||||
if (state_.positionValid) {
|
||||
state_.latitude = lat;
|
||||
state_.longitude = lon;
|
||||
}
|
||||
state_.satellitesUsed = integer(f[7]);
|
||||
if (number(f[8], hdop)) state_.hdop = static_cast<float>(hdop);
|
||||
state_.altitudeValid = quality > 0 && number(f[9], alt);
|
||||
if (state_.altitudeValid) state_.altitudeM = static_cast<float>(alt);
|
||||
}
|
||||
|
||||
void NmeaParser::gsa(const Fields& f, Constellation talker) {
|
||||
if (f.size() < 18) return;
|
||||
int mode = integer(f[2], 1);
|
||||
state_.fix = mode == 3 ? FixType::ThreeD : mode == 2 ? FixType::TwoD : FixType::None;
|
||||
Constellation system = f.size() > 18 ? fromSystemId(integer(f[18])) : talker;
|
||||
if (system == Constellation::Unknown) return; // can't tell whose satellites these are
|
||||
std::set<int>& used = used_[static_cast<int>(system)];
|
||||
used.clear();
|
||||
for (size_t i = 3; i <= 14; i++)
|
||||
if (!f[i].empty()) used.insert(integer(f[i]));
|
||||
rebuildSatellites();
|
||||
}
|
||||
|
||||
void NmeaParser::gsv(const Fields& f, Constellation talker) {
|
||||
if (f.size() < 4 || talker == Constellation::Unknown) return;
|
||||
int total = integer(f[1]), number = integer(f[2]);
|
||||
size_t extra = f.size() - 4;
|
||||
int signal = extra % 4 == 1 ? integer(f.back()) : 0; // NMEA 4.10 signal ID, last
|
||||
auto key = std::make_pair(static_cast<int>(talker), signal);
|
||||
std::vector<Satellite>& pending = pending_[key];
|
||||
if (number == 1) pending.clear();
|
||||
for (size_t i = 4; i + 3 < f.size(); i += 4) {
|
||||
Satellite s;
|
||||
s.system = talker;
|
||||
s.prn = integer(f[i]);
|
||||
s.elevation = integer(f[i + 1]);
|
||||
s.azimuth = integer(f[i + 2]);
|
||||
s.snr = integer(f[i + 3], -1);
|
||||
if (s.prn > 0) pending.push_back(s);
|
||||
}
|
||||
if (number == total) {
|
||||
inView_[key] = pending;
|
||||
pending.clear();
|
||||
rebuildSatellites();
|
||||
}
|
||||
}
|
||||
|
||||
void NmeaParser::rebuildSatellites() {
|
||||
std::vector<Satellite> merged;
|
||||
for (auto& [key, list] : inView_)
|
||||
for (const Satellite& s : list) {
|
||||
Satellite* same = nullptr;
|
||||
for (auto& m : merged)
|
||||
if (m.system == s.system && m.prn == s.prn) same = &m;
|
||||
if (!same) merged.push_back(s);
|
||||
else if (s.snr > same->snr) same->snr = s.snr;
|
||||
}
|
||||
for (auto& s : merged) {
|
||||
auto it = used_.find(static_cast<int>(s.system));
|
||||
s.used = it != used_.end() && it->second.count(s.prn) > 0;
|
||||
}
|
||||
state_.satellites = std::move(merged);
|
||||
}
|
||||
|
||||
} // namespace roro::gnss
|
||||
@@ -0,0 +1,82 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace roro::gnss {
|
||||
|
||||
enum class Constellation : uint8_t { Unknown, Gps, Glonass, Galileo, BeiDou, Qzss, Navic };
|
||||
const char* constellationName(Constellation c);
|
||||
|
||||
enum class FixType : uint8_t { None, TwoD, ThreeD };
|
||||
|
||||
struct Satellite {
|
||||
Constellation system = Constellation::Unknown;
|
||||
int prn = 0;
|
||||
int elevation = 0; // degrees above the horizon
|
||||
int azimuth = 0; // degrees from north, clockwise
|
||||
int snr = -1; // dB-Hz; -1 when not tracked
|
||||
bool used = false; // part of the current Fix
|
||||
};
|
||||
|
||||
// Everything the receiver has said, as of the last sentence (see CONTEXT.md: Fix).
|
||||
struct GnssState {
|
||||
FixType fix = FixType::None;
|
||||
bool positionValid = false;
|
||||
double latitude = 0, longitude = 0; // degrees, south and west negative
|
||||
bool altitudeValid = false;
|
||||
float altitudeM = 0; // above mean sea level
|
||||
float speedKmh = 0;
|
||||
bool courseValid = false;
|
||||
float courseDeg = 0;
|
||||
int satellitesUsed = 0;
|
||||
float hdop = 99.9f;
|
||||
// UTC, trusted only with a Fix: the receiver reports a time without one, from its own clock.
|
||||
bool timeValid = false;
|
||||
int year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0;
|
||||
std::vector<Satellite> satellites; // in view, all constellations, one entry per satellite
|
||||
|
||||
int64_t utcSeconds() const; // seconds since 1970-01-01 UTC; meaningful when timeValid
|
||||
};
|
||||
|
||||
// NMEA 0183 (4.10 style, as the Cap's AT6668 sends it): RMC, GGA, GSA and GSV, with the
|
||||
// constellation taken from the talker ID or GSA's system ID field. Host-tested; no hardware here.
|
||||
class NmeaParser {
|
||||
public:
|
||||
// Raw bytes from the UART, in any chunks.
|
||||
void feed(const char* data, size_t len);
|
||||
// One sentence without its line end. False if malformed or its checksum is wrong.
|
||||
bool sentence(const std::string& line);
|
||||
|
||||
// Called with every complete line fed in, valid or not (`gnss nmea on` echoes them).
|
||||
std::function<void(const std::string&)> onLine;
|
||||
|
||||
const GnssState& state() const { return state_; }
|
||||
uint32_t goodSentences() const { return good_; }
|
||||
uint32_t badSentences() const { return bad_; }
|
||||
|
||||
private:
|
||||
using Fields = std::vector<std::string>;
|
||||
void rmc(const Fields& f);
|
||||
void gga(const Fields& f);
|
||||
void gsa(const Fields& f, Constellation talker);
|
||||
void gsv(const Fields& f, Constellation talker);
|
||||
void rebuildSatellites();
|
||||
|
||||
static constexpr size_t kMaxLine = 120;
|
||||
std::string line_;
|
||||
bool overflow_ = false;
|
||||
uint32_t good_ = 0, bad_ = 0;
|
||||
GnssState state_;
|
||||
// GSV sequences per (constellation, signal): pending until their last message.
|
||||
std::map<std::pair<int, int>, std::vector<Satellite>> pending_, inView_;
|
||||
std::map<int, std::set<int>> used_; // per constellation, from GSA
|
||||
};
|
||||
|
||||
} // namespace roro::gnss
|
||||
@@ -0,0 +1,76 @@
|
||||
#include "track.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
|
||||
namespace roro::gnss {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr double kEarthRadiusM = 6371000.0;
|
||||
|
||||
double radians(double degrees) { return degrees * M_PI / 180.0; }
|
||||
|
||||
// UTC fields from seconds since 1970 (gmtime_r works on the device and the PC alike).
|
||||
struct tm utc(int64_t seconds) {
|
||||
time_t t = static_cast<time_t>(seconds);
|
||||
struct tm out;
|
||||
gmtime_r(&t, &out);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
double distanceMeters(double lat1, double lon1, double lat2, double lon2) {
|
||||
double dLat = radians(lat2 - lat1), dLon = radians(lon2 - lon1);
|
||||
double h = std::sin(dLat / 2) * std::sin(dLat / 2) +
|
||||
std::cos(radians(lat1)) * std::cos(radians(lat2)) * std::sin(dLon / 2) * std::sin(dLon / 2);
|
||||
return 2 * kEarthRadiusM * std::asin(std::sqrt(h));
|
||||
}
|
||||
|
||||
bool TrackRule::due(uint32_t nowMs, double lat, double lon) {
|
||||
if (any_ && (nowMs - lastMs_ < kEveryMs || distanceMeters(lat_, lon_, lat, lon) < kMinMeters)) return false;
|
||||
any_ = true;
|
||||
lastMs_ = nowMs;
|
||||
lat_ = lat;
|
||||
lon_ = lon;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string trackPath(int64_t utcSeconds) {
|
||||
struct tm t = utc(utcSeconds);
|
||||
char buf[48];
|
||||
std::snprintf(buf, sizeof buf, "/gnss/tracks/%04d%02d%02d-%02d%02d%02d.gpx", t.tm_year + 1900, t.tm_mon + 1,
|
||||
t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec);
|
||||
return buf;
|
||||
}
|
||||
|
||||
namespace gpx {
|
||||
|
||||
std::string header(const std::string& name) {
|
||||
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
"<gpx version=\"1.1\" creator=\"roro9stack\" xmlns=\"http://www.topografix.com/GPX/1/1\">\n"
|
||||
"<trk><name>" + name + "</name><trkseg>";
|
||||
}
|
||||
|
||||
std::string point(double lat, double lon, bool hasElevation, float elevationM, int64_t utcSeconds) {
|
||||
struct tm t = utc(utcSeconds);
|
||||
char buf[160], ele[32] = "";
|
||||
if (hasElevation) std::snprintf(ele, sizeof ele, "<ele>%.1f</ele>", elevationM);
|
||||
std::snprintf(buf, sizeof buf,
|
||||
"<trkpt lat=\"%.7f\" lon=\"%.7f\">%s<time>%04d-%02d-%02dT%02d:%02d:%02dZ</time></trkpt>", lat, lon,
|
||||
ele, t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec);
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string footer() { return "</trkseg></trk></gpx>"; }
|
||||
|
||||
bool finished(const std::string& tail) {
|
||||
size_t end = tail.find_last_not_of(" \r\n\t");
|
||||
return end != std::string::npos && end + 1 >= 6 && tail.compare(end + 1 - 6, 6, "</gpx>") == 0;
|
||||
}
|
||||
|
||||
} // namespace gpx
|
||||
|
||||
} // namespace roro::gnss
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace roro::gnss {
|
||||
|
||||
// Great-circle distance in metres (haversine, mean Earth radius).
|
||||
double distanceMeters(double lat1, double lon1, double lat2, double lon2);
|
||||
|
||||
// When a Track takes its next point (Q63): the first one always, then at least every 5 s and only
|
||||
// after moving at least 5 m, so standing still doesn't fill the card.
|
||||
class TrackRule {
|
||||
public:
|
||||
static constexpr uint32_t kEveryMs = 5000;
|
||||
static constexpr double kMinMeters = 5.0;
|
||||
|
||||
// True if this position should be recorded (and is then remembered as the last point).
|
||||
bool due(uint32_t nowMs, double lat, double lon);
|
||||
void reset() { any_ = false; }
|
||||
|
||||
private:
|
||||
bool any_ = false;
|
||||
uint32_t lastMs_ = 0;
|
||||
double lat_ = 0, lon_ = 0;
|
||||
};
|
||||
|
||||
// "/gnss/tracks/YYYYMMDD-HHMMSS.gpx", from the UTC start time (Storage Clean-up reads its date).
|
||||
std::string trackPath(int64_t utcSeconds);
|
||||
|
||||
// GPX 1.1, written a line at a time: the header when a Track starts, a point per line, the footer
|
||||
// when it stops. A file whose footer never came (power lost) is closed at the next boot.
|
||||
namespace gpx {
|
||||
std::string header(const std::string& name);
|
||||
std::string point(double lat, double lon, bool hasElevation, float elevationM, int64_t utcSeconds);
|
||||
std::string footer();
|
||||
bool finished(const std::string& tail); // the end of a file: does it close the GPX?
|
||||
} // namespace gpx
|
||||
|
||||
} // namespace roro::gnss
|
||||
@@ -29,6 +29,8 @@ const Definition kDefinitions[] = {
|
||||
{"sound", Kind::Bool, 1, nullptr, 0, 1},
|
||||
{"probe_mac_raw", Kind::Bool, 1, nullptr, 0, 1},
|
||||
{"wifi_on", Kind::Bool, 1, nullptr, 0, 1},
|
||||
{"gnss_on", Kind::Bool, 1, nullptr, 0, 1},
|
||||
{"coord_dms", Kind::Bool, 0, nullptr, 0, 1},
|
||||
};
|
||||
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
|
||||
"every Setting needs a definition");
|
||||
|
||||
@@ -21,6 +21,8 @@ enum class Setting : uint8_t {
|
||||
Sound, // bool
|
||||
ProbeMacRaw, // bool: probe-request Logs keep raw MAC addresses
|
||||
WifiEnabled, // bool: the Wi-Fi Service stays Connected when a Saved Network is in range
|
||||
GnssEnabled, // bool: the GNSS Service reads the receiver (M2, Q58)
|
||||
CoordinatesDms, // bool: show degrees, minutes and seconds instead of decimal degrees (Q64)
|
||||
Count
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ inline const CleanupCategory kCleanupCategories[] = {
|
||||
{"IRC logs", "/irc"},
|
||||
{"Wi-Fi scan logs", "/wifi/scans"},
|
||||
{"Wi-Fi captures", "/captures/wifi"},
|
||||
{"GNSS tracks", "/gnss/tracks"},
|
||||
};
|
||||
|
||||
enum class CleanupAge { OneMonth, ThreeMonths, SixMonths, OneYear, Everything };
|
||||
|
||||
@@ -20,6 +20,16 @@ build_flags =
|
||||
lib_deps =
|
||||
m5stack/M5Cardputer @ 1.1.1
|
||||
test_ignore = *
|
||||
; Smaller TLS buffers (M2): the framework is rebuilt with these settings (pioarduino "hybrid
|
||||
; compile"). Receive stays 16 KB (servers send full TLS records); send drops to 4 KB (IRC lines are
|
||||
; short); buffers are allocated as needed and handshake-only data is freed once connected.
|
||||
custom_sdkconfig =
|
||||
CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN=y
|
||||
CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=16384
|
||||
CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096
|
||||
CONFIG_MBEDTLS_DYNAMIC_BUFFER=y
|
||||
CONFIG_MBEDTLS_DYNAMIC_FREE_CONFIG_DATA=y
|
||||
CONFIG_MBEDTLS_DYNAMIC_FREE_CA_CERT=y
|
||||
|
||||
; Debug Build: the same firmware plus the Debug Console on TCP 2323 (see ADR 0004). The token comes
|
||||
; from ~/.config/roro9stack/debug-token, passed in by scripts/_docker.sh; it's never committed.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"""Pushes a signed Update File to a Cardputer over Wi-Fi (TCP 3232) and reports the result.
|
||||
|
||||
Usage: scripts/ota_push.py <file.ota> <host>
|
||||
<host> is the device's IP (shown in Settings > About), or its name when mDNS works on your network.
|
||||
<host> is the device's IP (shown in Settings > Firmware).
|
||||
"""
|
||||
import socket
|
||||
import sys
|
||||
|
||||
@@ -78,7 +78,6 @@ void FirmwarePage::draw(Canvas& c) {
|
||||
case kVersion: return "Version";
|
||||
case kStatus: return "Status";
|
||||
case kAddress: return "Push to";
|
||||
case kName: return "Name";
|
||||
case kSdHeader: return listed ? (found.empty() ? "No .ota files in /updates" : "On the SD card:") : "Looking on the SD card...";
|
||||
default: return " " + found[i - kFixed].substr(std::string(kUpdatesFolder).size() + 1);
|
||||
}
|
||||
@@ -88,7 +87,6 @@ void FirmwarePage::draw(Canvas& c) {
|
||||
case kVersion: return versionString();
|
||||
case kStatus: return update_.onProbation() ? "on probation" : "confirmed";
|
||||
case kAddress: return ip.empty() ? "Wi-Fi not connected" : ip + ":" + std::to_string(UpdateService::kPort);
|
||||
case kName: return update_.hostname() + ".local";
|
||||
case kSdHeader: return "";
|
||||
default: return "install >";
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class FirmwarePage {
|
||||
void draw(Canvas& c);
|
||||
|
||||
private:
|
||||
enum Row { kVersion, kStatus, kAddress, kName, kSdHeader, kFixed };
|
||||
enum Row { kVersion, kStatus, kAddress, kSdHeader, kFixed };
|
||||
|
||||
std::vector<std::string> files();
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
#include "gnss_app.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
#include "geo_format.h"
|
||||
#include "ui/canvas.h"
|
||||
#include "ui/fonts.h"
|
||||
#include "ui/theme.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
using namespace gnss;
|
||||
|
||||
namespace {
|
||||
|
||||
// One colour per constellation, readable in the 8-bit frame buffer.
|
||||
uint16_t colorOf(Constellation c) {
|
||||
switch (c) {
|
||||
case Constellation::Gps: return theme::kAccent;
|
||||
case Constellation::Glonass: return 0xF8C8; // red
|
||||
case Constellation::Galileo: return theme::kMessage;
|
||||
case Constellation::BeiDou: return 0xFEA0; // yellow
|
||||
case Constellation::Qzss: return 0xC99F; // violet
|
||||
default: return theme::kText;
|
||||
}
|
||||
}
|
||||
|
||||
const char* fixLabel(FixType f) { return f == FixType::ThreeD ? "3D Fix" : f == FixType::TwoD ? "2D Fix" : "No Fix"; }
|
||||
|
||||
std::string duration(uint32_t ms) {
|
||||
uint32_t s = ms / 1000;
|
||||
char buf[24];
|
||||
if (s < 60) std::snprintf(buf, sizeof buf, "%lu s", (unsigned long)s);
|
||||
else std::snprintf(buf, sizeof buf, "%lu min %02lu s", (unsigned long)(s / 60), (unsigned long)(s % 60));
|
||||
return buf;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool GnssApp::onKey(const KeyEvent& e) {
|
||||
if (e.key == Key::Tab) {
|
||||
sky_ = !sky_;
|
||||
requestRedraw();
|
||||
return true;
|
||||
}
|
||||
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();
|
||||
return true;
|
||||
}
|
||||
return false; // Back leaves the App
|
||||
}
|
||||
|
||||
void GnssApp::update(uint32_t nowMs) {
|
||||
if (nowMs - lastDrawMs_ >= 1000) { // the receiver reports once a second
|
||||
lastDrawMs_ = nowMs;
|
||||
requestRedraw();
|
||||
}
|
||||
}
|
||||
|
||||
void GnssApp::draw(Canvas& c) {
|
||||
c.setTextDatum(top_left);
|
||||
const auto& area = theme::kContent;
|
||||
if (!gnss_.on()) {
|
||||
c.setFont(&fonts::bold);
|
||||
c.setTextColor(theme::kText);
|
||||
c.drawString("GNSS is off", 4, area.y + 4);
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("Settings > GNSS turns it on.", 4, area.y + 22);
|
||||
return;
|
||||
}
|
||||
sky_ ? drawSky(c) : drawPosition(c);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.setTextDatum(top_right);
|
||||
c.drawString(sky_ ? "Tab: position" : "Tab: sky", area.w - 3, area.y + area.h - 9);
|
||||
c.setTextDatum(top_left);
|
||||
}
|
||||
|
||||
void GnssApp::drawPosition(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
const GnssState& s = gnss_.state();
|
||||
uint32_t now = millis();
|
||||
int used = 0;
|
||||
for (auto& sat : s.satellites) used += sat.used;
|
||||
char line[64];
|
||||
|
||||
c.setFont(&fonts::bold);
|
||||
c.setTextColor(s.fix == FixType::None ? theme::kWarning : theme::kMessage);
|
||||
if (!gnss_.receiving(now)) std::snprintf(line, sizeof line, "No data from the receiver");
|
||||
else if (s.fix == FixType::None)
|
||||
std::snprintf(line, sizeof line, "Searching: %u in view, %s", (unsigned)s.satellites.size(),
|
||||
duration(now - gnss_.onSinceMs()).c_str());
|
||||
else std::snprintf(line, sizeof line, "%s, %d of %u satellites", fixLabel(s.fix), s.satellitesUsed,
|
||||
(unsigned)s.satellites.size());
|
||||
c.drawString(line, 4, area.y + 2);
|
||||
|
||||
c.setFont(&fonts::body);
|
||||
int y = area.y + 19;
|
||||
auto row = [&](const char* label, const std::string& value) {
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString(label, 4, y);
|
||||
c.setTextColor(theme::kText);
|
||||
c.drawString(value.c_str(), 64, y);
|
||||
y += theme::kLineHeight;
|
||||
};
|
||||
bool dms = settings_.getBool(Setting::CoordinatesDms);
|
||||
if (s.positionValid) {
|
||||
row("Lat", dms ? formatDms(s.latitude, true) : formatDecimal(s.latitude, true));
|
||||
row("Lon", dms ? formatDms(s.longitude, false) : formatDecimal(s.longitude, false));
|
||||
row("Locator", maidenhead(s.latitude, s.longitude));
|
||||
std::snprintf(line, sizeof line, s.altitudeValid ? "%.0f m" : "-", s.altitudeM);
|
||||
row("Altitude", line);
|
||||
if (s.courseValid && s.speedKmh >= 1.0f)
|
||||
std::snprintf(line, sizeof line, "%.1f km/h, %.0f\xC2\xB0", s.speedKmh, s.courseDeg);
|
||||
else std::snprintf(line, sizeof line, "%.1f km/h", s.speedKmh);
|
||||
row("Speed", line);
|
||||
} else {
|
||||
row("Lat", "-");
|
||||
row("Lon", "-");
|
||||
y += theme::kLineHeight * 3;
|
||||
}
|
||||
std::snprintf(line, sizeof line, "%.1f", s.hdop);
|
||||
row("HDOP", s.fix == FixType::None ? "-" : line);
|
||||
if (s.timeValid) std::snprintf(line, sizeof line, "%02d:%02d:%02d UTC", s.hour, s.minute, s.second);
|
||||
row("Time", s.timeValid ? line : "-");
|
||||
|
||||
// The Track line, at the bottom left.
|
||||
c.setFont(&fonts::small);
|
||||
if (gnss_.tracking()) {
|
||||
c.setTextColor(theme::kWarning);
|
||||
std::snprintf(line, sizeof line, "REC %d point%s, %s r: stop", gnss_.trackPoints(),
|
||||
gnss_.trackPoints() == 1 ? "" : "s", duration(now - gnss_.trackStartMs()).c_str());
|
||||
} else if (!refusal_.empty() && now - refusalMs_ < 4000) {
|
||||
c.setTextColor(theme::kWarning);
|
||||
std::snprintf(line, sizeof line, "%s", refusal_.c_str());
|
||||
} else {
|
||||
c.setTextColor(theme::kMuted);
|
||||
std::snprintf(line, sizeof line, "r: record a Track");
|
||||
}
|
||||
c.drawString(line, 4, area.y + area.h - 9);
|
||||
}
|
||||
|
||||
void GnssApp::drawSky(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
const GnssState& s = gnss_.state();
|
||||
const int radius = (area.h - 6) / 2, cx = 4 + radius, cy = area.y + area.h / 2;
|
||||
|
||||
// The sky from above: horizon, 30° and 60° elevation rings, north up.
|
||||
c.drawCircle(cx, cy, radius, theme::kMuted);
|
||||
c.drawCircle(cx, cy, radius * 2 / 3, 0x4208);
|
||||
c.drawCircle(cx, cy, radius / 3, 0x4208);
|
||||
c.drawFastHLine(cx - radius, cy, 2 * radius, 0x4208);
|
||||
c.drawFastVLine(cx, cy - radius, 2 * radius, 0x4208);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.setTextDatum(middle_center);
|
||||
auto compass = [&](const char* letter, int x, int y) {
|
||||
c.fillRect(x - 3, y - 4, 7, 9, theme::kBackground); // off the lines, so it reads
|
||||
c.drawString(letter, x, y);
|
||||
};
|
||||
compass("N", cx, cy - radius + 5);
|
||||
compass("S", cx, cy + radius - 4);
|
||||
compass("E", cx + radius - 4, cy);
|
||||
compass("W", cx - radius + 4, cy);
|
||||
c.setTextDatum(top_left);
|
||||
|
||||
int inView[8] = {}, usedBy[8] = {};
|
||||
for (auto& sat : s.satellites) {
|
||||
int k = static_cast<int>(sat.system);
|
||||
inView[k]++;
|
||||
usedBy[k] += sat.used;
|
||||
SkyPoint p = skyPosition(sat.azimuth, sat.elevation, cx, cy, radius - 3);
|
||||
uint16_t color = colorOf(sat.system);
|
||||
if (sat.used) c.fillCircle(p.x, p.y, 3, color);
|
||||
else c.drawCircle(p.x, p.y, 3, sat.snr < 0 ? theme::kMuted : color);
|
||||
}
|
||||
|
||||
// Legend: used / in view per constellation, then the Fix.
|
||||
int x = cx + radius + 12, y = area.y + 4;
|
||||
c.setFont(&fonts::body);
|
||||
for (int k = 1; k < 7; k++) {
|
||||
if (!inView[k]) continue;
|
||||
auto system = static_cast<Constellation>(k);
|
||||
c.fillCircle(x + 3, y + 6, 3, colorOf(system));
|
||||
char line[32];
|
||||
std::snprintf(line, sizeof line, "%-8s%d/%d", constellationName(system), usedBy[k], inView[k]);
|
||||
c.setTextColor(theme::kText);
|
||||
c.drawString(line, x + 10, y);
|
||||
y += theme::kLineHeight;
|
||||
}
|
||||
if (s.satellites.empty()) {
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("No satellites", x, y);
|
||||
y += theme::kLineHeight;
|
||||
}
|
||||
c.setTextColor(s.fix == FixType::None ? theme::kWarning : theme::kMessage);
|
||||
c.drawString(fixLabel(s.fix), x, y + 4);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("filled: in use", x, area.y + area.h - 20);
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "app.h"
|
||||
#include "services/gnss_service.h"
|
||||
#include "settings.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// GNSS (see docs/milestones/M2.md, Q59): the Position view, and the Sky view with every satellite
|
||||
// in view placed by direction and elevation. Tab switches between them.
|
||||
class GnssApp : public App {
|
||||
public:
|
||||
GnssApp(GnssService& gnss, const Settings& settings) : gnss_(gnss), settings_(settings) {}
|
||||
void onEnter() override { requestRedraw(); }
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
void update(uint32_t nowMs) override;
|
||||
void draw(Canvas& c) override;
|
||||
|
||||
private:
|
||||
void drawPosition(Canvas& c);
|
||||
void drawSky(Canvas& c);
|
||||
|
||||
GnssService& gnss_;
|
||||
const Settings& settings_;
|
||||
bool sky_ = false;
|
||||
uint32_t lastDrawMs_ = 0;
|
||||
std::string refusal_; // why a Track couldn't start, shown for a few seconds
|
||||
uint32_t refusalMs_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -42,7 +42,7 @@ std::string lowered(std::string s) {
|
||||
void IrcApp::onEnter() {
|
||||
page_ = Page::Chat;
|
||||
editing_ = false;
|
||||
irc_.connect();
|
||||
if (!irc_.stoppedByUser()) irc_.connect(); // stopped by hand: stay stopped, typing reconnects
|
||||
view(current_);
|
||||
}
|
||||
|
||||
|
||||
+37
-5
@@ -2,11 +2,13 @@
|
||||
#include <M5Cardputer.h>
|
||||
#include <SD.h>
|
||||
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
|
||||
#include "app_manager.h"
|
||||
#include "apps/demo_app.h"
|
||||
#include "apps/gnss_app.h"
|
||||
#include "apps/irc_app.h"
|
||||
#include "apps/launcher_app.h"
|
||||
#include "apps/settings_app.h"
|
||||
@@ -25,6 +27,7 @@
|
||||
#include "services/irc_service.h"
|
||||
#include "services/clock_service.h"
|
||||
#include "services/debug_console.h"
|
||||
#include "services/gnss_service.h"
|
||||
#include "services/power_service.h"
|
||||
#include "services/storage_service.h"
|
||||
#include "services/update_service.h"
|
||||
@@ -50,6 +53,7 @@ static BatteryService* battery;
|
||||
static StorageService* storageService;
|
||||
static PowerService* power;
|
||||
static ClockService* clockService;
|
||||
static GnssService* gnssService;
|
||||
static SavedNetworks* savedNetworks;
|
||||
static WifiService* wifi;
|
||||
static IrcService* irc;
|
||||
@@ -88,6 +92,15 @@ static StatusInfo currentStatus() {
|
||||
s.sdLevel = storageState.level;
|
||||
s.compose = keyMapper.pendingCompose();
|
||||
s.unread = irc->totalUnread();
|
||||
if (gnssService && gnssService->on()) {
|
||||
const auto& g = gnssService->state();
|
||||
bool fresh = gnssService->receiving(millis());
|
||||
s.gnss = !fresh || g.fix == gnss::FixType::None ? StatusInfo::Gnss::Searching
|
||||
: g.fix == gnss::FixType::TwoD ? StatusInfo::Gnss::TwoD
|
||||
: StatusInfo::Gnss::ThreeD;
|
||||
s.gnssSatellites = g.satellitesUsed;
|
||||
s.tracking = gnssService->tracking();
|
||||
}
|
||||
using WifiState = WifiController::State;
|
||||
switch (wifi->state()) {
|
||||
case WifiState::Connected: {
|
||||
@@ -107,6 +120,9 @@ static StatusInfo currentStatus() {
|
||||
// (UpdateService::tick) decides instead, and an unconfirmed image stays PENDING_VERIFY.
|
||||
extern "C" bool verifyRollbackLater() { return true; } // C linkage, or the weak default wins
|
||||
|
||||
// The main loop's stack: Arduino's default is 8 KB; the measured peak is 3 KB (M2).
|
||||
size_t getArduinoLoopTaskStackSize() { return 6144; }
|
||||
|
||||
static void setupSafeMode(int crashes);
|
||||
|
||||
void setup() {
|
||||
@@ -115,7 +131,7 @@ void setup() {
|
||||
int crashes = crash_report::noteBoot(nvs);
|
||||
safeMode = SafeMode::active(crashes);
|
||||
Serial.setRxBufferSize(2 * FileReceiver::kChunk); // before the port opens; sd put sends 1 chunk at a time
|
||||
Serial.setTxBufferSize(2048); // the console skips Serial when it's full: room for a burst like `tasks`
|
||||
Serial.setTxBufferSize(1024); // the console skips Serial when it's full: room for a burst like `tasks`
|
||||
|
||||
auto cfg = M5.config();
|
||||
M5Cardputer.begin(cfg, true);
|
||||
@@ -129,6 +145,7 @@ void setup() {
|
||||
storageService = new StorageService(bus);
|
||||
power = new PowerService(settings);
|
||||
clockService = new ClockService(settings, bus);
|
||||
gnssService = new GnssService(settings, *clockService, *storageService, bus);
|
||||
savedNetworks = new SavedNetworks(nvs);
|
||||
savedNetworks->load();
|
||||
wifi = new WifiService(settings, *savedNetworks, *clockService);
|
||||
@@ -139,6 +156,7 @@ void setup() {
|
||||
services.add(*power);
|
||||
services.add(*clockService);
|
||||
services.add(*battery);
|
||||
services.add(*gnssService);
|
||||
services.add(*storageService);
|
||||
services.add(*wifi);
|
||||
services.add(*irc);
|
||||
@@ -152,6 +170,7 @@ void setup() {
|
||||
launcher.setManager(*apps);
|
||||
apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)});
|
||||
apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService)});
|
||||
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)});
|
||||
apps->registerApp({"settings", "Settings", false,
|
||||
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update})});
|
||||
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});
|
||||
@@ -323,11 +342,12 @@ static const char* const kHelp =
|
||||
"reboot restart\n"
|
||||
"boot other restart into the other app slot (manual Rollback)\n"
|
||||
"log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\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"
|
||||
"key <name|char> press a key: up down left right select back home, or one character\n"
|
||||
"key <name|char> press a key: up down left right select back home del tab, or one character\n"
|
||||
"wifi status | wifi add <ssid><TAB><password>\n"
|
||||
"irc start | irc dump | irc say <buffer> <text>\n"
|
||||
"irc start | irc stop | irc dump | irc say <buffer> <text>\n"
|
||||
"ls [folder] | rm <path> | install <path.ota> (Update from SD)\n"
|
||||
"sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
|
||||
#ifdef RORO_DEBUG
|
||||
@@ -384,6 +404,16 @@ static void runCommand(String line) {
|
||||
});
|
||||
}
|
||||
if (line.startsWith("install ")) update->installFromSd(line.substring(8).c_str()); // Update from SD
|
||||
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());
|
||||
if (line == "gnss track start" && gnssService) {
|
||||
std::string why = gnssService->startTrack(millis());
|
||||
console.printf("gnss track: %s\n", why.empty() ? "recording" : why.c_str());
|
||||
}
|
||||
if (line == "gnss track stop" && gnssService) gnssService->stopTrack();
|
||||
if (line.startsWith("gnss send ") && gnssService) // the checksum is added
|
||||
console.println(gnssService->send(line.substring(10).c_str()) ? "gnss: sent" : "gnss: off");
|
||||
if (line == "crash") crash_report::print(console, nvs);
|
||||
if (line == "coredump erase") console.println(crash_report::erase() ? "coredump: erased" : "coredump: nothing to erase");
|
||||
#ifdef RORO_DEBUG
|
||||
@@ -394,10 +424,11 @@ static void runCommand(String line) {
|
||||
if (line == "burst")
|
||||
for (int i = 1; i <= 5; i++)
|
||||
bus.publish(Event::withText(EventType::Notification, ("Burst " + String(i)).c_str(), 0));
|
||||
if (line.startsWith("key ")) { // key up|down|left|right|select|back|home
|
||||
if (line.startsWith("key ")) { // key up|down|left|right|select|back|home|del|tab
|
||||
String k = line.substring(4);
|
||||
Key key = k == "up" ? Key::Up : k == "down" ? Key::Down : k == "left" ? Key::Left
|
||||
: k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home : Key::Select;
|
||||
: k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home
|
||||
: k == "del" ? Key::Delete : k == "tab" ? Key::Tab : Key::Select;
|
||||
KeyEvent ev = k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0]) : KeyEvent::of(key);
|
||||
if (!power->onKey(millis())) apps->handleKey(ev);
|
||||
}
|
||||
@@ -432,6 +463,7 @@ static void runCommand(String line) {
|
||||
listingWanted = true;
|
||||
}
|
||||
if (line == "irc start") irc->connect();
|
||||
if (line == "irc stop") irc->disconnect();
|
||||
if (line.startsWith("irc say ")) { // irc say <buffer index> <text>
|
||||
String rest = line.substring(8);
|
||||
int space = rest.indexOf(' ');
|
||||
|
||||
@@ -17,7 +17,7 @@ class Console : public Print {
|
||||
using Print::write;
|
||||
|
||||
#ifdef RORO_DEBUG
|
||||
static constexpr size_t kRingBytes = 6144;
|
||||
static constexpr size_t kRingBytes = 4096;
|
||||
|
||||
// Also copies ESP-IDF's own log lines into the ring (they still reach the serial port).
|
||||
void captureEspLogs();
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
#include "services/gnss_service.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SD.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
|
||||
#include "platform/console.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
namespace {
|
||||
const char* fixName(gnss::FixType f) {
|
||||
return f == gnss::FixType::ThreeD ? "3D" : f == gnss::FixType::TwoD ? "2D" : "none";
|
||||
}
|
||||
} // namespace
|
||||
|
||||
GnssService::GnssService(const Settings& settings, ClockService& clock, StorageService& storage, EventBus& bus)
|
||||
: settings_(settings), clock_(clock), storage_(storage), bus_(bus) {
|
||||
parser_.onLine = [this](const std::string& line) {
|
||||
lastLineMs_ = millis();
|
||||
if (echo_) console.printf("nmea: %s\n", line.c_str());
|
||||
};
|
||||
}
|
||||
|
||||
void GnssService::start() {
|
||||
open(millis());
|
||||
storage_.runJob([this]() { closeUnfinishedTracks(); });
|
||||
}
|
||||
|
||||
void GnssService::stop() { close(); }
|
||||
|
||||
void GnssService::open(uint32_t nowMs) {
|
||||
Serial1.setRxBufferSize(512); // before begin(): over a second of NMEA, read every 50 ms
|
||||
Serial1.begin(kBaud, SERIAL_8N1, kRxPin, kTxPin);
|
||||
open_ = true;
|
||||
active_ = false; // tick() wakes it or puts it to sleep, per the setting
|
||||
standbyMs_ = 0;
|
||||
}
|
||||
|
||||
void GnssService::wake(uint32_t nowMs) {
|
||||
send("PCAS10,0"); // hot start: ends a standby, keeps what the receiver knows
|
||||
active_ = true;
|
||||
openedMs_ = nowMs;
|
||||
firstFixMs_ = 0;
|
||||
lastLineMs_ = 0;
|
||||
lastFix_ = gnss::FixType::None;
|
||||
console.println("gnss: on");
|
||||
}
|
||||
|
||||
void GnssService::standby(uint32_t nowMs) {
|
||||
if (tracking()) stopTrack();
|
||||
send("PCAS12,65535");
|
||||
if (active_) console.println("gnss: off (receiver in standby)");
|
||||
active_ = false;
|
||||
standbyMs_ = nowMs;
|
||||
}
|
||||
|
||||
void GnssService::close() {
|
||||
if (!open_) return;
|
||||
Serial1.end();
|
||||
open_ = active_ = false;
|
||||
lastLineMs_ = 0;
|
||||
}
|
||||
|
||||
void GnssService::tick(uint32_t nowMs) {
|
||||
if (!open_) open(nowMs);
|
||||
bool wanted = settings_.getBool(Setting::GnssEnabled);
|
||||
if (wanted && !active_) wake(nowMs);
|
||||
if (!wanted && (active_ || !standbyMs_ || nowMs - standbyMs_ >= kStandbyRenewMs)) standby(nowMs);
|
||||
|
||||
char buf[128];
|
||||
for (int avail; (avail = Serial1.available()) > 0;) {
|
||||
size_t n = Serial1.read(reinterpret_cast<uint8_t*>(buf), std::min<size_t>(avail, sizeof buf));
|
||||
if (n == 0) break;
|
||||
bytes_ += n;
|
||||
if (active_) parser_.feed(buf, n); // in standby: the last lines before it, discarded
|
||||
}
|
||||
|
||||
if (!active_) return;
|
||||
const gnss::GnssState& s = parser_.state();
|
||||
if (s.fix != lastFix_) {
|
||||
if (s.fix != gnss::FixType::None && !firstFixMs_) {
|
||||
firstFixMs_ = nowMs - openedMs_;
|
||||
console.printf("gnss: first Fix after %lu s\n", (unsigned long)(firstFixMs_ / 1000));
|
||||
}
|
||||
console.printf("gnss: Fix %s, %d satellites used\n", fixName(s.fix), s.satellitesUsed);
|
||||
lastFix_ = s.fix;
|
||||
}
|
||||
recordPoint(nowMs);
|
||||
// GNSS time is the most trusted source (ClockModel): set it with a Fix, then now and then.
|
||||
if (s.timeValid && s.fix != gnss::FixType::None && (!clockSetMs_ || nowMs - clockSetMs_ >= kClockEveryMs)) {
|
||||
if (clock_.set(s.utcSeconds(), TimeSource::Gnss) && !clockSetMs_) console.println("gnss: clock set");
|
||||
clockSetMs_ = nowMs;
|
||||
}
|
||||
}
|
||||
|
||||
void GnssService::notify(const std::string& text) {
|
||||
bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(NotificationLevel::Info)));
|
||||
}
|
||||
|
||||
std::string GnssService::startTrack(uint32_t nowMs) {
|
||||
if (tracking()) return "";
|
||||
if (!active_) return "GNSS is off";
|
||||
if (!storage_.state().present) return "No SD card";
|
||||
int64_t now = clock_.utcNow();
|
||||
if (now < 0) return "Waiting for the time (a Fix or Wi-Fi)";
|
||||
trackPath_ = gnss::trackPath(now);
|
||||
std::string name = trackPath_.substr(trackPath_.rfind('/') + 1);
|
||||
name = name.substr(0, name.size() - 4); // without ".gpx"
|
||||
storage_.appendLine(trackPath_, gnss::gpx::header(name), true);
|
||||
trackPoints_ = 0;
|
||||
trackStartMs_ = nowMs;
|
||||
trackRule_.reset();
|
||||
console.printf("gnss: Track %s started\n", trackPath_.c_str());
|
||||
notify("Track started");
|
||||
return "";
|
||||
}
|
||||
|
||||
void GnssService::stopTrack() {
|
||||
if (!tracking()) return;
|
||||
storage_.appendLine(trackPath_, gnss::gpx::footer(), true);
|
||||
console.printf("gnss: Track %s saved, %d points\n", trackPath_.c_str(), trackPoints_);
|
||||
notify("Track saved: " + std::to_string(trackPoints_) + (trackPoints_ == 1 ? " point" : " points"));
|
||||
trackPath_.clear();
|
||||
}
|
||||
|
||||
void GnssService::recordPoint(uint32_t nowMs) {
|
||||
const gnss::GnssState& s = parser_.state();
|
||||
if (!tracking() || !s.positionValid || !s.timeValid) return;
|
||||
if (!trackRule_.due(nowMs, s.latitude, s.longitude)) return;
|
||||
storage_.appendLine(trackPath_,
|
||||
gnss::gpx::point(s.latitude, s.longitude, s.altitudeValid, s.altitudeM, s.utcSeconds()), true);
|
||||
trackPoints_++;
|
||||
}
|
||||
|
||||
// A Track cut short by a power loss or a crash has no footer: GPX readers refuse it. Close it.
|
||||
void GnssService::closeUnfinishedTracks() {
|
||||
File dir = SD.open("/gnss/tracks");
|
||||
if (!dir || !dir.isDirectory()) return;
|
||||
int closed = 0;
|
||||
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
|
||||
std::string name = f.name();
|
||||
if (f.isDirectory() || name.size() < 4 || name.compare(name.size() - 4, 4, ".gpx") != 0) continue;
|
||||
char tail[32] = {};
|
||||
size_t size = f.size();
|
||||
f.seek(size > sizeof tail - 1 ? size - (sizeof tail - 1) : 0);
|
||||
f.read(reinterpret_cast<uint8_t*>(tail), sizeof tail - 1);
|
||||
f.close();
|
||||
if (gnss::gpx::finished(tail)) continue;
|
||||
File out = SD.open(("/gnss/tracks/" + name).c_str(), FILE_APPEND);
|
||||
if (out) {
|
||||
out.print(("\n" + gnss::gpx::footer() + "\n").c_str());
|
||||
out.close();
|
||||
closed++;
|
||||
}
|
||||
}
|
||||
if (closed) console.printf("gnss: closed %d unfinished Track(s)\n", closed);
|
||||
}
|
||||
|
||||
bool GnssService::send(const std::string& body) {
|
||||
if (!open_) return false;
|
||||
uint8_t sum = 0;
|
||||
for (char c : body) sum ^= static_cast<uint8_t>(c);
|
||||
char tail[8];
|
||||
std::snprintf(tail, sizeof tail, "*%02X\r\n", sum);
|
||||
std::string line = "$" + body + tail;
|
||||
Serial1.write(reinterpret_cast<const uint8_t*>(line.data()), line.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
void GnssService::printStatus(Print& out, uint32_t nowMs) const {
|
||||
if (!active_) return (void)out.println("gnss: off (receiver in standby)");
|
||||
const gnss::GnssState& s = parser_.state();
|
||||
out.printf("gnss: %s, Fix %s, %d used, %u in view, HDOP %.1f\n",
|
||||
receiving(nowMs) ? "receiving" : "silent", fixName(s.fix), s.satellitesUsed,
|
||||
(unsigned)s.satellites.size(), s.hdop);
|
||||
if (s.positionValid)
|
||||
out.printf("gnss: %.6f %.6f, %s%.0f m, %.1f km/h\n", s.latitude, s.longitude,
|
||||
s.altitudeValid ? "" : "altitude unknown ", s.altitudeM, s.speedKmh);
|
||||
if (s.timeValid)
|
||||
out.printf("gnss: %04d-%02d-%02d %02d:%02d:%02d UTC\n", s.year, s.month, s.day, s.hour, s.minute, s.second);
|
||||
int perSystem[8] = {}, usedPerSystem[8] = {};
|
||||
for (auto& sat : s.satellites) {
|
||||
perSystem[static_cast<int>(sat.system)]++;
|
||||
if (sat.used) usedPerSystem[static_cast<int>(sat.system)]++;
|
||||
}
|
||||
out.print("gnss: in view (used)");
|
||||
for (int c = 1; c < 7; c++)
|
||||
if (perSystem[c])
|
||||
out.printf(" %s %d (%d)", gnss::constellationName(static_cast<gnss::Constellation>(c)), perSystem[c],
|
||||
usedPerSystem[c]);
|
||||
out.println();
|
||||
if (tracking())
|
||||
out.printf("gnss: recording %s, %d points, %lu s\n", trackPath_.c_str(), trackPoints_,
|
||||
(unsigned long)((nowMs - trackStartMs_) / 1000));
|
||||
out.printf("gnss: up %lu s, first Fix %s, %lu bytes, %lu sentences, %lu bad\n", (unsigned long)((nowMs - openedMs_) / 1000),
|
||||
firstFixMs_ ? (std::to_string(firstFixMs_ / 1000) + " s").c_str() : "not yet", (unsigned long)bytes_,
|
||||
(unsigned long)parser_.goodSentences(), (unsigned long)parser_.badSentences());
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,84 @@
|
||||
#pragma once
|
||||
|
||||
#include <Print.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "event_bus.h"
|
||||
#include "nmea_parser.h"
|
||||
#include "service.h"
|
||||
#include "services/clock_service.h"
|
||||
#include "services/storage_service.h"
|
||||
#include "settings.h"
|
||||
#include "track.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// The GNSS receiver on the Cap LoRa-1262 (see CONTEXT.md and docs/milestones/M2.md): reads its
|
||||
// NMEA from the main loop's tick (about 450 bytes/s, so a 512-byte UART buffer covers a second),
|
||||
// holds the current Fix, and sets the clock from it. Settings → GNSS switches it on and off.
|
||||
class GnssService : public Service {
|
||||
public:
|
||||
static constexpr int kRxPin = 15, kTxPin = 13; // measured: M2 step 1
|
||||
static constexpr uint32_t kBaud = 115200;
|
||||
static constexpr uint32_t kSilentAfterMs = 3000; // no sentence for this long: receiver silent
|
||||
static constexpr uint32_t kClockEveryMs = 600000; // refresh the clock from the Fix this often
|
||||
// Off (Q58, measured): "$PCAS12,<s>" puts the receiver in standby for that long, and any
|
||||
// command wakes it. Standby is renewed while off, in case the duration is clamped.
|
||||
static constexpr uint32_t kStandbyRenewMs = 3600000;
|
||||
|
||||
GnssService(const Settings& settings, ClockService& clock, StorageService& storage, EventBus& bus);
|
||||
const char* name() const override { return "gnss"; }
|
||||
uint32_t tickIntervalMs() const override { return 50; }
|
||||
void start() override;
|
||||
void stop() override;
|
||||
void tick(uint32_t nowMs) override;
|
||||
|
||||
bool on() const { return active_; }
|
||||
bool receiving(uint32_t nowMs) const { return active_ && lastLineMs_ && nowMs - lastLineMs_ < kSilentAfterMs; }
|
||||
const gnss::GnssState& state() const { return parser_.state(); }
|
||||
uint32_t onSinceMs() const { return openedMs_; } // when the receiver was last woken
|
||||
|
||||
// Debug aids (Q68): raw sentences to the console, a status report, and sending a command
|
||||
// ("PCAS10,0" becomes "$PCAS10,0*1C\r\n").
|
||||
void setEcho(bool echo) { echo_ = echo; }
|
||||
void printStatus(Print& out, uint32_t nowMs) const;
|
||||
bool send(const std::string& body);
|
||||
// Tracks (Q63): GPX on the SD card, a point every 5 s once moved 5 m, recorded in the
|
||||
// background whatever App is open. startTrack() returns why it can't, or "".
|
||||
std::string startTrack(uint32_t nowMs);
|
||||
void stopTrack();
|
||||
bool tracking() const { return !trackPath_.empty(); }
|
||||
int trackPoints() const { return trackPoints_; }
|
||||
uint32_t trackStartMs() const { return trackStartMs_; }
|
||||
|
||||
void restart(uint32_t nowMs) {
|
||||
close();
|
||||
open(nowMs);
|
||||
}
|
||||
|
||||
private:
|
||||
void open(uint32_t nowMs); // the UART; the receiver's state follows the setting in tick()
|
||||
void close();
|
||||
void wake(uint32_t nowMs);
|
||||
void recordPoint(uint32_t nowMs);
|
||||
void closeUnfinishedTracks(); // on the storage task
|
||||
void notify(const std::string& text);
|
||||
void standby(uint32_t nowMs);
|
||||
|
||||
const Settings& settings_;
|
||||
ClockService& clock_;
|
||||
StorageService& storage_;
|
||||
EventBus& bus_;
|
||||
gnss::NmeaParser parser_;
|
||||
bool open_ = false, active_ = false, echo_ = false;
|
||||
uint32_t bytes_ = 0, standbyMs_ = 0;
|
||||
uint32_t openedMs_ = 0, lastLineMs_ = 0, firstFixMs_ = 0, clockSetMs_ = 0;
|
||||
gnss::FixType lastFix_ = gnss::FixType::None;
|
||||
std::string trackPath_;
|
||||
int trackPoints_ = 0;
|
||||
uint32_t trackStartMs_ = 0;
|
||||
gnss::TrackRule trackRule_;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -33,16 +33,23 @@ IrcService::IrcService(KeyValueStore& store, const std::string& defaultNick, Wif
|
||||
void IrcService::start() {
|
||||
if (task_) return;
|
||||
lock_ = xSemaphoreCreateMutex();
|
||||
xTaskCreate(taskEntry, "irc", 8192, this, 1, &task_);
|
||||
xTaskCreate(taskEntry, "irc", 6144, this, 1, &task_); // peak 4.0 KB (TLS handshake, M2)
|
||||
}
|
||||
|
||||
void IrcService::connect() {
|
||||
stoppedByUser_ = false;
|
||||
if (wanted_) return;
|
||||
retryAtMs_ = millis();
|
||||
backoff_.reset();
|
||||
wanted_ = true;
|
||||
}
|
||||
|
||||
void IrcService::disconnect() {
|
||||
stoppedByUser_ = true;
|
||||
stopRequested_ = true; // the IRC task says QUIT and closes; nothing to do if it never started
|
||||
if (!task_) wanted_ = false;
|
||||
}
|
||||
|
||||
int IrcService::totalUnread() {
|
||||
Lock l(lock_);
|
||||
return session_->totalUnread();
|
||||
@@ -176,13 +183,10 @@ void IrcService::flushEffects() {
|
||||
status_ = Status::Online;
|
||||
backoff_.reset();
|
||||
}
|
||||
if (quit && open_) {
|
||||
delay(300); // let QUIT reach the server
|
||||
close("");
|
||||
wanted_ = false;
|
||||
status_ = Status::Stopped;
|
||||
Lock l(lock_);
|
||||
session_->disconnected(clock_.utcNow(), "disconnected");
|
||||
if (quit) { // /quit: QUIT is already on its way if connected; stop in any state
|
||||
stoppedByUser_ = true;
|
||||
stopRequested_ = true;
|
||||
quitSent_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +196,19 @@ void IrcService::loop() {
|
||||
bool wifiUp = wifi_.state() == WifiController::State::Connected;
|
||||
bool monitoring = wifi_.state() == WifiController::State::Monitoring;
|
||||
|
||||
if (stopRequested_) {
|
||||
stopRequested_ = false;
|
||||
if (open_) {
|
||||
if (!quitSent_) conn_->print("QUIT :roro9stack\r\n");
|
||||
vTaskDelay(pdMS_TO_TICKS(300)); // let QUIT reach the server
|
||||
close("");
|
||||
}
|
||||
quitSent_ = false;
|
||||
wanted_ = false;
|
||||
status_ = Status::Stopped;
|
||||
Lock l(lock_);
|
||||
session_->disconnected(clock_.utcNow(), "disconnected (Enter reconnects)");
|
||||
}
|
||||
if (restart_) {
|
||||
restart_ = false;
|
||||
if (open_) {
|
||||
|
||||
@@ -31,9 +31,12 @@ class IrcService : public Service {
|
||||
const char* name() const override { return "irc"; }
|
||||
void start() override;
|
||||
|
||||
// Starts the session (the IRC App does this when opened); /quit stops it.
|
||||
// Starts the session (the IRC App does this when opened, unless you stopped it).
|
||||
void connect();
|
||||
// Stops it, from any state: says QUIT if connected, and stops retrying. /quit, `irc stop`.
|
||||
void disconnect();
|
||||
bool running() const { return wanted_; }
|
||||
bool stoppedByUser() const { return stoppedByUser_; }
|
||||
Status status() const { return status_; }
|
||||
|
||||
// Read or act on the session while holding its lock: withSession([](IrcSession& s) { ... }).
|
||||
@@ -78,6 +81,9 @@ class IrcService : public Service {
|
||||
ReconnectPolicy backoff_;
|
||||
std::string partial_;
|
||||
volatile bool wanted_ = false;
|
||||
volatile bool stoppedByUser_ = false; // a manual stop: opening the App doesn't reconnect
|
||||
volatile bool stopRequested_ = false;
|
||||
bool quitSent_ = false; // /quit already queued its QUIT
|
||||
volatile bool restart_ = false;
|
||||
volatile Status status_ = Status::Stopped;
|
||||
bool open_ = false;
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace roro {
|
||||
|
||||
void StorageService::start() {
|
||||
if (task_) return;
|
||||
xTaskCreate(taskEntry, "storage", 10240, this, 1, &task_); // room for a signature check
|
||||
xTaskCreate(taskEntry, "storage", 6144, this, 1, &task_); // peak 3.9 KB (SD install with its signature check, M2)
|
||||
}
|
||||
|
||||
void StorageService::stop() {
|
||||
@@ -32,10 +32,11 @@ StorageState StorageService::state() const {
|
||||
return copy;
|
||||
}
|
||||
|
||||
void StorageService::appendLine(const std::string& path, const std::string& line) {
|
||||
void StorageService::appendLine(const std::string& path, const std::string& line, bool capture) {
|
||||
lock();
|
||||
size_t bytes = path.size() + line.size();
|
||||
bool accept = monitor_.state().logsAllowed && pendingBytes_ + bytes <= kMaxPendingBytes;
|
||||
bool allowed = capture ? monitor_.state().capturesAllowed : monitor_.state().logsAllowed;
|
||||
bool accept = allowed && pendingBytes_ + bytes <= kMaxPendingBytes;
|
||||
if (accept) {
|
||||
pending_.emplace_back(path, line);
|
||||
pendingBytes_ += bytes;
|
||||
|
||||
@@ -29,8 +29,9 @@ class StorageService : public Service {
|
||||
StorageState state() const;
|
||||
|
||||
// Queues one line for a Log file (created with its folders as needed). Dropped while Logs are
|
||||
// paused (over 90 % full, or no card) or if too much is already waiting.
|
||||
void appendLine(const std::string& path, const std::string& line);
|
||||
// paused (over 90 % full, or no card) or if too much is already waiting. A Capture (or a Track,
|
||||
// something the user started) keeps going past 90 %, until the card is full.
|
||||
void appendLine(const std::string& path, const std::string& line, bool capture = false);
|
||||
uint32_t droppedLines() const { return dropped_; }
|
||||
|
||||
// Storage Clean-up: list every file of every category, then delete a selection.
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
#include "update_service.h"
|
||||
|
||||
#include <ESPmDNS.h>
|
||||
#include <SD.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_ota_ops.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "platform/identity.h"
|
||||
#include "platform/ota_device.h"
|
||||
#include "platform/system_info.h"
|
||||
#include "probation.h"
|
||||
@@ -66,7 +64,6 @@ class FileSource : public UpdateSource {
|
||||
UpdateService::UpdateService(KeyValueStore& store, WifiService& wifi, SavedNetworks& saved, StorageService& storage,
|
||||
EventBus& bus, const Settings& settings)
|
||||
: store_(store), wifi_(wifi), saved_(saved), storage_(storage), bus_(bus), settings_(settings) {
|
||||
hostname_ = "roro9stack-" + identity::defaultShortName();
|
||||
}
|
||||
|
||||
void UpdateService::notify(const std::string& text, NotificationLevel level) {
|
||||
@@ -90,7 +87,7 @@ void UpdateService::start() {
|
||||
store_.putString("ota_pending", "");
|
||||
}
|
||||
|
||||
if (!task_) xTaskCreate(taskEntry, "update", 8192, this, 1, &task_);
|
||||
if (!task_) xTaskCreate(taskEntry, "update", 5120, this, 1, &task_); // peak 3.4 KB (ECDSA check, M2)
|
||||
}
|
||||
|
||||
void UpdateService::bootGuard(KeyValueStore& store) {
|
||||
@@ -196,11 +193,9 @@ void UpdateService::listen() {
|
||||
bool connected = wifi_.state() == WifiController::State::Connected;
|
||||
if (connected && !listening) {
|
||||
server.begin();
|
||||
if (MDNS.begin(hostname_.c_str())) MDNS.addService("roro9stack", "tcp", kPort);
|
||||
listening = true;
|
||||
} else if (!connected && listening) {
|
||||
server.end();
|
||||
MDNS.end();
|
||||
listening = false;
|
||||
}
|
||||
// The main loop restarts into an installed update when it's safe. If it never does (stuck,
|
||||
|
||||
@@ -35,7 +35,6 @@ class UpdateService : public Service {
|
||||
int percent() const { return percent_; }
|
||||
std::string incomingVersion() const;
|
||||
bool onProbation() const { return probation_; }
|
||||
const std::string& hostname() const { return hostname_; }
|
||||
|
||||
// Installs an Update File from the SD card (runs on the storage task).
|
||||
void installFromSd(const std::string& path);
|
||||
@@ -58,7 +57,6 @@ class UpdateService : public Service {
|
||||
EventBus& bus_;
|
||||
const Settings& settings_;
|
||||
TaskHandle_t task_ = nullptr;
|
||||
std::string hostname_;
|
||||
volatile Phase phase_ = Phase::Idle;
|
||||
volatile int percent_ = 0;
|
||||
std::string incoming_;
|
||||
|
||||
@@ -48,6 +48,13 @@ void statusBar(Canvas& c, const StatusInfo& info) {
|
||||
case StatusInfo::Wifi::Monitoring: right("MON", kAccent); break;
|
||||
case StatusInfo::Wifi::None: break;
|
||||
}
|
||||
if (info.tracking) right("REC", kWarning);
|
||||
switch (info.gnss) { // Q61: muted while searching, normal with a Fix, the count with a 3D Fix
|
||||
case StatusInfo::Gnss::Searching: right("G", kMuted); break;
|
||||
case StatusInfo::Gnss::TwoD: right("G", kText); break;
|
||||
case StatusInfo::Gnss::ThreeD: right("G" + std::to_string(info.gnssSatellites), kText); break;
|
||||
case StatusInfo::Gnss::None: break;
|
||||
}
|
||||
if (info.compose) right(std::string("opt ") + (info.compose == '*' ? "" : std::string(1, info.compose)), kAccent);
|
||||
c.setTextDatum(top_left);
|
||||
}
|
||||
|
||||
+5
-1
@@ -24,11 +24,15 @@ struct StatusInfo {
|
||||
enum class Wifi { None, Searching, Connected, Monitoring } wifi = Wifi::None;
|
||||
int wifiBars = 0; // 0..3 when Connected
|
||||
int unread = 0; // unread messages across Services
|
||||
enum class Gnss { None, Searching, TwoD, ThreeD } gnss = Gnss::None; // None: GNSS off (Q61)
|
||||
int gnssSatellites = 0; // used in the Fix
|
||||
bool tracking = false; // a Track is recording (Q63)
|
||||
|
||||
bool operator==(const StatusInfo& o) const {
|
||||
return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock &&
|
||||
sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose && wifi == o.wifi &&
|
||||
wifiBars == o.wifiBars && unread == o.unread;
|
||||
wifiBars == o.wifiBars && unread == o.unread && gnss == o.gnss && gnssSatellites == o.gnssSatellites &&
|
||||
tracking == o.tracking;
|
||||
}
|
||||
bool operator!=(const StatusInfo& o) const { return !(*this == o); }
|
||||
};
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include "geo_format.h"
|
||||
|
||||
using namespace roro::gnss;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
void test_decimal_degrees_with_hemisphere() {
|
||||
TEST_ASSERT_EQUAL_STRING("50.86920° N", formatDecimal(50.8692034, true).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("33.50000° S", formatDecimal(-33.5, true).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("4.25350° E", formatDecimal(4.2535, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("151.21000° W", formatDecimal(-151.21, false).c_str());
|
||||
}
|
||||
|
||||
void test_degrees_minutes_seconds() {
|
||||
TEST_ASSERT_EQUAL_STRING("50° 52' 09.1\" N", formatDms(50.8692034, true).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("4° 15' 12.6\" E", formatDms(4.2535, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("33° 30' 00.0\" S", formatDms(-33.5, true).c_str());
|
||||
}
|
||||
|
||||
void test_seconds_that_round_up_carry_over() {
|
||||
TEST_ASSERT_EQUAL_STRING("11° 00' 00.0\" N", formatDms(10.9999999, true).c_str());
|
||||
}
|
||||
|
||||
void test_maidenhead_matches_known_locators() {
|
||||
TEST_ASSERT_EQUAL_STRING("FN31pr", maidenhead(41.714775, -72.727260).c_str()); // W1AW
|
||||
TEST_ASSERT_EQUAL_STRING("JN58td", maidenhead(48.14666, 11.60833).c_str()); // Munich
|
||||
TEST_ASSERT_EQUAL_STRING("JJ00aa", maidenhead(0, 0).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("AA00aa", maidenhead(-90, -180).c_str());
|
||||
}
|
||||
|
||||
void test_maidenhead_stays_in_range_at_the_edges() {
|
||||
TEST_ASSERT_EQUAL_STRING("RR99xx", maidenhead(90, 180).c_str());
|
||||
}
|
||||
|
||||
void test_sky_projection() {
|
||||
SkyPoint zenith = skyPosition(123, 90, 50, 60, 40);
|
||||
TEST_ASSERT_EQUAL(50, zenith.x);
|
||||
TEST_ASSERT_EQUAL(60, zenith.y);
|
||||
SkyPoint north = skyPosition(0, 0, 50, 60, 40);
|
||||
TEST_ASSERT_EQUAL(50, north.x);
|
||||
TEST_ASSERT_EQUAL(20, north.y); // up
|
||||
SkyPoint east = skyPosition(90, 0, 50, 60, 40);
|
||||
TEST_ASSERT_EQUAL(90, east.x);
|
||||
TEST_ASSERT_EQUAL(60, east.y);
|
||||
SkyPoint south45 = skyPosition(180, 45, 50, 60, 40);
|
||||
TEST_ASSERT_EQUAL(50, south45.x);
|
||||
TEST_ASSERT_EQUAL(80, south45.y);
|
||||
SkyPoint below = skyPosition(270, -5, 50, 60, 40); // just below the horizon: on the edge
|
||||
TEST_ASSERT_EQUAL(10, below.x);
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_decimal_degrees_with_hemisphere);
|
||||
RUN_TEST(test_degrees_minutes_seconds);
|
||||
RUN_TEST(test_seconds_that_round_up_carry_over);
|
||||
RUN_TEST(test_maidenhead_matches_known_locators);
|
||||
RUN_TEST(test_maidenhead_stays_in_range_at_the_edges);
|
||||
RUN_TEST(test_sky_projection);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "nmea_parser.h"
|
||||
|
||||
using namespace roro;
|
||||
using namespace roro::gnss;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
// "$<body>*<checksum>": the checksum is computed here, so scenarios stay readable.
|
||||
static std::string nmea(const std::string& body) {
|
||||
uint8_t sum = 0;
|
||||
for (char c : body) sum ^= static_cast<uint8_t>(c);
|
||||
char tail[4];
|
||||
std::snprintf(tail, sizeof tail, "*%02X", sum);
|
||||
return "$" + body + tail;
|
||||
}
|
||||
|
||||
static void feedLine(NmeaParser& p, const std::string& line) {
|
||||
std::string withEnd = line + "\r\n";
|
||||
p.feed(withEnd.data(), withEnd.size());
|
||||
}
|
||||
|
||||
void test_checksum_is_verified() {
|
||||
NmeaParser p;
|
||||
TEST_ASSERT_TRUE(p.sentence("$GNVTG,,,,,,,,,N*2E")); // captured from the device
|
||||
TEST_ASSERT_FALSE(p.sentence("$GNVTG,,,,,,,,,N*2F"));
|
||||
TEST_ASSERT_FALSE(p.sentence("$GNVTG,,,,,,,,,N"));
|
||||
TEST_ASSERT_FALSE(p.sentence("GNVTG,,,,,,,,,N*2E"));
|
||||
TEST_ASSERT_EQUAL_UINT32(3, p.badSentences());
|
||||
TEST_ASSERT_EQUAL_UINT32(1, p.goodSentences());
|
||||
}
|
||||
|
||||
void test_no_fix_as_captured_indoors() {
|
||||
NmeaParser p;
|
||||
feedLine(p, "$GNRMC,200231.00,V,,,,,,,041026,,,N,V*1A");
|
||||
feedLine(p, "$GNGGA,200231.00,,,,,0,00,25.5,,,,,,*48");
|
||||
feedLine(p, "$GNGSA,A,1,,,,,,,,,,,,,25.5,25.5,25.5,1*01");
|
||||
feedLine(p, "$GPGSV,1,1,01,25,41,108,17,1*58");
|
||||
feedLine(p, "$GLGSV,1,1,00,1*78");
|
||||
const GnssState& s = p.state();
|
||||
TEST_ASSERT_TRUE(s.fix == FixType::None);
|
||||
TEST_ASSERT_FALSE(s.positionValid);
|
||||
TEST_ASSERT_FALSE(s.timeValid); // a time without a Fix isn't trusted
|
||||
TEST_ASSERT_EQUAL(1, static_cast<int>(s.satellites.size()));
|
||||
TEST_ASSERT_TRUE(s.satellites[0].system == Constellation::Gps);
|
||||
TEST_ASSERT_EQUAL(25, s.satellites[0].prn);
|
||||
TEST_ASSERT_EQUAL(41, s.satellites[0].elevation);
|
||||
TEST_ASSERT_EQUAL(108, s.satellites[0].azimuth);
|
||||
TEST_ASSERT_EQUAL(17, s.satellites[0].snr);
|
||||
TEST_ASSERT_FALSE(s.satellites[0].used);
|
||||
}
|
||||
|
||||
void test_rmc_with_a_fix_gives_position_time_speed_course() {
|
||||
NmeaParser p;
|
||||
feedLine(p, nmea("GNRMC,123519.00,A,5050.8136,N,00421.1532,E,10.0,84.4,041026,,,A,V"));
|
||||
const GnssState& s = p.state();
|
||||
TEST_ASSERT_TRUE(s.positionValid);
|
||||
TEST_ASSERT_FLOAT_WITHIN(1e-6, 50.0 + 50.8136 / 60.0, s.latitude);
|
||||
TEST_ASSERT_FLOAT_WITHIN(1e-6, 4.0 + 21.1532 / 60.0, s.longitude);
|
||||
TEST_ASSERT_TRUE(s.timeValid);
|
||||
TEST_ASSERT_EQUAL(2026, s.year);
|
||||
TEST_ASSERT_EQUAL(10, s.month);
|
||||
TEST_ASSERT_EQUAL(4, s.day);
|
||||
TEST_ASSERT_EQUAL(12, s.hour);
|
||||
TEST_ASSERT_EQUAL(35, s.minute);
|
||||
TEST_ASSERT_EQUAL(19, s.second);
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.01, 18.52, s.speedKmh); // 10 knots
|
||||
TEST_ASSERT_TRUE(s.courseValid);
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.01, 84.4, s.courseDeg);
|
||||
}
|
||||
|
||||
void test_southern_and_western_hemispheres_are_negative() {
|
||||
NmeaParser p;
|
||||
feedLine(p, nmea("GNRMC,000000.00,A,3352.0000,S,15112.6000,W,0.0,,010126,,,A,V"));
|
||||
TEST_ASSERT_FLOAT_WITHIN(1e-6, -(33.0 + 52.0 / 60.0), p.state().latitude);
|
||||
TEST_ASSERT_FLOAT_WITHIN(1e-6, -(151.0 + 12.6 / 60.0), p.state().longitude);
|
||||
TEST_ASSERT_FALSE(p.state().courseValid); // empty course field
|
||||
}
|
||||
|
||||
void test_utc_seconds_from_the_fix_time() {
|
||||
NmeaParser p;
|
||||
feedLine(p, nmea("GNRMC,123519.00,A,5050.8136,N,00421.1532,E,0.0,,041026,,,A,V"));
|
||||
TEST_ASSERT_EQUAL_INT64(1791117319LL, p.state().utcSeconds()); // 2026-10-04 12:35:19 UTC
|
||||
}
|
||||
|
||||
void test_gga_gives_fix_quality_altitude_satellites_and_hdop() {
|
||||
NmeaParser p;
|
||||
feedLine(p, nmea("GNGGA,123519.00,5050.8136,N,00421.1532,E,1,09,0.9,61.4,M,46.9,M,,"));
|
||||
feedLine(p, nmea("GNGSA,A,3,05,13,15,18,23,,,,,,,,1.6,0.9,1.3,1"));
|
||||
const GnssState& s = p.state();
|
||||
TEST_ASSERT_TRUE(s.positionValid);
|
||||
TEST_ASSERT_EQUAL(9, s.satellitesUsed);
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.01, 0.9, s.hdop);
|
||||
TEST_ASSERT_TRUE(s.altitudeValid);
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.01, 61.4, s.altitudeM);
|
||||
TEST_ASSERT_TRUE(s.fix == FixType::ThreeD);
|
||||
}
|
||||
|
||||
void test_fix_type_comes_from_gsa_mode() {
|
||||
NmeaParser p;
|
||||
feedLine(p, nmea("GNGSA,A,2,05,13,15,,,,,,,,,,2.1,1.9,1.0,1"));
|
||||
TEST_ASSERT_TRUE(p.state().fix == FixType::TwoD);
|
||||
feedLine(p, nmea("GNGSA,A,1,,,,,,,,,,,,,25.5,25.5,25.5,1"));
|
||||
TEST_ASSERT_TRUE(p.state().fix == FixType::None);
|
||||
}
|
||||
|
||||
void test_losing_the_fix_drops_the_position() {
|
||||
NmeaParser p;
|
||||
feedLine(p, nmea("GNRMC,123519.00,A,5050.8136,N,00421.1532,E,0.0,,041026,,,A,V"));
|
||||
TEST_ASSERT_TRUE(p.state().positionValid);
|
||||
feedLine(p, nmea("GNRMC,123520.00,V,,,,,,,041026,,,N,V"));
|
||||
TEST_ASSERT_FALSE(p.state().positionValid);
|
||||
TEST_ASSERT_FALSE(p.state().timeValid);
|
||||
}
|
||||
|
||||
void test_gsv_sequence_spans_several_messages() {
|
||||
NmeaParser p;
|
||||
feedLine(p, nmea("GPGSV,2,1,05,05,60,120,40,13,45,200,35,15,30,300,30,18,20,050,25,1"));
|
||||
TEST_ASSERT_EQUAL(0, static_cast<int>(p.state().satellites.size())); // committed at the last message
|
||||
feedLine(p, nmea("GPGSV,2,2,05,23,10,010,,1"));
|
||||
const auto& sats = p.state().satellites;
|
||||
TEST_ASSERT_EQUAL(5, static_cast<int>(sats.size()));
|
||||
TEST_ASSERT_EQUAL(-1, sats[4].snr); // not tracked: empty SNR
|
||||
}
|
||||
|
||||
void test_each_constellation_keeps_its_own_satellites() {
|
||||
NmeaParser p;
|
||||
feedLine(p, nmea("GPGSV,1,1,01,05,60,120,40,1"));
|
||||
feedLine(p, nmea("GLGSV,1,1,01,70,30,90,33,1"));
|
||||
feedLine(p, nmea("GAGSV,1,1,01,11,20,45,28,7"));
|
||||
feedLine(p, nmea("GBGSV,1,1,01,24,50,270,36,1"));
|
||||
feedLine(p, nmea("GQGSV,1,1,01,02,70,180,41,1"));
|
||||
const auto& sats = p.state().satellites;
|
||||
TEST_ASSERT_EQUAL(5, static_cast<int>(sats.size()));
|
||||
int seen[8] = {};
|
||||
for (auto& s : sats) seen[static_cast<int>(s.system)]++;
|
||||
TEST_ASSERT_EQUAL(1, seen[static_cast<int>(Constellation::Gps)]);
|
||||
TEST_ASSERT_EQUAL(1, seen[static_cast<int>(Constellation::Glonass)]);
|
||||
TEST_ASSERT_EQUAL(1, seen[static_cast<int>(Constellation::Galileo)]);
|
||||
TEST_ASSERT_EQUAL(1, seen[static_cast<int>(Constellation::BeiDou)]);
|
||||
TEST_ASSERT_EQUAL(1, seen[static_cast<int>(Constellation::Qzss)]);
|
||||
}
|
||||
|
||||
void test_a_satellite_on_two_bands_counts_once_with_the_stronger_signal() {
|
||||
NmeaParser p;
|
||||
feedLine(p, nmea("GPGSV,1,1,01,05,60,120,30,1")); // L1
|
||||
feedLine(p, nmea("GPGSV,1,1,01,05,60,120,42,8")); // L5
|
||||
const auto& sats = p.state().satellites;
|
||||
TEST_ASSERT_EQUAL(1, static_cast<int>(sats.size()));
|
||||
TEST_ASSERT_EQUAL(42, sats[0].snr);
|
||||
}
|
||||
|
||||
void test_an_empty_gsv_clears_that_constellation() {
|
||||
NmeaParser p;
|
||||
feedLine(p, nmea("GLGSV,1,1,01,70,30,90,33,1"));
|
||||
TEST_ASSERT_EQUAL(1, static_cast<int>(p.state().satellites.size()));
|
||||
feedLine(p, "$GLGSV,1,1,00,1*78"); // captured
|
||||
TEST_ASSERT_EQUAL(0, static_cast<int>(p.state().satellites.size()));
|
||||
}
|
||||
|
||||
void test_gsa_marks_the_satellites_used_per_system() {
|
||||
NmeaParser p;
|
||||
feedLine(p, nmea("GPGSV,1,1,02,05,60,120,40,13,45,200,35,1"));
|
||||
feedLine(p, nmea("GBGSV,1,1,01,05,50,270,36,1")); // BeiDou 5, not GPS 5
|
||||
feedLine(p, nmea("GNGSA,A,3,05,,,,,,,,,,,,1.6,0.9,1.3,1")); // GPS: 5 used
|
||||
feedLine(p, nmea("GNGSA,A,3,,,,,,,,,,,,,1.6,0.9,1.3,4")); // BeiDou: none
|
||||
for (auto& s : p.state().satellites) {
|
||||
bool expected = s.system == Constellation::Gps && s.prn == 5;
|
||||
TEST_ASSERT_EQUAL(expected, s.used);
|
||||
}
|
||||
}
|
||||
|
||||
void test_split_feeds_and_noise_are_handled() {
|
||||
NmeaParser p;
|
||||
std::string a = "garbage\r\n" + nmea("GNGGA,123519.00,5050.8136,N,00421.1532,E,1,09,0.9,61.4,M,46.9,M,,");
|
||||
std::string b = "\r\n";
|
||||
p.feed(a.data(), 20);
|
||||
p.feed(a.data() + 20, a.size() - 20);
|
||||
TEST_ASSERT_FALSE(p.state().positionValid); // no line end yet
|
||||
p.feed(b.data(), b.size());
|
||||
TEST_ASSERT_TRUE(p.state().positionValid);
|
||||
}
|
||||
|
||||
void test_an_overlong_line_is_dropped() {
|
||||
NmeaParser p;
|
||||
std::string junk(300, 'x');
|
||||
junk += "\r\n";
|
||||
p.feed(junk.data(), junk.size());
|
||||
feedLine(p, nmea("GNGGA,123519.00,5050.8136,N,00421.1532,E,1,09,0.9,61.4,M,46.9,M,,"));
|
||||
TEST_ASSERT_TRUE(p.state().positionValid);
|
||||
}
|
||||
|
||||
void test_every_complete_line_reaches_the_echo() {
|
||||
NmeaParser p;
|
||||
std::vector<std::string> seen;
|
||||
p.onLine = [&](const std::string& l) { seen.push_back(l); };
|
||||
std::string data = "$GNVTG,,,,,,,,,N*2E\r\nbroken\r\n$GN";
|
||||
p.feed(data.data(), data.size());
|
||||
TEST_ASSERT_EQUAL(2, static_cast<int>(seen.size()));
|
||||
TEST_ASSERT_EQUAL_STRING("$GNVTG,,,,,,,,,N*2E", seen[0].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("broken", seen[1].c_str());
|
||||
}
|
||||
|
||||
void test_constellation_names() {
|
||||
TEST_ASSERT_EQUAL_STRING("GPS", constellationName(Constellation::Gps));
|
||||
TEST_ASSERT_EQUAL_STRING("Galileo", constellationName(Constellation::Galileo));
|
||||
TEST_ASSERT_EQUAL_STRING("BeiDou", constellationName(Constellation::BeiDou));
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_checksum_is_verified);
|
||||
RUN_TEST(test_no_fix_as_captured_indoors);
|
||||
RUN_TEST(test_rmc_with_a_fix_gives_position_time_speed_course);
|
||||
RUN_TEST(test_southern_and_western_hemispheres_are_negative);
|
||||
RUN_TEST(test_utc_seconds_from_the_fix_time);
|
||||
RUN_TEST(test_gga_gives_fix_quality_altitude_satellites_and_hdop);
|
||||
RUN_TEST(test_fix_type_comes_from_gsa_mode);
|
||||
RUN_TEST(test_losing_the_fix_drops_the_position);
|
||||
RUN_TEST(test_gsv_sequence_spans_several_messages);
|
||||
RUN_TEST(test_each_constellation_keeps_its_own_satellites);
|
||||
RUN_TEST(test_a_satellite_on_two_bands_counts_once_with_the_stronger_signal);
|
||||
RUN_TEST(test_an_empty_gsv_clears_that_constellation);
|
||||
RUN_TEST(test_gsa_marks_the_satellites_used_per_system);
|
||||
RUN_TEST(test_split_feeds_and_noise_are_handled);
|
||||
RUN_TEST(test_an_overlong_line_is_dropped);
|
||||
RUN_TEST(test_every_complete_line_reaches_the_echo);
|
||||
RUN_TEST(test_constellation_names);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -26,6 +26,8 @@ void test_defaults_when_store_is_empty() {
|
||||
TEST_ASSERT_TRUE(s.getBool(Setting::Sound));
|
||||
TEST_ASSERT_TRUE(s.getBool(Setting::ProbeMacRaw));
|
||||
TEST_ASSERT_TRUE(s.getBool(Setting::WifiEnabled));
|
||||
TEST_ASSERT_TRUE(s.getBool(Setting::GnssEnabled)); // Q58: on by default
|
||||
TEST_ASSERT_FALSE(s.getBool(Setting::CoordinatesDms)); // Q64: decimal degrees
|
||||
TEST_ASSERT_EQUAL_STRING("EU868", s.getString(Setting::Region).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("CET-1CEST,M3.5.0,M10.5.0/3", s.getString(Setting::Timezone).c_str());
|
||||
}
|
||||
|
||||
@@ -107,6 +107,19 @@ void test_names_are_text_rows_with_their_byte_limits() {
|
||||
TEST_ASSERT_EQUAL_STRING("RORO", f.menu.value(shortName).c_str());
|
||||
}
|
||||
|
||||
void test_gnss_and_coordinate_rows_toggle() {
|
||||
Fixture f;
|
||||
int gnss = f.row(SettingsMenu::Row::Gnss), coords = f.row(SettingsMenu::Row::Coordinates);
|
||||
TEST_ASSERT_EQUAL_STRING("On", f.menu.value(gnss).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("Decimal", f.menu.value(coords).c_str());
|
||||
f.menu.toggle(gnss);
|
||||
f.menu.toggle(coords);
|
||||
TEST_ASSERT_FALSE(f.settings.getBool(Setting::GnssEnabled));
|
||||
TEST_ASSERT_TRUE(f.settings.getBool(Setting::CoordinatesDms));
|
||||
TEST_ASSERT_EQUAL_STRING("Off", f.menu.value(gnss).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("Deg min sec", f.menu.value(coords).c_str());
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_every_row_has_a_label);
|
||||
@@ -118,5 +131,6 @@ int main() {
|
||||
RUN_TEST(test_brightness_steps_with_left_right_within_bounds);
|
||||
RUN_TEST(test_wifi_is_a_page);
|
||||
RUN_TEST(test_names_are_text_rows_with_their_byte_limits);
|
||||
RUN_TEST(test_gnss_and_coordinate_rows_toggle);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "track.h"
|
||||
|
||||
using namespace roro::gnss;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
void test_distance_is_haversine() {
|
||||
// One minute of latitude is one nautical mile.
|
||||
TEST_ASSERT_FLOAT_WITHIN(2.0, 1852.0, distanceMeters(50.0, 4.0, 50.0 + 1.0 / 60, 4.0));
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.01, 0.0, distanceMeters(50.0, 4.0, 50.0, 4.0));
|
||||
// A degree of longitude shrinks with latitude: cos(60°) = 0.5.
|
||||
TEST_ASSERT_FLOAT_WITHIN(60.0, 55597.0, distanceMeters(60.0, 0.0, 60.0, 1.0));
|
||||
}
|
||||
|
||||
void test_first_point_is_always_recorded() {
|
||||
TrackRule rule;
|
||||
TEST_ASSERT_TRUE(rule.due(1000, 50.0, 4.0));
|
||||
}
|
||||
|
||||
void test_a_point_needs_5_seconds_and_5_metres() {
|
||||
TrackRule rule;
|
||||
rule.due(0, 50.0, 4.0);
|
||||
double tenMetres = 10.0 / 111195.0; // degrees of latitude
|
||||
TEST_ASSERT_FALSE(rule.due(4999, 50.0 + tenMetres, 4.0)); // too soon
|
||||
TEST_ASSERT_FALSE(rule.due(5000, 50.0 + 1e-6, 4.0)); // too close (standing still)
|
||||
TEST_ASSERT_TRUE(rule.due(5000, 50.0 + tenMetres, 4.0));
|
||||
TEST_ASSERT_FALSE(rule.due(6000, 50.0 + 3 * tenMetres, 4.0)); // 5 s from the last point
|
||||
TEST_ASSERT_TRUE(rule.due(10000, 50.0 + 3 * tenMetres, 4.0));
|
||||
}
|
||||
|
||||
void test_reset_starts_over() {
|
||||
TrackRule rule;
|
||||
rule.due(0, 50.0, 4.0);
|
||||
rule.reset();
|
||||
TEST_ASSERT_TRUE(rule.due(100, 50.0, 4.0));
|
||||
}
|
||||
|
||||
void test_file_name_from_utc() {
|
||||
TEST_ASSERT_EQUAL_STRING("/gnss/tracks/20261004-203447.gpx", trackPath(1791146087LL).c_str()); // 20:34:47 UTC
|
||||
}
|
||||
|
||||
void test_gpx_header_point_and_footer() {
|
||||
std::string h = gpx::header("20261004-203447");
|
||||
TEST_ASSERT_TRUE(h.find("<?xml version=\"1.0\" encoding=\"UTF-8\"?>") == 0);
|
||||
TEST_ASSERT_TRUE(h.find("<gpx version=\"1.1\" creator=\"roro9stack\"") != std::string::npos);
|
||||
TEST_ASSERT_TRUE(h.find("<name>20261004-203447</name>") != std::string::npos);
|
||||
TEST_ASSERT_TRUE(h.find("<trkseg>") != std::string::npos);
|
||||
TEST_ASSERT_EQUAL_STRING(
|
||||
"<trkpt lat=\"50.8692030\" lon=\"-4.2535070\"><ele>42.5</ele><time>2026-10-04T20:34:47Z</time></trkpt>",
|
||||
gpx::point(50.869203, -4.253507, true, 42.5f, 1791146087LL).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("<trkpt lat=\"50.8692030\" lon=\"4.2535070\"><time>2026-10-04T20:34:47Z</time></trkpt>",
|
||||
gpx::point(50.869203, 4.253507, false, 0, 1791146087LL).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("</trkseg></trk></gpx>", gpx::footer().c_str());
|
||||
}
|
||||
|
||||
void test_an_unfinished_file_is_recognised() {
|
||||
TEST_ASSERT_TRUE(gpx::finished("...<time>x</time></trkpt>\n</trkseg></trk></gpx>\n"));
|
||||
TEST_ASSERT_TRUE(gpx::finished("</trkseg></trk></gpx>"));
|
||||
TEST_ASSERT_FALSE(gpx::finished("...<time>x</time></trkpt>\n"));
|
||||
TEST_ASSERT_FALSE(gpx::finished(""));
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_distance_is_haversine);
|
||||
RUN_TEST(test_first_point_is_always_recorded);
|
||||
RUN_TEST(test_a_point_needs_5_seconds_and_5_metres);
|
||||
RUN_TEST(test_reset_starts_over);
|
||||
RUN_TEST(test_file_name_from_utc);
|
||||
RUN_TEST(test_gpx_header_point_and_footer);
|
||||
RUN_TEST(test_an_unfinished_file_is_recognised);
|
||||
return UNITY_END();
|
||||
}
|
||||
Reference in New Issue
Block a user