Compare commits

...
20 Commits
Author SHA1 Message Date
twisla 8aa2848c5f Merge pull request 'Updates: a download that breaks is carried on' (#100) from resume-download into main
CI / build (push) Successful in 1m4s
Site / build (push) Successful in 12s
2026-10-11 16:30:05 +00:00
twislaandClaude Opus 5.5 472f13260f Updates: a download that breaks is carried on
CI / build (pull_request) Successful in 2m6s
Site / build (pull_request) Successful in 11s
When the connection breaks during a release download (issue #54), the
device waits up to a minute for Wi-Fi, asks for the rest of the file
with a Range request and carries on: the slot and the hash so far are
kept. It gives up after three tries in a row that bring nothing.
Nothing is kept across a restart.

Carrying on asks for 64 KB free, not 80: between two connections of an
install 78 KB is free, and the first attempt on the device was refused
for that. update damage cut now breaks every connection after n bytes.

Tried on the device: v0.23.0 installed over four connections, and a
download that brought nothing given up with nothing switched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 18:26:39 +02:00
twislaandClaude Opus 5.5 468dc0dc0d Updates: when an answer is the rest of the same file, and how long to try
For carrying on a download that broke (issue #54): the head's ETag and
Content-Range are read, a 206 is checked to be the rest of the same
file, and a policy says how many tries a download is worth. Host-tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 18:26:39 +02:00
twisla 7e14e270df Merge pull request 'Updates: a newer release installs by itself, after a question' (#99) from auto-update into main
CI / build (push) Successful in 1m5s
Site / build (push) Successful in 16s
2026-10-11 15:54:03 +00:00
twislaandClaude Opus 5.5 c3fd9909a1 Updates: a newer release installs by itself, after a question
CI / build (pull_request) Successful in 2m8s
Site / build (pull_request) Successful in 11s
With Settings > System > Install updates on By itself (issue #52), a
release found by the daily check is offered once no key was pressed for
2 minutes and nothing would be cut by a restart. The question goes over
whatever App is open, with Cancel and Accept, and installs after 30 s
without an answer. Each update that held, and each one undone, is a
dated line in /system/updates.log on the card.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 17:49:09 +02:00
twisla 5af695f43a Merge pull request 'GNSS App: a Compass, with Places and a level' (#98) from compass into main
CI / build (push) Successful in 1m6s
Site / build (push) Successful in 14s
2026-10-11 15:17:16 +00:00
twislaandClaude Opus 5.5 544bd726df GNSS App: a Compass, with Places to be led back to and a level
CI / build (pull_request) Successful in 2m20s
Site / build (pull_request) Successful in 10s
Tab goes on from the Sky to a third view (issue #16): a rose that turns
with the device, the heading, where north came from and how long ago,
the sun on the rose, and a level. m marks where you stand as a Place,
t picks one as the target: an arrowhead on the rose, its distance and
bearing. The sensor's directions were checked by hand on the device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 17:01:57 +02:00
twislaandClaude Opus 5.5 65a9643910 Console: imu prints what the motion sensor reads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 17:01:57 +02:00
twislaandClaude Opus 5.5 a87c3016cc GNSS: Places, kept as GPX waypoints
A Place is a named position the user marked (issue #16). The list is
read from and written to a GPX file, so a map program opens it and
waypoints made by one are read here. Host-tested.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 17:01:57 +02:00
twisla fd534af971 Merge pull request 'Launcher grid with icons, themes, Settings in groups, the website's colours' (#97) from launcher-grid into main
Site / build (push) Successful in 12s
CI / build (push) Successful in 3m11s
2026-10-09 22:25:49 +00:00
twislaandClaude Opus 5.5 0c52613b72 Settings: its rows in five groups
CI / build (pull_request) Successful in 2m22s
Site / build (pull_request) Successful in 10s
Settings had grown to 21 rows in one list. It opens on five groups (This
device, Display, GNSS and radio, Network, System); Enter opens one, Back
returns to the groups, and Back from the groups leaves Settings.

SettingsMenu holds the groups and which one is open (host-tested: every
setting is in exactly one group). The guide, the how-tos, the README,
the scripts' messages and the firmware's own name the new paths, such as
"Settings > System > Firmware".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-10 00:11:52 +02:00
twislaandClaude Opus 5.5 37335b4829 UI: seven themes, each dark and light, chosen in the Settings
Settings > Theme and Settings > Light or dark pick the colours of every
screen (issue #10): roro9stack (the website's, the default), Catppuccin,
Dracula, Nord, ANSI terminal, Gruvbox and Solarized.

- A palette is 14 roles (lib/ui/src/palette.h). theme::kText and the
  others are now references into the one in use, so the Apps are
  unchanged; the main loop applies it when a Setting changes.
- Every colour is one the RGB332 frame buffer holds exactly: the themes'
  own were rounded, and those that fell together or lost contrast were
  picked by hand. test_palette checks exactness and 18 contrast ratios
  for each palette.
- The Sky view's constellation colours come from the theme.
- `theme [0-6] [light|dark]` on the consoles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-10 00:11:52 +02:00
twislaandClaude Opus 5.5 10627e6e18 Launcher: the selected icon in slate, the name in white
The selected icon was dark blue on its cyan tile, which read as black:
it is the Status Bar's slate now. The App's name under the grid is
white instead of cyan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-09 23:23:20 +02:00
twislaandClaude Opus 5.5 e2326e113b Launcher: a grid of icons, with badges; the list stays as a Setting
The Launcher shows the Apps as a 4 x 3 grid of 32 x 32 icons, the
selected one's name under it (issue #9). The icons are the website's
own, doubled, and two drawn the same way for SSH and the Shell; they
are PNGs in assets/icons, turned into arrays by scripts/make_icons.py,
which CI checks. The selected tile is the website's: dark blue on cyan,
with notched corners.

- GridModel (lib/ui, host-tested) holds the selection and the scrolling.
- App::badge(): a dot on the tile for unread IRC messages, a Track
  recording, a Capture running, an SSH session open.
- Settings > Launcher: Grid or List.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-09 23:14:37 +02:00
twislaandClaude Opus 5.5 aadfd8cb37 UI: the website's colours, on every screen
The theme takes the website's palette, each a colour the RGB332 frame
buffer holds exactly: cyan for the accent, a selected row and a chosen
button in cyan with dark blue text, a slate Status Bar (the grey it
asked for was shown as a dull olive), the website's orange for
warnings and its pink for messages. "Good" (a Fix, the quietest
channel) keeps a green of its own, which the website doesn't have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-09 23:14:37 +02:00
twislaandClaude Opus 5.5 b1c80c912f SSH App: its messages were cleared the moment they were set
The bottom-line message (a malformed address, not enough memory, a
session still closing) is timed from millis() when a key sets it, and
update() compared that with the pass's own time, read before the keys
were handled. The unsigned difference wrapped, so the message was
cleared before it was ever drawn: Enter on a new connection that was
refused looked like it did nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-09 23:14:20 +02:00
twisla 015a9c59bc Merge pull request 'LoRa Scanner: a MeshCore preset (the default), its packets and public channel read' (#96) from meshcore-scanner into main
CI / build (push) Successful in 1m0s
Site / build (push) Successful in 13s
2026-10-09 20:23:28 +00:00
106 changed files with 3549 additions and 214 deletions
+3
View File
@@ -83,6 +83,9 @@ jobs:
git checkout -q --detach "${{ github.sha }}" git checkout -q --detach "${{ github.sha }}"
git describe --tags --always git describe --tags --always
- name: The icons are what their pictures give
run: python3 scripts/make_icons.py --check
- name: Host tests, and their coverage of lib/ - name: Host tests, and their coverage of lib/
if: github.event_name != 'workflow_dispatch' if: github.event_name != 'workflow_dispatch'
run: | run: |
+11
View File
@@ -64,6 +64,17 @@ _Avoid_: lock, signal
A route recorded from GNSS positions to a GPX file on the SD card, started and stopped by the user. 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) _Avoid_: trace, log (a Log is recorded on its own)
**Compass**:
The third view of the GNSS App: a rose turned by the Heading, and the way to the target Place.
**Heading**:
Where the top of the device points, in degrees from true north. It is given by the course over ground while walking or by aiming at the sun, and held by the gyroscope in between.
_Avoid_: course (the course is the direction of travel, from the receiver), bearing (a bearing is the direction to somewhere else)
**Place**:
A named position the user marked, kept as a GPX waypoint on the SD card, that the Compass can lead back to.
_Avoid_: waypoint, POI
**Wi-Fi Service**: **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. 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 _Avoid_: network manager
+17 -12
View File
@@ -7,7 +7,7 @@ A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262*
What it does today: What it does today:
- **LoRa Scanner:** every packet it hears, with the Meshtastic or MeshCore header read; a spectrum Sweep; captures for Wireshark. It listens and never transmits: the mesh messenger is the next milestone. - **LoRa Scanner:** every packet it hears, with the Meshtastic or MeshCore header read; a spectrum Sweep; captures for Wireshark. It listens and never transmits: the mesh messenger is the next milestone.
- **GNSS:** position, sky view, tracks as GPX. - **GNSS:** position, sky view, a compass that leads back to a marked place, tracks as GPX.
- **Gemini:** a browser, with bookmarks and pages saved for offline. - **Gemini:** a browser, with bookmarks and pages saved for offline.
- **IRC:** over TLS, with logs on the card. - **IRC:** over TLS, with logs on the card.
- **Wi-Fi Tools:** the networks around, sorted, filtered, logged. - **Wi-Fi Tools:** the networks around, sorted, filtered, logged.
@@ -43,6 +43,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`. This runs the host-side unit tests (`test/`, `native` environment), then builds the firmware. The output is `.pio/build/cardputer-adv/firmware.factory.bin`.
`scripts/make_icons.py` turns the Launcher's icons (`assets/icons/<App id>.png`, 32 × 32, one colour) into the arrays in `src/ui/icons.cpp`; run it after changing a picture. CI fails when the two don't match.
`scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure. `scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure.
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 6 minutes; later builds take about 15 seconds when little has changed. The platform knows the framework is already rebuilt by `sdkconfig.defaults` in the project folder, which it writes and git ignores: delete it and the next build rebuilds the framework. CI keeps that file, a build cache and ccache in its volume (docs/milestones/R1.md). 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 6 minutes; later builds take about 15 seconds when little has changed. The platform knows the framework is already rebuilt by `sdkconfig.defaults` in the project folder, which it writes and git ignores: delete it and the next build rebuilds the framework. CI keeps that file, a build cache and ccache in its volume (docs/milestones/R1.md).
@@ -92,19 +94,20 @@ scripts/ota_keygen.sh # once: creates the signing key (see ADR
scripts/flash.sh --ota 10.39.39.12 # build, sign and push; or set RORO_OTA_HOST scripts/flash.sh --ota 10.39.39.12 # build, sign and push; or set RORO_OTA_HOST
``` ```
The device shows the push address in **Settings → Firmware**. It installs a correctly signed update right away, restarts (waiting up to 60 s if you're typing), and runs the new firmware on **Probation**. If the new firmware crashes, or can't reconnect Wi-Fi within 3 minutes, it rolls back to the previous one and says so. The device shows the push address in **Settings → System → Firmware**. It installs a correctly signed update right away, restarts (waiting up to 60 s if you're typing), and runs the new firmware on **Probation**. If the new firmware crashes, or can't reconnect Wi-Fi within 3 minutes, it rolls back to the previous one and says so.
To install from the SD card instead, copy the `.ota` file from `.pio/build/cardputer-adv/` into `/updates` on the card, then use **Settings → Firmware**. With the Cardputer on USB, the card can stay in: `scripts/sd_put.sh <file.ota>` sends it over the serial console into `/updates` (about 30 s for 1.6 MB, checked with SHA-256 before it's renamed into place; `SD_PUT_DEBUG=1` shows the console while it runs). To install from the SD card instead, copy the `.ota` file from `.pio/build/cardputer-adv/` into `/updates` on the card, then use **Settings → System → Firmware**. With the Cardputer on USB, the card can stay in: `scripts/sd_put.sh <file.ota>` sends it over the serial console into `/updates` (about 30 s for 1.6 MB, checked with SHA-256 before it's renamed into place; `SD_PUT_DEBUG=1` shows the console while it runs).
**The private key** lives in `~/.config/roro9stack/ota-key.pem` and must never be committed. If it's lost, generate a new pair and flash once over USB. (CI signs releases with a copy kept as a repository secret, ADR 0008.) **The private key** lives in `~/.config/roro9stack/ota-key.pem` and must never be committed. If it's lost, generate a new pair and flash once over USB. (CI signs releases with a copy kept as a repository secret, ADR 0008.)
### Updates from Gitea ### Updates from Gitea
With no PC and no card, the device can install the project's releases itself (docs/milestones/R1.md). In **Settings → Firmware**: With no PC and no card, the device can install the project's releases itself (docs/milestones/R1.md). In **Settings → System → Firmware**:
- **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update. - **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update. A download that breaks is carried on from where it was (issue #54): the device waits up to a minute for Wi-Fi, asks for the rest of the file, and gives up only after three tries in a row that bring nothing.
- **Older releases** lists the last ten, newest first. Opening an older one offers to go back to it, with a different question. - **Older releases** lists the last ten, newest first. Opening an older one offers to go back to it, with a different question.
- **Settings → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device. - **Settings → System → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device.
- **Settings → System → Install updates** (`When asked` by default; `By itself`, issue #52): a newer release found by the daily check is installed without being asked for. The device waits until no key was pressed for 2 minutes and nothing would be cut by a restart (a Track, a Capture, a copy, an upload, an SSH session, a web share), then asks on the screen for 30 seconds, lighting it if it was off: Cancel leaves it until the next day's check; Accept, or no answer, installs it and restarts. What was installed and when, and what was undone, is written in `/system/updates.log` on the card.
The connection is checked against the two ISRG roots Let's Encrypt chains end in (ADR 0009), not the usual bundle of about 130 authorities. Whatever the connection, the Update File's own signature is what decides what gets installed. The connection is checked against the two ISRG roots Let's Encrypt chains end in (ADR 0009), not the usual bundle of about 130 authorities. Whatever the connection, the Update File's own signature is what decides what gets installed.
@@ -114,7 +117,7 @@ The connection is checked against the two ISRG roots Let's Encrypt chains end in
## Networks without DHCP ## Networks without DHCP
Each Saved Network gets its address automatically (DHCP) or has a Fixed one (docs/milestones/S1.md): in Settings > Wi-Fi, Enter on a network opens its page, where "IP address" switches between Automatic and Fixed, with an address, a prefix length (24 is 255.255.255.0) and an optional gateway. Switching to Fixed starts from what the network is giving the device at that moment. The setting is checked and applied when you leave the page. IPv4 only. Each Saved Network gets its address automatically (DHCP) or has a Fixed one (docs/milestones/S1.md): in Settings > Network > Wi-Fi, Enter on a network opens its page, where "IP address" switches between Automatic and Fixed, with an address, a prefix length (24 is 255.255.255.0) and an optional gateway. Switching to Fixed starts from what the network is giving the device at that moment. The setting is checked and applied when you leave the page. IPv4 only.
"DNS and NTP" on the same screen holds two DNS servers (9.9.9.9 and 1.1.1.1 by default), used on Fixed networks, or on every network with "Always use my DNS"; and two NTP servers (pool.ntp.org and time.cloudflare.com), used after any the network's DHCP offers. Enter on "Status" shows what's in use and where each value came from. "DNS and NTP" on the same screen holds two DNS servers (9.9.9.9 and 1.1.1.1 by default), used on Fixed networks, or on every network with "Always use my DNS"; and two NTP servers (pool.ntp.org and time.cloudflare.com), used after any the network's DHCP offers. Enter on "Status" shows what's in use and where each value came from.
@@ -138,7 +141,7 @@ On a page, `b` bookmarks it, `s` saves it to the SD card to read offline (a non-
## LoRa Scanner ## LoRa Scanner
The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **never transmits**. The Sniffer lists what it hears, newest first: time, RSSI, SNR, and for Meshtastic packets the sender and receiver (their last 4 hex digits) and hops; for MeshCore packets what they are (an advert with the node's name, a message with the first byte of each node's key, a message on the public channel read, with the name its sender gives; any other channel message with the channel's byte) and the hops in their path. Enter shows a packet's details: what is never encrypted (the Meshtastic header; MeshCore's header, path and, in an advert, the node's name, kind, key and position; and a public channel message's sender, time and text, decrypted with the channel's published key) and a hex dump. `p` picks **MeshCore** (the default; its EU/UK Narrow setting: 869.618 MHz, 62.5 kHz, SF8, CR 4/8) or one of the 7 Meshtastic presets allowed in EU868, `c` starts or stops a Capture: a pcap file with LoRaTap headers in `/captures/lora/`, for Wireshark. A Capture keeps recording with the App closed; otherwise the radio sleeps when the App isn't open. Tab switches to **Sweep**: the signal strength across 863–870 MHz in 100 kHz steps, as bars with peak hold and a waterfall, with the Sniffer's frequency marked; the Sniffer is paused meanwhile and picks up where it was. The GNSS receiver on the same Cap raises the radio's noise floor by 8 dB while it runs: Settings > "Pause GNSS for LoRa" (off by default) puts it in standby while the radio listens, except during a Track. The Status Bar shows `L` while the radio listens (bright for a moment on each packet), `SW` while sweeping, and `CAP` while capturing. The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **never transmits**. The Sniffer lists what it hears, newest first: time, RSSI, SNR, and for Meshtastic packets the sender and receiver (their last 4 hex digits) and hops; for MeshCore packets what they are (an advert with the node's name, a message with the first byte of each node's key, a message on the public channel read, with the name its sender gives; any other channel message with the channel's byte) and the hops in their path. Enter shows a packet's details: what is never encrypted (the Meshtastic header; MeshCore's header, path and, in an advert, the node's name, kind, key and position; and a public channel message's sender, time and text, decrypted with the channel's published key) and a hex dump. `p` picks **MeshCore** (the default; its EU/UK Narrow setting: 869.618 MHz, 62.5 kHz, SF8, CR 4/8) or one of the 7 Meshtastic presets allowed in EU868, `c` starts or stops a Capture: a pcap file with LoRaTap headers in `/captures/lora/`, for Wireshark. A Capture keeps recording with the App closed; otherwise the radio sleeps when the App isn't open. Tab switches to **Sweep**: the signal strength across 863–870 MHz in 100 kHz steps, as bars with peak hold and a waterfall, with the Sniffer's frequency marked; the Sniffer is paused meanwhile and picks up where it was. The GNSS receiver on the same Cap raises the radio's noise floor by 8 dB while it runs: Settings > GNSS and radio > "Pause GNSS for LoRa" (off by default) puts it in standby while the radio listens, except during a Track. The Status Bar shows `L` while the radio listens (bright for a moment on each packet), `SW` while sweeping, and `CAP` while capturing.
## Storage ## Storage
@@ -186,7 +189,7 @@ The terminal (`lib/term`, host-tested) understands what a shell, `less`, `top`,
## VPN ## VPN
A WireGuard tunnel (docs/milestones/N1.md), over whatever Wi-Fi the device is on: one peer, IPv4. Copy a client's `.conf` to the card as `/vpn/wg0.conf` and import it in Settings > VPN (or `vpn import`); the configuration, private key included, is then kept in the device and never shown, and Settings offers to delete the file. A switch brings the tunnel up until the next restart; "Start with Wi-Fi" does it every time. It waits for the clock, which WireGuard needs. `VPN` shows in the Status Bar, bright once the server has answered. A WireGuard tunnel (docs/milestones/N1.md), over whatever Wi-Fi the device is on: one peer, IPv4. Copy a client's `.conf` to the card as `/vpn/wg0.conf` and import it in Settings > Network > VPN (or `vpn import`); the configuration, private key included, is then kept in the device and never shown, and Settings offers to delete the file. A switch brings the tunnel up until the next restart; "Start with Wi-Fi" does it every time. It waits for the clock, which WireGuard needs. `VPN` shows in the Status Bar, bright once the server has answered.
**What goes through it is one of two things:** everything, when AllowedIPs has `0.0.0.0/0` (and then nothing leaves the device while the server is silent), or the one subnet the device's tunnel address is in. A home network behind the server needs the first: lwIP routes by an interface's subnet or by default, nothing finer, and the import says how many ranges it can't reach. With the tunnel up the Debug Console and the Update Service answer on the tunnel address too, behind their token and their signature. It costs 63 KB of flash and under 2 KB of memory while up. **What goes through it is one of two things:** everything, when AllowedIPs has `0.0.0.0/0` (and then nothing leaves the device while the server is silent), or the one subnet the device's tunnel address is in. A home network behind the server needs the first: lwIP routes by an interface's subnet or by default, nothing finer, and the import says how many ranges it can't reach. With the tunnel up the Debug Console and the Update Service answer on the tunnel address too, behind their token and their signature. It costs 63 KB of flash and under 2 KB of memory while up.
@@ -203,6 +206,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
| `burst` | Publishes 5 Notifications at once | | `burst` | Publishes 5 Notifications at once |
| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help\|shot`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing; `shot` is Fn+p: a screenshot). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` | | `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help\|shot`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing; `shot` is Fn+p: a screenshot). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` |
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
| `theme` / `theme <0-6> [light\|dark]` | Lists the colour themes, or picks one and its side |
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) | | `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
| `wifi ip <ssid> dhcp` / `wifi ip <ssid> <address>/<prefix> [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Add `try <seconds>` to go back to the previous setting unless `wifi ip keep` follows | | `wifi ip <ssid> dhcp` / `wifi ip <ssid> <address>/<prefix> [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Add `try <seconds>` to go back to the previous setting unless `wifi ip keep` follows |
@@ -228,9 +232,9 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
| `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path | | `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path |
| `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead | | `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead |
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete | | `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
| `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does | | `install <path>` | Update from SD with that `.ota` file, as Settings → System → Firmware does |
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one | | `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again | | `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, break each connection of the next download after n bytes (it is carried on, issue #54) or damage one byte of it, run the daily check again |
| `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor | | `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor |
| `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack | | `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack |
| `lora rx on` / `lora rx off` | Listens and prints each packet on the console | | `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
@@ -238,6 +242,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
| `lora capture start` / `lora capture stop` | A LoRa Capture, as `c` in the App | | `lora capture start` / `lora capture stop` | A LoRa Capture, as `c` in the App |
| `lora sweep on [from MHz] [to MHz] [step kHz]` / `lora sweep off` / `lora sweep dump` | Sweep a band (863 870 100 by default), with a summary every 2 s (floor, strongest, peaks), or print the latest pass | | `lora sweep on [from MHz] [to MHz] [step kHz]` / `lora sweep off` / `lora sweep dump` | Sweep a band (863 870 100 by default), with a summary every 2 s (floor, strongest, peaks), or print the latest pass |
| `gnss quiet on` / `gnss quiet off` | The "Pause GNSS for LoRa" setting | | `gnss quiet on` / `gnss quiet off` | The "Pause GNSS for LoRa" setting |
| `imu` | What the motion sensor reads: g on its three axes, then degrees a second (the Compass, issue #16) |
| `gnss status` / `gnss restart` | The receiver's state, and a restart of it | | `gnss status` / `gnss restart` | The receiver's state, and a restart of it |
| `gnss track start` / `gnss track stop` | A Track, as `r` in the GNSS App (the reason is printed if it can't start) | | `gnss track start` / `gnss track stop` | A Track, as `r` in the GNSS App (the reason is printed if it can't start) |
| `gnss nmea on` / `gnss nmea off` | Prints each NMEA sentence the receiver sends, as `nmea: …` | | `gnss nmea on` / `gnss nmea off` | Prints each NMEA sentence the receiver sends, as `nmea: …` |
@@ -263,7 +268,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
### The Debug Console ### The Debug Console
Every firmware has the Debug Console (ADR 0010): the serial console over Wi-Fi, on TCP 2323. It is **off** until you switch it on in **Settings → Debug Console**, which also makes its token and shows it. With the device on USB, `scripts/flash.sh --debug` flashes it, switches the console on and gives it the token in `~/.config/roro9stack/debug-token`, so nothing has to be typed. Then, with `RORO_OTA_HOST` set to the device's IP: Every firmware has the Debug Console (ADR 0010): the serial console over Wi-Fi, on TCP 2323. It is **off** until you switch it on in **Settings → System → Debug Console**, which also makes its token and shows it. With the device on USB, `scripts/flash.sh --debug` flashes it, switches the console on and gives it the token in `~/.config/roro9stack/debug-token`, so nothing has to be typed. Then, with `RORO_OTA_HOST` set to the device's IP:
```sh ```sh
scripts/rdbg.py # interactive: the console backlog, live lines, and commands scripts/rdbg.py # interactive: the console backlog, live lines, and commands
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 B

+39
View File
@@ -0,0 +1,39 @@
# H1 — On-board hardware
**Status:** in progress. Built, not released yet: the Compass (issue #16). Not started: the IR Remote (#15), the Audio App (#14).
**Goal:** the hardware the device already carries and nothing used yet (the motion sensor, the infrared LED, the microphone and speaker) each gets something useful to do.
## The Compass (issue #16)
The device has a GNSS receiver and a motion sensor (a BMI270: an accelerometer and a gyroscope) but **no magnetometer**: standing still, nothing in it knows where north is. The receiver gives the direction of travel while walking; the gyroscope measures turns, but not from where.
### Decisions (design round 2026-10-10)
| # | Decision |
|---|---|
| Q267 | **A third view of the GNSS App,** after Position and Sky, not an App of its own: Tab goes round the three. |
| Q268 | **North comes from outside, and the gyroscope holds it.** Walking at 3 km/h or more, each course the receiver reports pulls the heading 30 % of the way to it. Standing, the gyroscope follows the turns, and the screen says how long ago north was last given. |
| Q269 | **The sun gives north too:** `s` with the top of the device aimed at the sun sets the heading to the sun's azimuth, worked out from the time and the position. The sun is drawn on the rose, as something to check north against. |
| Q270 | **The heading is where the top of the device points.** The turn that counts is the one around the vertical, which the accelerometer gives: it works however the device is held. Walking, the device is taken to point the way you walk. |
| Q271 | **Places:** `m` marks where you stand, under a name; `t` picks one as the target. The rose then carries an arrowhead to it, with its distance and bearing. Targets from Tracks, typed coordinates or Gemini links are for later. |
| Q272 | **Places are GPX waypoints** in `/gnss/places.gpx` on the SD card, 50 at most: a map program opens the file, and waypoints made by one are read here. The whole file is written at each change. |
| Q273 | **A level** from the accelerometer, next to the readouts: the heading is only as good as the device is flat when aiming at the sun. |
| Q274 | **Not now:** a magnetometer on the Grove port (and so declination), a faster GNSS rate, sunrise and sunset, an estimated time of arrival. |
### As built
- **`lib/gnss/src/compass.{h,cpp}`**, host-tested (13 tests): `HeadingFilter` (the turn around "up" from the gyroscope, the course and the sun as references, the gyroscope's offset learnt while the device lies still), `sunPosition` (the Astronomical Almanac's low-precision formulas), `bearingDegrees`, `cardinal`, `formatDistance`.
- **`lib/gnss/src/places.{h,cpp}`**, host-tested (5 tests): the Places as GPX waypoints, read and written.
- **The GNSS App** reads the sensor 100 times a second while it is in front, in any of its views, and forgets north when it is left: the turns made meanwhile were not followed. The Compass is drawn again when the heading moves by a degree or the level moves, at most 20 times a second.
- **`imu`** on the consoles prints what the sensor reads.
### Checked on the device (2026-10-11)
- The sensor answers as a BMI270 through M5Unified, and reads about 0.5 degrees a second on one axis lying still.
- Turning the device clockwise makes the heading go up, and the level's bubble goes to the edge that is lifted: the sensor's axes are as the code took them.
- Not checked yet: north from the course while walking, north from the sun against a real compass, and how far the heading drifts in the hand.
### What the sensor's offset costs
A gyroscope reads a little even when nothing turns (up to about 0.5 degrees a second for this one), which would turn the rose by itself. The filter takes what the sensor reads while it has been still for a second (under 2 degrees a second on all axes) as that offset. In the hand the device is rarely that still: lay it down for a few seconds first. After 3 minutes without north the screen shows the age of the heading in the warning colour.
+64 -1
View File
@@ -1,6 +1,6 @@
# R1 — Releases # R1 — Releases
**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Not started: the Issues App (#4), automatic installs (#52), release channels (#53), resuming a download (#54). **Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Built, not released yet: installing a release by itself (#52). Built, not released yet: carrying on a download that broke (#54). Not started: the Issues App (#4), release channels (#53).
**Goal:** a tag is a release, built the same way every time and published where a device can find it. **Goal:** a tag is a release, built the same way every time and published where a device can find it.
@@ -124,6 +124,69 @@ The device looks at the project's Gitea for a newer release, says so, and instal
**Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure. **Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure.
## Installing a release by itself (issue #52)
For a device that sits on a desk, "always on the latest" may be what is wanted. Until now the daily check only said that a release was out.
### Decisions (2026-10-11)
| # | Decision |
|---|---|
| Q275 | **A setting, off by default:** Settings > System > Install updates, `When asked` or `By itself`. It changes what runs on the device without anyone looking, so it is never on unless the owner said so. |
| Q276 | **The owner is asked, and silence is yes:** a question over whatever is open, `Cancel` or `Accept`, that answers itself after 30 seconds by installing. The screen lights up for it if it was off. (The owner's own answer on the issue.) |
| Q277 | **The question comes before the download,** not before the restart: the download already takes the screen over and may take IRC offline for its memory, so that is the moment to ask. |
| Q278 | **Only in a quiet moment:** no key for 2 minutes, and nothing a restart would cut: a Track, a Capture, a file operation, an upload, an scp transfer, an SSH session, a web share, a noise test, another update. If one of them starts while the question is up, the question goes and comes back later with a full count. |
| Q279 | **Any newer release,** not only patch releases, and with no delay after it is published: Probation and Rollback are what protect against a bad one, and a version that rolled back here is never offered again. |
| Q280 | **Cancel lasts a day:** the release is offered again at the next daily check, for as long as it is the newest. |
| Q281 | **A record:** each update that held (and whether it installed by itself), and each one that was undone, is a dated line in `/system/updates.log` on the card. |
### As built
- **`lib/release/src/auto_update.h`**, host-tested (10 tests): the waiting, the question and its count, as a state machine that is told what "busy" and "idle" are.
- **The Update Service** offers the release at each background check when the setting is on (`shouldInstall`), and writes the record when Probation confirms a version or a rollback is reported.
- **The main loop** decides what busy is, puts the question up through `Screen::render`, and gives it every key while it is up.
- `update status` says whether a release is waiting for its quiet moment.
### Checked on the device (2026-10-11)
With the setting on, `update pretend v0.22.0` and `update daily`:
- The question came up over the Settings App, counting down each second. **Cancel** took it down and nothing was installed.
- Left unanswered, the device downloaded v0.23.0, installed it and restarted into it.
- `/system/updates.log` got its line at each confirmed update.
- Not checked: "by itself" in the record (the release installed did not have this code yet), the 2 minutes without a key and the busy cases (host tests only), and a screen that was off lighting up. Keys sent through the Debug Console do not count as keys.
## Carrying on a download that broke (issue #54)
A release goes straight into the inactive slot (Q167), so a connection that broke meant starting again, and on a weak link a 2.5 MB file might never arrive whole.
### What the server gives (checked 2026-10-11)
A request with `Range: bytes=0-0` for a release's file gets `206`, `Content-Range: bytes 0-0/2510384` and a strong `ETag`.
### Decisions (2026-10-11)
| # | Decision |
|---|---|
| Q282 | **Carried on within the same install, in memory only.** The slot's write handle and the parser, with its hash so far, stay as they are while the connection is opened again with `Range: bytes=<where it was>-`. The parser never sees the break. |
| Q283 | **Nothing is kept across a restart** (the issue's second question): the slot cannot be reopened at an offset, so a download cut by a power-off starts over. |
| Q284 | **The answer must be the rest of the same file:** a `206` from that byte to the end, of the same total size, with the same `ETag` if both answers have one. The `ETag` goes in `If-Range`, so a file that changed comes back whole (a `200`), which is refused. What decides whether the whole is good is still the Update File's own signature and hash. |
| Q285 | **How long:** before each try, up to a minute for Wi-Fi to come back. Tries go on while each brings more bytes, 20 at most in one download; three in a row that bring nothing end it. |
| Q286 | **`update damage cut <n>`** now breaks each connection of the next download after n bytes, so both the carrying on and the giving up can be tried. |
### As built
- **`lib/release/src/resume.h`**, host-tested (5 tests with the head's new fields): `resumeRefusal`, `ifRangeFor`, `ResumePolicy`. `HttpHead` reads `ETag` and `Content-Range`.
- **`HttpsGet::openFrom`** asks for a range; **`GiteaSource`** in the Update Service does the waiting and the trying inside its `read()`.
- `update status` says how often the last download was carried on.
### Checked on the device (2026-10-11)
- **`update damage cut 800000`, then an install of v0.23.0** (2 510 384 bytes): it arrived over four connections, its hash matched, and the device restarted into it.
- **`update damage cut 0`:** three connections that brought nothing, then it gave up (`carried on 2 time(s)`), with the running slot still the one to boot.
- **Memory decided the first attempt.** Between two connections 78 KB was free, under the 80 KB a TLS connection asks for before it starts, so every try was refused and the download gave up. The install's own buffers (about 8 KB) are what is missing, and nothing else asks for memory while the screen is taken over: carrying on asks for 64 KB (the 52 KB peak and 12 KB). The lowest the heap went during the broken download was 20 KB, with a console client connected.
- Not checked: a real Wi-Fi drop in the middle (the wait for Wi-Fi to come back), and a file that changed on the server.
## One firmware: the Debug Console in every build (issue #68) ## One firmware: the Debug Console in every build (issue #68)
The issue asked for a token that could be set, so that Debug Builds could be published. The design round ended somewhere simpler: no Debug Builds. The console is in every firmware, off until switched on, with a token that belongs to the device (ADR 0010, which supersedes part of ADR 0004). The issue asked for a token that could be set, so that Debug Builds could be published. The design round ended somewhere simpler: no Debug Builds. The console is in every firmware, off until switched on, with a token that belongs to the device (ADR 0010, which supersedes part of ADR 0004).
+77 -1
View File
@@ -1,6 +1,6 @@
# U1 — Look and feel # U1 — Look and feel
**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). Not started: screen recording (#17), a Launcher of tiles (#9), themes (#10). **Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). The Launcher as a grid of icons (#9) and themes (#10) are done, not released yet. Not started: screen recording (#17).
**Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content. **Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content.
@@ -59,3 +59,79 @@ A screenshot could only be taken by typing `screenshot` in the Shell, where "now
**Checked on the device** (2026-10-07, with `key shot`): in the Launcher, a 33,383-byte PNG appears in `/screenshots` and is the Launcher; with the help panel open, the picture is of the panel (which now lists Fn p) and the panel stays open; on Settings > Debug Console, no file is written; back on the Settings list, one is. The test pictures were removed. **Checked on the device** (2026-10-07, with `key shot`): in the Launcher, a 33,383-byte PNG appears in `/screenshots` and is the Launcher; with the help panel open, the picture is of the panel (which now lists Fn p) and the panel stays open; on Settings > Debug Console, no file is written; back on the Settings list, one is. The test pictures were removed.
**Not checked:** the real Fn+p on the keyboard (the mapper is host-tested); the two Toasts that refuse, which weren't looked at (one of them is on the page that mustn't be photographed); a device with no card. **Not checked:** the real Fn+p on the keyboard (the mapper is host-tested); the two Toasts that refuse, which weren't looked at (one of them is on the page that mustn't be photographed); a device with no card.
## The Launcher as a grid (issue #9)
The Launcher was a list of names. It is now a grid of icons.
| Question in the issue | Decided |
|---|---|
| Layout | **4 × 3** tiles of 60 × 36, so the 11 Apps are on one page. Only the selected App's name is written, on a line under the grid: a name under every tile doesn't fit 60 pixels ("LoRa Scanner"). More than 12 Apps: the rows scroll |
| Icons | **The website's own**: its nine 16 × 16 pictures (`site/static/img/icons-dark.svg`), doubled to 32 × 32, and two drawn the same way for SSH and the Shell. 1 bit a pixel (128 bytes each, in flash). The pictures are PNGs in `assets/icons/`, named after the App's id; `scripts/make_icons.py` writes `src/ui/icons.cpp` from them and CI checks the two match |
| Colours | **The website's**: cyan (#00dbff) on black, and the selected tile slate (#494955, the Status Bar's) on a cyan fill with notched corners. Both are RGB332 colours. The name under the grid is white |
| Live state | **A dot** on the tile, from `App::badge()`: IRC (unread messages), GNSS (a Track recording), the LoRa Scanner (a Capture running), SSH (a session open). The Launcher asks every App on each pass and redraws when an answer changes |
| Order | As registered, as before |
| Shortcuts | None for now |
| The list | Kept: **Settings > Launcher**, Grid or List. Both keep the same selection |
- `GridModel` (`lib/ui`) holds the selection and the scrolling, host-tested: left and right go through every item and wrap; up and down stay in the column, and land on the last item where the last row is short.
- An App registered without an icon shows its first letter in a frame.
- No animation: not asked for, and not measured.
## The website's colours, on every screen
Every screen takes its colours from `src/ui/theme.h`, so the palette changed there, to the website's (`site/static/css/site.css`, dark side). Each is a colour the RGB332 frame buffer holds exactly.
| | Was | Is |
|---|---|---|
| Accent | teal-blue | cyan `#00dbff` |
| A selected row, a dialog's chosen button | darker teal, white text | cyan, dark blue text (`#000055`) |
| Status Bar | `#202020`, which the frame buffer showed as a dull olive | slate `#494955`; what is idle on it in light grey `#b6b6aa` |
| Muted text | grey, as the frame buffer rounded it | the same grey, exact: `#9292aa` |
| Warning | orange | the website's orange `#ff9200` |
| Someone wrote (unread count, a mention, a message Toast) | green | pink `#ff92ff` |
| Good (a Fix, the quietest channel, a live task) | the same green | green `#49db55`, the one colour that isn't the website's: it has no green |
| Text on a Toast | black | dark blue |
Looked at on the device, by screenshot: the Launcher, IRC, Wi-Fi Tools (menu, channel occupancy), GNSS (position, sky), Gemini, the LoRa Scanner (Sniffer, Sweep, presets), Storage, Notes, SSH, the Shell, System, Settings and the help panel. Not looked at: dialogs, a warning or a message Toast, the SSH terminal (which keeps its own 16 ANSI colours), the Setup screens.
## Themes (issue #10)
Seven themes, each with a dark and a light side, chosen in **Settings > Theme** and **Settings > Light or dark**: roro9stack (the website's, the default), Catppuccin (Mocha and Latte), Dracula (and Alucard), Nord, ANSI terminal, Gruvbox, Solarized.
| Question in the issue | Decided |
|---|---|
| Which themes | The seven above. Not the high-contrast and phosphor ones the issue suggested: ANSI terminal dark (grey and green on black) and light (black on white) come close |
| Fonts and spacing too | No: colours only |
| Themes from the SD card | No: built in |
| A preview while picking | The Setting applies at once, so the Settings screen is the preview |
| Constellation colours | From the theme (its accent, its "good", and three roles named red, yellow and violet), so they read on every background |
| Light by day, dark by night | No |
- **A palette is 14 roles** (`lib/ui/src/palette.h`): background, text, muted, faint, the bar and its idle marks, accent, what is drawn on an accent, warning, message, good, and red, yellow and violet.
- **`theme::kText` and the others are now references** into the palette in use (`src/ui/theme.h`), so no App changed: they still read as constants. The main loop applies the palette when either Setting changes, and draws everything again.
- **RGB332.** Every colour in the table is one the frame buffer holds exactly. The themes' own colours were rounded to those; where two roles fell together or lost contrast, one was picked by hand (Nord's accent and muted text, Gruvbox's greys, Solarized's surfaces). `test_palette` checks that every colour is exact, and 18 contrast ratios for each of the 14 palettes (text on background at 4.5 or more, the rest at 2 to 3).
- **What it costs in fidelity:** blue has four levels, so Dracula and Nord share a dark background (#242455), which was Catppuccin's too: Catppuccin dark is on black instead (its "crust"), with that blue for its bar and its brighter accents (peach, pink, green, sky), to tell it from Dracula; Solarized light's cream becomes white, and Gruvbox's dark brown becomes a dark olive (#242400).
- `theme [0-6] [light|dark]` on the consoles lists them or picks one.
**Looked at on the device**, by screenshot: the Launcher in all 14; and in Gruvbox light, Settings, GNSS (position and sky), the Sweep, Storage, the help panel, Gemini and Wi-Fi Tools' channel occupancy. Nothing was drawn for black only.
**Kept as they are:** the SSH terminal's 16 ANSI colours and its black background, the Sweep's waterfall scale, pictures, and the screen shown while the device powers off.
**Not looked at:** the other 12 palettes beyond the Launcher, dialogs and Toasts in any theme but the default, the Setup screens. In Gruvbox light, the Sky view's GPS, GLONASS and BeiDou colours are three close shades of brown and red.
## Settings in groups
Settings had grown to 21 rows in one list. It now opens on five groups, each a short list of its own:
| Group | Rows |
|---|---|
| This device | Long name, Short name, Region, Timezone, Sound & LED |
| Display | Brightness, Dim after, Screen off after, Theme, Light or dark, Launcher |
| GNSS and radio | GNSS, Pause GNSS for LoRa, Coordinates, Probe MACs |
| Network | Wi-Fi, VPN |
| System | Check for updates, Install updates, Firmware, Debug Console, About |
- Enter opens a group, Back returns to the groups with the selection on the one just left, and Back from the groups leaves Settings. An open group has its name over its rows.
- `SettingsMenu` (`lib/apps_model`) holds the groups and which one is open; every index is into what is listed. Host-tested: every setting is in exactly one group, and no group has more rows than fit under its name.
- The guide, the how-tos, the README, the scripts' messages and the firmware's own (the Toast for a new release, the GNSS App's hint) name the new paths: "Settings > System > Firmware". The milestone notes and the devlog keep the paths of their day.
+95 -15
View File
@@ -1,6 +1,7 @@
#include "settings_menu.h" #include "settings_menu.h"
#include "choices.h" #include "choices.h"
#include "palette.h"
namespace roro { namespace roro {
@@ -15,18 +16,51 @@ struct RowDef {
const char* label; const char* label;
}; };
const RowDef kRows[] = { const RowDef kGroups[] = {
{Row::LongName, Kind::Text, "Long name"}, {Row::ShortName, Kind::Text, "Short name"}, {Row::GroupDevice, Kind::Group, "This device"}, {Row::GroupDisplay, Kind::Group, "Display"},
{Row::Region, Kind::Choice, "Region"}, {Row::Timezone, Kind::Choice, "Timezone"}, {Row::GroupPosition, Kind::Group, "GNSS and radio"}, {Row::GroupNetwork, Kind::Group, "Network"},
{Row::Brightness, Kind::Slider, "Brightness"}, {Row::DimTimeout, Kind::Choice, "Dim after"}, {Row::GroupSystem, Kind::Group, "System"},
{Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Sound, Kind::Toggle, "Sound & LED"},
{Row::Gnss, Kind::Toggle, "GNSS"}, {Row::GnssQuiet, Kind::Toggle, "Pause GNSS for LoRa"},
{Row::Coordinates, Kind::Toggle, "Coordinates"},
{Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"},
{Row::CheckUpdates, Kind::Toggle, "Check for updates"}, {Row::Firmware, Kind::Page, "Firmware"},
{Row::Vpn, Kind::Page, "VPN"},
{Row::DebugConsole, Kind::Page, "Debug Console"}, {Row::About, Kind::Page, "About"},
}; };
constexpr int kGroupCount = sizeof(kGroups) / sizeof(kGroups[0]);
struct Member {
int group; // an index into kGroups
RowDef def;
};
// In the order each group lists them.
const Member kRows[] = {
{0, {Row::LongName, Kind::Text, "Long name"}},
{0, {Row::ShortName, Kind::Text, "Short name"}},
{0, {Row::Region, Kind::Choice, "Region"}},
{0, {Row::Timezone, Kind::Choice, "Timezone"}},
{0, {Row::Sound, Kind::Toggle, "Sound & LED"}},
{1, {Row::Brightness, Kind::Slider, "Brightness"}},
{1, {Row::DimTimeout, Kind::Choice, "Dim after"}},
{1, {Row::OffTimeout, Kind::Choice, "Screen off after"}},
{1, {Row::Theme, Kind::Choice, "Theme"}},
{1, {Row::ThemeLight, Kind::Toggle, "Light or dark"}},
{1, {Row::Launcher, Kind::Toggle, "Launcher"}},
{2, {Row::Gnss, Kind::Toggle, "GNSS"}},
{2, {Row::GnssQuiet, Kind::Toggle, "Pause GNSS for LoRa"}},
{2, {Row::Coordinates, Kind::Toggle, "Coordinates"}},
{2, {Row::ProbeMacs, Kind::Toggle, "Probe MACs"}},
{3, {Row::Wifi, Kind::Page, "Wi-Fi"}},
{3, {Row::Vpn, Kind::Page, "VPN"}},
{4, {Row::CheckUpdates, Kind::Toggle, "Check for updates"}},
{4, {Row::InstallUpdates, Kind::Toggle, "Install updates"}},
{4, {Row::Firmware, Kind::Page, "Firmware"}},
{4, {Row::DebugConsole, Kind::Page, "Debug Console"}},
{4, {Row::About, Kind::Page, "About"}},
};
// Row i of what is listed: a group, or the i-th row of the open one.
const RowDef& at(int group, int i) {
if (group < 0) return kGroups[i < 0 || i >= kGroupCount ? 0 : i];
for (const Member& m : kRows)
if (m.group == group && i-- == 0) return m.def;
return kGroups[group]; // out of range: harmless
}
const int kDimSeconds[] = {10, 15, 30, 60, 120, 300}; const int kDimSeconds[] = {10, 15, 30, 60, 120, 300};
const int kOffSeconds[] = {30, 60, 120, 300, 600, 1800}; const int kOffSeconds[] = {30, 60, 120, 300, 600, 1800};
@@ -63,10 +97,42 @@ int indexOf(const int (&table)[N], int value) {
} // namespace } // namespace
int SettingsMenu::count() const { return sizeof(kRows) / sizeof(kRows[0]); } bool SettingsMenu::open(int i) {
SettingsMenu::Row SettingsMenu::row(int i) const { return kRows[i].row; } if (group_ >= 0 || i < 0 || i >= kGroupCount) return false;
SettingsMenu::Kind SettingsMenu::kind(int i) const { return kRows[i].kind; } group_ = lastGroup_ = i;
std::string SettingsMenu::label(int i) const { return kRows[i].label; } return true;
}
bool SettingsMenu::close() {
if (group_ < 0) return false;
group_ = -1;
return true;
}
std::string SettingsMenu::title() const { return group_ < 0 ? "" : kGroups[group_].label; }
bool SettingsMenu::reveal(Row r, int& index) {
int seen[kGroupCount] = {};
for (const Member& m : kRows) {
if (m.def.row == r) {
group_ = lastGroup_ = m.group;
index = seen[m.group];
return true;
}
seen[m.group]++;
}
return false;
}
int SettingsMenu::count() const {
if (group_ < 0) return kGroupCount;
int n = 0;
for (const Member& m : kRows) n += m.group == group_;
return n;
}
SettingsMenu::Row SettingsMenu::row(int i) const { return at(group_, i).row; }
SettingsMenu::Kind SettingsMenu::kind(int i) const { return at(group_, i).kind; }
std::string SettingsMenu::label(int i) const { return at(group_, i).label; }
std::string SettingsMenu::value(int i) const { std::string SettingsMenu::value(int i) const {
switch (row(i)) { switch (row(i)) {
@@ -80,10 +146,14 @@ std::string SettingsMenu::value(int i) const {
case Row::Brightness: return std::to_string(settings_.getInt(Setting::Brightness)) + "%"; case Row::Brightness: return std::to_string(settings_.getInt(Setting::Brightness)) + "%";
case Row::DimTimeout: return formatSeconds(settings_.getInt(Setting::DimTimeoutS)); case Row::DimTimeout: return formatSeconds(settings_.getInt(Setting::DimTimeoutS));
case Row::OffTimeout: return formatSeconds(settings_.getInt(Setting::OffTimeoutS)); case Row::OffTimeout: return formatSeconds(settings_.getInt(Setting::OffTimeoutS));
case Row::Launcher: return settings_.getBool(Setting::LauncherList) ? "List" : "Grid";
case Row::Theme: return ui::themeName(settings_.getInt(Setting::Theme));
case Row::ThemeLight: return settings_.getBool(Setting::ThemeLight) ? "Light" : "Dark";
case Row::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off"; case Row::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off";
case Row::Gnss: return settings_.getBool(Setting::GnssEnabled) ? "On" : "Off"; case Row::Gnss: return settings_.getBool(Setting::GnssEnabled) ? "On" : "Off";
case Row::GnssQuiet: return settings_.getBool(Setting::GnssQuietForLora) ? "On" : "Off"; case Row::GnssQuiet: return settings_.getBool(Setting::GnssQuietForLora) ? "On" : "Off";
case Row::CheckUpdates: return settings_.getBool(Setting::CheckUpdates) ? "Daily" : "Off"; case Row::CheckUpdates: return settings_.getBool(Setting::CheckUpdates) ? "Daily" : "Off";
case Row::InstallUpdates: return settings_.getBool(Setting::InstallUpdates) ? "By itself" : "When asked";
case Row::Coordinates: return settings_.getBool(Setting::CoordinatesDms) ? "Deg min sec" : "Decimal"; case Row::Coordinates: return settings_.getBool(Setting::CoordinatesDms) ? "Deg min sec" : "Decimal";
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised"; case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off"; case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
@@ -97,6 +167,11 @@ std::vector<std::string> SettingsMenu::choices(int i) const {
switch (row(i)) { switch (row(i)) {
case Row::Region: return labels(kRegions); case Row::Region: return labels(kRegions);
case Row::Timezone: return labels(kTimezones); case Row::Timezone: return labels(kTimezones);
case Row::Theme: {
std::vector<std::string> names;
for (int t = 0; t < ui::kThemeCount; ++t) names.push_back(ui::themeName(t));
return names;
}
case Row::DimTimeout: return durations(kDimSeconds); case Row::DimTimeout: return durations(kDimSeconds);
case Row::OffTimeout: return durations(kOffSeconds); case Row::OffTimeout: return durations(kOffSeconds);
default: return {}; default: return {};
@@ -107,6 +182,7 @@ int SettingsMenu::currentChoice(int i) const {
switch (row(i)) { switch (row(i)) {
case Row::Region: return indexOf(kRegions, settings_.getString(Setting::Region)); case Row::Region: return indexOf(kRegions, settings_.getString(Setting::Region));
case Row::Timezone: return indexOf(kTimezones, settings_.getString(Setting::Timezone)); case Row::Timezone: return indexOf(kTimezones, settings_.getString(Setting::Timezone));
case Row::Theme: return settings_.getInt(Setting::Theme);
case Row::DimTimeout: return indexOf(kDimSeconds, settings_.getInt(Setting::DimTimeoutS)); case Row::DimTimeout: return indexOf(kDimSeconds, settings_.getInt(Setting::DimTimeoutS));
case Row::OffTimeout: return indexOf(kOffSeconds, settings_.getInt(Setting::OffTimeoutS)); case Row::OffTimeout: return indexOf(kOffSeconds, settings_.getInt(Setting::OffTimeoutS));
default: return -1; default: return -1;
@@ -120,6 +196,7 @@ std::string SettingsMenu::choose(int i, int c) {
settings_.setBool(Setting::RegionConfirmed, true); settings_.setBool(Setting::RegionConfirmed, true);
return ""; return "";
case Row::Timezone: settings_.setString(Setting::Timezone, kTimezones[c].value); return ""; case Row::Timezone: settings_.setString(Setting::Timezone, kTimezones[c].value); return "";
case Row::Theme: settings_.setInt(Setting::Theme, c); return "";
case Row::DimTimeout: case Row::DimTimeout:
return settings_.setInt(Setting::DimTimeoutS, kDimSeconds[c]) ? "" : "Dimming must happen before screen off"; return settings_.setInt(Setting::DimTimeoutS, kDimSeconds[c]) ? "" : "Dimming must happen before screen off";
case Row::OffTimeout: case Row::OffTimeout:
@@ -129,10 +206,13 @@ std::string SettingsMenu::choose(int i, int c) {
} }
void SettingsMenu::toggle(int i) { void SettingsMenu::toggle(int i) {
if (row(i) == Row::ThemeLight) settings_.setBool(Setting::ThemeLight, !settings_.getBool(Setting::ThemeLight));
if (row(i) == Row::Launcher) settings_.setBool(Setting::LauncherList, !settings_.getBool(Setting::LauncherList));
if (row(i) == Row::Sound) settings_.setBool(Setting::Sound, !settings_.getBool(Setting::Sound)); 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::Gnss) settings_.setBool(Setting::GnssEnabled, !settings_.getBool(Setting::GnssEnabled));
if (row(i) == Row::GnssQuiet) settings_.setBool(Setting::GnssQuietForLora, !settings_.getBool(Setting::GnssQuietForLora)); if (row(i) == Row::GnssQuiet) settings_.setBool(Setting::GnssQuietForLora, !settings_.getBool(Setting::GnssQuietForLora));
if (row(i) == Row::CheckUpdates) settings_.setBool(Setting::CheckUpdates, !settings_.getBool(Setting::CheckUpdates)); if (row(i) == Row::CheckUpdates) settings_.setBool(Setting::CheckUpdates, !settings_.getBool(Setting::CheckUpdates));
if (row(i) == Row::InstallUpdates) settings_.setBool(Setting::InstallUpdates, !settings_.getBool(Setting::InstallUpdates));
if (row(i) == Row::Coordinates) settings_.setBool(Setting::CoordinatesDms, !settings_.getBool(Setting::CoordinatesDms)); if (row(i) == Row::Coordinates) settings_.setBool(Setting::CoordinatesDms, !settings_.getBool(Setting::CoordinatesDms));
if (row(i) == Row::ProbeMacs) settings_.setBool(Setting::ProbeMacRaw, !settings_.getBool(Setting::ProbeMacRaw)); if (row(i) == Row::ProbeMacs) settings_.setBool(Setting::ProbeMacRaw, !settings_.getBool(Setting::ProbeMacRaw));
} }
+16 -4
View File
@@ -7,15 +7,26 @@
namespace roro { namespace roro {
// What the Settings App lists: one row per user-facing setting (plus sub-pages), with readable // What the Settings App lists: the groups first, then, with one open, a row per user-facing setting
// values, choice lists and validation messages. Rendering and navigation live in the App. // of that group (plus sub-pages), with readable values, choice lists and validation messages.
// Rendering and navigation live in the App. Every index is into what is listed now.
class SettingsMenu { class SettingsMenu {
public: public:
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About }; enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Launcher, Theme, ThemeLight, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, InstallUpdates, Firmware, Vpn, DebugConsole, About,
enum class Kind { Text, Choice, Toggle, Slider, Page }; GroupDevice, GroupDisplay, GroupPosition, GroupNetwork, GroupSystem };
enum class Kind { Text, Choice, Toggle, Slider, Page, Group };
explicit SettingsMenu(Settings& settings) : settings_(settings) {} explicit SettingsMenu(Settings& settings) : settings_(settings) {}
// The groups are listed until one is opened; close() lists them again.
bool open(int i); // false when row i isn't a group
bool close(); // false when the groups were listed already
bool inGroup() const { return group_ >= 0; }
std::string title() const; // the open group's name, or ""
int groupIndex() const { return lastGroup_; } // where the group last opened is among the groups
// Opens the group a row is in, and says where the row is in it. False for a row there isn't.
bool reveal(Row r, int& index);
int count() const; int count() const;
Row row(int i) const; Row row(int i) const;
Kind kind(int i) const; Kind kind(int i) const;
@@ -37,6 +48,7 @@ class SettingsMenu {
private: private:
Settings& settings_; Settings& settings_;
int group_ = -1, lastGroup_ = 0;
}; };
} // namespace roro } // namespace roro
+4
View File
@@ -50,6 +50,10 @@ class App {
virtual bool retainsContent() const { return false; } virtual bool retainsContent() const { return false; }
virtual void contentLost() {} virtual void contentLost() {}
// True while something goes on in the App that is worth a look, whether it is in front or
// not (unread messages, a recording running): the Launcher marks its tile (issue #9).
virtual bool badge() const { return false; }
void requestRedraw() { redraw_ = true; } void requestRedraw() { redraw_ = true; }
bool consumeRedraw() { bool consumeRedraw() {
+20 -3
View File
@@ -48,7 +48,7 @@ inline constexpr KeyHelp kText[] = {
// launcher: The Launcher // launcher: The Launcher
inline constexpr KeyHelp kLauncher[] = { inline constexpr KeyHelp kLauncher[] = {
{"; .", "up, down"}, {"; . , /", "move"},
{"Enter", "open the App"}, {"Enter", "open the App"},
}; };
@@ -134,10 +134,26 @@ inline constexpr KeyHelp kWifiTracker[] = {
// gnss: GNSS // gnss: GNSS
inline constexpr KeyHelp kGnss[] = { inline constexpr KeyHelp kGnss[] = {
{"Tab", "the position, or the sky"}, {"Tab", "position, sky, compass"},
{"r", "record a Track, or stop it"}, {"r", "record a Track, or stop it"},
}; };
// gnss-compass: GNSS, the compass
inline constexpr KeyHelp kGnssCompass[] = {
{"Tab", "position, sky, compass"},
{"m", "mark this place"},
{"t", "the target: a place"},
{"s", "aimed at the sun: north"},
{"r", "record a Track, or stop it"},
};
// gnss-places: GNSS, the places
inline constexpr KeyHelp kGnssPlaces[] = {
{"; .", "up, down"},
{"Enter", "the target"},
{"d Del", "remove the place"},
};
// gemini: Gemini, a page // gemini: Gemini, a page
inline constexpr KeyHelp kGemini[] = { inline constexpr KeyHelp kGemini[] = {
{"Tab", "the next link"}, {"Tab", "the next link"},
@@ -414,8 +430,9 @@ inline constexpr KeyHelp kSystemSystem[] = {
// settings: Settings // settings: Settings
inline constexpr KeyHelp kSettings[] = { inline constexpr KeyHelp kSettings[] = {
{"; .", "up, down"}, {"; .", "up, down"},
{"Enter", "edit, or open the page"}, {"Enter", "open the group or the page, or edit"},
{", /", "change a switch or a slider"}, {", /", "change a switch or a slider"},
{"`", "back to the groups"},
}; };
// settings-choice: Settings, a choice // settings-choice: Settings, a choice
+3
View File
@@ -1,5 +1,7 @@
#pragma once #pragma once
#include <cstdint>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -12,6 +14,7 @@ struct AppInfo {
const char* title; const char* title;
bool hidden; // not listed in the Launcher, but can still be opened bool hidden; // not listed in the Launcher, but can still be opened
App* app; App* app;
const uint8_t* icon = nullptr; // the Launcher's picture of it (ui/icons.h); none: its first letter
}; };
// Owns which App is in the foreground and routes keys. Home always returns to the Launcher; // Owns which App is in the foreground and routes keys. Home always returns to the Launcher;
+123
View File
@@ -0,0 +1,123 @@
#include "compass.h"
#include <cmath>
#include <cstdio>
namespace roro::gnss {
namespace {
constexpr double kRad = M_PI / 180.0;
// The sensor's axes are right-handed, so a positive turn around "up" is anticlockwise seen from
// above, and the heading (clockwise) goes down. Measured on the device: issue #16.
constexpr float kTurnSign = -1.0f;
} // namespace
float wrap360(float degrees) {
float d = std::fmod(degrees, 360.0f);
return d < 0 ? d + 360.0f : d;
}
float angleDiff(float to, float from) {
float d = wrap360(to - from);
return d >= 180.0f ? d - 360.0f : d;
}
double bearingDegrees(double lat1, double lon1, double lat2, double lon2) {
double p1 = lat1 * kRad, p2 = lat2 * kRad, dl = (lon2 - lon1) * kRad;
double y = std::sin(dl) * std::cos(p2);
double x = std::cos(p1) * std::sin(p2) - std::sin(p1) * std::cos(p2) * std::cos(dl);
double b = std::atan2(y, x) / kRad;
return b < 0 ? b + 360.0 : b;
}
const char* cardinal(float degrees) {
static const char* const kPoints[16] = {"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"};
return kPoints[static_cast<int>(wrap360(degrees) / 22.5f + 0.5f) % 16];
}
std::string formatDistance(double meters) {
char buf[24];
if (meters < 999.5) std::snprintf(buf, sizeof buf, "%.0f m", meters);
else if (meters < 9950.0) std::snprintf(buf, sizeof buf, "%.1f km", meters / 1000.0);
else std::snprintf(buf, sizeof buf, "%.0f km", meters / 1000.0);
return buf;
}
SunPosition sunPosition(int64_t utcSeconds, double latitude, double longitude) {
double d = utcSeconds / 86400.0 - 10957.5; // days since 2000-01-01 12:00 UTC
double g = (357.529 + 0.98560028 * d) * kRad; // mean anomaly
double q = 280.459 + 0.98564736 * d; // mean longitude
double l = (q + 1.915 * std::sin(g) + 0.020 * std::sin(2 * g)) * kRad; // ecliptic longitude
double e = (23.439 - 0.00000036 * d) * kRad; // the ecliptic's tilt
double rightAscension = std::atan2(std::cos(e) * std::sin(l), std::cos(l));
double declination = std::asin(std::sin(e) * std::sin(l));
double siderealDeg = std::fmod(18.697374558 + 24.06570982441908 * d, 24.0) * 15.0; // at Greenwich
double hourAngle = (siderealDeg + longitude) * kRad - rightAscension;
double lat = latitude * kRad;
double elevation = std::asin(std::sin(lat) * std::sin(declination) +
std::cos(lat) * std::cos(declination) * std::cos(hourAngle));
double azimuth = std::atan2(-std::sin(hourAngle),
std::tan(declination) * std::cos(lat) - std::sin(lat) * std::cos(hourAngle));
return {wrap360(static_cast<float>(azimuth / kRad)), static_cast<float>(elevation / kRad)};
}
void HeadingFilter::motion(const float gyro[3], const float accel[3], float dt) {
if (dt <= 0) return;
// "Up" is where the accelerometer points at rest, smoothed so a shake doesn't move it.
float norm = std::sqrt(accel[0] * accel[0] + accel[1] * accel[1] + accel[2] * accel[2]);
if (norm > 0.5f && norm < 1.5f) {
float k = any_ ? std::fmin(1.0f, dt / kUpSeconds) : 1.0f;
float length = 0;
for (int i = 0; i < 3; i++) {
up_[i] += k * (accel[i] / norm - up_[i]);
length += up_[i] * up_[i];
}
length = std::sqrt(length);
if (length > 0.01f)
for (float& u : up_) u /= length;
any_ = true;
}
float rate[3], turning = 0;
for (int i = 0; i < 3; i++) {
rate[i] = gyro[i] - bias_[i];
turning += rate[i] * rate[i];
}
if (std::sqrt(turning) < kStillDps) {
stillFor_ += dt;
if (stillFor_ >= kStillSeconds) { // lying still: what the gyroscope still reads is its offset
float k = std::fmin(1.0f, dt / kBiasSeconds);
for (int i = 0; i < 3; i++) {
bias_[i] += k * rate[i];
rate[i] = gyro[i] - bias_[i];
}
}
} else stillFor_ = 0;
if (!known()) return;
float aroundUp = rate[0] * up_[0] + rate[1] * up_[1] + rate[2] * up_[2];
heading_ = wrap360(heading_ + kTurnSign * aroundUp * dt);
held_ += dt;
}
void HeadingFilter::course(float courseDeg, float speedKmh) {
if (speedKmh < kMinSpeedKmh) return;
heading_ = known() ? wrap360(heading_ + kCourseGain * angleDiff(courseDeg, heading_)) : wrap360(courseDeg);
source_ = Source::Course;
held_ = 0;
}
void HeadingFilter::sun(float azimuthDeg) {
heading_ = wrap360(azimuthDeg);
source_ = Source::Sun;
held_ = 0;
}
void HeadingFilter::forget() {
source_ = Source::None;
held_ = 0;
stillFor_ = 0;
}
} // namespace roro::gnss
+72
View File
@@ -0,0 +1,72 @@
#pragma once
#include <cstdint>
#include <string>
namespace roro::gnss {
// Angles are degrees from true north, clockwise, 0 up to 360.
float wrap360(float degrees);
// How far to turn from `from` to `to`: -180 up to 180, positive clockwise.
float angleDiff(float to, float from);
// The initial great-circle bearing from the first point to the second.
double bearingDegrees(double lat1, double lon1, double lat2, double lon2);
// One of the 16 points: "N", "NNE", "NE"...
const char* cardinal(float degrees);
// "340 m", "1.2 km", "12 km".
std::string formatDistance(double meters);
// Where the sun is in the sky, from the time and the place (the low-precision formulas of the
// Astronomical Almanac: within a hundredth of a degree until 2050, far finer than a hand can aim).
struct SunPosition {
float azimuthDeg;
float elevationDeg; // above the horizon; refraction is left out
};
SunPosition sunPosition(int64_t utcSeconds, double latitude, double longitude);
// The Compass's heading (issue #16): where the top of the device points. There is no
// magnetometer, so north comes from outside (the course over ground while walking, or the sun
// when the user aims at it) and the gyroscope follows the turns in between. Host-tested.
class HeadingFilter {
public:
enum class Source : uint8_t { None, Course, Sun };
// Below a walking pace the receiver's course is noise.
static constexpr float kMinSpeedKmh = 3.0f;
// Each course reading pulls the heading this much of the way: a few seconds to settle,
// without jumping at every reading.
static constexpr float kCourseGain = 0.3f;
// The gyroscope's own offset is learnt while the device lies still: turning slower than
// this for kStillSeconds counts as still.
static constexpr float kStillDps = 2.0f;
static constexpr float kStillSeconds = 1.0f;
static constexpr float kBiasSeconds = 4.0f;
// How fast the measured "up" follows the accelerometer.
static constexpr float kUpSeconds = 0.3f;
// One reading of the IMU: degrees a second and g, on the sensor's axes, `dt` seconds after
// the one before. Only the turn around the vertical counts, however the device is held.
void motion(const float gyro[3], const float accel[3], float dt);
// The receiver's course over ground, once a second: used when moving fast enough.
void course(float courseDeg, float speedKmh);
// The top of the device is aimed at the sun, which is at this azimuth.
void sun(float azimuthDeg);
void forget(); // the device was not watched for a while: north has to come again
bool known() const { return source_ != Source::None; }
float heading() const { return heading_; }
Source source() const { return source_; }
// Seconds the gyroscope has carried the heading alone since north was last given.
float heldSeconds() const { return held_; }
// "Up" along the sensor's x and y axes, -1 to 1: both 0 when the device lies flat.
float tiltX() const { return up_[0]; }
float tiltY() const { return up_[1]; }
bool still() const { return stillFor_ >= kStillSeconds; }
private:
float heading_ = 0, held_ = 0, stillFor_ = 0;
float up_[3] = {0, 0, 1}, bias_[3] = {0, 0, 0};
bool any_ = false;
Source source_ = Source::None;
};
} // namespace roro::gnss
+1 -1
View File
@@ -6,7 +6,7 @@ namespace roro::gnss {
// "50.86920° N": five decimals, about a metre. // "50.86920° N": five decimals, about a metre.
std::string formatDecimal(double degrees, bool latitude); std::string formatDecimal(double degrees, bool latitude);
// "50° 52' 09.1\" N" (Settings → Coordinates, Q64). // "50° 52' 09.1\" N" (Settings → GNSS and radio → Coordinates, Q64).
std::string formatDms(double degrees, bool latitude); std::string formatDms(double degrees, bool latitude);
// The 6-character Maidenhead locator ("JO20ef"), as radio amateurs give their square. // The 6-character Maidenhead locator ("JO20ef"), as radio amateurs give their square.
std::string maidenhead(double latitude, double longitude); std::string maidenhead(double latitude, double longitude);
+102
View File
@@ -0,0 +1,102 @@
#include "places.h"
#include <cstdio>
#include <cstdlib>
namespace roro::gnss {
namespace {
struct Entity {
const char* text;
char ch;
};
constexpr Entity kEntities[] = {{"&amp;", '&'}, {"&lt;", '<'}, {"&gt;", '>'}, {"&quot;", '"'}, {"&apos;", '\''}};
std::string escaped(const std::string& s) {
std::string out;
for (char c : s) {
const char* entity = nullptr;
for (auto& e : kEntities)
if (e.ch == c) entity = e.text;
if (entity) out += entity;
else out += c;
}
return out;
}
std::string unescaped(const std::string& s) {
std::string out;
for (size_t i = 0; i < s.size();) {
bool found = false;
if (s[i] == '&')
for (auto& e : kEntities) {
std::string text = e.text;
if (s.compare(i, text.size(), text) == 0) {
out += e.ch;
i += text.size();
found = true;
break;
}
}
if (!found) out += s[i++];
}
return out;
}
// The value of ` name="..."` in a tag; false if it isn't there or isn't a number.
bool attribute(const std::string& tag, const char* name, double& value) {
std::string key = std::string(" ") + name + "=";
size_t at = tag.find(key);
if (at == std::string::npos || at + key.size() >= tag.size()) return false;
at += key.size();
char quote = tag[at];
if (quote != '"' && quote != '\'') return false;
size_t end = tag.find(quote, at + 1);
if (end == std::string::npos) return false;
std::string text = tag.substr(at + 1, end - at - 1);
char* rest = nullptr;
value = std::strtod(text.c_str(), &rest);
return !text.empty() && rest && *rest == 0;
}
} // namespace
std::vector<Place> parsePlaces(const std::string& gpx) {
std::vector<Place> places;
size_t at = 0;
while (places.size() < kMaxPlaces && (at = gpx.find("<wpt", at)) != std::string::npos) {
size_t tagEnd = gpx.find('>', at);
if (tagEnd == std::string::npos) break;
std::string tag = gpx.substr(at, tagEnd - at);
bool alone = !tag.empty() && tag.back() == '/'; // <wpt .../>: no name
size_t close = alone ? tagEnd : gpx.find("</wpt>", tagEnd);
if (close == std::string::npos) break;
Place p;
if (attribute(tag, "lat", p.latitude) && attribute(tag, "lon", p.longitude) && p.latitude >= -90 &&
p.latitude <= 90 && p.longitude >= -180 && p.longitude <= 180) {
size_t name = alone ? std::string::npos : gpx.find("<name>", tagEnd);
if (name != std::string::npos && name < close) {
size_t nameEnd = gpx.find("</name>", name);
if (nameEnd != std::string::npos && nameEnd < close) p.name = unescaped(gpx.substr(name + 6, nameEnd - name - 6));
}
if (p.name.empty()) p.name = "Place " + std::to_string(places.size() + 1);
places.push_back(p);
}
at = close + 1;
}
return places;
}
std::string placesGpx(const std::vector<Place>& places) {
std::string out = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<gpx version=\"1.1\" creator=\"roro9stack\" xmlns=\"http://www.topografix.com/GPX/1/1\">\n";
for (auto& p : places) {
char buf[80];
std::snprintf(buf, sizeof buf, "<wpt lat=\"%.7f\" lon=\"%.7f\"><name>", p.latitude, p.longitude);
out += buf + escaped(p.name) + "</name></wpt>\n";
}
return out + "</gpx>\n";
}
} // namespace roro::gnss
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <cstddef>
#include <string>
#include <vector>
namespace roro::gnss {
// A Place (issue #16): somewhere the user marked, to be led back to by the Compass.
struct Place {
std::string name;
double latitude = 0, longitude = 0;
};
constexpr const char* kPlacesPath = "/gnss/places.gpx";
constexpr size_t kMaxPlaces = 50;
constexpr size_t kPlaceNameBytes = 24;
// The Places are kept as GPX waypoints, so a map program opens the file, and a file made by one
// is read here: every <wpt> with a latitude and a longitude, named by its <name>.
std::vector<Place> parsePlaces(const std::string& gpx);
std::string placesGpx(const std::vector<Place>& places);
} // namespace roro::gnss
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include <cstdint>
#include <string>
namespace roro::release {
// Installing a release without being asked to (issue #52): once a newer one is known, wait for a
// quiet moment (nothing recording, nothing written, no key for a while), then say so on the
// screen for 30 seconds. Unless someone answers no, it is installed. Host-tested; the caller says
// what "quiet" is and does the installing.
class AutoUpdate {
public:
static constexpr uint32_t kIdleMs = 120000; // no key for this long: nobody is using the device
static constexpr uint32_t kAskMs = 30000; // how long the question stays before it answers itself
enum class Action : uint8_t {
None,
Ask, // the question goes up
Dismiss, // the question comes down, nothing is installed
Install, // the question comes down, install tag()
};
// A newer release is known. A second offer while one is waiting or asked replaces its tag.
void offer(const std::string& tag) {
tag_ = tag;
if (state_ == State::Idle) state_ = State::Waiting;
}
// The setting went off, or the release is no longer wanted.
void withdraw() { withdrawn_ = true; }
// The user's answer to the question.
void answer(bool install) {
if (state_ != State::Asking) return;
answered_ = true;
accepted_ = install;
}
// Call often. `busy`: something is recording or being written, and a restart would cut it.
// `idleMs`: how long since the last key.
Action step(uint32_t nowMs, bool busy, uint32_t idleMs) {
bool withdrawn = withdrawn_;
withdrawn_ = false;
switch (state_) {
case State::Idle: return Action::None;
case State::Waiting:
if (withdrawn) return state_ = State::Idle, Action::None;
if (busy || idleMs < kIdleMs) return Action::None;
state_ = State::Asking;
askedMs_ = nowMs;
answered_ = false;
return Action::Ask;
case State::Asking:
if (withdrawn) return state_ = State::Idle, Action::Dismiss;
if (answered_) return state_ = State::Idle, accepted_ ? Action::Install : Action::Dismiss;
// Something started meanwhile (a Track from a console): the question goes, and comes back later.
if (busy) return state_ = State::Waiting, Action::Dismiss;
if (nowMs - askedMs_ >= kAskMs) return state_ = State::Idle, Action::Install;
return Action::None;
}
return Action::None;
}
bool waiting() const { return state_ == State::Waiting; }
bool asking() const { return state_ == State::Asking; }
const std::string& tag() const { return tag_; }
// Whole seconds until the question answers itself, rounded up.
int secondsLeft(uint32_t nowMs) const {
uint32_t gone = nowMs - askedMs_;
return !asking() || gone >= kAskMs ? 0 : static_cast<int>((kAskMs - gone + 999) / 1000);
}
private:
enum class State : uint8_t { Idle, Waiting, Asking };
State state_ = State::Idle;
std::string tag_;
uint32_t askedMs_ = 0;
bool answered_ = false, accepted_ = false, withdrawn_ = false;
};
} // namespace roro::release
+11
View File
@@ -1,5 +1,7 @@
#include "http_head.h" #include "http_head.h"
#include <cstdio>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <cstdlib> #include <cstdlib>
@@ -58,6 +60,15 @@ void HttpHeadParser::line() {
else if (name == "transfer-encoding") head_.chunked = lower(value).find("chunked") != std::string::npos; else if (name == "transfer-encoding") head_.chunked = lower(value).find("chunked") != std::string::npos;
else if (name == "location") head_.location = value; else if (name == "location") head_.location = value;
else if (name == "content-type") head_.contentType = value; else if (name == "content-type") head_.contentType = value;
else if (name == "etag") head_.etag = value;
else if (name == "content-range") {
long from = -1, to = -1, total = -1;
if (std::sscanf(lower(value).c_str(), "bytes %ld-%ld/%ld", &from, &to, &total) == 3 && from >= 0 && to >= from && total > to) {
head_.rangeFrom = from;
head_.rangeTo = to;
head_.rangeTotal = total;
}
}
} }
size_t ChunkedDecoder::decode(const uint8_t* in, size_t len, uint8_t* out) { size_t ChunkedDecoder::decode(const uint8_t* in, size_t len, uint8_t* out) {
+3
View File
@@ -12,6 +12,9 @@ struct HttpHead {
long contentLength = -1; // -1: not given long contentLength = -1; // -1: not given
bool chunked = false; bool chunked = false;
std::string location, contentType; std::string location, contentType;
std::string etag; // as sent, quotes included; "" if none
// "Content-Range: bytes 1000-2999/3000", the answer to a range request; -1: not given.
long rangeFrom = -1, rangeTo = -1, rangeTotal = -1;
}; };
class HttpHeadParser { class HttpHeadParser {
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <string>
#include "http_head.h"
namespace roro::release {
// Carrying on a download that broke (issue #54): the connection is opened again with
// "Range: bytes=<from>-", and what was already received stays where it is. The Update File's own
// hash and signature are what decide whether the whole is good; these checks only keep the device
// from carrying on with something that plainly is not the rest of the same file.
// The validator to send as "If-Range", so a file that changed comes back whole (a 200) instead of
// in part: only a strong ETag may be used for that. "" for none.
inline std::string ifRangeFor(const std::string& etag) {
return etag.size() >= 2 && etag.front() == '"' && etag.back() == '"' ? etag : "";
}
// Is this answer the rest of the file, from byte `from` of `total`? "" if so, or why not, in
// words for the screen. `etag` is what the first answer gave ("" if nothing).
inline std::string resumeRefusal(const HttpHead& head, long from, long total, const std::string& etag) {
if (head.status == 200) return "The file changed on the server";
if (head.status != 206) return "The server answered " + std::to_string(head.status);
if (head.chunked || head.rangeFrom != from) return "The server sent another part of the file";
if (head.rangeTotal != total || head.rangeTo != total - 1 || head.contentLength != total - from) return "The file changed size on the server";
if (!etag.empty() && !head.etag.empty() && head.etag != etag) return "The file changed on the server";
return "";
}
// How long to keep trying. A break that came after new bytes is the link being poor, and is
// worth another try; breaks with nothing in between mean it is not coming back.
class ResumePolicy {
public:
static constexpr int kMaxResumes = 20; // in one download
static constexpr int kMaxStuck = 3; // in a row without a byte
// The connection broke with `received` bytes of the file here: try again?
bool again(long received) {
stuck_ = received > last_ ? 0 : stuck_ + 1;
last_ = received;
if (resumes_ >= kMaxResumes || stuck_ >= kMaxStuck) return false;
resumes_++;
return true;
}
int resumes() const { return resumes_; }
private:
long last_ = 0;
int resumes_ = 0, stuck_ = 0;
};
} // namespace roro::release
+6
View File
@@ -24,6 +24,12 @@ inline bool shouldAnnounce(const Release& latest, const std::string& running, co
return isNewer(latest, running) && latest.tag != failed && latest.tag != announced; return isNewer(latest, running) && latest.tag != failed && latest.tag != announced;
} }
// Should the background check offer to install it by itself (issue #52)? Any newer release, each
// day it is still the newest; never a version that rolled back on this device.
inline bool shouldInstall(const Release& latest, const std::string& running, const std::string& failed) {
return isNewer(latest, running) && latest.tag != failed;
}
// Is `url` a download this device takes from the project's server, for this repository? Whatever // Is `url` a download this device takes from the project's server, for this repository? Whatever
// the API says, the device only fetches from where it asked (a redirected or rewritten answer // the API says, the device only fetches from where it asked (a redirected or rewritten answer
// can't send it elsewhere). // can't send it elsewhere).
+4
View File
@@ -53,6 +53,10 @@ const Definition kDefinitions[] = {
{"ssh_key", Kind::String, 0, "", 0, 800}, {"ssh_key", Kind::String, 0, "", 0, 800},
{"ssh_public", Kind::String, 0, "", 0, 200}, {"ssh_public", Kind::String, 0, "", 0, 200},
{"ssh_font", Kind::Int, 1, nullptr, 0, 4}, {"ssh_font", Kind::Int, 1, nullptr, 0, 4},
{"launcher_list", Kind::Bool, 0, nullptr, 0, 1}, // off: the grid
{"theme", Kind::Int, 0, nullptr, 0, 6}, // roro9stack's own; ui::kThemeCount of them
{"theme_light", Kind::Bool, 0, nullptr, 0, 1},
{"auto_install", Kind::Bool, 0, nullptr, 0, 1}, // off: what runs here changes only when someone says so
}; };
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count), static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
"every Setting needs a definition"); "every Setting needs a definition");
+4
View File
@@ -41,6 +41,10 @@ enum class Setting : uint8_t {
SshKey, // string: this device's own SSH private key, as OpenSSH writes one: never shown SshKey, // string: this device's own SSH private key, as OpenSSH writes one: never shown
SshPublic, // string: its public half, the line to put in a server's authorized_keys SshPublic, // string: its public half, the line to put in a server's authorized_keys
SshFont, // int: the terminal's font, 0 (smallest) to 4 SshFont, // int: the terminal's font, 0 (smallest) to 4
LauncherList, // bool: the Launcher is a list of names instead of a grid of icons (issue #9)
Theme, // int: the colour theme, an index into ui::palette (issue #10)
ThemeLight, // bool: its light side instead of its dark one
InstallUpdates, // bool: a newer release found by the daily check is installed without being asked for (issue #52)
Count Count
}; };
+47
View File
@@ -0,0 +1,47 @@
#include "grid_model.h"
namespace roro {
void GridModel::setCount(int count) {
count_ = count < 0 ? 0 : count;
if (count_ == 0) {
selected_ = -1;
first_ = 0;
return;
}
select(selected_ < 0 ? 0 : selected_);
}
void GridModel::select(int index) {
if (count_ == 0) return;
selected_ = index < 0 ? 0 : index >= count_ ? count_ - 1 : index;
int row = selected_ / cols_;
if (row < first_) first_ = row;
if (row >= first_ + rows_) first_ = row - rows_ + 1;
int maxFirst = rows() > rows_ ? rows() - rows_ : 0;
if (first_ > maxFirst) first_ = maxFirst;
}
void GridModel::left() {
if (count_) select(selected_ == 0 ? count_ - 1 : selected_ - 1);
}
void GridModel::right() {
if (count_) select(selected_ == count_ - 1 ? 0 : selected_ + 1);
}
void GridModel::up() {
if (!count_) return;
int col = selected_ % cols_, row = selected_ / cols_;
row = row == 0 ? rows() - 1 : row - 1;
select(row * cols_ + col); // past the end of a short last row: the last item
}
void GridModel::down() {
if (!count_) return;
int col = selected_ % cols_, row = selected_ / cols_;
row = row == rows() - 1 ? 0 : row + 1;
select(row * cols_ + col);
}
} // namespace roro
+33
View File
@@ -0,0 +1,33 @@
#pragma once
namespace roro {
// Selection and scrolling for a grid of `cols` columns showing `visibleRows` rows at a time, filled
// row by row. Left and Right go through every item and wrap around; Up and Down stay in the
// column and wrap around, landing on the last item where the last row has no such column.
class GridModel {
public:
GridModel(int cols, int visibleRows) : cols_(cols), rows_(visibleRows) {}
void setCount(int count);
int count() const { return count_; }
int selected() const { return selected_; } // -1 when empty
int cols() const { return cols_; }
int rows() const { return count_ ? (count_ - 1) / cols_ + 1 : 0; }
int firstVisibleRow() const { return first_; }
int visibleRows() const { return rows_; }
void select(int index);
void left();
void right();
void up();
void down();
private:
int cols_, rows_;
int count_ = 0;
int selected_ = -1;
int first_ = 0;
};
} // namespace roro
+59
View File
@@ -0,0 +1,59 @@
#include "palette.h"
namespace roro::ui {
namespace {
// 0xRRGGBB to RGB565. Every colour below is one the RGB332 frame buffer holds exactly: three bits
// of red and of green (00 24 49 6d 92 b6 db ff) and two of blue (00 55 aa ff). The themes' own
// colours were rounded to those, then the ones that fell together or lost their contrast were
// chosen by hand (test_palette checks both).
constexpr uint16_t c(uint32_t rgb) {
return static_cast<uint16_t>(((rgb >> 19) & 0x1F) << 11 | ((rgb >> 10) & 0x3F) << 5 | ((rgb >> 3) & 0x1F));
}
const char* const kNames[kThemeCount] = {"roro9stack", "Catppuccin", "Dracula", "Nord", "ANSI terminal", "Gruvbox", "Solarized"};
// background, text, muted, faint, bar, barMuted, accent, onAccent, warning, message, good, red, yellow, violet
constexpr Palette kPalettes[kThemeCount][2] = {
{
{c(0x000000), c(0xffffff), c(0x9292aa), c(0x494955), c(0x494955), c(0xb6b6aa), c(0x00dbff), c(0x000055), c(0xff9200), c(0xff92ff), c(0x49db55), c(0xff2455), c(0xffdb00), c(0xb692ff)}, // roro9stack, dark
{c(0xffffff), c(0x000055), c(0x4949aa), c(0xb6dbff), c(0xb6dbff), c(0x4949aa), c(0x0092aa), c(0xffffff), c(0xb64900), c(0xb600aa), c(0x006d00), c(0xb60000), c(0x926d00), c(0x6d49ff)}, // roro9stack, light
},
{
{c(0x000000), c(0xdbdbff), c(0x9292ff), c(0x494955), c(0x242455), c(0xb6b6ff), c(0xdbb6ff), c(0x000000), c(0xffb655), c(0xff92ff), c(0x92ffaa), c(0xff6daa), c(0xffdb55), c(0x6db6ff)}, // Catppuccin, dark
{c(0xffffff), c(0x242455), c(0x6d6daa), c(0xb6b6aa), c(0xb6b6aa), c(0x494955), c(0x9249ff), c(0xffffff), c(0xdb6d00), c(0xdb6daa), c(0x499255), c(0xdb0055), c(0xdb9200), c(0x246dff)}, // Catppuccin, light
},
{
{c(0x242455), c(0xffffff), c(0x6d6daa), c(0x494955), c(0x494955), c(0xb6b6aa), c(0xb692ff), c(0x242455), c(0xffb655), c(0xff6daa), c(0x49ff55), c(0xff4955), c(0xffffaa), c(0x92dbff)}, // Dracula, dark
{c(0xffffff), c(0x242400), c(0x6d6d55), c(0xdbdbff), c(0xdbdbff), c(0x6d6d55), c(0x6d49aa), c(0xffffff), c(0x924900), c(0x922455), c(0x246d00), c(0xdb4900), c(0x926d00), c(0x006daa)}, // Dracula, light
},
{
{c(0x242455), c(0xdbffff), c(0x9292aa), c(0x494955), c(0x494955), c(0xdbdbff), c(0x92dbff), c(0x242455), c(0xdb9255), c(0xb692aa), c(0x92b6aa), c(0xb66d55), c(0xdbdbaa), c(0x9292aa)}, // Nord, dark
{c(0xdbffff), c(0x242455), c(0x494955), c(0xdbdbff), c(0xdbdbff), c(0x494955), c(0x496daa), c(0xdbffff), c(0xb64900), c(0x9249aa), c(0x496d00), c(0xb66d55), c(0xb66d00), c(0x9292aa)}, // Nord, light
},
{
{c(0x000000), c(0xb6b6aa), c(0x6d6d55), c(0x494955), c(0x0000aa), c(0xb6b6aa), c(0x49ff55), c(0x000000), c(0xffff55), c(0xff49ff), c(0x49ffff), c(0xff4955), c(0xffff55), c(0x4949ff)}, // ANSI terminal, dark
{c(0xffffff), c(0x000000), c(0x494955), c(0xb6b6aa), c(0xb6b6aa), c(0x494955), c(0x0000aa), c(0xffffff), c(0xb64900), c(0xb600aa), c(0x006d00), c(0xb60000), c(0xb64900), c(0x00b6aa)}, // ANSI terminal, light
},
{
{c(0x242400), c(0xdbdbaa), c(0x929255), c(0x492400), c(0x492400), c(0xdbb6aa), c(0xffb655), c(0x242400), c(0xff9200), c(0xdb92aa), c(0xb6b600), c(0xff4955), c(0xffb655), c(0x92b6aa)}, // Gruvbox, dark
{c(0xffffaa), c(0x494955), c(0x6d6d55), c(0xdbdbaa), c(0xdbdbaa), c(0x494955), c(0xb64900), c(0xffffaa), c(0xb66d00), c(0x924955), c(0x6d6d00), c(0x920000), c(0xb66d00), c(0x006d55)}, // Gruvbox, light
},
{
{c(0x002455), c(0xb6b6aa), c(0x6d92aa), c(0x004955), c(0x004955), c(0x9292aa), c(0x2492aa), c(0xffffff), c(0xdb4900), c(0xdb24aa), c(0x929200), c(0xdb2455), c(0xb69200), c(0x6d6daa)}, // Solarized, dark
{c(0xffffff), c(0x244955), c(0x9292aa), c(0xdbdbaa), c(0xdbdbaa), c(0x6d6daa), c(0x2492aa), c(0xffffff), c(0xdb4900), c(0xdb24aa), c(0x929200), c(0xdb2455), c(0xb69200), c(0x6d6daa)}, // Solarized, light
},
};
} // namespace
const char* themeName(int theme) { return kNames[theme < 0 || theme >= kThemeCount ? 0 : theme]; }
const Palette& palette(int theme, bool light) { return kPalettes[theme < 0 || theme >= kThemeCount ? 0 : theme][light ? 1 : 0]; }
uint32_t rgb888(uint16_t c) {
// As the display shows an RGB332 pixel: the bits kept, spread over 8.
uint32_t r = ((c >> 13) & 7) * 0x49 >> 1, g = ((c >> 8) & 7) * 0x49 >> 1, b = ((c >> 3) & 3) * 0x55;
return r << 16 | g << 8 | b;
}
} // namespace roro::ui
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <cstdint>
// The colour themes (issue #10): each a dark and a light set of the same roles, as RGB565.
namespace roro::ui {
struct Palette {
uint16_t background, text;
uint16_t muted; // secondary text
uint16_t faint; // grid lines, what stays behind
uint16_t bar; // the Status Bar's background
uint16_t barMuted; // what is idle, on the bar
uint16_t accent; // what is chosen, what is live: also the fill behind a selected row
uint16_t onAccent; // text on an accent, warning or message fill
uint16_t warning;
uint16_t message; // someone wrote
uint16_t good; // a Fix, the best of a set
uint16_t red, yellow, violet; // to tell things apart (the constellations of the Sky view)
};
constexpr int kThemeCount = 7;
const char* themeName(int theme); // "roro9stack", "Catppuccin", "Dracula", ...
const Palette& palette(int theme, bool light); // out of range: the first
// What the 8-bit frame buffer shows for a colour, as 0xRRGGBB.
uint32_t rgb888(uint16_t rgb565);
} // namespace roro::ui
+1 -1
View File
@@ -2,7 +2,7 @@
# Flash the firmware over USB, then open the serial monitor. # Flash the firmware over USB, then open the serial monitor.
# Usage: scripts/flash.sh [--debug] [port] USB (default: the first Espressif device found) # Usage: scripts/flash.sh [--debug] [port] USB (default: the first Espressif device found)
# scripts/flash.sh --ota [host] Wi-Fi: build, sign and push a Firmware Update # scripts/flash.sh --ota [host] Wi-Fi: build, sign and push a Firmware Update
# (host: the device's IP from Settings > Firmware, or $RORO_OTA_HOST) # (host: the device's IP from Settings > System > Firmware, or $RORO_OTA_HOST)
# --debug (USB only): once flashed, switches the Debug Console on and gives it the token in # --debug (USB only): once flashed, switches the Debug Console on and gives it the token in
# ~/.config/roro9stack/debug-token, over the serial port (ADR 0010, Q192), so scripts/rdbg.py works # ~/.config/roro9stack/debug-token, over the serial port (ADR 0010, Q192), so scripts/rdbg.py works
# at once. The settings survive later updates: it is needed once for a device, not at each flash. # at once. The settings survive later updates: it is needed once for a device, not at each flash.
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Turns the Launcher's icons (assets/icons/<App id>.png) into C arrays: src/ui/icons.h and icons.cpp.
An icon is 32 x 32 and one colour: a pixel is drawn where the picture is light and opaque, and left
alone where it is dark or transparent. Any image editor's PNG will do (grey, RGB, palette, with or
without alpha, 1 or 8 bits a channel, not interlaced). Run this after changing a picture:
python3 scripts/make_icons.py
CI runs it with --check, which fails when the arrays are not what the pictures give.
"""
import os
import struct
import sys
import zlib
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
SOURCE = os.path.join(ROOT, "assets", "icons")
SIZE = 32
def read_png(path):
"""The picture as rows of booleans: True where a pixel is drawn."""
data = open(path, "rb").read()
if data[:8] != b"\x89PNG\r\n\x1a\n":
raise ValueError("not a PNG")
at, idat, palette, alpha = 8, b"", None, None
while at < len(data):
(length,), kind = struct.unpack(">I", data[at:at + 4]), data[at + 4:at + 8]
body = data[at + 8:at + 8 + length]
at += 12 + length
if kind == b"IHDR":
width, height, depth, colour, _, _, interlace = struct.unpack(">IIBBBBB", body)
elif kind == b"PLTE":
palette = [body[i:i + 3] for i in range(0, len(body), 3)]
elif kind == b"tRNS":
alpha = body
elif kind == b"IDAT":
idat += body
if interlace or depth not in (1, 8) or (depth == 1 and colour not in (0, 3)):
raise ValueError("save it as a plain 8-bit or 1-bit PNG, not interlaced")
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[colour]
step = channels if depth == 8 else 1 # bytes a pixel, for the filters
stride = (width * channels * depth + 7) // 8
raw, rows, before = zlib.decompress(idat), [], bytes(stride)
for y in range(height):
kind, line = raw[y * (stride + 1)], bytearray(raw[y * (stride + 1) + 1:(y + 1) * (stride + 1)])
for i in range(stride):
a = line[i - step] if i >= step else 0
b, c = before[i], before[i - step] if i >= step else 0
if kind == 1:
line[i] = (line[i] + a) & 255
elif kind == 2:
line[i] = (line[i] + b) & 255
elif kind == 3:
line[i] = (line[i] + (a + b) // 2) & 255
elif kind == 4:
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
line[i] = (line[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255
before = bytes(line)
row = []
for x in range(width):
if depth == 1:
v = (line[x // 8] >> (7 - x % 8)) & 1
px = [v * 255] if colour == 0 else [v]
else:
px = line[x * channels:(x + 1) * channels]
if colour == 3:
opaque = alpha is None or px[0] >= len(alpha) or alpha[px[0]] > 127
light = sum(palette[px[0]]) > 381
elif colour in (0, 4):
opaque, light = colour == 0 or px[1] > 127, px[0] > 127
else:
opaque, light = colour == 2 or px[3] > 127, sum(px[:3]) > 381
row.append(opaque and light)
rows.append(row)
return rows
def main():
names = sorted(f[:-4] for f in os.listdir(SOURCE) if f.endswith(".png"))
header = ["// Made by scripts/make_icons.py from assets/icons/*.png: change the pictures, not this file.", "#pragma once", "",
"#include <cstdint>", "", "namespace roro::icons {", "",
"// One bit a pixel, the leftmost first, a row after another: what Canvas::drawBitmap takes.",
f"constexpr int kSize = {SIZE};", ""]
body = ["// Made by scripts/make_icons.py from assets/icons/*.png: change the pictures, not this file.", '#include "icons.h"', "",
"namespace roro::icons {", ""]
for name in names:
rows = read_png(os.path.join(SOURCE, name + ".png"))
if len(rows) != SIZE or len(rows[0]) != SIZE:
sys.exit(f"{name}.png is {len(rows[0])} x {len(rows)}: an icon is {SIZE} x {SIZE}")
ident = name.replace("-", "_")
header.append(f"extern const uint8_t {ident}[{SIZE * SIZE // 8}]; // {name}.png")
body.append(f"const uint8_t {ident}[{SIZE * SIZE // 8}] = {{")
for row in rows:
packed = [sum(0x80 >> i for i in range(8) if row[b * 8 + i]) for b in range(SIZE // 8)]
body.append(" " + ", ".join(f"0x{v:02x}" for v in packed) + ", // " + "".join("#" if v else "." for v in row))
body += ["};", ""]
header += ["", "} // namespace roro::icons", ""]
body += ["} // namespace roro::icons", ""]
out = {os.path.join(ROOT, "src", "ui", "icons.h"): "\n".join(header), os.path.join(ROOT, "src", "ui", "icons.cpp"): "\n".join(body)}
if "--check" in sys.argv:
stale = [os.path.relpath(p, ROOT) for p, text in out.items() if not os.path.exists(p) or open(p).read() != text]
if stale:
sys.exit("out of date: " + ", ".join(stale) + " (run scripts/make_icons.py)")
print(f"{len(names)} icons, up to date")
return
for path, text in out.items():
open(path, "w").write(text)
print(f"{len(names)} icons written")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -2,7 +2,7 @@
"""Pushes a signed Update File to a Cardputer over Wi-Fi (TCP 3232) and reports the result. """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> Usage: scripts/ota_push.py <file.ota> <host>
<host> is the device's IP (shown in Settings > Firmware). <host> is the device's IP (shown in Settings > System > Firmware).
""" """
import socket import socket
import sys import sys
+7 -7
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""The Debug Console, over Wi-Fi (TCP 2323, see ADR 0010). Switch it on first: Settings > Debug Console. """The Debug Console, over Wi-Fi (TCP 2323, see ADR 0010). Switch it on first: Settings > System > Debug Console.
Usage: scripts/rdbg.py [-H host] [-t token] [-b] [command ...] Usage: scripts/rdbg.py [-H host] [-t token] [-b] [command ...]
no command interactive: type commands, see every console line live; Ctrl-D or `quit` leaves no command interactive: type commands, see every console line live; Ctrl-D or `quit` leaves
command runs it and prints what follows, until the console has been quiet for a moment command runs it and prints what follows, until the console has been quiet for a moment
-H host the device's IP (Settings > Debug Console), default $RORO_OTA_HOST -H host the device's IP (Settings > System > Debug Console), default $RORO_OTA_HOST
-t token the device's token (also --token), as Settings > Debug Console shows it: dashes and -t token the device's token (also --token), as Settings > System > Debug Console shows it: dashes and
case don't matter. Otherwise $RORO_DEBUG_TOKEN, otherwise ~/.config/roro9stack/debug-token case don't matter. Otherwise $RORO_DEBUG_TOKEN, otherwise ~/.config/roro9stack/debug-token
-b also print the backlog the device sends on connecting (boot messages and so on) -b also print the backlog the device sends on connecting (boot messages and so on)
@@ -55,7 +55,7 @@ def find_token(given):
token = tidy_token(token or "") token = tidy_token(token or "")
if not token: if not token:
sys.exit("No token: pass -t <token>, set RORO_DEBUG_TOKEN, or put it in " + TOKEN_FILE + sys.exit("No token: pass -t <token>, set RORO_DEBUG_TOKEN, or put it in " + TOKEN_FILE +
"\n(the device shows it in Settings > Debug Console)") "\n(the device shows it in Settings > System > Debug Console)")
return token return token
@@ -63,7 +63,7 @@ def log_in(sock, token):
"""Answers the device's challenge; returns the banner line, or exits saying what went wrong.""" """Answers the device's challenge; returns the banner line, or exits saying what went wrong."""
first = read_until(sock, b"\n", 10) first = read_until(sock, b"\n", 10)
if first is None: if first is None:
sys.exit("device: nothing came back. Is the console switched on (Settings > Debug Console)?") sys.exit("device: nothing came back. Is the console switched on (Settings > System > Debug Console)?")
line = first.decode(errors="replace").strip() line = first.decode(errors="replace").strip()
if line.startswith("locked"): if line.startswith("locked"):
sys.exit("device: closed for a minute after too many wrong tokens") sys.exit("device: closed for a minute after too many wrong tokens")
@@ -75,7 +75,7 @@ def log_in(sock, token):
if banner is None: if banner is None:
sys.exit("device: no answer") sys.exit("device: no answer")
if banner.startswith(b"denied"): if banner.startswith(b"denied"):
sys.exit("device: wrong token (Settings > Debug Console shows the right one)") sys.exit("device: wrong token (Settings > System > Debug Console shows the right one)")
return banner return banner
@@ -326,7 +326,7 @@ def main():
try: try:
sock = socket.create_connection((host, PORT), timeout=10) sock = socket.create_connection((host, PORT), timeout=10)
except OSError as e: except OSError as e:
sys.exit(f"device: can't connect to {host}:{PORT} ({e}). Is the console switched on (Settings > Debug Console)?") sys.exit(f"device: can't connect to {host}:{PORT} ({e}). Is the console switched on (Settings > System > Debug Console)?")
with sock: with sock:
banner = log_in(sock, token) banner = log_in(sock, token)
show = sys.stdout if backlog or not args else None show = sys.stdout if backlog or not args else None
+1 -1
View File
@@ -47,7 +47,7 @@ PREVIOUS="$(git -C "$SRC" describe --tags --abbrev=0 "$VERSION^" 2>/dev/null ||
echo echo
echo "## Files" echo "## Files"
echo echo
echo "- \`$NAME.ota\`: the signed Update File. Copy it to \`/updates\` on the SD card and install it from Settings > Firmware or the Storage App, or push it over Wi-Fi with \`scripts/ota_push.py\`." echo "- \`$NAME.ota\`: the signed Update File. Copy it to \`/updates\` on the SD card and install it from Settings > System > Firmware or the Storage App, or push it over Wi-Fi with \`scripts/ota_push.py\`."
echo "- \`$NAME-factory.bin\`: the whole flash image, for a first install over USB at offset 0." echo "- \`$NAME-factory.bin\`: the whole flash image, for a first install over USB at offset 0."
echo "- \`$NAME.elf.gz\`: the symbols, to decode a crash report from this build." echo "- \`$NAME.elf.gz\`: the symbols, to decode a crash report from this build."
echo "- \`SHA256SUMS\`: checksums of the three." echo "- \`SHA256SUMS\`: checksums of the three."
+1 -1
View File
@@ -2,7 +2,7 @@
"""Copies a file to the Cardputer's SD card over the USB serial console (the `sd put` command). """Copies a file to the Cardputer's SD card over the USB serial console (the `sd put` command).
Usage: scripts/sd_put.sh <file> [card path] Usage: scripts/sd_put.sh <file> [card path]
The card path defaults to /updates/<file name>, where Settings > Firmware finds Update Files. The card path defaults to /updates/<file name>, where Settings > System > Firmware finds Update Files.
The device acknowledges each chunk once it's on the card, checks the SHA-256 of the whole file, The device acknowledges each chunk once it's on the card, checks the SHA-256 of the whole file,
and only then renames <card path>.part to <card path>. and only then renames <card path>.part to <card path>.
""" """
+2
View File
@@ -20,6 +20,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`. This runs the host-side unit tests (`test/`, `native` environment), then builds the firmware. The output is `.pio/build/cardputer-adv/firmware.factory.bin`.
`scripts/make_icons.py` turns the Launcher's icons (`assets/icons/<App id>.png`, 32 × 32, one colour) into the arrays in `src/ui/icons.cpp`; run it after changing a picture. CI fails when the two don't match.
`scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure. `scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure.
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 6 minutes; later builds take about 15 seconds when little has changed. The platform knows the framework is already rebuilt by `sdkconfig.defaults` in the project folder, which it writes and git ignores: delete it and the next build rebuilds the framework. CI keeps that file, a build cache and ccache in its volume (docs/milestones/R1.md). 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 6 minutes; later builds take about 15 seconds when little has changed. The platform knows the framework is already rebuilt by `sdkconfig.defaults` in the project folder, which it writes and git ignores: delete it and the next build rebuilds the framework. CI keeps that file, a build cache and ccache in its volume (docs/milestones/R1.md).
+6 -5
View File
@@ -36,19 +36,20 @@ scripts/ota_keygen.sh # once: creates the signing key (see ADR
scripts/flash.sh --ota 10.39.39.12 # build, sign and push; or set RORO_OTA_HOST scripts/flash.sh --ota 10.39.39.12 # build, sign and push; or set RORO_OTA_HOST
``` ```
The device shows the push address in **Settings → Firmware**. It installs a correctly signed update right away, restarts (waiting up to 60 s if you're typing), and runs the new firmware on **Probation**. If the new firmware crashes, or can't reconnect Wi-Fi within 3 minutes, it rolls back to the previous one and says so. The device shows the push address in **Settings → System → Firmware**. It installs a correctly signed update right away, restarts (waiting up to 60 s if you're typing), and runs the new firmware on **Probation**. If the new firmware crashes, or can't reconnect Wi-Fi within 3 minutes, it rolls back to the previous one and says so.
To install from the SD card instead, copy the `.ota` file from `.pio/build/cardputer-adv/` into `/updates` on the card, then use **Settings → Firmware**. With the Cardputer on USB, the card can stay in: `scripts/sd_put.sh <file.ota>` sends it over the serial console into `/updates` (about 30 s for 1.6 MB, checked with SHA-256 before it's renamed into place; `SD_PUT_DEBUG=1` shows the console while it runs). To install from the SD card instead, copy the `.ota` file from `.pio/build/cardputer-adv/` into `/updates` on the card, then use **Settings → System → Firmware**. With the Cardputer on USB, the card can stay in: `scripts/sd_put.sh <file.ota>` sends it over the serial console into `/updates` (about 30 s for 1.6 MB, checked with SHA-256 before it's renamed into place; `SD_PUT_DEBUG=1` shows the console while it runs).
**The private key** lives in `~/.config/roro9stack/ota-key.pem` and must never be committed. If it's lost, generate a new pair and flash once over USB. (CI signs releases with a copy kept as a repository secret, ADR 0008.) **The private key** lives in `~/.config/roro9stack/ota-key.pem` and must never be committed. If it's lost, generate a new pair and flash once over USB. (CI signs releases with a copy kept as a repository secret, ADR 0008.)
### Updates from Gitea ### Updates from Gitea
With no PC and no card, the device can install the project's releases itself (docs/milestones/R1.md). In **Settings → Firmware**: With no PC and no card, the device can install the project's releases itself (docs/milestones/R1.md). In **Settings → System → Firmware**:
- **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update. - **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update. A download that breaks is carried on from where it was (issue #54): the device waits up to a minute for Wi-Fi, asks for the rest of the file, and gives up only after three tries in a row that bring nothing.
- **Older releases** lists the last ten, newest first. Opening an older one offers to go back to it, with a different question. - **Older releases** lists the last ten, newest first. Opening an older one offers to go back to it, with a different question.
- **Settings → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device. - **Settings → System → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device.
- **Settings → System → Install updates** (`When asked` by default; `By itself`, issue #52): a newer release found by the daily check is installed without being asked for. The device waits until no key was pressed for 2 minutes and nothing would be cut by a restart (a Track, a Capture, a copy, an upload, an SSH session, a web share), then asks on the screen for 30 seconds, lighting it if it was off: Cancel leaves it until the next day's check; Accept, or no answer, installs it and restarts. What was installed and when, and what was undone, is written in `/system/updates.log` on the card.
The connection is checked against the two ISRG roots Let's Encrypt chains end in (ADR 0009), not the usual bundle of about 130 authorities. Whatever the connection, the Update File's own signature is what decides what gets installed. The connection is checked against the two ISRG roots Let's Encrypt chains end in (ADR 0009), not the usual bundle of about 130 authorities. Whatever the connection, the Update File's own signature is what decides what gets installed.
@@ -34,8 +34,8 @@ scripts/flash.sh --ota 10.39.39.12 # builds, signs and pushe
{{ diagram(src="ways-in.svg", min_width=580, caption="Every path ends at the same Update Service and the same signature check.") }} {{ diagram(src="ways-in.svg", min_width=580, caption="Every path ends at the same Update Service and the same signature check.") }}
1. **Push over Wi-Fi** to TCP 3232: `scripts/flash.sh --ota`. The device always listens while Wi-Fi is connected. 1. **Push over Wi-Fi** to TCP 3232: `scripts/flash.sh --ota`. The device always listens while Wi-Fi is connected.
2. **From the SD card**: a `.ota` in `/updates`, installed from Settings → Firmware, from the Storage App, or with the `install <path>` command. Put it there by hand, with `scripts/rdbg.py put` over Wi-Fi, or with `scripts/sd_put.sh` over USB. 2. **From the SD card**: a `.ota` in `/updates`, installed from Settings → System → Firmware, from the Storage App, or with the `install <path>` command. Put it there by hand, with `scripts/rdbg.py put` over Wi-Fi, or with `scripts/sd_put.sh` over USB.
3. **From the project's releases** on Gitea, which the device downloads itself over TLS (Settings → Firmware, or `update check` / `update install <tag>`): see [Flash and update](/dev/build/flash/). 3. **From the project's releases** on Gitea, which the device downloads itself over TLS (Settings → System → Firmware, or `update check` / `update install <tag>`): see [Flash and update](/dev/build/flash/).
All three feed the same parser: the **header and signature are checked first**, the image is written to the **other app slot** while it is hashed, and the slot becomes the next boot **only if the hash matches**. Anything wrong ends in a Toast and **nothing changes**. A downgrade is allowed, and shows "older than the installed version". All three feed the same parser: the **header and signature are checked first**, the image is written to the **other app slot** while it is hashed, and the slot becomes the next boot **only if the hash matches**. Anything wrong ends in a Toast and **nothing changes**. A downgrade is allowed, and shows "older than the installed version".
@@ -21,7 +21,7 @@
<rect class="wi-panel" x="288" y="176" width="200" height="88" rx="8"/> <rect class="wi-panel" x="288" y="176" width="200" height="88" rx="8"/>
<text x="302" y="200" font-size="12" font-weight="600">SD card</text> <text x="302" y="200" font-size="12" font-weight="600">SD card</text>
<text x="302" y="219" font-size="11">/updates/*.ota</text> <text x="302" y="219" font-size="11">/updates/*.ota</text>
<text x="302" y="238" font-size="11" class="wi-dim">Settings → Firmware,</text> <text x="302" y="238" font-size="11" class="wi-dim">Settings → System → Firmware,</text>
<text x="302" y="254" font-size="11" class="wi-dim">or install &lt;path&gt;</text> <text x="302" y="254" font-size="11" class="wi-dim">or install &lt;path&gt;</text>
<rect class="wi-hot" x="536" y="32" width="208" height="158" rx="8"/> <rect class="wi-hot" x="536" y="32" width="208" height="158" rx="8"/>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

+8 -5
View File
@@ -26,6 +26,7 @@ lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)
lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100) lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100)
lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN) lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)
gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB) gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)
imu what the motion sensor reads: g on its three axes, then degrees a second
gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum> gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>
crash the last crash: firmware, reason, task, backtrace crash the last crash: firmware, reason, task, backtrace
coredump erase forget the core dump in flash coredump erase forget the core dump in flash
@@ -37,7 +38,7 @@ gemini get <url> fetch a Gemini page and report header, size, certificate, heap
irc start | irc stop | irc dump | irc say <buffer> <text> irc start | irc stop | irc dump | irc say <buffer> <text>
install <path.ota> Update from SD install <path.ota> Update from SD
update check | update list | update status | update install <tag> the project's releases on Gitea update check | update list | update status | update install <tag> the project's releases on Gitea
sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal sd card | sd list | cat <path> | log <text> | burst | sound on|off | theme [0-6] [light|dark] | short | normal
Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command
ping <host> [count] [size] | nslookup <name> [server] | port <host> <port> | traceroute <host> | cancel is it there, does its name resolve, is its port open, which way; one at a time ping <host> [count] [size] | nslookup <name> [server] | port <host> <port> | traceroute <host> | cancel is it there, does its name resolve, is its port open, which way; one at a time
tls <host> [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one tls <host> [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one
@@ -45,8 +46,8 @@ ifconfig | arp | netstat the interfaces (Wi-Fi and the VPN), their addresses,
uname the firmware, its version and the chip it is built for uname the firmware, its version and the chip it is built for
scp [-f] [-P port] <file on the card> user@host:path | scp [-f] [-P port] user@host:path <file on the card> one file over SSH, with this device's key or, in the Shell, a password, to a server the SSH App already trusts; -f replaces a file on the card; `cancel` stops it scp [-f] [-P port] <file on the card> user@host:path | scp [-f] [-P port] user@host:path <file on the card> one file over SSH, with this device's key or, in the Shell, a password, to a server the SSH App already trusts; -f replaces a file on the card; `cancel` stops it
ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here
vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > Network > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself
debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > System > Debug Console); with seconds, it comes back
debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token
crash abort|wdt crash on purpose (to test crash reports and Safe Mode) crash abort|wdt crash on purpose (to test crash reports and Safe Mode)
wifi ip ... try <seconds> | wifi ip keep a trial IP setting: back to the previous one unless kept wifi ip ... try <seconds> | wifi ip keep a trial IP setting: back to the previous one unless kept
@@ -71,6 +72,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| `burst` | Publishes 5 Notifications at once | | `burst` | Publishes 5 Notifications at once |
| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help\|shot`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing; `shot` is Fn+p: a screenshot). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` | | `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help\|shot`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing; `shot` is Fn+p: a screenshot). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` |
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
| `theme` / `theme <0-6> [light\|dark]` | Lists the colour themes, or picks one and its side |
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) | | `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
| `wifi ip <ssid> dhcp` / `wifi ip <ssid> <address>/<prefix> [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Add `try <seconds>` to go back to the previous setting unless `wifi ip keep` follows | | `wifi ip <ssid> dhcp` / `wifi ip <ssid> <address>/<prefix> [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Add `try <seconds>` to go back to the previous setting unless `wifi ip keep` follows |
@@ -96,9 +98,9 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path | | `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path |
| `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead | | `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead |
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete | | `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
| `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does | | `install <path>` | Update from SD with that `.ota` file, as Settings → System → Firmware does |
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one | | `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again | | `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, break each connection of the next download after n bytes (it is carried on, issue #54) or damage one byte of it, run the daily check again |
| `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor | | `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor |
| `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack | | `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack |
| `lora rx on` / `lora rx off` | Listens and prints each packet on the console | | `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
@@ -106,6 +108,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| `lora capture start` / `lora capture stop` | A LoRa Capture, as `c` in the App | | `lora capture start` / `lora capture stop` | A LoRa Capture, as `c` in the App |
| `lora sweep on [from MHz] [to MHz] [step kHz]` / `lora sweep off` / `lora sweep dump` | Sweep a band (863 870 100 by default), with a summary every 2 s (floor, strongest, peaks), or print the latest pass | | `lora sweep on [from MHz] [to MHz] [step kHz]` / `lora sweep off` / `lora sweep dump` | Sweep a band (863 870 100 by default), with a summary every 2 s (floor, strongest, peaks), or print the latest pass |
| `gnss quiet on` / `gnss quiet off` | The "Pause GNSS for LoRa" setting | | `gnss quiet on` / `gnss quiet off` | The "Pause GNSS for LoRa" setting |
| `imu` | What the motion sensor reads: g on its three axes, then degrees a second (the Compass, issue #16) |
| `gnss status` / `gnss restart` | The receiver's state, and a restart of it | | `gnss status` / `gnss restart` | The receiver's state, and a restart of it |
| `gnss track start` / `gnss track stop` | A Track, as `r` in the GNSS App (the reason is printed if it can't start) | | `gnss track start` / `gnss track stop` | A Track, as `r` in the GNSS App (the reason is printed if it can't start) |
| `gnss nmea on` / `gnss nmea off` | Prints each NMEA sentence the receiver sends, as `nmea: …` | | `gnss nmea on` / `gnss nmea off` | Prints each NMEA sentence the receiver sends, as `nmea: …` |
+3 -2
View File
@@ -83,7 +83,7 @@ An update that goes wrong is the case you most want to rehearse, and the firmwar
update status # what the device runs, what failed here before, the daily check, heap update status # what the device runs, what failed here before, the daily check, heap
update check | update list # look at the server: the latest release, or the last ten update check | update list # look at the server: the latest release, or the last ten
update pretend v0.9.0 # take the running version to be v0.9.0: the latest release now counts as "new" update pretend v0.9.0 # take the running version to be v0.9.0: the latest release now counts as "new"
update damage cut 50000 # the next download is cut after 50000 bytes update damage cut 800000 # each connection of the next download breaks after 800000 bytes
update damage flip 100000 # ...or has the byte at offset 100000 damaged update damage flip 100000 # ...or has the byte at offset 100000 damaged
update install v0.11.0 # try the install update install v0.11.0 # try the install
update probe git.twis.la # is that server's certificate accepted? (the two ISRG roots only) update probe git.twis.la # is that server's certificate accepted? (the two ISRG roots only)
@@ -91,7 +91,8 @@ update daily # forget today's daily check: it runs again
update pretend off # back to the real version update pretend off # back to the real version
``` ```
- **A damaged download must be refused cleanly:** the Update Service checks the signature after the first 160 bytes and the image hash at the end, so a cut or a flipped byte must end in a refusal with **nothing switched**. Check `info` afterwards: both slots, and `update: confirmed`. - **A download that breaks is carried on:** with `damage cut 800000` a 2.5 MB release comes in four connections, each asking for the rest of the file, and installs; `update status` then says how often it was carried on. With `damage cut 0` no connection brings anything, and it gives up after three tries.
- **A damaged download must be refused cleanly:** the Update Service checks the signature after the first 160 bytes and the image hash at the end, so a download that gave up or a flipped byte must end in a refusal with **nothing switched**. Check `info` afterwards: both slots, and `update: confirmed`.
- **An undamaged install really installs** the release, into the other slot, and the device restarts into it. Your build stays in the slot it was in until the next update overwrites it, and the console's setting and token are untouched: the release has the console too. - **An undamaged install really installs** the release, into the other slot, and the device restarts into it. Your build stays in the slot it was in until the next update overwrites it, and the console's setting and token are untouched: the release has the console too.
- The server is read by the device itself, with Wi-Fi up; the download is one TLS connection, about 52 KB of heap at its peak, so IRC steps aside (see [the memory limit](/howto/not-enough-memory/)). `status: heap` in the stream shows it happen. - The server is read by the device itself, with Wi-Fi up; the download is one TLS connection, about 52 KB of heap at its peak, so IRC steps aside (see [the memory limit](/howto/not-enough-memory/)). `status: heap` in the stream shows it happen.
+1 -1
View File
@@ -16,7 +16,7 @@ Before version 0.12 this was a separate *Debug Build* with a token compiled in f
## On the device ## On the device
**Settings → Debug Console:** **Settings → System → Debug Console:**
| Row | Does | | Row | Does |
|---|---| |---|---|
+47
View File
@@ -0,0 +1,47 @@
+++
title = "On-board hardware"
description = "The hardware the device already carries and nothing used yet (the motion sensor, the infrared LED, the microphone and speaker) each gets something useful to do."
weight = 110
[extra]
docs = true
source = "docs/milestones/H1.md"
tag = "H1"
+++
**Status:** in progress. Built, not released yet: the Compass (issue #16). Not started: the IR Remote (#15), the Audio App (#14).
**Goal:** the hardware the device already carries and nothing used yet (the motion sensor, the infrared LED, the microphone and speaker) each gets something useful to do.
## The Compass (issue #16)
The device has a GNSS receiver and a motion sensor (a BMI270: an accelerometer and a gyroscope) but **no magnetometer**: standing still, nothing in it knows where north is. The receiver gives the direction of travel while walking; the gyroscope measures turns, but not from where.
### Decisions (design round 2026-10-10)
| # | Decision |
|---|---|
| Q267 | **A third view of the GNSS App,** after Position and Sky, not an App of its own: Tab goes round the three. |
| Q268 | **North comes from outside, and the gyroscope holds it.** Walking at 3 km/h or more, each course the receiver reports pulls the heading 30 % of the way to it. Standing, the gyroscope follows the turns, and the screen says how long ago north was last given. |
| Q269 | **The sun gives north too:** `s` with the top of the device aimed at the sun sets the heading to the sun's azimuth, worked out from the time and the position. The sun is drawn on the rose, as something to check north against. |
| Q270 | **The heading is where the top of the device points.** The turn that counts is the one around the vertical, which the accelerometer gives: it works however the device is held. Walking, the device is taken to point the way you walk. |
| Q271 | **Places:** `m` marks where you stand, under a name; `t` picks one as the target. The rose then carries an arrowhead to it, with its distance and bearing. Targets from Tracks, typed coordinates or Gemini links are for later. |
| Q272 | **Places are GPX waypoints** in `/gnss/places.gpx` on the SD card, 50 at most: a map program opens the file, and waypoints made by one are read here. The whole file is written at each change. |
| Q273 | **A level** from the accelerometer, next to the readouts: the heading is only as good as the device is flat when aiming at the sun. |
| Q274 | **Not now:** a magnetometer on the Grove port (and so declination), a faster GNSS rate, sunrise and sunset, an estimated time of arrival. |
### As built
- **`lib/gnss/src/compass.{h,cpp}`**, host-tested (13 tests): `HeadingFilter` (the turn around "up" from the gyroscope, the course and the sun as references, the gyroscope's offset learnt while the device lies still), `sunPosition` (the Astronomical Almanac's low-precision formulas), `bearingDegrees`, `cardinal`, `formatDistance`.
- **`lib/gnss/src/places.{h,cpp}`**, host-tested (5 tests): the Places as GPX waypoints, read and written.
- **The GNSS App** reads the sensor 100 times a second while it is in front, in any of its views, and forgets north when it is left: the turns made meanwhile were not followed. The Compass is drawn again when the heading moves by a degree or the level moves, at most 20 times a second.
- **`imu`** on the consoles prints what the sensor reads.
### Checked on the device (2026-10-11)
- The sensor answers as a BMI270 through M5Unified, and reads about 0.5 degrees a second on one axis lying still.
- Turning the device clockwise makes the heading go up, and the level's bubble goes to the edge that is lifted: the sensor's axes are as the code took them.
- Not checked yet: north from the course while walking, north from the sun against a real compass, and how far the heading drifts in the hand.
### What the sensor's offset costs
A gyroscope reads a little even when nothing turns (up to about 0.5 degrees a second for this one), which would turn the rose by itself. The filter takes what the sensor reads while it has been still for a second (under 2 degrees a second on all axes) as that offset. In the hand the device is rarely that still: lay it down for a few seconds first. After 3 minutes without north the screen shows the age of the heading in the warning colour.
+64 -1
View File
@@ -8,7 +8,7 @@ docs = true
source = "docs/milestones/R1.md" source = "docs/milestones/R1.md"
tag = "R1" tag = "R1"
+++ +++
**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Not started: the Issues App (#4), automatic installs (#52), release channels (#53), resuming a download (#54). **Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Built, not released yet: installing a release by itself (#52). Built, not released yet: carrying on a download that broke (#54). Not started: the Issues App (#4), release channels (#53).
**Goal:** a tag is a release, built the same way every time and published where a device can find it. **Goal:** a tag is a release, built the same way every time and published where a device can find it.
@@ -132,6 +132,69 @@ The device looks at the project's Gitea for a newer release, says so, and instal
**Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure. **Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure.
## Installing a release by itself (issue #52)
For a device that sits on a desk, "always on the latest" may be what is wanted. Until now the daily check only said that a release was out.
### Decisions (2026-10-11)
| # | Decision |
|---|---|
| Q275 | **A setting, off by default:** Settings > System > Install updates, `When asked` or `By itself`. It changes what runs on the device without anyone looking, so it is never on unless the owner said so. |
| Q276 | **The owner is asked, and silence is yes:** a question over whatever is open, `Cancel` or `Accept`, that answers itself after 30 seconds by installing. The screen lights up for it if it was off. (The owner's own answer on the issue.) |
| Q277 | **The question comes before the download,** not before the restart: the download already takes the screen over and may take IRC offline for its memory, so that is the moment to ask. |
| Q278 | **Only in a quiet moment:** no key for 2 minutes, and nothing a restart would cut: a Track, a Capture, a file operation, an upload, an scp transfer, an SSH session, a web share, a noise test, another update. If one of them starts while the question is up, the question goes and comes back later with a full count. |
| Q279 | **Any newer release,** not only patch releases, and with no delay after it is published: Probation and Rollback are what protect against a bad one, and a version that rolled back here is never offered again. |
| Q280 | **Cancel lasts a day:** the release is offered again at the next daily check, for as long as it is the newest. |
| Q281 | **A record:** each update that held (and whether it installed by itself), and each one that was undone, is a dated line in `/system/updates.log` on the card. |
### As built
- **`lib/release/src/auto_update.h`**, host-tested (10 tests): the waiting, the question and its count, as a state machine that is told what "busy" and "idle" are.
- **The Update Service** offers the release at each background check when the setting is on (`shouldInstall`), and writes the record when Probation confirms a version or a rollback is reported.
- **The main loop** decides what busy is, puts the question up through `Screen::render`, and gives it every key while it is up.
- `update status` says whether a release is waiting for its quiet moment.
### Checked on the device (2026-10-11)
With the setting on, `update pretend v0.22.0` and `update daily`:
- The question came up over the Settings App, counting down each second. **Cancel** took it down and nothing was installed.
- Left unanswered, the device downloaded v0.23.0, installed it and restarted into it.
- `/system/updates.log` got its line at each confirmed update.
- Not checked: "by itself" in the record (the release installed did not have this code yet), the 2 minutes without a key and the busy cases (host tests only), and a screen that was off lighting up. Keys sent through the Debug Console do not count as keys.
## Carrying on a download that broke (issue #54)
A release goes straight into the inactive slot (Q167), so a connection that broke meant starting again, and on a weak link a 2.5 MB file might never arrive whole.
### What the server gives (checked 2026-10-11)
A request with `Range: bytes=0-0` for a release's file gets `206`, `Content-Range: bytes 0-0/2510384` and a strong `ETag`.
### Decisions (2026-10-11)
| # | Decision |
|---|---|
| Q282 | **Carried on within the same install, in memory only.** The slot's write handle and the parser, with its hash so far, stay as they are while the connection is opened again with `Range: bytes=<where it was>-`. The parser never sees the break. |
| Q283 | **Nothing is kept across a restart** (the issue's second question): the slot cannot be reopened at an offset, so a download cut by a power-off starts over. |
| Q284 | **The answer must be the rest of the same file:** a `206` from that byte to the end, of the same total size, with the same `ETag` if both answers have one. The `ETag` goes in `If-Range`, so a file that changed comes back whole (a `200`), which is refused. What decides whether the whole is good is still the Update File's own signature and hash. |
| Q285 | **How long:** before each try, up to a minute for Wi-Fi to come back. Tries go on while each brings more bytes, 20 at most in one download; three in a row that bring nothing end it. |
| Q286 | **`update damage cut <n>`** now breaks each connection of the next download after n bytes, so both the carrying on and the giving up can be tried. |
### As built
- **`lib/release/src/resume.h`**, host-tested (5 tests with the head's new fields): `resumeRefusal`, `ifRangeFor`, `ResumePolicy`. `HttpHead` reads `ETag` and `Content-Range`.
- **`HttpsGet::openFrom`** asks for a range; **`GiteaSource`** in the Update Service does the waiting and the trying inside its `read()`.
- `update status` says how often the last download was carried on.
### Checked on the device (2026-10-11)
- **`update damage cut 800000`, then an install of v0.23.0** (2 510 384 bytes): it arrived over four connections, its hash matched, and the device restarted into it.
- **`update damage cut 0`:** three connections that brought nothing, then it gave up (`carried on 2 time(s)`), with the running slot still the one to boot.
- **Memory decided the first attempt.** Between two connections 78 KB was free, under the 80 KB a TLS connection asks for before it starts, so every try was refused and the download gave up. The install's own buffers (about 8 KB) are what is missing, and nothing else asks for memory while the screen is taken over: carrying on asks for 64 KB (the 52 KB peak and 12 KB). The lowest the heap went during the broken download was 20 KB, with a console client connected.
- Not checked: a real Wi-Fi drop in the middle (the wait for Wi-Fi to come back), and a file that changed on the server.
## One firmware: the Debug Console in every build (issue #68) ## One firmware: the Debug Console in every build (issue #68)
The issue asked for a token that could be set, so that Debug Builds could be published. The design round ended somewhere simpler: no Debug Builds. The console is in every firmware, off until switched on, with a token that belongs to the device (ADR 0010, which supersedes part of ADR 0004). The issue asked for a token that could be set, so that Debug Builds could be published. The design round ended somewhere simpler: no Debug Builds. The console is in every firmware, off until switched on, with a token that belongs to the device (ADR 0010, which supersedes part of ADR 0004).
+77 -1
View File
@@ -8,7 +8,7 @@ docs = true
source = "docs/milestones/U1.md" source = "docs/milestones/U1.md"
tag = "U1" tag = "U1"
+++ +++
**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). Not started: screen recording (#17), a Launcher of tiles (#9), themes (#10). **Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). The Launcher as a grid of icons (#9) and themes (#10) are done, not released yet. Not started: screen recording (#17).
**Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content. **Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content.
@@ -67,3 +67,79 @@ A screenshot could only be taken by typing `screenshot` in the Shell, where "now
**Checked on the device** (2026-10-07, with `key shot`): in the Launcher, a 33,383-byte PNG appears in `/screenshots` and is the Launcher; with the help panel open, the picture is of the panel (which now lists Fn p) and the panel stays open; on Settings > Debug Console, no file is written; back on the Settings list, one is. The test pictures were removed. **Checked on the device** (2026-10-07, with `key shot`): in the Launcher, a 33,383-byte PNG appears in `/screenshots` and is the Launcher; with the help panel open, the picture is of the panel (which now lists Fn p) and the panel stays open; on Settings > Debug Console, no file is written; back on the Settings list, one is. The test pictures were removed.
**Not checked:** the real Fn+p on the keyboard (the mapper is host-tested); the two Toasts that refuse, which weren't looked at (one of them is on the page that mustn't be photographed); a device with no card. **Not checked:** the real Fn+p on the keyboard (the mapper is host-tested); the two Toasts that refuse, which weren't looked at (one of them is on the page that mustn't be photographed); a device with no card.
## The Launcher as a grid (issue #9)
The Launcher was a list of names. It is now a grid of icons.
| Question in the issue | Decided |
|---|---|
| Layout | **4 × 3** tiles of 60 × 36, so the 11 Apps are on one page. Only the selected App's name is written, on a line under the grid: a name under every tile doesn't fit 60 pixels ("LoRa Scanner"). More than 12 Apps: the rows scroll |
| Icons | **The website's own**: its nine 16 × 16 pictures (`site/static/img/icons-dark.svg`), doubled to 32 × 32, and two drawn the same way for SSH and the Shell. 1 bit a pixel (128 bytes each, in flash). The pictures are PNGs in `assets/icons/`, named after the App's id; `scripts/make_icons.py` writes `src/ui/icons.cpp` from them and CI checks the two match |
| Colours | **The website's**: cyan (#00dbff) on black, and the selected tile slate (#494955, the Status Bar's) on a cyan fill with notched corners. Both are RGB332 colours. The name under the grid is white |
| Live state | **A dot** on the tile, from `App::badge()`: IRC (unread messages), GNSS (a Track recording), the LoRa Scanner (a Capture running), SSH (a session open). The Launcher asks every App on each pass and redraws when an answer changes |
| Order | As registered, as before |
| Shortcuts | None for now |
| The list | Kept: **Settings > Launcher**, Grid or List. Both keep the same selection |
- `GridModel` (`lib/ui`) holds the selection and the scrolling, host-tested: left and right go through every item and wrap; up and down stay in the column, and land on the last item where the last row is short.
- An App registered without an icon shows its first letter in a frame.
- No animation: not asked for, and not measured.
## The website's colours, on every screen
Every screen takes its colours from `src/ui/theme.h`, so the palette changed there, to the website's (`site/static/css/site.css`, dark side). Each is a colour the RGB332 frame buffer holds exactly.
| | Was | Is |
|---|---|---|
| Accent | teal-blue | cyan `#00dbff` |
| A selected row, a dialog's chosen button | darker teal, white text | cyan, dark blue text (`#000055`) |
| Status Bar | `#202020`, which the frame buffer showed as a dull olive | slate `#494955`; what is idle on it in light grey `#b6b6aa` |
| Muted text | grey, as the frame buffer rounded it | the same grey, exact: `#9292aa` |
| Warning | orange | the website's orange `#ff9200` |
| Someone wrote (unread count, a mention, a message Toast) | green | pink `#ff92ff` |
| Good (a Fix, the quietest channel, a live task) | the same green | green `#49db55`, the one colour that isn't the website's: it has no green |
| Text on a Toast | black | dark blue |
Looked at on the device, by screenshot: the Launcher, IRC, Wi-Fi Tools (menu, channel occupancy), GNSS (position, sky), Gemini, the LoRa Scanner (Sniffer, Sweep, presets), Storage, Notes, SSH, the Shell, System, Settings and the help panel. Not looked at: dialogs, a warning or a message Toast, the SSH terminal (which keeps its own 16 ANSI colours), the Setup screens.
## Themes (issue #10)
Seven themes, each with a dark and a light side, chosen in **Settings > Theme** and **Settings > Light or dark**: roro9stack (the website's, the default), Catppuccin (Mocha and Latte), Dracula (and Alucard), Nord, ANSI terminal, Gruvbox, Solarized.
| Question in the issue | Decided |
|---|---|
| Which themes | The seven above. Not the high-contrast and phosphor ones the issue suggested: ANSI terminal dark (grey and green on black) and light (black on white) come close |
| Fonts and spacing too | No: colours only |
| Themes from the SD card | No: built in |
| A preview while picking | The Setting applies at once, so the Settings screen is the preview |
| Constellation colours | From the theme (its accent, its "good", and three roles named red, yellow and violet), so they read on every background |
| Light by day, dark by night | No |
- **A palette is 14 roles** (`lib/ui/src/palette.h`): background, text, muted, faint, the bar and its idle marks, accent, what is drawn on an accent, warning, message, good, and red, yellow and violet.
- **`theme::kText` and the others are now references** into the palette in use (`src/ui/theme.h`), so no App changed: they still read as constants. The main loop applies the palette when either Setting changes, and draws everything again.
- **RGB332.** Every colour in the table is one the frame buffer holds exactly. The themes' own colours were rounded to those; where two roles fell together or lost contrast, one was picked by hand (Nord's accent and muted text, Gruvbox's greys, Solarized's surfaces). `test_palette` checks that every colour is exact, and 18 contrast ratios for each of the 14 palettes (text on background at 4.5 or more, the rest at 2 to 3).
- **What it costs in fidelity:** blue has four levels, so Dracula and Nord share a dark background (#242455), which was Catppuccin's too: Catppuccin dark is on black instead (its "crust"), with that blue for its bar and its brighter accents (peach, pink, green, sky), to tell it from Dracula; Solarized light's cream becomes white, and Gruvbox's dark brown becomes a dark olive (#242400).
- `theme [0-6] [light|dark]` on the consoles lists them or picks one.
**Looked at on the device**, by screenshot: the Launcher in all 14; and in Gruvbox light, Settings, GNSS (position and sky), the Sweep, Storage, the help panel, Gemini and Wi-Fi Tools' channel occupancy. Nothing was drawn for black only.
**Kept as they are:** the SSH terminal's 16 ANSI colours and its black background, the Sweep's waterfall scale, pictures, and the screen shown while the device powers off.
**Not looked at:** the other 12 palettes beyond the Launcher, dialogs and Toasts in any theme but the default, the Setup screens. In Gruvbox light, the Sky view's GPS, GLONASS and BeiDou colours are three close shades of brown and red.
## Settings in groups
Settings had grown to 21 rows in one list. It now opens on five groups, each a short list of its own:
| Group | Rows |
|---|---|
| This device | Long name, Short name, Region, Timezone, Sound & LED |
| Display | Brightness, Dim after, Screen off after, Theme, Light or dark, Launcher |
| GNSS and radio | GNSS, Pause GNSS for LoRa, Coordinates, Probe MACs |
| Network | Wi-Fi, VPN |
| System | Check for updates, Install updates, Firmware, Debug Console, About |
- Enter opens a group, Back returns to the groups with the selection on the one just left, and Back from the groups leaves Settings. An open group has its name over its rows.
- `SettingsMenu` (`lib/apps_model`) holds the groups and which one is open; every index is into what is listed. Host-tested: every setting is in exactly one group, and no group has more rows than fit under its name.
- The guide, the how-tos, the README, the scripts' messages and the firmware's own (the Toast for a new release, the GNSS App's hint) name the new paths: "Settings > System > Firmware". The milestone notes and the devlog keep the paths of their day.
+2 -2
View File
@@ -39,7 +39,7 @@ The browser flasher uses Web Serial, which Chrome and Edge have on desktop and F
## How do I update it? ## How do I update it?
Three ways, all with the same signed update file: from the project's releases on the device itself (**Settings → Firmware**), from the SD card, or pushed over Wi-Fi from a PC. See [Updates](/guide/updates/) and [Install an update from the SD card](/howto/update-from-sd/). Three ways, all with the same signed update file: from the project's releases on the device itself (**Settings → System → Firmware**), from the SD card, or pushed over Wi-Fi from a PC. See [Updates](/guide/updates/) and [Install an update from the SD card](/howto/update-from-sd/).
## Is it safe to update? What if it goes wrong? ## Is it safe to update? What if it goes wrong?
@@ -51,7 +51,7 @@ Yes: it is [open source](https://git.twis.la/twisla/roro9stack) (GPL-3.0). The d
## Does it phone home? What about privacy? ## Does it phone home? What about privacy?
**The firmware contacts the project's server once a day,** to see whether a new release exists: **Settings → Check for updates**, on by default, only when Wi-Fi is up and the clock is set. It installs nothing by itself, and you can switch it off. Besides that, the device talks to what you ask it to: the IRC server and Gemini capsules you open, DNS servers (9.9.9.9 and 1.1.1.1 by default) and time servers (pool.ntp.org and time.cloudflare.com by default), all of which you can change. There is no account, no analytics and no telemetry. **The firmware contacts the project's server once a day,** to see whether a new release exists: **Settings → System → Check for updates**, on by default, only when Wi-Fi is up and the clock is set. It installs nothing by itself, and you can switch it off. Besides that, the device talks to what you ask it to: the IRC server and Gemini capsules you open, DNS servers (9.9.9.9 and 1.1.1.1 by default) and time servers (pool.ntp.org and time.cloudflare.com by default), all of which you can change. There is no account, no analytics and no telemetry.
**It listens on the network only for what you switched on:** the port that receives signed firmware updates, always; the Debug Console, the card shared with a browser and the VPN, only while you have them on. The SSH App connects out to the server you name and listens for nothing. **It listens on the network only for what you switched on:** the port that receives signed firmware updates, always; the Debug Console, the card shared with a browser and the VPN, only while you have them on. The SSH App connects out to the server you name and listens for nothing.
+8 -2
View File
@@ -36,7 +36,13 @@ While you type text (a note, an IRC line, a setting), `;` `.` `,` `/` type their
## The Launcher ## The Launcher
The home screen lists the Apps. Move with the arrows, open one with <kbd>Enter</kbd>. Back inside an App returns here. The home screen shows the Apps as a grid of icons, four to a row, with the name of the selected one written under the grid. Move with the four arrows, open an App with <kbd>Enter</kbd>. Back inside an App returns here.
Left and right go through every App and wrap around; up and down stay in the column.
**An orange dot on an icon** says something is going on in that App: unread messages in IRC, a Track being recorded in GNSS, a Capture running in the LoRa Scanner, a session open in SSH.
If you prefer the list of names the Launcher used to be, **Settings → Display → Launcher** switches between **Grid** and **List**. In the list, a `*` on the right stands for the dot.
## The Status Bar ## The Status Bar
@@ -51,7 +57,7 @@ A strip at the top of every screen: the name of the App on the left, and on the
| bars and `W` | Wi-Fi connected, with its signal; `W?` is searching; `MON` is the Wi-Fi radio in its monitoring mode, which pauses IRC | | bars and `W` | Wi-Fi connected, with its signal; `W?` is searching; `MON` is the Wi-Fi radio in its monitoring mode, which pauses IRC |
| `SSH` | An [SSH session](/guide/ssh/) is open, whatever App is in front | | `SSH` | An [SSH session](/guide/ssh/) is open, whatever App is in front |
| `VPN` | The [WireGuard tunnel](/guide/vpn/) is wanted; brighter once the server has answered | | `VPN` | The [WireGuard tunnel](/guide/vpn/) is wanted; brighter once the server has answered |
| `DBG` | The Debug Console is switched on (Settings → Debug Console); brighter while a PC is connected to it | | `DBG` | The Debug Console is switched on (Settings → System → Debug Console); brighter while a PC is connected to it |
| `REC` | A GNSS Track is being recorded | | `REC` | A GNSS Track is being recorded |
| `CAP` | A LoRa capture is being recorded | | `CAP` | A LoRa capture is being recorded |
| `L` | The radio is listening; it lights up for a moment on each packet. `SW` while a Sweep runs | | `L` | The radio is listening; it lights up for a moment on each packet. `SW` while a Sweep runs |
+26 -5
View File
@@ -1,19 +1,19 @@
+++ +++
title = "GNSS" title = "GNSS"
description = "Where you are and which satellites you can see, from the Cap's receiver, with Tracks saved to the SD card as GPX." description = "Where you are, which satellites you can see, and a compass that leads back to a place you marked, from the Cap's receiver, with Tracks saved to the SD card as GPX."
weight = 3 weight = 3
[extra] [extra]
tag = "GNSS" tag = "GNSS"
screens = ["sky.png"] screens = ["sky.png"]
+++ +++
GNSS needs the **Cap LoRa-1262**, an antenna with a view of the sky, and **Settings → GNSS** switched **On**. Otherwise the App says `GNSS is off` (or `paused`, when "Pause GNSS for LoRa" has put the receiver on standby while the radio listens). A first fix outdoors can take a while: the App says how long it has been searching. GNSS needs the **Cap LoRa-1262**, an antenna with a view of the sky, and **Settings → GNSS and radio → GNSS** switched **On**. Otherwise the App says `GNSS is off` (or `paused`, when "Pause GNSS for LoRa" has put the receiver on standby while the radio listens). A first fix outdoors can take a while: the App says how long it has been searching.
## Position ## Position
The first view shows the fix (`No Fix`, `2D Fix` or `3D Fix`, and how many satellites it uses), then: The first view shows the fix (`No Fix`, `2D Fix` or `3D Fix`, and how many satellites it uses), then:
- **Lat** and **Lon**, in decimal degrees or degrees, minutes and seconds (**Settings → Coordinates**); - **Lat** and **Lon**, in decimal degrees or degrees, minutes and seconds (**Settings → GNSS and radio → Coordinates**);
- the **Locator**, the Maidenhead grid square; - the **Locator**, the Maidenhead grid square;
- **Altitude**, **Speed** and the direction you are moving; - **Altitude**, **Speed** and the direction you are moving;
- **HDOP**, the horizontal precision: a smaller number is better; - **HDOP**, the horizontal precision: a smaller number is better;
@@ -23,7 +23,28 @@ Once there is a fix, the device's clock follows it.
## Sky ## Sky
<kbd>Tab</kbd> switches between Position and **Sky**: a circle with N, E, S and W, where each satellite is a dot, coloured by constellation, and filled when the receiver uses it. A legend counts, for each constellation (GPS, GLONASS, Galileo, BeiDou), the satellites used and in view. <kbd>Tab</kbd> goes from Position to **Sky**: a circle with N, E, S and W, where each satellite is a dot, coloured by constellation, and filled when the receiver uses it. A legend counts, for each constellation (GPS, GLONASS, Galileo, BeiDou), the satellites used and in view.
## Compass
<kbd>Tab</kbd> once more shows the **Compass**: a rose that turns with the device, so that its red `N` and the bright half of its needle stay to the north. The mark at the top of the rose is the top of the device, and the number beside it is where that points, in degrees and as one of 16 points (`247° WSW`).
The device has **no magnetometer**, so it cannot find north by itself. It learns it in one of two ways, then follows your turns with its motion sensor:
- **Walk.** At a walking pace (3 km/h or more) the receiver knows which way you are going, and the Compass takes that as where the device points. Hold it pointing ahead of you.
- **Aim at the sun.** Hold the device flat, point its top edge at the sun, and press <kbd>s</kbd>: the Compass works out where the sun is from the time and your position. It needs a fix, and the sun above the horizon.
Until one of these has happened the rose is dim and the screen says `North is not known`. After that, the line under the heading says where north came from and how long ago (`the sun, 40 s ago`). The motion sensor drifts by a few degrees a minute, so north should be given again now and then: after three minutes the line changes colour. Laying the device down for a few seconds lets the sensor steady itself and drift less. Leaving the GNSS App forgets north.
A yellow dot on the rose is the **sun**, where it is now: if the dot is not towards the sun, north is off.
The circle at the bottom is a **level**: its bubble is in the middle, and green, when the device is flat.
### Places
<kbd>m</kbd> **marks** where you are standing as a **Place**, under a name you type (the car, the tent), and makes it the target. With a target, the rose carries a cyan arrowhead that points to it, and the screen shows its name, how far it is and its bearing (`340 m 285°`): turn until the arrowhead is at the top and walk.
<kbd>t</kbd> lists the Places with their distances, to pick another target, or none; <kbd>d</kbd> in that list removes one. The Places are kept on the SD card in `/gnss/places.gpx`, as GPX waypoints: a map program opens the file, and waypoints you put in it from one show up in the list (50 at most).
## Tracks ## Tracks
@@ -33,4 +54,4 @@ Once there is a fix, the device's clock follows it.
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones. What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["gnss"]) }} {{ keys(scopes=["gnss", "gnss-compass", "gnss-places"]) }}
+1 -1
View File
@@ -63,7 +63,7 @@ A capture records packets into a **pcap** file with LoRaTap headers in `/capture
## The GNSS receiver raises the noise ## The GNSS receiver raises the noise
The GNSS receiver on the same Cap makes the radio's noise floor about **8 dB** worse while it runs. **Settings → Pause GNSS for LoRa** (off by default) puts the receiver on standby while the radio listens, except while a Track is being recorded. The GNSS receiver on the same Cap makes the radio's noise floor about **8 dB** worse while it runs. **Settings → GNSS and radio → Pause GNSS for LoRa** (off by default) puts the receiver on standby while the radio listens, except while a Track is being recorded.
## The keys, as the device lists them ## The keys, as the device lists them
+41 -4
View File
@@ -6,23 +6,52 @@ weight = 11
tag = "Settings" tag = "Settings"
+++ +++
Move with the arrows. On a toggle, a choice or a slider, left and right change the value; <kbd>Enter</kbd> opens a text field or a list of choices, or a page marked `>`. Back leaves. Settings opens on five **groups**. <kbd>Enter</kbd> opens a group, and Back returns to the groups; Back from the groups leaves Settings.
Inside a group, move with the arrows. On a toggle, a choice or a slider, left and right change the value; <kbd>Enter</kbd> opens a text field or a list of choices, or a page marked `>`.
### This device
| Setting | What it is | | Setting | What it is |
|---|---| |---|---|
| **Long name**, **Short name** | The names you gave in the first-start Setup (up to 39 bytes, and up to 4 characters) | | **Long name**, **Short name** | The names you gave in the first-start Setup (up to 39 bytes, and up to 4 characters) |
| **Region** | The regulatory region; EU868 | | **Region** | The regulatory region; EU868 |
| **Timezone** | For the clock and the dates in file names | | **Timezone** | For the clock and the dates in file names |
| **Sound & LED** | The beep and the flash on a [Toast](/guide/basics/) |
### Display
| Setting | What it is |
|---|---|
| **Brightness** | A slider | | **Brightness** | A slider |
| **Dim after**, **Screen off after** | Screen timeouts. Dimming must come before the screen turns off | | **Dim after**, **Screen off after** | Screen timeouts. Dimming must come before the screen turns off |
| **Sound & LED** | The beep and the flash on a [Toast](/guide/basics/) | | **Theme** | The colours of every screen: **roro9stack** (the website's), **Catppuccin**, **Dracula**, **Nord**, **ANSI terminal**, **Gruvbox** or **Solarized** |
| **Light or dark** | Which side of the theme: every one has both |
| **Launcher** | The home screen as a **Grid** of icons or a **List** of names (see [The basics](/guide/basics/#the-launcher)) |
### GNSS and radio
| Setting | What it is |
|---|---|
| **GNSS** | Switches the receiver on and off (see [GNSS](/guide/gnss/)) | | **GNSS** | Switches the receiver on and off (see [GNSS](/guide/gnss/)) |
| **Pause GNSS for LoRa** | Puts the receiver on standby while the radio listens (see [LoRa Scanner](/guide/lora-scanner/)) | | **Pause GNSS for LoRa** | Puts the receiver on standby while the radio listens (see [LoRa Scanner](/guide/lora-scanner/)) |
| **Coordinates** | Decimal degrees, or degrees, minutes and seconds | | **Coordinates** | Decimal degrees, or degrees, minutes and seconds |
| **Probe MACs** | Whether the Wi-Fi Tools' logs of probe requests keep the addresses as they are (**Raw**) or **Pseudonymised** |
### Network
| Setting | What it is |
|---|---|
| **Wi-Fi** | The page below | | **Wi-Fi** | The page below |
| **Check for updates** | Once a day, see [Updates](/guide/updates/) |
| **Firmware** | The page described in [Updates](/guide/updates/) |
| **VPN** | A WireGuard tunnel: its switch, "Start with Wi-Fi", and importing its configuration from the card. See [VPN](/guide/vpn/) | | **VPN** | A WireGuard tunnel: its switch, "Start with Wi-Fi", and importing its configuration from the card. See [VPN](/guide/vpn/) |
### System
| Setting | What it is |
|---|---|
| **Check for updates** | Once a day, see [Updates](/guide/updates/) |
| **Install updates** | `When asked` (the default), or `By itself`: a newer release is installed in a quiet moment, after a question that answers itself in 30 seconds. See [Updates](/guide/updates/) |
| **Firmware** | The page described in [Updates](/guide/updates/) |
| **Debug Console** | Off unless you switch it on. It lets a PC on the same network read the device's console and drive it, with a token shown on this page: see [the developer docs](/dev/debug/switch-it-on/). Leave it off if that means nothing to you | | **Debug Console** | Off unless you switch it on. It lets a PC on the same network read the device's console and drive it, with a token shown on this page: see [the developer docs](/dev/debug/switch-it-on/). Leave it off if that means nothing to you |
| **About** | The firmware version, the node number, battery, memory, uptime, clock and licence | | **About** | The firmware version, the node number, battery, memory, uptime, clock and licence |
@@ -48,3 +77,11 @@ Two DNS servers (9.9.9.9 and 1.1.1.1 by default), used on Fixed networks, or on
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones. What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["settings", "settings-choice", "wifi", "wifi-servers", "wifi-network", "wifi-status", "wifi-scan", "wifi-name", "vpn", "debug-console"]) }} {{ keys(scopes=["settings", "settings-choice", "wifi", "wifi-servers", "wifi-network", "wifi-status", "wifi-scan", "wifi-name", "vpn", "debug-console"]) }}
## Themes
A theme changes the colours and nothing else: the fonts and the layout stay. It applies at once, to every App, and is kept across restarts.
The screen shows **256 colours**, so a theme here is the nearest the screen can do to the palette it is named after, not an exact copy: pale backgrounds turn white or cream, and dark ones turn a deep blue or olive. Each theme is checked for contrast after that rounding.
Three things keep their own colours whatever the theme: the SSH terminal (the 16 colours a remote program asks for), the Sweep's waterfall (its colours are a scale of signal strength), and pictures.
+16 -2
View File
@@ -9,7 +9,7 @@ screens = ["update.png"]
Every update is one **signed file** (`.ota`). The device installs only a file signed with the project's key, so it cannot be tricked into installing anything else, whichever way the file arrives. Every update is one **signed file** (`.ota`). The device installs only a file signed with the project's key, so it cannot be tricked into installing anything else, whichever way the file arrives.
## Settings → Firmware ## Settings → System → Firmware
The page shows the **version** running, its **status**, the address to **push updates to** over Wi-Fi, and: The page shows the **version** running, its **status**, the address to **push updates to** over Wi-Fi, and:
@@ -19,9 +19,23 @@ The page shows the **version** running, its **status**, the address to **push up
An install needs Wi-Fi if it is a download. The new firmware is checked before anything is written (the signature after the first 160 bytes) and again at the end (the image's hash). Then the device restarts. It will **wait up to 60 seconds** if you are typing, so that a restart never eats a note. An install needs Wi-Fi if it is a download. The new firmware is checked before anything is written (the signature after the first 160 bytes) and again at the end (the image's hash). Then the device restarts. It will **wait up to 60 seconds** if you are typing, so that a restart never eats a note.
A download that **breaks** (the Wi-Fi drops, the link is poor) is not started over: the device waits up to a minute for Wi-Fi to come back, asks the server for the rest of the file, and carries on from where it was, as often as it takes while each try brings something more. It gives up after three tries in a row that bring nothing, and then nothing is changed. If the device is switched off in between, the download starts from the beginning next time.
## Check for updates ## Check for updates
**Settings → Check for updates** is on by default. Once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > Firmware`, once per version. It installs **nothing** by itself, and it does not announce a version that already failed and rolled back on this device. **Settings → System → Check for updates** is on by default. Once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs **nothing** by itself unless you ask for that (below), and it does not announce a version that already failed and rolled back on this device.
## Install updates by itself
**Settings → System → Install updates** is `When asked` by default. Set to `By itself`, a newer release found by the daily check is installed without your going to the Firmware page:
1. The device waits for a **quiet moment**: no key pressed for 2 minutes, and nothing a restart would cut short (a Track or a Capture recording, files being copied or received, an SSH session, a web share).
2. It then asks on the screen, over whatever is open, and lights the screen if it was off: `Update to v0.24.0?`, with a count from **30 seconds**.
3. **Cancel** (or <kbd>`</kbd>) leaves things as they are until the next day's check. **Accept**, or no answer when the count ends, downloads the release, installs it and restarts.
Everything else is as for an install you ask for: the signature is checked before anything is written, the new firmware runs on Probation, and a version that rolled back on this device is not offered again. It needs **Check for updates** to be on.
Each update that held, whoever started it, and each one that was undone, is written with its date in `/system/updates.log` on the SD card.
## Probation and Rollback ## Probation and Rollback
+2 -2
View File
@@ -15,14 +15,14 @@ You need a WireGuard server, yours or a provider's, and the configuration file i
1. On the server, make a configuration for a new client, as you would for a phone. 1. On the server, make a configuration for a new client, as you would for a phone.
2. Copy the file to the SD card as **`/vpn/wg0.conf`**. 2. Copy the file to the SD card as **`/vpn/wg0.conf`**.
3. On the device: **Settings → VPN → Import /vpn/wg0.conf**. 3. On the device: **Settings → Network → VPN → Import /vpn/wg0.conf**.
4. Say yes when it offers to **delete the file**: the configuration is now stored in the device, and the file on the card still holds the private key in clear. 4. Say yes when it offers to **delete the file**: the configuration is now stored in the device, and the file on the card still holds the private key in clear.
If the file can't be used, the page says which line and why. The key itself is never shown, anywhere, once imported. If the file can't be used, the page says which line and why. The key itself is never shown, anywhere, once imported.
## Using it ## Using it
**Settings → VPN** has a switch. `VPN` appears in the [Status Bar](/guide/basics/#the-status-bar) while the tunnel is wanted, and turns bright once the server has answered. The page shows the state, the server, this device's address in the tunnel, what goes through it, and how long ago the server was last heard. **Settings → Network → VPN** has a switch. `VPN` appears in the [Status Bar](/guide/basics/#the-status-bar) while the tunnel is wanted, and turns bright once the server has answered. The page shows the state, the server, this device's address in the tunnel, what goes through it, and how long ago the server was last heard.
- **The switch is for now.** It doesn't survive a restart. - **The switch is for now.** It doesn't survive a restart.
- **Start with Wi-Fi**, off by default, starts the tunnel whenever Wi-Fi connects. - **Start with Wi-Fi**, off by default, starts the tunnel whenever Wi-Fi connects.
+2 -2
View File
@@ -6,14 +6,14 @@ weight = 4
tag = "Wi-Fi" tag = "Wi-Fi"
+++ +++
1. **Add the network first.** In **Settings → Wi-Fi**, choose **Add a network**, pick it and type the password. (A hidden network has its own entry, which asks for the name first.) 1. **Add the network first.** In **Settings → Network → Wi-Fi**, choose **Add a network**, pick it and type the password. (A hidden network has its own entry, which asks for the name first.)
2. **Open the network's page:** <kbd>Enter</kbd> on it in the list of saved networks. 2. **Open the network's page:** <kbd>Enter</kbd> on it in the list of saved networks.
3. **Set *IP address* to *Fixed*.** The address, the prefix and the gateway start from what the network is giving the device at that moment, so you only change what is wrong. 3. **Set *IP address* to *Fixed*.** The address, the prefix and the gateway start from what the network is giving the device at that moment, so you only change what is wrong.
- **Address:** four numbers, like `10.39.39.13`. - **Address:** four numbers, like `10.39.39.13`.
- **Prefix:** 1 to 30. 24 is 255.255.255.0. - **Prefix:** 1 to 30. 24 is 255.255.255.0.
- **Gateway:** optional; empty means none. - **Gateway:** optional; empty means none.
4. **Leave the page.** The setting is checked and applied then; a bad address is refused with the reason. 4. **Leave the page.** The setting is checked and applied then; a bad address is refused with the reason.
5. **Check it.** Back in **Settings → Wi-Fi**, <kbd>Enter</kbd> on **Status** shows the address, the mask, the gateway, the DNS and NTP servers, and where each came from. 5. **Check it.** Back in **Settings → Network → Wi-Fi**, <kbd>Enter</kbd> on **Status** shows the address, the mask, the gateway, the DNS and NTP servers, and where each came from.
## DNS and time ## DNS and time
+1 -1
View File
@@ -8,7 +8,7 @@ tag = "GNSS"
You need the **Cap LoRa-1262**, an **SD card**, and a place with a view of the sky. You need the **Cap LoRa-1262**, an **SD card**, and a place with a view of the sky.
1. **Switch the receiver on:** **Settings → GNSS** to *On*. The Status Bar shows `G` once it is searching. 1. **Switch the receiver on:** **Settings → GNSS and radio → GNSS** to *On*. The Status Bar shows `G` once it is searching.
2. **Open the GNSS App** and wait for a fix. The first line says `Searching: n in view` and how long it has been, then `3D Fix, n of m satellites`. The first fix outdoors can take a while. 2. **Open the GNSS App** and wait for a fix. The first line says `Searching: n in view` and how long it has been, then `3D Fix, n of m satellites`. The first fix outdoors can take a while.
3. **The clock must be set.** A fix sets it, and so does Wi-Fi. A Track will not start without one: the App says `Waiting for the time`. 3. **The clock must be set.** A fix sets it, and so does Wi-Fi. A Track will not start without one: the App says `Waiting for the time`.
4. **Press <kbd>r</kbd>.** The bottom line says `REC`, with the number of points and how long it has been going, and the Status Bar shows `REC`. You can leave the App: the Track keeps recording. 4. **Press <kbd>r</kbd>.** The bottom line says `REC`, with the number of points and how long it has been going, and the Status Bar shows `REC`. You can leave the App: the Track keeps recording.
+1 -1
View File
@@ -14,5 +14,5 @@ tag = "Screen"
- It needs an SD card. - It needs an SD card.
- The files are PNGs of 240 by 135 pixels, named by date and time. - The files are PNGs of 240 by 135 pixels, named by date and time.
- **It refuses on Settings → Debug Console**, the page that shows the console's token: a picture of it would be a copy of the token. - **It refuses on Settings → System → Debug Console**, the page that shows the console's token: a picture of it would be a copy of the token.
- From the [Shell](/guide/shell/), `screenshot 5` takes the picture five seconds later, for a screen you can't press keys on. - From the [Shell](/guide/shell/), `screenshot 5` takes the picture five seconds later, for a screen you can't press keys on.
+1 -1
View File
@@ -8,7 +8,7 @@ tag = "Updates"
1. **Get the update file.** On the [Downloads page](/downloads/), take the `.ota` file of the release you want: `roro9stack-<version>.ota`. Every release has one. 1. **Get the update file.** On the [Downloads page](/downloads/), take the `.ota` file of the release you want: `roro9stack-<version>.ota`. Every release has one.
2. **Copy it to `/updates`** on the SD card, from a computer. If the folder is not there, create it. (With the Cardputer on USB, a developer can also send it with `scripts/sd_put.sh`; see the [README](https://git.twis.la/twisla/roro9stack#firmware-updates-over-wi-fi-ota).) 2. **Copy it to `/updates`** on the SD card, from a computer. If the folder is not there, create it. (With the Cardputer on USB, a developer can also send it with `scripts/sd_put.sh`; see the [README](https://git.twis.la/twisla/roro9stack#firmware-updates-over-wi-fi-ota).)
3. **Put the card in the Cardputer.** Open **Settings → Firmware.** Under *On the SD card* the file is listed. If it says `No .ota files in /updates`, the name or the folder is wrong. 3. **Put the card in the Cardputer.** Open **Settings → System → Firmware.** Under *On the SD card* the file is listed. If it says `No .ota files in /updates`, the name or the folder is wrong.
4. **Press Enter on the file,** then **Install**. The device checks the signature and the contents before it writes anything, installs, and restarts. It waits up to 60 seconds if you are typing. 4. **Press Enter on the file,** then **Install**. The device checks the signature and the contents before it writes anything, installs, and restarts. It waits up to 60 seconds if you are typing.
5. **After the restart** the new firmware is on **Probation**: if it crashes or cannot get Wi-Fi back within 3 minutes, the device goes back to the previous version by itself and says so. 5. **After the restart** the new firmware is on **Probation**: if it crashes or cannot get Wi-Fi back within 3 minutes, the device goes back to the previous version by itself and says so.
+1 -1
View File
@@ -12,7 +12,7 @@ You need a WireGuard server, and a **client configuration** made on it for the C
2. On the Cardputer, open **Storage** and press <kbd>w</kbd>; open the page on the phone ([Move files with your phone](/howto/phone-files/)). 2. On the Cardputer, open **Storage** and press <kbd>w</kbd>; open the page on the phone ([Move files with your phone](/howto/phone-files/)).
3. In the page, tap **New folder**, name it `vpn`, go into it, and **upload `wg0.conf`**. 3. In the page, tap **New folder**, name it `vpn`, go into it, and **upload `wg0.conf`**.
4. On the Cardputer, press Back to stop sharing. 4. On the Cardputer, press Back to stop sharing.
5. Open **Settings → VPN → Import /vpn/wg0.conf**. You should see "Imported", and a question: **delete the file**. Say yes: the configuration is now in the device, and the file still holds the private key in clear. 5. Open **Settings → Network → VPN → Import /vpn/wg0.conf**. You should see "Imported", and a question: **delete the file**. Say yes: the configuration is now in the device, and the file still holds the private key in clear.
6. Switch **VPN** to On. `VPN` appears in the Status Bar, and turns bright once the server has answered, usually within seconds. The page says "It is up". 6. Switch **VPN** to On. `VPN` appears in the Status Bar, and turns bright once the server has answered, usually within seconds. The page says "It is up".
7. To have it start by itself, switch on **Start with Wi-Fi**. 7. To have it start by itself, switch on **Start with Wi-Fi**.
+24 -3
View File
@@ -36,7 +36,7 @@ rows = [
id = "launcher" id = "launcher"
title = "The Launcher" title = "The Launcher"
rows = [ rows = [
["; .", "up, down"], ["; . , /", "move"],
["Enter", "open the App"], ["Enter", "open the App"],
] ]
@@ -142,10 +142,30 @@ rows = [
id = "gnss" id = "gnss"
title = "GNSS" title = "GNSS"
rows = [ rows = [
["Tab", "the position, or the sky"], ["Tab", "position, sky, compass"],
["r", "record a Track, or stop it"], ["r", "record a Track, or stop it"],
] ]
[[scope]]
id = "gnss-compass"
title = "GNSS, the compass"
rows = [
["Tab", "position, sky, compass"],
["m", "mark this place"],
["t", "the target: a place"],
["s", "aimed at the sun: north"],
["r", "record a Track, or stop it"],
]
[[scope]]
id = "gnss-places"
title = "GNSS, the places"
rows = [
["; .", "up, down"],
["Enter", "the target"],
["d Del", "remove the place"],
]
[[scope]] [[scope]]
id = "gemini" id = "gemini"
title = "Gemini, a page" title = "Gemini, a page"
@@ -488,8 +508,9 @@ id = "settings"
title = "Settings" title = "Settings"
rows = [ rows = [
["; .", "up, down"], ["; .", "up, down"],
["Enter", "edit, or open the page"], ["Enter", "open the group or the page, or edit"],
[", /", "change a switch or a slider"], [", /", "change a switch or a slider"],
["`", "back to the groups"],
] ]
[[scope]] [[scope]]
+1 -1
View File
@@ -11,7 +11,7 @@
</header> </header>
<div class="prose"> <div class="prose">
<p>Each release has a <strong>signed update file</strong> (<code>.ota</code>: copy it to <code>/updates</code> on the SD card and install it from Settings → Firmware or the Storage App, or push it over Wi-Fi), a <strong>factory image</strong> (<code>-factory.bin</code>, the whole flash at offset 0 for a first install over USB), the <strong>symbols</strong> (<code>.elf.gz</code>, to decode a crash report) and <code>SHA256SUMS</code>.</p> <p>Each release has a <strong>signed update file</strong> (<code>.ota</code>: copy it to <code>/updates</code> on the SD card and install it from Settings → System → Firmware or the Storage App, or push it over Wi-Fi), a <strong>factory image</strong> (<code>-factory.bin</code>, the whole flash at offset 0 for a first install over USB), the <strong>symbols</strong> (<code>.elf.gz</code>, to decode a crash report) and <code>SHA256SUMS</code>.</p>
</div> </div>
{% if releases %} {% if releases %}
+1 -1
View File
@@ -25,7 +25,7 @@ REPO = SITE.parent
OUT = SITE / "content" / "dev" OUT = SITE / "content" / "dev"
REPO_URL = re.search(r'repo\s*=\s*"([^"]+)"', (SITE / "config.toml").read_text()).group(1) REPO_URL = re.search(r'repo\s*=\s*"([^"]+)"', (SITE / "config.toml").read_text()).group(1)
MILESTONES = ["OTA", "M2", "G1", "M3", "S1", "F1", "R1", "W1", "U1", "N1"] # in the order they were done MILESTONES = ["OTA", "M2", "G1", "M3", "S1", "F1", "R1", "W1", "U1", "N1", "H1"] # in the order they were done
# Left out on purpose: docs/milestones/M0.md, M1.md and CONTEXT.md (the glossary) describe Wi-Fi monitoring, which this site does not publish. # Left out on purpose: docs/milestones/M0.md, M1.md and CONTEXT.md (the glossary) describe Wi-Fi monitoring, which this site does not publish.
# They stay in the repository. # They stay in the repository.
+1 -1
View File
@@ -15,7 +15,7 @@
namespace roro { namespace roro {
// Settings → Debug Console (ADR 0010): the switch, where to connect, and the token, which is shown // Settings → System → Debug Console (ADR 0010): the switch, where to connect, and the token, which is shown
// here and nowhere else. Switching on makes a token if there's none; a new one, or one typed by // here and nowhere else. Switching on makes a token if there's none; a new one, or one typed by
// hand, ends the connection of whoever holds the old one. // hand, ends the connection of whoever holds the old one.
class DebugConsolePage { class DebugConsolePage {
+1 -1
View File
@@ -18,7 +18,7 @@
namespace roro { namespace roro {
// Settings → Firmware: the running version and its Probation, where to push Firmware Updates, the // Settings → System → Firmware: the running version and its Probation, where to push Firmware Updates, the
// project's releases on Gitea (the latest, and the ten before it), and the Update Files on the SD // project's releases on Gitea (the latest, and the ten before it), and the Update Files on the SD
// card (in /updates) to install from. // card (in /updates) to install from.
class FirmwarePage { class FirmwarePage {
+1 -1
View File
@@ -534,7 +534,7 @@ void GeminiApp::drawLine(Canvas& c, size_t i, int firstRow, int y, int rowsLeft)
for (int r = firstRow; r < static_cast<int>(rows.size()) && r - firstRow < rowsLeft; r++) { for (int r = firstRow; r < static_cast<int>(rows.size()) && r - firstRow < rowsLeft; r++) {
int ry = y + (r - firstRow) * theme::kLineHeight; int ry = y + (r - firstRow) * theme::kLineHeight;
if (isSelected) c.fillRect(0, ry, theme::kWidth - 3, theme::kLineHeight, theme::kSelection); if (isSelected) c.fillRect(0, ry, theme::kWidth - 3, theme::kLineHeight, theme::kSelection);
c.setTextColor(isSelected ? theme::kText : st.color); c.setTextColor(isSelected ? theme::kOnAccent : st.color);
std::string text = rows[r]; std::string text = rows[r];
if (g.type == LineType::Preformatted) text = hscroll_ < static_cast<int>(text.size()) ? text.substr(hscroll_) : ""; if (g.type == LineType::Preformatted) text = hscroll_ < static_cast<int>(text.size()) ? text.substr(hscroll_) : "";
c.drawString(text.c_str(), kMargin, ry + 1); c.drawString(text.c_str(), kMargin, ry + 1);
+389 -21
View File
@@ -2,14 +2,19 @@
#include "gnss_app.h" #include "gnss_app.h"
#include <Arduino.h> #include <Arduino.h>
#include <M5Unified.h>
#include <SD.h>
#include <algorithm>
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
#include "geo_format.h" #include "geo_format.h"
#include "track.h"
#include "ui/canvas.h" #include "ui/canvas.h"
#include "ui/fonts.h" #include "ui/fonts.h"
#include "ui/theme.h" #include "ui/theme.h"
#include "ui/widgets.h"
namespace roro { namespace roro {
@@ -17,14 +22,14 @@ using namespace gnss;
namespace { namespace {
// One colour per constellation, readable in the 8-bit frame buffer. // One colour per constellation, from the theme: readable on its background.
uint16_t colorOf(Constellation c) { uint16_t colorOf(Constellation c) {
switch (c) { switch (c) {
case Constellation::Gps: return theme::kAccent; case Constellation::Gps: return theme::kAccent;
case Constellation::Glonass: return 0xF8C8; // red case Constellation::Glonass: return theme::kRed;
case Constellation::Galileo: return theme::kMessage; case Constellation::Galileo: return theme::kGood;
case Constellation::BeiDou: return 0xFEA0; // yellow case Constellation::BeiDou: return theme::kYellow;
case Constellation::Qzss: return 0xC99F; // violet case Constellation::Qzss: return theme::kViolet;
default: return theme::kText; default: return theme::kText;
} }
} }
@@ -39,27 +44,201 @@ std::string duration(uint32_t ms) {
return buf; return buf;
} }
// How the IMU sits in the device, checked by hand (issue #16): which way the sensor's x and y run on the
// screen, for the level's bubble, which goes to the side that is higher.
constexpr float kLevelX = 1.0f, kLevelY = -1.0f;
constexpr uint32_t kImuEveryMs = 10;
constexpr uint32_t kCompassFrameMs = 50;
constexpr uint32_t kSayMs = 4000;
} // namespace } // namespace
void GnssApp::onEnter() {
mode_ = Mode::View;
dialog_.reset();
heading_.forget(); // the turns made while the App was closed were not followed
imuUs_ = 0;
courseSecond_ = -1;
requestRedraw();
}
void GnssApp::say(const std::string& text) {
refusal_ = text;
refusalMs_ = millis();
requestRedraw();
}
bool GnssApp::onKey(const KeyEvent& e) { bool GnssApp::onKey(const KeyEvent& e) {
requestRedraw();
if (mode_ != Mode::View) return placesKey(e);
if (e.key == Key::Tab) { if (e.key == Key::Tab) {
sky_ = !sky_; view_ = view_ == View::Position ? View::Sky : view_ == View::Sky ? View::Compass : View::Position;
requestRedraw(); if (view_ == View::Compass) loadPlaces();
return true; return true;
} }
if (e.key == Key::Char && (e.ch == 'r' || e.ch == 'R')) { // record a Track, or stop it if (e.key == Key::Char && (e.ch == 'r' || e.ch == 'R')) { // record a Track, or stop it
if (gnss_.tracking()) gnss_.stopTrack(); if (gnss_.tracking()) gnss_.stopTrack();
else { else say(gnss_.startTrack(millis()));
refusal_ = gnss_.startTrack(millis());
refusalMs_ = millis();
}
requestRedraw();
return true; return true;
} }
if (view_ == View::Compass && gnss_.on()) return compassKey(e);
return false; // Back leaves the App return false; // Back leaves the App
} }
bool GnssApp::compassKey(const KeyEvent& e) {
if (e.key != Key::Char) return false;
switch (e.ch) {
case 'm': case 'M': mark(); return true;
case 's': case 'S': aimAtSun(); return true;
case 't': case 'T':
loadPlaces();
list_.setCount(static_cast<int>(places_.size()) + 1);
list_.select(target_ + 1);
mode_ = Mode::Places;
return true;
default: return false;
}
}
bool GnssApp::placesKey(const KeyEvent& e) {
if (dialog_) { // remove this Place?
dialog_->onKey(e);
int result = dialog_->result();
if (result == DialogModel::kPending) return true;
dialog_.reset();
int at = list_.selected() - 1;
if (result == 1 && at >= 0 && at < static_cast<int>(places_.size())) {
places_.erase(places_.begin() + at);
if (target_ == at) target_ = -1;
else if (target_ > at) target_--;
savePlaces();
list_.setCount(static_cast<int>(places_.size()) + 1);
}
return true;
}
if (mode_ == Mode::Name) {
switch (e.key) {
case Key::Char: name_.insert(e.ch); break;
case Key::Delete: name_.backspace(); break;
case Key::Left: name_.left(); break;
case Key::Right: name_.right(); break;
case Key::Back: mode_ = Mode::View; break;
case Key::Select: {
const GnssState& s = gnss_.state();
mode_ = Mode::View;
if (!s.positionValid) {
say("The Fix was lost");
break;
}
std::string name = name_.text();
if (name.empty()) name = "Place " + std::to_string(places_.size() + 1);
places_.push_back({name, s.latitude, s.longitude});
target_ = static_cast<int>(places_.size()) - 1;
savePlaces();
say("Marked");
break;
}
default: break;
}
return true;
}
switch (e.key) {
case Key::Up: list_.up(); break;
case Key::Down: list_.down(); break;
case Key::Back: mode_ = Mode::View; break;
case Key::Select:
target_ = list_.selected() - 1;
mode_ = Mode::View;
break;
case Key::Delete:
if (list_.selected() > 0) dialog_.reset(new DialogModel({"Cancel", "Remove"}));
break;
case Key::Char:
if ((e.ch == 'd' || e.ch == 'D') && list_.selected() > 0) dialog_.reset(new DialogModel({"Cancel", "Remove"}));
break;
default: break;
}
return true;
}
void GnssApp::mark() {
loadPlaces();
if (!gnss_.state().positionValid) return say("No Fix yet");
if (!storage_.state().present) return say("No SD card");
if (places_.size() >= kMaxPlaces) return say("Too many places");
name_.setText("");
mode_ = Mode::Name;
}
void GnssApp::aimAtSun() {
const GnssState& s = gnss_.state();
if (!s.positionValid || !s.timeValid) return say("No Fix yet");
SunPosition sun = sunPosition(s.utcSeconds(), s.latitude, s.longitude);
if (sun.elevationDeg < 0) return say("The sun is down");
heading_.sun(sun.azimuthDeg);
say("North from the sun");
}
// The Places are few and the file is small: read whole once, written whole at each change.
void GnssApp::loadPlaces() {
if (placesLoaded_ || !storage_.state().present) return;
std::string text;
bool ran = storage_.runAndWait([&]() {
File f = SD.open(kPlacesPath, FILE_READ);
if (!f) return;
size_t size = std::min<size_t>(f.size(), 16 * 1024);
text.resize(size);
text.resize(f.read(reinterpret_cast<uint8_t*>(&text[0]), size));
f.close();
});
if (!ran) return;
places_ = parsePlaces(text);
placesLoaded_ = true;
target_ = -1;
}
void GnssApp::savePlaces() {
std::string text = placesGpx(places_);
storage_.runJob([text]() {
if (!SD.exists("/gnss")) SD.mkdir("/gnss");
File f = SD.open(kPlacesPath, FILE_WRITE);
if (!f) return;
f.write(reinterpret_cast<const uint8_t*>(text.data()), text.size());
f.close();
});
}
void GnssApp::readImu() {
if (!M5.Imu.update()) return;
auto data = M5.Imu.getImuData();
uint32_t us = micros();
float dt = imuUs_ ? (us - imuUs_) / 1e6f : 0;
imuUs_ = us;
if (dt > 0.2f) dt = 0; // the loop was held up: a turn that long ago is not known
const float gyro[3] = {data.gyro.x, data.gyro.y, data.gyro.z};
const float accel[3] = {data.accel.x, data.accel.y, data.accel.z};
heading_.motion(gyro, accel, dt);
}
void GnssApp::update(uint32_t nowMs) { void GnssApp::update(uint32_t nowMs) {
// The heading is followed in every view, so the Compass is right when Tab comes back to it.
if (nowMs - imuMs_ >= kImuEveryMs) {
imuMs_ = nowMs;
readImu();
}
const GnssState& s = gnss_.state();
if (gnss_.on() && s.timeValid && s.second != courseSecond_) { // a new report from the receiver
courseSecond_ = s.second;
if (s.positionValid && s.courseValid) heading_.course(s.courseDeg, s.speedKmh);
}
if (view_ == View::Compass && mode_ == Mode::View && nowMs - lastDrawMs_ >= kCompassFrameMs) {
bool turned = heading_.known() && std::fabs(angleDiff(heading_.heading(), shownHeading_)) >= 1.0f;
bool tilted = std::fabs(heading_.tiltX() - shownTiltX_) > 0.01f || std::fabs(heading_.tiltY() - shownTiltY_) > 0.01f;
if (turned || tilted) {
lastDrawMs_ = nowMs;
requestRedraw();
}
}
if (nowMs - lastDrawMs_ >= 1000) { // the receiver reports once a second if (nowMs - lastDrawMs_ >= 1000) { // the receiver reports once a second
lastDrawMs_ = nowMs; lastDrawMs_ = nowMs;
requestRedraw(); requestRedraw();
@@ -76,17 +255,32 @@ void GnssApp::draw(Canvas& c) {
c.drawString(held ? "GNSS is paused" : "GNSS is off", 4, area.y + 4); c.drawString(held ? "GNSS is paused" : "GNSS is off", 4, area.y + 4);
c.setFont(&fonts::body); c.setFont(&fonts::body);
c.setTextColor(theme::kMuted); c.setTextColor(theme::kMuted);
c.drawString(held ? "The LoRa radio is listening, and" : "Settings > GNSS turns it on.", 4, area.y + 22); c.drawString(held ? "The LoRa radio is listening, and" : "On: Settings > GNSS and radio > GNSS", 4, area.y + 22);
if (held) c.drawString("Settings pauses GNSS for it.", 4, area.y + 22 + theme::kLineHeight); if (held) c.drawString("Settings pauses GNSS for it.", 4, area.y + 22 + theme::kLineHeight);
return; return;
} }
sky_ ? drawSky(c) : drawPosition(c); if (mode_ != Mode::View) return drawPlaces(c);
switch (view_) {
case View::Position: drawPosition(c); break;
case View::Sky: drawSky(c); break;
case View::Compass: drawCompass(c); break;
}
} }
void GnssApp::help(std::vector<KeyHelp>& out) const { void GnssApp::help(std::vector<KeyHelp>& out) const {
if (dialog_) return keys::add(out, keys::kDialog);
if (mode_ == Mode::Name) return keys::add(out, keys::kText);
if (mode_ == Mode::Places) return keys::add(out, keys::kGnssPlaces);
if (view_ == View::Compass) return keys::add(out, keys::kGnssCompass);
keys::add(out, keys::kGnss); keys::add(out, keys::kGnss);
} }
const char* GnssApp::helpTitle() const {
if (mode_ == Mode::Name) return "GNSS: a new place";
if (mode_ == Mode::Places) return "GNSS: the places";
return view_ == View::Compass ? "GNSS: the compass" : nullptr;
}
void GnssApp::drawPosition(Canvas& c) { void GnssApp::drawPosition(Canvas& c) {
const auto& area = theme::kContent; const auto& area = theme::kContent;
const GnssState& s = gnss_.state(); const GnssState& s = gnss_.state();
@@ -96,7 +290,7 @@ void GnssApp::drawPosition(Canvas& c) {
char line[64]; char line[64];
c.setFont(&fonts::bold); c.setFont(&fonts::bold);
c.setTextColor(s.fix == FixType::None ? theme::kWarning : theme::kMessage); c.setTextColor(s.fix == FixType::None ? theme::kWarning : theme::kGood);
if (!gnss_.receiving(now)) std::snprintf(line, sizeof line, "No data from the receiver"); if (!gnss_.receiving(now)) std::snprintf(line, sizeof line, "No data from the receiver");
else if (s.fix == FixType::None) else if (s.fix == FixType::None)
std::snprintf(line, sizeof line, "Searching: %u in view, %s", (unsigned)s.satellites.size(), std::snprintf(line, sizeof line, "Searching: %u in view, %s", (unsigned)s.satellites.size(),
@@ -141,7 +335,7 @@ void GnssApp::drawPosition(Canvas& c) {
c.setTextColor(theme::kWarning); c.setTextColor(theme::kWarning);
std::snprintf(line, sizeof line, "REC %d point%s, %s", gnss_.trackPoints(), std::snprintf(line, sizeof line, "REC %d point%s, %s", gnss_.trackPoints(),
gnss_.trackPoints() == 1 ? "" : "s", duration(now - gnss_.trackStartMs()).c_str()); gnss_.trackPoints() == 1 ? "" : "s", duration(now - gnss_.trackStartMs()).c_str());
} else if (!refusal_.empty() && now - refusalMs_ < 4000) { } else if (!refusal_.empty() && now - refusalMs_ < kSayMs) {
c.setTextColor(theme::kWarning); c.setTextColor(theme::kWarning);
std::snprintf(line, sizeof line, "%s", refusal_.c_str()); std::snprintf(line, sizeof line, "%s", refusal_.c_str());
} else line[0] = 0; } else line[0] = 0;
@@ -155,10 +349,10 @@ void GnssApp::drawSky(Canvas& c) {
// The sky from above: horizon, 30° and 60° elevation rings, north up. // The sky from above: horizon, 30° and 60° elevation rings, north up.
c.drawCircle(cx, cy, radius, theme::kMuted); c.drawCircle(cx, cy, radius, theme::kMuted);
c.drawCircle(cx, cy, radius * 2 / 3, 0x4208); c.drawCircle(cx, cy, radius * 2 / 3, theme::kFaint);
c.drawCircle(cx, cy, radius / 3, 0x4208); c.drawCircle(cx, cy, radius / 3, theme::kFaint);
c.drawFastHLine(cx - radius, cy, 2 * radius, 0x4208); c.drawFastHLine(cx - radius, cy, 2 * radius, theme::kFaint);
c.drawFastVLine(cx, cy - radius, 2 * radius, 0x4208); c.drawFastVLine(cx, cy - radius, 2 * radius, theme::kFaint);
c.setFont(&fonts::small); c.setFont(&fonts::small);
c.setTextColor(theme::kMuted); c.setTextColor(theme::kMuted);
c.setTextDatum(middle_center); c.setTextDatum(middle_center);
@@ -201,11 +395,185 @@ void GnssApp::drawSky(Canvas& c) {
c.drawString("No satellites", x, y); c.drawString("No satellites", x, y);
y += theme::kLineHeight; y += theme::kLineHeight;
} }
c.setTextColor(s.fix == FixType::None ? theme::kWarning : theme::kMessage); c.setTextColor(s.fix == FixType::None ? theme::kWarning : theme::kGood);
c.drawString(fixLabel(s.fix), x, y + 4); c.drawString(fixLabel(s.fix), x, y + 4);
c.setFont(&fonts::small); c.setFont(&fonts::small);
c.setTextColor(theme::kMuted); c.setTextColor(theme::kMuted);
c.drawString("filled: in use", x, area.y + area.h - 20); c.drawString("filled: in use", x, area.y + area.h - 20);
} }
void GnssApp::drawCompass(Canvas& c) {
const auto& area = theme::kContent;
const GnssState& s = gnss_.state();
const int radius = 54, cx = 4 + radius + 2, cy = area.y + area.h / 2 + 2;
bool known = heading_.known();
float heading = known ? heading_.heading() : 0; // north up until north is known
shownHeading_ = heading;
shownTiltX_ = heading_.tiltX();
shownTiltY_ = heading_.tiltY();
// A point of the rose: `bearing` from north, `r` from the centre. The top of the screen is
// where the top of the device points.
auto at = [&](float bearing, float r, int& x, int& y) {
float a = (bearing - heading) * static_cast<float>(M_PI) / 180.0f;
x = cx + static_cast<int>(std::lround(r * std::sin(a)));
y = cy - static_cast<int>(std::lround(r * std::cos(a)));
};
c.drawCircle(cx, cy, radius, known ? theme::kMuted : theme::kFaint);
for (int b = 0; b < 360; b += 10) {
int x0, y0, x1, y1;
bool major = b % 30 == 0;
at(b, radius - (major ? 6 : 3), x0, y0);
at(b, radius - 1, x1, y1);
c.drawLine(x0, y0, x1, y1, major && known ? theme::kMuted : theme::kFaint);
}
if (known) { // the needle: its bright half to the north
int nx, ny, sx, sy, lx, ly, rx, ry;
at(0, radius - 24, nx, ny);
at(180, radius - 24, sx, sy);
at(90, 5, rx, ry);
at(270, 5, lx, ly);
c.fillTriangle(sx, sy, lx, ly, rx, ry, theme::kFaint);
c.fillTriangle(nx, ny, lx, ly, rx, ry, theme::kRed);
}
c.setTextDatum(middle_center);
static const char* const kLetters[4] = {"N", "E", "S", "W"};
for (int i = 0; i < 4; i++) {
int x, y;
at(i * 90.0f, radius - 14, x, y);
c.setFont(i == 0 ? &fonts::bold : &fonts::body);
c.setTextColor(!known ? theme::kFaint : i == 0 ? theme::kRed : theme::kText);
c.drawString(kLetters[i], x, y);
}
bool placed = s.positionValid;
if (known && placed && s.timeValid) { // the sun, where it is now: something to check north against
SunPosition sun = sunPosition(s.utcSeconds(), s.latitude, s.longitude);
if (sun.elevationDeg > 0) {
int x, y;
at(sun.azimuthDeg, radius - 4, x, y);
c.fillCircle(x, y, 3, theme::kYellow);
}
}
const Place* target = target_ >= 0 && target_ < static_cast<int>(places_.size()) ? &places_[static_cast<size_t>(target_)] : nullptr;
double bearing = 0, distance = 0;
if (target && placed) {
bearing = bearingDegrees(s.latitude, s.longitude, target->latitude, target->longitude);
distance = distanceMeters(s.latitude, s.longitude, target->latitude, target->longitude);
if (known) { // an arrowhead on the rim, the way to go
int tx, ty, ax, ay, bx, by;
at(static_cast<float>(bearing), radius + 1, tx, ty);
at(static_cast<float>(bearing) - 7, radius - 12, ax, ay);
at(static_cast<float>(bearing) + 7, radius - 12, bx, by);
c.fillTriangle(tx, ty, ax, ay, bx, by, theme::kAccent);
}
}
// The mark the rose is read against: the top of the device.
c.fillTriangle(cx, cy - radius + 2, cx - 4, cy - radius - 5, cx + 4, cy - radius - 5, theme::kText);
// The readouts, to the right.
const int x = cx + radius + 10;
char line[48];
c.setTextDatum(top_left);
c.setFont(&fonts::large);
c.setTextColor(known ? theme::kText : theme::kMuted);
if (known) std::snprintf(line, sizeof line, "%03d\xC2\xB0 %s", static_cast<int>(std::lround(heading)) % 360, cardinal(heading));
else std::snprintf(line, sizeof line, "---\xC2\xB0");
c.drawString(line, x, area.y + 3);
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
if (!known) {
c.drawString("North is not known:", x, area.y + 22);
c.drawString("walk, or aim at the", x, area.y + 31);
c.drawString("sun and press s", x, area.y + 40);
} else {
const char* from = heading_.source() == HeadingFilter::Source::Sun ? "the sun" : "the course";
int held = static_cast<int>(heading_.heldSeconds());
if (held < 3) std::snprintf(line, sizeof line, "from %s", from);
else if (held < 120) std::snprintf(line, sizeof line, "%s, %d s ago", from, held);
else std::snprintf(line, sizeof line, "%s, %d min ago", from, held / 60);
// The gyroscope drifts: after a few minutes without north, say it is a guess.
c.setTextColor(held >= 180 ? theme::kWarning : theme::kMuted);
c.drawString(line, x, area.y + 22);
c.setTextColor(theme::kMuted);
if (placed) {
std::snprintf(line, sizeof line, "%.1f km/h", s.speedKmh);
c.drawString(line, x, area.y + 31);
}
}
c.setFont(&fonts::bold);
int y = area.y + 54;
if (target) {
c.setTextColor(theme::kAccent);
c.drawString(target->name.c_str(), x, y);
c.setFont(&fonts::body);
c.setTextColor(theme::kText);
if (placed) {
std::snprintf(line, sizeof line, "%s %03d\xC2\xB0", formatDistance(distance).c_str(), static_cast<int>(std::lround(bearing)) % 360);
c.drawString(line, x, y + theme::kLineHeight);
} else c.drawString("No Fix", x, y + theme::kLineHeight);
} else {
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString("No target: t", x, y);
c.drawString("m marks this place", x, y + 9);
}
// The level: the bubble goes to the higher side, and is in the middle when the device is flat.
const int lr = 17, bubble = 5, lx = x + lr, ly = area.y + area.h - lr - 3;
float bx = kLevelX * heading_.tiltX(), by = kLevelY * heading_.tiltY();
bool flat = std::sqrt(bx * bx + by * by) < 0.035f; // within 2 degrees
const float kFull = 0.26f; // 15 degrees puts the bubble at the edge
float reach = std::sqrt(bx * bx + by * by) / kFull;
if (reach > 1.0f) {
bx /= reach;
by /= reach;
}
c.drawCircle(lx, ly, lr, theme::kMuted);
c.drawCircle(lx, ly, bubble + 2, theme::kFaint);
const float travel = lr - bubble - 1;
c.fillCircle(lx + static_cast<int>(std::lround(bx / kFull * travel)), ly + static_cast<int>(std::lround(by / kFull * travel)), bubble,
flat ? theme::kGood : theme::kText);
c.setFont(&fonts::small);
if (!refusal_.empty() && millis() - refusalMs_ < kSayMs) {
c.setTextColor(theme::kWarning);
c.drawString(refusal_.c_str(), lx + lr + 5, ly - 3);
} else {
c.setTextColor(theme::kMuted);
c.drawString(flat ? "flat" : "tilted", lx + lr + 5, ly - 3);
}
}
void GnssApp::drawPlaces(Canvas& c) {
const auto& area = theme::kContent;
const GnssState& s = gnss_.state();
c.setFont(&fonts::bold);
c.setTextColor(theme::kText);
if (mode_ == Mode::Name) {
c.drawString("A name for this place", 4, area.y + 4);
widgets::lineEditor(c, name_, {4, area.y + 24, area.w - 8, 0});
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
c.drawString("The Compass will lead back to it.", 4, area.y + 46);
c.drawString("Left empty, it gets a number.", 4, area.y + 46 + theme::kLineHeight);
return;
}
c.drawString("Where to?", 4, area.y + 2);
theme::Rect rows{0, area.y + 18, area.w, list_.visibleRows() * theme::kLineHeight};
widgets::list(
c, list_, rows,
[&](int i) { return i == 0 ? std::string("No target") : places_[static_cast<size_t>(i - 1)].name; },
[&](int i) {
if (i == 0 || !s.positionValid) return std::string();
const Place& p = places_[static_cast<size_t>(i - 1)];
return formatDistance(distanceMeters(s.latitude, s.longitude, p.latitude, p.longitude));
});
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString(places_.empty() ? "No place yet: m in the compass marks one" : "Enter: go there d: remove it", 4, area.y + area.h - 9);
if (dialog_ && list_.selected() > 0) widgets::dialog(c, "Remove this place?", places_[static_cast<size_t>(list_.selected() - 1)].name, *dialog_);
}
} // namespace roro } // namespace roro
+46 -6
View File
@@ -1,34 +1,74 @@
#pragma once #pragma once
#include <memory>
#include <string> #include <string>
#include <vector>
#include "app.h" #include "app.h"
#include "compass.h"
#include "dialog_model.h"
#include "line_editor.h"
#include "list_model.h"
#include "places.h"
#include "services/gnss_service.h" #include "services/gnss_service.h"
#include "services/storage_service.h"
#include "settings.h" #include "settings.h"
namespace roro { namespace roro {
// GNSS (see docs/milestones/M2.md, Q59): the Position view, and the Sky view with every satellite // GNSS (see docs/milestones/M2.md, Q59): the Position view, the Sky view with every satellite in
// in view placed by direction and elevation. Tab switches between them. // view placed by direction and elevation, and the Compass (issue #16, docs/milestones/H1.md): a
// rose that turns with the device, and the way to a Place the user marked. Tab goes round them.
class GnssApp : public App { class GnssApp : public App {
public: public:
GnssApp(GnssService& gnss, const Settings& settings) : gnss_(gnss), settings_(settings) {} GnssApp(GnssService& gnss, const Settings& settings, StorageService& storage)
void onEnter() override { requestRedraw(); } : gnss_(gnss), settings_(settings), storage_(storage) {}
void onEnter() override;
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
bool textEntryActive() const override { return mode_ == Mode::Name; }
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override; void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
bool badge() const override { return gnss_.tracking(); }
private: private:
enum class View { Position, Sky, Compass };
// Over the Compass: the list of Places, and the name of a new one.
enum class Mode { View, Places, Name };
void drawPosition(Canvas& c); void drawPosition(Canvas& c);
void drawSky(Canvas& c); void drawSky(Canvas& c);
void drawCompass(Canvas& c);
void drawPlaces(Canvas& c);
bool compassKey(const KeyEvent& e);
bool placesKey(const KeyEvent& e);
void readImu();
void loadPlaces();
void savePlaces();
void mark();
void aimAtSun();
void say(const std::string& text);
GnssService& gnss_; GnssService& gnss_;
const Settings& settings_; const Settings& settings_;
bool sky_ = false; StorageService& storage_;
View view_ = View::Position;
Mode mode_ = Mode::View;
uint32_t lastDrawMs_ = 0; uint32_t lastDrawMs_ = 0;
std::string refusal_; // why a Track couldn't start, shown for a few seconds std::string refusal_; // why something couldn't be done, or that it was: shown for a few seconds
uint32_t refusalMs_ = 0; uint32_t refusalMs_ = 0;
gnss::HeadingFilter heading_;
uint32_t imuUs_ = 0, imuMs_ = 0;
int courseSecond_ = -1; // the Fix whose course was last given to the filter
float shownHeading_ = -1, shownTiltX_ = 0, shownTiltY_ = 0;
std::vector<gnss::Place> places_;
bool placesLoaded_ = false;
int target_ = -1; // in places_; -1: none
ListModel list_{6};
LineEditor name_{gnss::kPlaceNameBytes};
std::unique_ptr<DialogModel> dialog_;
}; };
} // namespace roro } // namespace roro
+1
View File
@@ -26,6 +26,7 @@ class IrcApp : public App {
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override; void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override; const char* helpTitle() const override;
bool badge() const override { return irc_.totalUnread() > 0; }
private: private:
enum class Page { Chat, Settings }; enum class Page { Chat, Settings };
+89 -5
View File
@@ -1,27 +1,111 @@
#include "launcher_app.h" #include "launcher_app.h"
#include "ui/fonts.h"
#include "ui/icons.h"
#include "ui/widgets.h" #include "ui/widgets.h"
namespace roro { namespace roro {
void LauncherApp::onEnter() { list_.setCount(static_cast<int>(manager_->visibleApps().size())); } void LauncherApp::onEnter() {
int count = static_cast<int>(manager_->visibleApps().size());
list_.setCount(count);
grid_.setCount(count);
badges_ = badges();
}
bool LauncherApp::onKey(const KeyEvent& e) { bool LauncherApp::onKey(const KeyEvent& e) {
bool list = asList();
switch (e.key) { switch (e.key) {
case Key::Up: list_.up(); break; case Key::Up: list ? list_.up() : grid_.up(); break;
case Key::Down: list_.down(); break; case Key::Down: list ? list_.down() : grid_.down(); break;
case Key::Left:
if (list) return false;
grid_.left();
break;
case Key::Right:
if (list) return false;
grid_.right();
break;
case Key::Select: case Key::Select:
if (list_.selected() >= 0) manager_->open(manager_->visibleApps()[list_.selected()]->id); if (list_.selected() >= 0) manager_->open(manager_->visibleApps()[list_.selected()]->id);
return true; return true;
default: return false; default: return false;
} }
// The view that didn't move follows.
if (list) grid_.select(list_.selected());
else list_.select(grid_.selected());
requestRedraw(); requestRedraw();
return true; return true;
} }
void LauncherApp::draw(Canvas& c) { uint32_t LauncherApp::badges() const {
uint32_t bits = 0;
auto apps = manager_->visibleApps(); auto apps = manager_->visibleApps();
widgets::list(c, list_, theme::kContent, [&](int i) { return std::string(apps[i]->title); }); for (size_t i = 0; i < apps.size() && i < 32; ++i)
if (apps[i]->app->badge()) bits |= 1u << i;
return bits;
}
void LauncherApp::update(uint32_t) {
uint32_t now = badges();
if (now == badges_) return;
badges_ = now;
requestRedraw();
}
void LauncherApp::draw(Canvas& c) {
if (!asList()) return drawGrid(c);
auto apps = manager_->visibleApps();
widgets::list(c, list_, theme::kContent, [&](int i) { return std::string(apps[i]->title); },
[&](int i) { return std::string(badges_ & (1u << i) ? "*" : ""); });
}
void LauncherApp::drawGrid(Canvas& c) {
using namespace theme;
auto apps = manager_->visibleApps();
int selected = grid_.selected();
for (int row = 0; row < kRows; ++row) {
for (int col = 0; col < kCols; ++col) {
int i = (grid_.firstVisibleRow() + row) * kCols + col;
if (i >= static_cast<int>(apps.size())) break;
int x = kContent.x + col * kTileW, y = kContent.y + row * kTileH;
int ix = x + (kTileW - icons::kSize) / 2, iy = y + (kTileH - icons::kSize) / 2;
// As on the website: cyan on black, and the chosen one on a cyan tile with notched corners,
// in the Status Bar's slate.
uint16_t ink = i == selected ? kFaint : kAccent;
if (i == selected) {
int tx = ix - 6, tw = icons::kSize + 12;
c.fillRect(tx + 4, y, tw - 8, kTileH, kAccent);
c.fillRect(tx + 2, y + 2, tw - 4, kTileH - 4, kAccent);
c.fillRect(tx, y + 4, tw, kTileH - 8, kAccent);
}
if (apps[i]->icon) {
c.drawBitmap(ix, iy, apps[i]->icon, icons::kSize, icons::kSize, ink);
} else { // an App without a picture: its first letter, framed
c.drawRect(ix + 2, iy + 2, icons::kSize - 4, icons::kSize - 4, ink);
c.setFont(&fonts::large);
c.setTextColor(ink);
c.setTextDatum(middle_center);
c.drawString(std::string(1, apps[i]->title[0]).c_str(), ix + icons::kSize / 2, iy + icons::kSize / 2);
}
if (badges_ & (1u << i)) { // a square dot on the icon's corner, with a margin to stand out from it
c.fillRect(ix + icons::kSize - 6, iy - 1, 10, 10, i == selected ? kAccent : kBackground);
c.fillRect(ix + icons::kSize - 4, iy + 1, 6, 6, kWarning);
}
}
}
// More rows than fit: where these are among them.
if (grid_.rows() > kRows) {
int h = kRows * kTileH, barH = h * kRows / grid_.rows();
c.fillRect(kContent.x + kContent.w - 2, kContent.y + (h - barH) * grid_.firstVisibleRow() / (grid_.rows() - kRows), 2, barH, kMuted);
}
if (selected >= 0) {
c.setFont(&fonts::bold);
c.setTextColor(kText);
c.setTextDatum(top_center);
c.drawString(apps[selected]->title, kContent.x + kContent.w / 2, kContent.y + kRows * kTileH + 1);
}
c.setTextDatum(top_left);
} }
} // namespace roro } // namespace roro
+18 -1
View File
@@ -3,23 +3,40 @@
#include "app.h" #include "app.h"
#include "app_keys.h" #include "app_keys.h"
#include "app_manager.h" #include "app_manager.h"
#include "grid_model.h"
#include "list_model.h" #include "list_model.h"
#include "settings.h"
#include "ui/theme.h" #include "ui/theme.h"
namespace roro { namespace roro {
// Home screen: lists the visible Apps; Select opens one. // Home screen: the visible Apps as a grid of icons, with the selected one's name under it, or as
// a list of names (Settings > Display > Launcher). Select opens one. An App with something going on has a
// dot on its tile (App::badge, issue #9).
class LauncherApp : public App { class LauncherApp : public App {
public: public:
void setManager(AppManager& manager) { manager_ = &manager; } void setManager(AppManager& manager) { manager_ = &manager; }
void setSettings(const Settings& settings) { settings_ = &settings; }
void onEnter() override; void onEnter() override;
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override { keys::add(out, keys::kLauncher); } void help(std::vector<KeyHelp>& out) const override { keys::add(out, keys::kLauncher); }
private: private:
static constexpr int kCols = 4, kRows = 3;
static constexpr int kTileW = theme::kWidth / kCols, kTileH = 36;
bool asList() const { return settings_ && settings_->getBool(Setting::LauncherList); }
uint32_t badges() const; // one bit a visible App
void drawGrid(Canvas& c);
AppManager* manager_ = nullptr; AppManager* manager_ = nullptr;
const Settings* settings_ = nullptr;
// One selection, kept in both: the view can change in the Settings.
ListModel list_{theme::kContent.h / theme::kLineHeight}; ListModel list_{theme::kContent.h / theme::kLineHeight};
GridModel grid_{kCols, kRows};
uint32_t badges_ = 0;
}; };
} // namespace roro } // namespace roro
+1 -1
View File
@@ -327,7 +327,7 @@ void LoraScannerApp::drawSweep(Canvas& c) {
uint32_t hz = radio_.config().frequencyHz; uint32_t hz = radio_.config().frequencyHz;
if (hz >= f.fromHz && hz <= f.fromHz + (f.steps - 1) * f.stepHz) { if (hz >= f.fromHz && hz <= f.fromHz + (f.steps - 1) * f.stepHz) {
int x = x0 + static_cast<int>((hz - f.fromHz) / f.stepHz) * cell + cell / 2; int x = x0 + static_cast<int>((hz - f.fromHz) / f.stepHz) * cell + cell / 2;
for (int y = barTop; y < barBottom; y += 3) c.drawPixel(x, y, theme::kMessage); for (int y = barTop; y < barBottom; y += 3) c.drawPixel(x, y, theme::kGood);
} }
// The waterfall. // The waterfall.
+1
View File
@@ -28,6 +28,7 @@ class LoraScannerApp : public App {
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override; void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override; const char* helpTitle() const override;
bool badge() const override { return capture_.capturing(); }
private: private:
enum class View { Packets, Details, Presets, Sweep }; enum class View { Packets, Details, Presets, Sweep };
+27 -4
View File
@@ -15,7 +15,9 @@ using Row = SettingsMenu::Row;
void SettingsApp::onEnter() { void SettingsApp::onEnter() {
page_ = Page::Menu; page_ = Page::Menu;
menu_.close();
list_.setCount(menu_.count()); list_.setCount(menu_.count());
list_.select(0);
} }
void SettingsApp::warn(const std::string& text) { void SettingsApp::warn(const std::string& text) {
@@ -56,7 +58,12 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) {
if (menu_.kind(i) == Kind::Toggle) menu_.toggle(i); if (menu_.kind(i) == Kind::Toggle) menu_.toggle(i);
return true; return true;
case Key::Select: break; case Key::Select: break;
default: return false; // Back leaves Settings case Key::Back:
if (!menu_.close()) return false; // from the groups, Back leaves Settings
list_.setCount(menu_.count());
list_.select(menu_.groupIndex());
return true;
default: return false;
} }
editingRow_ = i; editingRow_ = i;
switch (menu_.kind(i)) { switch (menu_.kind(i)) {
@@ -72,6 +79,11 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) {
break; break;
case Kind::Toggle: menu_.toggle(i); break; case Kind::Toggle: menu_.toggle(i); break;
case Kind::Slider: break; case Kind::Slider: break;
case Kind::Group:
menu_.open(i);
list_.setCount(menu_.count());
list_.select(0);
break;
case Kind::Page: case Kind::Page:
switch (menu_.row(i)) { switch (menu_.row(i)) {
case Row::Wifi: case Row::Wifi:
@@ -194,16 +206,27 @@ std::vector<std::string> SettingsApp::aboutLines() const {
void SettingsApp::draw(Canvas& c) { void SettingsApp::draw(Canvas& c) {
const auto& area = theme::kContent; const auto& area = theme::kContent;
switch (page_) { switch (page_) {
case Page::Menu: case Page::Menu: {
// An open group has its name on a line of its own, over its rows.
theme::Rect rows = area;
if (menu_.inGroup()) {
c.setFont(&fonts::bold);
c.setTextDatum(top_left);
c.setTextColor(theme::kMuted);
c.drawString(menu_.title().c_str(), 4, area.y + 1);
rows.y += theme::kLineHeight;
rows.h -= theme::kLineHeight;
}
widgets::list( widgets::list(
c, list_, area, [this](int i) { return menu_.label(i); }, c, list_, rows, [this](int i) { return menu_.label(i); },
[this](int i) { [this](int i) {
auto kind = menu_.kind(i); auto kind = menu_.kind(i);
if (kind == Kind::Page) return std::string(">"); if (kind == Kind::Page || kind == Kind::Group) return std::string(">");
if (kind == Kind::Slider) return "< " + menu_.value(i) + " >"; if (kind == Kind::Slider) return "< " + menu_.value(i) + " >";
return menu_.value(i); return menu_.value(i);
}); });
break; break;
}
case Page::Text: case Page::Text:
c.setFont(&fonts::body); c.setFont(&fonts::body);
c.setTextColor(theme::kMuted); c.setTextColor(theme::kMuted);
+1 -1
View File
@@ -35,7 +35,7 @@ struct SettingsAppDeps {
VpnService& vpn; VpnService& vpn;
}; };
// Settings: every user-facing setting, plus the Wi-Fi, Firmware, Debug Console and About pages. // Settings: every user-facing setting, in groups, plus the Wi-Fi, VPN, Firmware, Debug Console and About pages.
class SettingsApp : public App { class SettingsApp : public App {
public: public:
explicit SettingsApp(const SettingsAppDeps& deps) explicit SettingsApp(const SettingsAppDeps& deps)
+4 -2
View File
@@ -311,7 +311,9 @@ bool SshApp::onKey(const KeyEvent& e) {
} }
void SshApp::update(uint32_t nowMs) { void SshApp::update(uint32_t nowMs) {
if (!message_.empty() && nowMs - messageMs_ >= kMessageMs) { // nowMs was read before this pass handled its keys: a message a key just set is "later" than it,
// and an unsigned difference would call that 49 days old, and clear it at once.
if (!message_.empty() && static_cast<int32_t>(nowMs - messageMs_) >= static_cast<int32_t>(kMessageMs)) {
message_.clear(); message_.clear();
requestRedraw(); requestRedraw();
} }
@@ -422,7 +424,7 @@ void SshApp::drawTerminal(Canvas& c) {
c.setFont(&fonts::small); c.setFont(&fonts::small);
c.setTextDatum(top_right); c.setTextDatum(top_right);
c.fillRect(area.x + area.w - 5 * static_cast<int>(where.size()) - 3, area.y, 5 * static_cast<int>(where.size()) + 3, 9, theme::kAccent); c.fillRect(area.x + area.w - 5 * static_cast<int>(where.size()) - 3, area.y, 5 * static_cast<int>(where.size()) + 3, 9, theme::kAccent);
c.setTextColor(0x0000); c.setTextColor(theme::kOnAccent);
c.drawString(where.c_str(), area.x + area.w - 1, area.y + 1); c.drawString(where.c_str(), area.x + area.w - 1, area.y + 1);
c.setTextDatum(top_left); c.setTextDatum(top_left);
} }
+1
View File
@@ -28,6 +28,7 @@ class SshApp : public App {
bool textEntryActive() const override { return view_ == View::Entry || (view_ == View::Session && !dialog_); } bool textEntryActive() const override { return view_ == View::Entry || (view_ == View::Session && !dialog_); }
void help(std::vector<KeyHelp>& out) const override; void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override; const char* helpTitle() const override;
bool badge() const override { return ssh_.active(); }
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
+4 -4
View File
@@ -232,7 +232,7 @@ void SystemApp::drawOverview(Canvas& c) {
const auto& h = loadHistory_[core]; const auto& h = loadHistory_[core];
for (size_t i = 1; i < h.size(); i++) { for (size_t i = 1; i < h.size(); i++) {
int x0 = gx + 1 + static_cast<int>((i - 1) * gw / kHistory), x1 = gx + 1 + static_cast<int>(i * gw / kHistory); int x0 = gx + 1 + static_cast<int>((i - 1) * gw / kHistory), x1 = gx + 1 + static_cast<int>(i * gw / kHistory);
c.drawLine(x0, gy + gh - h.at(i - 1) * gh / 100, x1, gy + gh - h.at(i) * gh / 100, core ? theme::kMessage : theme::kAccent); c.drawLine(x0, gy + gh - h.at(i - 1) * gh / 100, x1, gy + gh - h.at(i) * gh / 100, core ? theme::kGood : theme::kAccent);
} }
} }
} }
@@ -290,7 +290,7 @@ void SystemApp::drawMemory(Canvas& c) {
struct { struct {
size_t bytes; size_t bytes;
uint16_t color; uint16_t color;
} floors[] = {{GeminiService::kStartFloor, theme::kMuted}, {GeminiService::kSteadyFloor, theme::kWarning}, {GeminiService::kTransientFloor, theme::kMessage}}; } floors[] = {{GeminiService::kStartFloor, theme::kMuted}, {GeminiService::kSteadyFloor, theme::kWarning}, {GeminiService::kTransientFloor, theme::kGood}};
for (auto& f : floors) { for (auto& f : floors) {
int kb = static_cast<int>(f.bytes / 1024), y = yOf(kb); int kb = static_cast<int>(f.bytes / 1024), y = yOf(kb);
for (int x = gx + 1; x < gx + gw - 1; x += 4) c.drawPixel(x, y, f.color); for (int x = gx + 1; x < gx + gw - 1; x += 4) c.drawPixel(x, y, f.color);
@@ -335,13 +335,13 @@ void SystemApp::drawNetwork(Canvas& c) {
for (size_t i = 0; i < kUsers; i++) { for (size_t i = 0; i < kUsers; i++) {
bool active = rateIn_[i] || rateOut_[i]; bool active = rateIn_[i] || rateOut_[i];
c.setTextDatum(top_left); c.setTextDatum(top_left);
c.setTextColor(active ? theme::kMessage : theme::kText); c.setTextColor(active ? theme::kGood : theme::kText);
c.drawString(net::userName(static_cast<net::User>(i)), 4, y); c.drawString(net::userName(static_cast<net::User>(i)), 4, y);
c.setTextDatum(top_right); c.setTextDatum(top_right);
c.setTextColor(theme::kText); c.setTextColor(theme::kText);
c.drawString(net::formatTraffic(traffic_[i].in).c_str(), 124, y); c.drawString(net::formatTraffic(traffic_[i].in).c_str(), 124, y);
c.drawString(net::formatTraffic(traffic_[i].out).c_str(), 168, y); c.drawString(net::formatTraffic(traffic_[i].out).c_str(), 168, y);
c.setTextColor(active ? theme::kMessage : theme::kMuted); c.setTextColor(active ? theme::kGood : theme::kMuted);
c.drawString(active ? net::formatTraffic(rateIn_[i] + rateOut_[i]).c_str() : "-", area.w - 4, y); c.drawString(active ? net::formatTraffic(rateIn_[i] + rateOut_[i]).c_str() : "-", area.w - 4, y);
y += theme::kLineHeight; y += theme::kLineHeight;
} }
+1 -1
View File
@@ -16,7 +16,7 @@
namespace roro { namespace roro {
// Settings > VPN (issue #8): the switch, "Start with Wi-Fi", importing a `.conf` from the card // Settings > Network > VPN (issue #8): the switch, "Start with Wi-Fi", importing a `.conf` from the card
// and forgetting it, and what the tunnel is doing. No key is ever on this page. // and forgetting it, and what the tunnel is doing. No key is ever on this page.
class VpnPage { class VpnPage {
public: public:
+1 -1
View File
@@ -17,7 +17,7 @@
namespace roro { namespace roro {
// Settings → Wi-Fi: the On/Off switch, the connection and its details, DNS and NTP servers, and // Settings → Network → Wi-Fi: the On/Off switch, the connection and its details, DNS and NTP servers, and
// the Saved Networks: adding one from a scan or by name (hidden), and each one's own page with its // the Saved Networks: adding one from a scan or by name (hidden), and each one's own page with its
// IP setting, Automatic or Fixed (docs/milestones/S1.md, Q113). Owned by the Settings App. // IP setting, Automatic or Fixed (docs/milestones/S1.md, Q113). Owned by the Settings App.
class WifiSettingsPage { class WifiSettingsPage {
+3 -3
View File
@@ -232,9 +232,9 @@ void WifiToolsApp::drawChannels(Canvas& c) {
for (int ch = 1; ch <= 13; ch++) { for (int ch = 1; ch <= 13; ch++) {
int x = x0 + (ch - 1) * slot; int x = x0 + (ch - 1) * slot;
int h = static_cast<int>(load[ch] / maxLoad * barMaxH); int h = static_cast<int>(load[ch] / maxLoad * barMaxH);
uint16_t color = ch == best ? theme::kMessage : (ch == 1 || ch == 6 || ch == 11) ? theme::kAccent : theme::kMuted; uint16_t color = ch == best ? theme::kGood : (ch == 1 || ch == 6 || ch == 11) ? theme::kAccent : theme::kMuted;
if (h > 0) c.fillRect(x, baseY - h, slot - 4, h, color); if (h > 0) c.fillRect(x, baseY - h, slot - 4, h, color);
c.setTextColor(ch == best ? theme::kMessage : theme::kText); c.setTextColor(ch == best ? theme::kGood : theme::kText);
c.setTextDatum(top_center); c.setTextDatum(top_center);
c.drawString(std::to_string(ch).c_str(), x + (slot - 4) / 2, baseY + 3); c.drawString(std::to_string(ch).c_str(), x + (slot - 4) / 2, baseY + 3);
c.setTextDatum(top_left); c.setTextDatum(top_left);
@@ -265,7 +265,7 @@ void WifiToolsApp::drawTracker(Canvas& c) {
int strength = lost ? 0 : std::min(100, std::max(0, (tracker_.latest() + 95) * 100 / 60)); int strength = lost ? 0 : std::min(100, std::max(0, (tracker_.latest() + 95) * 100 / 60));
int barW = area.w - 8; int barW = area.w - 8;
c.drawRect(4, area.y + 60, barW, 8, theme::kMuted); c.drawRect(4, area.y + 60, barW, 8, theme::kMuted);
c.fillRect(5, area.y + 61, (barW - 2) * strength / 100, 6, theme::kMessage); c.fillRect(5, area.y + 61, (barW - 2) * strength / 100, 6, theme::kGood);
const int gy = area.y + 74, gh = area.h - 76; const int gy = area.y + 74, gh = area.h - 76;
const auto& h = tracker_.history(); const auto& h = tracker_.history();
+118 -21
View File
@@ -20,6 +20,7 @@
#include "services/web_share.h" #include "services/web_share.h"
#include "apps/gemini_app.h" #include "apps/gemini_app.h"
#include "apps/gnss_app.h" #include "apps/gnss_app.h"
#include "auto_update.h"
#include "apps/irc_app.h" #include "apps/irc_app.h"
#include "apps/launcher_app.h" #include "apps/launcher_app.h"
#include "apps/lora_scanner_app.h" #include "apps/lora_scanner_app.h"
@@ -62,6 +63,7 @@
#include "safe_mode.h" #include "safe_mode.h"
#include "settings.h" #include "settings.h"
#include "storage_paths.h" #include "storage_paths.h"
#include "ui/icons.h"
#include "ui/notifier.h" #include "ui/notifier.h"
#include "ui/screen.h" #include "ui/screen.h"
#include "scp_args.h" #include "scp_args.h"
@@ -80,6 +82,12 @@ static Screen screen;
static BatteryService* battery; static BatteryService* battery;
static StorageService* storageService; static StorageService* storageService;
static FileOps* fileOps; static FileOps* fileOps;
static WebShare* webShare;
// Installing a release by itself (issue #52): see autoUpdateStep().
static release::AutoUpdate autoUpdate;
static std::unique_ptr<DialogModel> autoDialog;
static uint32_t lastKeyMs = 0;
static bool autoRedraw = false;
static std::vector<std::string> filesInUse(const std::string& path, bool folder); static std::vector<std::string> filesInUse(const std::string& path, bool folder);
static PowerService* power; static PowerService* power;
static ClockService* clockService; static ClockService* clockService;
@@ -192,6 +200,7 @@ void setup() {
Serial.begin(115200); Serial.begin(115200);
settings.load(); settings.load();
theme::apply(ui::palette(settings.getInt(Setting::Theme), settings.getBool(Setting::ThemeLight)));
if (safeMode) return setupSafeMode(crashes); if (safeMode) return setupSafeMode(crashes);
battery = new BatteryService(bus); battery = new BatteryService(bus);
@@ -230,19 +239,20 @@ void setup() {
apps = new AppManager(launcher); apps = new AppManager(launcher);
launcher.setManager(*apps); launcher.setManager(*apps);
apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)}); launcher.setSettings(settings);
apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService)}); apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus), icons::irc});
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)}); apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService), icons::wifi_tools});
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)}); apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings, *storageService), icons::gnss});
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)}); apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService), icons::gemini});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *new WebShare(*storageService, *fileOps, *wifi))}); apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService), icons::lora});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)}); apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *(webShare = new WebShare(*storageService, *fileOps, *wifi))), icons::storage});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power), icons::notes});
sshService = new SshService(settings); sshService = new SshService(settings);
sshApp = new SshApp(*sshService, settings, *storageService); sshApp = new SshApp(*sshService, settings, *storageService);
scpService = new ScpService(settings, *storageService, *fileOps); scpService = new ScpService(settings, *storageService, *fileOps);
apps->registerApp({"ssh", "SSH", false, sshApp}); apps->registerApp({"ssh", "SSH", false, sshApp, icons::ssh});
shellApp = new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps); shellApp = new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps);
apps->registerApp({"shell", "Shell", false, shellApp}); apps->registerApp({"shell", "Shell", false, shellApp, icons::shell});
shellApp->asking = [] { return scpService->asking(); }; shellApp->asking = [] { return scpService->asking(); };
shellApp->answer = [](const std::string& password) { scpService->answer(password); }; shellApp->answer = [](const std::string& password) { scpService->answer(password); };
shellApp->abort = [] { scpService->cancel(); }; shellApp->abort = [] { scpService->cancel(); };
@@ -252,9 +262,9 @@ void setup() {
// A long note being rewritten (issue #47): the editor waits for the card, so it draws from there. // A long note being rewritten (issue #47): the editor waits for the card, so it draws from there.
NoteEditor::onProgress = [](const std::string& name, int percent) { screen.renderUpdate("Saving", name, percent); }; NoteEditor::onProgress = [](const std::string& name, int percent) { screen.renderUpdate("Saving", name, percent); };
apps->registerApp({"system", "System", false, apps->registerApp({"system", "System", false,
new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs)}); new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs), icons::system});
apps->registerApp({"settings", "Settings", false, apps->registerApp({"settings", "Settings", false,
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update, *vpnService})}); new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update, *vpnService}), icons::settings});
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)}); apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});
apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)}); apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)});
@@ -566,6 +576,10 @@ static void updateCommand(const String& args) {
console.printf("update: running %s, failed here before: %s, daily check %s, heap %u\n", update->runningVersion().c_str(), console.printf("update: running %s, failed here before: %s, daily check %s, heap %u\n", update->runningVersion().c_str(),
update->failedVersion().empty() ? "none" : update->failedVersion().c_str(), update->failedVersion().empty() ? "none" : update->failedVersion().c_str(),
settings.getBool(Setting::CheckUpdates) ? "on" : "off", (unsigned)ESP.getFreeHeap()); settings.getBool(Setting::CheckUpdates) ? "on" : "off", (unsigned)ESP.getFreeHeap());
console.printf("update: installing by itself %s%s%s\n", settings.getBool(Setting::InstallUpdates) ? "on" : "off",
autoUpdate.waiting() ? ", waiting for a quiet moment to offer " : autoUpdate.asking() ? ", asking about " : "",
autoUpdate.waiting() || autoUpdate.asking() ? autoUpdate.tag().c_str() : "");
console.printf("update: the last download was carried on %d time(s) after a break\n", update->lastResumes());
console.printf("update: gitea %s %s\n", g.status() == GiteaReleases::Status::Done ? "done" : g.status() == GiteaReleases::Status::Failed ? "failed" : g.status() == GiteaReleases::Status::Busy ? "busy" : "never asked", g.error().c_str()); console.printf("update: gitea %s %s\n", g.status() == GiteaReleases::Status::Done ? "done" : g.status() == GiteaReleases::Status::Failed ? "failed" : g.status() == GiteaReleases::Status::Busy ? "busy" : "never asked", g.error().c_str());
printReleases(false); printReleases(false);
} else if (args.startsWith("install ")) { } else if (args.startsWith("install ")) {
@@ -581,7 +595,8 @@ static void updateCommand(const String& args) {
} else if (args.startsWith("damage ")) { // update damage cut <bytes> | flip <offset>: for the next download } else if (args.startsWith("damage ")) { // update damage cut <bytes> | flip <offset>: for the next download
long n = args.substring(args.lastIndexOf(' ') + 1).toInt(); long n = args.substring(args.lastIndexOf(' ') + 1).toInt();
args.startsWith("damage cut") ? update->damageNextDownload(n, -1) : update->damageNextDownload(-1, n); args.startsWith("damage cut") ? update->damageNextDownload(n, -1) : update->damageNextDownload(-1, n);
console.printf("update: the next download will be %s at byte %ld\n", args.startsWith("damage cut") ? "cut" : "damaged", n); if (args.startsWith("damage cut")) console.printf("update: each connection of the next download will break after %ld bytes\n", n);
else console.printf("update: the next download will be damaged at byte %ld\n", n);
} else if (args == "daily") { // forget today's check: the daily one runs again at once, if it may } else if (args == "daily") { // forget today's check: the daily one runs again at once, if it may
update->forgetToday(); update->forgetToday();
@@ -681,6 +696,7 @@ static const char* const kHelp =
"lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100)\n" "lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100)\n"
"lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)\n" "lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)\n"
"gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)\n" "gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)\n"
"imu what the motion sensor reads: g on its three axes, then degrees a second\n"
"gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n" "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" "crash the last crash: firmware, reason, task, backtrace\n"
"coredump erase forget the core dump in flash\n" "coredump erase forget the core dump in flash\n"
@@ -692,7 +708,7 @@ static const char* const kHelp =
"irc start | irc stop | irc dump | irc say <buffer> <text>\n" "irc start | irc stop | irc dump | irc say <buffer> <text>\n"
"install <path.ota> Update from SD\n" "install <path.ota> Update from SD\n"
"update check | update list | update status | update install <tag> the project's releases on Gitea\n" "update check | update list | update status | update install <tag> the project's releases on Gitea\n"
"sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n" "sd card | sd list | cat <path> | log <text> | burst | sound on|off | theme [0-6] [light|dark] | short | normal\n"
"Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command\n" "Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command\n"
"ping <host> [count] [size] | nslookup <name> [server] | port <host> <port> | traceroute <host> | cancel is it there, does its name resolve, is its port open, which way; one at a time\n" "ping <host> [count] [size] | nslookup <name> [server] | port <host> <port> | traceroute <host> | cancel is it there, does its name resolve, is its port open, which way; one at a time\n"
"tls <host> [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one\n" "tls <host> [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one\n"
@@ -700,8 +716,8 @@ static const char* const kHelp =
"uname the firmware, its version and the chip it is built for\n" "uname the firmware, its version and the chip it is built for\n"
"scp [-f] [-P port] <file on the card> user@host:path | scp [-f] [-P port] user@host:path <file on the card> one file over SSH, with this device's key or, in the Shell, a password, to a server the SSH App already trusts; -f replaces a file on the card; `cancel` stops it\n" "scp [-f] [-P port] <file on the card> user@host:path | scp [-f] [-P port] user@host:path <file on the card> one file over SSH, with this device's key or, in the Shell, a password, to a server the SSH App already trusts; -f replaces a file on the card; `cancel` stops it\n"
"ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here\n" "ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here\n"
"vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself\n" "vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > Network > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself\n"
"debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\n" "debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > System > Debug Console); with seconds, it comes back\n"
"debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token\n" "debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token\n"
"crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n" "crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n"
"wifi ip ... try <seconds> | wifi ip keep a trial IP setting: back to the previous one unless kept\n" "wifi ip ... try <seconds> | wifi ip keep a trial IP setting: back to the previous one unless kept\n"
@@ -845,8 +861,57 @@ static void screenshotKey() {
} }
// Every key goes through here, from the keyboard or from a console's `key`. // Every key goes through here, from the keyboard or from a console's `key`.
// Installing a release by itself (issue #52, Settings > System > Install updates): once the daily
// check has found one, wait until nothing would be cut by a restart and no key was pressed for a
// while, then ask on the screen, over whatever is open. Left unanswered for 30 s, it is installed.
// Would a restart now cut something short?
static bool busyForRestart() {
return gnssService->tracking() || loraCapture->capturing() || upload.active() || fileOps->busy() ||
(scpService && scpService->busy()) || (sshService && sshService->active()) || (webShare && webShare->running()) ||
(noiseTest && noiseTest->running()) || update->phase() != UpdateService::Phase::Idle || update->onProbation() ||
update->giteaBusy();
}
static void autoUpdateStep(uint32_t now) {
static int shownSeconds = -1;
if (!settings.getBool(Setting::InstallUpdates) && (autoUpdate.waiting() || autoUpdate.asking())) autoUpdate.withdraw();
std::string tag;
if (update->takeOffer(tag)) autoUpdate.offer(tag);
if (autoDialog && autoDialog->result() != DialogModel::kPending) autoUpdate.answer(autoDialog->result() == 1);
switch (autoUpdate.step(now, busyForRestart(), now - lastKeyMs)) {
case release::AutoUpdate::Action::None: break;
case release::AutoUpdate::Action::Ask:
autoDialog.reset(new DialogModel({"Cancel", "Accept"}));
power->onNotification(now, now + release::AutoUpdate::kAskMs); // a screen that is off lights up for it
autoRedraw = true;
break;
case release::AutoUpdate::Action::Dismiss:
autoDialog.reset();
autoRedraw = true;
break;
case release::AutoUpdate::Action::Install: {
autoDialog.reset();
autoRedraw = true;
std::string why = update->requestInstall(autoUpdate.tag(), true);
if (!why.empty())
bus.publish(Event::withText(EventType::Notification, ("Update not installed: " + why).c_str(), static_cast<int32_t>(NotificationLevel::Warning)));
break;
}
}
int seconds = autoUpdate.asking() ? autoUpdate.secondsLeft(now) : -1;
if (seconds != shownSeconds) {
shownSeconds = seconds;
autoRedraw = true;
}
}
static void handleKey(const KeyEvent& e) { static void handleKey(const KeyEvent& e) {
if (e.key == Key::Screenshot) return screenshotKey(); if (e.key == Key::Screenshot) return screenshotKey();
if (autoDialog) { // the question takes every key until it is answered
autoDialog->onKey(e);
return;
}
apps->handleKey(e); apps->handleKey(e);
} }
@@ -894,13 +959,13 @@ static void debugCommand(const String& args, bool fromSerial) {
debugResumes = true; debugResumes = true;
console.printf("debug: off for %lu s\n", (unsigned long)seconds); console.printf("debug: off for %lu s\n", (unsigned long)seconds);
} else if (!fromSerial) { } else if (!fromSerial) {
console.println("debug: over USB serial only (or Settings > Debug Console)"); console.println("debug: over USB serial only (or Settings > System > Debug Console)");
} else if (args == "on") { } else if (args == "on") {
DebugConsole::switchOn(settings); DebugConsole::switchOn(settings);
console.println("debug: on (the token is in Settings > Debug Console)"); console.println("debug: on (the token is in Settings > System > Debug Console)");
} else if (args == "token new") { } else if (args == "token new") {
settings.setString(Setting::DebugToken, DebugConsole::freshToken()); settings.setString(Setting::DebugToken, DebugConsole::freshToken());
console.println("debug: a new token (it is in Settings > Debug Console)"); console.println("debug: a new token (it is in Settings > System > Debug Console)");
} else if (args.startsWith("token ")) { } else if (args.startsWith("token ")) {
std::string token = debug::tidyToken(args.substring(6).c_str()); std::string token = debug::tidyToken(args.substring(6).c_str());
bool ok = debug::validToken(token) && settings.setString(Setting::DebugToken, token); bool ok = debug::validToken(token) && settings.setString(Setting::DebugToken, token);
@@ -1138,10 +1203,18 @@ static void runCommand(String line, bool fromSerial = false) {
if (found) radioService->setPreset(p); if (found) radioService->setPreset(p);
console.printf("lora preset: %s\n", found ? p.name : "unknown (LongFast LongSlow MediumSlow MediumFast ShortSlow ShortFast LongMod MeshCore)"); console.printf("lora preset: %s\n", found ? p.name : "unknown (LongFast LongSlow MediumSlow MediumFast ShortSlow ShortFast LongMod MeshCore)");
} }
if (line == "gnss quiet on" || line == "gnss quiet off") { // Settings > Pause GNSS for LoRa (issue #20) if (line == "gnss quiet on" || line == "gnss quiet off") { // Settings > GNSS and radio > Pause GNSS for LoRa (issue #20)
settings.setBool(Setting::GnssQuietForLora, line.endsWith("on")); settings.setBool(Setting::GnssQuietForLora, line.endsWith("on"));
console.printf("gnss quiet: %s\n", line.endsWith("on") ? "GNSS pauses while the LoRa radio listens" : "GNSS stays on"); console.printf("gnss quiet: %s\n", line.endsWith("on") ? "GNSS pauses while the LoRa radio listens" : "GNSS stays on");
} }
if (line == "imu") { // the Compass's sensor (issue #16), as it reads now
if (!M5.Imu.update()) console.println("imu: no reading");
else {
auto d = M5.Imu.getImuData();
console.printf("imu: type %d, accel %.2f %.2f %.2f g, gyro %.1f %.1f %.1f deg/s\n", static_cast<int>(M5.Imu.getType()),
d.accel.x, d.accel.y, d.accel.z, d.gyro.x, d.gyro.y, d.gyro.z);
}
}
if (line == "gnss status" && gnssService) gnssService->printStatus(console, millis()); if (line == "gnss status" && gnssService) gnssService->printStatus(console, millis());
if ((line == "gnss nmea on" || line == "gnss nmea off") && gnssService) gnssService->setEcho(line.endsWith("on")); 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 restart" && gnssService) gnssService->restart(millis());
@@ -1326,6 +1399,14 @@ static void runCommand(String line, bool fromSerial = false) {
settings.setBool(Setting::DnsAlways, line.endsWith("on")); settings.setBool(Setting::DnsAlways, line.endsWith("on"));
wifi->serversChanged(); wifi->serversChanged();
} }
if (line == "theme" || line.startsWith("theme ")) { // theme [0-6] [light|dark]
std::string rest = line.length() > 6 ? line.substring(6).c_str() : "";
if (!rest.empty() && isdigit(static_cast<unsigned char>(rest[0]))) settings.setInt(Setting::Theme, rest[0] - '0');
if (rest.find("light") != std::string::npos) settings.setBool(Setting::ThemeLight, true);
if (rest.find("dark") != std::string::npos) settings.setBool(Setting::ThemeLight, false);
for (int t = 0; t < ui::kThemeCount; ++t)
console.printf("theme: %d %s%s\n", t, ui::themeName(t), t == settings.getInt(Setting::Theme) ? (settings.getBool(Setting::ThemeLight) ? " (light)" : " (dark)") : "");
}
if (line == "sound off") settings.setBool(Setting::Sound, false); if (line == "sound off") settings.setBool(Setting::Sound, false);
if (line == "sound on") settings.setBool(Setting::Sound, true); if (line == "sound on") settings.setBool(Setting::Sound, true);
if (line == "short") { if (line == "short") {
@@ -1514,6 +1595,7 @@ static void loopPass() {
printListingWhenReady(); printListingWhenReady();
M5Cardputer.update(); M5Cardputer.update();
if (M5Cardputer.Keyboard.isChange()) { if (M5Cardputer.Keyboard.isChange()) {
lastKeyMs = now;
bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen
keyMapper.setTextEntry(apps->foreground().textEntryActive()); keyMapper.setTextEntry(apps->foreground().textEntryActive());
auto events = keyMapper.update(readKeys()); // always, so held keys are tracked auto events = keyMapper.update(readKeys()); // always, so held keys are tracked
@@ -1529,6 +1611,7 @@ static void loopPass() {
bus.dispatch(); bus.dispatch();
notifier->update(now); notifier->update(now);
apps->update(now); apps->update(now);
autoUpdateStep(now);
if (now - lastLog > 10000) { if (now - lastLog > 10000) {
lastLog = now; lastLog = now;
@@ -1565,10 +1648,24 @@ static void loopPass() {
StatusInfo status = currentStatus(); StatusInfo status = currentStatus();
const Toast* toast = notifier->toasts().current(now); const Toast* toast = notifier->toasts().current(now);
bool toastChanged = notifier->toasts().takeChanged(); bool toastChanged = notifier->toasts().takeChanged();
if (apps->takeRedraw() || toastChanged || status != lastStatus || redrawAfterUpdate) { // The theme, when the Settings (or the `theme` command) changed it: every colour is drawn again.
static int shownTheme = settings.getInt(Setting::Theme) * 2 + settings.getBool(Setting::ThemeLight);
int wantedTheme = settings.getInt(Setting::Theme) * 2 + settings.getBool(Setting::ThemeLight);
if (wantedTheme != shownTheme) {
shownTheme = wantedTheme;
theme::apply(ui::palette(wantedTheme / 2, wantedTheme % 2));
screen.invalidate();
redrawAfterUpdate = true;
}
if (apps->takeRedraw() || toastChanged || status != lastStatus || redrawAfterUpdate || autoRedraw) {
lastStatus = status; lastStatus = status;
redrawAfterUpdate = false; redrawAfterUpdate = false;
screen.render(*apps, status, toast); autoRedraw = false;
if (autoDialog) {
Screen::Question question{"Update to " + autoUpdate.tag() + "?",
"It installs in " + std::to_string(autoUpdate.secondsLeft(now)) + " s unless you cancel, and the device restarts.", autoDialog.get()};
screen.render(*apps, status, toast, &question);
} else screen.render(*apps, status, toast);
update->firstFrameDrawn(); update->firstFrameDrawn();
} }
} }
+13 -3
View File
@@ -27,9 +27,20 @@ std::string whyNotConnected(NetworkClientSecure& tls, const std::string& host) {
} // namespace } // namespace
std::string HttpsGet::open(const std::string& host, const std::string& path, const char* accept) { std::string HttpsGet::open(const std::string& host, const std::string& path, const char* accept) {
std::string why = request(host, path, accept, "", kNeedFree);
if (!why.empty()) return why;
if (head_.status != 200) return host + " answered " + std::to_string(head_.status) + (head_.status / 100 == 3 ? " (a redirect)" : "");
return "";
}
std::string HttpsGet::openFrom(const std::string& host, const std::string& path, const char* accept, long from, const std::string& ifRange) {
return request(host, path, accept, "Range: bytes=" + std::to_string(from) + "-\r\n" + (ifRange.empty() ? "" : "If-Range: " + ifRange + "\r\n"), kNeedFreeToCarryOn);
}
std::string HttpsGet::request(const std::string& host, const std::string& path, const char* accept, const std::string& more, size_t needFree) {
close(); close();
if (time(nullptr) < kClockSetAfter) return "The clock isn't set: can't check certificates"; if (time(nullptr) < kClockSetAfter) return "The clock isn't set: can't check certificates";
if (esp_get_free_heap_size() < kNeedFree) return "Not enough memory for a secure connection"; if (esp_get_free_heap_size() < needFree) return "Not enough memory for a secure connection";
tls_.setCACert(kTrustedRootsPem); tls_.setCACert(kTrustedRootsPem);
tls_.setTimeout(15); tls_.setTimeout(15);
@@ -38,7 +49,7 @@ std::string HttpsGet::open(const std::string& host, const std::string& path, con
raw_.reset(new (std::nothrow) uint8_t[kRaw]); raw_.reset(new (std::nothrow) uint8_t[kRaw]);
if (!raw_) return "Not enough memory"; if (!raw_) return "Not enough memory";
tls_.print(("GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\nUser-Agent: roro9stack/" + versionString() + tls_.print(("GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\nUser-Agent: roro9stack/" + versionString() +
"\r\nAccept: " + accept + "\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n") "\r\nAccept: " + accept + "\r\nAccept-Encoding: identity\r\n" + more + "Connection: close\r\n\r\n")
.c_str()); .c_str());
release::HttpHeadParser parser; release::HttpHeadParser parser;
@@ -50,7 +61,6 @@ std::string HttpsGet::open(const std::string& host, const std::string& path, con
if (parser.complete() && used < static_cast<size_t>(n)) early_.assign(reinterpret_cast<const char*>(raw_.get()) + used, n - used); if (parser.complete() && used < static_cast<size_t>(n)) early_.assign(reinterpret_cast<const char*>(raw_.get()) + used, n - used);
} }
head_ = parser.head(); head_ = parser.head();
if (head_.status != 200) return host + " answered " + std::to_string(head_.status) + (head_.status / 100 == 3 ? " (a redirect)" : "");
left_ = head_.chunked ? -1 : head_.contentLength; left_ = head_.chunked ? -1 : head_.contentLength;
return ""; return "";
} }
+11
View File
@@ -20,12 +20,22 @@ class HttpsGet {
// checking the certificate); with Q86's 20 KB to spare, it starts with at least this much free. // checking the certificate); with Q86's 20 KB to spare, it starts with at least this much free.
static constexpr size_t kNeedFree = 80 * 1024; static constexpr size_t kNeedFree = 80 * 1024;
// Carrying on a download (issue #54) happens in the middle of an install, whose own buffers
// (about 8 KB, measured: 78 KB free between two connections) are already taken out of what is
// free, and with the screen taken over nothing else will ask for memory: the peak and 12 KB.
static constexpr size_t kNeedFreeToCarryOn = 64 * 1024;
explicit HttpsGet(net::User user) : tls_(user) {} explicit HttpsGet(net::User user) : tls_(user) {}
~HttpsGet() { close(); } ~HttpsGet() { close(); }
// Connects and reads the head. "" when a 200 is on its way, or why not, in words for the screen. // Connects and reads the head. "" when a 200 is on its way, or why not, in words for the screen.
std::string open(const std::string& host, const std::string& path, const char* accept); std::string open(const std::string& host, const std::string& path, const char* accept);
// The same, for the rest of a file from byte `from` (issue #54): asks for that range, with
// `ifRange` (an ETag, or "") so that a file that changed comes whole. The answer, a 206 or
// not, is for the caller to judge with head(): "" here only says a head was read.
std::string openFrom(const std::string& host, const std::string& path, const char* accept, long from, const std::string& ifRange);
long contentLength() const { return head_.contentLength; } // -1: not known long contentLength() const { return head_.contentLength; } // -1: not known
const release::HttpHead& head() const { return head_; }
// Body bytes into `buf`: how many, 0 at the end, -1 when the connection broke or stalled // Body bytes into `buf`: how many, 0 at the end, -1 when the connection broke or stalled
// before the end. // before the end.
@@ -37,6 +47,7 @@ class HttpsGet {
static constexpr uint32_t kStallMs = 10000; static constexpr uint32_t kStallMs = 10000;
int readRaw(uint8_t* into, size_t len); // from the connection, waiting up to kStallMs int readRaw(uint8_t* into, size_t len); // from the connection, waiting up to kStallMs
std::string request(const std::string& host, const std::string& path, const char* accept, const std::string& more, size_t needFree);
Counted<NetworkClientSecure> tls_; Counted<NetworkClientSecure> tls_;
release::HttpHead head_; release::HttpHead head_;
+1 -1
View File
@@ -16,7 +16,7 @@ namespace roro {
// The GNSS receiver on the Cap LoRa-1262 (see CONTEXT.md and docs/milestones/M2.md): reads its // 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), // 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. // holds the current Fix, and sets the clock from it. Settings → GNSS and radio → GNSS switches it on and off.
class GnssService : public Service { class GnssService : public Service {
public: public:
static constexpr int kRxPin = 15, kTxPin = 13; // measured: M2 step 1 static constexpr int kRxPin = 15, kTxPin = 13; // measured: M2 step 1
+94 -12
View File
@@ -10,6 +10,7 @@
#include <ctime> #include <ctime>
#include "http_head.h" #include "http_head.h"
#include "resume.h"
#include "platform/https_get.h" #include "platform/https_get.h"
#include "platform/ota_device.h" #include "platform/ota_device.h"
#include "platform/system_info.h" #include "platform/system_info.h"
@@ -64,21 +65,61 @@ class FileSource : public UpdateSource {
File& f_; File& f_;
}; };
// A release being downloaded from Gitea: the same bytes a push or a card would give. // A release being downloaded from Gitea: the same bytes a push or a card would give. When the
// connection breaks, it is opened again for the rest of the file (issue #54): what was written
// to the slot stays, and the parser never sees the break.
class GiteaSource : public UpdateSource { class GiteaSource : public UpdateSource {
public: public:
GiteaSource(HttpsGet& get, long cutAfter, long flipAt) : get_(get), cut_(cutAfter), flip_(flipAt) {} static constexpr uint32_t kWifiWaitMs = 60000; // for Wi-Fi to come back, before each try
static constexpr uint32_t kPauseMs = 2000;
GiteaSource(HttpsGet& get, const release::Url& url, WifiService& wifi, long cutAfter, long flipAt)
: get_(get), url_(url), wifi_(wifi), total_(get.contentLength()), etag_(get.head().etag), cut_(cutAfter), flip_(flipAt) {}
int read(uint8_t* buf, size_t len) override { int read(uint8_t* buf, size_t len) override {
if (cut_ >= 0 && pos_ >= cut_) return -1; // Debug Builds: the connection "breaks" here for (;;) {
int n = get_.read(buf, len); // Debug: each connection "breaks" after cut_ bytes.
if (n > 0 && flip_ >= pos_ && flip_ < pos_ + n) buf[flip_ - pos_] ^= 1; // and a byte "arrives wrong" int n = cut_ >= 0 && pos_ - openedAt_ >= cut_ ? -1 : get_.read(buf, len);
if (n > 0) pos_ += n; if (n > 0 && flip_ >= pos_ && flip_ < pos_ + n) buf[flip_ - pos_] ^= 1; // and a byte "arrives wrong"
return n; if (n > 0) pos_ += n;
if (n >= 0) return n;
if (!resume()) return -1;
}
} }
int resumes() const { return policy_.resumes(); }
const std::string& why() const { return why_; } // why it gave up, when it did
private: private:
bool resume() {
// Without a length there is no knowing what "the rest" is.
while (total_ > 0 && pos_ < total_ && policy_.again(pos_)) {
get_.close();
delay(kPauseMs);
uint32_t since = millis();
while (wifi_.state() != WifiController::State::Connected && millis() - since < kWifiWaitMs) delay(250);
if (wifi_.state() != WifiController::State::Connected) {
why_ = "Wi-Fi did not come back";
continue;
}
why_ = get_.openFrom(url_.host, url_.path, "application/octet-stream", pos_, release::ifRangeFor(etag_));
if (!why_.empty()) continue; // no connection yet: another try, while the policy allows
why_ = release::resumeRefusal(get_.head(), pos_, total_, etag_);
if (!why_.empty()) return false; // it answered, and it is not the rest of this file
openedAt_ = pos_;
ESP_LOGW("update", "carrying on from byte %ld of %ld (try %d)", pos_, total_, policy_.resumes());
return true;
}
if (why_.empty()) why_ = "The download broke";
return false;
}
HttpsGet& get_; HttpsGet& get_;
long cut_, flip_, pos_ = 0; release::Url url_;
WifiService& wifi_;
long total_;
std::string etag_, why_;
release::ResumePolicy policy_;
long cut_, flip_, pos_ = 0, openedAt_ = 0;
}; };
constexpr time_t kClockSetAfter = 1700000000; // anything earlier is the clock's default constexpr time_t kClockSetAfter = 1700000000; // anything earlier is the clock's default
@@ -94,6 +135,26 @@ void UpdateService::notify(const std::string& text, NotificationLevel level) {
bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(level))); bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(level)));
} }
// A line in the record of updates on the card (issue #52): the firmware can change without
// anyone looking, so what was installed and when is written down.
void UpdateService::record(const std::string& what) {
time_t now = time(nullptr);
char when[24] = "undated";
if (now >= kClockSetAfter) {
struct tm t;
gmtime_r(&now, &t);
strftime(when, sizeof when, "%Y-%m-%d %H:%M UTC", &t);
}
storage_.appendLine(kRecordPath, std::string(when) + " " + what);
}
bool UpdateService::takeOffer(std::string& tag) {
if (!offer_) return false;
offer_ = false;
tag = offered_;
return true;
}
std::string UpdateService::incomingVersion() const { return incoming_; } std::string UpdateService::incomingVersion() const { return incoming_; }
void UpdateService::start() { void UpdateService::start() {
@@ -108,6 +169,8 @@ void UpdateService::start() {
store_.getString("ota_from", from); store_.getString("ota_from", from);
if (!pending.empty() && pending != versionString()) { if (!pending.empty() && pending != versionString()) {
notify("Update to " + pending + " failed, back on " + versionString(), NotificationLevel::Warning); notify("Update to " + pending + " failed, back on " + versionString(), NotificationLevel::Warning);
record(pending + " failed and was undone: back on " + versionString());
store_.putString("ota_auto", "");
store_.putString("ota_failed", pending); // not announced again until there's a newer one (Q168) store_.putString("ota_failed", pending); // not announced again until there's a newer one (Q168)
store_.putString("ota_pending", ""); store_.putString("ota_pending", "");
} }
@@ -142,6 +205,14 @@ void UpdateService::tick(uint32_t nowMs) {
store_.putString("ota_pending", ""); store_.putString("ota_pending", "");
store_.putInt("ota_attempts", 0); store_.putInt("ota_attempts", 0);
notify(std::string("Updated to ") + versionString(), NotificationLevel::Info); notify(std::string("Updated to ") + versionString(), NotificationLevel::Info);
{
std::string from, automatic;
store_.getString("ota_from", from);
store_.getString("ota_auto", automatic);
record(std::string(versionString()) + " installed" + (automatic == versionString() ? " by itself" : "") +
(from.empty() ? "" : ", from " + from));
store_.putString("ota_auto", "");
}
break; break;
case Probation::Verdict::RollBack: case Probation::Verdict::RollBack:
// ota_pending still names this version: the previous firmware will report the failure. // ota_pending still names this version: the previous firmware will report the failure.
@@ -215,12 +286,13 @@ std::string UpdateService::failedVersion() const {
return failed; return failed;
} }
std::string UpdateService::requestInstall(const std::string& tag) { std::string UpdateService::requestInstall(const std::string& tag, bool automatic) {
if (phase_ != Phase::Idle || giteaBusy()) return "Busy with something else"; if (phase_ != Phase::Idle || giteaBusy()) return "Busy with something else";
if (wifi_.state() != WifiController::State::Connected) return "No Wi-Fi"; if (wifi_.state() != WifiController::State::Connected) return "No Wi-Fi";
release::Release r; release::Release r;
if (!gitea_.find(tag, r)) return "Look for releases first"; if (!gitea_.find(tag, r)) return "Look for releases first";
if (!release::trustedAssetUrl(r.otaUrl)) return "That download isn't on the project's server"; if (!release::trustedAssetUrl(r.otaUrl)) return "That download isn't on the project's server";
store_.putString("ota_auto", automatic ? tag : "");
installTag_ = tag; installTag_ = tag;
request_ = Request::Install; request_ = Request::Install;
return ""; return "";
@@ -281,10 +353,17 @@ void UpdateService::serve() {
time_t now = time(nullptr); time_t now = time(nullptr);
if (now >= kClockSetAfter) store_.putInt("rel_day", static_cast<int32_t>(now / 86400)); if (now >= kClockSetAfter) store_.putInt("rel_day", static_cast<int32_t>(now / 86400));
release::Release latest; release::Release latest;
if (r == Request::BackgroundCheck && gitea_.latest(latest) && // Installing by itself (issue #52): offered at each daily check while it is the newest,
// so a "not now" lasts a day. The main loop asks on the screen when the moment is quiet.
if (r == Request::BackgroundCheck && gitea_.latest(latest) && settings_.getBool(Setting::InstallUpdates) &&
release::shouldInstall(latest, runningVersion(), failedVersion())) {
announced_ = latest.tag;
offered_ = latest.tag;
offer_ = true;
} else if (r == Request::BackgroundCheck && gitea_.latest(latest) &&
release::shouldAnnounce(latest, runningVersion(), failedVersion(), announced_)) { release::shouldAnnounce(latest, runningVersion(), failedVersion(), announced_)) {
announced_ = latest.tag; announced_ = latest.tag;
notify(latest.tag + " is out: see Settings > Firmware", NotificationLevel::Info); // 48 bytes at most notify(latest.tag + " is out: Settings > System > Firmware", NotificationLevel::Info); // 48 bytes at most
} }
break; break;
} }
@@ -325,9 +404,12 @@ void UpdateService::installFromGitea(const release::Release& r) {
notify("Update refused: " + why, NotificationLevel::Warning); notify("Update refused: " + why, NotificationLevel::Warning);
return; return;
} }
GiteaSource source(get, damageCut_, damageFlip_); GiteaSource source(get, url, wifi_, damageCut_, damageFlip_);
damageCut_ = damageFlip_ = -1; damageCut_ = damageFlip_ = -1;
install(source, "Gitea"); install(source, "Gitea");
resumes_ = source.resumes();
// What the parser says after a break is only that the file was short: say why it broke.
if (phase_ != Phase::Installed && !source.why().empty()) notify("Download gave up: " + source.why(), NotificationLevel::Warning);
} }
void UpdateService::taskEntry(void* self) { static_cast<UpdateService*>(self)->listen(); } void UpdateService::taskEntry(void* self) { static_cast<UpdateService*>(self)->listen(); }
+11 -2
View File
@@ -51,7 +51,12 @@ class UpdateService : public Service {
void requestList() { request_ = Request::List; } void requestList() { request_ = Request::List; }
// Downloads `tag` (a release known from the last check or list) into the inactive slot and // Downloads `tag` (a release known from the last check or list) into the inactive slot and
// installs it. "" when it started, or why not. // installs it. "" when it started, or why not.
std::string requestInstall(const std::string& tag); // `automatic`: nobody asked for it (issue #52), and the record of updates says so.
std::string requestInstall(const std::string& tag, bool automatic = false);
// A release the daily check found that may be installed without being asked for (the setting
// is on): true once for each find, with its tag. The main loop picks the moment.
bool takeOffer(std::string& tag);
static constexpr const char* kRecordPath = "/system/updates.log"; // what was installed, and when
bool giteaBusy() const { return request_ != Request::None || serving_; } bool giteaBusy() const { return request_ != Request::None || serving_; }
// Asked to make room for a TLS connection (R1, Q172): IRC steps aside while the Update Service // Asked to make room for a TLS connection (R1, Q172): IRC steps aside while the Update Service
// talks to Gitea. Set by the main loop; `true` to step aside, `false` to come back. // talks to Gitea. Set by the main loop; `true` to step aside, `false` to come back.
@@ -66,6 +71,7 @@ class UpdateService : public Service {
// (is its certificate accepted?), and a download cut short or with one byte flipped. // (is its certificate accepted?), and a download cut short or with one byte flipped.
void requestProbe(const std::string& host, const std::string& path) { probeHost_ = host; probePath_ = path; probeResult_ = "..."; request_ = Request::Probe; } void requestProbe(const std::string& host, const std::string& path) { probeHost_ = host; probePath_ = path; probeResult_ = "..."; request_ = Request::Probe; }
std::string probeResult() const { return probeResult_; } std::string probeResult() const { return probeResult_; }
int lastResumes() const { return resumes_; } // how often the last download from Gitea was carried on (issue #54)
void damageNextDownload(long cutAfter, long flipAt) { damageCut_ = cutAfter; damageFlip_ = flipAt; } void damageNextDownload(long cutAfter, long flipAt) { damageCut_ = cutAfter; damageFlip_ = flipAt; }
void forgetToday() { store_.putInt("rel_day", 0); nextCheckMs_ = 0; announced_.clear(); } // the daily check runs at the next tick void forgetToday() { store_.putInt("rel_day", 0); nextCheckMs_ = 0; announced_.clear(); } // the daily check runs at the next tick
@@ -85,6 +91,7 @@ class UpdateService : public Service {
void listen(); void listen();
void install(class UpdateSource& source, const char* via); void install(class UpdateSource& source, const char* via);
void notify(const std::string& text, NotificationLevel level); void notify(const std::string& text, NotificationLevel level);
void record(const std::string& what);
KeyValueStore& store_; KeyValueStore& store_;
WifiService& wifi_; WifiService& wifi_;
@@ -103,11 +110,13 @@ class UpdateService : public Service {
volatile Request request_ = Request::None; volatile Request request_ = Request::None;
volatile bool serving_ = false; volatile bool serving_ = false;
bool held_ = false; // IRC was asked to step aside, on this task bool held_ = false; // IRC was asked to step aside, on this task
std::string installTag_, fakeVersion_, announced_; std::string installTag_, fakeVersion_, announced_, offered_;
volatile bool offer_ = false;
bool dailyCheck_ = false; bool dailyCheck_ = false;
uint32_t nextCheckMs_ = 90000; // not before the device has settled uint32_t nextCheckMs_ = 90000; // not before the device has settled
std::string probeHost_, probePath_; std::string probeHost_, probePath_;
volatile long damageCut_ = -1, damageFlip_ = -1; volatile long damageCut_ = -1, damageFlip_ = -1;
volatile int resumes_ = 0;
std::string probeResult_; std::string probeResult_;
}; };
+391
View File
@@ -0,0 +1,391 @@
// Made by scripts/make_icons.py from assets/icons/*.png: change the pictures, not this file.
#include "icons.h"
namespace roro::icons {
const uint8_t gemini[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x3f, 0xff, 0xff, 0xfc, // ..############################..
0x3f, 0xff, 0xff, 0xfc, // ..############################..
0x33, 0x30, 0x00, 0x0c, // ..##..##..##................##..
0x33, 0x30, 0x00, 0x0c, // ..##..##..##................##..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x3f, 0xff, 0xff, 0xfc, // ..############################..
0x3f, 0xff, 0xff, 0xfc, // ..############################..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x30, 0xc0, 0xc0, 0x0c, // ..##....##......##..........##..
0x30, 0xc0, 0xc0, 0x0c, // ..##....##......##..........##..
0x30, 0x30, 0x30, 0x0c, // ..##......##......##........##..
0x30, 0x30, 0x30, 0x0c, // ..##......##......##........##..
0x30, 0x0c, 0x0c, 0x0c, // ..##........##......##......##..
0x30, 0x0c, 0x0c, 0x0c, // ..##........##......##......##..
0x30, 0x30, 0x30, 0x0c, // ..##......##......##........##..
0x30, 0x30, 0x30, 0x0c, // ..##......##......##........##..
0x30, 0xc0, 0xc0, 0x0c, // ..##....##......##..........##..
0x30, 0xc0, 0xc0, 0x0c, // ..##....##......##..........##..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x3f, 0xff, 0xff, 0xfc, // ..############################..
0x3f, 0xff, 0xff, 0xfc, // ..############################..
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t gnss[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x3f, 0xc0, 0x00, // ..........########..............
0x00, 0x3f, 0xc0, 0x00, // ..........########..............
0x03, 0xc0, 0x0f, 0x00, // ......####..........####........
0x03, 0xc0, 0x0f, 0x00, // ......####..........####........
0x0c, 0x00, 0x0f, 0x30, // ....##..............####..##....
0x0c, 0x00, 0x0f, 0x30, // ....##..............####..##....
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x30, 0x0f, 0xf0, 0x0c, // ..##........########........##..
0x30, 0x0f, 0xf0, 0x0c, // ..##........########........##..
0x00, 0x30, 0x0c, 0x0c, // ..........##........##......##..
0x00, 0x30, 0x0c, 0x0c, // ..........##........##......##..
0x3c, 0x30, 0x0c, 0x0c, // ..####....##........##......##..
0x3c, 0x30, 0x0c, 0x0c, // ..####....##........##......##..
0x3c, 0x30, 0x0c, 0x0c, // ..####....##........##......##..
0x3c, 0x30, 0x0c, 0x0c, // ..####....##........##......##..
0x00, 0x30, 0x0c, 0x0c, // ..........##........##......##..
0x00, 0x30, 0x0c, 0x0c, // ..........##........##......##..
0x30, 0x0f, 0xf0, 0x0c, // ..##........########........##..
0x30, 0x0f, 0xf0, 0x0c, // ..##........########........##..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x03, 0xc0, 0x03, 0xc0, // ......####............####......
0x03, 0xc0, 0x03, 0xc0, // ......####............####......
0x00, 0x3f, 0xfc, 0x00, // ..........############..........
0x00, 0x3f, 0xfc, 0x00, // ..........############..........
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t irc[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x0f, 0xff, 0xff, 0xf0, // ....########################....
0x0f, 0xff, 0xff, 0xf0, // ....########################....
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x0f, 0xff, 0xff, 0xf0, // ....########################....
0x0f, 0xff, 0xff, 0xf0, // ....########################....
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t lora[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x0c, 0x00, 0x00, // ............##..................
0x00, 0x0c, 0x00, 0x00, // ............##..................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x0c, 0x00, 0x00, // ............##..................
0x00, 0x0c, 0x00, 0x00, // ............##..................
0x00, 0x0c, 0x0c, 0x00, // ............##......##..........
0x00, 0x0c, 0x0c, 0x00, // ............##......##..........
0x00, 0x0c, 0x00, 0x00, // ............##..................
0x00, 0x0c, 0x00, 0x00, // ............##..................
0x00, 0x0c, 0x0c, 0x00, // ............##......##..........
0x00, 0x0c, 0x0c, 0x00, // ............##......##..........
0x00, 0x0c, 0x0c, 0x00, // ............##......##..........
0x00, 0x0c, 0x0c, 0x00, // ............##......##..........
0x00, 0xcc, 0x0c, 0xc0, // ........##..##......##..##......
0x00, 0xcc, 0x0c, 0xc0, // ........##..##......##..##......
0x00, 0xcc, 0xcc, 0xc0, // ........##..##..##..##..##......
0x00, 0xcc, 0xcc, 0xc0, // ........##..##..##..##..##......
0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##......
0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##......
0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##......
0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##......
0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##......
0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##......
0x3f, 0xff, 0xff, 0xfc, // ..############################..
0x3f, 0xff, 0xff, 0xfc, // ..############################..
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t notes[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0xc3, 0x0c, 0x00, // ........##....##....##..........
0x00, 0xc3, 0x0c, 0x00, // ........##....##....##..........
0x00, 0xc3, 0x0c, 0x00, // ........##....##....##..........
0x00, 0xc3, 0x0c, 0x00, // ........##....##....##..........
0x0f, 0xff, 0xff, 0xf0, // ....########################....
0x0f, 0xff, 0xff, 0xf0, // ....########################....
0x0c, 0xc3, 0x0c, 0x30, // ....##..##....##....##....##....
0x0c, 0xc3, 0x0c, 0x30, // ....##..##....##....##....##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0xff, 0xff, 0x30, // ....##..################..##....
0x0c, 0xff, 0xff, 0x30, // ....##..################..##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0xff, 0xff, 0x30, // ....##..################..##....
0x0c, 0xff, 0xff, 0x30, // ....##..################..##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0xff, 0xc0, 0x30, // ....##..##########........##....
0x0c, 0xff, 0xc0, 0x30, // ....##..##########........##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0f, 0xff, 0xff, 0xf0, // ....########################....
0x0f, 0xff, 0xff, 0xf0, // ....########################....
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t settings[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x0f, 0xf0, 0x00, // ............########............
0x00, 0x0f, 0xf0, 0x00, // ............########............
0x03, 0xcc, 0x33, 0xc0, // ......####..##....##..####......
0x03, 0xcc, 0x33, 0xc0, // ......####..##....##..####......
0x0c, 0x30, 0x0c, 0x30, // ....##....##........##....##....
0x0c, 0x30, 0x0c, 0x30, // ....##....##........##....##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x03, 0x0f, 0xf0, 0xc0, // ......##....########....##......
0x03, 0x0f, 0xf0, 0xc0, // ......##....########....##......
0x3c, 0x3c, 0x3c, 0x3c, // ..####....####....####....####..
0x3c, 0x3c, 0x3c, 0x3c, // ..####....####....####....####..
0x30, 0x30, 0x0c, 0x0c, // ..##......##........##......##..
0x30, 0x30, 0x0c, 0x0c, // ..##......##........##......##..
0x30, 0x30, 0x0c, 0x0c, // ..##......##........##......##..
0x30, 0x30, 0x0c, 0x0c, // ..##......##........##......##..
0x3c, 0x3c, 0x3c, 0x3c, // ..####....####....####....####..
0x3c, 0x3c, 0x3c, 0x3c, // ..####....####....####....####..
0x03, 0x0f, 0xf0, 0xc0, // ......##....########....##......
0x03, 0x0f, 0xf0, 0xc0, // ......##....########....##......
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x30, 0x0c, 0x30, // ....##....##........##....##....
0x0c, 0x30, 0x0c, 0x30, // ....##....##........##....##....
0x03, 0xcc, 0x33, 0xc0, // ......####..##....##..####......
0x03, 0xcc, 0x33, 0xc0, // ......####..##....##..####......
0x00, 0x0f, 0xf0, 0x00, // ............########............
0x00, 0x0f, 0xf0, 0x00, // ............########............
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t shell[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x3c, 0x00, 0x00, 0x00, // ..####..........................
0x3c, 0x00, 0x00, 0x00, // ..####..........................
0x0f, 0x00, 0x00, 0x00, // ....####........................
0x0f, 0x00, 0x00, 0x00, // ....####........................
0x03, 0xc0, 0x00, 0x00, // ......####......................
0x03, 0xc0, 0x00, 0x00, // ......####......................
0x00, 0xf0, 0x00, 0x00, // ........####....................
0x00, 0xf0, 0x00, 0x00, // ........####....................
0x00, 0x3c, 0x00, 0x00, // ..........####..................
0x00, 0x3c, 0x00, 0x00, // ..........####..................
0x00, 0x0f, 0x00, 0x00, // ............####................
0x00, 0x0f, 0x00, 0x00, // ............####................
0x00, 0x3c, 0x00, 0x00, // ..........####..................
0x00, 0x3c, 0x00, 0x00, // ..........####..................
0x00, 0xf0, 0x00, 0x00, // ........####....................
0x00, 0xf0, 0x00, 0x00, // ........####....................
0x03, 0xc0, 0x00, 0x00, // ......####......................
0x03, 0xc0, 0x00, 0x00, // ......####......................
0x0f, 0x00, 0x00, 0x00, // ....####........................
0x0f, 0x00, 0x00, 0x00, // ....####........................
0x3c, 0x00, 0xff, 0xfc, // ..####..........##############..
0x3c, 0x00, 0xff, 0xfc, // ..####..........##############..
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t ssh[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x0f, 0xf0, 0x00, 0x00, // ....########....................
0x0f, 0xf0, 0x00, 0x00, // ....########....................
0x30, 0x0c, 0x00, 0x00, // ..##........##..................
0x30, 0x0c, 0x00, 0x00, // ..##........##..................
0xc0, 0x03, 0x00, 0x00, // ##............##................
0xc0, 0x03, 0x00, 0x00, // ##............##................
0xc3, 0xc3, 0xff, 0xff, // ##....####....##################
0xc3, 0xc3, 0xff, 0xff, // ##....####....##################
0xc3, 0xc3, 0x00, 0xcc, // ##....####....##........##..##..
0xc3, 0xc3, 0x00, 0xcc, // ##....####....##........##..##..
0xc0, 0x03, 0x00, 0xcc, // ##............##........##..##..
0xc0, 0x03, 0x00, 0xcc, // ##............##........##..##..
0x30, 0x0c, 0x00, 0x00, // ..##........##..................
0x30, 0x0c, 0x00, 0x00, // ..##........##..................
0x0f, 0xf0, 0x00, 0x00, // ....########....................
0x0f, 0xf0, 0x00, 0x00, // ....########....................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t storage[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x03, 0xff, 0xf0, 0x00, // ......##############............
0x03, 0xff, 0xf0, 0x00, // ......##############............
0x03, 0x00, 0x0c, 0x00, // ......##............##..........
0x03, 0x00, 0x0c, 0x00, // ......##............##..........
0x03, 0x33, 0x03, 0x00, // ......##..##..##......##........
0x03, 0x33, 0x03, 0x00, // ......##..##..##......##........
0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##......
0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##......
0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##......
0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##......
0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##......
0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##......
0x03, 0x00, 0x00, 0xc0, // ......##................##......
0x03, 0x00, 0x00, 0xc0, // ......##................##......
0x03, 0x00, 0x00, 0xc0, // ......##................##......
0x03, 0x00, 0x00, 0xc0, // ......##................##......
0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##......
0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##......
0x03, 0x30, 0x0c, 0xc0, // ......##..##........##..##......
0x03, 0x30, 0x0c, 0xc0, // ......##..##........##..##......
0x03, 0x30, 0x0c, 0xc0, // ......##..##........##..##......
0x03, 0x30, 0x0c, 0xc0, // ......##..##........##..##......
0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##......
0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##......
0x03, 0x00, 0x00, 0xc0, // ......##................##......
0x03, 0x00, 0x00, 0xc0, // ......##................##......
0x03, 0xff, 0xff, 0xc0, // ......####################......
0x03, 0xff, 0xff, 0xc0, // ......####################......
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t system[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0xff, 0xff, 0x00, // ........################........
0x00, 0xff, 0xff, 0x00, // ........################........
0x0f, 0xc0, 0x03, 0xf0, // ....######............######....
0x0f, 0xc0, 0x03, 0xf0, // ....######............######....
0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........
0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........
0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........
0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........
0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........
0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........
0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........
0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........
0x0f, 0xc0, 0x03, 0xf0, // ....######............######....
0x0f, 0xc0, 0x03, 0xf0, // ....######............######....
0x00, 0xff, 0xff, 0x00, // ........################........
0x00, 0xff, 0xff, 0x00, // ........################........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t wifi_tools[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x3f, 0xfc, 0x00, // ..........############..........
0x00, 0x3f, 0xfc, 0x00, // ..........############..........
0x03, 0xc0, 0x03, 0xc0, // ......####............####......
0x03, 0xc0, 0x03, 0xc0, // ......####............####......
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x30, 0x3f, 0xfc, 0x0c, // ..##......############......##..
0x30, 0x3f, 0xfc, 0x0c, // ..##......############......##..
0x03, 0xc0, 0x03, 0xc0, // ......####............####......
0x03, 0xc0, 0x03, 0xc0, // ......####............####......
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x0f, 0xf0, 0x00, // ............########............
0x00, 0x0f, 0xf0, 0x00, // ............########............
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x03, 0xc0, 0x00, // ..............####..............
0x00, 0x03, 0xc0, 0x00, // ..............####..............
0x00, 0x03, 0xc0, 0x00, // ..............####..............
0x00, 0x03, 0xc0, 0x00, // ..............####..............
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
} // namespace roro::icons
+23
View File
@@ -0,0 +1,23 @@
// Made by scripts/make_icons.py from assets/icons/*.png: change the pictures, not this file.
#pragma once
#include <cstdint>
namespace roro::icons {
// One bit a pixel, the leftmost first, a row after another: what Canvas::drawBitmap takes.
constexpr int kSize = 32;
extern const uint8_t gemini[128]; // gemini.png
extern const uint8_t gnss[128]; // gnss.png
extern const uint8_t irc[128]; // irc.png
extern const uint8_t lora[128]; // lora.png
extern const uint8_t notes[128]; // notes.png
extern const uint8_t settings[128]; // settings.png
extern const uint8_t shell[128]; // shell.png
extern const uint8_t ssh[128]; // ssh.png
extern const uint8_t storage[128]; // storage.png
extern const uint8_t system[128]; // system.png
extern const uint8_t wifi_tools[128]; // wifi-tools.png
} // namespace roro::icons
+3 -2
View File
@@ -12,12 +12,12 @@ bool Screen::begin() {
return canvas_.createSprite(theme::kWidth, theme::kHeight) != nullptr; return canvas_.createSprite(theme::kWidth, theme::kHeight) != nullptr;
} }
void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast) { void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast, const Question* question) {
const auto& area = theme::kContent; const auto& area = theme::kContent;
App& app = apps.foreground(); App& app = apps.foreground();
// An App that keeps what it drew: only the Status Bar is cleared, unless the App has changed // An App that keeps what it drew: only the Status Bar is cleared, unless the App has changed
// or a Toast or the help panel, drawn over it, has just gone. // or a Toast or the help panel, drawn over it, has just gone.
bool overlay = apps.help().isOpen() || toast, retains = app.retainsContent(); bool overlay = apps.help().isOpen() || toast || question, retains = app.retainsContent();
if (overlayWas_ && !overlay) lost_ = true; if (overlayWas_ && !overlay) lost_ = true;
if (retains && &app == lastApp_ && !lost_) { if (retains && &app == lastApp_ && !lost_) {
canvas_.fillRect(0, 0, theme::kWidth, area.y, theme::kBackground); canvas_.fillRect(0, 0, theme::kWidth, area.y, theme::kBackground);
@@ -31,6 +31,7 @@ void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toa
canvas_.setClipRect(area.x, area.y, area.w, area.h); canvas_.setClipRect(area.x, area.y, area.w, area.h);
app.draw(canvas_); app.draw(canvas_);
if (apps.help().isOpen()) widgets::help(canvas_, apps.help()); // over the App, under the Status Bar if (apps.help().isOpen()) widgets::help(canvas_, apps.help()); // over the App, under the Status Bar
if (question) widgets::dialog(canvas_, question->title, question->message, *question->dialog);
canvas_.clearClipRect(); canvas_.clearClipRect();
widgets::statusBar(canvas_, status); widgets::statusBar(canvas_, status);
if (toast) widgets::toast(canvas_, *toast); if (toast) widgets::toast(canvas_, *toast);
+8 -1
View File
@@ -10,10 +10,17 @@ namespace roro {
class Screen { class Screen {
public: public:
bool begin(); // allocates the frame buffer (~32 KB at 8-bit colour) bool begin(); // allocates the frame buffer (~32 KB at 8-bit colour)
void render(AppManager& apps, const StatusInfo& status, const Toast* toast); // A question the device itself asks, over whatever App is open (issue #52).
struct Question {
std::string title, message;
const DialogModel* dialog;
};
void render(AppManager& apps, const StatusInfo& status, const Toast* toast, const Question* question = nullptr);
// Takes over the screen while a Firmware Update is received or about to restart. // Takes over the screen while a Firmware Update is received or about to restart.
void renderUpdate(const std::string& title, const std::string& detail, int percent); void renderUpdate(const std::string& title, const std::string& detail, int percent);
Canvas& canvas() { return canvas_; } Canvas& canvas() { return canvas_; }
// The colours changed: an App that keeps what it drew has to draw it all again.
void invalidate() { lost_ = true; }
private: private:
Canvas canvas_; Canvas canvas_;
+23 -9
View File
@@ -2,17 +2,31 @@
#include <cstdint> #include <cstdint>
#include "palette.h"
namespace roro::theme { namespace roro::theme {
// RGB565 // The colours every screen draws with: the theme chosen in the Settings (issue #10, lib/ui's
constexpr uint16_t kBackground = 0x0000; // palette.h), set at boot and whenever it changes. They read as constants, and are references into
constexpr uint16_t kText = 0xFFFF; // the palette in use, so a draw after apply() is in the new colours.
constexpr uint16_t kMuted = 0x8410; // grey inline ui::Palette current = ui::palette(0, false);
constexpr uint16_t kAccent = 0x2CBF; // teal-blue inline void apply(const ui::Palette& p) { current = p; }
constexpr uint16_t kSelection = 0x1A6F; // darker accent behind selected rows
constexpr uint16_t kBar = 0x2104; // Status Bar background inline const uint16_t& kBackground = current.background;
constexpr uint16_t kWarning = 0xFC60; // orange inline const uint16_t& kText = current.text;
constexpr uint16_t kMessage = 0x3666; // green inline const uint16_t& kAccent = current.accent; // what is chosen, what is live
inline const uint16_t& kOnAccent = current.onAccent; // text and pictures on an accent fill
inline const uint16_t& kSelection = current.accent; // behind a selected row, whose text is kOnAccent
inline const uint16_t& kMuted = current.muted;
inline const uint16_t& kFaint = current.faint; // grid lines, what stays behind
inline const uint16_t& kBar = current.bar; // Status Bar background
inline const uint16_t& kBarMuted = current.barMuted; // what is idle, on the bar
inline const uint16_t& kWarning = current.warning;
inline const uint16_t& kMessage = current.message; // someone wrote
inline const uint16_t& kGood = current.good; // a Fix, the best of a set
inline const uint16_t& kRed = current.red; // these three: to tell things apart
inline const uint16_t& kYellow = current.yellow;
inline const uint16_t& kViolet = current.violet;
constexpr int kWidth = 240; constexpr int kWidth = 240;
constexpr int kHeight = 135; constexpr int kHeight = 135;
+12 -12
View File
@@ -32,43 +32,43 @@ void statusBar(Canvas& c, const StatusInfo& info) {
if (info.unread > 0) right("[" + std::to_string(info.unread) + "]", kMessage); if (info.unread > 0) right("[" + std::to_string(info.unread) + "]", kMessage);
if (info.batteryPercent >= 0) if (info.batteryPercent >= 0)
right(std::to_string(info.batteryPercent) + "%", info.batteryPercent <= 15 ? kWarning : kText); right(std::to_string(info.batteryPercent) + "%", info.batteryPercent <= 15 ? kWarning : kText);
if (info.sdPresent) right("SD", info.sdLevel >= 80 ? kWarning : kMuted); if (info.sdPresent) right("SD", info.sdLevel >= 80 ? kWarning : kBarMuted);
switch (info.wifi) { switch (info.wifi) {
case StatusInfo::Wifi::Connected: { case StatusInfo::Wifi::Connected: {
// Three signal bars, then the label. // Three signal bars, then the label.
for (int i = 0; i < 3; i++) { for (int i = 0; i < 3; i++) {
int h = 2 + 2 * i; int h = 2 + 2 * i;
c.fillRect(x - 10 + 4 * i, 9 - h, 3, h, i < info.wifiBars ? kText : kMuted); c.fillRect(x - 10 + 4 * i, 9 - h, 3, h, i < info.wifiBars ? kText : kBackground);
} }
x -= 14; x -= 14;
right("W", kText); right("W", kText);
break; break;
} }
case StatusInfo::Wifi::Searching: right("W?", kMuted); break; case StatusInfo::Wifi::Searching: right("W?", kBarMuted); break;
case StatusInfo::Wifi::Monitoring: right("MON", kAccent); break; case StatusInfo::Wifi::Monitoring: right("MON", kAccent); break;
case StatusInfo::Wifi::None: break; case StatusInfo::Wifi::None: break;
} }
switch (info.vpn) { // Q252: there while the tunnel is wanted, bright once the peer has answered switch (info.vpn) { // Q252: there while the tunnel is wanted, bright once the peer has answered
case StatusInfo::Vpn::Trying: right("VPN", kMuted); break; case StatusInfo::Vpn::Trying: right("VPN", kBarMuted); break;
case StatusInfo::Vpn::Up: right("VPN", kAccent); break; case StatusInfo::Vpn::Up: right("VPN", kAccent); break;
case StatusInfo::Vpn::Off: break; case StatusInfo::Vpn::Off: break;
} }
if (info.ssh) right("SSH", kAccent); // Q260: a session goes on, whatever App is in front if (info.ssh) right("SSH", kAccent); // Q260: a session goes on, whatever App is in front
switch (info.debug) { // Q190: there while the console listens, bright with someone connected switch (info.debug) { // Q190: there while the console listens, bright with someone connected
case StatusInfo::Debug::On: right("DBG", kMuted); break; case StatusInfo::Debug::On: right("DBG", kBarMuted); break;
case StatusInfo::Debug::Client: right("DBG", kAccent); break; case StatusInfo::Debug::Client: right("DBG", kAccent); break;
case StatusInfo::Debug::Off: break; case StatusInfo::Debug::Off: break;
} }
if (info.tracking) right("REC", kWarning); if (info.tracking) right("REC", kWarning);
if (info.capturing) right("CAP", kWarning); if (info.capturing) right("CAP", kWarning);
switch (info.radio) { // Q101: muted while listening, bright for a moment on each packet switch (info.radio) { // Q101: muted while listening, bright for a moment on each packet
case StatusInfo::Radio::Listening: right("L", kMuted); break; case StatusInfo::Radio::Listening: right("L", kBarMuted); break;
case StatusInfo::Radio::Packet: right("L", kAccent); break; case StatusInfo::Radio::Packet: right("L", kAccent); break;
case StatusInfo::Radio::Sweep: right("SW", kMuted); break; // the Sniffer is paused case StatusInfo::Radio::Sweep: right("SW", kBarMuted); break; // the Sniffer is paused
case StatusInfo::Radio::None: break; case StatusInfo::Radio::None: break;
} }
switch (info.gnss) { // Q61: muted while searching, normal with a Fix, the count with a 3D Fix 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::Searching: right("G", kBarMuted); break;
case StatusInfo::Gnss::TwoD: right("G", kText); 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::ThreeD: right("G" + std::to_string(info.gnssSatellites), kText); break;
case StatusInfo::Gnss::None: break; case StatusInfo::Gnss::None: break;
@@ -88,10 +88,10 @@ void list(Canvas& c, const ListModel& model, Rect area, const std::function<std:
int y = area.y + row * kLineHeight; int y = area.y + row * kLineHeight;
bool selected = i == model.selected(); bool selected = i == model.selected();
if (selected) c.fillRect(area.x, y, area.w - 4, kLineHeight, kSelection); if (selected) c.fillRect(area.x, y, area.w - 4, kLineHeight, kSelection);
c.setTextColor(kText); c.setTextColor(selected ? kOnAccent : kText);
c.drawString(label(i).c_str(), area.x + 4, y + 1); c.drawString(label(i).c_str(), area.x + 4, y + 1);
if (value) { if (value) {
c.setTextColor(selected ? kText : kMuted); c.setTextColor(selected ? kOnAccent : kMuted);
c.setTextDatum(top_right); c.setTextDatum(top_right);
c.drawString(value(i).c_str(), area.x + area.w - 8, y + 1); c.drawString(value(i).c_str(), area.x + area.w - 8, y + 1);
c.setTextDatum(top_left); c.setTextDatum(top_left);
@@ -159,7 +159,7 @@ void dialog(Canvas& c, const std::string& title, const std::string& message, con
bool selected = i == model.selected(); bool selected = i == model.selected();
if (selected) c.fillRect(x, box.y + box.h - 19, w, 15, kAccent); if (selected) c.fillRect(x, box.y + box.h - 19, w, 15, kAccent);
else c.drawRect(x, box.y + box.h - 19, w, 15, kMuted); else c.drawRect(x, box.y + box.h - 19, w, 15, kMuted);
c.setTextColor(selected ? kBackground : kText); c.setTextColor(selected ? kOnAccent : kText);
c.drawString(buttons[i].c_str(), x + 6, box.y + box.h - 17); c.drawString(buttons[i].c_str(), x + 6, box.y + box.h - 17);
x -= 6; x -= 6;
} }
@@ -174,7 +174,7 @@ void toast(Canvas& c, const Toast& t) {
if (w > kWidth - 8) w = kWidth - 8; if (w > kWidth - 8) w = kWidth - 8;
int x = (kWidth - w) / 2, y = kHeight - 22; int x = (kWidth - w) / 2, y = kHeight - 22;
c.fillRect(x, y, w, 18, color); c.fillRect(x, y, w, 18, color);
c.setTextColor(kBackground); c.setTextColor(kOnAccent);
c.setTextDatum(top_left); c.setTextDatum(top_left);
c.setClipRect(x + 4, y, w - 8, 18); c.setClipRect(x + 4, y, w - 8, 18);
c.drawString(t.text.c_str(), x + 8, y + 3); c.drawString(t.text.c_str(), x + 8, y + 3);
+137
View File
@@ -0,0 +1,137 @@
#include <unity.h>
#include "auto_update.h"
#include "update_check.h"
using namespace roro::release;
using Action = AutoUpdate::Action;
void setUp() {}
void tearDown() {}
namespace {
constexpr uint32_t kIdle = AutoUpdate::kIdleMs, kAsk = AutoUpdate::kAskMs;
Release release(const char* tag) {
Release r;
r.tag = tag;
r.otaUrl = "https://git.twis.la/twisla/roro9stack/releases/download/x/x.ota";
r.otaSize = 1;
return r;
}
} // namespace
void test_nothing_happens_without_an_offer() {
AutoUpdate a;
TEST_ASSERT_TRUE(a.step(1000, false, kIdle) == Action::None);
TEST_ASSERT_FALSE(a.waiting());
TEST_ASSERT_FALSE(a.asking());
}
void test_it_waits_for_a_quiet_moment() {
AutoUpdate a;
a.offer("v1.2.0");
TEST_ASSERT_TRUE(a.waiting());
TEST_ASSERT_TRUE(a.step(1000, false, kIdle - 1) == Action::None); // a key not long ago
TEST_ASSERT_TRUE(a.step(2000, true, kIdle * 10) == Action::None); // a Track is recording
TEST_ASSERT_TRUE(a.step(3000, false, kIdle) == Action::Ask);
TEST_ASSERT_TRUE(a.asking());
TEST_ASSERT_EQUAL_STRING("v1.2.0", a.tag().c_str());
}
void test_unanswered_it_installs_after_30_seconds() {
AutoUpdate a;
a.offer("v1.2.0");
a.step(5000, false, kIdle);
TEST_ASSERT_EQUAL(30, a.secondsLeft(5000));
TEST_ASSERT_EQUAL(30, a.secondsLeft(5001));
TEST_ASSERT_EQUAL(1, a.secondsLeft(5000 + kAsk - 1));
TEST_ASSERT_TRUE(a.step(5000 + kAsk - 1, false, kIdle) == Action::None);
TEST_ASSERT_TRUE(a.step(5000 + kAsk, false, kIdle) == Action::Install);
TEST_ASSERT_FALSE(a.asking());
TEST_ASSERT_TRUE(a.step(5000 + kAsk + 1, false, kIdle) == Action::None); // once
}
void test_accept_installs_at_once() {
AutoUpdate a;
a.offer("v1.2.0");
a.step(0, false, kIdle);
a.answer(true);
TEST_ASSERT_TRUE(a.step(100, false, 0) == Action::Install);
}
void test_cancel_drops_it_until_the_next_offer() {
AutoUpdate a;
a.offer("v1.2.0");
a.step(0, false, kIdle);
a.answer(false);
TEST_ASSERT_TRUE(a.step(100, false, 0) == Action::Dismiss);
TEST_ASSERT_TRUE(a.step(kAsk * 2, false, kIdle * 2) == Action::None);
a.offer("v1.2.0"); // the next day's check
TEST_ASSERT_TRUE(a.step(kAsk * 3, false, kIdle) == Action::Ask);
}
void test_something_starting_takes_the_question_down() {
AutoUpdate a;
a.offer("v1.2.0");
a.step(0, false, kIdle);
TEST_ASSERT_TRUE(a.step(1000, true, kIdle) == Action::Dismiss);
TEST_ASSERT_TRUE(a.waiting());
TEST_ASSERT_TRUE(a.step(2000, true, kIdle) == Action::None);
TEST_ASSERT_TRUE(a.step(90000, false, kIdle) == Action::Ask); // and it comes back, with a full count
TEST_ASSERT_EQUAL(30, a.secondsLeft(90000));
}
void test_the_setting_going_off_withdraws_it() {
AutoUpdate a;
a.offer("v1.2.0");
a.withdraw();
TEST_ASSERT_TRUE(a.step(0, false, kIdle) == Action::None);
TEST_ASSERT_FALSE(a.waiting());
a.offer("v1.2.0");
a.step(0, false, kIdle);
a.withdraw();
TEST_ASSERT_TRUE(a.step(10, false, kIdle) == Action::Dismiss);
TEST_ASSERT_FALSE(a.asking());
}
void test_an_answer_is_only_for_a_question() {
AutoUpdate a;
a.offer("v1.2.0");
a.answer(true); // nothing was asked yet
TEST_ASSERT_TRUE(a.step(0, false, kIdle) == Action::Ask);
TEST_ASSERT_TRUE(a.step(1, false, kIdle) == Action::None);
}
void test_a_newer_offer_replaces_the_tag() {
AutoUpdate a;
a.offer("v1.2.0");
a.offer("v1.3.0");
a.step(0, false, kIdle);
TEST_ASSERT_EQUAL_STRING("v1.3.0", a.tag().c_str());
}
void test_what_is_offered() {
// Newer than what runs, and not a version that rolled back here.
TEST_ASSERT_TRUE(shouldInstall(release("v1.2.0"), "v1.1.0", ""));
TEST_ASSERT_FALSE(shouldInstall(release("v1.1.0"), "v1.1.0", ""));
TEST_ASSERT_FALSE(shouldInstall(release("v1.0.0"), "v1.1.0", ""));
TEST_ASSERT_FALSE(shouldInstall(release("v1.2.0"), "v1.1.0", "v1.2.0"));
Release noFile = release("v1.2.0");
noFile.otaUrl.clear();
TEST_ASSERT_FALSE(shouldInstall(noFile, "v1.1.0", ""));
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_nothing_happens_without_an_offer);
RUN_TEST(test_it_waits_for_a_quiet_moment);
RUN_TEST(test_unanswered_it_installs_after_30_seconds);
RUN_TEST(test_accept_installs_at_once);
RUN_TEST(test_cancel_drops_it_until_the_next_offer);
RUN_TEST(test_something_starting_takes_the_question_down);
RUN_TEST(test_the_setting_going_off_withdraws_it);
RUN_TEST(test_an_answer_is_only_for_a_question);
RUN_TEST(test_a_newer_offer_replaces_the_tag);
RUN_TEST(test_what_is_offered);
return UNITY_END();
}

Some files were not shown because too many files have changed in this diff Show More