Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd534af971 | ||
|
|
0c52613b72 | ||
|
|
37335b4829 | ||
|
|
10627e6e18 | ||
|
|
e2326e113b | ||
|
|
aadfd8cb37 | ||
|
|
b1c80c912f | ||
|
|
015a9c59bc | ||
|
|
305818466f | ||
|
|
3339d9ee59 | ||
|
|
8ba41f72b6 | ||
|
|
861a60b73d | ||
|
|
4c4d7fc467 | ||
|
|
18285c3212 | ||
|
|
e707a5f2d4 | ||
|
|
5bec851a1a | ||
|
|
9dfe675db7 | ||
|
|
079de4aec7 | ||
|
|
6b71aace4f | ||
|
|
7223147f26 | ||
|
|
01a8e2a233 | ||
|
|
0498916740 | ||
|
|
298407b5cf | ||
|
|
9808013fc0 | ||
|
|
9b6457d1ec | ||
|
|
a8f267416b | ||
|
|
ed7abdcaf5 | ||
|
|
86172c0342 | ||
|
|
e00fff670f | ||
|
|
4404dd9380 | ||
|
|
f0306dd880 | ||
|
|
e3fe618c7f | ||
|
|
d7092ee6d6 | ||
|
|
63c2da8138 | ||
|
|
057af773e4 | ||
|
|
6b02cd3d5f | ||
|
|
c35bc47693 |
@@ -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: |
|
||||||
|
|||||||
@@ -110,6 +110,26 @@ The share of airtime the Region allows this device to transmit. When it's used u
|
|||||||
The App that runs the console's commands on the device itself, and shows what the console prints. Trusted like the USB port, not like the network.
|
The App that runs the console's commands on the device itself, and shows what the console prints. Trusted like the USB port, not like the network.
|
||||||
_Avoid_: terminal, command line, REPL
|
_Avoid_: terminal, command line, REPL
|
||||||
|
|
||||||
|
**Tunnel**:
|
||||||
|
The WireGuard connection to one server, over whatever Wi-Fi the device is on. It carries either everything or the one subnet the device's address in it belongs to. Wanted or not is the user's switch; up or not depends on Wi-Fi, the clock and the server.
|
||||||
|
_Avoid_: VPN connection, link, session
|
||||||
|
|
||||||
|
**Session**:
|
||||||
|
The one SSH connection to a shell on another machine, from login until either side ends it. It belongs to the SSH Service, not to the SSH App: it goes on while another App is in front.
|
||||||
|
_Avoid_: connection, tunnel, terminal (the terminal is what draws it)
|
||||||
|
|
||||||
|
**Device Key**:
|
||||||
|
The Ed25519 key pair the device makes for itself to log in over SSH. The private half never leaves the device and is never shown; the public half is meant to be copied to servers.
|
||||||
|
_Avoid_: identity, SSH key file, certificate
|
||||||
|
|
||||||
|
**Sharing**:
|
||||||
|
Serving the SD card as a web page to a browser on the same network, for as long as the Storage App's Share screen is open, to whoever typed the code that screen shows.
|
||||||
|
_Avoid_: file server, web server, FTP, upload mode
|
||||||
|
|
||||||
|
**Screenshot**:
|
||||||
|
The screen as a PNG in `/screenshots`, taken with Fn+p on any screen or the Shell's `screenshot`. Not the Debug Console's `screenshot`, which sends the screen to a PC.
|
||||||
|
_Avoid_: capture (a **Capture** is radio packets), screen grab
|
||||||
|
|
||||||
**Help panel**:
|
**Help panel**:
|
||||||
The list of the keys that work on the screen you are on, opened with Fn+h anywhere (or `?` outside Text Entry). Each App answers for its current state; no screen names keys any other way, except the first-start Setup.
|
The list of the keys that work on the screen you are on, opened with Fn+h anywhere (or `?` outside Text Entry). Each App answers for its current state; no screen names keys any other way, except the first-start Setup.
|
||||||
_Avoid_: hints, cheat sheet, shortcuts bar
|
_Avoid_: hints, cheat sheet, shortcuts bar
|
||||||
@@ -181,6 +201,7 @@ _Avoid_: telnet, remote shell, Debug Build (there is one firmware)
|
|||||||
- **Services** keep running underneath, regardless of which **App** is in the foreground.
|
- **Services** keep running underneath, regardless of which **App** is in the foreground.
|
||||||
- The **Mesh Service** speaks one or more **Mesh Protocols** and tracks the known **Nodes**.
|
- The **Mesh Service** speaks one or more **Mesh Protocols** and tracks the known **Nodes**.
|
||||||
- The **Wi-Fi Service** is either Connected or Monitoring, never both. Monitoring pauses the **IRC Service**, which reconnects and rejoins its **Buffers** afterwards.
|
- The **Wi-Fi Service** is either Connected or Monitoring, never both. Monitoring pauses the **IRC Service**, which reconnects and rejoins its **Buffers** afterwards.
|
||||||
|
- The SSH App draws the one **Session**; the **Session** and the **IRC Service**'s connection don't fit in memory together, so each waits for the other.
|
||||||
- **Services** raise **Notifications**; the **Status Bar** summarises **Service** state.
|
- **Services** raise **Notifications**; the **Status Bar** summarises **Service** state.
|
||||||
- The **Radio Service** owns the radio; the **Mesh Service** and the LoRa Scanner use it.
|
- The **Radio Service** owns the radio; the **Mesh Service** and the LoRa Scanner use it.
|
||||||
- A **Sweep** pauses the **Mesh Service**; a **Sniffer** does not.
|
- A **Sweep** pauses the **Mesh Service**; a **Sniffer** does not.
|
||||||
@@ -188,6 +209,8 @@ _Avoid_: telnet, remote shell, Debug Build (there is one firmware)
|
|||||||
- Past 90% SD usage, **Logs** stop being written; the remaining space is kept for **Captures**. Nothing is deleted without the user's confirmation.
|
- Past 90% SD usage, **Logs** stop being written; the remaining space is kept for **Captures**. Nothing is deleted without the user's confirmation.
|
||||||
- A **Firmware Update** installs an **Update File**; the new firmware runs on **Probation**, and fails back by **Rollback**.
|
- A **Firmware Update** installs an **Update File**; the new firmware runs on **Probation**, and fails back by **Rollback**.
|
||||||
- **Rollback** covers new firmware; **Safe Mode** covers confirmed firmware that keeps crashing.
|
- **Rollback** covers new firmware; **Safe Mode** covers confirmed firmware that keeps crashing.
|
||||||
|
- A **Tunnel** rides on the **Wi-Fi Service**'s connection and ends with it; the next connection starts it afresh.
|
||||||
|
- **Sharing** lasts as long as its screen: leaving the **Storage App** ends it.
|
||||||
- A **Node** may be in several **Channels**. A **Direct Message** targets exactly one **Node**.
|
- A **Node** may be in several **Channels**. A **Direct Message** targets exactly one **Node**.
|
||||||
|
|
||||||
## Flagged ambiguities
|
## Flagged ambiguities
|
||||||
|
|||||||
@@ -2,7 +2,24 @@
|
|||||||
|
|
||||||
[](https://git.twis.la/twisla/roro9stack/actions?workflow=ci.yml) [](#build-and-test-local-ci) [](https://git.twis.la/twisla/roro9stack/releases/latest)
|
[](https://git.twis.la/twisla/roro9stack/actions?workflow=ci.yml) [](#build-and-test-local-ci) [](https://git.twis.la/twisla/roro9stack/releases/latest)
|
||||||
|
|
||||||
A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262**. It's a Meshtastic-compatible mesh messenger, plus Wi-Fi tools, IRC, GNSS and more. Licensed GPL-3.0.
|
A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262**. Licensed GPL-3.0. The user guide, the how-tos and every release are at **[roro9stack.net](https://roro9stack.net)**.
|
||||||
|
|
||||||
|
What it does today:
|
||||||
|
|
||||||
|
- **LoRa Scanner:** every packet it hears, with the Meshtastic or MeshCore header read; a spectrum Sweep; captures for Wireshark. It listens and never transmits: the mesh messenger is the next milestone.
|
||||||
|
- **GNSS:** position, sky view, tracks as GPX.
|
||||||
|
- **Gemini:** a browser, with bookmarks and pages saved for offline.
|
||||||
|
- **IRC:** over TLS, with logs on the card.
|
||||||
|
- **Wi-Fi Tools:** the networks around, sorted, filtered, logged.
|
||||||
|
- **Notes:** plain text files of any size, saved by themselves.
|
||||||
|
- **Storage:** the SD card: copy, move, rename, delete; viewers for text, hex, pictures (PNG, JPEG, BMP, GIF), tracks, captures and update files; **sharing with a phone's browser**.
|
||||||
|
- **Shell:** the firmware's commands on the device itself, with completion, including `ping`, `nslookup`, `port`, `traceroute`, `tls` and `ifconfig`.
|
||||||
|
- **System:** load, tasks, memory, network, battery, live.
|
||||||
|
- **SSH:** a terminal on another machine, with a password or the device's own key.
|
||||||
|
- **VPN:** a WireGuard tunnel.
|
||||||
|
- **Everywhere:** Fn+h lists the keys of the screen you are on; Fn+p takes a screenshot.
|
||||||
|
- **Updates:** signed, from the project's server, the card or a PC, with a rollback if the new firmware fails.
|
||||||
|
- **Debug Console:** the device's console over Wi-Fi, off until switched on.
|
||||||
|
|
||||||
- Domain language: [CONTEXT.md](CONTEXT.md)
|
- Domain language: [CONTEXT.md](CONTEXT.md)
|
||||||
- Decisions: [docs/adr/](docs/adr/)
|
- Decisions: [docs/adr/](docs/adr/)
|
||||||
@@ -10,6 +27,8 @@ A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262*
|
|||||||
|
|
||||||
## On the device: one key
|
## On the device: one key
|
||||||
|
|
||||||
|
**Fn+p, on any screen, saves a screenshot** to `/screenshots` on the card (not on the page that shows the Debug Console's token; issue #83).
|
||||||
|
|
||||||
**Fn+h, on any screen, lists the keys that work there** (`?` does the same outside a text field). No screen names its keys itself (docs/milestones/U1.md). Every screen's keys are one table in `lib/core/src/app_keys.h`: the help panel shows the table of the state an App is in, and the website's key tables are generated from the same file.
|
**Fn+h, on any screen, lists the keys that work there** (`?` does the same outside a text field). No screen names its keys itself (docs/milestones/U1.md). Every screen's keys are one table in `lib/core/src/app_keys.h`: the help panel shows the table of the state an App is in, and the website's key tables are generated from the same file.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -24,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).
|
||||||
@@ -73,19 +94,19 @@ 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.
|
||||||
- **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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -95,7 +116,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.
|
||||||
|
|
||||||
@@ -119,7 +140,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. Enter shows a packet's details: the Meshtastic header (which is never encrypted) and a hex dump. `p` picks one of the 7 Meshtastic presets allowed in EU868 (LongFast by default), `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
|
||||||
|
|
||||||
@@ -136,11 +157,13 @@ The Storage App (docs/milestones/F1.md) shows what's on the SD card: each folder
|
|||||||
|
|
||||||
A copy runs in the background of the card (about 400 KB a second) in short turns, so Logs and Captures keep being written; it shows its progress, Back cancels it and takes back what was copied, and each file's size is checked afterwards. Three things can't be changed: the top-level folders the firmware keeps its files in (what's inside them can), `/gemini/cache`, and any file being written right now (today's IRC Logs, a Track or a Capture being recorded). The App says why when it refuses. A folder with more than 256 entries shows the first 256 by name and says so.
|
A copy runs in the background of the card (about 400 KB a second) in short turns, so Logs and Captures keep being written; it shows its progress, Back cancels it and takes back what was copied, and each file's size is checked afterwards. Three things can't be changed: the top-level folders the firmware keeps its files in (what's inside them can), `/gemini/cache`, and any file being written right now (today's IRC Logs, a Track or a Capture being recorded). The App says why when it refuses. A folder with more than 256 entries shows the first 256 by name and says so.
|
||||||
|
|
||||||
|
**`w` shares the card with a browser on the same network** (issue #88): a small HTTP server and one page, for a phone with nothing to install. The screen shows the address as a QR code and a six-digit code, new each time; whoever has typed it can list, download, upload (streamed to the card under a temporary name), make folders and delete, under the Storage App's rules. It runs only while that screen is open, takes one request at a time, moves about 200 KB a second, and is not encrypted. It costs 57 KB of flash, and 13 KB of memory while it is on.
|
||||||
|
|
||||||
Enter on a file opens it by type; Tab switches to the same file as a hex dump or as text:
|
Enter on a file opens it by type; Tab switches to the same file as a hex dump or as text:
|
||||||
|
|
||||||
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only the screen's worth is read from the card, so a file of any size opens at once. Logs open at the end. Up and Down move a line, Left and Right a page, `t` and `b` go to the top and the end, `e` edits it, whatever its size (see Notes).
|
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only the screen's worth is read from the card, so a file of any size opens at once. Logs open at the end. Up and Down move a line, Left and Right a page, `t` and `b` go to the top and the end, `e` edits it, whatever its size (see Notes).
|
||||||
- **Pictures** (`.png`, `.jpg`, `.bmp`, `.gif`; issue #45): shrunk to fit the screen, or at their own size with Enter, the arrows then moving half a screen at a time; `i` gives the size in pixels. Dithered to the screen's 256 colours, decoded straight into the screen's buffer with no copy in memory, on the storage task so the keys keep working (12 megapixels of JPEG: 7 s). A GIF shows its first picture. A progressive JPEG or an interlaced PNG opens as hex, with the reason.
|
- **Pictures** (`.png`, `.jpg`, `.bmp`, `.gif`; issue #45): shrunk to fit the screen, or at their own size with Enter, the arrows then moving half a screen at a time; `i` gives the size in pixels. Dithered to the screen's 256 colours, decoded straight into the screen's buffer with no copy in memory, on the storage task so the keys keep working (12 megapixels of JPEG: 7 s). A GIF shows its first picture. A progressive JPEG or an interlaced PNG opens as hex, with the reason.
|
||||||
- **Captures** (`.pcap`): the packets as the LoRa Scanner lists them; Enter shows one with its Meshtastic header and bytes.
|
- **Captures** (`.pcap`): the packets as the LoRa Scanner lists them; Enter shows one with its Meshtastic or MeshCore header and bytes.
|
||||||
- **Tracks** (`.gpx`): the number of points, the start, the duration and the distance.
|
- **Tracks** (`.gpx`): the number of points, the start, the duration and the distance.
|
||||||
- **Update Files** (`.ota`): the version, and whether the file would install: it's checked as an install checks it (signature and contents) without writing anything. Enter then installs it.
|
- **Update Files** (`.ota`): the version, and whether the file would install: it's checked as an install checks it (signature and contents) without writing anything. Enter then installs it.
|
||||||
- **Anything else:** a hex dump.
|
- **Anything else:** a hex dump.
|
||||||
@@ -157,9 +180,21 @@ A new note has no file until something is typed; its file is then named after it
|
|||||||
|
|
||||||
**A note can be any size** (issue #47): the editor keeps a window of about 8 KB around the cursor in memory and the rest on the card, so a megabyte opens as fast as a line and uses the same 17 KB. Up to 64 KB a save rewrites the file. Above, the five-second save writes only what changed to a side file, `<note>.edit`, and the file itself is rewritten when the note is left, with a progress bar (about 450 KB a second). After a power cut, opening the note picks the edit up where it was saved. Saving needs room on the card for a second copy. The Storage App's text viewer has `e` to edit a file with the same editor, anywhere on the card, unless the file is read-only.
|
**A note can be any size** (issue #47): the editor keeps a window of about 8 KB around the cursor in memory and the rest on the card, so a megabyte opens as fast as a line and uses the same 17 KB. Up to 64 KB a save rewrites the file. Above, the five-second save writes only what changed to a side file, `<note>.edit`, and the file itself is rewritten when the note is left, with a progress bar (about 450 KB a second). After a power cut, opening the note picks the edit up where it was saved. Saving needs room on the card for a second copy. The Storage App's text viewer has `e` to edit a file with the same editor, anywhere on the card, unless the file is read-only.
|
||||||
|
|
||||||
|
## SSH
|
||||||
|
|
||||||
|
The SSH App (docs/milestones/N1.md, issue #2) is a terminal on another machine: one session to a shell, over libssh (`ewpa/LibSSH-ESP32`) on mbedTLS. `user@host[:port]`, typed in the App or as `ssh user@host` in the Shell; up to eight hosts are remembered. **A server is trusted the first time, on its fingerprint, and a changed key is a warning** whose selected answer is Cancel. **The password is typed each time and kept nowhere;** or the device makes itself an Ed25519 key (kept in the device, never shown, no passphrase) whose public half is shown, written to `/ssh/id_ed25519.pub` and printed by `ssh status`, for a server's `authorized_keys`.
|
||||||
|
|
||||||
|
The terminal (`lib/term`, host-tested) understands what a shell, `less`, `top`, `nano` and `vim` send: the cursor, erasing, sixteen colours, scroll regions, the alternate screen; no mouse. Five text sizes with Ctrl and + or -, from 60 x 20 to 26 x 8, told to the far end; 100 lines of scrollback with Alt and up or down. The backtick key sends Esc. **Leaving the App doesn't end the session:** `SSH` shows in the Status Bar and the App finds it again. It costs 292 KB of flash (109 KB of it one table, for signing with the device's key) and about 50 KB of memory while a session is open, so it isn't started under 75 KB free and IRC doesn't connect while one is open.
|
||||||
|
|
||||||
|
## 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 > 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.
|
||||||
|
|
||||||
## Shell
|
## Shell
|
||||||
|
|
||||||
The Shell App (docs/milestones/S1.md) runs the commands below on the device's own screen and keyboard: no PC, no cable, no Wi-Fi. **It shows the replies to its own commands and nothing else**: the console knows who each line is printed for, so a listing read by another task a moment later is still the Shell's, and what USB or the Debug Console asked for is not. Ctrl+b shows everything instead. Tab completes a command word by word (`lora st` gives `lora status`) and, past it, a path on the SD card (`ls /no` gives `ls /notes/`), Fn with up and down recalls earlier lines, Alt with up and down scrolls back. **An App's name with a capital opens it** (`Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `System`, `Settings`), from the consoles too. `rm` is Unix's, with a question: a folder needs `-r`; a file, or a folder with something in it, is asked about unless `-f` (`rm -rf`); an empty folder goes without a word. `*` and `?` in a name stand for several files (`rm /notes/*.txt` asks once, with the count; 64 at most). `clear` empties the screen and `quit` leaves. It is trusted like the USB port: `debug on` and `debug token` work from it. It uses about 7 KB of memory while it is open, and none otherwise.
|
The Shell App (docs/milestones/S1.md) runs the commands below on the device's own screen and keyboard: no PC, no cable, no Wi-Fi. **It shows the replies to its own commands and nothing else**: the console knows who each line is printed for, so a listing read by another task a moment later is still the Shell's, and what USB or the Debug Console asked for is not. Ctrl+b shows everything instead. Tab completes a command word by word (`lora st` gives `lora status`) and, past it, a path on the SD card (`ls /no` gives `ls /notes/`), Fn with up and down recalls earlier lines, Alt with up and down scrolls back. **An App's name with a capital opens it** (`Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Ssh`, `System`, `Settings`), from the consoles too. `rm` is Unix's, with a question: a folder needs `-r`; a file, or a folder with something in it, is asked about unless `-f` (`rm -rf`); an empty folder goes without a word. `*` and `?` in a name stand for several files (`rm /notes/*.txt` asks once, with the count; 64 at most). `clear` empties the screen and `quit` leaves. It is trusted like the USB port: `debug on` and `debug token` work from it. It uses about 7 KB of memory while it is open, and none otherwise. **For the network:** `ping`, `nslookup`, `port`, `traceroute`, `tls`, `ntp`, `ifconfig`, `arp` and `netstat` (issue #90).
|
||||||
|
|
||||||
## Development aids
|
## Development aids
|
||||||
|
|
||||||
@@ -170,6 +205,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 |
|
||||||
@@ -195,13 +231,13 @@ 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, cut or damage the next download, 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 |
|
||||||
| `lora preset <name>` / `lora custom <MHz> <BW kHz> <SF> <CR> <sync hex> [preamble]` | Receive settings: a Meshtastic preset, or anything else (`lora custom 868.1 125 7 5 34 8` for LoRaWAN) |
|
| `lora preset <name>` / `lora custom <MHz> <BW kHz> <SF> <CR> <sync hex> [preamble]` | Receive settings: a Meshtastic preset or `MeshCore`, or anything else (`lora custom 868.1 125 7 5 34 8` for LoRaWAN) |
|
||||||
| `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 |
|
||||||
@@ -215,6 +251,13 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
|
|||||||
| `coredump erase` | Forgets the core dump |
|
| `coredump erase` | Forgets the core dump |
|
||||||
| `loop spin on` / `loop spin off` | Make the main loop spin without resting, to compare load and radio noise |
|
| `loop spin on` / `loop spin off` | Make the main loop spin without resting, to compare load and radio noise |
|
||||||
| `crash abort` / `crash wdt` | Crash on purpose, or hang the main loop until the watchdog fires |
|
| `crash abort` / `crash wdt` | Crash on purpose, or hang the main loop until the watchdog fires |
|
||||||
|
| `ping <host> [count] [size]` / `nslookup <name> [server]` / `port <host> <port>` / `traceroute <host>` / `cancel` | Network troubleshooting (issue #90): does a host answer and how fast; a name's addresses, from which DNS server and in how long; is a TCP port open, refused or silent; the routers on the way. Each runs on a task of its own and prints as it goes, one at a time; `cancel` stops it |
|
||||||
|
| `tls <host> [port]` / `ntp [server]` | A TLS handshake that checks nothing, then the certificate said in words: who it is for, who signed it, until when, its SHA-256, and whether this device's roots and the name asked for accept it (about 52 KB of heap while it runs; refused under 70 KB free). A time server's clock against the device's, with the round trip |
|
||||||
|
| `ifconfig` / `arp` / `netstat` | The interfaces (Wi-Fi and the VPN) with their addresses, MTU, which is the default route, and the DNS servers; the neighbours heard on the Wi-Fi; what listens and what is connected |
|
||||||
|
| `ssh user@host[:port]` / `ssh status` / `ssh stop` | Opens the SSH App and connects (the password is asked there, never on a console); the session's state and this device's public key; end the session |
|
||||||
|
| `scp [-f] [-P port] <file> user@host:path` / `scp [-f] [-P port] user@host:path <file>` | One file between the card and a server, with the device's key, or a password asked (masked) in the Shell, to a server the SSH App already trusts; `-f` replaces a file on the card; `cancel` stops it |
|
||||||
|
| `uname` | The firmware, its version and the chip it is built for (IRC has `/uname`) |
|
||||||
|
| `vpn status` / `vpn up [seconds]` / `vpn down` / `vpn import [path]` / `vpn forget` / `vpn auto on\|off` | The WireGuard tunnel: its state, on (for that many seconds, then off by itself: for trying a configuration from afar), off, read a `.conf` from the card (`/vpn/wg0.conf`), erase it, start with Wi-Fi. No key is ever printed |
|
||||||
| `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long |
|
| `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long |
|
||||||
| `debug on` / `debug token <value>` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed |
|
| `debug on` / `debug token <value>` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed |
|
||||||
| `help` | Lists the commands |
|
| `help` | Lists the commands |
|
||||||
@@ -223,7 +266,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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 108 B |
|
After Width: | Height: | Size: 121 B |
|
After Width: | Height: | Size: 85 B |
|
After Width: | Height: | Size: 99 B |
|
After Width: | Height: | Size: 102 B |
|
After Width: | Height: | Size: 117 B |
|
After Width: | Height: | Size: 97 B |
|
After Width: | Height: | Size: 99 B |
|
After Width: | Height: | Size: 104 B |
|
After Width: | Height: | Size: 96 B |
|
After Width: | Height: | Size: 107 B |
@@ -1,6 +1,6 @@
|
|||||||
# F1 — Files and Notes
|
# F1 — Files and Notes
|
||||||
|
|
||||||
**Status:** in progress. The Storage App (issue #3) shipped as **v0.9.0** on 2026-10-06. Notes (#19) shipped as **v0.10.0** the same day. The card as a USB drive (#1) comes after.
|
**Status:** in progress. Shipped: the Storage App (issue #3, **v0.9.0**), Notes (#19, **v0.10.0**), notes of any size (#47, **v0.15.0**), pictures in the Storage App (#45, **v0.16.0**), sharing the card with a browser (#88, **v0.18.0**). Not started: the card as a USB drive (#1), selecting several items (#41), finding files by name (#42), opening a `.gmi` in Gemini (#43), a table view for `.csv` (#44), search, undo and copy-paste in Notes (#48, #49, #50).
|
||||||
|
|
||||||
**Goal:** get at what's on the SD card from the device itself: browse it, look inside the files the firmware writes, copy, move, rename and delete, and keep notes. A side milestone, like G1 and S1; Files and Notes were M3's original second half (Q30, Q89).
|
**Goal:** get at what's on the SD card from the device itself: browse it, look inside the files the firmware writes, copy, move, rename and delete, and keep notes. A side milestone, like G1 and S1; Files and Notes were M3's original second half (Q30, Q89).
|
||||||
|
|
||||||
@@ -290,3 +290,46 @@ Test pictures were copied to a scratch folder and removed afterwards, with the t
|
|||||||
**A decoder that worked once.** The library's PNG decoder showed the first picture and refused the next five: "no memory". It wants 44 KB in one piece, and after some use the largest piece is 43 to 47 KB. Writing a decoder that needs 32 KB was less work than it sounds, and unlike the library's it has tests.
|
**A decoder that worked once.** The library's PNG decoder showed the first picture and refused the next five: "no memory". It wants 44 KB in one piece, and after some use the largest piece is 43 to 47 KB. Writing a decoder that needs 32 KB was less work than it sounds, and unlike the library's it has tests.
|
||||||
|
|
||||||
**Two sentences still said "up to 16 KB"** about editing, in the README and the Storage guide, after issue #47 lifted that. Corrected here.
|
**Two sentences still said "up to 16 KB"** about editing, in the README and the Storage guide, after issue #47 lifted that. Corrected here.
|
||||||
|
|
||||||
|
## Sharing the card with a browser (issue #88)
|
||||||
|
|
||||||
|
Files reached the card through the Debug Console's `put` and `get`, or by taking the card out. A phone has neither.
|
||||||
|
|
||||||
|
### Decisions (2026-10-07; the recommendation was accepted as it stood, without a round of questions)
|
||||||
|
|
||||||
|
- **A web page, not FTP, SFTP or WebDAV.** A browser is the only client every phone has. FTP and WebDAV need an app there; SFTP needs a whole SSH server here. WebDAV can come later on the same server, for computers.
|
||||||
|
- **Off unless asked for:** `w` in the Storage App opens a "Share" screen, and the server runs only while that screen is open.
|
||||||
|
- **A six-digit code on the screen**, new each time, typed in the page; the address is also a QR code, which carries the code. Five wrong codes close it for a minute (the Debug Console's `AuthGate`). A browser that got it right holds a cookie; starting again puts every browser out.
|
||||||
|
- **Not encrypted.** A TLS server costs about 40 KB of memory a connection. The screen says so.
|
||||||
|
- **The Storage App's rules** (`whyReadOnly`): the firmware's own folders, and files in use.
|
||||||
|
|
||||||
|
### As built
|
||||||
|
|
||||||
|
- **`WebShare`** (`src/services/web_share`): ESP-IDF's HTTP server, which is in the framework already. Seven requests: the page, the code, a listing as JSON, a download, an upload, a new folder, a delete.
|
||||||
|
- **Every access to the card is handed to the storage task**, 8 KB at a time, from the server's own task: a download and an upload are loops of "one piece from the card, one piece to the network".
|
||||||
|
- **An upload is the request's body**, as the browser's `PUT` sends it: no form to take apart. It goes to `<name>.part` and is renamed when the last byte has come; anything less is removed. A file that exists is refused unless the page asked, after asking the user.
|
||||||
|
- **The page** (`web_share_page.h`) is one file of 5.4 KB with its style and script in it, served from flash.
|
||||||
|
- **`lib/files/src/share_rules.h`** (host-tested, 5 tests): what a request names, which paths a browser may ask for (from the root, no `..`), the JSON, the code and the cookie.
|
||||||
|
- **Cost:** 57 KB of flash, most of it the server. 13 KB of memory while sharing (108.4 KB free before, 95.4 with the screen open), given back on leaving.
|
||||||
|
|
||||||
|
### Checks on the device (2026-10-07 and 08)
|
||||||
|
|
||||||
|
A scratch folder was used and removed.
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `w` | The QR code, the address and the code; `share: on` on the console |
|
||||||
|
| The page, and a listing without the code | 200; 401 |
|
||||||
|
| A wrong code, the right one (typed `825 132`) | 403; in |
|
||||||
|
| Upload, 2.6 MB | 11 to 17 s (150 to 230 KB/s); downloaded again and compared: the same, byte for byte |
|
||||||
|
| The same name again; with "replace" | 409; replaced |
|
||||||
|
| `..` in a path; deleting `/notes`; deleting a folder that isn't empty | 400; 403 "The firmware keeps its files in /notes"; 403 |
|
||||||
|
| Five wrong codes | The fifth and every one after: 429, the right code too. A browser that was in stays in |
|
||||||
|
| In Chromium at a phone's width | The scanned address logs in by itself; two files uploaded, one downloaded and compared, a folder made, a file deleted after asking, a replacement after asking, the refusal shown. No sideways scroll |
|
||||||
|
| Back | The server is gone (connection refused), memory is back |
|
||||||
|
|
||||||
|
**Found on the way:** the server answers one request at a time. A second request during a slow download waited until it had ended. It is said in the guide, and not changed.
|
||||||
|
|
||||||
|
**On a real phone** (the maintainer's, 2026-10-08): the QR code, scanned with the phone's camera, opens the page and logs in; the page lists the card, in the phone's dark theme.
|
||||||
|
|
||||||
|
**Not checked:** Safari. A card pulled during a transfer. Sharing with IRC connected, when memory is shorter. Home, and the screen turning off, while sharing (the code stops the server on leaving the App; only Back was tried).
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
| Q92 | A **Radio Service** owns the SX1262: driver, bus lock, IRQ task. The LoRa Scanner uses it in M3; the Mesh Service sits on top of it in M4. |
|
| Q92 | A **Radio Service** owns the SX1262: driver, bus lock, IRQ task. The LoRa Scanner uses it in M3; the Mesh Service sits on top of it in M4. |
|
||||||
| Q93 | Every radio transfer takes the shared bus lock (`SPI.beginTransaction`, as the card does). DIO1's interrupt only wakes the task; no SPI in the ISR. **Done when** a Gemini page streams to the card while the Sniffer receives, with no lost packets and no card errors (`lora status` counters). |
|
| Q93 | Every radio transfer takes the shared bus lock (`SPI.beginTransaction`, as the card does). DIO1's interrupt only wakes the task; no SPI in the ISR. **Done when** a Gemini page streams to the card while the Sniffer receives, with no lost packets and no card errors (`lora status` counters). |
|
||||||
| Q94 | **Receive only:** the Radio Service has no transmit function in M3. It doesn't exist, rather than being unused. |
|
| Q94 | **Receive only:** the Radio Service has no transmit function in M3. It doesn't exist, rather than being unused. |
|
||||||
| Q95 | Sniffer defaults: **EU868 LongFast**, 869.525 MHz, BW 250 kHz, SF 11, CR 4/5, sync word 0x2B, preamble 16 (Q19). The other Meshtastic presets are offered, plus custom settings. |
|
| Q95 | Sniffer defaults: **EU868 LongFast**, 869.525 MHz, BW 250 kHz, SF 11, CR 4/5, sync word 0x2B, preamble 16 (Q19). The other Meshtastic presets are offered, plus custom settings. *Later, the default became is the MeshCore preset (869.618 MHz, BW 62.5 kHz, SF 8, CR 4/8, sync word 0x12): it is what is heard here.* |
|
||||||
| Q96 | The Sniffer lists packets (time, RSSI, SNR, frequency error, length; hex dump on Enter) **and decodes the Meshtastic header**: the first 16 bytes are never encrypted (destination, sender, packet ID, hop limit and hop start, channel hash, next hop, relay node). Host-tested. Payload decryption is M4. |
|
| Q96 | The Sniffer lists packets (time, RSSI, SNR, frequency error, length; hex dump on Enter) **and decodes the Meshtastic header**: the first 16 bytes are never encrypted (destination, sender, packet ID, hop limit and hop start, channel hash, next hop, relay node). Host-tested. Payload decryption is M4. |
|
||||||
| Q97 | A Sniffer **Capture** is pcap with **LoRaTap** headers (link type 270), for Wireshark. Started by hand, Status Bar mark, its own Clean-up category, the 90% rule. |
|
| Q97 | A Sniffer **Capture** is pcap with **LoRaTap** headers (link type 270), for Wireshark. Started by hand, Status Bar mark, its own Clean-up category, the 90% rule. |
|
||||||
| Q98 | **Sweep** steps across the Region's band (863–870 MHz) in 100 kHz steps by default, reading instant RSSI: bars with peak hold, and a waterfall, Wi-Fi Tools style. Optionally CAD on the preset's frequency to tell LoRa traffic from noise. |
|
| Q98 | **Sweep** steps across the Region's band (863–870 MHz) in 100 kHz steps by default, reading instant RSSI: bars with peak hold, and a waterfall, Wi-Fi Tools style. Optionally CAD on the preset's frequency to tell LoRa traffic from noise. |
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# N1 — Network tools
|
||||||
|
|
||||||
|
**Status:** in progress. The WireGuard tunnel (issue #8) shipped as **v0.19.0**. The network troubleshooting commands (issue #90) shipped in two parts: `ping`, `nslookup`, `port`, `traceroute`, `ifconfig` and `arp` as **v0.20.0**; `tls`, `ntp` and `netstat` as **v0.21.0**. The SSH client (issue #2) shipped as **v0.22.0**.
|
||||||
|
|
||||||
|
**Goal:** reach things from the device that aren't on the Wi-Fi it happens to be on, and keep its traffic private on a network that isn't yours.
|
||||||
|
|
||||||
|
## The WireGuard tunnel (issue #8)
|
||||||
|
|
||||||
|
A WireGuard client: the Cardputer joins a WireGuard network over whatever Wi-Fi it is on.
|
||||||
|
|
||||||
|
### Measured before deciding (2026-10-07)
|
||||||
|
|
||||||
|
The issue asked for the libraries to be measured first. `esphome/wireguard` 0.4.8 (maintained, published the same week; BSD-3-Clause) was built into a trial firmware and a tunnel brought up against a throwaway peer in a container.
|
||||||
|
|
||||||
|
| | Cost |
|
||||||
|
|---|---|
|
||||||
|
| Flash, the library | 43 KB |
|
||||||
|
| Flash, with our service, page and commands | 63 KB |
|
||||||
|
| Static RAM | 1.2 KB |
|
||||||
|
| Heap with the tunnel up | 1.8 KB |
|
||||||
|
|
||||||
|
- **It crashes this build as shipped.** The library calls lwIP's raw functions without taking lwIP's lock, and this framework is built to check for that (`CONFIG_LWIP_CHECK_THREAD_SAFETY`): the first `netif_add` stopped the device. Every call into it is made with the lock held, on our side; the library is not changed.
|
||||||
|
- **One address range is allowed by default;** more need `CONFIG_WIREGUARD_MAX_SRC_IPS`, set in `platformio.ini`.
|
||||||
|
- One peer, IPv4.
|
||||||
|
- The older `ciniml/WireGuard-ESP32` was last touched in 2021 and was not tried.
|
||||||
|
|
||||||
|
### Decisions (design round 2026-10-07)
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Q243 | **`esphome/wireguard`, pinned at 0.4.8,** with lwIP's lock taken around every call. |
|
||||||
|
| Q244 | **Configured by importing a standard `.conf` from the card** (`/vpn/wg0.conf`), from Settings or with `vpn import`. Nothing is typed on the device. |
|
||||||
|
| Q245 | **The private key comes in that file,** as WireGuard configurations are handed out. It is kept in the device's settings, never shown and never printed. After an import Settings **offers to delete the file**: the card comes out, and the key is in it in clear. |
|
||||||
|
| Q246 | One tunnel, one peer. |
|
||||||
|
| Q247 | **A switch, and "Start with Wi-Fi"** (off by default). The switch is for now: it doesn't outlast a restart. The tunnel waits for the clock, since a handshake carries the time and a server refuses one older than the last it saw; the clock is set over plain Wi-Fi first. |
|
||||||
|
| Q248 | *Narrowed while building.* **Either everything goes through the tunnel, or one subnet does.** With `0.0.0.0/0` in AllowedIPs the tunnel is the default route. Otherwise only the subnet this device's tunnel address is in is routed: the widest allowed range that holds it. **A home network behind the server can't be reached without the full tunnel:** lwIP routes by an interface's own subnet or by default, and has no table for anything finer. The import says how many ranges it can't reach. |
|
||||||
|
| Q249 | *Not as planned.* **With everything through the tunnel, nothing leaves while the server is silent:** the default route stays in the tunnel, which has nowhere to send. That is a kill switch, by construction and not by choice. With one subnet, packets for it go out on Wi-Fi again while the tunnel has no peer. |
|
||||||
|
| Q250 | The file's DNS servers are used while the tunnel is up, if they can be reached through it; what was there before goes back when it stops. |
|
||||||
|
| Q251 | **The Debug Console and the Update Service answer over the tunnel** as they do on Wi-Fi: the console still wants its token and an update its signature. |
|
||||||
|
| Q252 | **`VPN` in the Status Bar** while the tunnel is wanted, bright once the server has answered. Settings > VPN has the state, the server, this device's address, what goes through it and how long ago the server was heard. `vpn status`, `up`, `down`, `import`, `forget`, `auto`. A Toast when it comes up and when the server stops answering. |
|
||||||
|
| Q253 | PresharedKey, MTU and ListenPort from the file; keepalive 25 s if the file has none; the tunnel is taken down with the Wi-Fi it was on and started afresh on the next. No IPv6. |
|
||||||
|
|
||||||
|
### As built
|
||||||
|
|
||||||
|
- **`lib/net/src/wg_config.h`** (host-tested, 6 tests): reads a `.conf` as people write them (any case, comments, CRLF, IPv6 entries left out), refuses what it can't use with the line and the field and never the key, writes it back tidy for the settings store, and says what will be routed.
|
||||||
|
- **`VpnService`** (`src/services/vpn_service`): the tunnel is up when it is wanted, Wi-Fi is connected and the clock is set. It holds lwIP's lock around the library, adds the allowed ranges, makes the tunnel the default route for "everything", and puts the DNS servers in and out. A DHCP renewal that replaces them is noticed: the tunnel's go back in, and the renewed ones are what is restored later.
|
||||||
|
- **The tunnel's own packets never go into the tunnel:** the library sends them on the interface that was the default when it started.
|
||||||
|
- **Connections that came in over Wi-Fi stay on Wi-Fi** with everything routed into the tunnel: a reply leaves by the interface whose address it carries.
|
||||||
|
- **`vpn up <seconds>`** takes the tunnel down again by itself: for trying a configuration from afar, when a wrong one could cut the connection it was sent over.
|
||||||
|
- Settings: `VpnConfig` (the `.conf`, checked on every load) and `VpnAuto`.
|
||||||
|
|
||||||
|
### Checks on the device (2026-10-07, against a WireGuard peer in a container)
|
||||||
|
|
||||||
|
The test keys were made for the purpose and deleted. Two rounds: first with the device on a guest Wi-Fi that can't open connections to the machine the test peer ran on, so **the peer called the device** (`ListenPort`), which WireGuard allows either way round; then on a network where **the device called the peer**, as it normally would.
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `vpn import`, then the file removed | "imported, through it 10.9.0.0/24"; the configuration survives a firmware update |
|
||||||
|
| `vpn up` | Up within seconds; `VPN` bright in the Status Bar; a Toast |
|
||||||
|
| From the peer, through the tunnel | 25 pings of 25, 1300 bytes too; the Debug Console's greeting on TCP 2323; TCP 3232 answers |
|
||||||
|
| DNS | The file's server while up (`wifi status` says `(VPN)`), DHCP's back after `vpn down`, with no reconnection |
|
||||||
|
| Everything through the tunnel | The device stays reachable over Wi-Fi; an update check's HTTPS to the release server is seen inside the tunnel at the peer |
|
||||||
|
| `vpn up 100` | Down by itself after 100 s |
|
||||||
|
| "Start with Wi-Fi", then a restart | Up by itself 40 s after the restart, once Wi-Fi and the clock were there |
|
||||||
|
| The peer silenced | After three minutes: "no answer yet", a Toast, `VPN` dim. With everything through the tunnel, an update check then fails: nothing leaves. The peer back: up again in under half a minute, and a Toast |
|
||||||
|
| `vpn forget` | "not set"; DNS as before |
|
||||||
|
| **The device calling the peer**, the server given by name, with a PresharedKey and `MTU = 1280` | Up in seconds; the peer shows the device's address and port as the endpoint; pings through it |
|
||||||
|
| One subnet: a connection the device opens to the peer's tunnel address | Seen inside the tunnel at the peer |
|
||||||
|
| Everything: a Gemini page from a public capsule | Fetched (TLS, 1,184 bytes), and seen inside the tunnel at the peer. Free memory fell to 45.9 KB at the lowest |
|
||||||
|
| Memory | 106.1 KB free before, 104.3 KB with the tunnel up, 106.2 KB after |
|
||||||
|
| Settings > VPN | The four rows, the state and "heard 66 s ago", the server, the address; no key anywhere on it |
|
||||||
|
|
||||||
|
**Against a real server** (the maintainer's own, 2026-10-08): a configuration put on the card by the maintainer and imported in Settings; the server named by host name, on a port of its own, the device's address a /32, everything through the tunnel, "Start with Wi-Fi" on. The tunnel is up, and from another machine the device answers on its tunnel address: pings, and the Debug Console.
|
||||||
|
|
||||||
|
**Not checked:** from a network far from the server (the device was on the server's own network, reaching it by its public name). That the MTU is what limits a packet (larger pings were answered too, in pieces). Roaming from one Wi-Fi to another with the tunnel wanted. IRC through the tunnel. A day of uptime.
|
||||||
|
|
||||||
|
### What went wrong while building it
|
||||||
|
|
||||||
|
**The device stopped on the first try,** on lwIP's "Required to lock TCPIP core functionality!". The library was written for builds that don't check; ours does. The fix is three lines of ours, and the crash report named `netif_add` and the line that called it.
|
||||||
|
|
||||||
|
**Taking the tunnel down reconnected Wi-Fi.** The first version gave DHCP's DNS servers back by asking for a new lease, which is how the Wi-Fi settings do it, and which drops every connection: the Debug Console session that had typed `vpn down` among them. The servers that were there are now simply remembered and put back.
|
||||||
|
|
||||||
|
**"What AllowedIPs say" was more than the network stack can do.** The design round promised split tunnels by AllowedIPs. lwIP has no routing table: it can send by an interface's subnet, or by default. So it is one subnet or everything, and the import tells which.
|
||||||
|
|
||||||
|
## Network troubleshooting commands (issue #90)
|
||||||
|
|
||||||
|
With a tunnel, fixed addresses and a file server on the device, "is it the network or is it me" needed another machine to answer.
|
||||||
|
|
||||||
|
### Decisions (2026-10-08; built on the issue's list, without a round of questions)
|
||||||
|
|
||||||
|
- **The familiar names:** `ping`, `nslookup`, `traceroute`, `ifconfig`, `arp`. `port <host> <port>` for "is that TCP port open", which has no single familiar name.
|
||||||
|
- **In this version:** those six. **Not yet:** `tls` (why a certificate fails), `ntp` (the clock's offset), `netstat` (what listens). The issue stays open for them.
|
||||||
|
- **Commands only,** in the Shell and over both consoles; no page in an App.
|
||||||
|
- **One line an answer, short:** a Shell line is 38 characters.
|
||||||
|
|
||||||
|
### As built
|
||||||
|
|
||||||
|
- **`lib/net/src/net_probe.h`** (host-tested, 5 tests): what was typed; the ICMP echo request and what answers it, a router's "time exceeded" included; the DNS query and its answer, with the pointers names are shortened by.
|
||||||
|
- **`NetTools`** (`src/services/net_tools`): `ping`, `nslookup`, `port` and `traceroute` each run on a task of their own, made for the command and gone after it, printing to the console that asked (the Shell shows only its own replies). One at a time; `cancel` stops it within a fifth of a second.
|
||||||
|
- **`ping`** and **`traceroute`** share a raw ICMP socket: a traceroute is echo requests allowed one hop, then two, then three, and the routers' complaints are the list.
|
||||||
|
- **`nslookup`** asks one server itself, over UDP, and so can say which server answered and how long it took, which the system's resolver doesn't; and it can ask a server that isn't the configured one.
|
||||||
|
- **`port`** is a connection attempt that is not waited for: open, refused, or five seconds of nothing.
|
||||||
|
- **`ifconfig`** and **`arp`** read lwIP's own lists, with its lock held.
|
||||||
|
- **Cost:** 12 KB of flash. A 6 KB task while a command runs (2.6 KB of it never used), nothing otherwise.
|
||||||
|
|
||||||
|
### Checks on the device (2026-10-08, with the VPN up and everything routed through it)
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `ifconfig` | `vpn 10.9.0.2/32 mtu 1420, up, default route`; `wifi ... gw ... mtu 1500, up`; the DNS server |
|
||||||
|
| `arp` | The gateway and one other machine |
|
||||||
|
| `ping` of a neighbour, of a name | 4 of 4 in 3 to 4 ms; 3 of 3 in about 50 ms |
|
||||||
|
| `ping 9.9.9.9 2 1392`, then `1393` | Both back; neither back: the tunnel carries 1420 bytes exactly |
|
||||||
|
| `nslookup` | The address, the server and the time; an alias followed; with another server; "there is no nope.invalid" |
|
||||||
|
| `port` | `open, 52 ms`; `refused`; "no answer in 5 s"; "doesn't resolve" |
|
||||||
|
| `traceroute 9.9.9.9` | Nine hops, the tunnel's server first, "arrived" |
|
||||||
|
| A second command while a ping runs | "another one is running: `cancel` stops it" |
|
||||||
|
| `cancel` | "stopped", with the count so far |
|
||||||
|
| In the Shell | Tab completes them; the lines appear there and only there |
|
||||||
|
|
||||||
|
**Not checked:** without the VPN (every check went through the tunnel, or to the local network); a network that drops ICMP; the commands in Safe Mode, where they are not offered.
|
||||||
|
|
||||||
|
**Found on the way:** a refused connection is reported by lwIP as "reset", not "refused"; the first version called it "no route". And the header for the tested half was first given the same name as the service's, which makes a file include itself: the same mistake as an hour before, in the same way.
|
||||||
|
|
||||||
|
### The rest of the list: `tls`, `ntp`, `netstat` (2026-10-08)
|
||||||
|
|
||||||
|
- **`tls <host> [port]`** makes a handshake that checks nothing, so that a bad certificate can be looked at, and then checks it itself: against this device's roots (`ca_roots.h`, the ones the Update Service trusts) and the name asked for. It says who the certificate is for, who signed it, from when to when with the days left, the verdict with its reasons, and the SHA-256 that a Gemini pin is. It runs on a 12 KB task and isn't tried with less than 70 KB free: a handshake peaks at about 52 KB.
|
||||||
|
- **`ntp [server]`** sends one SNTP request and compares the answer with the device's clock, allowing for half the round trip. With no server it asks the first one in Settings.
|
||||||
|
- **`netstat`** reads lwIP's own lists: what listens, labelled where the firmware knows what it is, what is connected, and the UDP ports in use.
|
||||||
|
- Host tests: the NTP packet and the year 2036, the offset in words, a certificate's name (an old string type that mbedTLS prints as hex included), days between dates. 7 tests in `test/test_net_probe` in all.
|
||||||
|
|
||||||
|
| Check on the device | Result |
|
||||||
|
|---|---|
|
||||||
|
| `tls git.twis.la` | 709 ms; for git.twis.la, 67 days left, "this device trusts it", the SHA-256 |
|
||||||
|
| `tls geminiprotocol.net 1965` | "NOT trusted here: not signed by a root this device has": a capsule signs its own |
|
||||||
|
| `tls expired.badssl.com` | "EXPIRED 4197 days ago" |
|
||||||
|
| `tls wrong.host.badssl.com` | "NOT trusted here: not for that name" |
|
||||||
|
| `tls` to a port that isn't TLS | "no handshake ... An invalid SSL record was received" |
|
||||||
|
| `ntp` | The server, its stratum, 50 ms away; "this clock is right, to 0.1 s" |
|
||||||
|
| `netstat` | The update port and the Debug Console listening, the console's own connection, the UDP ports |
|
||||||
|
| Memory during a `tls` | 44.5 KB free at the lowest, from 104 KB |
|
||||||
|
|
||||||
|
**Not checked:** `tls` with IRC connected (it should refuse for lack of memory); `ntp` against a clock that is wrong; `netstat` while sharing.
|
||||||
|
|
||||||
|
## The SSH client (issue #2)
|
||||||
|
|
||||||
|
A terminal on another machine: one session to a shell, from the SSH App.
|
||||||
|
|
||||||
|
### Measured before deciding (2026-10-08)
|
||||||
|
|
||||||
|
`ewpa/LibSSH-ESP32` 5.10.0 (libssh on mbedTLS) was built into a trial firmware and a session opened against OpenSSH in a container, with a password.
|
||||||
|
|
||||||
|
| | Measured in the trial | As built |
|
||||||
|
|---|---|---|
|
||||||
|
| Flash | 120 KB | **292 KB** |
|
||||||
|
| Static RAM | 1.2 KB | |
|
||||||
|
| The session's task stack | 13 KB used | 13.7 KB used of 20 KB |
|
||||||
|
| Free heap with a session open | | 49 KB of 99 KB: it costs about 50 KB, the stack included |
|
||||||
|
| Lowest free heap during a login | | 30 KB |
|
||||||
|
| Key exchange (curve25519, ed25519 host key) | 227 ms | |
|
||||||
|
|
||||||
|
- **The trial undercounted the flash.** It logged in with a password. Signing with a key of the device's own (Q255) links libssh's table of multiples of the Ed25519 base point: `ge25519.c.o` alone is 109 KB. The rest of the difference is the public-key code, the terminal and three fonts. The firmware is at 71% of its slot.
|
||||||
|
- **libssh carries its own curve25519** (`src/external/curve25519_ref.c`) with the names libsodium uses, and libsodium is already here for WireGuard: two definitions don't link. A build script (`scripts/libssh_filter.py`) leaves libssh's copy out, and it uses libsodium's. It has to be a `pre:` script: libraries are built before any `post:` one runs.
|
||||||
|
- A session and a TLS connection don't fit together: IRC holds 40 KB.
|
||||||
|
|
||||||
|
### Decisions (design round 2026-10-08)
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Q254 | **LibSSH-ESP32 5.10.0**, with its duplicate curve file left out of the build. |
|
||||||
|
| Q255 | **A password, typed each time and never stored**, or **a key the device makes for itself** (Ed25519, no passphrase, kept in the settings store). Its public half is shown, written to `/ssh/id_ed25519.pub` and printed by `ssh status`. Keys made elsewhere aren't imported. |
|
||||||
|
| Q256 | **A terminal good enough for a shell, `less`, `top`, `nano` and `vim`:** cursor movement, erasing, sixteen colours, scroll regions, the alternate screen, the cursor keys' two modes. `TERM=xterm`. No mouse. |
|
||||||
|
| Q257 | **Five text sizes, changed with Ctrl and + or -** (the user's change to the round: the proposal was a setting). 4x6, 5x8, 6x10, 6x13 and 9x15 pixels: from 60 x 20 to 26 x 8 characters. The far end is told the new size; the choice is kept. |
|
||||||
|
| Q258 | **100 lines of scrollback**, as text, with Alt and up or down, as in the Shell. Not on the alternate screen. |
|
||||||
|
| Q259 | **Keys:** Ctrl with a letter; Tab; the backtick key sends Esc, as it is printed; Alt with it types a backtick; Fn with the arrow keys; Shift with those for Page Up and Down; Ctrl+Alt+q disconnects. Fn with backtick is Home, as everywhere. |
|
||||||
|
| Q260 | **The session outlives the App's time in front.** `SSH` in the Status Bar while one is open. |
|
||||||
|
| Q261 | **Not started under 75 KB free**, with the reason in words. |
|
||||||
|
| Q262 | **Up to eight hosts remembered**, the last used first, once a login has succeeded. Forgetting one forgets its server's fingerprint too, unless another remembered host is the same server. |
|
||||||
|
| Q263 | **Trust on first use,** on the SHA-256 fingerprint; sixteen servers remembered. **A changed key is a warning**, with Cancel selected. |
|
||||||
|
| Q264 | **UTF-8 in, the fonts' Latin-1 out:** what they lack is `?`, box-drawing lines are `+ - \|`. |
|
||||||
|
| Q265 | **`ssh user@host` in the Shell opens the App** and connects there. The password is never asked on a console. |
|
||||||
|
| Q266 | **Not built:** port forwarding, SFTP and scp, jump hosts, agent forwarding, keys with a passphrase, more than one session. |
|
||||||
|
|
||||||
|
### As built
|
||||||
|
|
||||||
|
- **`lib/term`** (host-tested, 12 tests in `test/test_terminal`): `Terminal`, the screen a program's output makes, with its history and the replies a program asks for; `encodeKey`, what a key sends; `SshHosts` and `SshKnownHosts`, the two lists kept in the settings store as lines of text.
|
||||||
|
- **`SshService`** (`src/services/ssh_service`): one session on a task of its own (20 KB of stack). The task and the main loop share the terminal, the bytes to send and the state under one lock. Its questions (is this the right server? the password?) are states it waits in until the App answers; the settings store is only written from the main loop. The password and the private key are overwritten after use.
|
||||||
|
- **`SshApp`** (`src/apps/ssh_app`): the hosts, the entry, the session, the key page. It draws the grid a run of same-coloured cells at a time, at most every 60 ms.
|
||||||
|
- **Keys that aren't characters now say what was held with them** (`lib/input/src/key_mapper.cpp`): the arrows, Enter, Del, Tab and Back carry Shift, Ctrl and Alt. The terminal needs it for Page Up and Alt+backtick. It also makes two documented keys work from the real keyboard, which until now only worked from the Debug Console's `key` command: Ctrl with Fn and up or down in a note, and Shift+Tab in Gemini.
|
||||||
|
- **IRC doesn't try to connect with less than 60 KB free** (`IrcService::kNeedFree`): see below.
|
||||||
|
- Settings: `SshHosts`, `SshKnown`, `SshKey`, `SshPublic`, `SshFont`.
|
||||||
|
|
||||||
|
### Checks on the device (2026-10-08, against OpenSSH 9.7 in a container on the same network)
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `ssh tester@host:2222` from the Debug Console | The App opens; the fingerprint shown is the one `ssh-keygen -lf` prints on the server |
|
||||||
|
| Trust it, a password | A shell; `stty size` says 15 48, `$TERM` is xterm |
|
||||||
|
| `ls -la`, `top`, `vim` (insert, Esc, `:wq`) | Drawn right: `top`'s reverse-video header, `vim`'s alternate screen and what was there before coming back; the file is on the server |
|
||||||
|
| Ctrl with + and - | `stty size` says 12 40, then 20 60; `top` redraws for it |
|
||||||
|
| `seq 1 60`, Alt with up | The history, in grey, with how far back in the corner |
|
||||||
|
| Fn+backtick, then the App again | The Launcher with `SSH` in the Status Bar; the session as it was |
|
||||||
|
| `sleep 100`, Ctrl+C; Alt+backtick | Interrupted; `` echo `id -u` `` prints 1000 |
|
||||||
|
| Ctrl+Alt+q; `exit` | "Disconnected"; "The session ended" |
|
||||||
|
| This device's key, its public half in `authorized_keys` | "Accepted publickey" in the server's log; no password asked |
|
||||||
|
| The server's host keys replaced | "THE SERVER'S KEY CHANGED" with the new fingerprint, Cancel selected. Cancel: "Not trusted: not connected". Replace: it connects, and doesn't ask again |
|
||||||
|
| A wrong password | "Wrong password", and asked again; Back gives up |
|
||||||
|
| A port nothing listens on | "Nothing listens there: the connection was refused" |
|
||||||
|
| `ssh nobody`, `ssh a@`, a port of 99999 | Refused, each with its reason |
|
||||||
|
| Forgetting a host | Asked, then gone from the list |
|
||||||
|
| `irc start` with a session open | "not enough memory: close the SSH session, retrying in 5 s", and no attempt: the lowest free heap doesn't move |
|
||||||
|
| Memory | 99 KB free before, 49 KB with a session open, 30 KB at the lowest during a login, 99 KB again after |
|
||||||
|
| Stacks | `ssh` 6.8 KB free of 20 KB; `loopTask` 1.9 KB free, as before |
|
||||||
|
|
||||||
|
**Not checked:** the refusal under 75 KB free (it is one comparison, and wasn't provoked). A server on the internet, or through the VPN. Wi-Fi lost in the middle of a session. Servers other than OpenSSH. `nano`, `less`, `htop`, `tmux`. Keyboard-interactive logins (two-factor prompts). A session left open for hours.
|
||||||
|
|
||||||
|
### What went wrong while building it
|
||||||
|
|
||||||
|
- **IRC, started with a session open, took the free heap down to 236 bytes.** A test script's keys went to the Launcher instead of the terminal and opened the IRC App, which connects when opened. Its TLS handshake found no memory, failed, and tried again with its usual back-off, six times; nothing crashed and it never connected, but 236 bytes is no margin at all. IRC now looks at the free heap before each attempt and says "not enough memory: close the SSH session" instead of trying.
|
||||||
|
- **The build script did nothing as a `post:` script:** the libraries were already built when it ran.
|
||||||
|
- **A failed connection was first shown as an empty terminal** with its reason squeezed on the last line, and a host was remembered before anyone had logged in to it. Both changed: the reason has a page, and a host is remembered once a login succeeds.
|
||||||
|
- **The trust question didn't fit its dialog:** the fingerprint is 50 characters. It is now split over two lines, under one line of words.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# R1 — Releases
|
# R1 — Releases
|
||||||
|
|
||||||
**Status:** in progress. CI and signed releases on Gitea (issue #5) are in place since 2026-10-06: every tag from v0.1.0 to v0.10.0 has its release. Updates from Gitea (issue #6) is built and checked on the device, on branch `gitea-updates`, not merged yet. The Issues App (#4) comes after.
|
**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).
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# S1 — System basics
|
# S1 — System basics
|
||||||
|
|
||||||
**Status:** the three planned items are done: the SD driver fix in v0.6.1 (issue #21, ADR 0007), fixed IPv4 settings in v0.7.0 (issue #7), the System App in v0.8.0 (issue #11). v0.8.1 adds the resting main loop (issue #40) and the GNSS pause for the radio's noise (issue #20, still open for the 11 dB that remain). Still open in the milestone: #39, following the SD driver upstream.
|
**Status:** the three planned items are done: the SD driver fix in v0.6.1 (issue #21, ADR 0007), fixed IPv4 settings in v0.7.0 (issue #7), the System App in v0.8.0 (issue #11). v0.8.1 adds the resting main loop (issue #40) and the GNSS pause for the radio's noise (issue #20, still open for the 11 dB that remain). Still open in the milestone: #39, following the SD driver upstream. The **Shell** (issue #67) shipped as **v0.14.0**; the Shell in Safe Mode (#77) is not started.
|
||||||
|
|
||||||
**Goal:** the device works on any network, the card can be trusted, and you can see what the system is doing. A side milestone, like G1.
|
**Goal:** the device works on any network, the card can be trusted, and you can see what the system is doing. A side milestone, like G1.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# U1 — Look and feel
|
# U1 — Look and feel
|
||||||
|
|
||||||
**Status:** in progress. The help key (issue #69) is merged; the website's key tables generated from the same lists (issue #72) are in a pull request. Screen recording (#17) and the rest of the milestone are not started.
|
**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, 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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# W1: Website
|
# W1: Website
|
||||||
|
|
||||||
**Status:** phases 1 to 3 (home, Install and Downloads; the user guide; how-tos and the FAQ) and the devlog are live at roro9stack.net; phase 4 (the developer docs) is in a pull request. Issue #12.
|
**Status:** live at roro9stack.net: the home, Install and Downloads pages, the user guide, the how-tos and the FAQ, the developer docs and the devlog (issue #12), published by CI since issue #79, with a search since issue #60. Not started: a Gemini mirror (#57), a French translation (#58), the docs of each version (#59).
|
||||||
|
|
||||||
**Goal:** a public home for the project at **roro9stack.net**, separate from the blog (stories) and from Gitea (developers): what it is, how to install it, how to use each App, and the docs.
|
**Goal:** a public home for the project at **roro9stack.net**, separate from the blog (stories) and from Gitea (developers): what it is, how to install it, how to use each App, and the docs.
|
||||||
|
|
||||||
|
|||||||
@@ -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,17 +16,50 @@ 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::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::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};
|
||||||
@@ -62,10 +96,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)) {
|
||||||
@@ -79,6 +145,9 @@ 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";
|
||||||
@@ -86,6 +155,7 @@ std::string SettingsMenu::value(int i) const {
|
|||||||
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";
|
||||||
|
case Row::Vpn: return settings_.getString(Setting::VpnConfig).empty() ? "Not set" : settings_.getBool(Setting::VpnAuto) ? "With Wi-Fi" : "By hand";
|
||||||
case Row::DebugConsole: return settings_.getBool(Setting::DebugConsole) ? "On" : "Off";
|
case Row::DebugConsole: return settings_.getBool(Setting::DebugConsole) ? "On" : "Off";
|
||||||
default: return "";
|
default: return "";
|
||||||
}
|
}
|
||||||
@@ -95,6 +165,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 {};
|
||||||
@@ -105,6 +180,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;
|
||||||
@@ -118,6 +194,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:
|
||||||
@@ -127,6 +204,8 @@ 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));
|
||||||
|
|||||||
@@ -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, DebugConsole, About };
|
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Launcher, Theme, ThemeLight, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About,
|
||||||
enum class 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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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"},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ inline constexpr KeyHelp kGeminiAnswer[] = {
|
|||||||
inline constexpr KeyHelp kLora[] = {
|
inline constexpr KeyHelp kLora[] = {
|
||||||
{"; .", "up, down"},
|
{"; .", "up, down"},
|
||||||
{"Enter", "the packet's details"},
|
{"Enter", "the packet's details"},
|
||||||
{"p", "pick a Meshtastic preset"},
|
{"p", "pick a preset"},
|
||||||
{"c", "start a Capture, or stop it"},
|
{"c", "start a Capture, or stop it"},
|
||||||
{"Tab", "the Sweep"},
|
{"Tab", "the Sweep"},
|
||||||
};
|
};
|
||||||
@@ -223,9 +223,15 @@ inline constexpr KeyHelp kStorage[] = {
|
|||||||
{"i", "details: size, date, type"},
|
{"i", "details: size, date, type"},
|
||||||
{"s", "sort: name, date, size"},
|
{"s", "sort: name, date, size"},
|
||||||
{"m", "Maintenance: clean-up, erase"},
|
{"m", "Maintenance: clean-up, erase"},
|
||||||
|
{"w", "share with a browser"},
|
||||||
{"`", "the folder above"},
|
{"`", "the folder above"},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// storage-share: Storage, sharing with a browser
|
||||||
|
inline constexpr KeyHelp kStorageShare[] = {
|
||||||
|
{"`", "stop sharing"},
|
||||||
|
};
|
||||||
|
|
||||||
// storage-details: Storage, an item's details
|
// storage-details: Storage, an item's details
|
||||||
inline constexpr KeyHelp kStorageDetails[] = {
|
inline constexpr KeyHelp kStorageDetails[] = {
|
||||||
{"; .", "scroll"},
|
{"; .", "scroll"},
|
||||||
@@ -301,6 +307,40 @@ inline constexpr KeyHelp kViewerImage[] = {
|
|||||||
{"Tab", "the file as hex"},
|
{"Tab", "the file as hex"},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// vpn: Settings, VPN
|
||||||
|
inline constexpr KeyHelp kVpn[] = {
|
||||||
|
{"Enter", "switch, import, forget"},
|
||||||
|
{"; .", "up, down"},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ssh: SSH, the hosts
|
||||||
|
inline constexpr KeyHelp kSsh[] = {
|
||||||
|
{"Enter", "connect, open"},
|
||||||
|
{"; .", "up, down"},
|
||||||
|
{"n", "a new connection"},
|
||||||
|
{"d", "forget this host"},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ssh-terminal: SSH, the terminal
|
||||||
|
inline constexpr KeyHelp kSshTerminal[] = {
|
||||||
|
{"`", "Esc"},
|
||||||
|
{"Alt `", "a backtick"},
|
||||||
|
{"Fn ; . , /", "the arrows"},
|
||||||
|
{"Fn Shift ; .", "Page Up, Page Down"},
|
||||||
|
{"Ctrl a..z", "Ctrl+C and the rest"},
|
||||||
|
{"Alt ; .", "scroll back, forward"},
|
||||||
|
{"Ctrl + -", "larger, smaller text"},
|
||||||
|
{"Ctrl Alt q", "disconnect"},
|
||||||
|
{"Fn `", "leave it running"},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ssh-key: SSH, this device's key
|
||||||
|
inline constexpr KeyHelp kSshKey[] = {
|
||||||
|
{"Enter", "make a key, or a new one"},
|
||||||
|
{"w", "write the public half to the card"},
|
||||||
|
{"`", "back"},
|
||||||
|
};
|
||||||
|
|
||||||
// notes: Notes, the list
|
// notes: Notes, the list
|
||||||
inline constexpr KeyHelp kNotes[] = {
|
inline constexpr KeyHelp kNotes[] = {
|
||||||
{"; .", "up, down"},
|
{"; .", "up, down"},
|
||||||
@@ -374,8 +414,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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#include "share_rules.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
namespace roro::files {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
int hexDigit(char c) {
|
||||||
|
if (c >= '0' && c <= '9') return c - '0';
|
||||||
|
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||||
|
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
// Whatever the two strings hold, the time taken says nothing about where they differ.
|
||||||
|
bool sameText(const std::string& a, const std::string& b) {
|
||||||
|
unsigned diff = static_cast<unsigned>(a.size() ^ b.size());
|
||||||
|
for (size_t i = 0; i < a.size() && i < b.size(); i++) diff |= static_cast<unsigned char>(a[i]) ^ static_cast<unsigned char>(b[i]);
|
||||||
|
return diff == 0;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string urlDecode(const std::string& text) {
|
||||||
|
std::string out;
|
||||||
|
out.reserve(text.size());
|
||||||
|
for (size_t i = 0; i < text.size(); i++) {
|
||||||
|
int hi, lo;
|
||||||
|
if (text[i] == '%' && i + 2 < text.size() + 0 && (hi = hexDigit(text[i + 1])) >= 0 && (lo = hexDigit(text[i + 2])) >= 0) {
|
||||||
|
out += static_cast<char>(hi * 16 + lo);
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
out += text[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool queryParam(const std::string& query, const std::string& key, std::string& out) {
|
||||||
|
for (size_t at = 0; at <= query.size();) {
|
||||||
|
size_t amp = query.find('&', at);
|
||||||
|
if (amp == std::string::npos) amp = query.size();
|
||||||
|
size_t eq = query.find('=', at);
|
||||||
|
if (eq != std::string::npos && eq < amp && query.compare(at, eq - at, key) == 0) {
|
||||||
|
out = urlDecode(query.substr(eq + 1, amp - eq - 1));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
at = amp + 1;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string cookieValue(const std::string& header, const std::string& name) {
|
||||||
|
for (size_t at = 0; at < header.size();) {
|
||||||
|
while (at < header.size() && (header[at] == ' ' || header[at] == ';')) at++;
|
||||||
|
size_t end = header.find(';', at);
|
||||||
|
if (end == std::string::npos) end = header.size();
|
||||||
|
size_t eq = header.find('=', at);
|
||||||
|
if (eq != std::string::npos && eq < end && header.compare(at, eq - at, name) == 0) return header.substr(eq + 1, end - eq - 1);
|
||||||
|
at = end;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string checkSharePath(const std::string& path) {
|
||||||
|
if (path.empty() || path[0] != '/') return "a path starts with /";
|
||||||
|
if (path.size() > 255) return "that path is too long";
|
||||||
|
if (path.size() > 1 && path.back() == '/') return "a path doesn't end with /";
|
||||||
|
for (size_t at = 1; at < path.size();) {
|
||||||
|
size_t end = path.find('/', at);
|
||||||
|
if (end == std::string::npos) end = path.size();
|
||||||
|
std::string part = path.substr(at, end - at);
|
||||||
|
if (part.empty() || part == "." || part == "..") return "that isn't a path on the card";
|
||||||
|
for (char c : part)
|
||||||
|
if (static_cast<unsigned char>(c) < 0x20 || c == 0x7F || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|')
|
||||||
|
return "a name can't hold that character";
|
||||||
|
at = end + 1;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string jsonString(const std::string& text) {
|
||||||
|
std::string out = "\"";
|
||||||
|
for (char c : text) {
|
||||||
|
unsigned char u = static_cast<unsigned char>(c);
|
||||||
|
if (c == '"' || c == '\\') {
|
||||||
|
out += '\\';
|
||||||
|
out += c;
|
||||||
|
} else if (u < 0x20) {
|
||||||
|
char buf[8];
|
||||||
|
std::snprintf(buf, sizeof buf, "\\u%04x", u);
|
||||||
|
out += buf;
|
||||||
|
} else {
|
||||||
|
out += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareListing::ShareListing(const std::string& path) : out_("{\"path\":" + jsonString(path) + ",\"items\":[") {}
|
||||||
|
|
||||||
|
void ShareListing::add(const std::string& name, uint32_t size, bool folder, int64_t modified) {
|
||||||
|
if (count_++) out_ += ',';
|
||||||
|
out_ += "{\"n\":" + jsonString(name) + ",\"s\":" + std::to_string(size) + ",\"d\":" + (folder ? "1" : "0") + ",\"t\":" + std::to_string(modified) + "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ShareListing::json(bool more) { return out_ + "],\"more\":" + (more ? "true" : "false") + "}"; }
|
||||||
|
|
||||||
|
void ShareAuth::begin(const uint8_t random[4]) {
|
||||||
|
uint32_t n = (static_cast<uint32_t>(random[0]) << 24 | random[1] << 16 | random[2] << 8 | random[3]) % 1000000u;
|
||||||
|
char buf[8];
|
||||||
|
std::snprintf(buf, sizeof buf, "%06u", static_cast<unsigned>(n));
|
||||||
|
code_ = buf;
|
||||||
|
token_.clear();
|
||||||
|
gate_ = debug::AuthGate();
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareAuth::Result ShareAuth::login(const std::string& code, uint32_t nowMs, const uint8_t random[16], std::string& token) {
|
||||||
|
if (code_.empty() || gate_.locked(nowMs)) return Result::Locked;
|
||||||
|
std::string digits;
|
||||||
|
for (char c : code)
|
||||||
|
if (c >= '0' && c <= '9') digits += c; // "123 456" is as good
|
||||||
|
if (!sameText(digits, code_)) return gate_.failed(nowMs) ? Result::Locked : Result::Wrong;
|
||||||
|
gate_.succeeded();
|
||||||
|
static const char* const kHex = "0123456789abcdef";
|
||||||
|
token_.clear();
|
||||||
|
for (int i = 0; i < 16; i++) {
|
||||||
|
token_ += kHex[random[i] >> 4];
|
||||||
|
token_ += kHex[random[i] & 15];
|
||||||
|
}
|
||||||
|
token = token_;
|
||||||
|
return Result::Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ShareAuth::allowed(const std::string& token) const { return !token_.empty() && sameText(token, token_); }
|
||||||
|
|
||||||
|
} // namespace roro::files
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "debug_auth.h"
|
||||||
|
|
||||||
|
// The parts of sharing files with a browser (issue #88) that need no network: what a request
|
||||||
|
// asks for, whether it may, and the answers as JSON. The server itself is src/services/web_share.h.
|
||||||
|
namespace roro::files {
|
||||||
|
|
||||||
|
std::string urlDecode(const std::string& text); // %41 is A; a + stays a +
|
||||||
|
// The value of `key` in a query string ("path=%2Fnotes&replace=1"), decoded. False if it isn't there.
|
||||||
|
bool queryParam(const std::string& query, const std::string& key, std::string& out);
|
||||||
|
// The value of a cookie in a Cookie header ("a=1; s=abc"), or "".
|
||||||
|
std::string cookieValue(const std::string& header, const std::string& name);
|
||||||
|
|
||||||
|
// A path a browser may name: from the card's root, no "..", nothing a file name can't hold.
|
||||||
|
// "" or why not.
|
||||||
|
std::string checkSharePath(const std::string& path);
|
||||||
|
|
||||||
|
std::string jsonString(const std::string& text); // with its quotes
|
||||||
|
|
||||||
|
// A folder's listing as the page wants it: {"path":"/notes","items":[{"n":"a.txt","s":12,"d":0,"t":1791400000}],"more":false}
|
||||||
|
class ShareListing {
|
||||||
|
public:
|
||||||
|
explicit ShareListing(const std::string& path);
|
||||||
|
void add(const std::string& name, uint32_t size, bool folder, int64_t modified);
|
||||||
|
std::string json(bool more);
|
||||||
|
size_t count() const { return count_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string out_;
|
||||||
|
size_t count_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Who may use the page: whoever typed the code the device's screen shows. The code is new each
|
||||||
|
// time sharing starts; five wrong ones in a row close the door for a minute (as the Debug
|
||||||
|
// Console's token does). A browser that got it right is given a token to send back as a cookie.
|
||||||
|
// Nothing here is encrypted on the way: see the issue.
|
||||||
|
class ShareAuth {
|
||||||
|
public:
|
||||||
|
enum class Result { Ok, Wrong, Locked };
|
||||||
|
|
||||||
|
void begin(const uint8_t random[4]); // a new code, and nobody is logged in
|
||||||
|
const std::string& code() const { return code_; } // six digits
|
||||||
|
Result login(const std::string& code, uint32_t nowMs, const uint8_t random[16], std::string& token);
|
||||||
|
bool allowed(const std::string& token) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string code_, token_;
|
||||||
|
debug::AuthGate gate_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro::files
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ KeyEvent charEvent(uint32_t cp, const RawKeys& keys) {
|
|||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A key that isn't a character, with what was held: a terminal tells Alt+Enter from Enter (issue #2).
|
||||||
|
KeyEvent keyEvent(Key key, const RawKeys& keys) {
|
||||||
|
KeyEvent e = KeyEvent::of(key);
|
||||||
|
e.shift = keys.shift;
|
||||||
|
e.ctrl = keys.ctrl;
|
||||||
|
e.alt = keys.alt;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
std::vector<KeyEvent> KeyMapper::update(const RawKeys& keys) {
|
std::vector<KeyEvent> KeyMapper::update(const RawKeys& keys) {
|
||||||
@@ -51,9 +60,9 @@ std::vector<KeyEvent> KeyMapper::update(const RawKeys& keys) {
|
|||||||
if (keys.opt && !previous_.opt && keys.chars.empty()) {
|
if (keys.opt && !previous_.opt && keys.chars.empty()) {
|
||||||
compose_ = compose_ ? 0 : kArmed; // a second opt cancels
|
compose_ = compose_ ? 0 : kArmed; // a second opt cancels
|
||||||
}
|
}
|
||||||
if (keys.enter && !previous_.enter) out.push_back(KeyEvent::of(Key::Select));
|
if (keys.enter && !previous_.enter) out.push_back(keyEvent(Key::Select, keys));
|
||||||
if (keys.del && !previous_.del) out.push_back(KeyEvent::of(Key::Delete));
|
if (keys.del && !previous_.del) out.push_back(keyEvent(Key::Delete, keys));
|
||||||
if (keys.tab && !previous_.tab) out.push_back(KeyEvent::of(Key::Tab));
|
if (keys.tab && !previous_.tab) out.push_back(keyEvent(Key::Tab, keys));
|
||||||
|
|
||||||
for (char c : keys.chars) {
|
for (char c : keys.chars) {
|
||||||
bool wasHeld = std::find(previous_.chars.begin(), previous_.chars.end(), c) != previous_.chars.end();
|
bool wasHeld = std::find(previous_.chars.begin(), previous_.chars.end(), c) != previous_.chars.end();
|
||||||
@@ -89,10 +98,10 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
|
|||||||
|
|
||||||
if (keys.fn || !textEntry_) {
|
if (keys.fn || !textEntry_) {
|
||||||
switch (c) {
|
switch (c) {
|
||||||
case ';': out.push_back(KeyEvent::of(Key::Up)); return;
|
case ';': out.push_back(keyEvent(Key::Up, keys)); return;
|
||||||
case '.': out.push_back(KeyEvent::of(Key::Down)); return;
|
case '.': out.push_back(keyEvent(Key::Down, keys)); return;
|
||||||
case ',': out.push_back(KeyEvent::of(Key::Left)); return;
|
case ',': out.push_back(keyEvent(Key::Left, keys)); return;
|
||||||
case '/': out.push_back(KeyEvent::of(Key::Right)); return;
|
case '/': out.push_back(keyEvent(Key::Right, keys)); return;
|
||||||
case '`':
|
case '`':
|
||||||
if (keys.fn) {
|
if (keys.fn) {
|
||||||
out.push_back(KeyEvent::of(Key::Home));
|
out.push_back(KeyEvent::of(Key::Home));
|
||||||
@@ -126,7 +135,7 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (c == '`') {
|
if (c == '`') {
|
||||||
out.push_back(KeyEvent::of(Key::Back));
|
out.push_back(keyEvent(Key::Back, keys));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
out.push_back(charEvent(static_cast<unsigned char>(c), keys));
|
out.push_back(charEvent(static_cast<unsigned char>(c), keys));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
|
||||||
#include "base64.h"
|
#include "base64.h"
|
||||||
|
#include "version.h"
|
||||||
|
|
||||||
namespace roro {
|
namespace roro {
|
||||||
|
|
||||||
@@ -284,7 +285,7 @@ void IrcSession::onPrivmsg(const IrcMessage& m, int64_t utc, bool notice) {
|
|||||||
action = true;
|
action = true;
|
||||||
text = rest;
|
text = rest;
|
||||||
} else {
|
} else {
|
||||||
if (verb == "VERSION" && !notice) send(std::string("NOTICE ") + from + " :" + kCtcp + "VERSION roro9stack" + kCtcp);
|
if (verb == "VERSION" && !notice) send(std::string("NOTICE ") + from + " :" + kCtcp + "VERSION " + kProductName + " " + versionString() + kCtcp);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,10 +360,12 @@ void IrcSession::command(int b, const std::string& text, int64_t utc) {
|
|||||||
} else if (verb == "quit") {
|
} else if (verb == "quit") {
|
||||||
quit_ = true;
|
quit_ = true;
|
||||||
send(IrcMessage::serialize("QUIT", {rest.empty() ? "roro9stack" : rest}));
|
send(IrcMessage::serialize("QUIT", {rest.empty() ? "roro9stack" : rest}));
|
||||||
|
} else if (verb == "uname") { // shown here, not said to anyone
|
||||||
|
info(b, unameString(), utc);
|
||||||
} else if (verb == "raw" || verb == "quote") {
|
} else if (verb == "raw" || verb == "quote") {
|
||||||
if (!rest.empty()) send(rest);
|
if (!rest.empty()) send(rest);
|
||||||
} else {
|
} else {
|
||||||
info(b, "Unknown command /" + verb + " (try /join or /j, /part /msg /me /nick /topic /names /quit /raw)", utc);
|
info(b, "Unknown command /" + verb + " (try /join or /j, /part /msg /me /nick /topic /names /uname /quit /raw)", utc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <ctime>
|
||||||
|
|
||||||
|
#include "meshcore_channel.h"
|
||||||
|
#include "meshcore_packet.h"
|
||||||
#include "meshtastic_header.h"
|
#include "meshtastic_header.h"
|
||||||
#include "meshtastic_presets.h"
|
#include "meshtastic_presets.h"
|
||||||
|
|
||||||
@@ -10,13 +13,137 @@ namespace roro::lora {
|
|||||||
|
|
||||||
using namespace meshtastic;
|
using namespace meshtastic;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::string hex(const uint8_t* p, size_t n) {
|
||||||
|
std::string out;
|
||||||
|
char s[4];
|
||||||
|
for (size_t i = 0; i < n; ++i) {
|
||||||
|
std::snprintf(s, sizeof s, "%02x", p[i]);
|
||||||
|
out += s;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Onto lines of `cols` characters at most, broken at spaces where there are some.
|
||||||
|
void wrapInto(std::vector<std::string>& lines, std::string text, size_t cols) {
|
||||||
|
while (text.size() > cols) {
|
||||||
|
size_t cut = text.rfind(' ', cols);
|
||||||
|
if (cut == std::string::npos || cut == 0) cut = cols;
|
||||||
|
lines.push_back(text.substr(0, cut));
|
||||||
|
text.erase(0, text[cut] == ' ' ? cut + 1 : cut);
|
||||||
|
}
|
||||||
|
if (!text.empty()) lines.push_back(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `room` characters at most: a node's name or a message is cut to fit.
|
||||||
|
std::string meshcoreRow(const meshcore::Packet& m, size_t room) {
|
||||||
|
std::string out = meshcore::payloadShort(m.type);
|
||||||
|
std::string hops = m.hops ? " " + std::to_string(m.hops) + "h" : "";
|
||||||
|
meshcore::Advert a;
|
||||||
|
meshcore::Discover d;
|
||||||
|
meshcore::ChannelText t;
|
||||||
|
if (meshcore::parseDiscover(m, d)) {
|
||||||
|
out = d.response ? "here " + hex(d.id, 2) : "who's there?";
|
||||||
|
} else if (meshcore::readChannelText(m, t)) {
|
||||||
|
out = (t.sender.empty() ? "" : t.sender + ": ") + t.text;
|
||||||
|
out = out.substr(0, room > hops.size() ? room - hops.size() : 0);
|
||||||
|
while (!out.empty() && out.back() == ' ') out.pop_back();
|
||||||
|
return out + hops;
|
||||||
|
} else if (meshcore::parseAdvert(m, a)) {
|
||||||
|
size_t used = out.size() + 1 + hops.size();
|
||||||
|
out += " " + (a.name.empty() ? hex(a.key, 2) : a.name.substr(0, room > used ? room - used : 0));
|
||||||
|
} else if (m.addressed() && m.payloadLen >= 2)
|
||||||
|
out += " " + hex(m.payload + 1, 1) + ">" + hex(m.payload, 1);
|
||||||
|
else if (m.group() && m.payloadLen >= 1)
|
||||||
|
out += " #" + hex(m.payload, 1);
|
||||||
|
else if (m.type == meshcore::Payload::AnonRequest && m.payloadLen >= 1)
|
||||||
|
out += " >" + hex(m.payload, 1);
|
||||||
|
return out + hops;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> meshcoreLines(const uint8_t* data, size_t len) {
|
||||||
|
meshcore::Packet m;
|
||||||
|
if (!meshcore::parsePacket(data, len, m)) return {};
|
||||||
|
char s[64];
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
lines.push_back("MeshCore " + std::string(meshcore::payloadName(m.type)) + ", " + meshcore::routeName(m.route));
|
||||||
|
if (m.hasTransport) {
|
||||||
|
std::snprintf(s, sizeof s, "Region code %04x", m.transport[0]);
|
||||||
|
lines.push_back(s);
|
||||||
|
}
|
||||||
|
// A flood gathers who repeated it; a direct packet carries who is still to repeat it.
|
||||||
|
if (m.hops) {
|
||||||
|
std::string path = std::string(m.flood() ? "Through" : "Route");
|
||||||
|
for (uint8_t i = 0; i < m.hops; ++i) {
|
||||||
|
std::string hop = " " + hex(m.path + i * m.hashSize, m.hashSize);
|
||||||
|
if (path.size() + hop.size() > 36) { // onto another line
|
||||||
|
lines.push_back(path);
|
||||||
|
path = " ";
|
||||||
|
}
|
||||||
|
path += hop;
|
||||||
|
}
|
||||||
|
lines.push_back(path);
|
||||||
|
} else {
|
||||||
|
lines.push_back(m.flood() ? "Heard first hand: no repeater yet" : "No route in it: to a neighbour");
|
||||||
|
}
|
||||||
|
meshcore::Advert a;
|
||||||
|
meshcore::Discover d;
|
||||||
|
if (meshcore::parseDiscover(m, d) && d.response) {
|
||||||
|
lines.push_back(std::string(meshcore::kindName(d.kind)) + " answering a search");
|
||||||
|
lines.push_back("Key " + hex(d.id, 8) + "...");
|
||||||
|
std::snprintf(s, sizeof s, "It heard the search at %.1f dB SNR", d.snr);
|
||||||
|
lines.push_back(s);
|
||||||
|
} else if (meshcore::parseDiscover(m, d)) {
|
||||||
|
std::string who;
|
||||||
|
for (int kind = 1; kind <= 4; ++kind)
|
||||||
|
if (d.kinds & (1 << kind)) who += std::string(who.empty() ? "" : ", ") + meshcore::kindName(static_cast<uint8_t>(kind));
|
||||||
|
lines.push_back("A search for nodes nearby");
|
||||||
|
lines.push_back("Wanted: " + (who.empty() ? std::string("any") : who));
|
||||||
|
} else if (meshcore::parseAdvert(m, a)) {
|
||||||
|
lines.push_back(std::string(meshcore::kindName(a.kind)) + (a.name.empty() ? "" : " " + a.name));
|
||||||
|
lines.push_back("Key " + hex(a.key, 8) + "...");
|
||||||
|
if (a.hasLocation) {
|
||||||
|
std::snprintf(s, sizeof s, "At %.5f, %.5f", a.latE6 / 1e6, a.lonE6 / 1e6);
|
||||||
|
lines.push_back(s);
|
||||||
|
}
|
||||||
|
} else if (m.addressed() && m.payloadLen >= 4) {
|
||||||
|
std::snprintf(s, sizeof s, "From ..%02x to ..%02x, %u B encrypted", m.payload[1], m.payload[0], static_cast<unsigned>(m.payloadLen - 4));
|
||||||
|
lines.push_back(s);
|
||||||
|
} else if (meshcore::ChannelText t; meshcore::readChannelText(m, t)) {
|
||||||
|
lines.push_back(std::string("On the ") + t.channel + " channel" + (t.sender.empty() ? "" : ", from " + t.sender));
|
||||||
|
std::time_t sent = t.timestamp;
|
||||||
|
std::tm tm{};
|
||||||
|
gmtime_r(&sent, &tm);
|
||||||
|
std::strftime(s, sizeof s, "Sent %Y-%m-%d %H:%M:%S UTC", &tm);
|
||||||
|
lines.push_back(s);
|
||||||
|
wrapInto(lines, t.text, 38);
|
||||||
|
} else if (m.group() && m.payloadLen >= 3) {
|
||||||
|
std::snprintf(s, sizeof s, "Channel 0x%02x%s, %u B encrypted", m.payload[0], m.payload[0] == meshcore::kPublicChannelHash ? " (public?)" : "",
|
||||||
|
static_cast<unsigned>(m.payloadLen - 3));
|
||||||
|
lines.push_back(s);
|
||||||
|
} else if (m.type == meshcore::Payload::AnonRequest && m.payloadLen >= 35) {
|
||||||
|
std::snprintf(s, sizeof s, "To ..%02x from key %s...", m.payload[0], hex(m.payload + 1, 4).c_str());
|
||||||
|
lines.push_back(s);
|
||||||
|
} else if (m.type == meshcore::Payload::Ack && m.payloadLen >= 4) {
|
||||||
|
lines.push_back("Acknowledges " + hex(m.payload, 4));
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Protocol protocolOf(uint8_t syncWord) {
|
||||||
|
return syncWord == meshtastic::kSyncWord ? Protocol::Meshtastic : syncWord == meshcore::kSyncWord ? Protocol::MeshCore : Protocol::None;
|
||||||
|
}
|
||||||
|
|
||||||
std::string row(const PacketSummary& p, const std::string& time) {
|
std::string row(const PacketSummary& p, const std::string& time) {
|
||||||
char s[64];
|
char s[64];
|
||||||
int n = std::snprintf(s, sizeof s, "%s %ld %.1f ", time.c_str(), std::lround(p.rssi), p.snr);
|
int n = std::snprintf(s, sizeof s, "%s %ld %.1f ", time.c_str(), std::lround(p.rssi), p.snr);
|
||||||
std::string out(s, n);
|
std::string out(s, n);
|
||||||
PacketHeader h;
|
PacketHeader h;
|
||||||
if (!p.crcOk) return out + "bad CRC, " + std::to_string(p.len) + " B";
|
if (!p.crcOk) return out + "bad CRC, " + std::to_string(p.len) + " B";
|
||||||
if (!p.meshtastic || !parseHeader(p.data, p.len, h)) return out + std::to_string(p.len) + " B";
|
meshcore::Packet m;
|
||||||
|
if (p.protocol == Protocol::MeshCore && meshcore::parsePacket(p.data, p.len, m)) return out + meshcoreRow(m, out.size() < 38 ? 38 - out.size() : 0);
|
||||||
|
if (p.protocol != Protocol::Meshtastic || !parseHeader(p.data, p.len, h)) return out + std::to_string(p.len) + " B";
|
||||||
auto shortId = [](uint32_t node) {
|
auto shortId = [](uint32_t node) {
|
||||||
if (node == kBroadcast) return std::string("all");
|
if (node == kBroadcast) return std::string("all");
|
||||||
char id[8];
|
char id[8];
|
||||||
@@ -48,7 +175,9 @@ std::vector<std::string> hexDump(const uint8_t* data, size_t len) {
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<std::string> headerLines(const uint8_t* data, size_t len) {
|
std::vector<std::string> headerLines(const uint8_t* data, size_t len, Protocol protocol) {
|
||||||
|
if (protocol == Protocol::MeshCore) return meshcoreLines(data, len);
|
||||||
|
if (protocol != Protocol::Meshtastic) return {};
|
||||||
PacketHeader h;
|
PacketHeader h;
|
||||||
if (!parseHeader(data, len, h)) return {};
|
if (!parseHeader(data, len, h)) return {};
|
||||||
char s[64];
|
char s[64];
|
||||||
|
|||||||
@@ -7,23 +7,30 @@
|
|||||||
|
|
||||||
namespace roro::lora {
|
namespace roro::lora {
|
||||||
|
|
||||||
|
// Whose packets the radio's settings were for, by their sync word: whose clear header to read.
|
||||||
|
enum class Protocol : uint8_t { None, Meshtastic, MeshCore };
|
||||||
|
Protocol protocolOf(uint8_t syncWord);
|
||||||
|
|
||||||
// What the LoRa Scanner shows of one packet (M3, Q96).
|
// What the LoRa Scanner shows of one packet (M3, Q96).
|
||||||
struct PacketSummary {
|
struct PacketSummary {
|
||||||
const uint8_t* data;
|
const uint8_t* data;
|
||||||
size_t len;
|
size_t len;
|
||||||
float rssi, snr;
|
float rssi, snr;
|
||||||
bool crcOk;
|
bool crcOk;
|
||||||
bool meshtastic; // received on Meshtastic settings (sync word 0x2B): read its clear header
|
Protocol protocol; // what the settings it was received on were for
|
||||||
};
|
};
|
||||||
|
|
||||||
// One list row, at most about 36 characters: "21:45:07 -97 6.2 5678>all 1/3". Nodes by their default
|
// One list row, at most about 36 characters: "21:45:07 -97 6.2 5678>all 1/3". Meshtastic nodes by their
|
||||||
// short name (the last 4 hex digits); headerLines() has the full numbers.
|
// default short name (the last 4 hex digits); headerLines() has the full numbers. MeshCore: what
|
||||||
|
// it is, then who ("adv Brussels-R1", "txt a3>7f", "chan #11", "who's there?", "here 18a6") and the hops in its path ("2h");
|
||||||
|
// a message on the public channel is read: "Alice: hello fro 2h".
|
||||||
std::string row(const PacketSummary& p, const std::string& time);
|
std::string row(const PacketSummary& p, const std::string& time);
|
||||||
|
|
||||||
// Eight bytes a line, with the printable ones: "0000 ff ff ff ff 78 56 34 12 ....xV4.".
|
// Eight bytes a line, with the printable ones: "0000 ff ff ff ff 78 56 34 12 ....xV4.".
|
||||||
std::vector<std::string> hexDump(const uint8_t* data, size_t len);
|
std::vector<std::string> hexDump(const uint8_t* data, size_t len);
|
||||||
|
|
||||||
// The Meshtastic header, one field a line; empty when the packet is too short to have one.
|
// What is in clear, one field a line: the Meshtastic header, or MeshCore's header, path and (for an
|
||||||
std::vector<std::string> headerLines(const uint8_t* data, size_t len);
|
// advert) who the node says it is, and a message on its public channel. Empty when the packet can't be read as that.
|
||||||
|
std::vector<std::string> headerLines(const uint8_t* data, size_t len, Protocol protocol = Protocol::Meshtastic);
|
||||||
|
|
||||||
} // namespace roro::lora
|
} // namespace roro::lora
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#include "scan_presets.h"
|
||||||
|
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
|
#include "meshcore_packet.h"
|
||||||
|
#include "meshtastic_presets.h"
|
||||||
|
|
||||||
|
namespace roro::lora {
|
||||||
|
|
||||||
|
size_t scanPresetCount() { return meshtastic::kEu868PresetCount + 1; }
|
||||||
|
|
||||||
|
ScanPreset scanPreset(size_t i) {
|
||||||
|
if (i < meshtastic::kEu868PresetCount) {
|
||||||
|
const meshtastic::Preset& p = meshtastic::kEu868Presets[i];
|
||||||
|
return {p.name, meshtastic::eu868FrequencyHz(p), p.bwKHz, p.sf, p.cr, meshtastic::kSyncWord, meshtastic::kPreambleLength, Protocol::Meshtastic};
|
||||||
|
}
|
||||||
|
return {meshcore::kPresetName, meshcore::kEuNarrowHz, meshcore::kEuNarrowBwKHz, meshcore::kEuNarrowSf, meshcore::kEuNarrowCr,
|
||||||
|
meshcore::kSyncWord, meshcore::kPreambleLength, Protocol::MeshCore};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool findScanPreset(const char* name, ScanPreset& out) {
|
||||||
|
for (size_t i = 0; i < scanPresetCount(); ++i) {
|
||||||
|
ScanPreset p = scanPreset(i);
|
||||||
|
const char *a = p.name, *b = name;
|
||||||
|
while (*a && *b && std::tolower(static_cast<unsigned char>(*a)) == std::tolower(static_cast<unsigned char>(*b))) ++a, ++b;
|
||||||
|
if (!*a && !*b) {
|
||||||
|
out = p;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro::lora
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "packet_view.h"
|
||||||
|
|
||||||
|
namespace roro::lora {
|
||||||
|
|
||||||
|
// What the LoRa Scanner can be told to listen to by name: the Meshtastic presets allowed in
|
||||||
|
// EU_868, LongFast first, then MeshCore's EU/UK (Narrow), the default. The order is kept: the
|
||||||
|
// chosen one is stored by its number.
|
||||||
|
struct ScanPreset {
|
||||||
|
const char* name;
|
||||||
|
uint32_t frequencyHz;
|
||||||
|
float bwKHz;
|
||||||
|
uint8_t sf, cr, syncWord;
|
||||||
|
uint16_t preamble;
|
||||||
|
Protocol protocol;
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr size_t kDefaultScanPreset = 7; // MeshCore: the Settings' default says the same
|
||||||
|
size_t scanPresetCount();
|
||||||
|
ScanPreset scanPreset(size_t i); // i < scanPresetCount()
|
||||||
|
// By name, whatever its case. False when there is none.
|
||||||
|
bool findScanPreset(const char* name, ScanPreset& out);
|
||||||
|
|
||||||
|
} // namespace roro::lora
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#include "aes128.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace roro::meshcore {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// The S-box and its inverse, worked out once rather than typed in.
|
||||||
|
struct Tables {
|
||||||
|
uint8_t s[256], inv[256];
|
||||||
|
Tables() {
|
||||||
|
auto rotl = [](uint8_t x, int n) { return static_cast<uint8_t>(x << n | x >> (8 - n)); };
|
||||||
|
uint8_t p = 1, q = 1;
|
||||||
|
do {
|
||||||
|
p = static_cast<uint8_t>(p ^ (p << 1) ^ (p & 0x80 ? 0x1B : 0)); // p times 3
|
||||||
|
q ^= static_cast<uint8_t>(q << 1); // q divided by 3
|
||||||
|
q ^= static_cast<uint8_t>(q << 2);
|
||||||
|
q ^= static_cast<uint8_t>(q << 4);
|
||||||
|
if (q & 0x80) q ^= 0x09;
|
||||||
|
uint8_t x = static_cast<uint8_t>(q ^ rotl(q, 1) ^ rotl(q, 2) ^ rotl(q, 3) ^ rotl(q, 4) ^ 0x63);
|
||||||
|
s[p] = x;
|
||||||
|
inv[x] = p;
|
||||||
|
} while (p != 1);
|
||||||
|
s[0] = 0x63;
|
||||||
|
inv[0x63] = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const Tables& tables() {
|
||||||
|
static const Tables t;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t mul(uint8_t a, uint8_t b) { // in GF(2^8)
|
||||||
|
uint8_t r = 0;
|
||||||
|
for (; b; b >>= 1) {
|
||||||
|
if (b & 1) r ^= a;
|
||||||
|
a = static_cast<uint8_t>(a << 1 ^ (a & 0x80 ? 0x1B : 0));
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Aes128::Aes128(const uint8_t key[16]) {
|
||||||
|
const Tables& t = tables();
|
||||||
|
std::memcpy(roundKeys_, key, 16);
|
||||||
|
uint8_t rcon = 1;
|
||||||
|
for (int i = 16; i < 176; i += 4) {
|
||||||
|
uint8_t w[4];
|
||||||
|
std::memcpy(w, roundKeys_ + i - 4, 4);
|
||||||
|
if (i % 16 == 0) {
|
||||||
|
uint8_t first = w[0];
|
||||||
|
w[0] = static_cast<uint8_t>(t.s[w[1]] ^ rcon);
|
||||||
|
w[1] = t.s[w[2]];
|
||||||
|
w[2] = t.s[w[3]];
|
||||||
|
w[3] = t.s[first];
|
||||||
|
rcon = static_cast<uint8_t>(rcon << 1 ^ (rcon & 0x80 ? 0x1B : 0));
|
||||||
|
}
|
||||||
|
for (int j = 0; j < 4; ++j) roundKeys_[i + j] = roundKeys_[i - 16 + j] ^ w[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Aes128::decryptBlock(const uint8_t in[16], uint8_t out[16]) const {
|
||||||
|
const Tables& t = tables();
|
||||||
|
uint8_t s[16];
|
||||||
|
for (int i = 0; i < 16; ++i) s[i] = in[i] ^ roundKeys_[160 + i];
|
||||||
|
for (int round = 9; round >= 0; --round) {
|
||||||
|
// Rows shifted back and bytes substituted back, in one go: column c, row r came from column c - r.
|
||||||
|
uint8_t u[16];
|
||||||
|
for (int c = 0; c < 4; ++c)
|
||||||
|
for (int r = 0; r < 4; ++r) u[4 * c + r] = t.inv[s[4 * ((c - r + 4) % 4) + r]];
|
||||||
|
for (int i = 0; i < 16; ++i) u[i] ^= roundKeys_[16 * round + i];
|
||||||
|
if (round == 0) {
|
||||||
|
std::memcpy(s, u, 16);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for (int c = 0; c < 4; ++c) {
|
||||||
|
const uint8_t* a = u + 4 * c;
|
||||||
|
s[4 * c + 0] = mul(a[0], 14) ^ mul(a[1], 11) ^ mul(a[2], 13) ^ mul(a[3], 9);
|
||||||
|
s[4 * c + 1] = mul(a[0], 9) ^ mul(a[1], 14) ^ mul(a[2], 11) ^ mul(a[3], 13);
|
||||||
|
s[4 * c + 2] = mul(a[0], 13) ^ mul(a[1], 9) ^ mul(a[2], 14) ^ mul(a[3], 11);
|
||||||
|
s[4 * c + 3] = mul(a[0], 11) ^ mul(a[1], 13) ^ mul(a[2], 9) ^ mul(a[3], 14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::memcpy(out, s, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro::meshcore
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace roro::meshcore {
|
||||||
|
|
||||||
|
// AES-128 (FIPS 197), one block at a time, decryption only: what reading a MeshCore message
|
||||||
|
// takes. Small and dependency-free, so the same code runs in the PC tests and on the device.
|
||||||
|
class Aes128 {
|
||||||
|
public:
|
||||||
|
explicit Aes128(const uint8_t key[16]);
|
||||||
|
void decryptBlock(const uint8_t in[16], uint8_t out[16]) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
uint8_t roundKeys_[176];
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro::meshcore
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#include "meshcore_channel.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "aes128.h"
|
||||||
|
#include "sha256.h"
|
||||||
|
|
||||||
|
namespace roro::meshcore {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// izOH6cXN6mrJ5e26oRXNcg== in base64, as the apps show it.
|
||||||
|
const Channel kChannels[] = {
|
||||||
|
{"Public", {0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72}},
|
||||||
|
};
|
||||||
|
|
||||||
|
void hmacSha256(const uint8_t key[16], const uint8_t* data, size_t len, uint8_t out[32]) {
|
||||||
|
uint8_t pad[64], inner[32];
|
||||||
|
std::memset(pad, 0x36, sizeof pad);
|
||||||
|
for (int i = 0; i < 16; ++i) pad[i] ^= key[i];
|
||||||
|
Sha256 in;
|
||||||
|
in.update(pad, sizeof pad);
|
||||||
|
in.update(data, len);
|
||||||
|
in.finish(inner);
|
||||||
|
std::memset(pad, 0x5C, sizeof pad);
|
||||||
|
for (int i = 0; i < 16; ++i) pad[i] ^= key[i];
|
||||||
|
Sha256 o;
|
||||||
|
o.update(pad, sizeof pad);
|
||||||
|
o.update(inner, sizeof inner);
|
||||||
|
o.finish(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes that aren't printable ASCII become '?': one for a whole UTF-8 character.
|
||||||
|
std::string printable(const uint8_t* p, size_t n) {
|
||||||
|
std::string out;
|
||||||
|
for (size_t i = 0; i < n; ++i) {
|
||||||
|
if (p[i] >= 0x20 && p[i] < 0x7F) out += static_cast<char>(p[i]);
|
||||||
|
else if ((p[i] & 0xC0) != 0x80) out += '?';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
size_t channelCount() { return sizeof kChannels / sizeof kChannels[0]; }
|
||||||
|
const Channel& channel(size_t i) { return kChannels[i]; }
|
||||||
|
|
||||||
|
uint8_t channelHash(const Channel& c) {
|
||||||
|
uint8_t h[32];
|
||||||
|
Sha256::hash(c.key, sizeof c.key, h);
|
||||||
|
return h[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool readChannelText(const Packet& p, ChannelText& out) {
|
||||||
|
// The channel's hash, 2 bytes of check, then whole AES blocks.
|
||||||
|
if (p.type != Payload::GroupText || p.payloadLen < 3 + 16 || (p.payloadLen - 3) % 16) return false;
|
||||||
|
const uint8_t* cipher = p.payload + 3;
|
||||||
|
size_t len = p.payloadLen - 3;
|
||||||
|
for (size_t i = 0; i < channelCount(); ++i) {
|
||||||
|
const Channel& c = channel(i);
|
||||||
|
if (channelHash(c) != p.payload[0]) continue;
|
||||||
|
uint8_t mac[32];
|
||||||
|
hmacSha256(c.key, cipher, len, mac);
|
||||||
|
if (mac[0] != p.payload[1] || mac[1] != p.payload[2]) continue;
|
||||||
|
uint8_t plain[192];
|
||||||
|
Aes128 aes(c.key);
|
||||||
|
for (size_t at = 0; at < len; at += 16) aes.decryptBlock(cipher + at, plain + at);
|
||||||
|
// Timestamp, then a byte whose top 6 bits say what follows: 0 is plain text, "name: words".
|
||||||
|
if (plain[4] >> 2) return false;
|
||||||
|
ChannelText t;
|
||||||
|
t.channel = c.name;
|
||||||
|
t.timestamp = static_cast<uint32_t>(plain[0] | plain[1] << 8 | plain[2] << 16 | static_cast<uint32_t>(plain[3]) << 24);
|
||||||
|
size_t end = 5;
|
||||||
|
while (end < len && plain[end]) ++end; // padded with zeros
|
||||||
|
std::string all = printable(plain + 5, end - 5);
|
||||||
|
size_t colon = all.find(": ");
|
||||||
|
if (colon == std::string::npos) {
|
||||||
|
t.text = all;
|
||||||
|
} else {
|
||||||
|
t.sender = all.substr(0, colon);
|
||||||
|
t.text = all.substr(colon + 2);
|
||||||
|
}
|
||||||
|
out = t;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro::meshcore
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "meshcore_packet.h"
|
||||||
|
|
||||||
|
// MeshCore's channel messages: encrypted with a key everyone on the channel has. The public
|
||||||
|
// channel's key is published, so what is said there can be read by anyone listening.
|
||||||
|
namespace roro::meshcore {
|
||||||
|
|
||||||
|
struct Channel {
|
||||||
|
const char* name;
|
||||||
|
uint8_t key[16];
|
||||||
|
};
|
||||||
|
|
||||||
|
// The channels whose key is known here: the public one, for now.
|
||||||
|
size_t channelCount();
|
||||||
|
const Channel& channel(size_t i);
|
||||||
|
// What a message on the channel starts with: the first byte of the SHA-256 of its key.
|
||||||
|
uint8_t channelHash(const Channel& c);
|
||||||
|
|
||||||
|
struct ChannelText {
|
||||||
|
const char* channel = "";
|
||||||
|
uint32_t timestamp = 0; // the sender's clock, seconds since 1970
|
||||||
|
std::string sender; // what the sender calls itself: not signed, anyone can claim a name
|
||||||
|
std::string text; // printable ASCII kept, the rest as '?'
|
||||||
|
};
|
||||||
|
|
||||||
|
// True when the packet is a text on a known channel and its check (2 bytes of HMAC-SHA256) holds.
|
||||||
|
bool readChannelText(const Packet& p, ChannelText& out);
|
||||||
|
|
||||||
|
} // namespace roro::meshcore
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
#include "meshcore_packet.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace roro::meshcore {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
int32_t le32(const uint8_t* p) { return static_cast<int32_t>(p[0] | p[1] << 8 | p[2] << 16 | static_cast<uint32_t>(p[3]) << 24); }
|
||||||
|
constexpr size_t kMaxPath = 64, kMaxPayload = 184;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool parsePacket(const uint8_t* data, size_t len, Packet& out) {
|
||||||
|
if (!data || len < 2) return false;
|
||||||
|
uint8_t header = data[0];
|
||||||
|
if (header >> 6) return false; // versions 1 to 3 are reserved
|
||||||
|
uint8_t type = (header >> 2) & 0x0F;
|
||||||
|
if (type >= 0xC && type <= 0xE) return false; // reserved
|
||||||
|
Packet p;
|
||||||
|
p.route = static_cast<Route>(header & 0x03);
|
||||||
|
p.type = static_cast<Payload>(type);
|
||||||
|
size_t at = 1;
|
||||||
|
p.hasTransport = p.route == Route::TransportFlood || p.route == Route::TransportDirect;
|
||||||
|
if (p.hasTransport) {
|
||||||
|
if (len < at + 5) return false;
|
||||||
|
p.transport[0] = static_cast<uint16_t>(data[at] | data[at + 1] << 8);
|
||||||
|
p.transport[1] = static_cast<uint16_t>(data[at + 2] | data[at + 3] << 8);
|
||||||
|
at += 4;
|
||||||
|
}
|
||||||
|
uint8_t pathLen = data[at++];
|
||||||
|
p.hops = pathLen & 0x3F;
|
||||||
|
p.hashSize = static_cast<uint8_t>((pathLen >> 6) + 1);
|
||||||
|
size_t pathBytes = static_cast<size_t>(p.hops) * p.hashSize;
|
||||||
|
if (p.hashSize > 3 || pathBytes > kMaxPath || at + pathBytes > len) return false;
|
||||||
|
p.path = data + at;
|
||||||
|
at += pathBytes;
|
||||||
|
p.payload = data + at;
|
||||||
|
p.payloadLen = len - at;
|
||||||
|
if (p.payloadLen > kMaxPayload) return false;
|
||||||
|
out = p;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parseAdvert(const Packet& p, Advert& out) {
|
||||||
|
constexpr size_t kFixed = 32 + 4 + 64; // key, timestamp, signature
|
||||||
|
if (p.type != Payload::Advert || p.payloadLen < kFixed + 1) return false;
|
||||||
|
Advert a;
|
||||||
|
std::memcpy(a.key, p.payload, 32);
|
||||||
|
a.timestamp = static_cast<uint32_t>(le32(p.payload + 32));
|
||||||
|
const uint8_t* app = p.payload + kFixed;
|
||||||
|
size_t left = p.payloadLen - kFixed;
|
||||||
|
uint8_t flags = app[0];
|
||||||
|
size_t at = 1;
|
||||||
|
a.kind = flags & 0x0F;
|
||||||
|
if (flags & 0x10) {
|
||||||
|
if (left < at + 8) return false;
|
||||||
|
a.hasLocation = true;
|
||||||
|
a.latE6 = le32(app + at);
|
||||||
|
a.lonE6 = le32(app + at + 4);
|
||||||
|
at += 8;
|
||||||
|
}
|
||||||
|
if (flags & 0x20) at += 2; // two reserved fields
|
||||||
|
if (flags & 0x40) at += 2;
|
||||||
|
if (at > left) return false;
|
||||||
|
if (flags & 0x80)
|
||||||
|
for (; at < left && app[at]; ++at) a.name += app[at] >= 0x20 && app[at] < 0x7F ? static_cast<char>(app[at]) : '?';
|
||||||
|
out = a;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parseDiscover(const Packet& p, Discover& out) {
|
||||||
|
if (p.type != Payload::Control || p.payloadLen < 6) return false;
|
||||||
|
uint8_t sub = p.payload[0] >> 4;
|
||||||
|
Discover d;
|
||||||
|
d.tag = static_cast<uint32_t>(le32(p.payload + 2));
|
||||||
|
if (sub == 0x8) {
|
||||||
|
d.kinds = p.payload[1];
|
||||||
|
} else if (sub == 0x9) {
|
||||||
|
d.response = true;
|
||||||
|
d.kind = p.payload[0] & 0x0F;
|
||||||
|
d.snr = static_cast<int8_t>(p.payload[1]) / 4.0f;
|
||||||
|
d.id = p.payload + 6;
|
||||||
|
d.idLen = p.payloadLen - 6;
|
||||||
|
if (d.idLen != 8 && d.idLen != 32) return false;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out = d;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* routeName(Route r) {
|
||||||
|
switch (r) {
|
||||||
|
case Route::TransportFlood: return "flood in a region";
|
||||||
|
case Route::Flood: return "flood";
|
||||||
|
case Route::Direct: return "direct";
|
||||||
|
case Route::TransportDirect: return "direct in a region";
|
||||||
|
}
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* payloadName(Payload t) {
|
||||||
|
switch (t) {
|
||||||
|
case Payload::Request: return "request";
|
||||||
|
case Payload::Response: return "response";
|
||||||
|
case Payload::Text: return "text message";
|
||||||
|
case Payload::Ack: return "ack";
|
||||||
|
case Payload::Advert: return "advert";
|
||||||
|
case Payload::GroupText: return "channel message";
|
||||||
|
case Payload::GroupData: return "channel data";
|
||||||
|
case Payload::AnonRequest: return "anonymous request";
|
||||||
|
case Payload::Path: return "returned path";
|
||||||
|
case Payload::Trace: return "trace";
|
||||||
|
case Payload::Multipart: return "multipart";
|
||||||
|
case Payload::Control: return "control";
|
||||||
|
case Payload::RawCustom: return "custom";
|
||||||
|
}
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* payloadShort(Payload t) {
|
||||||
|
switch (t) {
|
||||||
|
case Payload::Request: return "req";
|
||||||
|
case Payload::Response: return "rsp";
|
||||||
|
case Payload::Text: return "txt";
|
||||||
|
case Payload::Ack: return "ack";
|
||||||
|
case Payload::Advert: return "adv";
|
||||||
|
case Payload::GroupText: return "chan";
|
||||||
|
case Payload::GroupData: return "cdat";
|
||||||
|
case Payload::AnonRequest: return "anon";
|
||||||
|
case Payload::Path: return "path";
|
||||||
|
case Payload::Trace: return "trace";
|
||||||
|
case Payload::Multipart: return "multi";
|
||||||
|
case Payload::Control: return "ctl";
|
||||||
|
case Payload::RawCustom: return "raw";
|
||||||
|
}
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* kindName(uint8_t kind) {
|
||||||
|
switch (kind) {
|
||||||
|
case 1: return "Chat node";
|
||||||
|
case 2: return "Repeater";
|
||||||
|
case 3: return "Room server";
|
||||||
|
case 4: return "Sensor";
|
||||||
|
}
|
||||||
|
return "Node";
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro::meshcore
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// MeshCore's packets as they are on air (its docs/packet_format.md and docs/payloads.md): what is
|
||||||
|
// sent in clear, which is the header, the path, and a node's advertisement. meshcore_channel.h
|
||||||
|
// reads the public channel's messages.
|
||||||
|
namespace roro::meshcore {
|
||||||
|
|
||||||
|
// Radio settings: RadioLib's "private" sync word, and the preset its EU/UK networks use.
|
||||||
|
constexpr uint8_t kSyncWord = 0x12;
|
||||||
|
constexpr uint16_t kPreambleLength = 16;
|
||||||
|
constexpr const char* kPresetName = "MeshCore"; // "EU/UK (Narrow)" in its apps
|
||||||
|
constexpr uint32_t kEuNarrowHz = 869618000;
|
||||||
|
constexpr float kEuNarrowBwKHz = 62.5f;
|
||||||
|
constexpr uint8_t kEuNarrowSf = 8, kEuNarrowCr = 8;
|
||||||
|
|
||||||
|
// The first byte of the SHA-256 of the public channel's key (izOH6cXN6mrJ5e26oRXNcg==): what a
|
||||||
|
// group message on the channel every node has starts with.
|
||||||
|
constexpr uint8_t kPublicChannelHash = 0x11;
|
||||||
|
|
||||||
|
enum class Route : uint8_t { TransportFlood = 0, Flood = 1, Direct = 2, TransportDirect = 3 };
|
||||||
|
enum class Payload : uint8_t {
|
||||||
|
Request = 0x0, Response = 0x1, Text = 0x2, Ack = 0x3, Advert = 0x4, GroupText = 0x5, GroupData = 0x6,
|
||||||
|
AnonRequest = 0x7, Path = 0x8, Trace = 0x9, Multipart = 0xA, Control = 0xB, RawCustom = 0xF,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Packet {
|
||||||
|
Route route = Route::Flood;
|
||||||
|
Payload type = Payload::Request;
|
||||||
|
bool hasTransport = false;
|
||||||
|
uint16_t transport[2] = {0, 0}; // the first is the region's; the second is reserved
|
||||||
|
uint8_t hops = 0; // entries in the path
|
||||||
|
uint8_t hashSize = 1; // bytes each
|
||||||
|
const uint8_t* path = nullptr; // hops * hashSize bytes
|
||||||
|
const uint8_t* payload = nullptr;
|
||||||
|
size_t payloadLen = 0;
|
||||||
|
|
||||||
|
bool flood() const { return route == Route::Flood || route == Route::TransportFlood; }
|
||||||
|
// Sent to one node from one node, each named by the first byte of its key.
|
||||||
|
bool addressed() const { return type == Payload::Request || type == Payload::Response || type == Payload::Text || type == Payload::Path; }
|
||||||
|
bool group() const { return type == Payload::GroupText || type == Payload::GroupData; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// False when the bytes can't be a MeshCore packet: a version other than the first, a reserved
|
||||||
|
// type, or a path longer than what follows.
|
||||||
|
bool parsePacket(const uint8_t* data, size_t len, Packet& out);
|
||||||
|
|
||||||
|
// A node saying who it is, signed: never encrypted.
|
||||||
|
struct Advert {
|
||||||
|
uint8_t key[32];
|
||||||
|
uint32_t timestamp = 0; // the node's clock, seconds since 1970
|
||||||
|
uint8_t kind = 0; // 1 chat, 2 repeater, 3 room server, 4 sensor
|
||||||
|
bool hasLocation = false;
|
||||||
|
int32_t latE6 = 0, lonE6 = 0;
|
||||||
|
std::string name; // printable ASCII kept, the rest as '?'
|
||||||
|
};
|
||||||
|
bool parseAdvert(const Packet& p, Advert& out);
|
||||||
|
|
||||||
|
// Control packets that look for nodes nearby, and the answers: sent in clear, to neighbours only.
|
||||||
|
struct Discover {
|
||||||
|
bool response = false;
|
||||||
|
uint8_t kinds = 0; // a request: one bit per kind of node wanted (bit 2: repeaters)
|
||||||
|
uint8_t kind = 0; // a response: what answers, as in an advert
|
||||||
|
float snr = 0; // a response: how well it heard the request, dB
|
||||||
|
uint32_t tag = 0; // random in the request, repeated in its responses
|
||||||
|
const uint8_t* id = nullptr; // a response: the node's key, or its first 8 bytes
|
||||||
|
size_t idLen = 0;
|
||||||
|
};
|
||||||
|
bool parseDiscover(const Packet& p, Discover& out);
|
||||||
|
|
||||||
|
const char* routeName(Route r); // "flood", "direct", ...
|
||||||
|
const char* payloadName(Payload t); // "advert", "text", ...
|
||||||
|
const char* payloadShort(Payload t); // for a list row: "adv", "txt", ...
|
||||||
|
const char* kindName(uint8_t kind); // "Chat node", "Repeater", ...
|
||||||
|
|
||||||
|
} // namespace roro::meshcore
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
#include "net_probe.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "ipv4.h"
|
||||||
|
|
||||||
|
namespace roro::net {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::vector<std::string> words(const std::string& text) {
|
||||||
|
std::vector<std::string> out;
|
||||||
|
size_t at = 0;
|
||||||
|
while (at < text.size()) {
|
||||||
|
while (at < text.size() && text[at] == ' ') at++;
|
||||||
|
size_t end = text.find(' ', at);
|
||||||
|
if (end == std::string::npos) end = text.size();
|
||||||
|
if (end > at) out.push_back(text.substr(at, end - at));
|
||||||
|
at = end;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
bool number(const std::string& s, long& out) {
|
||||||
|
if (s.empty() || s.size() > 6) return false;
|
||||||
|
out = 0;
|
||||||
|
for (char c : s) {
|
||||||
|
if (c < '0' || c > '9') return false;
|
||||||
|
out = out * 10 + (c - '0');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
uint16_t be16(const uint8_t* p) { return static_cast<uint16_t>((p[0] << 8) | p[1]); }
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string parsePing(const std::string& args, PingArgs& out) {
|
||||||
|
static const char* const kUsage = "ping <host> [count] [size]";
|
||||||
|
auto w = words(args);
|
||||||
|
if (w.empty() || w.size() > 3 || !validHost(w[0])) return kUsage;
|
||||||
|
PingArgs a;
|
||||||
|
a.host = w[0];
|
||||||
|
long n;
|
||||||
|
if (w.size() > 1) {
|
||||||
|
if (!number(w[1], n) || n < 1 || n > 100) return "a count from 1 to 100";
|
||||||
|
a.count = static_cast<int>(n);
|
||||||
|
}
|
||||||
|
if (w.size() > 2) {
|
||||||
|
if (!number(w[2], n) || n > 1400) return "a size from 0 to 1400 bytes";
|
||||||
|
a.size = static_cast<int>(n);
|
||||||
|
}
|
||||||
|
out = a;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string parsePort(const std::string& args, PortArgs& out) {
|
||||||
|
static const char* const kUsage = "port <host> <port>";
|
||||||
|
auto w = words(args);
|
||||||
|
if (w.size() == 1) { // host:port
|
||||||
|
size_t colon = w[0].rfind(':');
|
||||||
|
if (colon == std::string::npos) return kUsage;
|
||||||
|
w = {w[0].substr(0, colon), w[0].substr(colon + 1)};
|
||||||
|
}
|
||||||
|
long n;
|
||||||
|
if (w.size() != 2 || !validHost(w[0])) return kUsage;
|
||||||
|
if (!number(w[1], n) || n < 1 || n > 65535) return "a port from 1 to 65535";
|
||||||
|
out.host = w[0];
|
||||||
|
out.port = static_cast<uint16_t>(n);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string parseLookup(const std::string& args, LookupArgs& out) {
|
||||||
|
static const char* const kUsage = "nslookup <name> [server's address]";
|
||||||
|
auto w = words(args);
|
||||||
|
uint32_t ip;
|
||||||
|
if (w.empty() || w.size() > 2 || !validHost(w[0])) return kUsage;
|
||||||
|
if (w.size() == 2 && !parseIpv4(w[1], ip)) return "the server as an address: 9.9.9.9";
|
||||||
|
out.name = w[0];
|
||||||
|
out.server = w.size() == 2 ? w[1] : "";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t inetChecksum(const uint8_t* data, size_t len) {
|
||||||
|
uint32_t sum = 0;
|
||||||
|
for (size_t i = 0; i + 1 < len; i += 2) sum += static_cast<uint32_t>((data[i] << 8) | data[i + 1]);
|
||||||
|
if (len & 1) sum += static_cast<uint32_t>(data[len - 1] << 8);
|
||||||
|
while (sum >> 16) sum = (sum & 0xFFFF) + (sum >> 16);
|
||||||
|
return static_cast<uint16_t>(~sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t buildEcho(uint8_t* out, size_t max, uint16_t id, uint16_t seq, size_t payload) {
|
||||||
|
size_t len = 8 + payload;
|
||||||
|
if (len > max) return 0;
|
||||||
|
out[0] = 8; // echo request
|
||||||
|
out[1] = 0;
|
||||||
|
out[2] = out[3] = 0;
|
||||||
|
out[4] = static_cast<uint8_t>(id >> 8);
|
||||||
|
out[5] = static_cast<uint8_t>(id);
|
||||||
|
out[6] = static_cast<uint8_t>(seq >> 8);
|
||||||
|
out[7] = static_cast<uint8_t>(seq);
|
||||||
|
for (size_t i = 0; i < payload; i++) out[8 + i] = static_cast<uint8_t>('a' + i % 26);
|
||||||
|
uint16_t sum = inetChecksum(out, len);
|
||||||
|
out[2] = static_cast<uint8_t>(sum >> 8);
|
||||||
|
out[3] = static_cast<uint8_t>(sum);
|
||||||
|
return len;
|
||||||
|
}
|
||||||
|
|
||||||
|
IcmpAnswer parseIcmp(const uint8_t* packet, size_t len) {
|
||||||
|
IcmpAnswer a;
|
||||||
|
if (len < 20 || (packet[0] >> 4) != 4) return a;
|
||||||
|
size_t header = static_cast<size_t>(packet[0] & 0x0F) * 4;
|
||||||
|
if (header < 20 || len < header + 8 || packet[9] != 1) return a; // not ICMP
|
||||||
|
const uint8_t* icmp = packet + header;
|
||||||
|
size_t left = len - header;
|
||||||
|
if (icmp[0] == 0 && icmp[1] == 0) { // echo reply
|
||||||
|
a.kind = IcmpAnswer::Kind::Echo;
|
||||||
|
a.id = be16(icmp + 4);
|
||||||
|
a.seq = be16(icmp + 6);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (icmp[0] != 11 && icmp[0] != 3) return a;
|
||||||
|
// Inside: the IP header of the packet it is about, and that packet's first 8 bytes.
|
||||||
|
if (left < 8 + 20) return a;
|
||||||
|
const uint8_t* inner = icmp + 8;
|
||||||
|
size_t innerHeader = static_cast<size_t>(inner[0] & 0x0F) * 4;
|
||||||
|
if ((inner[0] >> 4) != 4 || innerHeader < 20 || left < 8 + innerHeader + 8 || inner[9] != 1 || inner[innerHeader] != 8) return a;
|
||||||
|
a.kind = icmp[0] == 11 ? IcmpAnswer::Kind::TimeExceeded : IcmpAnswer::Kind::Unreachable;
|
||||||
|
a.id = be16(inner + innerHeader + 4);
|
||||||
|
a.seq = be16(inner + innerHeader + 6);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PingStats::add(uint32_t ms) {
|
||||||
|
minMs = back ? std::min(minMs, ms) : ms;
|
||||||
|
maxMs = std::max(maxMs, ms);
|
||||||
|
sumMs += ms;
|
||||||
|
back++;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string PingStats::summary() const {
|
||||||
|
int lost = sent ? (sent - back) * 100 / sent : 0;
|
||||||
|
std::string s = std::to_string(back) + "/" + std::to_string(sent) + " back, " + std::to_string(lost) + "% lost"; // short: a Shell line is 38 characters
|
||||||
|
if (back) s += ", " + std::to_string(minMs) + "/" + std::to_string(sumMs / static_cast<uint32_t>(back)) + "/" + std::to_string(maxMs) + " ms";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t buildDnsQuery(uint8_t* out, size_t max, uint16_t id, const std::string& name) {
|
||||||
|
if (name.empty() || name.size() > 253 || 12 + name.size() + 2 + 4 > max) return 0;
|
||||||
|
std::memset(out, 0, 12);
|
||||||
|
out[0] = static_cast<uint8_t>(id >> 8);
|
||||||
|
out[1] = static_cast<uint8_t>(id);
|
||||||
|
out[2] = 0x01; // recursion wanted
|
||||||
|
out[5] = 1; // one question
|
||||||
|
size_t at = 12;
|
||||||
|
for (size_t from = 0; from <= name.size();) {
|
||||||
|
size_t dot = name.find('.', from);
|
||||||
|
if (dot == std::string::npos) dot = name.size();
|
||||||
|
size_t n = dot - from;
|
||||||
|
if (n == 0 && dot == name.size()) break; // a final dot
|
||||||
|
if (n == 0 || n > 63) return 0;
|
||||||
|
out[at++] = static_cast<uint8_t>(n);
|
||||||
|
std::memcpy(out + at, name.data() + from, n);
|
||||||
|
at += n;
|
||||||
|
from = dot + 1;
|
||||||
|
}
|
||||||
|
out[at++] = 0;
|
||||||
|
out[at++] = 0;
|
||||||
|
out[at++] = 1; // A
|
||||||
|
out[at++] = 0;
|
||||||
|
out[at++] = 1; // IN
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Reads a name at `at`, following the pointers DNS shortens names with. Where the name ends in
|
||||||
|
// the message (not where a pointer led), or 0 if it is broken.
|
||||||
|
size_t readName(const uint8_t* m, size_t len, size_t at, std::string* out) {
|
||||||
|
size_t end = 0;
|
||||||
|
int jumps = 0;
|
||||||
|
while (at < len) {
|
||||||
|
uint8_t n = m[at];
|
||||||
|
if (n == 0) return end ? end : at + 1;
|
||||||
|
if ((n & 0xC0) == 0xC0) {
|
||||||
|
if (at + 1 >= len || ++jumps > 8) return 0;
|
||||||
|
if (!end) end = at + 2;
|
||||||
|
at = static_cast<size_t>(((n & 0x3F) << 8) | m[at + 1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (n > 63 || at + 1 + n > len) return 0;
|
||||||
|
if (out) {
|
||||||
|
if (!out->empty()) *out += '.';
|
||||||
|
out->append(reinterpret_cast<const char*>(m + at + 1), n);
|
||||||
|
}
|
||||||
|
at += 1 + static_cast<size_t>(n);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool parseDnsAnswer(const uint8_t* m, size_t len, uint16_t id, DnsAnswer& out) {
|
||||||
|
if (len < 12 || be16(m) != id || !(m[2] & 0x80)) return false;
|
||||||
|
DnsAnswer a;
|
||||||
|
a.truncated = m[2] & 0x02;
|
||||||
|
a.rcode = m[3] & 0x0F;
|
||||||
|
int questions = be16(m + 4), answers = be16(m + 6);
|
||||||
|
size_t at = 12;
|
||||||
|
for (int i = 0; i < questions; i++) {
|
||||||
|
at = readName(m, len, at, nullptr);
|
||||||
|
if (!at || at + 4 > len) return false;
|
||||||
|
at += 4;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < answers; i++) {
|
||||||
|
at = readName(m, len, at, nullptr);
|
||||||
|
if (!at || at + 10 > len) return false;
|
||||||
|
uint16_t type = be16(m + at), size = be16(m + at + 8);
|
||||||
|
at += 10;
|
||||||
|
if (at + size > len) return false;
|
||||||
|
if (type == 1 && size == 4) a.addresses.push_back((static_cast<uint32_t>(m[at]) << 24) | (m[at + 1] << 16) | (m[at + 2] << 8) | m[at + 3]);
|
||||||
|
if (type == 5) {
|
||||||
|
std::string name;
|
||||||
|
if (readName(m, len, at, &name)) a.alias = name;
|
||||||
|
}
|
||||||
|
at += size;
|
||||||
|
}
|
||||||
|
out = a;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void buildNtpRequest(uint8_t out[kNtpPacket]) {
|
||||||
|
std::memset(out, 0, kNtpPacket);
|
||||||
|
out[0] = 0x23; // no warning, version 4, a client
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parseNtpAnswer(const uint8_t* p, size_t len, NtpAnswer& out) {
|
||||||
|
if (len < kNtpPacket || (p[0] & 0x07) != 4) return false; // not a server's
|
||||||
|
if (p[1] == 0 || p[1] > 15) return false; // "kiss of death", or not synchronised
|
||||||
|
uint32_t secs = (static_cast<uint32_t>(p[40]) << 24) | (p[41] << 16) | (p[42] << 8) | p[43];
|
||||||
|
uint32_t frac = (static_cast<uint32_t>(p[44]) << 24) | (p[45] << 16) | (p[46] << 8) | p[47];
|
||||||
|
if (!secs) return false;
|
||||||
|
// NTP counts from 1900 and wraps in 2036: a small number is the era after.
|
||||||
|
constexpr int64_t k1900To1970 = 2208988800LL;
|
||||||
|
int64_t since1900 = secs < 0x80000000u ? static_cast<int64_t>(secs) + 4294967296LL : static_cast<int64_t>(secs);
|
||||||
|
out.stratum = p[1];
|
||||||
|
out.seconds = since1900 - k1900To1970;
|
||||||
|
out.millis = static_cast<uint32_t>((static_cast<uint64_t>(frac) * 1000) >> 32);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string clockOffset(int64_t ownMs, int64_t serverMs) {
|
||||||
|
int64_t diff = ownMs - serverMs, size = diff < 0 ? -diff : diff;
|
||||||
|
if (size < 100) return "right, to 0.1 s";
|
||||||
|
std::string amount = size < 10000 ? std::to_string(size / 1000) + "." + std::to_string(size % 1000 / 100) + " s"
|
||||||
|
: size < 120000 ? std::to_string(size / 1000) + " s"
|
||||||
|
: size < 7200000 ? std::to_string(size / 60000) + " min"
|
||||||
|
: size < 172800000LL ? std::to_string(size / 3600000) + " h" : std::to_string(size / 86400000LL) + " days";
|
||||||
|
return amount + (diff > 0 ? " ahead" : " behind");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string certName(const std::string& dn) {
|
||||||
|
for (const char* key : {"CN=", "O="}) {
|
||||||
|
size_t at = 0;
|
||||||
|
while ((at = dn.find(key, at)) != std::string::npos) {
|
||||||
|
if (at == 0 || dn[at - 1] == ' ' || dn[at - 1] == ',') {
|
||||||
|
size_t from = at + std::strlen(key), end = dn.find(", ", from);
|
||||||
|
std::string name = dn.substr(from, end == std::string::npos ? std::string::npos : end - from);
|
||||||
|
// An old kind of string comes out as "#" and hex, type and length first: read it.
|
||||||
|
if (name.size() > 5 && name[0] == '#' && name.size() % 2 == 1) {
|
||||||
|
std::string plain;
|
||||||
|
for (size_t i = 5; i + 1 < name.size(); i += 2) {
|
||||||
|
auto digit = [](char c) { return c >= '0' && c <= '9' ? c - '0' : c >= 'A' && c <= 'F' ? c - 'A' + 10 : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1; };
|
||||||
|
int hi = digit(name[i]), lo = digit(name[i + 1]);
|
||||||
|
if (hi < 0 || lo < 0 || hi * 16 + lo < 0x20 || hi * 16 + lo > 0x7E) return name;
|
||||||
|
plain += static_cast<char>(hi * 16 + lo);
|
||||||
|
}
|
||||||
|
return plain;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
at++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dn;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Days since a fixed day long ago (the civil calendar, leap years and all).
|
||||||
|
long dayNumber(int y, int m, int d) {
|
||||||
|
y -= m <= 2;
|
||||||
|
long era = (y >= 0 ? y : y - 399) / 400;
|
||||||
|
long yoe = y - era * 400, doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
|
||||||
|
return era * 146097 + yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int daysBetween(int y1, int m1, int d1, int y2, int m2, int d2) { return static_cast<int>(dayNumber(y2, m2, d2) - dayNumber(y1, m1, d1)); }
|
||||||
|
|
||||||
|
const char* portLabel(uint16_t port, bool tcp) {
|
||||||
|
if (tcp) return port == 3232 ? "updates" : port == 2323 ? "Debug Console" : port == 80 ? "sharing" : "";
|
||||||
|
return port == 68 ? "DHCP" : port == 123 ? "NTP" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro::net
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// The parts of the network troubleshooting commands (issue #90) that need no network: what was
|
||||||
|
// asked, the packets to send, and what the answers mean. The sockets are src/services/net_tools.h (a different name on purpose: two headers of one name find themselves).
|
||||||
|
namespace roro::net {
|
||||||
|
|
||||||
|
// --- what was typed
|
||||||
|
|
||||||
|
struct PingArgs {
|
||||||
|
std::string host;
|
||||||
|
int count = 4; // 1 to 100
|
||||||
|
int size = 56; // bytes of payload, 0 to 1400: with its headers a ping is 28 more
|
||||||
|
};
|
||||||
|
// "ping <host> [count] [size]". "" or how to ask.
|
||||||
|
std::string parsePing(const std::string& args, PingArgs& out);
|
||||||
|
|
||||||
|
struct PortArgs {
|
||||||
|
std::string host;
|
||||||
|
uint16_t port = 0;
|
||||||
|
};
|
||||||
|
// "port <host> <port>", or host:port.
|
||||||
|
std::string parsePort(const std::string& args, PortArgs& out);
|
||||||
|
|
||||||
|
struct LookupArgs {
|
||||||
|
std::string name, server; // server: an address, or "" for the one in use
|
||||||
|
};
|
||||||
|
std::string parseLookup(const std::string& args, LookupArgs& out);
|
||||||
|
|
||||||
|
// --- ICMP: ping and traceroute
|
||||||
|
|
||||||
|
uint16_t inetChecksum(const uint8_t* data, size_t len);
|
||||||
|
// An echo request: 8 bytes of header and `payload` bytes after it. The length written, or 0 if
|
||||||
|
// it doesn't fit.
|
||||||
|
size_t buildEcho(uint8_t* out, size_t max, uint16_t id, uint16_t seq, size_t payload);
|
||||||
|
|
||||||
|
struct IcmpAnswer {
|
||||||
|
enum class Kind { Other, Echo, TimeExceeded, Unreachable } kind = Kind::Other;
|
||||||
|
uint16_t id = 0, seq = 0; // of the echo request it answers
|
||||||
|
};
|
||||||
|
// `packet` as a raw socket hands it over: the IP header first. A router's "time exceeded" and
|
||||||
|
// "unreachable" carry the start of the packet they are about, which is where id and seq come from.
|
||||||
|
IcmpAnswer parseIcmp(const uint8_t* packet, size_t len);
|
||||||
|
|
||||||
|
// What a run of pings came to: "3/4 back, 25% lost, 12/25/41 ms" (the least, the mean, the most).
|
||||||
|
struct PingStats {
|
||||||
|
int sent = 0, back = 0;
|
||||||
|
uint32_t minMs = 0, maxMs = 0, sumMs = 0;
|
||||||
|
void add(uint32_t ms);
|
||||||
|
std::string summary() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- DNS: nslookup
|
||||||
|
|
||||||
|
// A query for the IPv4 addresses of `name`. The length written, or 0 if that isn't a name.
|
||||||
|
size_t buildDnsQuery(uint8_t* out, size_t max, uint16_t id, const std::string& name);
|
||||||
|
|
||||||
|
struct DnsAnswer {
|
||||||
|
int rcode = 0; // 0: fine, 3: no such name
|
||||||
|
bool truncated = false; // the answer didn't fit in one packet
|
||||||
|
std::vector<uint32_t> addresses;
|
||||||
|
std::string alias; // the last name a CNAME led to, if any
|
||||||
|
};
|
||||||
|
// False if it isn't the answer to query `id`, or is cut short.
|
||||||
|
bool parseDnsAnswer(const uint8_t* message, size_t len, uint16_t id, DnsAnswer& out);
|
||||||
|
|
||||||
|
// --- NTP: the clock's offset
|
||||||
|
|
||||||
|
constexpr size_t kNtpPacket = 48;
|
||||||
|
void buildNtpRequest(uint8_t out[kNtpPacket]);
|
||||||
|
struct NtpAnswer {
|
||||||
|
int stratum = 0; // 1: a reference clock; 2 and up: that many steps from one
|
||||||
|
int64_t seconds = 0; // the server's clock when it answered, UTC since 1970
|
||||||
|
uint32_t millis = 0; // and the part of a second
|
||||||
|
};
|
||||||
|
// False if it isn't a server's answer, or says the server has no time to give.
|
||||||
|
bool parseNtpAnswer(const uint8_t* packet, size_t len, NtpAnswer& out);
|
||||||
|
// "0.3 s ahead", "12 s behind", "right, to 0.1 s": this clock against the server's, both in ms.
|
||||||
|
std::string clockOffset(int64_t ownMs, int64_t serverMs);
|
||||||
|
|
||||||
|
// --- TLS: who a certificate is for
|
||||||
|
|
||||||
|
// The common name out of a certificate's subject or issuer as mbedTLS prints it
|
||||||
|
// ("C=US, O=Let's Encrypt, CN=R11"): the CN, else the O, else all of it.
|
||||||
|
std::string certName(const std::string& dn);
|
||||||
|
// Whole days from one date to another (negative: the second is earlier).
|
||||||
|
int daysBetween(int y1, int m1, int d1, int y2, int m2, int d2);
|
||||||
|
|
||||||
|
// --- netstat
|
||||||
|
|
||||||
|
// What listens on a port of this firmware, or "".
|
||||||
|
const char* portLabel(uint16_t port, bool tcp);
|
||||||
|
|
||||||
|
} // namespace roro::net
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
#include "wg_config.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "ipv4.h"
|
||||||
|
|
||||||
|
namespace roro::net {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::string trim(const std::string& s) {
|
||||||
|
size_t a = s.find_first_not_of(" \t\r"), b = s.find_last_not_of(" \t\r");
|
||||||
|
return a == std::string::npos ? "" : s.substr(a, b - a + 1);
|
||||||
|
}
|
||||||
|
std::string lower(std::string s) {
|
||||||
|
for (char& c : s)
|
||||||
|
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c + 32);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
bool number(const std::string& s, long& out, long max) {
|
||||||
|
if (s.empty() || s.size() > 6) return false;
|
||||||
|
out = 0;
|
||||||
|
for (char c : s) {
|
||||||
|
if (c < '0' || c > '9') return false;
|
||||||
|
out = out * 10 + (c - '0');
|
||||||
|
}
|
||||||
|
return out <= max;
|
||||||
|
}
|
||||||
|
// "10.9.0.2/24", or an address alone (then /32). False for anything else, IPv6 included.
|
||||||
|
bool range(const std::string& text, WgRange& out) {
|
||||||
|
size_t slash = text.find('/');
|
||||||
|
long prefix = 32;
|
||||||
|
if (slash != std::string::npos && !number(text.substr(slash + 1), prefix, 32)) return false;
|
||||||
|
if (!parseIpv4(text.substr(0, slash), out.address)) return false;
|
||||||
|
out.prefix = static_cast<int>(prefix);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
template <typename Each>
|
||||||
|
void eachItem(const std::string& list, Each each) {
|
||||||
|
size_t at = 0;
|
||||||
|
while (at <= list.size()) {
|
||||||
|
size_t comma = list.find(',', at);
|
||||||
|
if (comma == std::string::npos) comma = list.size();
|
||||||
|
std::string item = trim(list.substr(at, comma - at));
|
||||||
|
if (!item.empty()) each(item);
|
||||||
|
at = comma + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool inRange(uint32_t address, const WgRange& r) { return (address & maskOf(r.prefix)) == (r.address & maskOf(r.prefix)); }
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool validWgKey(const std::string& key) {
|
||||||
|
if (key.size() != 44 || key[43] != '=') return false;
|
||||||
|
for (size_t i = 0; i < 43; i++) {
|
||||||
|
char c = key[i];
|
||||||
|
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '/')) return false;
|
||||||
|
}
|
||||||
|
// 43 characters carry 258 bits: the last one's two low bits belong to no byte and are zero.
|
||||||
|
static const std::string kLast = "AEIMQUYcgkosw048";
|
||||||
|
return kLast.find(key[42]) != std::string::npos;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string parseWgConf(const std::string& text, WgConfig& out) {
|
||||||
|
WgConfig c;
|
||||||
|
enum { None, Interface, Peer, OtherPeer } section = None;
|
||||||
|
bool hasAddress = false, hasEndpoint = false, hasKeepalive = false;
|
||||||
|
int lineNo = 0;
|
||||||
|
std::string problem;
|
||||||
|
auto fail = [&](const std::string& what) {
|
||||||
|
if (problem.empty()) problem = "line " + std::to_string(lineNo) + ": " + what;
|
||||||
|
};
|
||||||
|
for (size_t at = 0; at <= text.size() && problem.empty();) {
|
||||||
|
size_t end = text.find('\n', at);
|
||||||
|
if (end == std::string::npos) end = text.size();
|
||||||
|
std::string line = text.substr(at, end - at);
|
||||||
|
at = end + 1;
|
||||||
|
lineNo++;
|
||||||
|
size_t hash = line.find_first_of("#;");
|
||||||
|
if (hash != std::string::npos) line.resize(hash);
|
||||||
|
line = trim(line);
|
||||||
|
if (line.empty()) continue;
|
||||||
|
if (line[0] == '[') {
|
||||||
|
std::string name = lower(line);
|
||||||
|
if (name == "[interface]") section = Interface;
|
||||||
|
else if (name == "[peer]") section = section == Peer || section == OtherPeer ? OtherPeer : Peer;
|
||||||
|
else fail("a section this doesn't know");
|
||||||
|
if (section == OtherPeer) fail("a second peer: this device has one tunnel to one peer");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
size_t eq = line.find('=');
|
||||||
|
if (eq == std::string::npos) {
|
||||||
|
fail("not a setting");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
std::string key = lower(trim(line.substr(0, eq))), value = trim(line.substr(eq + 1));
|
||||||
|
long n = 0;
|
||||||
|
if (section == Interface) {
|
||||||
|
if (key == "privatekey") {
|
||||||
|
if (!validWgKey(value)) fail("PrivateKey isn't a key");
|
||||||
|
c.privateKey = value;
|
||||||
|
} else if (key == "address") {
|
||||||
|
eachItem(value, [&](const std::string& item) {
|
||||||
|
WgRange r;
|
||||||
|
if (!hasAddress && range(item, r)) {
|
||||||
|
c.address = r.address;
|
||||||
|
c.prefix = r.prefix;
|
||||||
|
hasAddress = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!hasAddress) fail("Address has no IPv4 address");
|
||||||
|
} else if (key == "dns") {
|
||||||
|
int count = 0;
|
||||||
|
eachItem(value, [&](const std::string& item) { // names and IPv6 servers are left out
|
||||||
|
uint32_t ip;
|
||||||
|
if (count < 2 && parseIpv4(item, ip)) c.dns[count++] = ip;
|
||||||
|
});
|
||||||
|
} else if (key == "mtu") {
|
||||||
|
if (!number(value, n, 1500) || n < 576) fail("MTU must be 576 to 1500");
|
||||||
|
c.mtu = static_cast<int>(n);
|
||||||
|
} else if (key == "listenport") {
|
||||||
|
if (!number(value, n, 65535)) fail("ListenPort must be a port");
|
||||||
|
c.listenPort = static_cast<uint16_t>(n);
|
||||||
|
} // Table, PostUp and the rest mean nothing here
|
||||||
|
} else if (section == Peer) {
|
||||||
|
if (key == "publickey") {
|
||||||
|
if (!validWgKey(value)) fail("PublicKey isn't a key");
|
||||||
|
c.peerKey = value;
|
||||||
|
} else if (key == "presharedkey") {
|
||||||
|
if (!validWgKey(value)) fail("PresharedKey isn't a key");
|
||||||
|
c.presharedKey = value;
|
||||||
|
} else if (key == "endpoint") {
|
||||||
|
size_t colon = value.rfind(':');
|
||||||
|
if (value.empty() || value[0] == '[') fail("an IPv6 Endpoint: IPv4 or a name only");
|
||||||
|
else if (colon == std::string::npos || colon == 0 || !number(value.substr(colon + 1), n, 65535) || n == 0) fail("Endpoint must be host:port");
|
||||||
|
else if (value.find_first_of(" \t,/") != std::string::npos || colon > 253) fail("Endpoint must be host:port");
|
||||||
|
else {
|
||||||
|
c.endpointHost = value.substr(0, colon);
|
||||||
|
c.endpointPort = static_cast<uint16_t>(n);
|
||||||
|
hasEndpoint = true;
|
||||||
|
}
|
||||||
|
} else if (key == "allowedips") {
|
||||||
|
eachItem(value, [&](const std::string& item) {
|
||||||
|
WgRange r;
|
||||||
|
if (item.find(':') != std::string::npos) return; // IPv6: not routed here
|
||||||
|
if (!range(item, r)) return fail("AllowedIPs has something that isn't an address range");
|
||||||
|
if (c.allowedCount == WgConfig::kMaxRanges) return fail("AllowedIPs: four IPv4 ranges at most");
|
||||||
|
r.address &= maskOf(r.prefix);
|
||||||
|
c.allowed[c.allowedCount++] = r;
|
||||||
|
});
|
||||||
|
} else if (key == "persistentkeepalive") {
|
||||||
|
if (lower(value) == "off") n = 0;
|
||||||
|
else if (!number(value, n, 65535)) fail("PersistentKeepalive must be seconds");
|
||||||
|
c.keepalive = static_cast<int>(n);
|
||||||
|
hasKeepalive = true;
|
||||||
|
}
|
||||||
|
} else if (section == None) {
|
||||||
|
fail("a setting before [Interface]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(void)hasKeepalive;
|
||||||
|
if (!problem.empty()) return problem;
|
||||||
|
if (c.privateKey.empty()) return "no PrivateKey under [Interface]";
|
||||||
|
if (!hasAddress) return "no Address under [Interface]";
|
||||||
|
if (c.peerKey.empty()) return "no PublicKey under [Peer]";
|
||||||
|
if (!hasEndpoint) return "no Endpoint under [Peer]";
|
||||||
|
if (!c.allowedCount) return "no IPv4 range in AllowedIPs";
|
||||||
|
out = c;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string toWgConf(const WgConfig& c) {
|
||||||
|
std::string s = "[Interface]\nPrivateKey = " + c.privateKey + "\nAddress = " + formatIpv4(c.address) + "/" + std::to_string(c.prefix) + "\n";
|
||||||
|
if (c.dns[0]) s += "DNS = " + formatIpv4(c.dns[0]) + (c.dns[1] ? ", " + formatIpv4(c.dns[1]) : "") + "\n";
|
||||||
|
if (c.mtu) s += "MTU = " + std::to_string(c.mtu) + "\n";
|
||||||
|
if (c.listenPort) s += "ListenPort = " + std::to_string(c.listenPort) + "\n";
|
||||||
|
s += "[Peer]\nPublicKey = " + c.peerKey + "\n";
|
||||||
|
if (!c.presharedKey.empty()) s += "PresharedKey = " + c.presharedKey + "\n";
|
||||||
|
s += "Endpoint = " + c.endpointHost + ":" + std::to_string(c.endpointPort) + "\nAllowedIPs = ";
|
||||||
|
for (int i = 0; i < c.allowedCount; i++) s += (i ? ", " : "") + formatIpv4(c.allowed[i].address) + "/" + std::to_string(c.allowed[i].prefix);
|
||||||
|
s += "\nPersistentKeepalive = " + std::to_string(c.keepalive) + "\n";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
WgRouting routingOf(const WgConfig& c) {
|
||||||
|
WgRouting r;
|
||||||
|
for (int i = 0; i < c.allowedCount; i++)
|
||||||
|
if (c.allowed[i].prefix == 0) r.full = true;
|
||||||
|
if (r.full) return r;
|
||||||
|
// The widest allowed range this device's own address is in is the interface's subnet; with
|
||||||
|
// none, the Address line's own.
|
||||||
|
r.prefix = c.prefix;
|
||||||
|
bool found = false;
|
||||||
|
for (int i = 0; i < c.allowedCount; i++)
|
||||||
|
if (inRange(c.address, c.allowed[i]) && (!found || c.allowed[i].prefix < r.prefix)) {
|
||||||
|
r.prefix = c.allowed[i].prefix;
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
WgRange subnet{c.address, r.prefix};
|
||||||
|
for (int i = 0; i < c.allowedCount; i++)
|
||||||
|
if (c.allowed[i].prefix < r.prefix || !inRange(c.allowed[i].address, subnet)) r.unreachable++;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wgReaches(const WgConfig& c, uint32_t address) {
|
||||||
|
WgRouting r = routingOf(c);
|
||||||
|
if (r.full) return true;
|
||||||
|
return inRange(address, WgRange{c.address, r.prefix});
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string describeWgRouting(const WgConfig& c) {
|
||||||
|
WgRouting r = routingOf(c);
|
||||||
|
if (r.full) return "everything";
|
||||||
|
std::string s = formatIpv4(c.address & maskOf(r.prefix)) + "/" + std::to_string(r.prefix);
|
||||||
|
if (r.unreachable) s += ", not " + std::to_string(r.unreachable) + " other range" + (r.unreachable > 1 ? "s" : "");
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro::net
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// A WireGuard tunnel's configuration (issue #8, N1 Q243-Q253): read from the standard `.conf` a
|
||||||
|
// server's owner hands out, checked, and written back in a tidy form for the device's settings.
|
||||||
|
// One peer, IPv4. The keys are never put in a message: errors name the line and the field.
|
||||||
|
namespace roro::net {
|
||||||
|
|
||||||
|
struct WgRange {
|
||||||
|
uint32_t address = 0;
|
||||||
|
int prefix = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct WgConfig {
|
||||||
|
static constexpr int kMaxRanges = 4;
|
||||||
|
|
||||||
|
std::string privateKey, peerKey, presharedKey; // base64, as in the file; the last may be empty
|
||||||
|
uint32_t address = 0; // the tunnel's address on this device
|
||||||
|
int prefix = 32;
|
||||||
|
uint32_t dns[2] = {0, 0};
|
||||||
|
int mtu = 0; // 0: WireGuard's 1420
|
||||||
|
uint16_t listenPort = 0; // 0: any; a fixed one lets the peer be the one that calls
|
||||||
|
std::string endpointHost;
|
||||||
|
uint16_t endpointPort = 51820;
|
||||||
|
WgRange allowed[kMaxRanges];
|
||||||
|
int allowedCount = 0;
|
||||||
|
int keepalive = 25; // seconds; what the file says, or 25: this device is always behind a NAT
|
||||||
|
};
|
||||||
|
|
||||||
|
// "" and `out` filled, or why the file can't be used ("line 7: ...").
|
||||||
|
std::string parseWgConf(const std::string& text, WgConfig& out);
|
||||||
|
// The same configuration as a `.conf` again: what the settings keep.
|
||||||
|
std::string toWgConf(const WgConfig& config);
|
||||||
|
bool validWgKey(const std::string& key); // 32 bytes in base64
|
||||||
|
|
||||||
|
// What can go through the tunnel. The network stack routes by an interface's own subnet or by
|
||||||
|
// default, nothing finer: so either everything goes through it (AllowedIPs has 0.0.0.0/0), or the
|
||||||
|
// one subnet this device's tunnel address is in. Ranges that are neither can't be reached, and
|
||||||
|
// the user is told how many.
|
||||||
|
struct WgRouting {
|
||||||
|
bool full = false; // the tunnel is the default route
|
||||||
|
int prefix = 32; // of the tunnel interface, when not full
|
||||||
|
int unreachable = 0; // allowed ranges outside it
|
||||||
|
};
|
||||||
|
WgRouting routingOf(const WgConfig& config);
|
||||||
|
bool wgReaches(const WgConfig& config, uint32_t address); // would a packet to this address go through it?
|
||||||
|
|
||||||
|
// For the screen and the console: never a key.
|
||||||
|
std::string describeWgRouting(const WgConfig& config);
|
||||||
|
|
||||||
|
} // namespace roro::net
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "debug_auth.h"
|
#include "debug_auth.h"
|
||||||
#include "ipv4.h"
|
#include "ipv4.h"
|
||||||
|
#include "wg_config.h"
|
||||||
|
|
||||||
namespace roro {
|
namespace roro {
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ const Definition kDefinitions[] = {
|
|||||||
{"wifi_on", Kind::Bool, 1, nullptr, 0, 1},
|
{"wifi_on", Kind::Bool, 1, nullptr, 0, 1},
|
||||||
{"gnss_on", Kind::Bool, 1, nullptr, 0, 1},
|
{"gnss_on", Kind::Bool, 1, nullptr, 0, 1},
|
||||||
{"coord_dms", Kind::Bool, 0, nullptr, 0, 1},
|
{"coord_dms", Kind::Bool, 0, nullptr, 0, 1},
|
||||||
{"lora_preset", Kind::Int, 0, nullptr, 0, 6}, // LongFast first
|
{"lora_preset", Kind::Int, 7, nullptr, 0, 7}, // MeshCore, after the 7 Meshtastic ones
|
||||||
{"dns1", Kind::String, 0, "9.9.9.9", 7, 15}, // Quad9
|
{"dns1", Kind::String, 0, "9.9.9.9", 7, 15}, // Quad9
|
||||||
{"dns2", Kind::String, 0, "1.1.1.1", 0, 15}, // Cloudflare
|
{"dns2", Kind::String, 0, "1.1.1.1", 0, 15}, // Cloudflare
|
||||||
{"dns_always", Kind::Bool, 0, nullptr, 0, 1},
|
{"dns_always", Kind::Bool, 0, nullptr, 0, 1},
|
||||||
@@ -45,6 +46,16 @@ const Definition kDefinitions[] = {
|
|||||||
{"debug_on", Kind::Bool, 0, nullptr, 0, 1}, // off: nothing listens until the owner says so (Q189)
|
{"debug_on", Kind::Bool, 0, nullptr, 0, 1}, // off: nothing listens until the owner says so (Q189)
|
||||||
{"debug_token", Kind::String, 0, "", 0, 64}, // empty, or a valid token
|
{"debug_token", Kind::String, 0, "", 0, 64}, // empty, or a valid token
|
||||||
{"help_told", Kind::Bool, 0, nullptr, 0, 1},
|
{"help_told", Kind::Bool, 0, nullptr, 0, 1},
|
||||||
|
{"vpn_config", Kind::String, 0, "", 0, 900}, // empty, or a .conf that parses
|
||||||
|
{"vpn_auto", Kind::Bool, 0, nullptr, 0, 1},
|
||||||
|
{"ssh_hosts", Kind::String, 0, "", 0, 800},
|
||||||
|
{"ssh_known", Kind::String, 0, "", 0, 2400},
|
||||||
|
{"ssh_key", Kind::String, 0, "", 0, 800},
|
||||||
|
{"ssh_public", Kind::String, 0, "", 0, 200},
|
||||||
|
{"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},
|
||||||
};
|
};
|
||||||
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");
|
||||||
@@ -103,6 +114,10 @@ bool Settings::validString(Setting s, const std::string& value) const {
|
|||||||
if (s == Setting::Ntp1) return net::validHost(value);
|
if (s == Setting::Ntp1) return net::validHost(value);
|
||||||
if (s == Setting::Ntp2) return value.empty() || net::validHost(value);
|
if (s == Setting::Ntp2) return value.empty() || net::validHost(value);
|
||||||
if (s == Setting::DebugToken) return value.empty() || debug::validToken(value);
|
if (s == Setting::DebugToken) return value.empty() || debug::validToken(value);
|
||||||
|
if (s == Setting::VpnConfig) {
|
||||||
|
net::WgConfig config;
|
||||||
|
return value.empty() || net::parseWgConf(value, config).empty();
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ enum class Setting : uint8_t {
|
|||||||
WifiEnabled, // bool: the Wi-Fi Service stays Connected when a Saved Network is in range
|
WifiEnabled, // bool: the Wi-Fi Service stays Connected when a Saved Network is in range
|
||||||
GnssEnabled, // bool: the GNSS Service reads the receiver (M2, Q58)
|
GnssEnabled, // bool: the GNSS Service reads the receiver (M2, Q58)
|
||||||
CoordinatesDms, // bool: show degrees, minutes and seconds instead of decimal degrees (Q64)
|
CoordinatesDms, // bool: show degrees, minutes and seconds instead of decimal degrees (Q64)
|
||||||
LoraPreset, // int: the LoRa Scanner's Meshtastic preset, an index into the EU868 list (M3, Q95)
|
LoraPreset, // int: the LoRa Scanner's preset, an index into lora::scanPreset (M3, Q95): MeshCore by default
|
||||||
Dns1, // string: the first DNS server, an IPv4 address (S1, Q108, Q109)
|
Dns1, // string: the first DNS server, an IPv4 address (S1, Q108, Q109)
|
||||||
Dns2, // string: the second, or empty
|
Dns2, // string: the second, or empty
|
||||||
DnsAlways, // bool: use them on Automatic (DHCP) networks too, instead of DHCP's
|
DnsAlways, // bool: use them on Automatic (DHCP) networks too, instead of DHCP's
|
||||||
@@ -34,6 +34,16 @@ enum class Setting : uint8_t {
|
|||||||
DebugConsole, // bool: the Debug Console listens on Wi-Fi (ADR 0010, Q189: off unless switched on)
|
DebugConsole, // bool: the Debug Console listens on Wi-Fi (ADR 0010, Q189: off unless switched on)
|
||||||
DebugToken, // string: its token, tidied (debug_auth.h); empty until the console is first switched on
|
DebugToken, // string: its token, tidied (debug_auth.h); empty until the console is first switched on
|
||||||
HelpTold, // bool: this device has been told about the help key once (issue #69, Q201)
|
HelpTold, // bool: this device has been told about the help key once (issue #69, Q201)
|
||||||
|
VpnConfig, // string: the WireGuard tunnel as a .conf (wg_config.h), private key included: never shown (issue #8)
|
||||||
|
VpnAuto, // bool: the tunnel starts whenever Wi-Fi is connected (Q247: off unless switched on)
|
||||||
|
SshHosts, // string: the SSH App's saved hosts, one user@host[:port] a line (issue #2, ssh_hosts.h)
|
||||||
|
SshKnown, // string: the fingerprint each server showed first, one "host:port fingerprint" a line
|
||||||
|
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
|
||||||
|
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
|
||||||
Count
|
Count
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#include "scp_args.h"
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace roro::term {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::vector<std::string> words(const std::string& text) {
|
||||||
|
std::vector<std::string> out;
|
||||||
|
for (size_t at = 0; at < text.size();) {
|
||||||
|
size_t end = text.find(' ', at);
|
||||||
|
if (end == std::string::npos) end = text.size();
|
||||||
|
if (end > at) out.push_back(text.substr(at, end - at));
|
||||||
|
at = end + 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// user@host:path, as opposed to a path on the card.
|
||||||
|
bool isRemote(const std::string& w) {
|
||||||
|
if (w.empty() || w[0] == '/') return false;
|
||||||
|
size_t at = w.find('@'), colon = w.find(':');
|
||||||
|
return at != std::string::npos && colon != std::string::npos && at < colon;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string baseName(const std::string& path) {
|
||||||
|
size_t slash = path.rfind('/');
|
||||||
|
return slash == std::string::npos ? path : path.substr(slash + 1);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string parseScp(const std::string& args, ScpArgs& out) {
|
||||||
|
static const char* kUsage = "scp [-f] [-P port] <file on the card> user@host:path | scp [-f] [-P port] user@host:path <file on the card>";
|
||||||
|
std::vector<std::string> w = words(args);
|
||||||
|
long port = 0;
|
||||||
|
bool replace = false;
|
||||||
|
while (!w.empty() && w[0][0] == '-') {
|
||||||
|
if (w[0] == "-f") {
|
||||||
|
replace = true;
|
||||||
|
w.erase(w.begin());
|
||||||
|
} else if (w[0] == "-P" && w.size() >= 2) {
|
||||||
|
port = 0;
|
||||||
|
for (char c : w[1]) {
|
||||||
|
if (c < '0' || c > '9' || port > 65535) return "a port from 1 to 65535";
|
||||||
|
port = port * 10 + (c - '0');
|
||||||
|
}
|
||||||
|
if (port < 1 || port > 65535) return "a port from 1 to 65535";
|
||||||
|
w.erase(w.begin(), w.begin() + 2);
|
||||||
|
} else {
|
||||||
|
return kUsage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (w.size() != 2) return kUsage;
|
||||||
|
|
||||||
|
bool firstRemote = isRemote(w[0]), secondRemote = isRemote(w[1]);
|
||||||
|
if (firstRemote == secondRemote) return firstRemote ? "one side has to be on the card, not both on servers" : kUsage;
|
||||||
|
|
||||||
|
ScpArgs a;
|
||||||
|
a.upload = secondRemote;
|
||||||
|
a.replace = replace;
|
||||||
|
const std::string& remote = a.upload ? w[1] : w[0];
|
||||||
|
std::string local = a.upload ? w[0] : w[1];
|
||||||
|
size_t colon = remote.find(':');
|
||||||
|
std::string why = parseSshTarget(remote.substr(0, colon), a.target);
|
||||||
|
if (!why.empty()) return why;
|
||||||
|
if (port) a.target.port = static_cast<uint16_t>(port);
|
||||||
|
a.remote = remote.substr(colon + 1);
|
||||||
|
if (a.remote.empty()) return "say where on " + a.target.host + ": user@host:path";
|
||||||
|
if (local.empty() || local[0] != '/') return "a path on the card starts with a slash";
|
||||||
|
if (local.back() == '/') {
|
||||||
|
if (a.upload) return "that's a folder: scp copies one file";
|
||||||
|
std::string name = baseName(a.remote);
|
||||||
|
if (name.empty()) return "name the file to fetch";
|
||||||
|
local += name;
|
||||||
|
}
|
||||||
|
a.local = local;
|
||||||
|
out = a;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro::term
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "ssh_hosts.h"
|
||||||
|
|
||||||
|
namespace roro::term {
|
||||||
|
|
||||||
|
// What `scp` was asked: one file to the card from a server, or from the card to a server.
|
||||||
|
// scp [-f] [-P port] /notes/a.txt user@host:path (upload)
|
||||||
|
// scp [-f] [-P port] user@host:path /notes/a.txt (download; a destination ending in / gets the remote file's name)
|
||||||
|
// The card's paths start with a slash; the server's are the server's own (relative ones start at the home folder).
|
||||||
|
struct ScpArgs {
|
||||||
|
bool upload = false;
|
||||||
|
bool replace = false; // -f: a download may replace a file on the card
|
||||||
|
SshTarget target;
|
||||||
|
std::string local, remote;
|
||||||
|
};
|
||||||
|
|
||||||
|
// "" or what is wrong with it.
|
||||||
|
std::string parseScp(const std::string& args, ScpArgs& out);
|
||||||
|
|
||||||
|
} // namespace roro::term
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#include "ssh_hosts.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace roro::term {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::vector<std::string> linesOf(const std::string& text) {
|
||||||
|
std::vector<std::string> out;
|
||||||
|
for (size_t at = 0; at < text.size();) {
|
||||||
|
size_t end = text.find('\n', at);
|
||||||
|
if (end == std::string::npos) end = text.size();
|
||||||
|
if (end > at) out.push_back(text.substr(at, end - at));
|
||||||
|
at = end + 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
bool hostChars(const std::string& s) {
|
||||||
|
if (s.empty() || s.size() > 253) return false;
|
||||||
|
for (char c : s)
|
||||||
|
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '-')) return false;
|
||||||
|
return s.front() != '.' && s.front() != '-';
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string SshTarget::text() const { return user + "@" + host + (port == 22 ? "" : ":" + std::to_string(port)); }
|
||||||
|
std::string SshTarget::hostPort() const { return host + ":" + std::to_string(port); }
|
||||||
|
|
||||||
|
std::string parseSshTarget(const std::string& text, SshTarget& out) {
|
||||||
|
size_t at = text.find('@');
|
||||||
|
if (at == std::string::npos || at == 0) return "user@host, please";
|
||||||
|
SshTarget t;
|
||||||
|
t.user = text.substr(0, at);
|
||||||
|
std::string rest = text.substr(at + 1);
|
||||||
|
if (t.user.size() > 32 || t.user.find_first_of(" @:/") != std::string::npos) return "that isn't a user name";
|
||||||
|
size_t colon = rest.rfind(':');
|
||||||
|
if (colon != std::string::npos) {
|
||||||
|
std::string port = rest.substr(colon + 1);
|
||||||
|
long n = 0;
|
||||||
|
if (port.empty() || port.size() > 5) return "a port from 1 to 65535";
|
||||||
|
for (char c : port) {
|
||||||
|
if (c < '0' || c > '9') return "a port from 1 to 65535";
|
||||||
|
n = n * 10 + (c - '0');
|
||||||
|
}
|
||||||
|
if (n < 1 || n > 65535) return "a port from 1 to 65535";
|
||||||
|
t.port = static_cast<uint16_t>(n);
|
||||||
|
rest.resize(colon);
|
||||||
|
}
|
||||||
|
if (!hostChars(rest)) return "that isn't a host";
|
||||||
|
t.host = rest;
|
||||||
|
out = t;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
SshHosts::SshHosts(const std::string& stored) {
|
||||||
|
for (auto& line : linesOf(stored)) {
|
||||||
|
SshTarget t;
|
||||||
|
if (hosts_.size() < kMax && parseSshTarget(line, t).empty()) hosts_.push_back(t.text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SshHosts::used(const SshTarget& target) {
|
||||||
|
std::string text = target.text();
|
||||||
|
hosts_.erase(std::remove(hosts_.begin(), hosts_.end(), text), hosts_.end());
|
||||||
|
hosts_.insert(hosts_.begin(), text);
|
||||||
|
if (hosts_.size() > kMax) hosts_.resize(kMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SshHosts::remove(size_t index) {
|
||||||
|
if (index < hosts_.size()) hosts_.erase(hosts_.begin() + static_cast<long>(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string SshHosts::stored() const {
|
||||||
|
std::string s;
|
||||||
|
for (auto& h : hosts_) s += h + "\n";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
SshKnownHosts::SshKnownHosts(const std::string& stored) {
|
||||||
|
for (auto& line : linesOf(stored)) {
|
||||||
|
size_t space = line.find(' ');
|
||||||
|
if (space != std::string::npos && space > 0 && space + 1 < line.size() && known_.size() < kMax) known_.emplace_back(line.substr(0, space), line.substr(space + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string SshKnownHosts::fingerprintOf(const std::string& hostPort) const {
|
||||||
|
for (auto& k : known_)
|
||||||
|
if (k.first == hostPort) return k.second;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
void SshKnownHosts::remember(const std::string& hostPort, const std::string& fingerprint) {
|
||||||
|
known_.erase(std::remove_if(known_.begin(), known_.end(), [&](const std::pair<std::string, std::string>& k) { return k.first == hostPort; }), known_.end());
|
||||||
|
known_.emplace_back(hostPort, fingerprint);
|
||||||
|
if (known_.size() > kMax) known_.erase(known_.begin());
|
||||||
|
}
|
||||||
|
|
||||||
|
void SshKnownHosts::forget(const std::string& hostPort) {
|
||||||
|
known_.erase(std::remove_if(known_.begin(), known_.end(), [&](const std::pair<std::string, std::string>& k) { return k.first == hostPort; }), known_.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string SshKnownHosts::stored() const {
|
||||||
|
std::string s;
|
||||||
|
for (auto& k : known_) s += k.first + " " + k.second + "\n";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro::term
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// Who the SSH App connects to, and which servers it has met (issue #2, N1 Q262 and Q263): kept
|
||||||
|
// in the device's settings as a few lines of text.
|
||||||
|
namespace roro::term {
|
||||||
|
|
||||||
|
struct SshTarget {
|
||||||
|
std::string user, host;
|
||||||
|
uint16_t port = 22;
|
||||||
|
std::string text() const; // user@host, with :port when it isn't 22
|
||||||
|
std::string hostPort() const; // host:port, always: what a host key is remembered under
|
||||||
|
};
|
||||||
|
// "user@host", "user@host:2222". "" or what is wrong with it.
|
||||||
|
std::string parseSshTarget(const std::string& text, SshTarget& out);
|
||||||
|
|
||||||
|
// Up to eight, the last used first; one a line.
|
||||||
|
class SshHosts {
|
||||||
|
public:
|
||||||
|
static constexpr size_t kMax = 8;
|
||||||
|
explicit SshHosts(const std::string& stored = "");
|
||||||
|
const std::vector<std::string>& list() const { return hosts_; }
|
||||||
|
void used(const SshTarget& target); // to the front, added if it's new
|
||||||
|
void remove(size_t index);
|
||||||
|
std::string stored() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<std::string> hosts_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The fingerprint each server showed the first time: "host:port SHA256:...", one a line, sixteen
|
||||||
|
// at most (the oldest goes).
|
||||||
|
class SshKnownHosts {
|
||||||
|
public:
|
||||||
|
static constexpr size_t kMax = 16;
|
||||||
|
explicit SshKnownHosts(const std::string& stored = "");
|
||||||
|
std::string fingerprintOf(const std::string& hostPort) const; // "" if never met
|
||||||
|
void remember(const std::string& hostPort, const std::string& fingerprint);
|
||||||
|
void forget(const std::string& hostPort);
|
||||||
|
std::string stored() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<std::pair<std::string, std::string>> known_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro::term
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
#include "terminal.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace roro::term {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// A character the screen's font has, for one it may not.
|
||||||
|
uint8_t glyphFor(uint32_t cp) {
|
||||||
|
if (cp >= 0x20 && cp <= 0x7E) return static_cast<uint8_t>(cp);
|
||||||
|
if (cp >= 0xA0 && cp <= 0xFF) return static_cast<uint8_t>(cp);
|
||||||
|
if (cp >= 0x2500 && cp <= 0x257F) { // box drawing
|
||||||
|
switch (cp) {
|
||||||
|
case 0x2500: case 0x2501: case 0x2504: case 0x2505: case 0x2508: case 0x2509: case 0x254C: case 0x254D: case 0x2550: return '-';
|
||||||
|
case 0x2502: case 0x2503: case 0x2506: case 0x2507: case 0x250A: case 0x250B: case 0x254E: case 0x254F: case 0x2551: return '|';
|
||||||
|
default: return '+';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (cp) {
|
||||||
|
case 0x2018: case 0x2019: return '\'';
|
||||||
|
case 0x201C: case 0x201D: return '"';
|
||||||
|
case 0x2010: case 0x2011: case 0x2012: case 0x2013: case 0x2014: return '-';
|
||||||
|
case 0x2022: case 0x25CF: return '*';
|
||||||
|
case 0x2026: return '.';
|
||||||
|
case 0x2190: return '<';
|
||||||
|
case 0x2192: return '>';
|
||||||
|
case 0x2191: return '^';
|
||||||
|
case 0x2193: return 'v';
|
||||||
|
case 0x2588: case 0x2593: case 0x2592: case 0x2591: return '#';
|
||||||
|
default: return '?';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The DEC "special graphics" set: lines drawn with the letters j to x.
|
||||||
|
uint8_t lineGlyph(uint8_t c) {
|
||||||
|
switch (c) {
|
||||||
|
case 'q': return '-';
|
||||||
|
case 'x': return '|';
|
||||||
|
case 'j': case 'k': case 'l': case 'm': case 'n': case 't': case 'u': case 'v': case 'w': return '+';
|
||||||
|
case '`': return '*';
|
||||||
|
case 'a': return '#';
|
||||||
|
case '~': return '*';
|
||||||
|
default: return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// One of 256 colours, or a colour given as red, green and blue, as the nearest of the sixteen.
|
||||||
|
int nearest16(int r, int g, int b) {
|
||||||
|
int most = std::max(r, std::max(g, b));
|
||||||
|
if (most < 48) return 0;
|
||||||
|
int half = most / 2;
|
||||||
|
int colour = (r > half ? 1 : 0) | (g > half ? 2 : 0) | (b > half ? 4 : 0);
|
||||||
|
if (colour == 7 && most < 200) return most < 110 ? 8 : 7;
|
||||||
|
return most > 170 ? colour | 8 : colour;
|
||||||
|
}
|
||||||
|
int from256(int n) {
|
||||||
|
if (n < 16) return n;
|
||||||
|
if (n >= 232) return nearest16(8 + (n - 232) * 10, 8 + (n - 232) * 10, 8 + (n - 232) * 10);
|
||||||
|
n -= 16;
|
||||||
|
static const int kSteps[6] = {0, 95, 135, 175, 215, 255};
|
||||||
|
return nearest16(kSteps[n / 36], kSteps[(n / 6) % 6], kSteps[n % 6]);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void colourRgb(int index, uint8_t& r, uint8_t& g, uint8_t& b) {
|
||||||
|
static const uint8_t kTable[16][3] = {{0, 0, 0}, {205, 49, 49}, {13, 188, 121}, {229, 229, 16}, {36, 114, 200}, {188, 63, 188},
|
||||||
|
{17, 168, 205}, {204, 204, 204}, {102, 102, 102}, {241, 76, 76}, {35, 209, 139}, {245, 245, 67},
|
||||||
|
{59, 142, 234}, {214, 112, 214}, {41, 184, 219}, {255, 255, 255}};
|
||||||
|
r = kTable[index & 15][0];
|
||||||
|
g = kTable[index & 15][1];
|
||||||
|
b = kTable[index & 15][2];
|
||||||
|
}
|
||||||
|
|
||||||
|
Terminal::Terminal(int cols, int rows, int historyLines)
|
||||||
|
: cols_(std::max(2, cols)), rows_(std::max(2, rows)), historyMax_(std::max(0, historyLines)), mainGrid_(static_cast<size_t>(cols_ * rows_)),
|
||||||
|
altGrid_(static_cast<size_t>(cols_ * rows_)), bottom_(rows_ - 1) {}
|
||||||
|
|
||||||
|
Cell Terminal::blank() const {
|
||||||
|
Cell c;
|
||||||
|
c.attr = static_cast<uint8_t>((bg_ << 4) | 7);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Terminal::takeBell() {
|
||||||
|
bool b = bell_;
|
||||||
|
bell_ = false;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Terminal::rowText(int row) const {
|
||||||
|
std::string s;
|
||||||
|
for (int c = 0; c < cols_; c++) s += static_cast<char>(cell(row, c).ch);
|
||||||
|
size_t end = s.find_last_not_of(' ');
|
||||||
|
s.resize(end == std::string::npos ? 0 : end + 1);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
int Terminal::param(size_t i, int fallback) const { return i < params_.size() && params_[i] > 0 ? params_[i] : fallback; }
|
||||||
|
|
||||||
|
void Terminal::moveTo(int row, int col) {
|
||||||
|
row_ = std::clamp(row, 0, rows_ - 1);
|
||||||
|
col_ = std::clamp(col, 0, cols_ - 1);
|
||||||
|
wrapPending_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Terminal::eraseCells(int row, int from, int to) {
|
||||||
|
Cell b = blank();
|
||||||
|
for (int c = std::max(0, from); c <= std::min(cols_ - 1, to); c++) grid()[static_cast<size_t>(row * cols_ + c)] = b;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Terminal::scrollUp(int top, int bottom, int n, bool toHistory) {
|
||||||
|
n = std::min(n, bottom - top + 1);
|
||||||
|
auto& g = grid();
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
if (toHistory && !alt_ && top == 0 && historyMax_ > 0) { // off the top of the real screen: kept, as text
|
||||||
|
history_.push_back(rowText(0));
|
||||||
|
if (static_cast<int>(history_.size()) > historyMax_) history_.pop_front();
|
||||||
|
}
|
||||||
|
std::move(g.begin() + (top + 1) * cols_, g.begin() + (bottom + 1) * cols_, g.begin() + top * cols_);
|
||||||
|
eraseCells(bottom, 0, cols_ - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Terminal::scrollDown(int top, int bottom, int n) {
|
||||||
|
n = std::min(n, bottom - top + 1);
|
||||||
|
auto& g = grid();
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
std::move_backward(g.begin() + top * cols_, g.begin() + bottom * cols_, g.begin() + (bottom + 1) * cols_);
|
||||||
|
eraseCells(top, 0, cols_ - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Terminal::lineFeed() {
|
||||||
|
wrapPending_ = false;
|
||||||
|
if (row_ == bottom_) scrollUp(top_, bottom_, 1);
|
||||||
|
else if (row_ < rows_ - 1) row_++;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Terminal::reverseIndex() {
|
||||||
|
wrapPending_ = false;
|
||||||
|
if (row_ == top_) scrollDown(top_, bottom_, 1);
|
||||||
|
else if (row_ > 0) row_--;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Terminal::put(uint32_t cp) {
|
||||||
|
uint8_t ch = lineDrawing_ && cp < 0x80 ? lineGlyph(static_cast<uint8_t>(cp)) : glyphFor(cp);
|
||||||
|
if (wrapPending_) {
|
||||||
|
col_ = 0;
|
||||||
|
lineFeed();
|
||||||
|
}
|
||||||
|
uint8_t fg = static_cast<uint8_t>(bold_ && fg_ < 8 ? fg_ | 8 : fg_), bg = bg_;
|
||||||
|
if (inverse_) std::swap(fg, bg);
|
||||||
|
Cell& c = grid()[static_cast<size_t>(row_ * cols_ + col_)];
|
||||||
|
c.ch = ch;
|
||||||
|
c.attr = static_cast<uint8_t>((bg << 4) | (fg & 15));
|
||||||
|
if (col_ == cols_ - 1) wrapPending_ = autoWrap_;
|
||||||
|
else col_++;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Terminal::control(uint8_t c) {
|
||||||
|
switch (c) {
|
||||||
|
case 0x07: bell_ = true; break;
|
||||||
|
case 0x08:
|
||||||
|
if (col_ > 0) col_--;
|
||||||
|
wrapPending_ = false;
|
||||||
|
break;
|
||||||
|
case 0x09: moveTo(row_, std::min(cols_ - 1, (col_ / 8 + 1) * 8)); break;
|
||||||
|
case 0x0A: case 0x0B: case 0x0C: lineFeed(); break;
|
||||||
|
case 0x0D:
|
||||||
|
col_ = 0;
|
||||||
|
wrapPending_ = false;
|
||||||
|
break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Terminal::reset() {
|
||||||
|
alt_ = false;
|
||||||
|
Cell b;
|
||||||
|
std::fill(mainGrid_.begin(), mainGrid_.end(), b);
|
||||||
|
std::fill(altGrid_.begin(), altGrid_.end(), b);
|
||||||
|
row_ = col_ = top_ = 0;
|
||||||
|
bottom_ = rows_ - 1;
|
||||||
|
fg_ = 7;
|
||||||
|
bg_ = 0;
|
||||||
|
bold_ = inverse_ = wrapPending_ = appCursor_ = lineDrawing_ = false;
|
||||||
|
autoWrap_ = cursorShown_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Terminal::escape(uint8_t c) {
|
||||||
|
state_ = State::Ground;
|
||||||
|
switch (c) {
|
||||||
|
case '[':
|
||||||
|
state_ = State::Csi;
|
||||||
|
params_.clear();
|
||||||
|
paramStarted_ = private_ = false;
|
||||||
|
break;
|
||||||
|
case ']': case 'P': case '^': case '_': state_ = State::Osc; break; // a title, or something this doesn't read: skipped to its end
|
||||||
|
case '(': case ')': case '*': case '+': state_ = State::Charset; break;
|
||||||
|
case '7':
|
||||||
|
savedRow_ = row_;
|
||||||
|
savedCol_ = col_;
|
||||||
|
savedAttr_ = static_cast<uint8_t>((bg_ << 4) | fg_);
|
||||||
|
break;
|
||||||
|
case '8':
|
||||||
|
moveTo(savedRow_, savedCol_);
|
||||||
|
fg_ = savedAttr_ & 15;
|
||||||
|
bg_ = savedAttr_ >> 4;
|
||||||
|
break;
|
||||||
|
case 'D': lineFeed(); break;
|
||||||
|
case 'E':
|
||||||
|
col_ = 0;
|
||||||
|
lineFeed();
|
||||||
|
break;
|
||||||
|
case 'M': reverseIndex(); break;
|
||||||
|
case 'c': reset(); break;
|
||||||
|
default: break; // = and >, the keypad's modes, among others
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Terminal::sgr() {
|
||||||
|
if (params_.empty()) params_.push_back(0);
|
||||||
|
for (size_t i = 0; i < params_.size(); i++) {
|
||||||
|
int p = params_[i];
|
||||||
|
if (p == 0) {
|
||||||
|
fg_ = 7;
|
||||||
|
bg_ = 0;
|
||||||
|
bold_ = inverse_ = false;
|
||||||
|
} else if (p == 1) bold_ = true;
|
||||||
|
else if (p == 7) inverse_ = true;
|
||||||
|
else if (p == 22) bold_ = false;
|
||||||
|
else if (p == 27) inverse_ = false;
|
||||||
|
else if (p >= 30 && p <= 37) fg_ = static_cast<uint8_t>(p - 30);
|
||||||
|
else if (p == 39) fg_ = 7;
|
||||||
|
else if (p >= 40 && p <= 47) bg_ = static_cast<uint8_t>(p - 40);
|
||||||
|
else if (p == 49) bg_ = 0;
|
||||||
|
else if (p >= 90 && p <= 97) fg_ = static_cast<uint8_t>(p - 90 + 8);
|
||||||
|
else if (p >= 100 && p <= 107) bg_ = static_cast<uint8_t>(p - 100 + 8);
|
||||||
|
else if ((p == 38 || p == 48) && i + 1 < params_.size()) {
|
||||||
|
int colour = -1;
|
||||||
|
if (params_[i + 1] == 5 && i + 2 < params_.size()) {
|
||||||
|
colour = from256(params_[i + 2] & 255);
|
||||||
|
i += 2;
|
||||||
|
} else if (params_[i + 1] == 2 && i + 4 < params_.size()) {
|
||||||
|
colour = nearest16(params_[i + 2] & 255, params_[i + 3] & 255, params_[i + 4] & 255);
|
||||||
|
i += 4;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
(p == 38 ? fg_ : bg_) = static_cast<uint8_t>(colour);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Terminal::mode(bool on) {
|
||||||
|
for (int p : params_) {
|
||||||
|
if (!private_) continue;
|
||||||
|
switch (p) {
|
||||||
|
case 1: appCursor_ = on; break;
|
||||||
|
case 7: autoWrap_ = on; break;
|
||||||
|
case 25: cursorShown_ = on; break;
|
||||||
|
case 47: case 1047: case 1049:
|
||||||
|
if (on == alt_) break;
|
||||||
|
if (on && p == 1049) {
|
||||||
|
savedRow_ = row_;
|
||||||
|
savedCol_ = col_;
|
||||||
|
}
|
||||||
|
alt_ = on;
|
||||||
|
if (on) std::fill(altGrid_.begin(), altGrid_.end(), Cell());
|
||||||
|
top_ = 0;
|
||||||
|
bottom_ = rows_ - 1;
|
||||||
|
if (!on && p == 1049) moveTo(savedRow_, savedCol_);
|
||||||
|
else if (on) moveTo(0, 0);
|
||||||
|
break;
|
||||||
|
default: break; // the mouse, bracketed paste and the rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Terminal::csi(uint8_t final) {
|
||||||
|
int n = param(0, 1);
|
||||||
|
switch (final) {
|
||||||
|
case 'A': moveTo(std::max(row_ - n, row_ >= top_ ? top_ : 0), col_); break;
|
||||||
|
case 'B': case 'e': moveTo(std::min(row_ + n, row_ <= bottom_ ? bottom_ : rows_ - 1), col_); break;
|
||||||
|
case 'C': case 'a': moveTo(row_, col_ + n); break;
|
||||||
|
case 'D': moveTo(row_, col_ - n); break;
|
||||||
|
case 'E': moveTo(row_ + n, 0); break;
|
||||||
|
case 'F': moveTo(row_ - n, 0); break;
|
||||||
|
case 'G': case '`': moveTo(row_, n - 1); break;
|
||||||
|
case 'd': moveTo(n - 1, col_); break;
|
||||||
|
case 'H': case 'f': moveTo(param(0, 1) - 1, param(1, 1) - 1); break;
|
||||||
|
case 'J': {
|
||||||
|
int what = params_.empty() ? 0 : params_[0];
|
||||||
|
if (what == 0) {
|
||||||
|
eraseCells(row_, col_, cols_ - 1);
|
||||||
|
for (int r = row_ + 1; r < rows_; r++) eraseCells(r, 0, cols_ - 1);
|
||||||
|
} else if (what == 1) {
|
||||||
|
for (int r = 0; r < row_; r++) eraseCells(r, 0, cols_ - 1);
|
||||||
|
eraseCells(row_, 0, col_);
|
||||||
|
} else {
|
||||||
|
for (int r = 0; r < rows_; r++) eraseCells(r, 0, cols_ - 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'K': {
|
||||||
|
int what = params_.empty() ? 0 : params_[0];
|
||||||
|
eraseCells(row_, what == 0 ? col_ : 0, what == 1 ? col_ : cols_ - 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'L':
|
||||||
|
if (row_ >= top_ && row_ <= bottom_) scrollDown(row_, bottom_, n);
|
||||||
|
break;
|
||||||
|
case 'M':
|
||||||
|
if (row_ >= top_ && row_ <= bottom_) scrollUp(row_, bottom_, n, false); // deleted, not scrolled away: not history
|
||||||
|
break;
|
||||||
|
case 'P': {
|
||||||
|
n = std::min(n, cols_ - col_);
|
||||||
|
auto row = grid().begin() + row_ * cols_;
|
||||||
|
std::move(row + col_ + n, row + cols_, row + col_);
|
||||||
|
eraseCells(row_, cols_ - n, cols_ - 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '@': {
|
||||||
|
n = std::min(n, cols_ - col_);
|
||||||
|
auto row = grid().begin() + row_ * cols_;
|
||||||
|
std::move_backward(row + col_, row + cols_ - n, row + cols_);
|
||||||
|
eraseCells(row_, col_, col_ + n - 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'X': eraseCells(row_, col_, col_ + n - 1); break;
|
||||||
|
case 'S': scrollUp(top_, bottom_, n); break;
|
||||||
|
case 'T': scrollDown(top_, bottom_, n); break;
|
||||||
|
case 'm': sgr(); break;
|
||||||
|
case 'r': {
|
||||||
|
int top = param(0, 1) - 1, bottom = param(1, rows_) - 1;
|
||||||
|
if (top < bottom && bottom < rows_) {
|
||||||
|
top_ = top;
|
||||||
|
bottom_ = bottom;
|
||||||
|
} else {
|
||||||
|
top_ = 0;
|
||||||
|
bottom_ = rows_ - 1;
|
||||||
|
}
|
||||||
|
moveTo(0, 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 's':
|
||||||
|
savedRow_ = row_;
|
||||||
|
savedCol_ = col_;
|
||||||
|
break;
|
||||||
|
case 'u': moveTo(savedRow_, savedCol_); break;
|
||||||
|
case 'h': mode(true); break;
|
||||||
|
case 'l': mode(false); break;
|
||||||
|
case 'n':
|
||||||
|
if (!reply) break;
|
||||||
|
if (param(0, 0) == 6) reply("\x1b[" + std::to_string(row_ + 1) + ";" + std::to_string(col_ + 1) + "R");
|
||||||
|
else if (param(0, 0) == 5) reply("\x1b[0n");
|
||||||
|
break;
|
||||||
|
case 'c':
|
||||||
|
if (reply && !private_) reply("\x1b[?6c"); // "a VT102"
|
||||||
|
break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Terminal::feed(const uint8_t* data, size_t len) {
|
||||||
|
for (size_t i = 0; i < len; i++) {
|
||||||
|
uint8_t c = data[i];
|
||||||
|
if (state_ == State::Osc || state_ == State::OscEsc) { // skipped: to a bell, or to ESC backslash
|
||||||
|
if (c == 0x07 || (state_ == State::OscEsc && c == '\\')) state_ = State::Ground;
|
||||||
|
else state_ = c == 0x1B ? State::OscEsc : State::Osc;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == 0x1B) {
|
||||||
|
state_ = State::Esc;
|
||||||
|
utf8Left_ = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c < 0x20) { // these act wherever they come, in the middle of a sequence too
|
||||||
|
if (c == 0x0E) lineDrawing_ = true;
|
||||||
|
else if (c == 0x0F) lineDrawing_ = false;
|
||||||
|
else control(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
switch (state_) {
|
||||||
|
case State::Esc: escape(c); break;
|
||||||
|
case State::Charset:
|
||||||
|
lineDrawing_ = c == '0';
|
||||||
|
state_ = State::Ground;
|
||||||
|
break;
|
||||||
|
case State::Csi:
|
||||||
|
if (c >= '0' && c <= '9') {
|
||||||
|
if (!paramStarted_) {
|
||||||
|
if (params_.size() < 16) params_.push_back(0);
|
||||||
|
paramStarted_ = true;
|
||||||
|
}
|
||||||
|
if (!params_.empty() && params_.back() < 10000) params_.back() = params_.back() * 10 + (c - '0');
|
||||||
|
} else if (c == ';' || c == ':') {
|
||||||
|
if (!paramStarted_ && params_.size() < 16) params_.push_back(0);
|
||||||
|
paramStarted_ = false;
|
||||||
|
} else if (c == '?' || c == '>' || c == '=' || c == '<') {
|
||||||
|
private_ = true;
|
||||||
|
} else if (c >= 0x40 && c <= 0x7E) {
|
||||||
|
state_ = State::Ground;
|
||||||
|
csi(c);
|
||||||
|
} // anything else is an in-between byte: passed over
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (c == 0x7F) break;
|
||||||
|
if (c < 0x80) {
|
||||||
|
utf8Left_ = 0;
|
||||||
|
put(c);
|
||||||
|
} else if (c >= 0xC0) { // the first byte of a longer character
|
||||||
|
utf8Left_ = c >= 0xF0 ? 3 : c >= 0xE0 ? 2 : 1;
|
||||||
|
utf8_ = c & (c >= 0xF0 ? 0x07 : c >= 0xE0 ? 0x0F : 0x1F);
|
||||||
|
} else if (utf8Left_ > 0) {
|
||||||
|
utf8_ = (utf8_ << 6) | (c & 0x3F);
|
||||||
|
if (--utf8Left_ == 0) put(utf8_);
|
||||||
|
} else {
|
||||||
|
put('?'); // a byte that belongs to nothing
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
revision_++;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Terminal::resize(int cols, int rows) {
|
||||||
|
cols = std::max(2, cols);
|
||||||
|
rows = std::max(2, rows);
|
||||||
|
if (cols == cols_ && rows == rows_) return;
|
||||||
|
// The cursor's line stays on screen: what is above it goes to the history if it has to.
|
||||||
|
int shift = alt_ ? 0 : std::max(0, row_ - (rows - 1));
|
||||||
|
if (shift) {
|
||||||
|
int oldTop = top_, oldBottom = bottom_;
|
||||||
|
top_ = 0;
|
||||||
|
bottom_ = rows_ - 1;
|
||||||
|
scrollUp(0, rows_ - 1, shift);
|
||||||
|
top_ = oldTop;
|
||||||
|
bottom_ = oldBottom;
|
||||||
|
}
|
||||||
|
auto copy = [&](std::vector<Cell>& g) {
|
||||||
|
std::vector<Cell> fresh(static_cast<size_t>(cols * rows));
|
||||||
|
for (int r = 0; r < std::min(rows, rows_); r++)
|
||||||
|
for (int c = 0; c < std::min(cols, cols_); c++) fresh[static_cast<size_t>(r * cols + c)] = g[static_cast<size_t>(r * cols_ + c)];
|
||||||
|
g.swap(fresh);
|
||||||
|
};
|
||||||
|
copy(mainGrid_);
|
||||||
|
copy(altGrid_);
|
||||||
|
cols_ = cols;
|
||||||
|
rows_ = rows;
|
||||||
|
top_ = 0;
|
||||||
|
bottom_ = rows_ - 1;
|
||||||
|
moveTo(row_ - shift, col_);
|
||||||
|
savedRow_ = std::min(savedRow_, rows_ - 1);
|
||||||
|
savedCol_ = std::min(savedCol_, cols_ - 1);
|
||||||
|
revision_++;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string encodeKey(TermKey key, uint32_t ch, bool ctrl, bool alt, bool appCursorKeys) {
|
||||||
|
std::string out;
|
||||||
|
auto arrow = [&](char letter) { return std::string(appCursorKeys ? "\x1bO" : "\x1b[") + letter; };
|
||||||
|
switch (key) {
|
||||||
|
case TermKey::Up: out = arrow('A'); break;
|
||||||
|
case TermKey::Down: out = arrow('B'); break;
|
||||||
|
case TermKey::Right: out = arrow('C'); break;
|
||||||
|
case TermKey::Left: out = arrow('D'); break;
|
||||||
|
case TermKey::Enter: out = "\r"; break;
|
||||||
|
case TermKey::Backspace: out = "\x7f"; break;
|
||||||
|
case TermKey::Tab: out = "\t"; break;
|
||||||
|
case TermKey::Escape: out = "\x1b"; break;
|
||||||
|
case TermKey::PageUp: out = "\x1b[5~"; break;
|
||||||
|
case TermKey::PageDown: out = "\x1b[6~"; break;
|
||||||
|
case TermKey::Char:
|
||||||
|
if (ctrl) {
|
||||||
|
uint32_t c = ch >= 'a' && ch <= 'z' ? ch - 32 : ch;
|
||||||
|
if (c >= '@' && c <= '_') out = std::string(1, static_cast<char>(c - '@'));
|
||||||
|
else if (c == ' ' || c == '2') out = std::string(1, '\0');
|
||||||
|
else if (c == '?' || c == '8') out = "\x7f";
|
||||||
|
else if (c == '3') out = "\x1b";
|
||||||
|
else if (c == '4') out = "\x1c";
|
||||||
|
else if (c == '5') out = "\x1d";
|
||||||
|
else if (c == '6') out = "\x1e";
|
||||||
|
else if (c == '7' || c == '/') out = "\x1f";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (ch < 0x80) out = std::string(1, static_cast<char>(ch));
|
||||||
|
else if (ch < 0x800) out = {static_cast<char>(0xC0 | (ch >> 6)), static_cast<char>(0x80 | (ch & 0x3F))};
|
||||||
|
else if (ch < 0x10000) out = {static_cast<char>(0xE0 | (ch >> 12)), static_cast<char>(0x80 | ((ch >> 6) & 0x3F)), static_cast<char>(0x80 | (ch & 0x3F))};
|
||||||
|
else out = {static_cast<char>(0xF0 | (ch >> 18)), static_cast<char>(0x80 | ((ch >> 12) & 0x3F)), static_cast<char>(0x80 | ((ch >> 6) & 0x3F)),
|
||||||
|
static_cast<char>(0x80 | (ch & 0x3F))};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (alt && !out.empty() && key != TermKey::Escape) out.insert(0, 1, '\x1b');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro::term
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <deque>
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// A terminal's screen (issue #2, N1 Q256): what a remote program's output makes of a grid of
|
||||||
|
// characters. It understands what a shell, `less`, `top`, `nano` and plain `vim` send: the cursor,
|
||||||
|
// erasing, sixteen colours, scroll regions, the alternate screen. No mouse. Characters are kept
|
||||||
|
// as the screen's font has them (Latin-1); what it lacks becomes `?`, and box-drawing lines
|
||||||
|
// become + - |.
|
||||||
|
namespace roro::term {
|
||||||
|
|
||||||
|
struct Cell {
|
||||||
|
uint8_t ch = ' ';
|
||||||
|
uint8_t attr = 0x07; // low four bits: the colour of the character, high four: of what is behind it
|
||||||
|
};
|
||||||
|
|
||||||
|
class Terminal {
|
||||||
|
public:
|
||||||
|
Terminal(int cols, int rows, int historyLines);
|
||||||
|
|
||||||
|
void feed(const uint8_t* data, size_t len);
|
||||||
|
// A new size: what is on screen stays where it is, from the top left; if the cursor would fall
|
||||||
|
// off the bottom, the top lines go to the history.
|
||||||
|
void resize(int cols, int rows);
|
||||||
|
// What the program asked to be told (where the cursor is, what kind of terminal this is).
|
||||||
|
std::function<void(const std::string&)> reply;
|
||||||
|
|
||||||
|
int cols() const { return cols_; }
|
||||||
|
int rows() const { return rows_; }
|
||||||
|
const Cell& cell(int row, int col) const { return grid()[static_cast<size_t>(row * cols_ + col)]; }
|
||||||
|
int cursorRow() const { return row_; }
|
||||||
|
int cursorCol() const { return col_; }
|
||||||
|
bool cursorVisible() const { return cursorShown_; }
|
||||||
|
bool appCursorKeys() const { return appCursor_; } // the arrows are sent another way (vim, less)
|
||||||
|
bool altScreen() const { return alt_; }
|
||||||
|
uint32_t revision() const { return revision_; } // changes whenever the screen may have
|
||||||
|
bool takeBell();
|
||||||
|
|
||||||
|
// Lines that scrolled off the top of the main screen, oldest first; their text only.
|
||||||
|
int historyCount() const { return static_cast<int>(history_.size()); }
|
||||||
|
const std::string& historyLine(int i) const { return history_[static_cast<size_t>(i)]; }
|
||||||
|
|
||||||
|
std::string rowText(int row) const; // without trailing spaces
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class State { Ground, Esc, Csi, Osc, OscEsc, Charset };
|
||||||
|
|
||||||
|
std::vector<Cell>& grid() { return alt_ ? altGrid_ : mainGrid_; }
|
||||||
|
const std::vector<Cell>& grid() const { return alt_ ? altGrid_ : mainGrid_; }
|
||||||
|
Cell blank() const;
|
||||||
|
void put(uint32_t codePoint);
|
||||||
|
void control(uint8_t c);
|
||||||
|
void escape(uint8_t c);
|
||||||
|
void csi(uint8_t final);
|
||||||
|
void sgr();
|
||||||
|
void mode(bool on);
|
||||||
|
void lineFeed();
|
||||||
|
void reverseIndex();
|
||||||
|
void scrollUp(int top, int bottom, int n, bool toHistory = true);
|
||||||
|
void scrollDown(int top, int bottom, int n);
|
||||||
|
void eraseCells(int row, int from, int to);
|
||||||
|
void moveTo(int row, int col);
|
||||||
|
void reset();
|
||||||
|
int param(size_t i, int fallback) const;
|
||||||
|
|
||||||
|
int cols_, rows_, historyMax_;
|
||||||
|
std::vector<Cell> mainGrid_, altGrid_;
|
||||||
|
std::deque<std::string> history_;
|
||||||
|
bool alt_ = false;
|
||||||
|
int row_ = 0, col_ = 0, top_ = 0, bottom_ = 0;
|
||||||
|
int savedRow_ = 0, savedCol_ = 0;
|
||||||
|
uint8_t savedAttr_ = 0x07;
|
||||||
|
bool wrapPending_ = false, autoWrap_ = true, cursorShown_ = true, appCursor_ = false, lineDrawing_ = false, bell_ = false;
|
||||||
|
uint8_t fg_ = 7, bg_ = 0;
|
||||||
|
bool bold_ = false, inverse_ = false;
|
||||||
|
State state_ = State::Ground;
|
||||||
|
std::vector<int> params_;
|
||||||
|
bool paramStarted_ = false, private_ = false;
|
||||||
|
uint32_t utf8_ = 0;
|
||||||
|
int utf8Left_ = 0;
|
||||||
|
uint32_t revision_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// What a key sends to the remote program.
|
||||||
|
enum class TermKey { Char, Up, Down, Left, Right, Enter, Backspace, Tab, Escape, PageUp, PageDown };
|
||||||
|
std::string encodeKey(TermKey key, uint32_t ch, bool ctrl, bool alt, bool appCursorKeys);
|
||||||
|
|
||||||
|
// One of the terminal's sixteen colours as red, green and blue.
|
||||||
|
void colourRgb(int index, uint8_t& r, uint8_t& g, uint8_t& b);
|
||||||
|
|
||||||
|
} // namespace roro::term
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -8,8 +8,26 @@
|
|||||||
#define RORO_VERSION "unknown"
|
#define RORO_VERSION "unknown"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if __has_include("sdkconfig.h")
|
||||||
|
#include "sdkconfig.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace roro {
|
namespace roro {
|
||||||
|
|
||||||
const char* versionString() { return RORO_VERSION; }
|
const char* versionString() { return RORO_VERSION; }
|
||||||
|
|
||||||
|
const char* architectureString() {
|
||||||
|
#if defined(CONFIG_IDF_TARGET_ESP32S3)
|
||||||
|
return "xtensa-lx7 esp32s3";
|
||||||
|
#elif defined(CONFIG_IDF_TARGET_ESP32)
|
||||||
|
return "xtensa-lx6 esp32";
|
||||||
|
#elif defined(CONFIG_IDF_TARGET)
|
||||||
|
return CONFIG_IDF_TARGET;
|
||||||
|
#else
|
||||||
|
return "host";
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string unameString() { return std::string(kProductName) + " " + versionString() + " " + architectureString(); }
|
||||||
|
|
||||||
} // namespace roro
|
} // namespace roro
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
namespace roro {
|
namespace roro {
|
||||||
|
|
||||||
constexpr const char* kProductName = "roro9stack";
|
constexpr const char* kProductName = "roro9stack";
|
||||||
@@ -9,4 +11,10 @@ constexpr const char* kProductName = "roro9stack";
|
|||||||
// that one file, and everything else comes from the build cache (issue #74).
|
// that one file, and everything else comes from the build cache (issue #74).
|
||||||
const char* versionString();
|
const char* versionString();
|
||||||
|
|
||||||
|
// The chip this firmware is built for: "xtensa-lx7 esp32s3". "host" in the native tests.
|
||||||
|
const char* architectureString();
|
||||||
|
|
||||||
|
// What `uname` and IRC's /uname report: "roro9stack v0.22.0 xtensa-lx7 esp32s3".
|
||||||
|
std::string unameString();
|
||||||
|
|
||||||
} // namespace roro
|
} // namespace roro
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ extra_scripts = pre:scripts/version.py
|
|||||||
test_framework = unity
|
test_framework = unity
|
||||||
|
|
||||||
[env:cardputer-adv]
|
[env:cardputer-adv]
|
||||||
|
extra_scripts =
|
||||||
|
${env.extra_scripts}
|
||||||
|
pre:scripts/libssh_filter.py
|
||||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip
|
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip
|
||||||
board = m5stack-stamps3
|
board = m5stack-stamps3
|
||||||
framework = arduino
|
framework = arduino
|
||||||
@@ -17,9 +20,14 @@ monitor_speed = 115200
|
|||||||
build_flags =
|
build_flags =
|
||||||
-DARDUINO_USB_CDC_ON_BOOT=1
|
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||||
-DARDUINO_USB_MODE=1
|
-DARDUINO_USB_MODE=1
|
||||||
|
-DCONFIG_WIREGUARD_MAX_SRC_IPS=4
|
||||||
lib_deps =
|
lib_deps =
|
||||||
m5stack/M5Cardputer @ 1.1.1
|
m5stack/M5Cardputer @ 1.1.1
|
||||||
jgromes/RadioLib @ 7.8.1
|
jgromes/RadioLib @ 7.8.1
|
||||||
|
esphome/wireguard @ 0.4.8
|
||||||
|
ewpa/LibSSH-ESP32 @ 5.10.0
|
||||||
|
; WireGuard brings libsodium in; 1.10021.12 defines crypto_sign_ed25519_open as LibSSH does, and the two do not link
|
||||||
|
esphome/libsodium @ 1.10021.11
|
||||||
test_ignore = *
|
test_ignore = *
|
||||||
; Smaller TLS buffers (M2): the framework is rebuilt with these settings (pioarduino "hybrid
|
; Smaller TLS buffers (M2): the framework is rebuilt with these settings (pioarduino "hybrid
|
||||||
; compile"). Receive stays 16 KB (servers send full TLS records); send drops to 4 KB (IRC lines are
|
; compile"). Receive stays 16 KB (servers send full TLS records); send drops to 4 KB (IRC lines are
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# libssh ships its own copy of curve25519 (src/external/curve25519_ref.c), whose two functions,
|
||||||
|
# crypto_scalarmult and crypto_scalarmult_base, have the names libsodium's have: and libsodium is
|
||||||
|
# already in the firmware, for WireGuard. Two definitions don't link. libssh's copy is left out
|
||||||
|
# of the build and it uses libsodium's, which is the same function (issue #2, docs/milestones/N1.md).
|
||||||
|
#
|
||||||
|
# A `pre:` script: the libraries are built by the platform's own script, which runs before any `post:` one.
|
||||||
|
Import("env") # noqa: F821 (provided by PlatformIO)
|
||||||
|
|
||||||
|
env.AddBuildMiddleware(lambda env, node: None, "*LibSSH-ESP32*curve25519_ref.c") # noqa: F821
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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>.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -36,19 +36,19 @@ 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.
|
||||||
- **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.
|
||||||
|
|
||||||
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 <path></text>
|
<text x="302" y="254" font-size="11" class="wi-dim">or install <path></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 |
@@ -21,7 +21,7 @@ boot other restart into the other app slot (manual Rollback)
|
|||||||
log level <0-5> ESP-IDF log level (0 none ... 5 verbose)
|
log level <0-5> ESP-IDF log level (0 none ... 5 verbose)
|
||||||
ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask; * and ? in a name: /notes/*.txt)
|
ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask; * and ? in a name: /notes/*.txt)
|
||||||
screenshot [seconds] the screen as a PNG in /screenshots on the card, now or after a pause
|
screenshot [seconds] the screen as a PNG in /screenshots on the card, now or after a pause
|
||||||
lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only
|
lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only; a Meshtastic preset (LongFast...) or MeshCore
|
||||||
lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)
|
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)
|
||||||
@@ -37,9 +37,16 @@ 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
|
||||||
debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back
|
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
|
||||||
|
ifconfig | arp | netstat the interfaces (Wi-Fi and the VPN), their addresses, the default route and the DNS servers; the neighbours heard; what listens and what is connected
|
||||||
|
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
|
||||||
|
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 > 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 > 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
|
||||||
@@ -53,7 +60,7 @@ get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) bina
|
|||||||
quit close the Debug Console connection
|
quit close the Debug Console connection
|
||||||
```
|
```
|
||||||
|
|
||||||
In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few run: `help`, `info`, `tasks`, `net`, `reboot`, `boot other`, `wifi status`, and anything starting with `log level`, `crash`, `coredump`, `wifi add`, `debug`. Anything else answers `not available in Safe Mode`.
|
In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few run: `help`, `uname`, `info`, `tasks`, `net`, `reboot`, `boot other`, `wifi status`, and anything starting with `log level`, `crash`, `coredump`, `wifi add`, `debug`. Anything else answers `not available in Safe Mode`.
|
||||||
|
|
||||||
## What they do
|
## What they do
|
||||||
|
|
||||||
@@ -64,6 +71,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 |
|
||||||
@@ -89,13 +97,13 @@ 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, cut or damage the next download, 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 |
|
||||||
| `lora preset <name>` / `lora custom <MHz> <BW kHz> <SF> <CR> <sync hex> [preamble]` | Receive settings: a Meshtastic preset, or anything else (`lora custom 868.1 125 7 5 34 8` for LoRaWAN) |
|
| `lora preset <name>` / `lora custom <MHz> <BW kHz> <SF> <CR> <sync hex> [preamble]` | Receive settings: a Meshtastic preset or `MeshCore`, or anything else (`lora custom 868.1 125 7 5 34 8` for LoRaWAN) |
|
||||||
| `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 |
|
||||||
@@ -109,6 +117,13 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
|
|||||||
| `coredump erase` | Forgets the core dump |
|
| `coredump erase` | Forgets the core dump |
|
||||||
| `loop spin on` / `loop spin off` | Make the main loop spin without resting, to compare load and radio noise |
|
| `loop spin on` / `loop spin off` | Make the main loop spin without resting, to compare load and radio noise |
|
||||||
| `crash abort` / `crash wdt` | Crash on purpose, or hang the main loop until the watchdog fires |
|
| `crash abort` / `crash wdt` | Crash on purpose, or hang the main loop until the watchdog fires |
|
||||||
|
| `ping <host> [count] [size]` / `nslookup <name> [server]` / `port <host> <port>` / `traceroute <host>` / `cancel` | Network troubleshooting (issue #90): does a host answer and how fast; a name's addresses, from which DNS server and in how long; is a TCP port open, refused or silent; the routers on the way. Each runs on a task of its own and prints as it goes, one at a time; `cancel` stops it |
|
||||||
|
| `tls <host> [port]` / `ntp [server]` | A TLS handshake that checks nothing, then the certificate said in words: who it is for, who signed it, until when, its SHA-256, and whether this device's roots and the name asked for accept it (about 52 KB of heap while it runs; refused under 70 KB free). A time server's clock against the device's, with the round trip |
|
||||||
|
| `ifconfig` / `arp` / `netstat` | The interfaces (Wi-Fi and the VPN) with their addresses, MTU, which is the default route, and the DNS servers; the neighbours heard on the Wi-Fi; what listens and what is connected |
|
||||||
|
| `ssh user@host[:port]` / `ssh status` / `ssh stop` | Opens the SSH App and connects (the password is asked there, never on a console); the session's state and this device's public key; end the session |
|
||||||
|
| `scp [-f] [-P port] <file> user@host:path` / `scp [-f] [-P port] user@host:path <file>` | One file between the card and a server, with the device's key, or a password asked (masked) in the Shell, to a server the SSH App already trusts; `-f` replaces a file on the card; `cancel` stops it |
|
||||||
|
| `uname` | The firmware, its version and the chip it is built for (IRC has `/uname`) |
|
||||||
|
| `vpn status` / `vpn up [seconds]` / `vpn down` / `vpn import [path]` / `vpn forget` / `vpn auto on\|off` | The WireGuard tunnel: its state, on (for that many seconds, then off by itself: for trying a configuration from afar), off, read a `.conf` from the card (`/vpn/wg0.conf`), erase it, start with Wi-Fi. No key is ever printed |
|
||||||
| `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long |
|
| `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long |
|
||||||
| `debug on` / `debug token <value>` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed |
|
| `debug on` / `debug token <value>` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed |
|
||||||
| `help` | Lists the commands |
|
| `help` | Lists the commands |
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ tag = "Console"
|
|||||||
|
|
||||||
These are the **binary commands**: a text header line, then raw bytes. The console task answers them itself, so they keep working when the main loop is stuck. `scripts/rdbg.py` handles each one on the PC side; the protocol is given too, for your own tools.
|
These are the **binary commands**: a text header line, then raw bytes. The console task answers them itself, so they keep working when the main loop is stuck. `scripts/rdbg.py` handles each one on the PC side; the protocol is given too, for your own tools.
|
||||||
|
|
||||||
|
**Without a PC,** the device does both by itself now: <kbd>Fn</kbd> + <kbd>p</kbd> saves a screenshot to the card ([how-to](/howto/screenshot/)), and <kbd>w</kbd> in the Storage App serves the card to a browser ([how-to](/howto/phone-files/)). What follows is the scripted way, with checksums.
|
||||||
|
|
||||||
## `get`: card to PC
|
## `get`: card to PC
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ docs = true
|
|||||||
source = "docs/milestones/F1.md"
|
source = "docs/milestones/F1.md"
|
||||||
tag = "F1"
|
tag = "F1"
|
||||||
+++
|
+++
|
||||||
**Status:** in progress. The Storage App (issue #3) shipped as **v0.9.0** on 2026-10-06. Notes (#19) shipped as **v0.10.0** the same day. The card as a USB drive (#1) comes after.
|
**Status:** in progress. Shipped: the Storage App (issue #3, **v0.9.0**), Notes (#19, **v0.10.0**), notes of any size (#47, **v0.15.0**), pictures in the Storage App (#45, **v0.16.0**), sharing the card with a browser (#88, **v0.18.0**). Not started: the card as a USB drive (#1), selecting several items (#41), finding files by name (#42), opening a `.gmi` in Gemini (#43), a table view for `.csv` (#44), search, undo and copy-paste in Notes (#48, #49, #50).
|
||||||
|
|
||||||
**Goal:** get at what's on the SD card from the device itself: browse it, look inside the files the firmware writes, copy, move, rename and delete, and keep notes. A side milestone, like G1 and S1; Files and Notes were M3's original second half (Q30, Q89).
|
**Goal:** get at what's on the SD card from the device itself: browse it, look inside the files the firmware writes, copy, move, rename and delete, and keep notes. A side milestone, like G1 and S1; Files and Notes were M3's original second half (Q30, Q89).
|
||||||
|
|
||||||
@@ -298,3 +298,46 @@ Test pictures were copied to a scratch folder and removed afterwards, with the t
|
|||||||
**A decoder that worked once.** The library's PNG decoder showed the first picture and refused the next five: "no memory". It wants 44 KB in one piece, and after some use the largest piece is 43 to 47 KB. Writing a decoder that needs 32 KB was less work than it sounds, and unlike the library's it has tests.
|
**A decoder that worked once.** The library's PNG decoder showed the first picture and refused the next five: "no memory". It wants 44 KB in one piece, and after some use the largest piece is 43 to 47 KB. Writing a decoder that needs 32 KB was less work than it sounds, and unlike the library's it has tests.
|
||||||
|
|
||||||
**Two sentences still said "up to 16 KB"** about editing, in the README and the Storage guide, after issue #47 lifted that. Corrected here.
|
**Two sentences still said "up to 16 KB"** about editing, in the README and the Storage guide, after issue #47 lifted that. Corrected here.
|
||||||
|
|
||||||
|
## Sharing the card with a browser (issue #88)
|
||||||
|
|
||||||
|
Files reached the card through the Debug Console's `put` and `get`, or by taking the card out. A phone has neither.
|
||||||
|
|
||||||
|
### Decisions (2026-10-07; the recommendation was accepted as it stood, without a round of questions)
|
||||||
|
|
||||||
|
- **A web page, not FTP, SFTP or WebDAV.** A browser is the only client every phone has. FTP and WebDAV need an app there; SFTP needs a whole SSH server here. WebDAV can come later on the same server, for computers.
|
||||||
|
- **Off unless asked for:** `w` in the Storage App opens a "Share" screen, and the server runs only while that screen is open.
|
||||||
|
- **A six-digit code on the screen**, new each time, typed in the page; the address is also a QR code, which carries the code. Five wrong codes close it for a minute (the Debug Console's `AuthGate`). A browser that got it right holds a cookie; starting again puts every browser out.
|
||||||
|
- **Not encrypted.** A TLS server costs about 40 KB of memory a connection. The screen says so.
|
||||||
|
- **The Storage App's rules** (`whyReadOnly`): the firmware's own folders, and files in use.
|
||||||
|
|
||||||
|
### As built
|
||||||
|
|
||||||
|
- **`WebShare`** (`src/services/web_share`): ESP-IDF's HTTP server, which is in the framework already. Seven requests: the page, the code, a listing as JSON, a download, an upload, a new folder, a delete.
|
||||||
|
- **Every access to the card is handed to the storage task**, 8 KB at a time, from the server's own task: a download and an upload are loops of "one piece from the card, one piece to the network".
|
||||||
|
- **An upload is the request's body**, as the browser's `PUT` sends it: no form to take apart. It goes to `<name>.part` and is renamed when the last byte has come; anything less is removed. A file that exists is refused unless the page asked, after asking the user.
|
||||||
|
- **The page** (`web_share_page.h`) is one file of 5.4 KB with its style and script in it, served from flash.
|
||||||
|
- **`lib/files/src/share_rules.h`** (host-tested, 5 tests): what a request names, which paths a browser may ask for (from the root, no `..`), the JSON, the code and the cookie.
|
||||||
|
- **Cost:** 57 KB of flash, most of it the server. 13 KB of memory while sharing (108.4 KB free before, 95.4 with the screen open), given back on leaving.
|
||||||
|
|
||||||
|
### Checks on the device (2026-10-07 and 08)
|
||||||
|
|
||||||
|
A scratch folder was used and removed.
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `w` | The QR code, the address and the code; `share: on` on the console |
|
||||||
|
| The page, and a listing without the code | 200; 401 |
|
||||||
|
| A wrong code, the right one (typed `825 132`) | 403; in |
|
||||||
|
| Upload, 2.6 MB | 11 to 17 s (150 to 230 KB/s); downloaded again and compared: the same, byte for byte |
|
||||||
|
| The same name again; with "replace" | 409; replaced |
|
||||||
|
| `..` in a path; deleting `/notes`; deleting a folder that isn't empty | 400; 403 "The firmware keeps its files in /notes"; 403 |
|
||||||
|
| Five wrong codes | The fifth and every one after: 429, the right code too. A browser that was in stays in |
|
||||||
|
| In Chromium at a phone's width | The scanned address logs in by itself; two files uploaded, one downloaded and compared, a folder made, a file deleted after asking, a replacement after asking, the refusal shown. No sideways scroll |
|
||||||
|
| Back | The server is gone (connection refused), memory is back |
|
||||||
|
|
||||||
|
**Found on the way:** the server answers one request at a time. A second request during a slow download waited until it had ended. It is said in the guide, and not changed.
|
||||||
|
|
||||||
|
**On a real phone** (the maintainer's, 2026-10-08): the QR code, scanned with the phone's camera, opens the page and logs in; the page lists the card, in the phone's dark theme.
|
||||||
|
|
||||||
|
**Not checked:** Safari. A card pulled during a transfer. Sharing with IRC connected, when memory is shorter. Home, and the screen turning off, while sharing (the code stops the server on leaving the App; only Back was tried).
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ tag = "M3"
|
|||||||
| Q92 | A **Radio Service** owns the SX1262: driver, bus lock, IRQ task. The LoRa Scanner uses it in M3; the Mesh Service sits on top of it in M4. |
|
| Q92 | A **Radio Service** owns the SX1262: driver, bus lock, IRQ task. The LoRa Scanner uses it in M3; the Mesh Service sits on top of it in M4. |
|
||||||
| Q93 | Every radio transfer takes the shared bus lock (`SPI.beginTransaction`, as the card does). DIO1's interrupt only wakes the task; no SPI in the ISR. **Done when** a Gemini page streams to the card while the Sniffer receives, with no lost packets and no card errors (`lora status` counters). |
|
| Q93 | Every radio transfer takes the shared bus lock (`SPI.beginTransaction`, as the card does). DIO1's interrupt only wakes the task; no SPI in the ISR. **Done when** a Gemini page streams to the card while the Sniffer receives, with no lost packets and no card errors (`lora status` counters). |
|
||||||
| Q94 | **Receive only:** the Radio Service has no transmit function in M3. It doesn't exist, rather than being unused. |
|
| Q94 | **Receive only:** the Radio Service has no transmit function in M3. It doesn't exist, rather than being unused. |
|
||||||
| Q95 | Sniffer defaults: **EU868 LongFast**, 869.525 MHz, BW 250 kHz, SF 11, CR 4/5, sync word 0x2B, preamble 16 (Q19). The other Meshtastic presets are offered, plus custom settings. |
|
| Q95 | Sniffer defaults: **EU868 LongFast**, 869.525 MHz, BW 250 kHz, SF 11, CR 4/5, sync word 0x2B, preamble 16 (Q19). The other Meshtastic presets are offered, plus custom settings. *Later, the default became is the MeshCore preset (869.618 MHz, BW 62.5 kHz, SF 8, CR 4/8, sync word 0x12): it is what is heard here.* |
|
||||||
| Q96 | The Sniffer lists packets (time, RSSI, SNR, frequency error, length; hex dump on Enter) **and decodes the Meshtastic header**: the first 16 bytes are never encrypted (destination, sender, packet ID, hop limit and hop start, channel hash, next hop, relay node). Host-tested. Payload decryption is M4. |
|
| Q96 | The Sniffer lists packets (time, RSSI, SNR, frequency error, length; hex dump on Enter) **and decodes the Meshtastic header**: the first 16 bytes are never encrypted (destination, sender, packet ID, hop limit and hop start, channel hash, next hop, relay node). Host-tested. Payload decryption is M4. |
|
||||||
| Q97 | A Sniffer **Capture** is pcap with **LoRaTap** headers (link type 270), for Wireshark. Started by hand, Status Bar mark, its own Clean-up category, the 90% rule. |
|
| Q97 | A Sniffer **Capture** is pcap with **LoRaTap** headers (link type 270), for Wireshark. Started by hand, Status Bar mark, its own Clean-up category, the 90% rule. |
|
||||||
| Q98 | **Sweep** steps across the Region's band (863–870 MHz) in 100 kHz steps by default, reading instant RSSI: bars with peak hold, and a waterfall, Wi-Fi Tools style. Optionally CAD on the preset's frequency to tell LoRa traffic from noise. |
|
| Q98 | **Sweep** steps across the Region's band (863–870 MHz) in 100 kHz steps by default, reading instant RSSI: bars with peak hold, and a waterfall, Wi-Fi Tools style. Optionally CAD on the preset's frequency to tell LoRa traffic from noise. |
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
+++
|
||||||
|
title = "Network tools"
|
||||||
|
description = "Reach things from the device that aren't on the Wi-Fi it happens to be on, and keep its traffic private on a network that isn't yours."
|
||||||
|
weight = 100
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
docs = true
|
||||||
|
source = "docs/milestones/N1.md"
|
||||||
|
tag = "N1"
|
||||||
|
+++
|
||||||
|
**Status:** in progress. The WireGuard tunnel (issue #8) shipped as **v0.19.0**. The network troubleshooting commands (issue #90) shipped in two parts: `ping`, `nslookup`, `port`, `traceroute`, `ifconfig` and `arp` as **v0.20.0**; `tls`, `ntp` and `netstat` as **v0.21.0**. The SSH client (issue #2) shipped as **v0.22.0**.
|
||||||
|
|
||||||
|
**Goal:** reach things from the device that aren't on the Wi-Fi it happens to be on, and keep its traffic private on a network that isn't yours.
|
||||||
|
|
||||||
|
## The WireGuard tunnel (issue #8)
|
||||||
|
|
||||||
|
A WireGuard client: the Cardputer joins a WireGuard network over whatever Wi-Fi it is on.
|
||||||
|
|
||||||
|
### Measured before deciding (2026-10-07)
|
||||||
|
|
||||||
|
The issue asked for the libraries to be measured first. `esphome/wireguard` 0.4.8 (maintained, published the same week; BSD-3-Clause) was built into a trial firmware and a tunnel brought up against a throwaway peer in a container.
|
||||||
|
|
||||||
|
| | Cost |
|
||||||
|
|---|---|
|
||||||
|
| Flash, the library | 43 KB |
|
||||||
|
| Flash, with our service, page and commands | 63 KB |
|
||||||
|
| Static RAM | 1.2 KB |
|
||||||
|
| Heap with the tunnel up | 1.8 KB |
|
||||||
|
|
||||||
|
- **It crashes this build as shipped.** The library calls lwIP's raw functions without taking lwIP's lock, and this framework is built to check for that (`CONFIG_LWIP_CHECK_THREAD_SAFETY`): the first `netif_add` stopped the device. Every call into it is made with the lock held, on our side; the library is not changed.
|
||||||
|
- **One address range is allowed by default;** more need `CONFIG_WIREGUARD_MAX_SRC_IPS`, set in `platformio.ini`.
|
||||||
|
- One peer, IPv4.
|
||||||
|
- The older `ciniml/WireGuard-ESP32` was last touched in 2021 and was not tried.
|
||||||
|
|
||||||
|
### Decisions (design round 2026-10-07)
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Q243 | **`esphome/wireguard`, pinned at 0.4.8,** with lwIP's lock taken around every call. |
|
||||||
|
| Q244 | **Configured by importing a standard `.conf` from the card** (`/vpn/wg0.conf`), from Settings or with `vpn import`. Nothing is typed on the device. |
|
||||||
|
| Q245 | **The private key comes in that file,** as WireGuard configurations are handed out. It is kept in the device's settings, never shown and never printed. After an import Settings **offers to delete the file**: the card comes out, and the key is in it in clear. |
|
||||||
|
| Q246 | One tunnel, one peer. |
|
||||||
|
| Q247 | **A switch, and "Start with Wi-Fi"** (off by default). The switch is for now: it doesn't outlast a restart. The tunnel waits for the clock, since a handshake carries the time and a server refuses one older than the last it saw; the clock is set over plain Wi-Fi first. |
|
||||||
|
| Q248 | *Narrowed while building.* **Either everything goes through the tunnel, or one subnet does.** With `0.0.0.0/0` in AllowedIPs the tunnel is the default route. Otherwise only the subnet this device's tunnel address is in is routed: the widest allowed range that holds it. **A home network behind the server can't be reached without the full tunnel:** lwIP routes by an interface's own subnet or by default, and has no table for anything finer. The import says how many ranges it can't reach. |
|
||||||
|
| Q249 | *Not as planned.* **With everything through the tunnel, nothing leaves while the server is silent:** the default route stays in the tunnel, which has nowhere to send. That is a kill switch, by construction and not by choice. With one subnet, packets for it go out on Wi-Fi again while the tunnel has no peer. |
|
||||||
|
| Q250 | The file's DNS servers are used while the tunnel is up, if they can be reached through it; what was there before goes back when it stops. |
|
||||||
|
| Q251 | **The Debug Console and the Update Service answer over the tunnel** as they do on Wi-Fi: the console still wants its token and an update its signature. |
|
||||||
|
| Q252 | **`VPN` in the Status Bar** while the tunnel is wanted, bright once the server has answered. Settings > VPN has the state, the server, this device's address, what goes through it and how long ago the server was heard. `vpn status`, `up`, `down`, `import`, `forget`, `auto`. A Toast when it comes up and when the server stops answering. |
|
||||||
|
| Q253 | PresharedKey, MTU and ListenPort from the file; keepalive 25 s if the file has none; the tunnel is taken down with the Wi-Fi it was on and started afresh on the next. No IPv6. |
|
||||||
|
|
||||||
|
### As built
|
||||||
|
|
||||||
|
- **`lib/net/src/wg_config.h`** (host-tested, 6 tests): reads a `.conf` as people write them (any case, comments, CRLF, IPv6 entries left out), refuses what it can't use with the line and the field and never the key, writes it back tidy for the settings store, and says what will be routed.
|
||||||
|
- **`VpnService`** (`src/services/vpn_service`): the tunnel is up when it is wanted, Wi-Fi is connected and the clock is set. It holds lwIP's lock around the library, adds the allowed ranges, makes the tunnel the default route for "everything", and puts the DNS servers in and out. A DHCP renewal that replaces them is noticed: the tunnel's go back in, and the renewed ones are what is restored later.
|
||||||
|
- **The tunnel's own packets never go into the tunnel:** the library sends them on the interface that was the default when it started.
|
||||||
|
- **Connections that came in over Wi-Fi stay on Wi-Fi** with everything routed into the tunnel: a reply leaves by the interface whose address it carries.
|
||||||
|
- **`vpn up <seconds>`** takes the tunnel down again by itself: for trying a configuration from afar, when a wrong one could cut the connection it was sent over.
|
||||||
|
- Settings: `VpnConfig` (the `.conf`, checked on every load) and `VpnAuto`.
|
||||||
|
|
||||||
|
### Checks on the device (2026-10-07, against a WireGuard peer in a container)
|
||||||
|
|
||||||
|
The test keys were made for the purpose and deleted. Two rounds: first with the device on a guest Wi-Fi that can't open connections to the machine the test peer ran on, so **the peer called the device** (`ListenPort`), which WireGuard allows either way round; then on a network where **the device called the peer**, as it normally would.
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `vpn import`, then the file removed | "imported, through it 10.9.0.0/24"; the configuration survives a firmware update |
|
||||||
|
| `vpn up` | Up within seconds; `VPN` bright in the Status Bar; a Toast |
|
||||||
|
| From the peer, through the tunnel | 25 pings of 25, 1300 bytes too; the Debug Console's greeting on TCP 2323; TCP 3232 answers |
|
||||||
|
| DNS | The file's server while up (`wifi status` says `(VPN)`), DHCP's back after `vpn down`, with no reconnection |
|
||||||
|
| Everything through the tunnel | The device stays reachable over Wi-Fi; an update check's HTTPS to the release server is seen inside the tunnel at the peer |
|
||||||
|
| `vpn up 100` | Down by itself after 100 s |
|
||||||
|
| "Start with Wi-Fi", then a restart | Up by itself 40 s after the restart, once Wi-Fi and the clock were there |
|
||||||
|
| The peer silenced | After three minutes: "no answer yet", a Toast, `VPN` dim. With everything through the tunnel, an update check then fails: nothing leaves. The peer back: up again in under half a minute, and a Toast |
|
||||||
|
| `vpn forget` | "not set"; DNS as before |
|
||||||
|
| **The device calling the peer**, the server given by name, with a PresharedKey and `MTU = 1280` | Up in seconds; the peer shows the device's address and port as the endpoint; pings through it |
|
||||||
|
| One subnet: a connection the device opens to the peer's tunnel address | Seen inside the tunnel at the peer |
|
||||||
|
| Everything: a Gemini page from a public capsule | Fetched (TLS, 1,184 bytes), and seen inside the tunnel at the peer. Free memory fell to 45.9 KB at the lowest |
|
||||||
|
| Memory | 106.1 KB free before, 104.3 KB with the tunnel up, 106.2 KB after |
|
||||||
|
| Settings > VPN | The four rows, the state and "heard 66 s ago", the server, the address; no key anywhere on it |
|
||||||
|
|
||||||
|
**Against a real server** (the maintainer's own, 2026-10-08): a configuration put on the card by the maintainer and imported in Settings; the server named by host name, on a port of its own, the device's address a /32, everything through the tunnel, "Start with Wi-Fi" on. The tunnel is up, and from another machine the device answers on its tunnel address: pings, and the Debug Console.
|
||||||
|
|
||||||
|
**Not checked:** from a network far from the server (the device was on the server's own network, reaching it by its public name). That the MTU is what limits a packet (larger pings were answered too, in pieces). Roaming from one Wi-Fi to another with the tunnel wanted. IRC through the tunnel. A day of uptime.
|
||||||
|
|
||||||
|
### What went wrong while building it
|
||||||
|
|
||||||
|
**The device stopped on the first try,** on lwIP's "Required to lock TCPIP core functionality!". The library was written for builds that don't check; ours does. The fix is three lines of ours, and the crash report named `netif_add` and the line that called it.
|
||||||
|
|
||||||
|
**Taking the tunnel down reconnected Wi-Fi.** The first version gave DHCP's DNS servers back by asking for a new lease, which is how the Wi-Fi settings do it, and which drops every connection: the Debug Console session that had typed `vpn down` among them. The servers that were there are now simply remembered and put back.
|
||||||
|
|
||||||
|
**"What AllowedIPs say" was more than the network stack can do.** The design round promised split tunnels by AllowedIPs. lwIP has no routing table: it can send by an interface's subnet, or by default. So it is one subnet or everything, and the import tells which.
|
||||||
|
|
||||||
|
## Network troubleshooting commands (issue #90)
|
||||||
|
|
||||||
|
With a tunnel, fixed addresses and a file server on the device, "is it the network or is it me" needed another machine to answer.
|
||||||
|
|
||||||
|
### Decisions (2026-10-08; built on the issue's list, without a round of questions)
|
||||||
|
|
||||||
|
- **The familiar names:** `ping`, `nslookup`, `traceroute`, `ifconfig`, `arp`. `port <host> <port>` for "is that TCP port open", which has no single familiar name.
|
||||||
|
- **In this version:** those six. **Not yet:** `tls` (why a certificate fails), `ntp` (the clock's offset), `netstat` (what listens). The issue stays open for them.
|
||||||
|
- **Commands only,** in the Shell and over both consoles; no page in an App.
|
||||||
|
- **One line an answer, short:** a Shell line is 38 characters.
|
||||||
|
|
||||||
|
### As built
|
||||||
|
|
||||||
|
- **`lib/net/src/net_probe.h`** (host-tested, 5 tests): what was typed; the ICMP echo request and what answers it, a router's "time exceeded" included; the DNS query and its answer, with the pointers names are shortened by.
|
||||||
|
- **`NetTools`** (`src/services/net_tools`): `ping`, `nslookup`, `port` and `traceroute` each run on a task of their own, made for the command and gone after it, printing to the console that asked (the Shell shows only its own replies). One at a time; `cancel` stops it within a fifth of a second.
|
||||||
|
- **`ping`** and **`traceroute`** share a raw ICMP socket: a traceroute is echo requests allowed one hop, then two, then three, and the routers' complaints are the list.
|
||||||
|
- **`nslookup`** asks one server itself, over UDP, and so can say which server answered and how long it took, which the system's resolver doesn't; and it can ask a server that isn't the configured one.
|
||||||
|
- **`port`** is a connection attempt that is not waited for: open, refused, or five seconds of nothing.
|
||||||
|
- **`ifconfig`** and **`arp`** read lwIP's own lists, with its lock held.
|
||||||
|
- **Cost:** 12 KB of flash. A 6 KB task while a command runs (2.6 KB of it never used), nothing otherwise.
|
||||||
|
|
||||||
|
### Checks on the device (2026-10-08, with the VPN up and everything routed through it)
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `ifconfig` | `vpn 10.9.0.2/32 mtu 1420, up, default route`; `wifi ... gw ... mtu 1500, up`; the DNS server |
|
||||||
|
| `arp` | The gateway and one other machine |
|
||||||
|
| `ping` of a neighbour, of a name | 4 of 4 in 3 to 4 ms; 3 of 3 in about 50 ms |
|
||||||
|
| `ping 9.9.9.9 2 1392`, then `1393` | Both back; neither back: the tunnel carries 1420 bytes exactly |
|
||||||
|
| `nslookup` | The address, the server and the time; an alias followed; with another server; "there is no nope.invalid" |
|
||||||
|
| `port` | `open, 52 ms`; `refused`; "no answer in 5 s"; "doesn't resolve" |
|
||||||
|
| `traceroute 9.9.9.9` | Nine hops, the tunnel's server first, "arrived" |
|
||||||
|
| A second command while a ping runs | "another one is running: `cancel` stops it" |
|
||||||
|
| `cancel` | "stopped", with the count so far |
|
||||||
|
| In the Shell | Tab completes them; the lines appear there and only there |
|
||||||
|
|
||||||
|
**Not checked:** without the VPN (every check went through the tunnel, or to the local network); a network that drops ICMP; the commands in Safe Mode, where they are not offered.
|
||||||
|
|
||||||
|
**Found on the way:** a refused connection is reported by lwIP as "reset", not "refused"; the first version called it "no route". And the header for the tested half was first given the same name as the service's, which makes a file include itself: the same mistake as an hour before, in the same way.
|
||||||
|
|
||||||
|
### The rest of the list: `tls`, `ntp`, `netstat` (2026-10-08)
|
||||||
|
|
||||||
|
- **`tls <host> [port]`** makes a handshake that checks nothing, so that a bad certificate can be looked at, and then checks it itself: against this device's roots (`ca_roots.h`, the ones the Update Service trusts) and the name asked for. It says who the certificate is for, who signed it, from when to when with the days left, the verdict with its reasons, and the SHA-256 that a Gemini pin is. It runs on a 12 KB task and isn't tried with less than 70 KB free: a handshake peaks at about 52 KB.
|
||||||
|
- **`ntp [server]`** sends one SNTP request and compares the answer with the device's clock, allowing for half the round trip. With no server it asks the first one in Settings.
|
||||||
|
- **`netstat`** reads lwIP's own lists: what listens, labelled where the firmware knows what it is, what is connected, and the UDP ports in use.
|
||||||
|
- Host tests: the NTP packet and the year 2036, the offset in words, a certificate's name (an old string type that mbedTLS prints as hex included), days between dates. 7 tests in `test/test_net_probe` in all.
|
||||||
|
|
||||||
|
| Check on the device | Result |
|
||||||
|
|---|---|
|
||||||
|
| `tls git.twis.la` | 709 ms; for git.twis.la, 67 days left, "this device trusts it", the SHA-256 |
|
||||||
|
| `tls geminiprotocol.net 1965` | "NOT trusted here: not signed by a root this device has": a capsule signs its own |
|
||||||
|
| `tls expired.badssl.com` | "EXPIRED 4197 days ago" |
|
||||||
|
| `tls wrong.host.badssl.com` | "NOT trusted here: not for that name" |
|
||||||
|
| `tls` to a port that isn't TLS | "no handshake ... An invalid SSL record was received" |
|
||||||
|
| `ntp` | The server, its stratum, 50 ms away; "this clock is right, to 0.1 s" |
|
||||||
|
| `netstat` | The update port and the Debug Console listening, the console's own connection, the UDP ports |
|
||||||
|
| Memory during a `tls` | 44.5 KB free at the lowest, from 104 KB |
|
||||||
|
|
||||||
|
**Not checked:** `tls` with IRC connected (it should refuse for lack of memory); `ntp` against a clock that is wrong; `netstat` while sharing.
|
||||||
|
|
||||||
|
## The SSH client (issue #2)
|
||||||
|
|
||||||
|
A terminal on another machine: one session to a shell, from the SSH App.
|
||||||
|
|
||||||
|
### Measured before deciding (2026-10-08)
|
||||||
|
|
||||||
|
`ewpa/LibSSH-ESP32` 5.10.0 (libssh on mbedTLS) was built into a trial firmware and a session opened against OpenSSH in a container, with a password.
|
||||||
|
|
||||||
|
| | Measured in the trial | As built |
|
||||||
|
|---|---|---|
|
||||||
|
| Flash | 120 KB | **292 KB** |
|
||||||
|
| Static RAM | 1.2 KB | |
|
||||||
|
| The session's task stack | 13 KB used | 13.7 KB used of 20 KB |
|
||||||
|
| Free heap with a session open | | 49 KB of 99 KB: it costs about 50 KB, the stack included |
|
||||||
|
| Lowest free heap during a login | | 30 KB |
|
||||||
|
| Key exchange (curve25519, ed25519 host key) | 227 ms | |
|
||||||
|
|
||||||
|
- **The trial undercounted the flash.** It logged in with a password. Signing with a key of the device's own (Q255) links libssh's table of multiples of the Ed25519 base point: `ge25519.c.o` alone is 109 KB. The rest of the difference is the public-key code, the terminal and three fonts. The firmware is at 71% of its slot.
|
||||||
|
- **libssh carries its own curve25519** (`src/external/curve25519_ref.c`) with the names libsodium uses, and libsodium is already here for WireGuard: two definitions don't link. A build script (`scripts/libssh_filter.py`) leaves libssh's copy out, and it uses libsodium's. It has to be a `pre:` script: libraries are built before any `post:` one runs.
|
||||||
|
- A session and a TLS connection don't fit together: IRC holds 40 KB.
|
||||||
|
|
||||||
|
### Decisions (design round 2026-10-08)
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Q254 | **LibSSH-ESP32 5.10.0**, with its duplicate curve file left out of the build. |
|
||||||
|
| Q255 | **A password, typed each time and never stored**, or **a key the device makes for itself** (Ed25519, no passphrase, kept in the settings store). Its public half is shown, written to `/ssh/id_ed25519.pub` and printed by `ssh status`. Keys made elsewhere aren't imported. |
|
||||||
|
| Q256 | **A terminal good enough for a shell, `less`, `top`, `nano` and `vim`:** cursor movement, erasing, sixteen colours, scroll regions, the alternate screen, the cursor keys' two modes. `TERM=xterm`. No mouse. |
|
||||||
|
| Q257 | **Five text sizes, changed with Ctrl and + or -** (the user's change to the round: the proposal was a setting). 4x6, 5x8, 6x10, 6x13 and 9x15 pixels: from 60 x 20 to 26 x 8 characters. The far end is told the new size; the choice is kept. |
|
||||||
|
| Q258 | **100 lines of scrollback**, as text, with Alt and up or down, as in the Shell. Not on the alternate screen. |
|
||||||
|
| Q259 | **Keys:** Ctrl with a letter; Tab; the backtick key sends Esc, as it is printed; Alt with it types a backtick; Fn with the arrow keys; Shift with those for Page Up and Down; Ctrl+Alt+q disconnects. Fn with backtick is Home, as everywhere. |
|
||||||
|
| Q260 | **The session outlives the App's time in front.** `SSH` in the Status Bar while one is open. |
|
||||||
|
| Q261 | **Not started under 75 KB free**, with the reason in words. |
|
||||||
|
| Q262 | **Up to eight hosts remembered**, the last used first, once a login has succeeded. Forgetting one forgets its server's fingerprint too, unless another remembered host is the same server. |
|
||||||
|
| Q263 | **Trust on first use,** on the SHA-256 fingerprint; sixteen servers remembered. **A changed key is a warning**, with Cancel selected. |
|
||||||
|
| Q264 | **UTF-8 in, the fonts' Latin-1 out:** what they lack is `?`, box-drawing lines are `+ - \|`. |
|
||||||
|
| Q265 | **`ssh user@host` in the Shell opens the App** and connects there. The password is never asked on a console. |
|
||||||
|
| Q266 | **Not built:** port forwarding, SFTP and scp, jump hosts, agent forwarding, keys with a passphrase, more than one session. |
|
||||||
|
|
||||||
|
### As built
|
||||||
|
|
||||||
|
- **`lib/term`** (host-tested, 12 tests in `test/test_terminal`): `Terminal`, the screen a program's output makes, with its history and the replies a program asks for; `encodeKey`, what a key sends; `SshHosts` and `SshKnownHosts`, the two lists kept in the settings store as lines of text.
|
||||||
|
- **`SshService`** (`src/services/ssh_service`): one session on a task of its own (20 KB of stack). The task and the main loop share the terminal, the bytes to send and the state under one lock. Its questions (is this the right server? the password?) are states it waits in until the App answers; the settings store is only written from the main loop. The password and the private key are overwritten after use.
|
||||||
|
- **`SshApp`** (`src/apps/ssh_app`): the hosts, the entry, the session, the key page. It draws the grid a run of same-coloured cells at a time, at most every 60 ms.
|
||||||
|
- **Keys that aren't characters now say what was held with them** (`lib/input/src/key_mapper.cpp`): the arrows, Enter, Del, Tab and Back carry Shift, Ctrl and Alt. The terminal needs it for Page Up and Alt+backtick. It also makes two documented keys work from the real keyboard, which until now only worked from the Debug Console's `key` command: Ctrl with Fn and up or down in a note, and Shift+Tab in Gemini.
|
||||||
|
- **IRC doesn't try to connect with less than 60 KB free** (`IrcService::kNeedFree`): see below.
|
||||||
|
- Settings: `SshHosts`, `SshKnown`, `SshKey`, `SshPublic`, `SshFont`.
|
||||||
|
|
||||||
|
### Checks on the device (2026-10-08, against OpenSSH 9.7 in a container on the same network)
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `ssh tester@host:2222` from the Debug Console | The App opens; the fingerprint shown is the one `ssh-keygen -lf` prints on the server |
|
||||||
|
| Trust it, a password | A shell; `stty size` says 15 48, `$TERM` is xterm |
|
||||||
|
| `ls -la`, `top`, `vim` (insert, Esc, `:wq`) | Drawn right: `top`'s reverse-video header, `vim`'s alternate screen and what was there before coming back; the file is on the server |
|
||||||
|
| Ctrl with + and - | `stty size` says 12 40, then 20 60; `top` redraws for it |
|
||||||
|
| `seq 1 60`, Alt with up | The history, in grey, with how far back in the corner |
|
||||||
|
| Fn+backtick, then the App again | The Launcher with `SSH` in the Status Bar; the session as it was |
|
||||||
|
| `sleep 100`, Ctrl+C; Alt+backtick | Interrupted; `` echo `id -u` `` prints 1000 |
|
||||||
|
| Ctrl+Alt+q; `exit` | "Disconnected"; "The session ended" |
|
||||||
|
| This device's key, its public half in `authorized_keys` | "Accepted publickey" in the server's log; no password asked |
|
||||||
|
| The server's host keys replaced | "THE SERVER'S KEY CHANGED" with the new fingerprint, Cancel selected. Cancel: "Not trusted: not connected". Replace: it connects, and doesn't ask again |
|
||||||
|
| A wrong password | "Wrong password", and asked again; Back gives up |
|
||||||
|
| A port nothing listens on | "Nothing listens there: the connection was refused" |
|
||||||
|
| `ssh nobody`, `ssh a@`, a port of 99999 | Refused, each with its reason |
|
||||||
|
| Forgetting a host | Asked, then gone from the list |
|
||||||
|
| `irc start` with a session open | "not enough memory: close the SSH session, retrying in 5 s", and no attempt: the lowest free heap doesn't move |
|
||||||
|
| Memory | 99 KB free before, 49 KB with a session open, 30 KB at the lowest during a login, 99 KB again after |
|
||||||
|
| Stacks | `ssh` 6.8 KB free of 20 KB; `loopTask` 1.9 KB free, as before |
|
||||||
|
|
||||||
|
**Not checked:** the refusal under 75 KB free (it is one comparison, and wasn't provoked). A server on the internet, or through the VPN. Wi-Fi lost in the middle of a session. Servers other than OpenSSH. `nano`, `less`, `htop`, `tmux`. Keyboard-interactive logins (two-factor prompts). A session left open for hours.
|
||||||
|
|
||||||
|
### What went wrong while building it
|
||||||
|
|
||||||
|
- **IRC, started with a session open, took the free heap down to 236 bytes.** A test script's keys went to the Launcher instead of the terminal and opened the IRC App, which connects when opened. Its TLS handshake found no memory, failed, and tried again with its usual back-off, six times; nothing crashed and it never connected, but 236 bytes is no margin at all. IRC now looks at the free heap before each attempt and says "not enough memory: close the SSH session" instead of trying.
|
||||||
|
- **The build script did nothing as a `post:` script:** the libraries were already built when it ran.
|
||||||
|
- **A failed connection was first shown as an empty terminal** with its reason squeezed on the last line, and a host was remembered before anyone had logged in to it. Both changed: the reason has a page, and a host is remembered once a login succeeds.
|
||||||
|
- **The trust question didn't fit its dialog:** the fingerprint is 50 characters. It is now split over two lines, under one line of words.
|
||||||
@@ -8,7 +8,7 @@ docs = true
|
|||||||
source = "docs/milestones/R1.md"
|
source = "docs/milestones/R1.md"
|
||||||
tag = "R1"
|
tag = "R1"
|
||||||
+++
|
+++
|
||||||
**Status:** in progress. CI and signed releases on Gitea (issue #5) are in place since 2026-10-06: every tag from v0.1.0 to v0.10.0 has its release. Updates from Gitea (issue #6) is built and checked on the device, on branch `gitea-updates`, not merged yet. The Issues App (#4) comes after.
|
**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).
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ docs = true
|
|||||||
source = "docs/milestones/S1.md"
|
source = "docs/milestones/S1.md"
|
||||||
tag = "S1"
|
tag = "S1"
|
||||||
+++
|
+++
|
||||||
**Status:** the three planned items are done: the SD driver fix in v0.6.1 (issue #21, ADR 0007), fixed IPv4 settings in v0.7.0 (issue #7), the System App in v0.8.0 (issue #11). v0.8.1 adds the resting main loop (issue #40) and the GNSS pause for the radio's noise (issue #20, still open for the 11 dB that remain). Still open in the milestone: #39, following the SD driver upstream.
|
**Status:** the three planned items are done: the SD driver fix in v0.6.1 (issue #21, ADR 0007), fixed IPv4 settings in v0.7.0 (issue #7), the System App in v0.8.0 (issue #11). v0.8.1 adds the resting main loop (issue #40) and the GNSS pause for the radio's noise (issue #20, still open for the 11 dB that remain). Still open in the milestone: #39, following the SD driver upstream. The **Shell** (issue #67) shipped as **v0.14.0**; the Shell in Safe Mode (#77) is not started.
|
||||||
|
|
||||||
**Goal:** the device works on any network, the card can be trusted, and you can see what the system is doing. A side milestone, like G1.
|
**Goal:** the device works on any network, the card can be trusted, and you can see what the system is doing. A side milestone, like G1.
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ docs = true
|
|||||||
source = "docs/milestones/U1.md"
|
source = "docs/milestones/U1.md"
|
||||||
tag = "U1"
|
tag = "U1"
|
||||||
+++
|
+++
|
||||||
**Status:** in progress. The help key (issue #69) is merged; the website's key tables generated from the same lists (issue #72) are in a pull request. Screen recording (#17) and the rest of the milestone are not started.
|
**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, 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.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ docs = true
|
|||||||
source = "docs/milestones/W1.md"
|
source = "docs/milestones/W1.md"
|
||||||
tag = "W1"
|
tag = "W1"
|
||||||
+++
|
+++
|
||||||
**Status:** phases 1 to 3 (home, Install and Downloads; the user guide; how-tos and the FAQ) and the devlog are live at roro9stack.net; phase 4 (the developer docs) is in a pull request. Issue #12.
|
**Status:** live at roro9stack.net: the home, Install and Downloads pages, the user guide, the how-tos and the FAQ, the developer docs and the devlog (issue #12), published by CI since issue #79, with a search since issue #60. Not started: a Gemini mirror (#57), a French translation (#58), the docs of each version (#59).
|
||||||
|
|
||||||
**Goal:** a public home for the project at **roro9stack.net**, separate from the blog (stories) and from Gitea (developers): what it is, how to install it, how to use each App, and the docs.
|
**Goal:** a public home for the project at **roro9stack.net**, separate from the blog (stories) and from Gitea (developers): what it is, how to install it, how to use each App, and the docs.
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 132 KiB |
@@ -0,0 +1,188 @@
|
|||||||
|
+++
|
||||||
|
title = '''Press w'''
|
||||||
|
description = '''I asked whether roro9stack should get an FTP, SFTP or WebDAV server, to move files to and from my phone. The answer was none of them: a web page. Less than an hour later I pressed one key on the Cardputer, opened my phone's browser, and my SD card was in it. It works, and I'm still grinning.'''
|
||||||
|
date = 2026-10-08T00:30:00+02:00
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
topics = '''ESP32-S3 · HTTP · Files'''
|
||||||
|
read_label = '''Read how the card got into my phone →'''
|
||||||
|
uid = '''<b>share:</b> on at http://172.16.42.25/'''
|
||||||
|
dek = "A short one, written straight after it worked, because I'm too pleased to wait. [roro9stack](/devlog/roro9stack/), my firmware for the M5Stack Cardputer, can now hand its SD card to any browser on the same Wi-Fi: no cable, no app, no computer. One key, one code, done."
|
||||||
|
byline = '''one question asked, the wrong three answers offered, a fourth taken'''
|
||||||
|
|
||||||
|
[extra.sign]
|
||||||
|
label = "Apps installed on the phone to make this work"
|
||||||
|
note = "A browser was already there."
|
||||||
|
count = "0"
|
||||||
|
|
||||||
|
[[extra.cast]]
|
||||||
|
name = "The question"
|
||||||
|
role = "\"FTP, SFTP or WebDAV?\""
|
||||||
|
text = "Three ways to serve files, all of which want an app on the phone. I'd have picked one and been mildly unhappy with it for months."
|
||||||
|
|
||||||
|
[[extra.cast]]
|
||||||
|
name = "The w key"
|
||||||
|
role = "in the Storage App"
|
||||||
|
text = "Starts a web server and shows where it is. Back stops it. That is the entire user interface on the device."
|
||||||
|
|
||||||
|
[[extra.cast]]
|
||||||
|
name = "The code"
|
||||||
|
role = "six digits, new every time"
|
||||||
|
text = "On the device's screen and nowhere else. Type it in the page and you're in. Five wrong tries and the door stays shut for a minute."
|
||||||
|
|
||||||
|
[[extra.cast]]
|
||||||
|
name = "The page"
|
||||||
|
role = "5.4 KB, one file"
|
||||||
|
text = "A list of what's on the card, an Upload button, a New folder button, and a Delete next to every row. Served from the firmware's own flash."
|
||||||
|
|
||||||
|
[[extra.cast]]
|
||||||
|
name = "The storage task"
|
||||||
|
role = "the only one allowed to touch the card"
|
||||||
|
text = "Every byte in either direction goes through it, 8 KB at a time. It was there long before this and didn't need to learn anything new."
|
||||||
|
+++
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
- **Press `w` in the Storage App** (**v0.18.0**), scan the QR code with a phone on the same Wi-Fi, and the SD card is a web page: list, download, upload, new folder, delete.
|
||||||
|
- **Nothing to install**, on the phone or anywhere else. That was the whole point.
|
||||||
|
- **It runs only while that screen is open.** A six-digit code, new each time, keeps the rest of the network out.
|
||||||
|
- **It is not encrypted,** and the screen says so. Fine at home; think first elsewhere.
|
||||||
|
- About **200 KB a second**, one request at a time, 57 KB of flash, 13 KB of memory while it's on.
|
||||||
|
- Also since [the last post](/devlog/roro9stack-shell/): the device shows **pictures** (v0.16.0), **Fn+p takes a screenshot** anywhere (v0.17.0), and this site has a **search**.
|
||||||
|
|
||||||
|
## It works!
|
||||||
|
|
||||||
|
I'll skip the build-up. I pressed `w`. This came up:
|
||||||
|
|
||||||
|
{{ figure(src="share.png", alt="The Cardputer's screen at 2x: a large QR code on the left; on the right, In a browser, on this network: 172.16.42.25/, Code 825 132 in large blue digits, Nothing asked yet, Not encrypted, and backtick stops sharing.", width=480, height=270, caption="The whole feature, as the device sees it. The code in this picture stopped being valid the moment I pressed Back.") }}
|
||||||
|
|
||||||
|
I pointed my phone's camera at the QR code, and there was my SD card, in the browser. No address to type, no code to type. **It works. That's fucking awesome.**
|
||||||
|
|
||||||
|
{{ figure(src="phone.png", alt="A phone's browser in dark mode at the address 172.16.42.25, at 00:15: roro9stack: the SD card, a link SD card, a bright blue Upload files button and a New folder button, then rows captures, gemini, gnss, irc, notes, screenshots, updates and wifi, each with a Delete button.", width=462, height=1001, caption="My phone, at a quarter past midnight. Its browser, my card, nothing else.") }}
|
||||||
|
|
||||||
|
{{ figure(src="device-photo.jpg", alt="A photograph of the Cardputer ADV on a wooden table. Its small screen shows the QR code, the address, the code 997 216, and 2.5 MB in, 3.2 MB out. Below the screen, the whole keyboard.", width=900, height=864, landscape=true, caption="And the other end of it, for scale. The whole server is in there, behind a screen smaller than the QR code on most posters.") }}
|
||||||
|
|
||||||
|
Files, from my phone, to a computer the size of a biscuit and back. Over Wi-Fi. With nothing installed on either end that wasn't there this morning.
|
||||||
|
|
||||||
|
Most of this devlog is about things that took a week of measuring and still bit me. This one I can explain to anybody in a sentence: it's a web page with your files on it.
|
||||||
|
|
||||||
|
## The question I asked, and the one I should have
|
||||||
|
|
||||||
|
Until tonight a file reached the card in one of two ways: the Debug Console's `put` command, which wants a PC, a Python script and a token, or pulling the card out. My phone can do neither. So I asked the obvious question: FTP, SFTP or WebDAV?
|
||||||
|
|
||||||
|
The answer was a table, and the table was unkind to all three:
|
||||||
|
|
||||||
|
{% table() %}
|
||||||
|
| | On the phone | On the device |
|
||||||
|
|---|---|---|
|
||||||
|
| FTP | needs an app; passwords in clear | easy |
|
||||||
|
| SFTP | needs an app | a whole SSH server: hundreds of KB, and a key exchange this chip would feel |
|
||||||
|
| WebDAV | needs an app, on iOS and Android both | fine, but see the first column |
|
||||||
|
| **A web page** | **any browser** | a small HTTP server |
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
I had been choosing a protocol. What I wanted was to move a file with my thumb. Every phone made in the last fifteen years has exactly one file-transfer client that needs no setup, and it's the browser.
|
||||||
|
|
||||||
|
## What's in it
|
||||||
|
|
||||||
|
**On the device, almost nothing.** `w` starts the server and draws a screen. Back stops it. The server is the one that ships inside ESP-IDF, the framework the firmware is built on, so there was no library to choose. Seven requests: the page, the code, a listing, a download, an upload, a new folder, a delete.
|
||||||
|
|
||||||
|
**The QR code carries the code.** Scan it and you're in without typing; type the address by hand and the page asks for the six digits. The code is made fresh from the hardware random generator each time `w` is pressed, and pressing Back throws every browser out.
|
||||||
|
|
||||||
|
**An upload is just the request's body.** The browser sends the file as it is with a `PUT`, so there is no form to pick apart on a device with 100 KB of free memory. It lands on the card as `photo.jpg.part`, 8 KB at a time, and is renamed when the last byte has arrived. A transfer that dies halfway leaves nothing behind. If the name is taken, the page asks before replacing it, and the device refuses until it has.
|
||||||
|
|
||||||
|
**The Storage App's rules still hold.** The page can't delete the folders the firmware keeps its own files in, and it says why:
|
||||||
|
|
||||||
|
{{ figure(src="page.png", alt="The page in a browser at a phone's width: roro9stack: the SD card, a link SD card, buttons Upload files and New folder, then rows a-web, captures, gemini, gnss, irc, notes, screenshots, updates and wifi, each with a Delete button. Below, in orange: The firmware keeps its files in /notes.", width=390, height=630, caption="The page, at a phone's width, just after it was asked to delete `/notes`. It said no, in the device's own words.") }}
|
||||||
|
|
||||||
|
**Nobody gets to walk out of the card.** `/a-web/../wifi` is refused before anything looks at the disk, by a function with a test that tries a dozen ways of asking.
|
||||||
|
|
||||||
|
## What it isn't
|
||||||
|
|
||||||
|
**Encrypted.** A TLS server costs this device about 40 KB of memory per connection, and it has around 100 KB on a good day. So the files and the code cross the Wi-Fi in clear. On my own network I don't mind. On a hotel's, I'd think about it. The device's screen says "Not encrypted." in plain words every time, because a limit you have to read the docs to find is a trap.
|
||||||
|
|
||||||
|
**Fast.** 200 KB a second, give or take. A 2.6 MB photo takes eleven to seventeen seconds going up. I tried bigger pieces and smaller ones; the numbers moved around more between two runs of the same setting than between settings, so it's 8 KB and I stopped fiddling.
|
||||||
|
|
||||||
|
**Able to do two things at once.** The server answers one request at a time. Start a big download and the page waits until it's done. I found that by asking for a listing in the middle of a download and watching it time out. It's written in the guide and left as it is.
|
||||||
|
|
||||||
|
## What went wrong, for about four minutes each
|
||||||
|
|
||||||
|
**A file that included itself.** The part that can be tested on a PC lived in `web_share.h`. So did the service, in another folder. The service's header said `#include "web_share.h"`, meaning the other one, and the compiler quite reasonably gave it itself. The testable half is now called `share_rules.h`.
|
||||||
|
|
||||||
|
**The scanned address did nothing, sometimes.** If the page was already open and you then went to the same address with the code after the `#`, nothing happened: to a browser that isn't a new page, so the script never ran again. One line, `onhashchange`. Found by a browser test, not by me, which is the right way round.
|
||||||
|
|
||||||
|
That's the list. Two.
|
||||||
|
|
||||||
|
## What I checked, and what I didn't
|
||||||
|
|
||||||
|
Checked, before I went anywhere near my phone:
|
||||||
|
|
||||||
|
- Every request and every refusal, from a PC: wrong code, right code, a path with `..` in it, deleting a protected folder, deleting a folder that isn't empty, uploading over a file that exists.
|
||||||
|
- **2.6 MB up, then down again, compared byte for byte.** The same.
|
||||||
|
- The page in a real browser at a phone's width: uploads, a download, a new folder, a delete, a replace.
|
||||||
|
- Five wrong codes: shut for a minute, for the right code too. A browser that was already in stays in.
|
||||||
|
- Back: the server is gone and the memory comes back.
|
||||||
|
|
||||||
|
And then on my actual phone, where it works, which is the sentence this post exists for.
|
||||||
|
|
||||||
|
Not checked: Safari. Pulling the card out mid-transfer. Sharing while IRC is connected, when memory is tighter. What happens if the screen turns off while it's sharing.
|
||||||
|
|
||||||
|
## Also since last time
|
||||||
|
|
||||||
|
The day didn't stop at the [last post](/devlog/roro9stack-shell/):
|
||||||
|
|
||||||
|
- **Pictures.** The Storage App opens PNG, JPEG, BMP and GIF files (**v0.16.0**). The interesting part was the PNG decoder: the one in the display library wanted 44 KB in a single block, got it once, and refused the next five pictures. The firmware has its own now, which needs 32.
|
||||||
|
- **Fn+p** takes a screenshot on any screen (**v0.17.0**), except the one that shows the Debug Console's token, where it politely declines.
|
||||||
|
- **This site has a [search](/search/).**
|
||||||
|
- **A WireGuard tunnel** is sitting in a pull request. It works against a test server on my own network and is waiting to meet a real one.
|
||||||
|
|
||||||
|
## By the numbers
|
||||||
|
|
||||||
|
{% table() %}
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Keys to press on the device | 1 |
|
||||||
|
| Apps to install on the phone | 0 |
|
||||||
|
| Digits in the code | 6 |
|
||||||
|
| Wrong codes before it shuts for a minute | 5 |
|
||||||
|
| The page | 5.4 KB |
|
||||||
|
| Flash | 57 KB |
|
||||||
|
| Memory while sharing | 13 KB |
|
||||||
|
| Speed | about 200 KB/s |
|
||||||
|
| Requests at a time | 1 |
|
||||||
|
| Bytes that differed after 2.6 MB went up and came back | 0 |
|
||||||
|
| Host tests | 533 |
|
||||||
|
| Things that went wrong | 2 |
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
## Where it stands
|
||||||
|
|
||||||
|
{% steps() %}
|
||||||
|
1. ~~M0 and M1: the skeleton, Wi-Fi, IRC, Wi-Fi Tools.~~ v0.1.0 to v0.2.1, [the first post](/devlog/roro9stack/).
|
||||||
|
|
||||||
|
2. ~~Updates and debugging over the air.~~ v0.3.0, [Look, no cables](/devlog/roro9stack-ota/).
|
||||||
|
|
||||||
|
3. ~~M2: GNSS.~~ v0.4.0, [Seventeen satellites](/devlog/roro9stack-gnss/).
|
||||||
|
|
||||||
|
4. ~~G1: Gemini.~~ v0.5.0, [A browser in the RAM IRC left over](/devlog/roro9stack-gemini/).
|
||||||
|
|
||||||
|
5. ~~M3: the LoRa radio, listening.~~ v0.6.0, [The loudest thing it hears is itself](/devlog/roro9stack-lora/).
|
||||||
|
|
||||||
|
6. ~~S1: the card, fixed addresses, the System App.~~ v0.6.1 to v0.8.1, [One byte too early](/devlog/roro9stack-s1/).
|
||||||
|
|
||||||
|
7. ~~F1 and the start of R1: files, notes, signed releases, updates from Gitea.~~ v0.9.0 to v0.11.0, [836 bytes](/devlog/roro9stack-f1-r1/).
|
||||||
|
|
||||||
|
8. ~~W1: the website, and one firmware with the Debug Console in it.~~ v0.12.0, [It was off](/devlog/roro9stack-console/).
|
||||||
|
|
||||||
|
9. ~~A help key, the Shell, notes of any size.~~ v0.13.0 to v0.15.0, [It said "No"](/devlog/roro9stack-shell/).
|
||||||
|
|
||||||
|
10. ~~Pictures, and a screenshot key.~~ v0.16.0 and v0.17.0.
|
||||||
|
|
||||||
|
11. ~~The card in a phone's browser.~~ v0.18.0, this post.
|
||||||
|
|
||||||
|
12. Next: the WireGuard tunnel, once it has talked to a real server. And M4, the mesh, which still wants a second node.
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
{% signoff() %}
|
||||||
|
I asked which of three servers to build and got told to build none of them. Then I pressed a key, picked up my phone, and my files were on it. Most of what this firmware does took days of careful measuring to get right. This took less than an evening, and it works, and I'm going to enjoy that at least until the next thing breaks.
|
||||||
|
{% end %}
|
||||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,312 @@
|
|||||||
|
+++
|
||||||
|
title = '''Six releases behind'''
|
||||||
|
description = '''roro9stack gets a WireGuard tunnel, and the tools to find out why a network doesn't work: ping, nslookup, traceroute, a TLS check and the rest. In between, I looked at the project's own website and found that the documentation had stopped keeping up six releases earlier, and nobody had noticed, me included.'''
|
||||||
|
date = 2026-10-08T03:15:00+02:00
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
topics = '''ESP32-S3 · WireGuard · Documentation'''
|
||||||
|
read_label = '''Read what was missing →'''
|
||||||
|
uid = '''<b>ifconfig:</b> vpn 10.9.0.2/32 mtu 1420, up, default route'''
|
||||||
|
dek = "Three more releases of [roro9stack](/devlog/roro9stack/), my firmware for the M5Stack Cardputer: a VPN (v0.19.0) and nine commands for troubleshooting a network from the device itself (v0.20.0 and v0.21.0). The tunnel crashed the device the first time it was started and then turned out to be the easy part. The hard part was noticing that the user guide described a firmware from the day before."
|
||||||
|
byline = '''measured before deciding, for once; then promised more than the network stack could do'''
|
||||||
|
|
||||||
|
[extra.sign]
|
||||||
|
label = "Releases shipped with the documentation behind"
|
||||||
|
note = "v0.13.0 to v0.18.0. Each had its own page updated, and nothing around it."
|
||||||
|
count = "6"
|
||||||
|
tone = "red"
|
||||||
|
|
||||||
|
[[extra.cast]]
|
||||||
|
name = "The tunnel"
|
||||||
|
role = "WireGuard, one peer, IPv4"
|
||||||
|
text = "Costs under 2 KB of memory once it's up, which on this device is close to free. Stopped the device dead the first time it was asked to start."
|
||||||
|
|
||||||
|
[[extra.cast]]
|
||||||
|
name = "lwIP"
|
||||||
|
role = "the network stack"
|
||||||
|
text = "Has a lock, and in this firmware it checks that you hold it. Has no routing table, which I found out after promising one."
|
||||||
|
|
||||||
|
[[extra.cast]]
|
||||||
|
name = "The test peer"
|
||||||
|
role = "a WireGuard server in a container"
|
||||||
|
text = "Could reach the device. The device couldn't reach it. So the server called the client, which WireGuard doesn't mind at all."
|
||||||
|
|
||||||
|
[[extra.cast]]
|
||||||
|
name = "The home page"
|
||||||
|
role = "of this site"
|
||||||
|
text = "Said the Storage App opens text, hex, captures and tracks. It had been showing pictures for four releases and serving files to phones for one."
|
||||||
|
|
||||||
|
[[extra.cast]]
|
||||||
|
name = "ping"
|
||||||
|
role = "and eight friends"
|
||||||
|
text = "nslookup, port, traceroute, tls, ntp, ifconfig, arp, netstat. The first thing I did with them was measure my own tunnel, and learn something."
|
||||||
|
+++
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
- **A WireGuard VPN** (**v0.19.0**): copy a client `.conf` to the card, import it in Settings, switch it on. One tunnel, to one server. It carries everything, or the tunnel's own subnet.
|
||||||
|
- **It costs 63 KB of flash and under 2 KB of memory.** The library crashed the firmware on its first call; the fix was three lines of ours.
|
||||||
|
- **I promised split tunnels by `AllowedIPs` and couldn't deliver:** the network stack routes by one subnet or by default, nothing finer.
|
||||||
|
- **The documentation was six releases behind.** Every feature had its own page. The home page, Settings, the Status Bar, the how-tos, the glossary and the README's first paragraph had none of it.
|
||||||
|
- **Nine network commands** in the Shell (**v0.20.0**, **v0.21.0**): `ping`, `nslookup`, `port`, `traceroute`, `tls`, `ntp`, `ifconfig`, `arp`, `netstat`.
|
||||||
|
- A ping of 1392 bytes crosses my tunnel and one of 1393 doesn't. I now know my tunnel's MTU to the byte, from a device with a 240-pixel screen.
|
||||||
|
- 546 host tests, 13 more than last time.
|
||||||
|
|
||||||
|
## The cast
|
||||||
|
|
||||||
|
{{ cast() }}
|
||||||
|
|
||||||
|
## Measured first, for once
|
||||||
|
|
||||||
|
The issue for the VPN had a line I'd written days ago and am glad of: *measure both libraries first.* So before any design, a trial firmware with the maintained library in it, a throwaway WireGuard server in a container, and a real tunnel.
|
||||||
|
|
||||||
|
{% table() %}
|
||||||
|
| | Cost |
|
||||||
|
|---|---|
|
||||||
|
| Flash, the library | 43 KB |
|
||||||
|
| Flash, with the service, the Settings page and the commands | 63 KB |
|
||||||
|
| Static RAM | 1.2 KB |
|
||||||
|
| Heap with the tunnel up | 1.8 KB |
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
On a device where a TLS connection takes 52 KB, a VPN for 1.8 is a gift. That number alone decided most of the design round: no memory floors, no "close IRC first", nothing to ration.
|
||||||
|
|
||||||
|
Getting to that number took three surprises.
|
||||||
|
|
||||||
|
### It stopped on the first call
|
||||||
|
|
||||||
|
{% code(caption="The first `wg up`. The crash report named the line.") %}
|
||||||
|
```
|
||||||
|
assert failed: netif_add /IDF/components/lwip/lwip/src/core/netif.c:297
|
||||||
|
(Required to lock TCPIP core functionality!)
|
||||||
|
```
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
The library talks to lwIP, the network stack, through its low-level functions, and takes no lock while it does. Most builds don't mind. This firmware's framework is built with the check switched on, and so the first `netif_add` was the last thing the device did.
|
||||||
|
|
||||||
|
The fix isn't in the library. Every call into it is made with the lock held, on our side: a three-line guard object. The library is used exactly as published.
|
||||||
|
|
||||||
|
### The server had to call the client
|
||||||
|
|
||||||
|
My device was on a guest Wi-Fi. The test server was on another network, and the guest network doesn't let its devices open connections inward. No handshake. For a while it looked like the library didn't work.
|
||||||
|
|
||||||
|
It worked fine. WireGuard doesn't have clients and servers, only peers, and either can call the other. I gave the device a fixed port to listen on, told the *server* where the device was, and the server started the handshake. Twenty-five pings of twenty-five, through a tunnel set up backwards.
|
||||||
|
|
||||||
|
(Later, on a network where the device could reach out, the normal direction worked first time. And then against my real server, which is the test that counts.)
|
||||||
|
|
||||||
|
### "What AllowedIPs say"
|
||||||
|
|
||||||
|
In the design round I'd agreed to this: *what goes through the tunnel is what the file's `AllowedIPs` line says.* Home subnets through the tunnel, the rest out over Wi-Fi. That's what every WireGuard client does.
|
||||||
|
|
||||||
|
Then I read how lwIP decides where a packet goes. It has two rules: the packet is for an interface's own subnet, or it goes to the default interface. There is no routing table to add "192.168.1.0/24 via the tunnel" to.
|
||||||
|
|
||||||
|
So the firmware does one of two things, and says which when you import a file:
|
||||||
|
|
||||||
|
{% table() %}
|
||||||
|
| The file says | What happens |
|
||||||
|
|---|---|
|
||||||
|
| `AllowedIPs = 0.0.0.0/0` | Everything goes through the tunnel |
|
||||||
|
| Anything else | The one subnet the device's tunnel address is in |
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
A home network *behind* the server needs the first kind. An import with more ranges than that says so: "through it 10.9.0.0/24, not 1 other range". I'd rather the screen admit a limit than the documentation.
|
||||||
|
|
||||||
|
It also changed an answer I'd given about a kill switch. I'd said there wouldn't be one. With everything routed into the tunnel, there is: while the server is silent the default route still points into a tunnel that has nowhere to send, and nothing leaves. Not by decision. By construction.
|
||||||
|
|
||||||
|
{{ figure(src="vpn.png", alt="The Cardputer's Settings, VPN page at 2x: VPN On, Start with Wi-Fi On, Import /vpn/wg0.conf, Forget it; then in blue It is up, heard 66 s ago, and in grey the server's address and This device 10.9.0.2, through it everything. The Status Bar shows VPN in blue.", width=480, height=270, caption="Settings → VPN, against the test server. No key appears on this page, or anywhere else: the private key goes in with the file and is never shown again.") }}
|
||||||
|
|
||||||
|
## Taking it down took my connection with it
|
||||||
|
|
||||||
|
One more, because it's the kind of bug that only shows when you test the way you work.
|
||||||
|
|
||||||
|
The tunnel puts its own DNS servers in while it's up, and has to give the old ones back when it stops. The Wi-Fi settings already had a way to get DHCP's servers back: ask for a new lease. So I called that.
|
||||||
|
|
||||||
|
A new lease reconnects Wi-Fi. Reconnecting Wi-Fi drops every connection, including the Debug Console session I had just typed `vpn down` into. The command worked and I never saw it say so.
|
||||||
|
|
||||||
|
The tunnel now remembers what was there and puts it back. It also notices when a DHCP renewal replaces its servers mid-flight, puts its own back in, and keeps the renewed ones for later.
|
||||||
|
|
||||||
|
## Six releases behind
|
||||||
|
|
||||||
|
With the tunnel working against my real server, I went to the website to see how it read. And then I went through the rest of the site, and it got worse with every page.
|
||||||
|
|
||||||
|
- The **home page** described a Storage App that opens "text, hex, captures, tracks and update files". It had been showing pictures since v0.16.0 and serving the card to phones since v0.18.0.
|
||||||
|
- The **Notes** card didn't say that a note can be any size, which it can since v0.15.0.
|
||||||
|
- **Settings**, in the guide, had no VPN row. The **Status Bar** table had no `VPN`.
|
||||||
|
- There was **no how-to** for anything built since: nothing on moving files with a phone, nothing on screenshots.
|
||||||
|
- The **README** opened by calling this "a Meshtastic-compatible mesh messenger". It listens to a mesh. It has never sent a message.
|
||||||
|
- Every milestone document had a **status line** from days ago. One still said a feature was "in a pull request" that had long been merged and released.
|
||||||
|
|
||||||
|
None of this was neglect in the usual sense. Each feature *had* been documented: its own guide page, its section in the README, its design notes. That was the trap. Every pull request looked finished because the page about the new thing was there. Nobody was looking at the pages about the old things, which is where a reader starts.
|
||||||
|
|
||||||
|
Six releases went out like that.
|
||||||
|
|
||||||
|
What changed isn't a resolution to be more careful, which lasts a week. It's a list, the same one every time: the home page's cards, every Settings row, the Status Bar, a how-to for anything a person would want to do, the FAQ, the glossary, the README's opening, the status lines, a screenshot of each new screen. A feature isn't done until each of those has been asked "does the new thing show here?"
|
||||||
|
|
||||||
|
The catch-up went into the same pull request as the VPN: three new how-tos, five new screenshots, and a README that starts with what the firmware does today.
|
||||||
|
|
||||||
|
The two releases after it were documented as they were built. That's two. Ask me again at twenty.
|
||||||
|
|
||||||
|
## Nine commands for a bad network day
|
||||||
|
|
||||||
|
A VPN, addresses you can set by hand, a file server: this device now has enough networking to have network problems. And until today, the only thing it could tell you was `wifi status`.
|
||||||
|
|
||||||
|
{% table() %}
|
||||||
|
| Command | Tells you |
|
||||||
|
|---|---|
|
||||||
|
| `ping` | Does it answer, and how fast |
|
||||||
|
| `nslookup` | A name's addresses, which DNS server answered, in how long |
|
||||||
|
| `port` | A TCP port: open, refused, or silent |
|
||||||
|
| `traceroute` | The routers on the way |
|
||||||
|
| `tls` | A certificate: who for, who by, until when, and whether *this device* trusts it |
|
||||||
|
| `ntp` | A time server's clock against this one |
|
||||||
|
| `ifconfig` | The interfaces, and **which one is the default route** |
|
||||||
|
| `arp` | The neighbours on the Wi-Fi |
|
||||||
|
| `netstat` | What the device itself listens on |
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
They run in the Shell and over both consoles. The slow ones each get a task of their own and print as they go; `cancel` stops one.
|
||||||
|
|
||||||
|
{{ figure(src="shell.png", alt="The Shell at 2x with VPN in the Status Bar: ping: 1 4 ms, 2 7 ms, 3 4 ms, then ping: 3 sent, 3 back, 0% lost, 4/5/7 with ms wrapped onto the next line; then nslookup roro9stack.net, 10.9.0.1 answered in 6 ms, 65.21.233.110.", width=480, height=270, caption="The first build, in the Shell. The ping summary is one character too long for the screen and drops its `ms` onto a line of its own. It reads `3/3 back, 0% lost, 4/5/7 ms` now.") }}
|
||||||
|
|
||||||
|
Three things I like about them.
|
||||||
|
|
||||||
|
**`nslookup` asks the server itself.** The system's resolver gives you an address and nothing else. This sends its own question over UDP, so it can say *which* server answered and how long it took, and it can ask a different server to compare. When a name doesn't resolve, that's the whole diagnosis.
|
||||||
|
|
||||||
|
**`tls` checks nothing, then checks everything.** IRC, Gemini and updates all fail with "TLS error" and no way to see why. `tls` makes a handshake that accepts any certificate, so that a bad one can be looked at, and then judges it itself against the roots this device actually trusts:
|
||||||
|
|
||||||
|
{% code(caption="Four servers, four verdicts.") %}
|
||||||
|
```
|
||||||
|
tls: for git.twis.la, by YE2
|
||||||
|
tls: valid 2026-09-15 to 2026-12-14, 67 days left
|
||||||
|
tls: this device trusts it
|
||||||
|
|
||||||
|
tls: for geminiprotocol.net, by geminiprotocol.net
|
||||||
|
tls: NOT trusted here: not signed by a root this device has
|
||||||
|
|
||||||
|
tls: valid 2015-04-09 to 2015-04-12, EXPIRED 4197 days ago
|
||||||
|
tls: NOT trusted here: expired, not signed by a root this device has
|
||||||
|
|
||||||
|
tls: for *.badssl.com, by YR1
|
||||||
|
tls: NOT trusted here: not for that name
|
||||||
|
```
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
The second one is fine, by the way: a Gemini capsule signs its own certificate, and the browser trusts it on first sight.
|
||||||
|
|
||||||
|
**A ping with a size finds a tunnel's MTU.** This is the one I didn't expect to use within the hour:
|
||||||
|
|
||||||
|
{% code(caption="Through my tunnel. 1392 bytes plus 28 of headers is 1420, WireGuard's usual.") %}
|
||||||
|
```
|
||||||
|
$ ping 9.9.9.9 2 1392
|
||||||
|
ping: 2/2 back, 0% lost, 27/30/33 ms
|
||||||
|
$ ping 9.9.9.9 2 1393
|
||||||
|
ping: 0/2 back, 100% lost
|
||||||
|
```
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
One byte. And `ifconfig` on the same device, same minute:
|
||||||
|
|
||||||
|
{% code() %}
|
||||||
|
```
|
||||||
|
ifconfig: vpn 10.9.0.2/32 mtu 1420, up, default route
|
||||||
|
ifconfig: wifi 172.16.42.25/21 gw 172.16.42.1 mtu 1500, up
|
||||||
|
ifconfig: dns 10.9.0.1
|
||||||
|
```
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
"default route" on the `vpn` line is the answer to the first question anybody has with a VPN up.
|
||||||
|
|
||||||
|
## All of them, on the device
|
||||||
|
|
||||||
|
Typed on the Cardputer's own keyboard, in the Shell, with the tunnel up. `VPN` in the Status Bar is the tunnel; everything below went through it.
|
||||||
|
|
||||||
|
{{ figure(src="ifconfig.png", alt="The Shell: ifconfig prints vpn 10.9.0.2/32 mtu 1420, up, default route; wifi 172.16.42.25/21 gw 172.16.42.1 mtu 1500, up; dns 10.9.0.1.", width=480, height=270, caption="`ifconfig`. The first line answers the first question: with the tunnel up, everything leaves through it.") }}
|
||||||
|
|
||||||
|
{{ figure(src="ping.png", alt="The Shell: ping 9.9.9.9 3 prints 9.9.9.9, 56 bytes, then 1 25 ms, 2 25 ms, 3 33 ms, and 3/3 back, 0% lost, 25/27/33 ms.", width=480, height=270, caption="`ping`, with a count. The summary fits on one line now.") }}
|
||||||
|
|
||||||
|
{{ figure(src="nslookup.png", alt="The Shell: nslookup www.wikipedia.org prints 10.9.0.1 answered in 293 ms, www.wikipedia.org is dyna.wikimedia.org, and 185.15.59.224.", width=480, height=270, caption="`nslookup`. Which server answered, how long it took, the alias the name really is, and the address.") }}
|
||||||
|
|
||||||
|
{{ figure(src="port.png", alt="The Shell: port git.twis.la 443 prints 65.21.233.110:443 open, 48 ms.", width=480, height=270, caption="`port`. Open, in 48 ms. The other two answers are `refused` and nothing at all for five seconds.") }}
|
||||||
|
|
||||||
|
{{ figure(src="tls.png", alt="The Shell after tls git.twis.la: for git.twis.la, by YE2; valid 2026-09-15 to 2026-12-14, 67 days left; this device trusts it; sha256 and 64 hexadecimal digits over two lines.", width=480, height=270, caption="`tls`. The verdict, and the fingerprint that a Gemini pin is made of. The first line scrolled off: the handshake took 0.7 s.") }}
|
||||||
|
|
||||||
|
{{ figure(src="ntp.png", alt="The Shell: ntp prints pool.ntp.org (162.159.200.123), stratum 3, 23 ms away, and this clock is right, to 0.1 s.", width=480, height=270, caption="`ntp`. The clock gates TLS and the VPN, so it's worth being able to ask.") }}
|
||||||
|
|
||||||
|
{{ figure(src="netstat.png", alt="The Shell: netstat prints tcp 2323 listens (Debug Console), tcp 3232 listens (updates), tcp 172.16.42.25:2323 - 172.16.42.249:51552, udp 49974, udp 49973, udp 68 (DHCP).", width=480, height=270, caption="`netstat`. What the device offers the network right now. The one connection is the Debug Console session that pressed these keys.") }}
|
||||||
|
|
||||||
|
No picture of `traceroute` or `arp`: the first would list my provider's routers and the second my machines' hardware addresses, and neither belongs on a website. The traceroute through the tunnel was nine hops, my own server first.
|
||||||
|
|
||||||
|
## The same mistake, twice, in three hours
|
||||||
|
|
||||||
|
For the file sharing, the testable half of the code went in a file called `web_share.h`, and the service that uses it in another file called `web_share.h`, in another folder. One included the other by name and got itself. I wrote that up in [the last post](/devlog/roro9stack-share/) as one of the two things that went wrong.
|
||||||
|
|
||||||
|
For the network commands, the testable half went in `net_tools.h`, and the service in `net_tools.h`.
|
||||||
|
|
||||||
|
Same error message. Same fix. I'd like to say the second time took less long to spot.
|
||||||
|
|
||||||
|
Two smaller ones:
|
||||||
|
|
||||||
|
- **A refused connection isn't called refused.** lwIP reports it as "reset". My first `port` command told me a port on my own PC had "no route to it".
|
||||||
|
- **My test tool lied about concurrency.** The script that sends commands waits "until the console is quiet". A ping prints every second, so the console was never quiet, so my "second command while a ping runs" was sent after the ping had finished. It ran, and I briefly believed the one-at-a-time guard didn't work.
|
||||||
|
|
||||||
|
## What I didn't check
|
||||||
|
|
||||||
|
- **From far away.** My real server answered, but the device was on the server's own network, reaching it by its public name.
|
||||||
|
- **Roaming** from one Wi-Fi to another with the tunnel wanted.
|
||||||
|
- **IRC through the tunnel.**
|
||||||
|
- `tls` with IRC connected, when there shouldn't be the memory and it should say so.
|
||||||
|
- The network commands **without** the VPN: every test went through the tunnel or to the local network.
|
||||||
|
|
||||||
|
## By the numbers
|
||||||
|
|
||||||
|
{% table() %}
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Releases | 3 |
|
||||||
|
| Heap a WireGuard tunnel costs | 1.8 KB |
|
||||||
|
| Flash it costs | 63 KB |
|
||||||
|
| Calls into the library before the device stopped | 1 |
|
||||||
|
| Lines to fix that | 3 |
|
||||||
|
| Ways lwIP can route a packet | 2 |
|
||||||
|
| Releases that shipped with the docs behind | 6 |
|
||||||
|
| How-tos written in one sitting to catch up | 3 |
|
||||||
|
| Network commands | 9 |
|
||||||
|
| Flash they cost | 20 KB |
|
||||||
|
| Bytes between a ping that crosses my tunnel and one that doesn't | 1 |
|
||||||
|
| Times I gave two headers the same name | 2 |
|
||||||
|
| Host tests | 546 |
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
## Where it stands
|
||||||
|
|
||||||
|
{% steps() %}
|
||||||
|
1. ~~M0 and M1: the skeleton, Wi-Fi, IRC, Wi-Fi Tools.~~ v0.1.0 to v0.2.1, [the first post](/devlog/roro9stack/).
|
||||||
|
|
||||||
|
2. ~~Updates and debugging over the air.~~ v0.3.0, [Look, no cables](/devlog/roro9stack-ota/).
|
||||||
|
|
||||||
|
3. ~~M2: GNSS.~~ v0.4.0, [Seventeen satellites](/devlog/roro9stack-gnss/).
|
||||||
|
|
||||||
|
4. ~~G1: Gemini.~~ v0.5.0, [A browser in the RAM IRC left over](/devlog/roro9stack-gemini/).
|
||||||
|
|
||||||
|
5. ~~M3: the LoRa radio, listening.~~ v0.6.0, [The loudest thing it hears is itself](/devlog/roro9stack-lora/).
|
||||||
|
|
||||||
|
6. ~~S1: the card, fixed addresses, the System App.~~ v0.6.1 to v0.8.1, [One byte too early](/devlog/roro9stack-s1/).
|
||||||
|
|
||||||
|
7. ~~F1 and the start of R1: files, notes, signed releases, updates from Gitea.~~ v0.9.0 to v0.11.0, [836 bytes](/devlog/roro9stack-f1-r1/).
|
||||||
|
|
||||||
|
8. ~~W1: the website, and one firmware with the Debug Console in it.~~ v0.12.0, [It was off](/devlog/roro9stack-console/).
|
||||||
|
|
||||||
|
9. ~~A help key, the Shell, notes of any size.~~ v0.13.0 to v0.15.0, [It said "No"](/devlog/roro9stack-shell/).
|
||||||
|
|
||||||
|
10. ~~Pictures, a screenshot key, the card in a phone's browser.~~ v0.16.0 to v0.18.0, [Press w](/devlog/roro9stack-share/).
|
||||||
|
|
||||||
|
11. ~~A WireGuard tunnel, and the documentation caught up.~~ v0.19.0, this post.
|
||||||
|
|
||||||
|
12. ~~Nine commands for a bad network day.~~ v0.20.0 and v0.21.0, this post.
|
||||||
|
|
||||||
|
13. Next: M4, the mesh, which still wants a second node. And maybe SSH, now that there is a tunnel to reach things through.
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
{% signoff() %}
|
||||||
|
The VPN took a library, a lock and an evening. The documentation took a look. I'd spent a day shipping features to a website that described yesterday's firmware, with every pull request looking complete because its own page was there. The tunnel carries exactly 1420 bytes, and I know that because the device told me.
|
||||||
|
{% end %}
|
||||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -12,7 +12,7 @@ Press **<kbd>Fn</kbd> + <kbd>h</kbd>**, on any screen: it lists the keys that wo
|
|||||||
|
|
||||||
## Can I send messages over the mesh?
|
## Can I send messages over the mesh?
|
||||||
|
|
||||||
**Not yet.** The LoRa Scanner **listens** to Meshtastic traffic and shows what it hears, but nothing is transmitted. A mesh messenger is the goal and is planned in two milestones, one for receiving and one for transmitting; it waits for a second node to test against.
|
**Not yet.** The LoRa Scanner **listens** to Meshtastic or MeshCore traffic and shows what it hears, but nothing is transmitted. A mesh messenger is the goal and is planned in two milestones, one for receiving and one for transmitting; it waits for a second node to test against.
|
||||||
|
|
||||||
## Is this Meshtastic?
|
## Is this Meshtastic?
|
||||||
|
|
||||||
@@ -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,9 @@ 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.
|
||||||
|
|
||||||
**This website** sets no cookies and has no analytics, loads nothing from other sites, and its server logs keep only a masked part of visitors' addresses. The Install page asks the project's own server for the latest release.
|
**This website** sets no cookies and has no analytics, loads nothing from other sites, and its server logs keep only a masked part of visitors' addresses. The Install page asks the project's own server for the latest release.
|
||||||
|
|
||||||
@@ -59,6 +61,26 @@ Yes: it is [open source](https://git.twis.la/twisla/roro9stack) (GPL-3.0). The d
|
|||||||
|
|
||||||
A secure connection takes about 52 KB of the 107 KB the device has, and IRC's takes about 40 KB. Both together do not always fit. See [When a connection says "not enough memory"](/howto/not-enough-memory/).
|
A secure connection takes about 52 KB of the 107 KB the device has, and IRC's takes about 40 KB. Both together do not always fit. See [When a connection says "not enough memory"](/howto/not-enough-memory/).
|
||||||
|
|
||||||
|
## Can it use a VPN?
|
||||||
|
|
||||||
|
Yes, WireGuard: one tunnel to one server, set up by copying the client's `.conf` to the SD card and importing it in Settings. It can carry everything, or just the VPN's own subnet. See [VPN](/guide/vpn/).
|
||||||
|
|
||||||
|
## Can it log in to my server?
|
||||||
|
|
||||||
|
Yes, over SSH: the [SSH App](/guide/ssh/) is a terminal on another machine, with a password or with a key the device makes for itself. `vim`, `top` and `less` work. One session at a time, and not at the same time as IRC: there isn't the memory for both. `scp` in the [Shell](/guide/shell/) copies a file between the card and a server.
|
||||||
|
|
||||||
|
## How do I copy files to and from my phone?
|
||||||
|
|
||||||
|
In the Storage App, press <kbd>w</kbd>: the device serves a small web page to any browser on the same Wi-Fi. Scan the QR code it shows, type the code, and upload or download. Nothing to install. See [From a phone](/guide/storage/#from-a-phone).
|
||||||
|
|
||||||
|
## How do I take a screenshot?
|
||||||
|
|
||||||
|
<kbd>Fn</kbd> + <kbd>p</kbd>, on any screen. The picture goes to `/screenshots` on the SD card. See [Take a screenshot](/howto/screenshot/).
|
||||||
|
|
||||||
|
## The network doesn't work: how do I find out why?
|
||||||
|
|
||||||
|
From the device itself, in the Shell: `ifconfig`, `ping`, `nslookup`, `port` and `traceroute`. [When the network doesn't work](/howto/network-check/) puts them in order.
|
||||||
|
|
||||||
## Do I need an SD card?
|
## Do I need an SD card?
|
||||||
|
|
||||||
For the radio, GNSS position, Wi-Fi tools, IRC chat and Gemini browsing, no. For anything that is *kept*, yes: notes, IRC logs, Wi-Fi scan logs, GNSS Tracks, LoRa captures, saved Gemini pages and update files. See [Find your files on the SD card](/howto/sd-files/).
|
For the radio, GNSS position, Wi-Fi tools, IRC chat and Gemini browsing, no. For anything that is *kept*, yes: notes, IRC logs, Wi-Fi scan logs, GNSS Tracks, LoRa captures, saved Gemini pages and update files. See [Find your files on the SD card](/howto/sd-files/).
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ This guide says what the firmware does **today** and nothing else. Start with th
|
|||||||
|
|
||||||
**The mesh messenger is planned, not built.** The LoRa Scanner listens to Meshtastic traffic and shows it, but the device sends nothing yet: that is the next milestone and waits for a second node to test with.
|
**The mesh messenger is planned, not built.** The LoRa Scanner listens to Meshtastic traffic and shows it, but the device sends nothing yet: that is the next milestone and waits for a second node to test with.
|
||||||
|
|
||||||
|
**Three things work on every screen:** <kbd>Fn</kbd> + <kbd>h</kbd> for the keys, <kbd>Fn</kbd> + <kbd>p</kbd> for a screenshot, and <kbd>Fn</kbd> + <kbd>`</kbd> to go back to the Launcher.
|
||||||
|
|
||||||
|
**Two things keep running when you leave their App:** IRC stays connected, and an [SSH](/guide/ssh/) session stays open.
|
||||||
|
|
||||||
Looking for a recipe or a quick answer? There are [how-tos](/howto/) and a [FAQ](/faq/).
|
Looking for a recipe or a quick answer? There are [how-tos](/howto/) and a [FAQ](/faq/).
|
||||||
|
|
||||||
Something missing or wrong? Write to the [issue tracker](https://git.twis.la/twisla/roro9stack/issues) or to contact@roro9stack.net.
|
Something missing or wrong? Write to the [issue tracker](https://git.twis.la/twisla/roro9stack/issues) or to contact@roro9stack.net.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ description = "The keys, the Launcher, the Status Bar and what happens the first
|
|||||||
weight = 1
|
weight = 1
|
||||||
[extra]
|
[extra]
|
||||||
tag = "Start here"
|
tag = "Start here"
|
||||||
|
screens = ["help.png"]
|
||||||
+++
|
+++
|
||||||
|
|
||||||
## One key to remember
|
## One key to remember
|
||||||
@@ -35,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
|
||||||
|
|
||||||
@@ -48,7 +55,9 @@ A strip at the top of every screen: the name of the App on the left, and on the
|
|||||||
| `97%` | The battery (in the warning colour at 15% and under) |
|
| `97%` | The battery (in the warning colour at 15% and under) |
|
||||||
| `SD` | A card is in; the warning colour at 80% full |
|
| `SD` | A card is in; the warning colour at 80% full |
|
||||||
| 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 |
|
||||||
| `DBG` | The Debug Console is switched on (Settings → Debug Console); brighter while a PC is connected to it |
|
| `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 |
|
||||||
|
| `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 |
|
||||||
|
|||||||