Compare commits

..
14 Commits
Author SHA1 Message Date
twisla 86172c0342 Merge pull request 'VPN: a WireGuard tunnel (#8)' (#87) from vpn-wireguard into main
Site / build (push) Successful in 17s
CI / build (push) Successful in 2m59s
2026-10-07 23:52:04 +00:00
twislaandClaude Opus 5.5 e00fff670f Docs: caught up with v0.13.0 to v0.19.0
CI / build (pull_request) Successful in 1m33s
Site / build (pull_request) Successful in 15s
The documentation had kept up feature page by feature page and nowhere
else. Now also:

- the home page's App cards (notes of any size, pictures, sharing) and its
  note (the Shell, the VPN, the screenshot key);
- the guide: VPN in the Status Bar and in Settings, the three keys that work
  everywhere, screenshots of the Shell, a picture, sharing, the VPN page and
  the help panel;
- three how-tos: move files with your phone, set up the VPN, take a
  screenshot; where the files are and what things cost in memory;
- the FAQ: screenshots, and what listens on the network;
- the README's opening: what the firmware does today;
- the glossary: Tunnel, Sharing, Screenshot;
- every milestone's status line, with the version each thing shipped in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 01:49:47 +02:00
twislaandClaude Opus 5.5 4404dd9380 VPN: a WireGuard tunnel (#8)
The device joins a WireGuard network over whatever Wi-Fi it is on: one
peer, IPv4. A client's .conf is imported from the card (/vpn/wg0.conf) and
kept in the device's settings, private key included, never shown; Settings
offers to delete the file. A switch brings the tunnel up until the next
restart, "Start with Wi-Fi" every time; it waits for the clock, which a
handshake needs. VPN shows in the Status Bar.

The protocol is esphome/wireguard 0.4.8. It calls lwIP without lwIP's lock,
which this framework checks: every call into it is made with the lock held.

What goes through the tunnel is everything (AllowedIPs 0.0.0.0/0) or the
one subnet the device's tunnel address is in: lwIP routes by an
interface's subnet or by default, nothing finer. The import says how many
ranges it can't reach.

Checked against a test peer in both directions and against a real server,
with a configuration uploaded from a phone (docs/milestones/N1.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 01:49:47 +02:00
twisla f0306dd880 Merge pull request 'Storage: share the card with a phone's browser (#88)' (#89) from web-files into main
Site / build (push) Successful in 13s
CI / build (push) Successful in 2m45s
2026-10-07 22:37:11 +00:00
twislaandClaude Opus 5.5 e3fe618c7f Devlog: "Press w" is v0.18.0
CI / build (pull_request) Successful in 1m25s
Site / build (pull_request) Successful in 9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 00:35:15 +02:00
twislaandClaude Opus 5.5 d7092ee6d6 F1, devlog: the QR code scanned with a real phone (#88)
CI / build (pull_request) Successful in 1m31s
Site / build (pull_request) Successful in 9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 00:33:14 +02:00
twislaandClaude Opus 5.5 63c2da8138 Devlog: "Press w" with the phone's screenshot and a photo of the device (#88)
CI / build (pull_request) Successful in 1m32s
Site / build (pull_request) Successful in 15s
Both pictures were resized and saved again from their pixels alone: no
metadata is carried over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 00:30:50 +02:00
twislaandClaude Opus 5.5 057af773e4 Devlog: "Press w", the SD card in a phone's browser (#88)
CI / build (pull_request) Successful in 1m39s
Site / build (pull_request) Successful in 15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 00:26:43 +02:00
twislaandClaude Opus 5.5 6b02cd3d5f Storage: share the card with a phone's browser (#88)
CI / build (pull_request) Successful in 1m51s
Site / build (pull_request) Successful in 16s
`w` in the Storage App starts a small HTTP server and shows its address,
as a QR code and in letters, with a six-digit code. A browser on the same
network that has typed the code can list, download, upload, make folders
and delete, under the Storage App's rules. The server runs only while that
screen is open. Nothing is encrypted, and the screen says so.

Uploads are streamed to the card under a temporary name and renamed when
whole. Every access to the card is handed to the storage task, 8 KB at a
time, from the server's own task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 00:11:55 +02:00
twisla c35bc47693 Merge pull request 'Screenshots: Fn+p on every screen (#83)' (#86) from screenshot-key into main
Site / build (push) Successful in 13s
CI / build (push) Successful in 2m49s
2026-10-07 19:42:11 +00:00
twislaandClaude Opus 5.5 4cdb4c342f Screenshots: Fn+p on every screen (#83)
CI / build (pull_request) Successful in 1m44s
Site / build (pull_request) Successful in 9s
Fn+p saves the screen as it is to /screenshots, as the Shell's `screenshot`
does, from anywhere: text fields, dialogs and the help panel included. The
key never reaches an App.

It refuses on Settings > Debug Console, which shows the token: a picture of
that page is a copy of the token in a file (App::showsSecret). `key shot`
presses it over the consoles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 21:39:16 +02:00
twisla 1c9f90f92e Merge pull request 'Storage: view pictures, PNG, JPEG, BMP and GIF (#45)' (#85) from storage-images into main
Site / build (push) Successful in 20s
CI / build (push) Successful in 2m54s
2026-10-07 19:25:12 +00:00
twislaandClaude Opus 5.5 2c18762614 Storage: view pictures, PNG, JPEG, BMP and GIF (#45)
CI / build (pull_request) Successful in 1m56s
Site / build (pull_request) Successful in 10s
Enter on a picture shows it: shrunk to fit the screen, or at its own size
with Enter again and the arrows to move. Dithered to the screen's 256
colours; a colour the screen has exactly is left alone, so screenshots are
shown as they are.

The picture is decoded once, straight into the screen's buffer, and kept
there (App::retainsContent): no copy in memory. Decoding runs on the
storage task, so the keys keep working and a 12 megapixel photograph
appears as it comes instead of tripping the watchdog.

PNG, BMP and GIF are read by decoders of our own, host-tested against files
made by Pillow; the PNG one needs 32 KB where the display library's needed
44 KB in one block, which the device often doesn't have. JPEG uses the
library's TJpgDec.

Also corrects two sentences that still gave 16 KB as the editing limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 21:04:39 +02:00
twisla ae25cf0be2 Merge pull request 'Site: search over the documentation (#60)' (#84) from site-search into main
Site / build (push) Successful in 22s
2026-10-07 17:06:06 +00:00
94 changed files with 4789 additions and 54 deletions
+14
View File
@@ -110,6 +110,18 @@ 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.
_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
**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**:
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
@@ -188,6 +200,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.
- 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.
- 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**.
## Flagged ambiguities
+31 -3
View File
@@ -2,7 +2,23 @@
[![CI](https://git.twis.la/twisla/roro9stack/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.twis.la/twisla/roro9stack/actions?workflow=ci.yml) [![Coverage of lib/ by the host tests](https://git.twis.la/twisla/roro9stack/raw/branch/badges/coverage.svg)](#build-and-test-local-ci) [![Latest release](https://git.twis.la/twisla/roro9stack/raw/branch/badges/release.svg)](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 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.
- **System:** load, tasks, memory, network, battery, live.
- **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)
- Decisions: [docs/adr/](docs/adr/)
@@ -10,6 +26,8 @@ A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262*
## 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.
## Requirements
@@ -136,9 +154,12 @@ 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.
**`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:
- **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 (up to 16 KB, 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.
- **Captures** (`.pcap`): the packets as the LoRa Scanner lists them; Enter shows one with its Meshtastic header and bytes.
- **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.
@@ -156,6 +177,12 @@ 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.
## VPN
A WireGuard tunnel (docs/milestones/N1.md), over whatever Wi-Fi the device is on: one peer, IPv4. Copy a client's `.conf` to the card as `/vpn/wg0.conf` and import it in Settings > VPN (or `vpn import`); the configuration, private key included, is then kept in the device and never shown, and Settings offers to delete the file. A switch brings the tunnel up until the next restart; "Start with Wi-Fi" does it every time. It waits for the clock, which WireGuard needs. `VPN` shows in the Status Bar, bright once the server has answered.
**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
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.
@@ -167,7 +194,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
| Command | Effect |
|---|---|
| `burst` | Publishes 5 Notifications at once |
| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing). `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) |
| `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) |
@@ -214,6 +241,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
| `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 |
| `crash abort` / `crash wdt` | Crash on purpose, or hang the main loop until the watchdog fires |
| `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 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 |
+118 -1
View File
@@ -1,6 +1,6 @@
# 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).
@@ -216,3 +216,120 @@ Test notes were copied to `/notes` and removed afterwards; the note that was alr
**Not checked:** the power button's path (side file only), the screen turning off, a card pulled while editing, and memory with IRC connected, which wasn't connected for these checks: the editor's own use hasn't changed, and it still refuses to open without a free block of 24 KB. The real keyboard's Ctrl with Fn and the arrows. A file of tens of megabytes. Renaming or deleting a note from the Storage App leaves its side file behind.
**Measured against what was said:** the first build rewrote 1.2 MB in 3.5 to 4.5 s, with 2 KB blocks. With 4 KB blocks it is 2.6 s, about 450 KB a second, which is what the card gives a plain copy.
## Pictures in the Storage App (issue #45)
Q139 left images out: the firmware wrote none. Since the Shell's `screenshot` it does.
### Decisions (design round 2026-10-07)
| # | Decision |
|---|---|
| Q233 | **PNG, JPEG, BMP and GIF.** A GIF shows its first picture; it doesn't move. |
| Q234 | *Revised while building.* **Our own PNG decoder**, a row at a time, with the window the file's compression asks for: 32 KB at most. The plan was the display library's, which takes 44 KB in one block: after one picture the largest free block was 43 to 47 KB, and every second PNG was refused. **The firmware's own screenshots** are read with no decoding at all: they are stored uncompressed, each byte already a colour of the screen. |
| Q235 | **The picture is decoded once, straight into the screen's buffer, and left there.** No copy in memory (it would be up to 30 KB). `App::retainsContent()` tells the screen not to clear the App's part; `contentLost()` tells the App that it was cleared after all, or that a Toast or the help panel drawn over it has gone: then it is decoded again. |
| Q236 | **Shrunk to fit; Enter shows it at its own size**, the arrows then moving half a screen. A picture smaller than the screen sits in the middle at its size. |
| Q237 | **Ordered dithering** to the screen's 256 colours (a 4 x 4 pattern). A colour the screen has exactly comes out as itself wherever it lands, so a screenshot isn't touched. |
| Q238 | Shrinking takes, for each pixel of the screen, the first of the picture's that falls on it. No averaging: there is nowhere to keep the sums. Thin lines break up; a JPEG looks better, its decoder halving it up to three times first. |
| Q239 | **What can't be shown opens as hex, with the reason:** a progressive JPEG, an interlaced PNG, a BMP that is compressed or has 16 bits. The rotation a camera stores in the file is ignored. |
| Q240 | *Revised while building.* **Decoding runs on the storage task while the main loop goes on.** The picture appears as it comes, and anything that needs the screen back stops the decoding first. The plan was to wait for it, behind a "Decoding..." line: 12 megapixels took longer than the watchdog allows the main loop to stand still, and the device restarted. |
| Q241 | The Storage App: Enter on `.png`, `.jpg`, `.jpeg`, `.bmp`, `.gif`, or on a file with no known extension whose first bytes say what it is. Tab gives the hex. `i`, and opening, show the size in pixels on the last line for three seconds. |
| Q242 | Not in this one: animation, opening a picture from the Gemini App, a slideshow. |
### As built
- **`lib/files/src/image_file.h`** (host-tested): what a file is and how big, where each pixel lands (`ImageFrame`, `ImageMap`), the dithering, and readers for BMP and GIF that hand their pixels on as they get them. **`png_reader.h`**: the PNG decoder, with its own inflate: every colour type and bit depth, palettes with transparency. Transparent pixels are left as the background.
- **`ImagePane`** (`src/apps/image_pane`) is the view. One decoding is a `Job` shared with the storage task; `cancel()` flags it and waits behind it in the task's queue, which is how the screen is known to be free again.
- **JPEG is the one decoder that isn't ours:** the display library's TJpgDec, with its 3.9 KB pool. It shrinks by 2, 4 or 8 while decoding, which is why a photograph is possible at all.
- **A decoder stops early** once the rest of the file is below the screen (a picture at its own size), and a BMP's rows that aren't shown aren't read.
- **The note** on the last line is written over the picture; the strip under it (2.6 KB) is kept and put back, so showing it costs no decoding.
- **A BMP is read in the order its rows are stored**, last row first: reading it top to bottom meant going back through the file for every row, a second for 135 rows.
- **Cost:** 21 KB of flash. Nothing while no picture is shown.
### Measured on the device
| Picture | Fitted | Its own size |
|---|---|---|
| A screenshot of ours, 240 x 135 | 80 ms | 78 ms |
| PNG, 800 x 600 | 855 ms | 575 ms |
| PNG with transparency, 800 x 600 | 1,098 ms | |
| JPEG, 800 x 600 | 305 ms | |
| JPEG, 4000 x 3000 (2.6 MB) | 6.9 s | 7.7 s (the middle of it) |
| GIF, 800 x 600 | 642 ms | |
| BMP, 800 x 600 (1.4 MB) | 991 ms | |
| BMP, 240 x 135 | 124 ms | |
Free memory fell to 48.8 KB at the lowest while a PNG was decoded, from 104 KB. The storage task's stack: 3.2 KB never used, of 6.
### Host tests (20, `test/test_image_file` and `test/test_png_reader`)
The pictures in them were made with Pillow, so the readers are checked against an encoder that isn't ours: GIFs plain, interlaced, transparent and long enough for the codes to reach 12 bits; BMPs of 8 and 24 bits; PNGs of every kind (colour, with alpha, palette of 8 and 4 bits with a transparent entry, greys of 1, 8 and 16 bits, grey with alpha, not compressed, and one that refers 21,600 bytes back). Every reader is also fed its file with a byte changed, at every few bytes: an answer each time, and no pixel outside the picture.
### Checks on the device (2026-10-07, driven over the Debug Console)
Test pictures were copied to a scratch folder and removed afterwards, with the test screenshot.
| Check | Result |
|---|---|
| Colour bars as PNG, JPEG, BMP and GIF, 240 x 135 and 800 x 600 | The same picture each time, the colours in the right order |
| A PNG with a transparent square | The square is the background |
| A screenshot taken in the Shell | Shown; at its own size it is the screen, pixel for pixel |
| A progressive JPEG | Hex, with "A progressive JPEG can't be shown" |
| An animated GIF | Its first picture |
| 12 megapixels | Arrives from the top down in 6.9 s; the size is noted when it is whole |
| Enter, then the arrows | Its own size from the middle, then half a screen at a time |
| Back in the middle of a decoding | The folder's listing at once |
| Tab to hex and back, three times; the help panel, then closed | The picture again each time |
**Not checked:** a photograph from a real camera (the test JPEGs were made by Pillow); a Toast over a picture; a PNG with IRC connected, when there may not be the memory; the card pulled while decoding; the real keyboard.
### What went wrong while building it
**The watchdog.** The first version waited for the decoder. The main loop is watched: five seconds without a pass and the device restarts, which is what it did on the 12-megapixel test. The crash report named the decoder's line. The decoding moved to the background, which also made the picture appear as it comes.
**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.
## 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).
+83
View File
@@ -0,0 +1,83 @@
# N1 — Network tools
**Status:** in progress. The WireGuard tunnel (issue #8) shipped as **v0.19.0**. SSH (#2) is not started.
**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 uploaded from a phone through the Storage App's sharing (issue #88) 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.
+1 -1
View File
@@ -1,6 +1,6 @@
# 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.
+1 -1
View File
@@ -1,6 +1,6 @@
# 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.
+16 -1
View File
@@ -1,6 +1,6 @@
# 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**). Not started: screen recording (#17), a Launcher of tiles (#9), themes (#10).
**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.
@@ -44,3 +44,18 @@ The lists first lived in each App's `help()`, as code. They are now **data, in o
Three rows lost their second wording on the way, since a table is constant: GNSS's `Tab` and `r`, and the Scanner's `c`, now say both things they do ("record a Track, or stop it") instead of the one that applies. The guide pages keep their written tables too, where they say more than a key list can; those can still drift, and the generated ones under them are the reference.
**Not checked:** the real Fn+h and `?` on the keyboard (the mapper is host-tested; the device was driven with `key help`); the Setup screens, which only a device that was never set up shows, so their new text has not been seen on a screen; and the states that need something to happen first (a dialog, a copy in progress, a Gemini prompt, a packet's details): their lists were read against the key handling, not looked at.
## The screenshot key (issue #83)
A screenshot could only be taken by typing `screenshot` in the Shell, where "now" is a picture of the Shell.
- **Fn+p, on every screen**, text fields included, saves the screen as it is (a dialog, the help panel or a Toast if one is showing) as a PNG in `/screenshots`, the way the Shell's command does. A Toast says so once the file is written, so it is never in the picture.
- **It never reaches an App.** `Key::Screenshot` comes out of the key mapper and is handled before the App manager, so the help panel stays open and a dialog keeps its selection.
- **Not on Settings > Debug Console:** that page shows the token, and a picture of it is a copy of the token in a file. `App::showsSecret()` says so, and the key answers with a Toast instead.
- **No card:** a Toast says so.
- No setting to switch it off: Fn with a letter isn't pressed by accident.
- It is in the "Everywhere" group of the help panel, and so in the website's key tables. `key shot` presses it over the consoles.
**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.
+1 -1
View File
@@ -1,6 +1,6 @@
# 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.
+2
View File
@@ -24,6 +24,7 @@ const RowDef kRows[] = {
{Row::Coordinates, Kind::Toggle, "Coordinates"},
{Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"},
{Row::CheckUpdates, Kind::Toggle, "Check for updates"}, {Row::Firmware, Kind::Page, "Firmware"},
{Row::Vpn, Kind::Page, "VPN"},
{Row::DebugConsole, Kind::Page, "Debug Console"}, {Row::About, Kind::Page, "About"},
};
@@ -86,6 +87,7 @@ std::string SettingsMenu::value(int i) const {
case Row::Coordinates: return settings_.getBool(Setting::CoordinatesDms) ? "Deg min sec" : "Decimal";
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
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";
default: return "";
}
+1 -1
View File
@@ -11,7 +11,7 @@ namespace roro {
// values, choice lists and validation messages. Rendering and navigation live in the App.
class SettingsMenu {
public:
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, DebugConsole, About };
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About };
enum class Kind { Text, Choice, Toggle, Slider, Page };
explicit SettingsMenu(Settings& settings) : settings_(settings) {}
+11
View File
@@ -39,6 +39,17 @@ class App {
virtual void draw(Canvas& canvas) = 0;
// True while the screen shows something a picture of it shouldn't hold: the screenshot key
// (Fn+p, issue #83) then refuses, and says so.
virtual bool showsSecret() const { return false; }
// An App whose screen is costly to draw again (a picture decoded from the card, issue #45) can
// keep what it drew: while this is true its part of the screen isn't cleared before draw(),
// which then draws only what changed. contentLost() says that it was cleared after all, or
// that something drawn over it has gone: everything has to be drawn again.
virtual bool retainsContent() const { return false; }
virtual void contentLost() {}
void requestRedraw() { redraw_ = true; }
bool consumeRedraw() {
+21
View File
@@ -27,6 +27,7 @@ inline constexpr KeyHelp kEverywhere[] = {
{"Fn `", "home, the Launcher"},
{"; . , /", "arrows (Fn+ while typing)"},
{"Fn h ?", "these keys (? not typing)"},
{"Fn p", "a screenshot, on the card"},
};
// dialog: A question
@@ -222,9 +223,15 @@ inline constexpr KeyHelp kStorage[] = {
{"i", "details: size, date, type"},
{"s", "sort: name, date, size"},
{"m", "Maintenance: clean-up, erase"},
{"w", "share with a browser"},
{"`", "the folder above"},
};
// storage-share: Storage, sharing with a browser
inline constexpr KeyHelp kStorageShare[] = {
{"`", "stop sharing"},
};
// storage-details: Storage, an item's details
inline constexpr KeyHelp kStorageDetails[] = {
{"; .", "scroll"},
@@ -292,6 +299,20 @@ inline constexpr KeyHelp kViewerOta[] = {
{"Tab", "the file as hex"},
};
// viewer-image: A picture
inline constexpr KeyHelp kViewerImage[] = {
{"Enter", "its own size, or all of it"},
{"; . , /", "move around it"},
{"i", "its size"},
{"Tab", "the file as hex"},
};
// vpn: Settings, VPN
inline constexpr KeyHelp kVpn[] = {
{"Enter", "switch, import, forget"},
{"; .", "up, down"},
};
// notes: Notes, the list
inline constexpr KeyHelp kNotes[] = {
{"; .", "up, down"},
+1
View File
@@ -17,6 +17,7 @@ enum class Key : uint8_t {
Tab,
Delete,
Help, // Fn+h anywhere, or ? outside Text Entry: the keys of this screen (issue #69)
Screenshot, // Fn+p anywhere: the screen as a PNG on the card (issue #83). Never reaches an App
};
struct KeyEvent {
+464
View File
@@ -0,0 +1,464 @@
#include "image_file.h"
#include <algorithm>
#include <cstring>
#include <memory>
#include <new>
#include "file_names.h"
#include "png_rgb332.h"
namespace roro::files {
namespace {
uint32_t le16(const uint8_t* p) { return p[0] | (p[1] << 8); }
uint32_t le32(const uint8_t* p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (static_cast<uint32_t>(p[3]) << 24); }
uint32_t be16(const uint8_t* p) { return (p[0] << 8) | p[1]; }
uint32_t be32(const uint8_t* p) { return (static_cast<uint32_t>(p[0]) << 24) | (p[1] << 16) | (p[2] << 8) | p[3]; }
constexpr int kMaxSide = 16384; // more than that isn't a picture for this screen
const uint8_t kPngSignature[] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
// A file read from its start on, a small block at a time.
class Stream {
public:
Stream(const ImageRead& read, uint32_t size) : read_(read), size_(size) {}
int get() {
if (at_ >= have_) {
if (next_ >= size_) return -1;
have_ = read_(next_, buf_, std::min<size_t>(sizeof buf_, size_ - next_));
at_ = 0;
next_ += static_cast<uint32_t>(have_);
if (!have_) return -1;
}
return buf_[at_++];
}
bool take(uint8_t* into, size_t len) {
for (size_t i = 0; i < len; i++) {
int c = get();
if (c < 0) return false;
into[i] = static_cast<uint8_t>(c);
}
return true;
}
bool skip(size_t len) {
size_t buffered = std::min(len, have_ - at_);
at_ += buffered;
len -= buffered;
if (len > size_ - next_) return false;
next_ += static_cast<uint32_t>(len);
return true;
}
private:
const ImageRead& read_;
uint32_t size_, next_ = 0;
uint8_t buf_[256];
size_t have_ = 0, at_ = 0;
};
} // namespace
ImageKind imageKindOfName(const std::string& name) {
std::string ext = extensionOf(name);
if (ext == "png") return ImageKind::Png;
if (ext == "jpg" || ext == "jpeg") return ImageKind::Jpeg;
if (ext == "bmp") return ImageKind::Bmp;
if (ext == "gif") return ImageKind::Gif;
return ImageKind::None;
}
ImageKind imageKindOfBytes(const uint8_t* head, size_t len) {
if (len >= 8 && std::memcmp(head, kPngSignature, 8) == 0) return ImageKind::Png;
if (len >= 3 && head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF) return ImageKind::Jpeg;
if (len >= 6 && (std::memcmp(head, "GIF87a", 6) == 0 || std::memcmp(head, "GIF89a", 6) == 0)) return ImageKind::Gif;
if (len >= 2 && head[0] == 'B' && head[1] == 'M') return ImageKind::Bmp;
return ImageKind::None;
}
const char* imageKindName(ImageKind kind) {
switch (kind) {
case ImageKind::Png: return "PNG";
case ImageKind::Jpeg: return "JPEG";
case ImageKind::Bmp: return "BMP";
case ImageKind::Gif: return "GIF";
default: return "";
}
}
std::string imageInfo(const ImageRead& read, uint32_t size, ImageInfo& out) {
uint8_t head[32];
size_t n = read(0, head, std::min<size_t>(sizeof head, size));
out.kind = imageKindOfBytes(head, n);
long w = 0, h = 0;
switch (out.kind) {
case ImageKind::Png:
if (n < 24 || std::memcmp(head + 12, "IHDR", 4) != 0) return "This PNG is damaged";
w = static_cast<long>(be32(head + 16));
h = static_cast<long>(be32(head + 20));
break;
case ImageKind::Gif:
if (n < 10) return "This GIF is damaged";
w = static_cast<long>(le16(head + 6));
h = static_cast<long>(le16(head + 8));
break;
case ImageKind::Bmp: {
if (n < 26) return "This BMP is damaged";
uint32_t dib = le32(head + 14);
if (dib < 40) return "This kind of BMP can't be shown";
w = static_cast<int32_t>(le32(head + 18));
h = static_cast<int32_t>(le32(head + 22));
if (h < 0) h = -h; // top row first
break;
}
case ImageKind::Jpeg: {
// Marker after marker until the one that carries the size.
uint32_t at = 2;
for (int guard = 0; guard < 4000; guard++) {
uint8_t m[9];
if (read(at, m, 4) != 4 || m[0] != 0xFF) return "This JPEG is damaged";
uint8_t marker = m[1];
if (marker == 0xFF) { // padding
at++;
continue;
}
if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD8)) { // no length
at += 2;
continue;
}
if (marker == 0xD9 || marker == 0xDA) return "This JPEG is damaged"; // the picture, and no size yet
bool frame = marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC;
if (frame) {
if (read(at, m, 9) != 9) return "This JPEG is damaged";
h = static_cast<long>(be16(m + 5));
w = static_cast<long>(be16(m + 7));
if (marker == 0xC2) return "A progressive JPEG can't be shown";
if (marker != 0xC0 && marker != 0xC1) return "This kind of JPEG can't be shown";
break;
}
at += 2 + be16(m + 2);
}
break;
}
default: return "Not a picture this can show";
}
if (w <= 0 || h <= 0) return "This picture is damaged";
if (w > kMaxSide || h > kMaxSide) return "Too big: 16,384 pixels a side at most";
out.width = static_cast<int>(w);
out.height = static_cast<int>(h);
return "";
}
uint32_t screenshotPixelsAt(const ImageRead& read, uint32_t size, int width, int height) {
if (width <= 0 || height <= 0 || size != png::Rgb332Writer::fileSize(width, height)) return 0;
// Signature, IHDR, then a palette of 256 colours, then the one IDAT: a zlib header and a
// single stored block.
uint8_t ihdr[5], plte[8], idat[15];
constexpr uint32_t kPlteAt = 8 + 25, kIdatAt = kPlteAt + 12 + 768;
if (read(24, ihdr, 5) != 5 || ihdr[0] != 8 || ihdr[1] != 3 || ihdr[4] != 0) return 0;
if (read(kPlteAt, plte, 8) != 8 || be32(plte) != 768 || std::memcmp(plte + 4, "PLTE", 4) != 0) return 0;
if (read(kIdatAt, idat, 15) != 15 || std::memcmp(idat + 4, "IDAT", 4) != 0) return 0;
uint32_t raw = static_cast<uint32_t>(width + 1) * static_cast<uint32_t>(height);
if (idat[8] != 0x78 || idat[10] != 0x01 || le16(idat + 11) != raw || le16(idat + 13) != (raw ^ 0xFFFF)) return 0;
return kIdatAt + 15 + 1; // past the first row's filter byte
}
uint8_t rgb332Dithered(uint8_t r, uint8_t g, uint8_t b, int x, int y) {
static const uint8_t kBayer[16] = {0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5};
int threshold = kBayer[((y & 3) << 2) | (x & 3)] * 16 + 8; // 8 to 248
auto level = [threshold](int v, int top) {
int nearest = (v * top + 127) / 255;
if (nearest * 255 / top == v) return nearest; // a colour the screen has
int low = v * top / 255, rest = v * top - low * 255;
return rest > threshold ? low + 1 : low;
};
return static_cast<uint8_t>((level(r, 7) << 5) | (level(g, 7) << 2) | level(b, 3));
}
bool ImageMap::at(int sx, int sy, int& tx, int& ty) const {
if (sx < 0 || sy < 0) return false;
if (scale >= 65536) {
tx = sx + offX;
ty = sy + offY;
} else {
uint64_t fx = static_cast<uint64_t>(sx) * scale, fy = static_cast<uint64_t>(sy) * scale;
if ((fx & 0xFFFF) >= scale || (fy & 0xFFFF) >= scale) return false;
tx = static_cast<int>(fx >> 16) + offX;
ty = static_cast<int>(fy >> 16) + offY;
}
if (tx < 0 || ty < 0 || tx >= viewW || ty >= viewH) return false;
tx += viewX;
ty += viewY;
return true;
}
bool ImageMap::rowUsed(int sy) const {
if (sy < 0) return false;
int ty;
if (scale >= 65536) {
ty = sy + offY;
} else {
uint64_t fy = static_cast<uint64_t>(sy) * scale;
if ((fy & 0xFFFF) >= scale) return false;
ty = static_cast<int>(fy >> 16) + offY;
}
return ty >= 0 && ty < viewH;
}
bool ImageMap::below(int sy) const {
if (sy < 0) return false;
int ty = scale >= 65536 ? sy + offY : static_cast<int>((static_cast<uint64_t>(sy) * scale) >> 16) + offY;
return ty >= viewH;
}
ImageFrame::ImageFrame(int width, int height, int viewX, int viewY, int viewW, int viewH)
: w_(std::max(1, width)), h_(std::max(1, height)), vx_(viewX), vy_(viewY), vw_(std::max(1, viewW)), vh_(std::max(1, viewH)) {}
uint32_t ImageFrame::fitScale() const {
uint64_t sx = (static_cast<uint64_t>(vw_) << 16) / static_cast<uint64_t>(w_), sy = (static_cast<uint64_t>(vh_) << 16) / static_cast<uint64_t>(h_);
return static_cast<uint32_t>(std::min<uint64_t>(65536, std::max<uint64_t>(1, std::min(sx, sy))));
}
void ImageFrame::toggle() {
if (!bigger()) return;
actual_ = !actual_;
if (actual_) { // the middle of it first
panX_ = std::max(0, (w_ - vw_) / 2);
panY_ = std::max(0, (h_ - vh_) / 2);
}
}
bool ImageFrame::pan(int dx, int dy) {
if (!actual_) return false;
int x = std::clamp(panX_ + dx * (vw_ / 2), 0, std::max(0, w_ - vw_));
int y = std::clamp(panY_ + dy * (vh_ / 2), 0, std::max(0, h_ - vh_));
bool moved = x != panX_ || y != panY_;
panX_ = x;
panY_ = y;
return moved;
}
int ImageFrame::percent() const { return actual_ ? 100 : static_cast<int>((static_cast<uint64_t>(fitScale()) * 100 + 32768) >> 16); }
int ImageFrame::jpegShrink() const {
if (actual_) return 0;
uint32_t scale = fitScale();
int shrink = 0;
while (shrink < 3 && (static_cast<uint64_t>(scale) << (shrink + 1)) <= 65536) shrink++;
return shrink;
}
ImageMap ImageFrame::map(int shrink) const {
ImageMap m;
m.viewX = vx_;
m.viewY = vy_;
m.viewW = vw_;
m.viewH = vh_;
if (actual_) {
m.scale = 65536;
m.offX = w_ <= vw_ ? (vw_ - w_) / 2 : -panX_;
m.offY = h_ <= vh_ ? (vh_ - h_) / 2 : -panY_;
return m;
}
uint32_t scale = fitScale();
int tw = std::max<int>(1, static_cast<int>((static_cast<uint64_t>(w_) * scale) >> 16));
int th = std::max<int>(1, static_cast<int>((static_cast<uint64_t>(h_) * scale) >> 16));
m.offX = (vw_ - tw) / 2;
m.offY = (vh_ - th) / 2;
m.scale = static_cast<uint32_t>(std::min<uint64_t>(65536, static_cast<uint64_t>(scale) << shrink));
return m;
}
std::string readBmp(const ImageRead& read, uint32_t size, const ImagePixels& pixels, const std::function<bool(int y)>& rowNeeded) {
uint8_t head[54];
if (read(0, head, sizeof head) != sizeof head || head[0] != 'B' || head[1] != 'M') return "This BMP is damaged";
uint32_t dataAt = le32(head + 10), dib = le32(head + 14), compression = le32(head + 30), colours = le32(head + 46);
int32_t w = static_cast<int32_t>(le32(head + 18)), h = static_cast<int32_t>(le32(head + 22));
uint32_t bits = le16(head + 28);
bool topDown = h < 0;
if (topDown) h = -h;
if (dib < 40 || w <= 0 || h <= 0 || w > kMaxSide || h > kMaxSide) return "This kind of BMP can't be shown";
if ((bits != 8 && bits != 24 && bits != 32) || (compression != 0 && !(compression == 3 && bits == 32))) return "This kind of BMP can't be shown";
std::unique_ptr<uint8_t[]> palette;
if (bits == 8) {
if (!colours || colours > 256) colours = 256;
palette.reset(new (std::nothrow) uint8_t[1024]());
if (!palette) return "Not enough memory";
if (read(14 + dib, palette.get(), colours * 4) != colours * 4) return "This BMP is damaged";
}
uint32_t bytes = bits / 8, rowSize = (static_cast<uint32_t>(w) * bytes + 3) & ~3u;
if (static_cast<uint64_t>(dataAt) + static_cast<uint64_t>(rowSize) * static_cast<uint32_t>(h) > size) return "This BMP is cut short";
// A row in one read when it fits, a piece of it at a time otherwise.
constexpr int kOut = 64; // pixels handed on at once
constexpr uint32_t kRowBuffer = 4096; // bytes
uint32_t rowBytes = static_cast<uint32_t>(w) * bytes, bufSize = std::min(rowBytes, kRowBuffer);
bufSize -= bufSize % bytes;
std::unique_ptr<uint8_t[]> in(new (std::nothrow) uint8_t[bufSize]);
if (!in) return "Not enough memory";
uint8_t out[kOut * 3];
// In the order the file has them, which is usually the last row first: going back through a
// file on the card costs far more than going on (measured: a second for 135 rows).
for (int stored = 0; stored < h; stored++) {
int y = topDown ? stored : h - 1 - stored;
if (rowNeeded && !rowNeeded(y)) continue;
uint32_t rowAt = dataAt + rowSize * static_cast<uint32_t>(stored);
for (uint32_t done = 0; done < rowBytes; done += bufSize) {
size_t want = std::min(bufSize, rowBytes - done);
if (read(rowAt + done, in.get(), want) != want) return "The card refused to read it";
int first = static_cast<int>(done / bytes), count = static_cast<int>(want / bytes);
for (int at = 0; at < count; at += kOut) {
int n = std::min(kOut, count - at);
for (int i = 0; i < n; i++) {
const uint8_t* p = bits == 8 ? palette.get() + in[at + i] * 4 : in.get() + static_cast<size_t>(at + i) * bytes;
out[i * 3] = p[2]; // stored blue, green, red
out[i * 3 + 1] = p[1];
out[i * 3 + 2] = p[0];
}
pixels(first + at, y, n, out);
}
}
}
return "";
}
namespace {
struct GifWork {
uint8_t palette[768];
uint16_t prefix[4096];
uint8_t suffix[4096], stack[4096];
};
} // namespace
std::string readGif(const ImageRead& read, uint32_t size, const ImagePixels& pixels) {
Stream in(read, size);
uint8_t head[13];
if (!in.take(head, 13) || imageKindOfBytes(head, 6) != ImageKind::Gif) return "This GIF is damaged";
int screenW = static_cast<int>(le16(head + 6)), screenH = static_cast<int>(le16(head + 8));
std::unique_ptr<GifWork> work(new (std::nothrow) GifWork);
if (!work) return "Not enough memory to show a GIF";
std::memset(work->palette, 0, sizeof work->palette);
if (head[10] & 0x80 && !in.take(work->palette, 3u << ((head[10] & 7) + 1))) return "This GIF is damaged";
int transparent = -1;
for (int guard = 0; guard < 100000; guard++) {
int kind = in.get();
if (kind == 0x21) { // an extension: only the one before a picture matters, for its transparent colour
int label = in.get();
for (bool first = true;; first = false) {
int len = in.get();
if (len < 0) return "This GIF is damaged";
if (len == 0) break;
uint8_t block[255];
if (!in.take(block, static_cast<size_t>(len))) return "This GIF is damaged";
if (label == 0xF9 && first && len >= 4) transparent = (block[0] & 1) ? block[3] : -1;
}
continue;
}
if (kind != 0x2C) return kind == 0x3B ? "This GIF has no picture" : "This GIF is damaged";
break;
}
uint8_t desc[9];
if (!in.take(desc, 9)) return "This GIF is damaged";
int left = static_cast<int>(le16(desc)), top = static_cast<int>(le16(desc + 2));
int fw = static_cast<int>(le16(desc + 4)), fh = static_cast<int>(le16(desc + 6));
bool interlaced = desc[8] & 0x40;
if (desc[8] & 0x80 && !in.take(work->palette, 3u << ((desc[8] & 7) + 1))) return "This GIF is damaged";
int minBits = in.get();
if (fw <= 0 || fh <= 0 || minBits < 2 || minBits > 8) return "This GIF is damaged";
// The pixels come out in the order they are stored; an interlaced picture stores every eighth
// row first, then the rows between, in four passes.
static const int kStart[4] = {0, 4, 2, 1}, kStep[4] = {8, 8, 4, 2};
int px = 0, row = 0, pass = 0, rowsDone = 0;
uint8_t run[64 * 3];
int runLen = 0, runX = 0;
auto flush = [&]() {
int y = top + row;
if (runLen && y >= 0 && y < screenH) pixels(left + runX, y, runLen, run);
runLen = 0;
};
auto put = [&](uint8_t index) {
if (rowsDone >= fh) return;
int x = left + px;
if (index == transparent || x < 0 || x >= screenW) {
flush();
} else {
if (!runLen) runX = px;
std::memcpy(run + runLen * 3, work->palette + index * 3, 3);
if (++runLen == 64) flush();
}
if (++px < fw) return;
flush();
px = 0;
rowsDone++;
if (!interlaced) {
row++;
return;
}
row += kStep[pass];
while (row >= fh && pass < 3) row = kStart[++pass];
};
const int clear = 1 << minBits, stop = clear + 1;
int bits = minBits + 1, next = clear + 2, prev = -1, first = 0;
uint32_t hold = 0;
int held = 0, blockLeft = 0;
bool ended = false;
for (int i = 0; i < clear; i++) work->suffix[i] = static_cast<uint8_t>(i);
while (rowsDone < fh && !ended) {
while (held < bits) {
if (!blockLeft) {
blockLeft = in.get();
if (blockLeft <= 0) {
ended = true;
break;
}
}
int c = in.get();
if (c < 0) return "This GIF is cut short";
blockLeft--;
hold |= static_cast<uint32_t>(c) << held;
held += 8;
}
if (ended) break;
int code = static_cast<int>(hold & ((1u << bits) - 1));
hold >>= bits;
held -= bits;
if (code == clear) {
bits = minBits + 1;
next = clear + 2;
prev = -1;
continue;
}
if (code == stop) break;
if (prev < 0) {
if (code >= clear) return "This GIF is damaged";
put(static_cast<uint8_t>(code));
first = prev = code;
continue;
}
if (code > next) return "This GIF is damaged";
int sp = 0, walk = code;
if (code == next) { // the string being defined: the one before, and its own first pixel again
work->stack[sp++] = static_cast<uint8_t>(first);
walk = prev;
}
while (walk >= clear && sp < 4095) {
work->stack[sp++] = work->suffix[walk];
walk = work->prefix[walk];
}
if (walk >= clear) return "This GIF is damaged";
first = walk;
work->stack[sp++] = static_cast<uint8_t>(walk);
if (next < 4096) {
work->prefix[next] = static_cast<uint16_t>(prev);
work->suffix[next] = static_cast<uint8_t>(first);
next++;
if (next == (1 << bits) && bits < 12) bits++;
}
prev = code;
while (sp) put(work->stack[--sp]);
}
flush();
return rowsDone ? "" : "This GIF is damaged";
}
} // namespace roro::files
+86
View File
@@ -0,0 +1,86 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <functional>
#include <string>
// Pictures for the Storage App (issue #45, F1 Q233-Q242): what a file is and how big, where each
// of its pixels goes on the screen, and the readers for BMP and the first frame of a GIF. PNG is in
// png_reader.h; JPEG is decoded on the device by the display library's decoder.
// Nothing here holds a picture: every reader hands its pixels on as it gets them.
namespace roro::files {
enum class ImageKind : uint8_t { None, Png, Jpeg, Bmp, Gif };
// Reads up to `len` bytes at `offset`; returns how many it got.
using ImageRead = std::function<size_t(uint32_t offset, uint8_t* into, size_t len)>;
// `count` pixels of row `y` from column `x` on, three bytes each: red, green, blue.
using ImagePixels = std::function<void(int x, int y, int count, const uint8_t* rgb)>;
ImageKind imageKindOfName(const std::string& name); // by its extension
ImageKind imageKindOfBytes(const uint8_t* head, size_t len); // by its first bytes (8 are enough)
const char* imageKindName(ImageKind kind);
struct ImageInfo {
ImageKind kind = ImageKind::None;
int width = 0, height = 0;
};
// "" and `out` filled, or why the file can't be shown.
std::string imageInfo(const ImageRead& read, uint32_t size, ImageInfo& out);
// A PNG the firmware's `screenshot` wrote (png_rgb332.h): its pixels are not compressed and each
// is already a colour of the screen. Where the first row's pixels start, or 0 if it isn't one.
// Row y's pixels are at that offset + y * (width + 1).
uint32_t screenshotPixelsAt(const ImageRead& read, uint32_t size, int width, int height);
// A colour as the screen has it (RRRGGGBB), dithered by where it lands: the screen has 8 levels of
// red and green and 4 of blue, and a photograph bands without it. A colour the screen has exactly
// comes out as itself wherever it lands, so a screenshot isn't touched.
uint8_t rgb332Dithered(uint8_t r, uint8_t g, uint8_t b, int x, int y);
// From a picture's pixels to the screen's.
struct ImageMap {
int viewX = 0, viewY = 0, viewW = 0, viewH = 0; // the part of the screen the picture may use
int offX = 0, offY = 0; // the picture's corner in it (negative: scrolled)
uint32_t scale = 65536; // screen pixels for one of the picture's, 16.16; never over 1
// Where the picture's pixel lands. False if it's outside the view, or if another pixel is the
// one drawn there (shrunk, each screen pixel takes the first of the picture's that falls on it).
bool at(int sx, int sy, int& tx, int& ty) const;
bool rowUsed(int sy) const; // does any pixel of this row land?
bool below(int sy) const; // this row and every one after it land under the view: nothing more to draw
};
// How a picture is looked at: whole, shrunk to fit if it has to be; or at its own size, a
// screenful at a time.
class ImageFrame {
public:
ImageFrame() = default;
ImageFrame(int width, int height, int viewX, int viewY, int viewW, int viewH);
bool bigger() const { return w_ > vw_ || h_ > vh_; } // than the view: there is something to zoom
bool actual() const { return actual_; }
void toggle();
bool pan(int dx, int dy); // half a view a step, at its own size only; false: nothing moved
int percent() const; // of its own size, as shown
int jpegShrink() const; // 0 to 3: the halvings a JPEG decoder may do first, the picture still at least as big as shown
// For pixels counted after `shrink` halvings (a JPEG's), or the picture's own.
ImageMap map(int shrink = 0) const;
private:
uint32_t fitScale() const;
int w_ = 0, h_ = 0, vx_ = 0, vy_ = 0, vw_ = 1, vh_ = 1, panX_ = 0, panY_ = 0;
bool actual_ = false;
};
// A BMP: 8 bits with a palette, 24 or 32 bits, not compressed. `rowNeeded` lets rows be skipped
// without being read. "" or why it can't be shown.
std::string readBmp(const ImageRead& read, uint32_t size, const ImagePixels& pixels, const std::function<bool(int y)>& rowNeeded = nullptr);
// The first picture of a GIF, interlaced or not; transparent pixels are not handed on. It needs
// 17 KB while it runs. "" or why it can't be shown.
std::string readGif(const ImageRead& read, uint32_t size, const ImagePixels& pixels);
} // namespace roro::files
+354
View File
@@ -0,0 +1,354 @@
#include "png_reader.h"
#include <algorithm>
#include <cstring>
#include <memory>
#include <new>
namespace roro::files {
namespace {
uint32_t be32(const uint8_t* p) { return (static_cast<uint32_t>(p[0]) << 24) | (p[1] << 16) | (p[2] << 8) | p[3]; }
const char* const kDamaged = "This PNG is damaged";
const char* const kCut = "This PNG is cut short";
const char* const kNoMemory = "Not enough memory for this PNG";
// A Huffman code as its lengths say: how many codes of each length, and the symbols in order.
struct Huffman {
uint16_t count[16];
uint16_t symbol[288];
// False if the lengths don't make a code.
bool build(const uint8_t* lengths, int n) {
std::memset(count, 0, sizeof count);
for (int i = 0; i < n; i++) count[lengths[i]]++;
int left = 1;
for (int len = 1; len < 16; len++) {
left = (left << 1) - count[len];
if (left < 0) return false;
}
uint16_t offs[16];
offs[1] = 0;
for (int len = 1; len < 15; len++) offs[len + 1] = static_cast<uint16_t>(offs[len] + count[len]);
for (int i = 0; i < n; i++)
if (lengths[i]) symbol[offs[lengths[i]]++] = static_cast<uint16_t>(i);
return true;
}
};
// Everything one decoding holds, but the window and the two rows: on the heap, in one piece.
struct Work {
// The file, and the IDAT chunks as one stream of bytes.
const ImageRead* read = nullptr;
uint32_t size = 0, at = 0, chunkLeft = 0;
uint8_t in[256];
size_t inHave = 0, inAt = 0;
bool inEnd = false;
// Bits.
uint32_t hold = 0;
int held = 0;
// The picture.
int width = 0, height = 0, depth = 0, type = 0, channels = 0, bpp = 0;
uint32_t rowBytes = 0;
uint8_t palette[768], alpha[256];
bool hasAlpha = false;
// The window, the rows, and where the decoding is.
std::unique_ptr<uint8_t[]> window, rows;
uint32_t windowSize = 0, written = 0;
uint8_t *cur = nullptr, *prev = nullptr;
int64_t pos = -1; // in the row; -1: its filter byte comes next
int filter = 0, y = 0;
bool stop = false;
const char* problem = nullptr;
const ImagePixels* pixels = nullptr;
const std::function<bool(int)>* rowNeeded = nullptr;
const std::function<bool(int)>* enough = nullptr;
Huffman lengths, distances;
uint8_t codeLengths[320];
int byte() {
if (inAt >= inHave) {
while (!chunkLeft && !inEnd) { // the next IDAT, past this one's checksum
uint8_t head[12];
if (at + 12 > size || (*read)(at, head, 12) != 12) return inEnd = true, -1;
at += 4; // the checksum
if (std::memcmp(head + 8, "IDAT", 4) != 0) return inEnd = true, -1;
chunkLeft = be32(head + 4);
at += 8;
}
if (inEnd) return -1;
size_t want = std::min<size_t>(sizeof in, chunkLeft);
inHave = (*read)(at, in, want);
inAt = 0;
if (inHave != want) return inEnd = true, -1;
at += static_cast<uint32_t>(want);
chunkLeft -= static_cast<uint32_t>(want);
}
return in[inAt++];
}
int bits(int n) { // -1: no more
while (held < n) {
int b = byte();
if (b < 0) return -1;
hold |= static_cast<uint32_t>(b) << held;
held += 8;
}
int v = static_cast<int>(hold & ((1u << n) - 1));
hold >>= n;
held -= n;
return v;
}
int decode(const Huffman& h) { // -1: no more, or not a code
int code = 0, first = 0, index = 0;
for (int len = 1; len < 16; len++) {
int b = bits(1);
if (b < 0) return -1;
code |= b;
int n = h.count[len];
if (code - n < first) return h.symbol[index + (code - first)];
index += n;
first += n;
first <<= 1;
code <<= 1;
}
return -1;
}
void row();
// One byte out of the decompression: into the window, and into the row being rebuilt.
void out(uint8_t b) {
window[written++ & (windowSize - 1)] = b;
if (pos < 0) {
if (b > 4) {
problem = kDamaged;
stop = true;
}
filter = b;
pos = 0;
return;
}
uint32_t i = static_cast<uint32_t>(pos);
int a = i >= static_cast<uint32_t>(bpp) ? cur[i - bpp] : 0, up = prev[i], c = i >= static_cast<uint32_t>(bpp) ? prev[i - bpp] : 0, add = 0;
switch (filter) {
case 1: add = a; break;
case 2: add = up; break;
case 3: add = (a + up) >> 1; break;
case 4: {
int p = a + up - c, pa = std::abs(p - a), pb = std::abs(p - up), pc = std::abs(p - c);
add = pa <= pb && pa <= pc ? a : pb <= pc ? up : c;
break;
}
default: break;
}
cur[i] = static_cast<uint8_t>(b + add);
if (static_cast<uint32_t>(++pos) < rowBytes) return;
if (!rowNeeded || !*rowNeeded || (*rowNeeded)(y)) row();
std::swap(cur, prev);
pos = -1;
y++;
if (y >= height || (enough && *enough && (*enough)(y))) stop = true;
}
bool inflate();
};
// The row as colours: runs of pixels, broken where one is transparent.
void Work::row() {
uint8_t run[64 * 3];
int n = 0, from = 0;
auto flush = [&]() {
if (n) (*pixels)(from, y, n, run);
n = 0;
};
int top = (1 << depth) - 1;
for (int x = 0; x < width; x++) {
uint8_t r, g, b, a = 255;
auto sample = [&](int k) -> int { // the k-th value of this pixel, as 8 bits; an index stays an index
if (depth == 8) return cur[x * channels + k];
if (depth == 16) return cur[(x * channels + k) * 2];
int bit = x * depth, v = (cur[bit >> 3] >> (8 - depth - (bit & 7))) & top;
return type == 3 ? v : v * 255 / top;
};
switch (type) {
case 0: r = g = b = static_cast<uint8_t>(sample(0)); break;
case 2: r = static_cast<uint8_t>(sample(0)), g = static_cast<uint8_t>(sample(1)), b = static_cast<uint8_t>(sample(2)); break;
case 3: {
int i = sample(0);
r = palette[i * 3], g = palette[i * 3 + 1], b = palette[i * 3 + 2];
if (hasAlpha) a = alpha[i];
break;
}
case 4: r = g = b = static_cast<uint8_t>(sample(0)), a = static_cast<uint8_t>(sample(1)); break;
default: r = static_cast<uint8_t>(sample(0)), g = static_cast<uint8_t>(sample(1)), b = static_cast<uint8_t>(sample(2)), a = static_cast<uint8_t>(sample(3)); break;
}
if (a < 128) {
flush();
continue;
}
if (!n) from = x;
run[n * 3] = r, run[n * 3 + 1] = g, run[n * 3 + 2] = b;
if (++n == 64) flush();
}
flush();
}
// Deflate (RFC 1951) inside a zlib stream (RFC 1950). False with `problem` set, or true when the
// stream ended or enough rows were made.
bool Work::inflate() {
static const uint16_t kLenBase[29] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258};
static const uint8_t kLenExtra[29] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0};
static const uint16_t kDistBase[30] = {1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577};
static const uint8_t kDistExtra[30] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13};
static const uint8_t kOrder[19] = {16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15};
auto fail = [this](const char* what) {
if (!problem) problem = what;
return false;
};
for (bool last = false; !last && !stop;) {
int head = bits(3);
if (head < 0) return fail(kCut);
last = head & 1;
int kind = head >> 1;
if (kind == 0) { // stored
hold = 0;
held = 0;
int a = byte(), b = byte(), c = byte(), d = byte();
if (d < 0) return fail(kCut);
int len = a | (b << 8);
if (len != ((c | (d << 8)) ^ 0xFFFF)) return fail(kDamaged);
for (int i = 0; i < len && !stop; i++) {
int v = byte();
if (v < 0) return fail(kCut);
out(static_cast<uint8_t>(v));
}
continue;
}
if (kind == 3) return fail(kDamaged);
if (kind == 1) { // the code every decoder knows
for (int i = 0; i < 288; i++) codeLengths[i] = i < 144 ? 8 : i < 256 ? 9 : i < 280 ? 7 : 8;
lengths.build(codeLengths, 288);
for (int i = 0; i < 30; i++) codeLengths[i] = 5;
distances.build(codeLengths, 30);
} else { // a code of the block's own, itself sent coded
int nlen = bits(5), ndist = bits(5), ncode = bits(4);
if (ncode < 0) return fail(kCut);
nlen += 257, ndist += 1, ncode += 4;
if (nlen > 286 || ndist > 30) return fail(kDamaged);
uint8_t first[19] = {0};
for (int i = 0; i < ncode; i++) {
int v = bits(3);
if (v < 0) return fail(kCut);
first[kOrder[i]] = static_cast<uint8_t>(v);
}
if (!lengths.build(first, 19)) return fail(kDamaged);
for (int i = 0; i < nlen + ndist;) {
int sym = decode(lengths);
if (sym < 0) return fail(kDamaged);
if (sym < 16) {
codeLengths[i++] = static_cast<uint8_t>(sym);
continue;
}
int repeat, value = 0;
if (sym == 16) {
if (!i) return fail(kDamaged);
value = codeLengths[i - 1];
repeat = 3 + bits(2);
} else if (sym == 17) {
repeat = 3 + bits(3);
} else {
repeat = 11 + bits(7);
}
if (i + repeat > nlen + ndist) return fail(kDamaged);
while (repeat--) codeLengths[i++] = static_cast<uint8_t>(value);
}
uint8_t dist[30];
std::memcpy(dist, codeLengths + nlen, static_cast<size_t>(ndist));
if (!lengths.build(codeLengths, nlen) || !distances.build(dist, ndist)) return fail(kDamaged);
}
while (!stop) {
int sym = decode(lengths);
if (sym < 0) return fail(inEnd ? kCut : kDamaged);
if (sym < 256) {
out(static_cast<uint8_t>(sym));
continue;
}
if (sym == 256) break;
sym -= 257;
if (sym >= 29) return fail(kDamaged);
int extra = bits(kLenExtra[sym]);
int dsym = decode(distances);
if (extra < 0 || dsym < 0 || dsym >= 30) return fail(inEnd ? kCut : kDamaged);
int dextra = bits(kDistExtra[dsym]);
if (dextra < 0) return fail(kCut);
uint32_t len = static_cast<uint32_t>(kLenBase[sym] + extra), dist = static_cast<uint32_t>(kDistBase[dsym] + dextra);
if (dist > written || dist > windowSize) return fail(kDamaged);
for (uint32_t i = 0; i < len && !stop; i++) out(window[(written - dist) & (windowSize - 1)]);
}
}
return true;
}
} // namespace
std::string readPng(const ImageRead& read, uint32_t size, const ImagePixels& pixels, const std::function<bool(int y)>& rowNeeded,
const std::function<bool(int y)>& enough) {
static const uint8_t kSignature[] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
uint8_t head[33];
if (read(0, head, sizeof head) != sizeof head || std::memcmp(head, kSignature, 8) != 0 || std::memcmp(head + 12, "IHDR", 4) != 0) return kDamaged;
std::unique_ptr<Work> w(new (std::nothrow) Work);
if (!w) return kNoMemory;
w->read = &read;
w->size = size;
w->pixels = &pixels;
w->rowNeeded = &rowNeeded;
w->enough = &enough;
uint32_t width = be32(head + 16), height = be32(head + 20);
w->depth = head[24];
w->type = head[25];
if (!width || !height || width > 16384 || height > 16384 || head[26] || head[27]) return kDamaged;
if (head[28]) return "An interlaced PNG can't be shown";
w->width = static_cast<int>(width);
w->height = static_cast<int>(height);
static const int8_t kChannels[7] = {1, 0, 3, 1, 2, 0, 4};
int depth = w->depth, type = w->type;
bool depthOk = depth == 8 || (depth == 16 && type != 3) || ((depth == 1 || depth == 2 || depth == 4) && (type == 0 || type == 3));
if (type > 6 || !kChannels[type] || !depthOk) return "This kind of PNG can't be shown";
w->channels = kChannels[type];
w->bpp = std::max(1, w->channels * depth / 8);
w->rowBytes = (width * static_cast<uint32_t>(w->channels * depth) + 7) / 8;
std::memset(w->palette, 0, sizeof w->palette);
std::memset(w->alpha, 255, sizeof w->alpha);
// The chunks before the picture: the palette and its transparency.
uint32_t at = 33;
for (int guard = 0; guard < 1000; guard++) {
uint8_t c[8];
if (at + 8 > size || read(at, c, 8) != 8) return kCut;
uint32_t len = be32(c);
if (std::memcmp(c + 4, "IDAT", 4) == 0) break;
if (std::memcmp(c + 4, "IEND", 4) == 0 || len > size) return kDamaged;
if (std::memcmp(c + 4, "PLTE", 4) == 0 && read(at + 8, w->palette, std::min<size_t>(len, 768)) != std::min<size_t>(len, 768)) return kCut;
if (std::memcmp(c + 4, "tRNS", 4) == 0 && type == 3) {
if (read(at + 8, w->alpha, std::min<size_t>(len, 256)) != std::min<size_t>(len, 256)) return kCut;
w->hasAlpha = true;
}
at += 12 + len;
}
w->at = at - 4; // as if a chunk's checksum had just been reached: byte() steps over it to the IDAT
// The zlib header says how far back the data refers: the window is that big and no bigger.
int cmf = w->byte(), flg = w->byte();
if (flg < 0) return kCut;
if ((cmf & 0x0F) != 8 || (cmf >> 4) > 7 || ((cmf << 8) | flg) % 31 || (flg & 0x20)) return kDamaged;
w->windowSize = 1u << ((cmf >> 4) + 8);
w->window.reset(new (std::nothrow) uint8_t[w->windowSize]);
w->rows.reset(new (std::nothrow) uint8_t[static_cast<size_t>(w->rowBytes) * 2]());
if (!w->window || !w->rows) return kNoMemory;
w->cur = w->rows.get();
w->prev = w->rows.get() + w->rowBytes;
if (enough && enough(0)) return "";
if (!w->inflate()) return w->problem ? w->problem : kDamaged;
if (w->problem) return w->problem;
return w->stop ? "" : kCut; // the data ended before the last row
}
} // namespace roro::files
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include "image_file.h"
namespace roro::files {
// A PNG, decoded a row at a time (issue #45): every colour type and bit depth, not interlaced.
// Pixels that are mostly transparent are not handed on. `rowNeeded` lets rows be left out (they
// are still decoded: a row is stored as its difference from the one before); `enough` says that
// from this row on nothing is wanted, and the decoding stops there.
//
// Memory while it runs: the window the file's compression refers back into (what its header asks
// for, 32 KB at most), two rows of the picture, and about 3 KB. The display library's decoder
// wanted 44 KB in one block, which this device often doesn't have.
//
// "" or why it can't be shown.
std::string readPng(const ImageRead& read, uint32_t size, const ImagePixels& pixels, const std::function<bool(int y)>& rowNeeded = nullptr,
const std::function<bool(int y)>& enough = nullptr);
} // namespace roro::files
+135
View File
@@ -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
+55
View File
@@ -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
+7
View File
@@ -106,6 +106,13 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
return;
}
break;
case 'p':
case 'P':
if (keys.fn) { // Fn+p: a screenshot, while typing too
out.push_back(KeyEvent::of(Key::Screenshot));
return;
}
break;
case '?':
if (!textEntry_ && !keys.fn) { // ? alone, when it wouldn't be typed
out.push_back(KeyEvent::of(Key::Help));
+217
View File
@@ -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
+53
View File
@@ -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
+7
View File
@@ -2,6 +2,7 @@
#include "debug_auth.h"
#include "ipv4.h"
#include "wg_config.h"
namespace roro {
@@ -45,6 +46,8 @@ const Definition kDefinitions[] = {
{"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
{"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},
};
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
"every Setting needs a definition");
@@ -103,6 +106,10 @@ bool Settings::validString(Setting s, const std::string& value) const {
if (s == Setting::Ntp1) return 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::VpnConfig) {
net::WgConfig config;
return value.empty() || net::parseWgConf(value, config).empty();
}
return true;
}
+2
View File
@@ -34,6 +34,8 @@ enum class Setting : uint8_t {
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
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)
Count
};
+2
View File
@@ -17,9 +17,11 @@ monitor_speed = 115200
build_flags =
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
-DCONFIG_WIREGUARD_MAX_SRC_IPS=4
lib_deps =
m5stack/M5Cardputer @ 1.1.1
jgromes/RadioLib @ 7.8.1
esphome/wireguard @ 0.4.8
test_ignore = *
; Smaller TLS buffers (M2): the framework is rebuilt with these settings (pioarduino "hybrid
; compile"). Receive stays 16 KB (servers send full TLS records); send drops to 4 KB (IRC lines are
+4 -2
View File
@@ -29,7 +29,7 @@ gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it cos
gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>
crash the last crash: firmware, reason, task, backtrace
coredump erase forget the core dump in flash
key <name|char> press a key: up down left right select back home del tab space help, or one character; ctrl- alt- shift- before it (key ctrl-down)
key <name|char> press a key: up down left right select back home del tab space help shot, or one character; ctrl- alt- shift- before it (key ctrl-down)
wifi status | wifi add <ssid><TAB><password>
wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting
wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers
@@ -39,6 +39,7 @@ install <path.ota> Update from SD
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
Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command
vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself
debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > 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
crash abort|wdt crash on purpose (to test crash reports and Safe Mode)
@@ -62,7 +63,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| Command | Effect |
|---|---|
| `burst` | Publishes 5 Notifications at once |
| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing). `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) |
| `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) |
@@ -109,6 +110,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| `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 |
| `crash abort` / `crash wdt` | Crash on purpose, or hang the main loop until the watchdog fires |
| `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 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 |
+1 -1
View File
@@ -11,7 +11,7 @@ Everything the keyboard can do, a command can do, and everything on the screen c
## Keys
```
key up|down|left|right|select|back|home|del|tab|space|help
key up|down|left|right|select|back|home|del|tab|space|help|shot
key a # any single character: it is typed
```
@@ -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.
**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
```sh
+118 -1
View File
@@ -8,7 +8,7 @@ docs = true
source = "docs/milestones/F1.md"
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).
@@ -224,3 +224,120 @@ Test notes were copied to `/notes` and removed afterwards; the note that was alr
**Not checked:** the power button's path (side file only), the screen turning off, a card pulled while editing, and memory with IRC connected, which wasn't connected for these checks: the editor's own use hasn't changed, and it still refuses to open without a free block of 24 KB. The real keyboard's Ctrl with Fn and the arrows. A file of tens of megabytes. Renaming or deleting a note from the Storage App leaves its side file behind.
**Measured against what was said:** the first build rewrote 1.2 MB in 3.5 to 4.5 s, with 2 KB blocks. With 4 KB blocks it is 2.6 s, about 450 KB a second, which is what the card gives a plain copy.
## Pictures in the Storage App (issue #45)
Q139 left images out: the firmware wrote none. Since the Shell's `screenshot` it does.
### Decisions (design round 2026-10-07)
| # | Decision |
|---|---|
| Q233 | **PNG, JPEG, BMP and GIF.** A GIF shows its first picture; it doesn't move. |
| Q234 | *Revised while building.* **Our own PNG decoder**, a row at a time, with the window the file's compression asks for: 32 KB at most. The plan was the display library's, which takes 44 KB in one block: after one picture the largest free block was 43 to 47 KB, and every second PNG was refused. **The firmware's own screenshots** are read with no decoding at all: they are stored uncompressed, each byte already a colour of the screen. |
| Q235 | **The picture is decoded once, straight into the screen's buffer, and left there.** No copy in memory (it would be up to 30 KB). `App::retainsContent()` tells the screen not to clear the App's part; `contentLost()` tells the App that it was cleared after all, or that a Toast or the help panel drawn over it has gone: then it is decoded again. |
| Q236 | **Shrunk to fit; Enter shows it at its own size**, the arrows then moving half a screen. A picture smaller than the screen sits in the middle at its size. |
| Q237 | **Ordered dithering** to the screen's 256 colours (a 4 x 4 pattern). A colour the screen has exactly comes out as itself wherever it lands, so a screenshot isn't touched. |
| Q238 | Shrinking takes, for each pixel of the screen, the first of the picture's that falls on it. No averaging: there is nowhere to keep the sums. Thin lines break up; a JPEG looks better, its decoder halving it up to three times first. |
| Q239 | **What can't be shown opens as hex, with the reason:** a progressive JPEG, an interlaced PNG, a BMP that is compressed or has 16 bits. The rotation a camera stores in the file is ignored. |
| Q240 | *Revised while building.* **Decoding runs on the storage task while the main loop goes on.** The picture appears as it comes, and anything that needs the screen back stops the decoding first. The plan was to wait for it, behind a "Decoding..." line: 12 megapixels took longer than the watchdog allows the main loop to stand still, and the device restarted. |
| Q241 | The Storage App: Enter on `.png`, `.jpg`, `.jpeg`, `.bmp`, `.gif`, or on a file with no known extension whose first bytes say what it is. Tab gives the hex. `i`, and opening, show the size in pixels on the last line for three seconds. |
| Q242 | Not in this one: animation, opening a picture from the Gemini App, a slideshow. |
### As built
- **`lib/files/src/image_file.h`** (host-tested): what a file is and how big, where each pixel lands (`ImageFrame`, `ImageMap`), the dithering, and readers for BMP and GIF that hand their pixels on as they get them. **`png_reader.h`**: the PNG decoder, with its own inflate: every colour type and bit depth, palettes with transparency. Transparent pixels are left as the background.
- **`ImagePane`** (`src/apps/image_pane`) is the view. One decoding is a `Job` shared with the storage task; `cancel()` flags it and waits behind it in the task's queue, which is how the screen is known to be free again.
- **JPEG is the one decoder that isn't ours:** the display library's TJpgDec, with its 3.9 KB pool. It shrinks by 2, 4 or 8 while decoding, which is why a photograph is possible at all.
- **A decoder stops early** once the rest of the file is below the screen (a picture at its own size), and a BMP's rows that aren't shown aren't read.
- **The note** on the last line is written over the picture; the strip under it (2.6 KB) is kept and put back, so showing it costs no decoding.
- **A BMP is read in the order its rows are stored**, last row first: reading it top to bottom meant going back through the file for every row, a second for 135 rows.
- **Cost:** 21 KB of flash. Nothing while no picture is shown.
### Measured on the device
| Picture | Fitted | Its own size |
|---|---|---|
| A screenshot of ours, 240 x 135 | 80 ms | 78 ms |
| PNG, 800 x 600 | 855 ms | 575 ms |
| PNG with transparency, 800 x 600 | 1,098 ms | |
| JPEG, 800 x 600 | 305 ms | |
| JPEG, 4000 x 3000 (2.6 MB) | 6.9 s | 7.7 s (the middle of it) |
| GIF, 800 x 600 | 642 ms | |
| BMP, 800 x 600 (1.4 MB) | 991 ms | |
| BMP, 240 x 135 | 124 ms | |
Free memory fell to 48.8 KB at the lowest while a PNG was decoded, from 104 KB. The storage task's stack: 3.2 KB never used, of 6.
### Host tests (20, `test/test_image_file` and `test/test_png_reader`)
The pictures in them were made with Pillow, so the readers are checked against an encoder that isn't ours: GIFs plain, interlaced, transparent and long enough for the codes to reach 12 bits; BMPs of 8 and 24 bits; PNGs of every kind (colour, with alpha, palette of 8 and 4 bits with a transparent entry, greys of 1, 8 and 16 bits, grey with alpha, not compressed, and one that refers 21,600 bytes back). Every reader is also fed its file with a byte changed, at every few bytes: an answer each time, and no pixel outside the picture.
### Checks on the device (2026-10-07, driven over the Debug Console)
Test pictures were copied to a scratch folder and removed afterwards, with the test screenshot.
| Check | Result |
|---|---|
| Colour bars as PNG, JPEG, BMP and GIF, 240 x 135 and 800 x 600 | The same picture each time, the colours in the right order |
| A PNG with a transparent square | The square is the background |
| A screenshot taken in the Shell | Shown; at its own size it is the screen, pixel for pixel |
| A progressive JPEG | Hex, with "A progressive JPEG can't be shown" |
| An animated GIF | Its first picture |
| 12 megapixels | Arrives from the top down in 6.9 s; the size is noted when it is whole |
| Enter, then the arrows | Its own size from the middle, then half a screen at a time |
| Back in the middle of a decoding | The folder's listing at once |
| Tab to hex and back, three times; the help panel, then closed | The picture again each time |
**Not checked:** a photograph from a real camera (the test JPEGs were made by Pillow); a Toast over a picture; a PNG with IRC connected, when there may not be the memory; the card pulled while decoding; the real keyboard.
### What went wrong while building it
**The watchdog.** The first version waited for the decoder. The main loop is watched: five seconds without a pass and the device restarts, which is what it did on the 12-megapixel test. The crash report named the decoder's line. The decoding moved to the background, which also made the picture appear as it comes.
**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.
## 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).
+91
View File
@@ -0,0 +1,91 @@
+++
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**. SSH (#2) is not started.
**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 uploaded from a phone through the Storage App's sharing (issue #88) 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.
+1 -1
View File
@@ -8,7 +8,7 @@ docs = true
source = "docs/milestones/R1.md"
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.
+1 -1
View File
@@ -8,7 +8,7 @@ docs = true
source = "docs/milestones/S1.md"
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.
+16 -1
View File
@@ -8,7 +8,7 @@ docs = true
source = "docs/milestones/U1.md"
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**). Not started: screen recording (#17), a Launcher of tiles (#9), themes (#10).
**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.
@@ -52,3 +52,18 @@ The lists first lived in each App's `help()`, as code. They are now **data, in o
Three rows lost their second wording on the way, since a table is constant: GNSS's `Tab` and `r`, and the Scanner's `c`, now say both things they do ("record a Track, or stop it") instead of the one that applies. The guide pages keep their written tables too, where they say more than a key list can; those can still drift, and the generated ones under them are the reference.
**Not checked:** the real Fn+h and `?` on the keyboard (the mapper is host-tested; the device was driven with `key help`); the Setup screens, which only a device that was never set up shows, so their new text has not been seen on a screen; and the states that need something to happen first (a dialog, a copy in progress, a Gemini prompt, a packet's details): their lists were read against the key handling, not looked at.
## The screenshot key (issue #83)
A screenshot could only be taken by typing `screenshot` in the Shell, where "now" is a picture of the Shell.
- **Fn+p, on every screen**, text fields included, saves the screen as it is (a dialog, the help panel or a Toast if one is showing) as a PNG in `/screenshots`, the way the Shell's command does. A Toast says so once the file is written, so it is never in the picture.
- **It never reaches an App.** `Key::Screenshot` comes out of the key mapper and is handled before the App manager, so the help panel stays open and a dialog keeps its selection.
- **Not on Settings > Debug Console:** that page shows the token, and a picture of it is a copy of the token in a file. `App::showsSecret()` says so, and the key answers with a Toast instead.
- **No card:** a Toast says so.
- No setting to switch it off: Fn with a letter isn't pressed by accident.
- It is in the "Everywhere" group of the help panel, and so in the website's key tables. `key shot` presses it over the consoles.
**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.
+1 -1
View File
@@ -8,7 +8,7 @@ docs = true
source = "docs/milestones/W1.md"
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.
Binary file not shown.

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 %}
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+14
View File
@@ -53,12 +53,26 @@ Yes: it is [open source](https://git.twis.la/twisla/roro9stack) (GPL-3.0). The d
**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.
**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.
**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.
## Why does IRC disconnect when I update, or when I open a Gemini page?
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/).
## 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/).
## 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/).
+2
View File
@@ -10,6 +10,8 @@ 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.
**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.
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.
+3
View File
@@ -4,6 +4,7 @@ description = "The keys, the Launcher, the Status Bar and what happens the first
weight = 1
[extra]
tag = "Start here"
screens = ["help.png"]
+++
## One key to remember
@@ -26,6 +27,7 @@ The Cardputer's keyboard has no arrow keys and no Escape, so the firmware gives
| <kbd>Fn</kbd> + <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> | The arrows: up, down, left, right |
| <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> alone | The same arrows, as long as you are **not** typing text |
| <kbd>Fn</kbd> + <kbd>h</kbd>, or <kbd>?</kbd> when not typing | **Help:** the keys of the screen you are on |
| <kbd>Fn</kbd> + <kbd>p</kbd> | **A screenshot:** the screen as it is, saved as a picture in `/screenshots` on the SD card |
| <kbd>Tab</kbd> | Switches view in an App that has more than one |
| <kbd>Del</kbd> | Deletes backwards when you type |
| <kbd>opt</kbd> then an accent, then a letter | Types an accented letter: <kbd>opt</kbd> <kbd>'</kbd> <kbd>e</kbd> gives é |
@@ -47,6 +49,7 @@ 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) |
| `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 |
| `VPN` | The [WireGuard tunnel](/guide/vpn/) is wanted; brighter once the server has answered |
| `DBG` | The Debug Console is switched on (Settings → Debug Console); brighter while a PC is connected to it |
| `REC` | A GNSS Track is being recorded |
| `CAP` | A LoRa capture is being recorded |
+1 -1
View File
@@ -1,7 +1,7 @@
+++
title = "Every key"
description = "The keys of every screen of the firmware, as the help panel lists them on the device: one table for each screen and state."
weight = 13
weight = 14
[extra]
tag = "Reference"
+++
+3 -2
View File
@@ -1,6 +1,6 @@
+++
title = "Settings"
description = "The device's names, region, screen, sound, GNSS and Wi-Fi, and where firmware updates are found."
description = "The device's names, region, screen, sound, GNSS, Wi-Fi and VPN, and where firmware updates are found."
weight = 11
[extra]
tag = "Settings"
@@ -22,6 +22,7 @@ Move with the arrows. On a toggle, a choice or a slider, left and right change t
| **Wi-Fi** | The page below |
| **Check for updates** | Once a day, see [Updates](/guide/updates/) |
| **Firmware** | The page described in [Updates](/guide/updates/) |
| **VPN** | A WireGuard tunnel: its switch, "Start with Wi-Fi", and importing its configuration from the card. See [VPN](/guide/vpn/) |
| **Debug Console** | Off unless you switch it on. It lets a PC on the same network read the device's console and drive it, with a token shown on this page: see [the developer docs](/dev/debug/switch-it-on/). Leave it off if that means nothing to you |
| **About** | The firmware version, the node number, battery, memory, uptime, clock and licence |
@@ -46,4 +47,4 @@ Two DNS servers (9.9.9.9 and 1.1.1.1 by default), used on Fixed networks, or on
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["settings", "settings-choice", "wifi", "wifi-servers", "wifi-network", "wifi-status", "wifi-scan", "wifi-name", "debug-console"]) }}
{{ keys(scopes=["settings", "settings-choice", "wifi", "wifi-servers", "wifi-network", "wifi-status", "wifi-scan", "wifi-name", "vpn", "debug-console"]) }}
+2 -1
View File
@@ -4,6 +4,7 @@ description = "The firmware's own commands, typed on the device: look at its sta
weight = 9
[extra]
tag = "Shell"
screens = ["shell.png"]
+++
The firmware has a set of **commands**, made for working on it from a PC. The Shell runs them **on the device itself**: no computer, no cable, no Wi-Fi. It is the tool for the day something is wrong and you are nowhere near a desk.
@@ -77,7 +78,7 @@ screenshot the screen, now
screenshot 5 the screen in 5 seconds: time to go to another App
```
The picture is saved as a PNG in `/screenshots` on the SD card, named by date and time, and a Toast says so once it is written (so the Toast is never in the picture). From the Shell, "now" is always a picture of the Shell: use the pause to get to the screen you want. The [Storage App](/guide/storage/) shows the files; to look at them, take the card to a computer.
The picture is saved as a PNG in `/screenshots` on the SD card, named by date and time, and a Toast says so once it is written (so the Toast is never in the picture). From the Shell, "now" is always a picture of the Shell: use the pause to get to the screen you want, or, simpler, press <kbd>Fn</kbd> + <kbd>p</kbd> on that screen: it takes the same picture from anywhere. The [Storage App](/guide/storage/) lists the files and [shows them](/guide/storage/#pictures).
## What it costs
+30 -3
View File
@@ -4,7 +4,7 @@ description = "Browse the SD card: copy, move, rename and delete with a clipboar
weight = 8
[extra]
tag = "Storage"
screens = ["storage.png"]
screens = ["storage.png", "picture.png", "share.png"]
+++
Storage shows what is on the SD card: each folder's entries with their size and date, folders first. <kbd>Enter</kbd> opens a folder, Back goes up, and <kbd>s</kbd> sorts by name, date or size. A folder with more than 256 entries shows the first 256 by name, and says so.
@@ -34,12 +34,39 @@ The App says why when it refuses.
<kbd>Enter</kbd> on a file opens it by its type, and <kbd>Tab</kbd> switches the same file to a hex dump or to text:
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only a screenful is read from the card, so a file of any size opens at once, and logs open at the end. Up and down move a line, left and right a page, <kbd>t</kbd> and <kbd>b</kbd> go to the top and the end, and <kbd>e</kbd> edits it (up to 16 KB, as in [Notes](/guide/notes/)).
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only a screenful is read from the card, so a file of any size opens at once, and logs open at the end. Up and down move a line, left and right a page, <kbd>t</kbd> and <kbd>b</kbd> go to the top and the end, and <kbd>e</kbd> edits it, whatever its size (as in [Notes](/guide/notes/)).
- **Pictures** (`.png`, `.jpg`, `.bmp`, `.gif`): see [Pictures](#pictures) below.
- **Captures** (`.pcap`): the packets as the [LoRa Scanner](/guide/lora-scanner/) lists them; <kbd>Enter</kbd> shows one with its Meshtastic header and bytes.
- **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 is checked as an install checks it, signature and contents, without writing anything. <kbd>Enter</kbd> then installs it (see [Updates](/guide/updates/)).
- **Anything else:** a hex dump.
## Pictures
<kbd>Enter</kbd> on a PNG, a JPEG, a BMP or a GIF shows it. A picture bigger than the screen is shrunk to fit; <kbd>Enter</kbd> again shows it **at its own size**, and the arrow keys then move around it, half a screen at a time. <kbd>i</kbd> gives its size in pixels, and <kbd>Tab</kbd> the file as hex.
- **The screen has 256 colours.** A photograph is dithered to them; a drawing or a screenshot usually has colours the screen shows exactly. The screenshots the [Shell](/guide/shell/) saves are shown pixel for pixel at their own size.
- **A big photograph takes time**, because every pixel of the file goes through the decoder, shown or not: about 7 seconds for 12 megapixels. The picture appears as it is decoded, and the keys keep working: Back leaves at once.
- **A GIF shows its first picture only.** It doesn't move.
- **What can't be shown** opens as hex, with the reason: a progressive JPEG, an interlaced PNG, a BMP that is compressed or has 16 bits a pixel.
- **A PNG needs 32 KB of memory in one piece** while it is decoded, and a few more (a JPEG needs 4 KB, a GIF 17 KB). With IRC connected there may not be that much: the viewer says so. The firmware's own screenshots need none.
## From a phone
Press <kbd>w</kbd> in the Storage App to **share the card with a browser** on the same network. The screen shows an address, as a QR code and in letters, and a six-digit code.
1. On the phone (or any computer on the same Wi-Fi), scan the QR code, or type the address and then the code.
2. The page lists the card. Tap a folder to open it and a file to download it. **Upload files** sends files from the phone into the folder you are in; **New folder** and **Delete** do what they say.
3. Press Back on the device to stop. Sharing also stops when you leave the Storage App.
What to know:
- **It runs only while that screen is open**, and the code is new each time. Five wrong codes close the door for a minute.
- **It is not encrypted.** On your own network that is the usual trade; on a network you don't trust, someone listening could read the files and the code. Through the [VPN](/guide/vpn/) it is protected.
- **One thing at a time:** while a big file is going up or down, the page waits. About 200 KB a second.
- The same rules as on the device: the folders the firmware keeps for itself can't be deleted, and a folder has to be empty to be deleted from the page.
- An upload is written under a temporary name and renamed when it is whole, so a transfer that is cut leaves nothing behind.
## Maintenance
At the top of the card, the last row, **Maintenance** (or <kbd>m</kbd>), shows the card's usage and holds **Storage clean-up** and **Erase SD card**. They delete for good, so they sit behind a warning. Clean-up deletes old logs and captures by category and age, showing the space it would free first. Notes and Saved Pages are never offered.
@@ -50,4 +77,4 @@ The firmware warns once per start when the card passes **80%** full; past **90%*
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["storage", "storage-details", "storage-name", "storage-busy", "maintenance", "viewer-text", "viewer-hex", "viewer-pcap", "viewer-packet", "viewer-gpx", "viewer-ota"]) }}
{{ keys(scopes=["storage", "storage-details", "storage-name", "storage-busy", "maintenance", "viewer-text", "viewer-hex", "viewer-pcap", "viewer-packet", "viewer-gpx", "viewer-ota", "viewer-image", "storage-share"]) }}
+1 -1
View File
@@ -1,7 +1,7 @@
+++
title = "Updates"
description = "How the device updates itself from the project's releases, from the SD card or from a PC, and how it protects itself when an update goes wrong."
weight = 12
weight = 13
[extra]
tag = "Firmware"
screens = ["update.png"]
+69
View File
@@ -0,0 +1,69 @@
+++
title = "VPN"
description = "A WireGuard tunnel: reach your own network from any Wi-Fi, or send everything through it on a network you don't trust."
weight = 12
[extra]
tag = "WireGuard"
screens = ["vpn.png"]
+++
The Cardputer can join a **WireGuard** network over whatever Wi-Fi it is on. Two uses: reaching your own machines from anywhere (an IRC bouncer, the device's own [Debug Console](/dev/debug/)), and keeping its traffic private on a hotel or café network.
You need a WireGuard server, yours or a provider's, and the configuration file it gives a client: a `.conf`.
## Setting it up
1. On the server, make a configuration for a new client, as you would for a phone.
2. Copy the file to the SD card as **`/vpn/wg0.conf`**.
3. On the device: **Settings → VPN → Import /vpn/wg0.conf**.
4. Say yes when it offers to **delete the file**: the configuration is now stored in the device, and the file on the card still holds the private key in clear.
If the file can't be used, the page says which line and why. The key itself is never shown, anywhere, once imported.
## Using it
**Settings → VPN** has a switch. `VPN` appears in the [Status Bar](/guide/basics/#the-status-bar) while the tunnel is wanted, and turns bright once the server has answered. The page shows the state, the server, this device's address in the tunnel, what goes through it, and how long ago the server was last heard.
- **The switch is for now.** It doesn't survive a restart.
- **Start with Wi-Fi**, off by default, starts the tunnel whenever Wi-Fi connects.
- The tunnel waits for the clock: WireGuard needs the time. The clock is set over plain Wi-Fi first, or from GNSS.
- A [Toast](/guide/basics/#toasts) says when the tunnel comes up, and when the server stops answering.
## What goes through it
That depends on the `AllowedIPs` line of the file, and there are only two cases:
| The file says | What happens |
|---|---|
| `AllowedIPs = 0.0.0.0/0` | **Everything** goes through the tunnel. While the server is silent, nothing leaves the device at all. |
| Anything else | **One subnet** goes through it: the one the device's own tunnel address is in (for `10.9.0.2` with `AllowedIPs = 10.9.0.0/24`, that is `10.9.0.x`). The rest goes out on Wi-Fi as before. |
**A home network behind the server can only be reached with the first kind.** If the file lists `10.9.0.0/24, 192.168.1.0/24`, the second range isn't routed, and the import says so: "through it 10.9.0.0/24, not 1 other range". This is a limit of the device's network software, which can route by one subnet or by default and nothing finer.
The DNS servers in the file are used while the tunnel is up, if they can be reached through it.
## Being reached through it
With the tunnel up, the device answers on its tunnel address as it does on Wi-Fi: the [Debug Console](/dev/debug/) if you switched it on (it still wants its token), and the port that receives firmware updates (they still have to be signed).
## From the Shell
```
vpn status what it is doing
vpn up on, until the next restart
vpn up 120 on for two minutes, then off by itself
vpn down
vpn import reads /vpn/wg0.conf (or the path you give)
vpn forget stops it and erases its keys from the device
vpn auto on|off start with Wi-Fi
```
`vpn up` with a number of seconds is for trying a new configuration from a distance: if it cuts you off, it comes back by itself.
## Limits
One tunnel, to one server. IPv4 only: IPv6 addresses in the file are left out. The server can be an address or a name.
## The keys, as the device lists them
{{ keys(scopes=["vpn"]) }}
+1 -1
View File
@@ -1,6 +1,6 @@
+++
title = "How-tos"
description = "Short recipes for things you will want to do: put a file on the card, record a track, capture radio packets, and what to try when something does not work."
description = "Short recipes for things you will want to do: move files with your phone, set up the VPN, take a screenshot, record a track, capture radio packets, and what to try when something does not work."
template = "guide-index.html"
page_template = "guide-page.html"
sort_by = "weight"
+12
View File
@@ -20,6 +20,18 @@ Opening a Gemini page fails with *not enough memory: stop IRC or retry*. Or an u
The [System App](/guide/system/)'s **Memory** view shows free memory, the lowest since the device started, and the largest free block, drawn against the three memory floors (55, 40 and 20 KB). Watch it fall when a connection opens, and recover when it closes.
## What the other things cost
Small next to a secure connection, but they add up when memory is already short:
| | While it is in use |
|---|---|
| A note open in the editor, whatever its size | 17 KB |
| Sharing the card with a browser | 13 KB |
| The Shell | 7 KB |
| The VPN tunnel | under 2 KB |
| Showing a PNG | one free block of 32 KB, while it is decoded |
## Why it happens
The Cardputer's chip has no extra memory (no PSRAM). A secure (TLS) connection costs about **52 KB** at its peak, and IRC's own connection holds about 40 KB of the 107 KB there is. A second secure connection on top does not fit, so the firmware refuses it early instead of crashing. This is also why IRC steps aside during an update, and why the daily update check waits until IRC is not connected: see [Updates](/guide/updates/).
+30
View File
@@ -0,0 +1,30 @@
+++
title = "Move files with your phone"
description = "Open the SD card in your phone's browser, over Wi-Fi: download what the device wrote, upload what it needs. Nothing to install."
weight = 9
[extra]
tag = "SD card"
+++
The phone and the Cardputer must be on the **same Wi-Fi network**.
1. On the Cardputer, open **Storage** and press <kbd>w</kbd>. You should see a QR code, an address and a six-digit code.
2. On the phone, **scan the QR code** with the camera and open the link. The page opens on the card's folders. (Without a camera: type the address in a browser, then the code.)
3. **To download:** tap a folder to go in, tap a file to download it.
4. **To upload:** go to the folder you want, tap **Upload files** and pick one or several. A bar shows the progress; if a file with that name is there already, the page asks before replacing it.
5. **New folder** and **Delete** do what they say. A folder must be empty to be deleted.
6. On the Cardputer, press Back. Sharing stops, and the code is no longer good.
## What to expect
- About **200 KB a second**: a 3 MB photo takes a quarter of a minute.
- **One thing at a time.** While a big file moves, the page waits.
- The device's screen shows how much has gone in and out, and the last thing that was asked.
## Good to know
- **It is not encrypted.** Use it on a network you trust, or [through the VPN](/guide/vpn/).
- Five wrong codes close it for a minute.
- It works from a computer's browser too.
More in [Storage: From a phone](/guide/storage/#from-a-phone).
+18
View File
@@ -0,0 +1,18 @@
+++
title = "Take a screenshot"
description = "Save what the screen shows as a picture, look at it on the device, and get it onto your phone."
weight = 11
[extra]
tag = "Screen"
+++
1. On any screen, press <kbd>Fn</kbd> + <kbd>p</kbd>. A [Toast](/guide/basics/#toasts) says "Screenshot saved in /screenshots". The Toast itself is never in the picture; a dialog or the help panel that is open is.
2. **To look at it on the device:** open **Storage**, go into `screenshots` and press <kbd>Enter</kbd> on the file. <kbd>Enter</kbd> again shows it at its own size.
3. **To get it out:** in Storage press <kbd>w</kbd> and [open the card in your phone's browser](/howto/phone-files/); the pictures are in `screenshots`.
## Good to know
- It needs an SD card.
- The files are PNGs of 240 by 135 pixels, named by date and time.
- **It refuses on Settings → Debug Console**, the page that shows the console's token: a picture of it would be a copy of the token.
- From the [Shell](/guide/shell/), `screenshot 5` takes the picture five seconds later, for a screen you can't press keys on.
+3 -2
View File
@@ -6,7 +6,7 @@ weight = 2
tag = "SD card"
+++
Everything the firmware writes goes in a folder at the top of the card. Switch the Cardputer off, take the card out and read it in a computer; or look at the same folders in the [Storage App](/guide/storage/).
Everything the firmware writes goes in a folder at the top of the card. To get at it: look at the folders in the [Storage App](/guide/storage/); or [open the card in your phone's browser](/howto/phone-files/), with nothing to install; or switch the Cardputer off, take the card out and read it in a computer.
| What | Where | Kind of file |
|---|---|---|
@@ -19,7 +19,8 @@ Everything the firmware writes goes in a folder at the top of the card. Switch t
| Gemini bookmarks | `/gemini/bookmarks.gmi` | gemtext |
| Files saved from Gemini that are not text | `/gemini/downloads` | whatever they were |
| Update files | `/updates` | `.ota` |
| Screenshots (the Shell's `screenshot`) | `/screenshots` | `.png`, named by date and time |
| [Screenshots](/howto/screenshot/) (<kbd>Fn</kbd> + <kbd>p</kbd>) | `/screenshots` | `.png`, named by date and time |
| A VPN configuration waiting to be imported | `/vpn/wg0.conf` | you put it there; delete it once imported |
## Rules worth knowing
+36
View File
@@ -0,0 +1,36 @@
+++
title = "Set up the VPN"
description = "Put a WireGuard configuration on the device with your phone, import it, and check that the tunnel is up."
weight = 10
[extra]
tag = "VPN"
+++
You need a WireGuard server, and a **client configuration** made on it for the Cardputer, as you would make one for a phone: a `.conf` file.
1. Get the `.conf` onto the phone (or a computer on the same Wi-Fi), and name it **`wg0.conf`**.
2. On the Cardputer, open **Storage** and press <kbd>w</kbd>; open the page on the phone ([Move files with your phone](/howto/phone-files/)).
3. In the page, tap **New folder**, name it `vpn`, go into it, and **upload `wg0.conf`**.
4. On the Cardputer, press Back to stop sharing.
5. Open **Settings → VPN → Import /vpn/wg0.conf**. You should see "Imported", and a question: **delete the file**. Say yes: the configuration is now in the device, and the file still holds the private key in clear.
6. Switch **VPN** to On. `VPN` appears in the Status Bar, and turns bright once the server has answered, usually within seconds. The page says "It is up".
7. To have it start by itself, switch on **Start with Wi-Fi**.
## Check it
From another machine on the VPN, ping the Cardputer's tunnel address (the `Address` line of the file). Or on the device, in the [Shell](/guide/shell/): `vpn status`.
## If it stays dim
| The page says | Try |
|---|---|
| waiting for Wi-Fi | Connect to a network first |
| waiting for the clock | Give it a moment after Wi-Fi connects: the time comes from the network |
| looking up the server | The server's name doesn't resolve from this network |
| no answer yet | The server's address or port, a firewall on the way, or the keys: check the server's side has this client's public key |
## What goes through it
Everything, if the file says `AllowedIPs = 0.0.0.0/0`; otherwise only the VPN's own subnet. To reach your home network behind the server, you need the first. See [VPN](/guide/vpn/#what-goes-through-it).
**The upload in step 3 is not encrypted,** and the file holds a private key: do it on a network you trust, with a key made for this device.
+2 -2
View File
@@ -45,7 +45,7 @@ icon = 3
num = "06"
tag = "Notes"
title = "Write it down"
text = "Keep plain text notes on the SD card. There is no save key: the editor writes five seconds after you stop typing, through a temporary file, so a power cut never costs the note."
text = "Keep plain text notes on the SD card, of any size: a megabyte opens as fast as a line. There is no save key: the editor writes five seconds after you stop typing, so a power cut never costs the note."
fact = "Plain .txt files in /notes"
icon = 6
@@ -53,7 +53,7 @@ icon = 6
num = "07"
tag = "Storage"
title = "Manage the SD card"
text = "Browse, copy, cut, rename and delete with a clipboard. Copies run in the background and Back cancels one. Opens text, hex, captures, tracks and update files."
text = "Browse, copy, cut, rename and delete with a clipboard. Opens text, pictures, hex, captures, tracks and update files. Press w and the card is a web page in your phone's browser: upload and download with nothing to install."
fact = "Checks every copy by size"
icon = 8
+27
View File
@@ -9,6 +9,7 @@ rows = [
["Fn `", "home, the Launcher"],
["; . , /", "arrows (Fn+ while typing)"],
["Fn h ?", "these keys (? not typing)"],
["Fn p", "a screenshot, on the card"],
]
[[scope]]
@@ -248,9 +249,17 @@ rows = [
["i", "details: size, date, type"],
["s", "sort: name, date, size"],
["m", "Maintenance: clean-up, erase"],
["w", "share with a browser"],
["`", "the folder above"],
]
[[scope]]
id = "storage-share"
title = "Storage, sharing with a browser"
rows = [
["`", "stop sharing"],
]
[[scope]]
id = "storage-details"
title = "Storage, an item's details"
@@ -338,6 +347,24 @@ rows = [
["Tab", "the file as hex"],
]
[[scope]]
id = "viewer-image"
title = "A picture"
rows = [
["Enter", "its own size, or all of it"],
["; . , /", "move around it"],
["i", "its size"],
["Tab", "the file as hex"],
]
[[scope]]
id = "vpn"
title = "Settings, VPN"
rows = [
["Enter", "switch, import, forget"],
["; .", "up, down"],
]
[[scope]]
id = "notes"
title = "Notes, the list"
+25
View File
@@ -38,3 +38,28 @@ caption = "Storage"
file = "update.png"
alt = "The full-screen progress of a firmware update: Receiving v0.10.0, a bar at 28 percent"
caption = "A firmware update"
[[screen]]
file = "shell.png"
alt = "The Shell after rm -f /gt2/*: the command in blue, then rm: 5 match /gt2/* and five lines rm: ok 1 files, above an empty input line"
caption = "Shell"
[[screen]]
file = "picture.png"
alt = "A picture open in the Storage App: colour bars, grey and colour gradients and a yellow ellipse, shrunk to fit the screen and dithered to its 256 colours"
caption = "Storage, a picture"
[[screen]]
file = "share.png"
alt = "The Storage App's Share screen: a QR code, the address 172.16.42.25, the code 825 132, Nothing asked yet, Not encrypted, and the key that stops sharing"
caption = "Storage, sharing with a browser"
[[screen]]
file = "vpn.png"
alt = "Settings, VPN: VPN On, Start with Wi-Fi On, Import /vpn/wg0.conf, Forget it; then It is up, heard 66 s ago, the server's address, and This device 10.9.0.2, through it everything. VPN shows in the Status Bar"
caption = "Settings, VPN"
[[screen]]
file = "help.png"
alt = "The help panel over the Launcher: Keys: Launcher, up and down, Enter opens the App, then Everywhere: back, home, the arrows, Fn h for these keys and Fn p for a screenshot"
caption = "The help panel (Fn+h)"
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+1 -1
View File
@@ -68,7 +68,7 @@
</article>
{% endfor %}
</div>
<p class="cards-note">Plus a Shell that runs the firmware's commands on the device, and Settings, with its Wi-Fi and Firmware pages. Every App has a page in the <a class="accent-link" href="/guide/">user guide</a>.</p>
<p class="cards-note">Plus a <a class="accent-link" href="/guide/shell/">Shell</a> that runs the firmware's commands on the device, a <a class="accent-link" href="/guide/vpn/">WireGuard VPN</a>, a screenshot key that works on every screen, and Settings, with its Wi-Fi and Firmware pages. Every App has a page in the <a class="accent-link" href="/guide/">user guide</a>.</p>
</section>
<section class="wrap" id="screens" aria-labelledby="screens-title">
+1 -1
View File
@@ -25,7 +25,7 @@ REPO = SITE.parent
OUT = SITE / "content" / "dev"
REPO_URL = re.search(r'repo\s*=\s*"([^"]+)"', (SITE / "config.toml").read_text()).group(1)
MILESTONES = ["OTA", "M2", "G1", "M3", "S1", "F1", "R1", "W1", "U1"] # in the order they were done
MILESTONES = ["OTA", "M2", "G1", "M3", "S1", "F1", "R1", "W1", "U1", "N1"] # in the order they were done
# Left out on purpose: docs/milestones/M0.md, M1.md and CONTEXT.md (the glossary) describe Wi-Fi monitoring, which this site does not publish.
# They stay in the repository.
+15 -2
View File
@@ -162,11 +162,15 @@ void FileViewer::open(const std::string& path, uint32_t size) {
file_ = std::make_shared<fs::File>();
std::string name = files::baseName(path);
files::FileKind kind = files::kindOf(name);
if (kind == files::FileKind::Unknown) { // what do its first bytes look like?
files::ImageKind picture = files::imageKindOfName(name);
if (kind == files::FileKind::Unknown && picture == files::ImageKind::None) { // what do its first bytes look like?
uint8_t head[256];
size_t n = readAt(0, head, sizeof head);
picture = files::imageKindOfBytes(head, n);
kind = files::looksLikeText(head, n) ? files::FileKind::Text : files::FileKind::Unknown;
}
std::string notShown;
if (picture != files::ImageKind::None) notShown = image_.open(path, size);
switch (kind) {
case files::FileKind::Text: base_ = Mode::Text; break;
case files::FileKind::Gpx: base_ = Mode::Gpx; break;
@@ -174,11 +178,13 @@ void FileViewer::open(const std::string& path, uint32_t size) {
case files::FileKind::Ota: base_ = Mode::Ota; break;
default: base_ = Mode::Hex; break;
}
if (picture != files::ImageKind::None && notShown.empty()) base_ = Mode::Image;
pager_.reset(new files::TextPager([this](uint32_t offset, uint8_t* into, size_t len) { return readAt(offset, into, len); }, size_,
kCols, kRows));
if (files::opensAtEnd(name)) pager_->toEnd();
hexTop_ = 0;
show(base_);
if (!notShown.empty()) say(notShown); // a picture that can't be shown: its bytes, and why
if (base_ == Mode::Gpx || base_ == Mode::Pcap || base_ == Mode::Ota) startScan(base_);
}
@@ -203,6 +209,7 @@ void FileViewer::close() {
});
}
file_.reset();
image_.close();
pager_.reset();
confirm_.reset();
message_.clear();
@@ -214,11 +221,13 @@ void FileViewer::close() {
void FileViewer::show(Mode mode) {
mode_ = mode;
if (mode == Mode::Image) image_.lost(); // another view was drawn where it was
stale_ = true;
rowsFrom_ = -1;
}
void FileViewer::say(const std::string& text) {
if (mode_ == Mode::Image) return image_.say(text);
message_ = text;
messageMs_ = millis();
}
@@ -275,6 +284,7 @@ void FileViewer::help(std::vector<KeyHelp>& out) const {
case Mode::Packet: keys::add(out, keys::kViewerPacket); break;
case Mode::Gpx: keys::add(out, keys::kViewerGpx); break;
case Mode::Ota: keys::add(out, keys::kViewerOta); break;
case Mode::Image: image_.help(out); break;
}
}
@@ -299,6 +309,7 @@ bool FileViewer::onKey(const KeyEvent& e) {
else show(base_ == Mode::Hex || base_ == Mode::Gpx ? Mode::Text : Mode::Hex);
return true;
}
if (mode_ == Mode::Image) return image_.onKey(e), true;
uint32_t ch = e.key == Key::Char ? e.ch : 0;
switch (mode_) {
case Mode::Text:
@@ -332,7 +343,8 @@ bool FileViewer::onKey(const KeyEvent& e) {
return true;
}
bool FileViewer::update(uint32_t) {
bool FileViewer::update(uint32_t nowMs) {
if (mode_ == Mode::Image) return image_.update(nowMs);
if (!message_.empty() && millis() - messageMs_ >= 4000) {
message_.clear();
return true;
@@ -389,6 +401,7 @@ void FileViewer::refresh() {
}
void FileViewer::draw(Canvas& c) {
if (mode_ == Mode::Image) return image_.draw(c);
const auto& area = theme::kContent;
refresh();
c.setTextDatum(top_left);
+7 -2
View File
@@ -6,6 +6,7 @@
#include <string>
#include <vector>
#include "apps/image_pane.h"
#include "dialog_model.h"
#include "key_help.h"
#include "key_event.h"
@@ -23,7 +24,7 @@ namespace roro {
// genuine. Tab switches to the text or the hex of the same file. Nothing here changes a file.
class FileViewer {
public:
FileViewer(StorageService& storage, UpdateService& update) : storage_(storage), update_(update) {}
FileViewer(StorageService& storage, UpdateService& update) : storage_(storage), update_(update), image_(storage) {}
void open(const std::string& path, uint32_t size);
void close();
@@ -37,9 +38,12 @@ class FileViewer {
const std::string& path() const { return path_; }
uint32_t size() const { return size_; }
void say(const std::string& text);
// A picture is drawn once and kept on the screen (App::retainsContent).
bool retains() const { return mode_ == Mode::Image; }
void lost() { image_.lost(); }
private:
enum class Mode { Text, Hex, Gpx, Pcap, Packet, Ota };
enum class Mode { Text, Hex, Gpx, Pcap, Packet, Ota, Image };
static constexpr int kRows = 8;
static constexpr int kCols = 38;
struct Scan; // what a storage job reads through a whole file for: shared with that job
@@ -54,6 +58,7 @@ class FileViewer {
StorageService& storage_;
UpdateService& update_;
ImagePane image_;
std::string path_;
uint32_t size_ = 0;
Mode mode_ = Mode::Text, base_ = Mode::Text;
+348
View File
@@ -0,0 +1,348 @@
#include "image_pane.h"
#include <Arduino.h>
#include <SD.h>
#include <atomic>
#include <cstring>
#include <memory>
#include <new>
#include <lgfx/utility/lgfx_tjpgd.h>
#include "app_keys.h"
#include "file_names.h"
#include "platform/console.h"
#include "png_reader.h"
#include "ui/fonts.h"
#include "ui/theme.h"
namespace roro {
namespace {
constexpr uint32_t kNoteMs = 3000;
constexpr uint32_t kPushMs = 250; // how often the screen shows how far the decoding is
constexpr int kNoteHeight = 11;
constexpr size_t kJpegPool = 3900; // what the library gives its own JPEG decoder
} // namespace
struct ImagePane::Job {
std::string path, why;
files::ImageInfo info;
files::ImageFrame frame;
files::ImageMap map;
uint32_t size = 0, shotAt = 0, tookMs = 0;
uint8_t* screen = nullptr; // the screen's buffer: one byte a pixel, RRRGGGBB
int stride = 0;
std::atomic<bool> stop{false}, done{false};
bool enough = false; // the rest of the file is under the view: the decoder is told to stop
File* file = nullptr;
uint32_t sinceRest = 0;
void put(int sx, int sy, uint8_t r, uint8_t g, uint8_t b) {
int tx, ty;
if (map.at(sx, sy, tx, ty)) screen[ty * stride + tx] = files::rgb332Dithered(r, g, b, tx, ty);
}
// A long decoding must leave the processor to others now and then (the idle task is watched).
void rest(uint32_t bytes) {
sinceRest += bytes;
if (sinceRest < 16 * 1024) return;
sinceRest = 0;
vTaskDelay(1);
}
size_t readAt(uint32_t at, uint8_t* into, size_t len) {
if (stop || !file->seek(at)) return 0;
int n = file->read(into, len);
rest(static_cast<uint32_t>(len));
return n > 0 ? static_cast<size_t>(n) : 0;
}
void run();
};
namespace {
// The library's JPEG decoder reads the file from its start on; a null buffer means "skip".
// Nothing more to read is how a decoding is told to stop.
uint32_t readNext(void* job, uint8_t* into, uint32_t len) {
auto& j = *static_cast<ImagePane::Job*>(job);
if (j.stop || j.enough) return 0;
File& f = *j.file;
j.rest(len);
if (!into) return f.seek(f.position() + len) ? len : 0;
int n = f.read(into, len);
return n > 0 ? static_cast<uint32_t>(n) : 0;
}
// A block of a JPEG: its pixels row by row, three bytes each.
uint32_t jpegBlock(void* job, void* bitmap, JRECT* rect) {
auto& j = *static_cast<ImagePane::Job*>(job);
const uint8_t* p = static_cast<const uint8_t*>(bitmap);
if (j.map.below(static_cast<int>(rect->top))) return j.enough = true, 0;
for (uint32_t y = rect->top; y <= rect->bottom; y++)
for (uint32_t x = rect->left; x <= rect->right; x++, p += 3) j.put(static_cast<int>(x), static_cast<int>(y), p[0], p[1], p[2]);
return j.stop ? 0 : 1;
}
} // namespace
// On the storage task.
void ImagePane::Job::run() {
uint32_t started = millis();
File f = SD.open(path.c_str(), FILE_READ);
if (!f) {
why = "The card refused to open it";
done = true;
return;
}
file = &f;
files::ImageRead read = [this](uint32_t at, uint8_t* into, size_t len) { return readAt(at, into, len); };
files::ImagePixels pixels = [this](int x, int y, int count, const uint8_t* rgb) {
for (int i = 0; i < count; i++, rgb += 3) put(x + i, y, rgb[0], rgb[1], rgb[2]);
};
switch (info.kind) {
case files::ImageKind::Png:
if (shotAt) { // one of ours: each byte is already a colour of the screen
std::unique_ptr<uint8_t[]> row(new (std::nothrow) uint8_t[info.width]);
if (!row) {
why = "Not enough memory";
break;
}
for (int y = 0; y < info.height && why.empty() && !stop; y++) {
if (!map.rowUsed(y)) continue;
size_t w = static_cast<size_t>(info.width);
if (readAt(shotAt + static_cast<uint32_t>(y) * (info.width + 1), row.get(), w) != w) why = "The card refused to read it";
int tx, ty;
for (int x = 0; x < info.width; x++)
if (map.at(x, y, tx, ty)) screen[ty * stride + tx] = row[x];
}
break;
}
why = files::readPng(read, size, pixels, [this](int y) { return map.rowUsed(y); }, [this](int y) { return map.below(y); });
break;
case files::ImageKind::Jpeg: {
std::unique_ptr<lgfxJdec> jpeg(new (std::nothrow) lgfxJdec);
std::unique_ptr<uint8_t[]> pool(new (std::nothrow) uint8_t[kJpegPool]);
if (!jpeg || !pool) {
why = "Not enough memory";
break;
}
int shrink = frame.jpegShrink();
map = frame.map(shrink);
JRESULT r = lgfx_jd_prepare(jpeg.get(), readNext, pool.get(), kJpegPool, this);
if (r == JDR_OK) r = lgfx_jd_decomp(jpeg.get(), jpegBlock, static_cast<uint_fast8_t>(shrink));
if (r == JDR_FMT3) why = "This kind of JPEG can't be shown";
else if (r == JDR_MEM1 || r == JDR_MEM2) why = "This JPEG is too complex to show";
else if (r != JDR_OK && !enough) why = "This JPEG is damaged";
break;
}
case files::ImageKind::Bmp:
why = files::readBmp(read, size, pixels, [this](int y) { return map.rowUsed(y); });
break;
case files::ImageKind::Gif: why = files::readGif(read, size, pixels); break;
default: why = "Not a picture this can show"; break;
}
f.close();
file = nullptr;
tookMs = millis() - started;
if (stop) why.clear();
else
console.printf("image: %s, %d x %d %s, %s in %u ms\n", path.c_str(), info.width, info.height, files::imageKindName(info.kind),
why.empty() ? (frame.actual() ? "its own size" : "fitted") : why.c_str(), (unsigned)tookMs);
done = true;
}
std::string ImagePane::open(const std::string& path, uint32_t size) {
close();
std::string why;
files::ImageInfo info;
uint32_t shotAt = 0;
bool ran = storage_.runAndWait([&]() {
File f = SD.open(path.c_str(), FILE_READ);
if (!f) {
why = "The card refused to open it";
return;
}
files::ImageRead read = [&f](uint32_t at, uint8_t* into, size_t len) -> size_t {
if (!f.seek(at)) return 0;
int n = f.read(into, len);
return n > 0 ? static_cast<size_t>(n) : 0;
};
why = files::imageInfo(read, size, info);
if (why.empty() && info.kind == files::ImageKind::Png) shotAt = files::screenshotPixelsAt(read, size, info.width, info.height);
f.close();
});
if (!ran) why = "No SD card";
if (!why.empty()) return why;
path_ = path;
size_ = size;
info_ = info;
shotAt_ = shotAt;
const auto& area = theme::kContent;
frame_ = files::ImageFrame(info.width, info.height, area.x, area.y, area.w, area.h);
phase_ = Phase::Wanted;
told_ = noteDrawn_ = false;
problem_.clear();
note_.clear();
return "";
}
void ImagePane::cancel() {
if (job_ && !job_->done) {
job_->stop = true;
storage_.runAndWait([]() {}); // behind the decoding in the queue: back when it has ended
}
job_.reset();
}
void ImagePane::close() {
cancel();
path_.clear();
problem_.clear();
note_.clear();
info_ = files::ImageInfo();
phase_ = Phase::Wanted;
std::vector<uint8_t>().swap(under_);
}
void ImagePane::lost() {
cancel();
phase_ = Phase::Wanted;
noteDrawn_ = false;
}
std::string ImagePane::describe() const {
std::string s = std::to_string(info_.width) + " x " + std::to_string(info_.height) + " " + files::imageKindName(info_.kind);
if (frame_.bigger()) s += frame_.actual() ? ", its own size" : ", at " + std::to_string(frame_.percent()) + " %";
return s;
}
void ImagePane::say(const std::string& text) {
hideNote(canvas_);
note_ = text;
noteMs_ = millis();
}
void ImagePane::help(std::vector<KeyHelp>& out) const { keys::add(out, keys::kViewerImage); }
bool ImagePane::onKey(const KeyEvent& e) {
if (path_.empty()) return false;
bool changed = false;
switch (e.key) {
case Key::Select:
if (!frame_.bigger()) return true;
cancel(); // before the frame it is drawing into changes
frame_.toggle();
told_ = false;
changed = true;
break;
case Key::Up:
case Key::Down:
case Key::Left:
case Key::Right: {
if (!frame_.actual()) return true;
cancel();
int dx = e.key == Key::Left ? -1 : e.key == Key::Right ? 1 : 0, dy = e.key == Key::Up ? -1 : e.key == Key::Down ? 1 : 0;
changed = frame_.pan(dx, dy);
if (!changed && phase_ == Phase::Decoding) changed = true; // it was stopped: start it again
break;
}
case Key::Char:
if (e.ch != 'i' && e.ch != 'I') return false;
if (phase_ == Phase::Shown) say(describe());
return true;
default: return false;
}
if (changed) {
phase_ = Phase::Wanted;
note_.clear();
noteDrawn_ = false;
}
return true;
}
// The strip the note was written over goes back as it was: no decoding for that.
void ImagePane::hideNote(Canvas* c) {
if (noteDrawn_ && c && phase_ == Phase::Shown && under_.size() == static_cast<size_t>(c->width()) * kNoteHeight) {
const auto& area = theme::kContent;
uint8_t* screen = static_cast<uint8_t*>(c->getBuffer());
std::memcpy(screen + (area.y + area.h - kNoteHeight) * c->width(), under_.data(), under_.size());
}
noteDrawn_ = false;
note_.clear();
}
bool ImagePane::update(uint32_t) {
if (path_.empty()) return false;
uint32_t now = millis();
if (phase_ == Phase::Wanted) return true;
if (phase_ == Phase::Decoding) {
if (job_ && job_->done) return true;
if (now - pushedMs_ < kPushMs) return false;
pushedMs_ = now;
return true; // what has arrived so far
}
if (!note_.empty() && now - noteMs_ >= kNoteMs) {
hideNote(canvas_);
return true;
}
return !note_.empty() && !noteDrawn_;
}
void ImagePane::start(Canvas& c) {
cancel();
job_ = std::make_shared<Job>();
job_->path = path_;
job_->info = info_;
job_->frame = frame_;
job_->map = frame_.map();
job_->size = size_;
job_->shotAt = shotAt_;
job_->screen = static_cast<uint8_t*>(c.getBuffer());
job_->stride = c.width();
auto job = job_;
storage_.runJob([job]() { job->run(); });
phase_ = Phase::Decoding;
pushedMs_ = millis();
}
void ImagePane::draw(Canvas& c) {
if (path_.empty()) return;
canvas_ = &c;
const auto& area = theme::kContent;
if (phase_ == Phase::Wanted) {
c.fillRect(area.x, area.y, area.w, area.h, theme::kBackground);
noteDrawn_ = false;
problem_.clear();
start(c);
return;
}
if (phase_ == Phase::Decoding) {
if (!job_ || !job_->done) return; // the picture is arriving in the buffer by itself
problem_ = job_->why;
job_.reset();
phase_ = Phase::Shown;
if (!problem_.empty()) {
c.fillRect(area.x, area.y, area.w, area.h, theme::kBackground);
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
c.setTextDatum(middle_center);
c.drawString(problem_.c_str(), area.x + area.w / 2, area.y + area.h / 2);
c.setTextDatum(top_left);
} else if (!told_) {
told_ = true;
note_ = describe();
noteMs_ = millis();
}
}
if (!note_.empty() && !noteDrawn_) {
int top = area.y + area.h - kNoteHeight;
uint8_t* screen = static_cast<uint8_t*>(c.getBuffer());
under_.assign(screen + top * c.width(), screen + (top + kNoteHeight) * c.width());
c.fillRect(area.x, top, area.w, kNoteHeight, theme::kBackground);
c.setFont(&fonts::small);
c.setTextColor(theme::kText);
c.setTextDatum(top_left);
c.drawString(note_.c_str(), area.x + 4, top + 2);
noteDrawn_ = true;
}
}
} // namespace roro
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include "image_file.h"
#include "key_event.h"
#include "key_help.h"
#include "services/storage_service.h"
#include "ui/canvas.h"
namespace roro {
// A picture from the card, for the Storage App's viewer (issue #45, F1 Q233-Q242): PNG, JPEG, BMP
// and the first picture of a GIF (only JPEG needs a decoder that isn't ours). It is decoded once, straight into the screen's own buffer, and
// left there (App::retainsContent): there is no copy of it in memory. It is decoded again only
// when something else was drawn over it, or when it is zoomed or moved.
//
// The decoding runs on the storage task while the main loop goes on: a photograph takes seconds,
// and the picture appears as it comes. Anything that needs the screen back stops it first.
class ImagePane {
public:
explicit ImagePane(StorageService& storage) : storage_(storage) {}
std::string open(const std::string& path, uint32_t size); // "" or why it can't be shown
void close();
bool onKey(const KeyEvent& e); // true: the key was the picture's
void help(std::vector<KeyHelp>& out) const;
bool update(uint32_t nowMs); // true: draw again
void draw(Canvas& c);
void lost(); // the screen no longer holds it
void say(const std::string& text);
struct Job; // one decoding: shared with the storage task, which may outlive the view of it
private:
enum class Phase { Wanted, Decoding, Shown };
std::string describe() const;
void start(Canvas& c);
void cancel(); // returns once the storage task has let go of the screen
void hideNote(Canvas* c);
StorageService& storage_;
std::string path_, problem_, note_;
uint32_t size_ = 0, noteMs_ = 0, shotAt_ = 0, pushedMs_ = 0;
files::ImageInfo info_;
files::ImageFrame frame_;
Phase phase_ = Phase::Wanted;
std::shared_ptr<Job> job_;
Canvas* canvas_ = nullptr;
bool told_ = false, noteDrawn_ = false;
std::vector<uint8_t> under_; // what the note was written over
};
} // namespace roro
+11 -1
View File
@@ -35,6 +35,9 @@ bool SettingsApp::onKey(const KeyEvent& e) {
case Page::Firmware:
if (!firmwarePage_.onKey(e)) page_ = Page::Menu;
return true;
case Page::Vpn:
if (!vpnPage_.onKey(e)) page_ = Page::Menu;
return true;
case Page::Debug:
if (!debugPage_.onKey(e)) page_ = Page::Menu;
return true;
@@ -79,6 +82,10 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) {
page_ = Page::Firmware;
firmwarePage_.enter();
break;
case Row::Vpn:
page_ = Page::Vpn;
vpnPage_.enter();
break;
case Row::DebugConsole:
page_ = Page::Debug;
debugPage_.enter();
@@ -139,6 +146,7 @@ void SettingsApp::help(std::vector<KeyHelp>& out) const {
case Page::Wifi: wifiPage_.help(out); break;
case Page::Firmware: firmwarePage_.help(out); break;
case Page::Debug: debugPage_.help(out); break;
case Page::Vpn: vpnPage_.help(out); break;
}
}
@@ -147,6 +155,7 @@ const char* SettingsApp::helpTitle() const {
case Page::Wifi: return wifiPage_.helpTitle();
case Page::Firmware: return firmwarePage_.helpTitle();
case Page::Debug: return debugPage_.helpTitle();
case Page::Vpn: return "VPN";
case Page::About: return "About";
default: return nullptr;
}
@@ -154,7 +163,7 @@ const char* SettingsApp::helpTitle() const {
void SettingsApp::update(uint32_t nowMs) {
// Live values on About and Firmware.
bool live = page_ == Page::About || page_ == Page::Firmware || page_ == Page::Debug ||
bool live = page_ == Page::About || page_ == Page::Firmware || page_ == Page::Debug || page_ == Page::Vpn ||
(page_ == Page::Wifi && wifiPage_.live());
if (live && nowMs - lastRefreshMs_ >= 500) {
lastRefreshMs_ = nowMs;
@@ -213,6 +222,7 @@ void SettingsApp::draw(Canvas& c) {
case Page::Wifi: wifiPage_.draw(c); break;
case Page::Firmware: firmwarePage_.draw(c); break;
case Page::Debug: debugPage_.draw(c); break;
case Page::Vpn: vpnPage_.draw(c); break;
}
}
+7 -2
View File
@@ -14,6 +14,7 @@
#include "services/clock_service.h"
#include "services/storage_service.h"
#include "apps/debug_console_page.h"
#include "apps/vpn_page.h"
#include "apps/firmware_page.h"
#include "apps/wifi_settings_page.h"
#include "settings_menu.h"
@@ -31,6 +32,7 @@ struct SettingsAppDeps {
WifiService& wifi;
SavedNetworks& savedNetworks;
UpdateService& update;
VpnService& vpn;
};
// Settings: every user-facing setting, plus the Wi-Fi, Firmware, Debug Console and About pages.
@@ -41,7 +43,8 @@ class SettingsApp : public App {
menu_(deps.settings),
wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus),
firmwarePage_(deps.update, deps.wifi, deps.storage),
debugPage_(deps.settings, deps.wifi) {}
debugPage_(deps.settings, deps.wifi),
vpnPage_(deps.settings, deps.vpn, deps.storage, deps.clock) {}
void onEnter() override;
bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override;
@@ -52,9 +55,10 @@ class SettingsApp : public App {
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
bool showsSecret() const override { return page_ == Page::Debug; } // the Debug Console's token
private:
enum class Page { Menu, Text, Choice, About, Wifi, Firmware, Debug };
enum class Page { Menu, Text, Choice, About, Wifi, Firmware, Debug, Vpn };
bool onMenuKey(const KeyEvent& e);
bool onTextKey(const KeyEvent& e);
@@ -68,6 +72,7 @@ class SettingsApp : public App {
WifiSettingsPage wifiPage_;
FirmwarePage firmwarePage_;
DebugConsolePage debugPage_;
VpnPage vpnPage_;
Page page_ = Page::Menu;
ListModel list_{theme::kContent.h / theme::kLineHeight};
ListModel choices_{theme::kContent.h / theme::kLineHeight};
+1 -1
View File
@@ -26,7 +26,7 @@ void ShellApp::onEnter() {
scroll_ = 0;
confirm_.reset();
// What Tab completes besides the firmware's own commands, written as `help` writes them.
ownHelp_ = "help | clear | quit | exit\nkey up|down|left|right|select|back|home|del|tab|space|help\n";
ownHelp_ = "help | clear | quit | exit\nkey up|down|left|right|select|back|home|del|tab|space|help|shot\n";
log_.clear();
log_.add(open_ ? "The console's commands. `help` lists them." : "No memory for the Shell: leave an App, or stop IRC.");
}
+56
View File
@@ -48,6 +48,7 @@ void StorageApp::onEnter() {
}
void StorageApp::onExit() {
share_.stop(); // sharing lasts as long as its screen
if (wait_ != Wait::None && wait_ != Wait::Work) {
ops_.cancel();
wait_ = Wait::None;
@@ -97,6 +98,13 @@ bool StorageApp::work(const std::string& why, const std::string& selectAfter) {
void StorageApp::update(uint32_t nowMs) {
bool present = storage_.state().present;
if (view_ == View::Share) {
if (!present || !share_.running()) {
share_.stop();
view_ = View::Browse;
}
if (nowMs - lastDrawMs_ >= 500) requestRedraw(); // what the browser is doing
}
if (present != cardPresent_) { // taken out, put in, or erased: back to the top
cardPresent_ = present;
cwd_ = want_ = "/";
@@ -281,6 +289,7 @@ void StorageApp::help(std::vector<KeyHelp>& out) const {
case View::Editor: return noteEditor_.help(out);
case View::Viewer: return viewer_.help(out);
case View::Details: return keys::add(out, keys::kStorageDetails);
case View::Share: return keys::add(out, keys::kStorageShare);
default: break;
}
if (dialog_) return keys::add(out, keys::kDialog);
@@ -295,6 +304,7 @@ const char* StorageApp::helpTitle() const {
case View::Editor: return "Storage: the editor";
case View::Viewer: return "Storage: a file";
case View::Details: return "Storage: details";
case View::Share: return "Storage: sharing";
case View::Name: return "Storage: a name";
default: return nullptr;
}
@@ -303,6 +313,14 @@ const char* StorageApp::helpTitle() const {
bool StorageApp::onKey(const KeyEvent& e) {
requestRedraw();
if (view_ == View::NoMemory) return false;
if (view_ == View::Share) {
if (e.key == Key::Back) {
share_.stop();
view_ = View::Browse;
open(cwd_); // what was uploaded or deleted meanwhile
}
return true;
}
if (view_ == View::Maintenance) {
if (!maintenance_.onKey(e)) {
view_ = View::Browse;
@@ -413,6 +431,12 @@ bool StorageApp::onBrowseKey(const KeyEvent& e) {
}
uint32_t ch = e.key == Key::Char ? (e.ch >= 'A' && e.ch <= 'Z' ? e.ch + 32 : e.ch) : 0;
if (ch == 'm') askMaintenance();
if (ch == 'w') {
std::string why = share_.start();
if (why.empty()) view_ = View::Share;
else say(why);
return true;
}
if (!cardPresent_ || !loaded_) return true;
Item item = selected();
@@ -507,6 +531,7 @@ void StorageApp::draw(Canvas& c) {
c.drawString("in Gemini) and open Storage again.", 4, area.y + 22 + theme::kLineHeight);
return;
case View::Maintenance: maintenance_.draw(c); return;
case View::Share: drawShare(c); return;
case View::Viewer: viewer_.draw(c); return;
case View::Editor: noteEditor_.draw(c); return;
case View::Details: {
@@ -539,6 +564,37 @@ void StorageApp::draw(Canvas& c) {
}
}
// The address as a QR code and in letters, the code to type, and what the browser has done.
void StorageApp::drawShare(Canvas& c) {
const auto& area = theme::kContent;
std::string url = share_.url(), code = share_.code();
const int qr = 104;
c.fillRect(2, area.y + 3, qr + 6, qr + 6, 0xFFFF); // a QR code wants a quiet border
c.qrcode((url + "#" + code).c_str(), 5, area.y + 6, qr);
int x = qr + 14, y = area.y + 4;
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString("In a browser, on", x, y);
c.drawString("this network:", x, y + 10);
c.setTextColor(theme::kText);
c.drawString(url.size() > 7 ? url.substr(7).c_str() : url.c_str(), x, y + 24); // without http://
c.setTextColor(theme::kMuted);
c.drawString("Code", x, y + 40);
c.setFont(&fonts::bold);
c.setTextColor(theme::kAccent);
std::string grouped = code.size() == 6 ? code.substr(0, 3) + " " + code.substr(3) : code;
c.drawString(grouped.c_str(), x, y + 50);
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
std::string moved = formatBytes(share_.bytesIn()) + " in, " + formatBytes(share_.bytesOut()) + " out";
c.drawString(share_.requests() ? moved.c_str() : "Nothing asked yet", x, y + 72);
c.drawString("Not encrypted.", x, y + 84);
c.drawString("` stops sharing", x, y + 96);
c.setTextColor(theme::kText);
std::string last = share_.last();
if (!last.empty()) c.drawString(last.c_str(), 4, area.y + area.h - 9);
}
void StorageApp::drawBrowse(Canvas& c) {
const auto& area = theme::kContent;
StorageState card = storage_.state();
+8 -3
View File
@@ -16,6 +16,7 @@
#include "services/clock_service.h"
#include "services/file_ops.h"
#include "services/storage_service.h"
#include "services/web_share.h"
#include "ui/theme.h"
namespace roro {
@@ -26,19 +27,21 @@ namespace roro {
// usage, Storage Clean-up and erasing the card, behind a warning (Q128).
class StorageApp : public App {
public:
StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, PowerService& power, EventBus& bus)
: ops_(ops), storage_(storage), bus_(bus), maintenance_(storage, clock, bus), viewer_(storage, update), noteEditor_(storage, clock, power) {}
StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, PowerService& power, EventBus& bus, WebShare& share)
: ops_(ops), storage_(storage), bus_(bus), share_(share), maintenance_(storage, clock, bus), viewer_(storage, update), noteEditor_(storage, clock, power) {}
void onEnter() override;
void onExit() override;
bool onKey(const KeyEvent& e) override;
bool textEntryActive() const override { return view_ == View::Name || view_ == View::Editor; }
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
bool retainsContent() const override { return view_ == View::Viewer && viewer_.retains(); }
void contentLost() override { viewer_.lost(); }
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private:
enum class View { Browse, Details, Name, Viewer, Editor, Maintenance, NoMemory };
enum class View { Browse, Details, Name, Viewer, Editor, Maintenance, NoMemory, Share };
enum class Ask { None, Delete, Replace, Maintenance };
enum class Wait { None, List, CountToDelete, CountForDetails, Work }; // what the running operation is for
static constexpr int kRows = 8;
@@ -64,10 +67,12 @@ class StorageApp : public App {
void say(const std::string& text);
void drawBrowse(Canvas& c);
void drawProgress(Canvas& c);
void drawShare(Canvas& c); // `w`: the card in a browser on the same network (issue #88)
FileOps& ops_;
StorageService& storage_;
EventBus& bus_;
WebShare& share_;
MaintenancePage maintenance_;
FileViewer viewer_;
NoteEditor noteEditor_; // `e` in the text viewer (Q146)
+133
View File
@@ -0,0 +1,133 @@
#include "apps/vpn_page.h"
#include <SD.h>
#include "app_keys.h"
#include "ipv4.h"
#include "ui/fonts.h"
#include "ui/theme.h"
#include "ui/widgets.h"
namespace roro {
void VpnPage::enter() {
list_.setCount(kRows);
confirm_.reset();
message_.clear();
}
bool VpnPage::onKey(const KeyEvent& e) {
if (confirm_) {
confirm_->onKey(e);
int result = confirm_->result();
if (result == DialogModel::kPending) return true;
Ask asked = ask_;
ask_ = Ask::None;
confirm_.reset();
if (result == 1 && asked == Ask::DeleteFile) {
storage_.runJob([]() { SD.remove(kConfPath); });
message_ = "Imported, and the file is deleted";
} else if (result == 1 && asked == Ask::Forget) {
vpn_.forget();
message_ = "Forgotten";
}
return true;
}
switch (e.key) {
case Key::Up: list_.up(); break;
case Key::Down: list_.down(); break;
case Key::Back: return false;
case Key::Left:
case Key::Right:
case Key::Select:
if (e.key != Key::Select && list_.selected() > kAuto) break;
message_.clear();
switch (list_.selected()) {
case kSwitch:
if (!vpn_.configured()) message_ = "Import a .conf first";
else vpn_.want(!vpn_.wanted());
break;
case kAuto:
if (!vpn_.configured()) message_ = "Import a .conf first";
else settings_.setBool(Setting::VpnAuto, !settings_.getBool(Setting::VpnAuto));
break;
case kImport: {
std::string why = vpn_.importFile(storage_, kConfPath);
if (!why.empty()) {
message_ = why;
break;
}
message_ = "Imported";
ask_ = Ask::DeleteFile; // the card can be taken out, and the key is in that file
confirm_.reset(new DialogModel({"Keep it", "Delete it"}));
break;
}
case kForget:
if (!vpn_.configured()) break;
ask_ = Ask::Forget;
confirm_.reset(new DialogModel({"Cancel", "Forget"}));
break;
default: break;
}
break;
default: break;
}
return true;
}
void VpnPage::help(std::vector<KeyHelp>& out) const {
if (confirm_) return keys::add(out, keys::kDialog);
keys::add(out, keys::kVpn);
}
void VpnPage::draw(Canvas& c) {
const auto& area = theme::kContent;
c.setTextDatum(top_left);
bool set = vpn_.configured();
widgets::list(
c, list_, {area.x, area.y, area.w, kRows * theme::kLineHeight},
[](int i) -> std::string {
switch (i) {
case kSwitch: return "VPN";
case kAuto: return "Start with Wi-Fi";
case kImport: return "Import /vpn/wg0.conf";
default: return "Forget it";
}
},
[&](int i) -> std::string {
switch (i) {
case kSwitch: return !set ? "Not set" : vpn_.wanted() ? "On" : "Off";
case kAuto: return settings_.getBool(Setting::VpnAuto) ? "On" : "Off";
default: return "";
}
});
int y = area.y + kRows * theme::kLineHeight + 4;
c.setFont(&fonts::small);
auto line = [&](const std::string& text, uint16_t colour) {
c.setTextColor(colour);
c.drawString(text.c_str(), 4, y);
y += 10;
};
if (set) {
const net::WgConfig& k = vpn_.config();
std::string state = std::string("It is ") + vpn_.stateText();
int64_t now = clock_.utcNow(), last = vpn_.lastHandshake();
if (vpn_.state() == VpnService::State::Up && now >= 0 && last > 0 && now >= last) state += ", heard " + std::to_string(now - last) + " s ago";
line(state, vpn_.state() == VpnService::State::Up ? theme::kAccent : theme::kText);
line("Server " + k.endpointHost + ":" + std::to_string(k.endpointPort), theme::kMuted);
line("This device " + net::formatIpv4(k.address) + ", through it " + net::describeWgRouting(k), theme::kMuted);
} else {
line("Copy a WireGuard .conf to the card as", theme::kMuted);
line(std::string(kConfPath) + ", then import it.", theme::kMuted);
}
if (!message_.empty()) line(message_, theme::kWarning);
else if (!vpn_.lastError().empty()) line(vpn_.lastError(), theme::kWarning);
if (confirm_ && ask_ == Ask::DeleteFile)
widgets::dialog(c, "Delete the file?", "It is stored in the device now. The file on the card still holds the private key.", *confirm_);
else if (confirm_)
widgets::dialog(c, "Forget the VPN?", "The tunnel stops and its keys are erased from the device.", *confirm_);
}
} // namespace roro
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include "dialog_model.h"
#include "key_event.h"
#include "key_help.h"
#include "list_model.h"
#include "services/clock_service.h"
#include "services/storage_service.h"
#include "services/vpn_service.h"
#include "settings.h"
#include "ui/canvas.h"
namespace roro {
// Settings > VPN (issue #8): the switch, "Start with Wi-Fi", importing a `.conf` from the card
// and forgetting it, and what the tunnel is doing. No key is ever on this page.
class VpnPage {
public:
static constexpr const char* kConfPath = "/vpn/wg0.conf";
VpnPage(Settings& settings, VpnService& vpn, StorageService& storage, ClockService& clock)
: settings_(settings), vpn_(vpn), storage_(storage), clock_(clock) {}
void enter();
bool onKey(const KeyEvent& e); // false: leave the page
void draw(Canvas& c);
void help(std::vector<KeyHelp>& out) const;
private:
enum Row { kSwitch, kAuto, kImport, kForget, kRows };
enum class Ask { None, DeleteFile, Forget };
Settings& settings_;
VpnService& vpn_;
StorageService& storage_;
ClockService& clock_;
ListModel list_{kRows};
std::unique_ptr<DialogModel> confirm_;
Ask ask_ = Ask::None;
std::string message_;
};
} // namespace roro
+75 -7
View File
@@ -12,6 +12,8 @@
#include "apps/demo_app.h"
#include "apps/note_editor.h"
#include "apps/shell_app.h"
#include "services/vpn_service.h"
#include "services/web_share.h"
#include "apps/gemini_app.h"
#include "apps/gnss_app.h"
#include "apps/irc_app.h"
@@ -26,6 +28,7 @@
#include "event_bus.h"
#include "file_receiver.h"
#include "ipv4.h"
#include "wg_config.h"
#include "traffic.h"
#include "key_mapper.h"
#include "platform/console.h"
@@ -85,6 +88,7 @@ static WifiService* wifi;
static IrcService* irc;
static UpdateService* update;
static DebugConsole* debugConsole;
static VpnService* vpnService; // not in Safe Mode
static Notifier* notifier;
static LauncherApp launcher;
static AppManager* apps;
@@ -131,6 +135,8 @@ static StatusInfo currentStatus() {
s.radio = last && millis() - last < 400 ? StatusInfo::Radio::Packet : StatusInfo::Radio::Listening;
}
s.capturing = loraCapture && loraCapture->capturing();
if (vpnService && vpnService->wanted())
s.vpn = vpnService->state() == VpnService::State::Up ? StatusInfo::Vpn::Up : StatusInfo::Vpn::Trying;
s.debug = !debugConsole->on() ? StatusInfo::Debug::Off : debugConsole->clientConnected() ? StatusInfo::Debug::Client : StatusInfo::Debug::On;
using WifiState = WifiController::State;
switch (wifi->state()) {
@@ -208,6 +214,8 @@ void setup() {
services.add(*update);
debugConsole = new DebugConsole(*wifi, *storageService, settings);
services.add(*debugConsole);
vpnService = new VpnService(settings, *wifi, *clockService, bus);
services.add(*vpnService);
apps = new AppManager(launcher);
launcher.setManager(*apps);
@@ -216,7 +224,7 @@ void setup() {
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)});
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)});
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus)});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *new WebShare(*storageService, *fileOps, *wifi))});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)});
// Leaving the foreground App makes it save: a note being typed, when the device is powered off.
@@ -226,7 +234,7 @@ void setup() {
apps->registerApp({"system", "System", false,
new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs)});
apps->registerApp({"settings", "Settings", false,
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update})});
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update, *vpnService})});
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});
apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)});
@@ -656,7 +664,7 @@ static const char* const kHelp =
"gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n"
"crash the last crash: firmware, reason, task, backtrace\n"
"coredump erase forget the core dump in flash\n"
"key <name|char> press a key: up down left right select back home del tab space help, or one character; ctrl- alt- shift- before it (key ctrl-down)\n"
"key <name|char> press a key: up down left right select back home del tab space help shot, or one character; ctrl- alt- shift- before it (key ctrl-down)\n"
"wifi status | wifi add <ssid><TAB><password>\n"
"wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting\n"
"wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers\n"
@@ -666,6 +674,7 @@ static const char* const kHelp =
"update check | update list | update status | update install <tag> the project's releases on Gitea\n"
"sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
"Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command\n"
"vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself\n"
"debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\n"
"debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token\n"
"crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n"
@@ -725,6 +734,64 @@ static void saveScreenshot() {
});
}
// `vpn ...` (issue #8). Nothing here prints a key.
static void vpnCommand(const String& arg) {
if (!vpnService) return (void)console.println("vpn: not available in Safe Mode");
VpnService& v = *vpnService;
if (arg == "up" || arg.startsWith("up ")) {
if (!v.configured()) return (void)console.println("vpn: error not set: copy a .conf to /vpn/wg0.conf, then `vpn import`");
uint32_t seconds = arg.length() > 3 ? constrain(arg.substring(3).toInt(), 0, 86400) : 0;
v.want(true, seconds);
if (seconds) console.printf("vpn: on for %lu s\n", (unsigned long)seconds);
else console.println("vpn: on");
} else if (arg == "down") {
v.want(false);
console.println("vpn: off");
} else if (arg == "import" || arg.startsWith("import ")) {
std::string path = arg.length() > 7 ? arg.substring(7).c_str() : "/vpn/wg0.conf";
std::string why = v.importFile(*storageService, path);
if (!why.empty()) return (void)console.printf("vpn: error %s\n", why.c_str());
console.printf("vpn: imported, through it %s. %s still holds the private key: `rm -f` it\n", net::describeWgRouting(v.config()).c_str(), path.c_str());
} else if (arg == "forget") {
v.forget();
console.println("vpn: forgotten");
} else if (arg == "status") {
if (!v.configured()) return (void)console.println("vpn: not set");
const net::WgConfig& k = v.config();
console.printf("vpn: %s%s, server %s:%u, this device %s/%d, through it %s\n", v.wanted() ? "" : "off, ", v.wanted() ? v.stateText() : "configured",
k.endpointHost.c_str(), (unsigned)k.endpointPort, net::formatIpv4(k.address).c_str(), k.prefix, net::describeWgRouting(k).c_str());
int64_t now = clockService->utcNow(), last = v.lastHandshake();
if (last > 0 && now >= last) console.printf("vpn: last handshake %ld s ago\n", (long)(now - last));
if (!v.lastError().empty()) console.printf("vpn: %s\n", v.lastError().c_str());
console.printf("vpn: start with Wi-Fi %s\n", settings.getBool(Setting::VpnAuto) ? "on" : "off");
} else if (arg == "auto on" || arg == "auto off") {
settings.setBool(Setting::VpnAuto, arg == "auto on");
console.printf("vpn: start with Wi-Fi %s\n", arg == "auto on" ? "on" : "off");
} else {
console.println("vpn: status | up [seconds] | down | import [path] | forget | auto on|off");
}
}
// Fn+p (issue #83): the screen as it is, dialog, help panel or Toast included. Not the page that
// shows the Debug Console's token: a picture of it is a copy of the token in a file.
static void screenshotKey() {
const char* why = nullptr;
if (apps->foreground().showsSecret()) why = "No screenshot here: it shows the token";
else if (!storageService || !storageService->state().present) why = "No SD card for the screenshot";
if (why) {
bus.publish(Event::withText(EventType::Notification, why, static_cast<int32_t>(NotificationLevel::Warning)));
return;
}
shotFrom = Console::Origin::System;
saveScreenshot();
}
// Every key goes through here, from the keyboard or from a console's `key`.
static void handleKey(const KeyEvent& e) {
if (e.key == Key::Screenshot) return screenshotKey();
apps->handleKey(e);
}
static void screenshotStep() {
if (shotPending && static_cast<int32_t>(millis() - shotDueMs) >= 0) {
shotPending = false;
@@ -799,6 +866,7 @@ static void runCommand(String line, bool fromSerial = false) {
else console.println("Not now: Setup is running");
return;
}
if (line == "vpn" || line.startsWith("vpn ")) return vpnCommand(line.length() > 4 ? line.substring(4) : String("status"));
if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial);
if (line == "screenshot" || line.startsWith("screenshot ")) {
uint32_t seconds = constrain(line.substring(10).toInt(), 0, 60);
@@ -1039,14 +1107,14 @@ static void runCommand(String line, bool fromSerial = false) {
}
Key key = k == "up" ? Key::Up : k == "down" ? Key::Down : k == "left" ? Key::Left
: k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home
: k == "del" ? Key::Delete : k == "tab" ? Key::Tab : k == "help" ? Key::Help : Key::Select;
: k == "del" ? Key::Delete : k == "tab" ? Key::Tab : k == "help" ? Key::Help : k == "shot" ? Key::Screenshot : Key::Select;
KeyEvent ev = k == "space" ? KeyEvent::character(' ')
: k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0])
: KeyEvent::of(key);
ev.ctrl = ctrl;
ev.alt = alt;
ev.shift = shift;
if (!power->onKey(millis())) apps->handleKey(ev);
if (!power->onKey(millis())) handleKey(ev);
}
if (line.startsWith("wifi add ")) { // wifi add <ssid>\t<password>: credentials never touch the repo
int tab = line.indexOf('\t');
@@ -1136,7 +1204,7 @@ static void runCommand(String line, bool fromSerial = false) {
console.printf("wifi: address %s/%d (%s), gateway %s\n", c.address.c_str(), c.prefix, c.fixed ? "fixed" : "DHCP",
c.gateway.empty() ? "none" : c.gateway.c_str());
console.printf("wifi: dns %s %s (%s)\n", c.dns[0].empty() ? "none" : c.dns[0].c_str(), c.dns[1].c_str(),
c.dnsFromSettings ? "Settings" : "DHCP");
vpnService && vpnService->dnsThroughIt() ? "VPN" : c.dnsFromSettings ? "Settings" : "DHCP");
console.print("wifi: ntp");
for (int i = 0; i < c.ntpCount; i++) console.printf(" %s (%s%s)", c.ntp[i].server.c_str(), c.ntp[i].fromDhcp ? "DHCP" : "Settings", c.ntp[i].answered ? ", answered" : "");
console.println(c.ntpCount ? "" : " none");
@@ -1385,7 +1453,7 @@ static void loopPass() {
keyMapper.setTextEntry(apps->foreground().textEntryActive());
auto events = keyMapper.update(readKeys()); // always, so held keys are tracked
if (!swallow)
for (auto& e : events) apps->handleKey(e);
for (auto& e : events) handleKey(e);
}
// Issue #20: the GNSS receiver costs the LoRa radio 8 dB while it runs. If the user chose so,
+249
View File
@@ -0,0 +1,249 @@
#include "services/vpn_service.h"
#include <Arduino.h>
#include <SD.h>
#include <esp_wireguard.h>
#include <lwip/dns.h>
#include <lwip/tcpip.h>
#include "ipv4.h"
#include "platform/console.h"
namespace roro {
namespace {
constexpr uint32_t kRetryMs = 10000;
constexpr size_t kMaxConf = 4096;
// The library calls lwIP's raw functions and takes no lock; this build checks that the lock is
// held (CONFIG_LWIP_CHECK_THREAD_SAFETY) and stops the device when it isn't.
struct LwipLock {
LwipLock() { LOCK_TCPIP_CORE(); }
~LwipLock() { UNLOCK_TCPIP_CORE(); }
};
} // namespace
struct VpnService::Tunnel {
wireguard_config_t config = ESP_WIREGUARD_CONFIG_DEFAULT();
wireguard_ctx_t ctx = ESP_WIREGUARD_CONTEXT_DEFAULT();
std::string address, netmask; // what `config` points into, with config_'s own strings
bool inited = false, connected = false, isDefault = false, dnsIn = false;
ip_addr_t dnsBefore[2];
};
const char* VpnService::stateText() const {
switch (state_) {
case State::NoConfig: return "not set";
case State::Off: return "off";
case State::WaitingWifi: return "waiting for Wi-Fi";
case State::WaitingClock: return "waiting for the clock";
case State::Resolving: return "looking up the server";
case State::Trying: return "no answer yet";
case State::Up: return "up";
}
return "";
}
void VpnService::loadConfig() {
const std::string& stored = settings_.getString(Setting::VpnConfig);
configured_ = !stored.empty() && net::parseWgConf(stored, config_).empty();
if (!configured_) config_ = net::WgConfig();
}
void VpnService::start() {
loadConfig();
wanted_ = configured_ && settings_.getBool(Setting::VpnAuto);
state_ = !configured_ ? State::NoConfig : State::Off;
}
void VpnService::want(bool on, uint32_t seconds) {
wanted_ = on && configured_;
timed_ = wanted_ && seconds > 0;
untilMs_ = millis() + seconds * 1000;
retryMs_ = 0;
if (!wanted_) takeDown();
}
std::string VpnService::import(const std::string& confText) {
net::WgConfig fresh;
std::string why = net::parseWgConf(confText, fresh);
if (!why.empty()) return why;
if (!settings_.setString(Setting::VpnConfig, net::toWgConf(fresh))) return "it couldn't be stored";
takeDown(); // it comes back up by itself with the new one, if it was wanted
loadConfig();
state_ = State::Off;
return "";
}
std::string VpnService::importFile(StorageService& storage, const std::string& path) {
std::string text, why;
bool ran = storage.runAndWait([&]() {
File f = SD.open(path.c_str(), FILE_READ);
if (!f || f.isDirectory()) {
why = "there is no " + path;
return;
}
size_t size = f.size();
if (size > kMaxConf) why = "that file is too big to be a .conf";
else {
text.resize(size);
if (size && f.read(reinterpret_cast<uint8_t*>(&text[0]), size) != static_cast<int>(size)) why = "the card refused to read it";
}
f.close();
});
if (!ran) return "no SD card";
if (!why.empty()) return why;
return import(text);
}
void VpnService::forget() {
takeDown();
wanted_ = false;
settings_.setString(Setting::VpnConfig, "");
settings_.setBool(Setting::VpnAuto, false);
loadConfig();
state_ = State::NoConfig;
}
void VpnService::bringUp() {
if (!tunnel_) tunnel_ = new Tunnel();
Tunnel& t = *tunnel_;
net::WgRouting routing = net::routingOf(config_);
t.address = net::formatIpv4(config_.address);
t.netmask = net::formatIpv4(net::maskOf(routing.full ? config_.prefix : routing.prefix));
t.config.private_key = config_.privateKey.c_str();
t.config.public_key = config_.peerKey.c_str();
t.config.preshared_key = config_.presharedKey.empty() ? nullptr : config_.presharedKey.c_str();
t.config.address = t.address.c_str();
t.config.netmask = t.netmask.c_str();
t.config.endpoint = config_.endpointHost.c_str();
t.config.port = config_.endpointPort;
t.config.listen_port = config_.listenPort;
t.config.persistent_keepalive = static_cast<uint16_t>(config_.keepalive);
LwipLock lock;
esp_err_t err = ESP_OK;
if (!t.inited) {
err = esp_wireguard_init(&t.config, &t.ctx);
t.inited = err == ESP_OK;
}
if (err == ESP_OK) err = esp_wireguard_connect(&t.ctx);
if (err == ESP_ERR_RETRY) { // the server's name isn't resolved yet: asked again at the next tick
state_ = State::Resolving;
return;
}
if (err == ESP_OK) {
// What may come out of the tunnel, and with "everything", where every packet now goes. The
// tunnel's own packets don't: the library sends them on the interface it started on.
for (int i = 0; i < config_.allowedCount && err == ESP_OK; i++) {
std::string address = net::formatIpv4(config_.allowed[i].address), mask = net::formatIpv4(net::maskOf(config_.allowed[i].prefix));
err = esp_wireguard_add_allowed_ip(&t.ctx, address.c_str(), mask.c_str());
}
}
if (err != ESP_OK) {
error_ = std::string("the tunnel couldn't start (") + esp_err_to_name(err) + ")";
console.printf("vpn: error %s\n", error_.c_str());
esp_wireguard_disconnect(&t.ctx);
t = Tunnel();
retryMs_ = millis() + kRetryMs;
state_ = State::Trying;
return;
}
if (routing.full) t.isDefault = esp_wireguard_set_default(&t.ctx) == ESP_OK;
if (config_.mtu && t.ctx.netif) t.ctx.netif->mtu = static_cast<u16_t>(config_.mtu);
// The file's DNS servers, if they can be reached through the tunnel at all.
if (config_.dns[0] && net::wgReaches(config_, config_.dns[0])) {
t.dnsIn = true;
for (int i = 0; i < 2; i++) ip_addr_set_any(false, &t.dnsBefore[i]);
keepDns();
}
t.connected = true;
error_.clear();
announced_ = false;
lastHandshake_ = 0;
state_ = State::Trying;
console.printf("vpn: started, %s:%u, through it %s\n", config_.endpointHost.c_str(), (unsigned)config_.endpointPort, net::describeWgRouting(config_).c_str());
}
bool VpnService::dnsThroughIt() const { return tunnel_ && tunnel_->dnsIn; }
// With lwIP's lock held. What is found in the two slots, if it isn't the tunnel's, is what goes
// back when the tunnel stops: so a DHCP renewal while it is up is not lost.
void VpnService::keepDns() {
Tunnel& t = *tunnel_;
for (int i = 0; i < 2; i++) {
ip_addr_t wanted;
ip_addr_set_zero_ip4(&wanted);
if (config_.dns[i]) ip_addr_set_ip4_u32(&wanted, lwip_htonl(config_.dns[i]));
const ip_addr_t* now = dns_getserver(static_cast<u8_t>(i));
if (ip_addr_cmp(now, &wanted)) continue;
t.dnsBefore[i] = *now;
dns_setserver(static_cast<u8_t>(i), &wanted);
}
}
void VpnService::takeDown() {
if (tunnel_) {
Tunnel& t = *tunnel_;
bool dns = t.dnsIn;
{
LwipLock lock;
if (t.dnsIn)
for (int i = 0; i < 2; i++) dns_setserver(static_cast<u8_t>(i), &t.dnsBefore[i]);
if (t.isDefault) esp_wireguard_restore_default(&t.ctx);
if (t.inited) esp_wireguard_disconnect(&t.ctx);
}
delete tunnel_;
tunnel_ = nullptr;
if (dns) wifi_.holdDns(false);
console.println("vpn: stopped");
}
lastHandshake_ = 0;
state_ = !configured_ ? State::NoConfig : State::Off;
}
void VpnService::tick(uint32_t nowMs) {
if (timed_ && static_cast<int32_t>(nowMs - untilMs_) >= 0) want(false);
if (!configured_ || !wanted_) {
if (tunnel_) takeDown();
return;
}
// A tunnel doesn't outlive the network it was started on: the next one starts it afresh.
if (wifi_.state() != WifiController::State::Connected) {
if (tunnel_) takeDown();
state_ = State::WaitingWifi;
return;
}
if (clock_.utcNow() < 0) {
state_ = State::WaitingClock;
return;
}
if (!tunnel_ || !tunnel_->connected) {
if (retryMs_ && static_cast<int32_t>(nowMs - retryMs_) < 0) return;
retryMs_ = 0;
bringUp();
if (tunnel_ && tunnel_->dnsIn) wifi_.holdDns(true);
return;
}
bool up;
time_t last = 0;
{
LwipLock lock;
up = esp_wireguard_peer_is_up(&tunnel_->ctx) == ESP_OK;
esp_wireguard_latest_handshake(&tunnel_->ctx, &last);
if (tunnel_->dnsIn) keepDns();
}
if (last > 0) lastHandshake_ = static_cast<int64_t>(last);
State was = state_;
state_ = up ? State::Up : State::Trying;
if (state_ == State::Up && !announced_) {
announced_ = true;
bus_.publish(Event::withText(EventType::Notification, ("VPN up: " + config_.endpointHost).c_str(), static_cast<int32_t>(NotificationLevel::Info)));
} else if (was == State::Up && state_ == State::Trying) {
announced_ = false;
bus_.publish(Event::withText(EventType::Notification, "VPN: the server stopped answering", static_cast<int32_t>(NotificationLevel::Warning)));
}
}
} // namespace roro
+74
View File
@@ -0,0 +1,74 @@
#pragma once
#include <string>
#include "event_bus.h"
#include "service.h"
#include "services/clock_service.h"
#include "services/storage_service.h"
#include "services/wifi_service.h"
#include "settings.h"
#include "wg_config.h"
namespace roro {
// The WireGuard tunnel (issue #8, docs/milestones/N1.md): one peer, IPv4, over whatever Wi-Fi the
// device is on. The protocol is the `esphome/wireguard` library's; this decides when the tunnel
// is up, takes lwIP's lock around every call into it (the library takes none), and puts the
// tunnel's DNS servers in and out.
//
// It starts once Wi-Fi is connected and the clock is set: a handshake carries the time, and a
// server refuses one older than the last it saw from this key.
class VpnService : public Service {
public:
enum class State { NoConfig, Off, WaitingWifi, WaitingClock, Resolving, Trying, Up };
VpnService(Settings& settings, WifiService& wifi, ClockService& clock, EventBus& bus)
: settings_(settings), wifi_(wifi), clock_(clock), bus_(bus) {}
const char* name() const override { return "vpn"; }
void start() override;
void stop() override { takeDown(); }
void tick(uint32_t nowMs) override;
State state() const { return state_; }
const char* stateText() const;
bool configured() const { return configured_; }
const net::WgConfig& config() const { return config_; } // its keys are for the library only
bool wanted() const { return wanted_; }
// On or off, until the next restart; `seconds`: on for that long, then off by itself (for
// trying a configuration from afar, when a wrong one would cut the connection it was sent over).
void want(bool on, uint32_t seconds = 0);
// A `.conf`'s text, or the file itself. "" or why it wasn't taken. A tunnel that is up starts
// again with the new one.
std::string import(const std::string& confText);
std::string importFile(StorageService& storage, const std::string& path);
void forget();
bool dnsThroughIt() const; // the tunnel's DNS servers are the ones in use
int64_t lastHandshake() const { return lastHandshake_; } // UTC seconds, 0: none yet
const std::string& lastError() const { return error_; }
private:
struct Tunnel; // the library's structures, kept out of this header
void bringUp();
void takeDown();
void loadConfig();
void keepDns(); // puts the tunnel's servers back in if a DHCP renewal replaced them
Settings& settings_;
WifiService& wifi_;
ClockService& clock_;
EventBus& bus_;
net::WgConfig config_;
bool configured_ = false, wanted_ = false, announced_ = false;
State state_ = State::NoConfig;
Tunnel* tunnel_ = nullptr;
uint32_t untilMs_ = 0, retryMs_ = 0;
bool timed_ = false;
int64_t lastHandshake_ = 0;
std::string error_;
};
} // namespace roro
+335
View File
@@ -0,0 +1,335 @@
#include "services/web_share.h"
#include <Arduino.h>
#include <SD.h>
#include <esp_http_server.h>
#include <esp_random.h>
#include <memory>
#include "file_list.h"
#include "file_names.h"
#include "platform/console.h"
#include "services/web_share_page.h"
namespace roro {
namespace {
constexpr size_t kPiece = 8192; // read from or written to the card at once
constexpr size_t kMaxListed = 300;
WebShare& shareOf(httpd_req_t* req) { return *static_cast<WebShare*>(req->user_ctx); }
esp_err_t reply(httpd_req_t* req, const char* status, const std::string& text) {
httpd_resp_set_status(req, status);
httpd_resp_set_type(req, "text/plain; charset=utf-8");
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
return httpd_resp_send(req, text.c_str(), static_cast<ssize_t>(text.size()));
}
std::string queryOf(httpd_req_t* req) {
size_t len = httpd_req_get_url_query_len(req);
if (!len || len > 600) return "";
std::string q(len + 1, '\0');
if (httpd_req_get_url_query_str(req, &q[0], len + 1) != ESP_OK) return "";
q.resize(len);
return q;
}
// The request's path, checked; or an answer already sent and false.
bool pathOf(httpd_req_t* req, std::string& path) {
std::string query = queryOf(req);
if (!files::queryParam(query, "path", path)) return reply(req, "400 Bad Request", "No path"), false;
std::string why = files::checkSharePath(path);
if (!why.empty()) return reply(req, "400 Bad Request", why), false;
return true;
}
// Has this browser typed the code? If not: 401, and false.
bool allowed(httpd_req_t* req) {
char cookie[160] = "";
httpd_req_get_hdr_value_str(req, "Cookie", cookie, sizeof cookie);
if (shareOf(req).auth().allowed(files::cookieValue(cookie, "s"))) return true;
reply(req, "401 Unauthorized", "The code, please");
return false;
}
esp_err_t noCard(httpd_req_t* req) { return reply(req, "503 Service Unavailable", "No SD card"); }
esp_err_t page(httpd_req_t* req) {
httpd_resp_set_type(req, "text/html; charset=utf-8");
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
return httpd_resp_send(req, kWebSharePage, sizeof kWebSharePage - 1);
}
esp_err_t login(httpd_req_t* req) {
char body[64] = "";
int n = httpd_req_recv(req, body, std::min<size_t>(req->content_len, sizeof body - 1));
std::string code;
files::queryParam(n > 0 ? std::string(body, static_cast<size_t>(n)) : "", "code", code);
uint8_t random[16];
esp_fill_random(random, sizeof random);
std::string token;
switch (shareOf(req).auth().login(code, millis(), random, token)) {
case files::ShareAuth::Result::Ok: {
std::string cookie = "s=" + token + "; HttpOnly; SameSite=Strict; Path=/";
httpd_resp_set_hdr(req, "Set-Cookie", cookie.c_str());
shareOf(req).note("a browser opened", "");
return reply(req, "200 OK", "ok");
}
case files::ShareAuth::Result::Locked: return reply(req, "429 Too Many Requests", "Too many wrong codes: wait a minute");
default: return reply(req, "403 Forbidden", "That isn't the code on the screen");
}
}
esp_err_t list(httpd_req_t* req) {
std::string path;
if (!allowed(req) || !pathOf(req, path)) return ESP_OK;
WebShare& share = shareOf(req);
std::string json, why;
bool ran = share.storage().runAndWait([&]() {
File dir = SD.open(path.c_str());
if (!dir || !dir.isDirectory()) {
why = "No such folder";
return;
}
files::ShareListing listing(path);
bool more = false;
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
if (listing.count() >= kMaxListed) {
more = true;
f.close();
break;
}
listing.add(f.name(), static_cast<uint32_t>(f.size()), f.isDirectory(), static_cast<int64_t>(f.getLastWrite()));
f.close();
}
dir.close();
json = listing.json(more);
});
if (!ran) return noCard(req);
if (!why.empty()) return reply(req, "404 Not Found", why);
share.note("listed", path);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
return httpd_resp_send(req, json.c_str(), static_cast<ssize_t>(json.size()));
}
esp_err_t download(httpd_req_t* req) {
std::string path;
if (!allowed(req) || !pathOf(req, path)) return ESP_OK;
WebShare& share = shareOf(req);
auto file = std::make_shared<File>();
std::unique_ptr<char[]> piece(new (std::nothrow) char[kPiece]);
if (!piece) return reply(req, "503 Service Unavailable", "Not enough memory");
bool found = false;
if (!share.storage().runAndWait([&]() {
*file = SD.open(path.c_str(), FILE_READ);
found = *file && !file->isDirectory();
if (*file && !found) file->close();
}))
return noCard(req);
if (!found) return reply(req, "404 Not Found", "No such file");
share.note("sent", path);
std::string disposition = "attachment; filename=" + files::jsonString(files::baseName(path));
httpd_resp_set_type(req, "application/octet-stream");
httpd_resp_set_hdr(req, "Content-Disposition", disposition.c_str());
esp_err_t err = ESP_OK;
for (;;) {
int n = 0;
if (!share.storage().runAndWait([&]() { n = file->read(reinterpret_cast<uint8_t*>(piece.get()), kPiece); })) {
err = ESP_FAIL;
break;
}
if (n <= 0) break;
err = httpd_resp_send_chunk(req, piece.get(), n);
if (err != ESP_OK) break; // the browser went away
share.counted(0, static_cast<uint32_t>(n));
}
share.storage().runJob([file]() { file->close(); });
if (err == ESP_OK) return httpd_resp_send_chunk(req, nullptr, 0);
return ESP_FAIL; // closes the connection: the browser sees a download cut short, not a whole file
}
// The file arrives as the request's body and goes to the card a piece at a time, under a
// temporary name: the real one only ever holds a whole file.
esp_err_t upload(httpd_req_t* req) {
std::string path;
if (!allowed(req) || !pathOf(req, path)) return ESP_OK;
WebShare& share = shareOf(req);
std::string replace;
bool mayReplace = files::queryParam(queryOf(req), "replace", replace) && replace == "1";
std::string name = files::baseName(path), why = name.empty() ? "No name" : files::checkName(name);
if (!why.empty()) return reply(req, "400 Bad Request", why);
std::string part = path + ".part";
auto file = std::make_shared<File>();
std::unique_ptr<char[]> piece(new (std::nothrow) char[kPiece]);
if (!piece) return reply(req, "503 Service Unavailable", "Not enough memory");
const char* status = nullptr;
size_t total = req->content_len;
bool ran = share.storage().runAndWait([&]() {
File parent = SD.open(files::parentOf(path).c_str());
bool folder = parent && parent.isDirectory();
if (parent) parent.close();
if (!folder) {
status = "404 Not Found";
why = "No such folder";
return;
}
if (SD.exists(path.c_str())) {
File there = SD.open(path.c_str());
bool isFolder = there && there.isDirectory();
if (there) there.close();
std::string readOnly = isFolder ? "A folder has that name" : mayReplace ? share.ops().whyReadOnly(path, false) : "";
if (!readOnly.empty()) {
status = "403 Forbidden";
why = readOnly;
return;
}
if (!mayReplace) {
status = "409 Conflict";
why = "It is there already";
return;
}
}
StorageState state = share.storage().state();
if (state.totalBytes - state.usedBytes < static_cast<uint64_t>(total) + 65536) {
status = "507 Insufficient Storage";
why = "Not enough room on the card";
return;
}
*file = SD.open(part.c_str(), FILE_WRITE);
if (!*file) {
status = "500 Internal Server Error";
why = "The card refused to make the file";
}
});
if (!ran) return noCard(req);
if (status) return reply(req, status, why);
share.note("receiving", path);
size_t got = 0;
int idle = 0;
bool wrote = true;
while (got < total && wrote) {
int n = httpd_req_recv(req, piece.get(), std::min(kPiece, total - got));
if (n == HTTPD_SOCK_ERR_TIMEOUT && ++idle < 3) continue;
if (n <= 0) break;
idle = 0;
if (!share.storage().runAndWait([&]() { wrote = file->write(reinterpret_cast<const uint8_t*>(piece.get()), static_cast<size_t>(n)) == static_cast<size_t>(n); })) wrote = false;
got += static_cast<size_t>(n);
share.counted(static_cast<uint32_t>(n), 0);
}
bool whole = wrote && got == total, placed = false;
share.storage().runAndWait([&]() {
file->close();
if (whole) {
if (SD.exists(path.c_str())) SD.remove(path.c_str());
placed = SD.rename(part.c_str(), path.c_str());
}
if (!placed) SD.remove(part.c_str());
});
if (placed) {
share.note("received", path);
return reply(req, "200 OK", "ok");
}
if (got < total) return ESP_FAIL; // the browser went away, or the network did
return reply(req, "500 Internal Server Error", "The card refused a write");
}
esp_err_t remove(httpd_req_t* req) {
std::string path;
if (!allowed(req) || !pathOf(req, path)) return ESP_OK;
WebShare& share = shareOf(req);
if (path == "/") return reply(req, "403 Forbidden", "Not the card itself");
std::string why;
const char* status = "403 Forbidden";
bool ran = share.storage().runAndWait([&]() {
File f = SD.open(path.c_str());
if (!f) {
status = "404 Not Found";
why = "It isn't there";
return;
}
bool folder = f.isDirectory();
f.close();
why = share.ops().whyReadOnly(path, folder);
if (!why.empty()) return;
if (folder ? !SD.rmdir(path.c_str()) : !SD.remove(path.c_str())) why = folder ? "That folder isn't empty: delete what is in it first" : "The card refused";
});
if (!ran) return noCard(req);
if (!why.empty()) return reply(req, status, why);
share.note("deleted", path);
return reply(req, "200 OK", "ok");
}
esp_err_t makeFolder(httpd_req_t* req) {
std::string path;
if (!allowed(req) || !pathOf(req, path)) return ESP_OK;
WebShare& share = shareOf(req);
std::string why = files::checkName(files::baseName(path));
if (!why.empty()) return reply(req, "400 Bad Request", why);
bool ran = share.storage().runAndWait([&]() {
if (SD.exists(path.c_str())) why = "It is there already";
else if (!SD.mkdir(path.c_str())) why = "The card refused";
});
if (!ran) return noCard(req);
if (!why.empty()) return reply(req, "409 Conflict", why);
share.note("made", path);
return reply(req, "200 OK", "ok");
}
} // namespace
void WebShare::note(const char* what, const std::string& path) {
requests_++;
std::string text = path.empty() ? what : std::string(what) + " " + files::fitName(files::baseName(path).empty() ? "/" : files::baseName(path), 26);
strlcpy(last_, text.c_str(), sizeof last_);
}
std::string WebShare::last() const { return last_; }
std::string WebShare::url() const {
std::string ip = wifi_.ip();
return ip.empty() ? "" : "http://" + ip + "/";
}
std::string WebShare::start() {
if (server_) return "";
if (wifi_.ip().empty()) return "Wi-Fi isn't connected";
if (!storage_.state().present) return "No SD card";
uint8_t random[4];
esp_fill_random(random, sizeof random);
auth_.begin(random);
requests_ = bytesIn_ = bytesOut_ = 0;
last_[0] = 0;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.stack_size = 8192;
config.max_open_sockets = 4;
config.lru_purge_enable = true; // a phone's browser opens more connections than it closes
config.max_uri_handlers = 8;
config.recv_wait_timeout = 10;
config.send_wait_timeout = 10;
httpd_handle_t server = nullptr;
if (httpd_start(&server, &config) != ESP_OK) return "The server couldn't start";
const httpd_uri_t routes[] = {
{"/", HTTP_GET, page, this}, {"/api/login", HTTP_POST, login, this}, {"/api/list", HTTP_GET, list, this},
{"/dl", HTTP_GET, download, this}, {"/up", HTTP_PUT, upload, this}, {"/api/delete", HTTP_POST, remove, this},
{"/api/mkdir", HTTP_POST, makeFolder, this},
};
for (const auto& r : routes) httpd_register_uri_handler(server, &r);
server_ = server;
console.printf("share: on at %s\n", url().c_str());
return "";
}
void WebShare::stop() {
if (!server_) return;
httpd_stop(static_cast<httpd_handle_t>(server_));
server_ = nullptr;
uint8_t zero[4] = {0, 0, 0, 0};
auth_.begin(zero); // nobody is logged in any more
console.println("share: off");
}
} // namespace roro
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <atomic>
#include <string>
#include "services/file_ops.h"
#include "services/storage_service.h"
#include "services/wifi_service.h"
#include "share_rules.h"
namespace roro {
// Sharing the SD card with a browser on the same network (issue #88): a small HTTP server with
// one page and a few requests behind it: list, download, upload, new folder, delete. It runs only
// between start() and stop(), which the Storage App's "Share" screen calls on opening and
// closing. Whoever has typed the code that screen shows may use it; nothing is encrypted.
//
// The server has its own task. Every access to the card is handed to the storage task, a piece
// at a time, as everywhere else.
class WebShare {
public:
WebShare(StorageService& storage, FileOps& ops, WifiService& wifi) : storage_(storage), ops_(ops), wifi_(wifi) {}
std::string start(); // "" or why it can't
void stop();
bool running() const { return server_ != nullptr; }
std::string url() const; // http://<address>/
const std::string& code() const { return auth_.code(); }
// For the screen.
uint32_t requests() const { return requests_; }
uint32_t bytesIn() const { return bytesIn_; }
uint32_t bytesOut() const { return bytesOut_; }
std::string last() const; // the last thing asked for
// Used by the request handlers (web_share.cpp).
StorageService& storage() { return storage_; }
FileOps& ops() { return ops_; }
files::ShareAuth& auth() { return auth_; }
void note(const char* what, const std::string& path);
void counted(uint32_t in, uint32_t out) {
bytesIn_ += in;
bytesOut_ += out;
}
private:
StorageService& storage_;
FileOps& ops_;
WifiService& wifi_;
files::ShareAuth auth_;
void* server_ = nullptr; // httpd_handle_t
std::atomic<uint32_t> requests_{0}, bytesIn_{0}, bytesOut_{0};
char last_[56] = "";
};
} // namespace roro
+69
View File
@@ -0,0 +1,69 @@
#pragma once
// The page a phone's browser gets (issue #88): one file, no other request but the API's. Kept
// small: it is sent from flash as it is.
namespace roro {
inline constexpr char kWebSharePage[] = R"HTML(<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>roro9stack files</title>
<style>
:root{color-scheme:dark light;--bg:#000;--fg:#fff;--mu:#9ab;--ac:#2cf;--ln:#345;--wa:#fa3}
@media (prefers-color-scheme:light){:root{--bg:#fff;--fg:#012;--mu:#567;--ac:#06a;--ln:#bcd;--wa:#b50}}
*{box-sizing:border-box}body{margin:0;font:16px/1.4 system-ui,sans-serif;background:var(--bg);color:var(--fg)}
main{max-width:720px;margin:0 auto;padding:12px}h1{font-size:18px;margin:4px 0 12px}
button,input,.btn{font:inherit;min-height:44px;padding:0 14px;border:1px solid var(--ln);background:none;color:inherit;border-radius:6px}
button,.btn{cursor:pointer}.main{background:var(--ac);color:#000;border-color:var(--ac)}
#crumbs{margin:8px 0;word-break:break-all}#crumbs a{color:var(--ac);text-decoration:none;padding:6px 2px;display:inline-block}
ul{list-style:none;margin:0;padding:0}li{display:flex;align-items:center;gap:8px;border-top:1px solid var(--ln);min-height:48px}
li a{flex:1;color:inherit;text-decoration:none;padding:10px 0;word-break:break-all}li.d a{color:var(--ac)}
li small{color:var(--mu);white-space:nowrap}li button{min-height:36px;padding:0 10px;color:var(--mu)}
#bar{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}#msg{color:var(--wa);min-height:24px;margin:8px 0}
progress{width:100%}.hide{display:none}#login input{width:9em;font-size:22px;letter-spacing:.15em;text-align:center}
</style></head><body><main>
<h1>roro9stack: the SD card</h1>
<form id="login" class="hide"><p>The code on the device's screen:</p>
<input id="code" inputmode="numeric" autocomplete="off" maxlength="7" autofocus> <button class="main">Open</button></form>
<div id="app" class="hide">
<div id="crumbs"></div>
<div id="bar"><label class="btn main" style="display:inline-flex;align-items:center">Upload files<input id="pick" type="file" multiple class="hide"></label>
<button id="mk">New folder</button></div>
<progress id="prog" class="hide" max="100" value="0"></progress>
<ul id="list"></ul></div>
<p id="msg"></p>
</main><script>
const $=i=>document.getElementById(i),E=encodeURIComponent;let cwd='/';
const say=t=>{$('msg').textContent=t||''};
const join=(d,n)=>d=='/'?'/'+n:d+'/'+n;
const size=s=>s<1024?s+' B':s<1048576?(s/1024).toFixed(1)+' KB':(s/1048576).toFixed(1)+' MB';
function show(app){$('login').classList.toggle('hide',app);$('app').classList.toggle('hide',!app)}
async function call(u,o){const r=await fetch(u,o);if(r.status==401){show(false);throw new Error('The code, please')}
if(!r.ok)throw new Error(await r.text()||('Error '+r.status));return r}
async function login(code){const r=await fetch('/api/login',{method:'POST',body:'code='+E(code)});
if(r.ok){show(true);list('/')}else say(await r.text())}
async function list(p){try{const j=await(await call('/api/list?path='+E(p))).json();cwd=j.path;say(j.more?'Only the first '+j.items.length+' are shown':'');
const c=$('crumbs');c.textContent='';let at='';const parts=['/'].concat(cwd.split('/').filter(x=>x));
parts.forEach((n,i)=>{at=i?join(at||'/',n):'/';const a=document.createElement('a');a.href='#';a.textContent=i?n:'SD card';const to=at;a.onclick=e=>{e.preventDefault();list(to)};
if(i)c.append(' / ');c.append(a)});
const ul=$('list');ul.textContent='';j.items.sort((a,b)=>b.d-a.d||a.n.localeCompare(b.n));
if(!j.items.length){const li=document.createElement('li');li.innerHTML='<small>Nothing here</small>';ul.append(li)}
for(const it of j.items){const li=document.createElement('li'),a=document.createElement('a'),s=document.createElement('small'),b=document.createElement('button'),full=join(cwd,it.n);
a.textContent=it.n+(it.d?'/':'');if(it.d){li.className='d';a.href='#';a.onclick=e=>{e.preventDefault();list(full)}}else{a.href='/dl?path='+E(full);a.download=it.n;s.textContent=size(it.s)}
b.textContent='Delete';b.onclick=async()=>{if(!confirm('Delete '+it.n+'?'))return;try{await call('/api/delete?path='+E(full),{method:'POST'});list(cwd)}catch(e){say(e.message)}};
li.append(a,s,b);ul.append(li)}}catch(e){say(e.message)}}
function put(f,replace){return new Promise((ok,no)=>{const x=new XMLHttpRequest();x.open('PUT','/up?path='+E(join(cwd,f.name))+(replace?'&replace=1':''));
x.upload.onprogress=e=>{if(e.lengthComputable)$('prog').value=100*e.loaded/e.total};
x.onload=()=>x.status==200?ok():x.status==409&&!replace&&confirm(f.name+' is there already. Replace it?')?put(f,1).then(ok,no):x.status==409?ok():no(new Error(x.responseText||'Error '+x.status));
x.onerror=()=>no(new Error('The connection was lost'));x.send(f)})}
$('pick').onchange=async e=>{const fs=[...e.target.files];$('prog').classList.remove('hide');
try{let n=0;for(const f of fs){say('Uploading '+f.name+' ('+(++n)+' of '+fs.length+')');$('prog').value=0;await put(f)}say('')}catch(err){say(err.message)}
$('prog').classList.add('hide');e.target.value='';list(cwd)};
$('mk').onclick=async()=>{const n=prompt('The new folder\'s name');if(!n)return;try{await call('/api/mkdir?path='+E(join(cwd,n)),{method:'POST'});list(cwd)}catch(e){say(e.message)}};
$('login').onsubmit=e=>{e.preventDefault();login($('code').value)};
async function start(){const h=location.hash.slice(1);if(h){history.replaceState(null,'','/');await login(h)}
else{const r=await fetch('/api/list?path=%2F');if(r.ok){show(true);list('/')}else show(false)}}
onhashchange=start;start();
</script></body></html>
)HTML";
} // namespace roro
+11 -1
View File
@@ -52,6 +52,14 @@ void WifiService::ipSettingChanged(const std::string& ssid) {
controller_.retryNow(millis());
}
void WifiService::holdDns(bool held) {
if (dnsHeld_ == held) return;
dnsHeld_ = held;
// Whoever held them puts back what it found (DHCP's servers can't be asked for again without
// a new lease, which would drop every connection); ours are checked right away.
if (!held) applyServers(Why::Check);
}
// DNS and NTP as decided in Q108 and Q110. Run when connected, when a setting changes, and now
// and then: a DHCP renewal puts DHCP's DNS back and clears the NTP slots it didn't fill.
void WifiService::applyServers(Why why) {
@@ -61,7 +69,9 @@ void WifiService::applyServers(Why why) {
bool wasFromSettings = dnsFromSettings_;
dnsFromSettings_ = fixed_ || settings_.getBool(Setting::DnsAlways);
if (dnsFromSettings_) {
if (dnsHeld_) {
// a tunnel's servers are in: see holdDns()
} else if (dnsFromSettings_) {
IPAddress dns1 = toIp(settings_.getString(Setting::Dns1)), dns2 = toIp(settings_.getString(Setting::Dns2));
if (WiFi.dnsIP(0) != dns1 || WiFi.dnsIP(1) != dns2) WiFi.setDNS(dns1, dns2);
} else if (why == Why::SettingsChanged && wasFromSettings) {
+3
View File
@@ -56,6 +56,8 @@ class WifiService : public Service {
void ipSettingChanged(const std::string& ssid);
// The DNS or NTP settings changed: use them now.
void serversChanged() { applyServers(Why::SettingsChanged); }
// While a tunnel has put its own DNS servers in (issue #8), ours are not put back over them.
void holdDns(bool held);
// The noise self-test switches the radio off for a few seconds. Not saved anywhere: a restart
// during the test brings Wi-Fi back, which a changed setting wouldn't.
void debugPause(bool paused) { paused_ = paused; }
@@ -89,6 +91,7 @@ class WifiService : public Service {
bool paused_ = false; // Debug Builds: off for a moment, whatever the setting says
bool fixed_ = false; // the network in use has a Fixed address
bool dnsFromSettings_ = false;
bool dnsHeld_ = false;
std::string ntpNames_[2]; // lwIP keeps the pointers, so the names live here
uint32_t serversCheckedMs_ = 0;
};
+16 -2
View File
@@ -13,10 +13,23 @@ bool Screen::begin() {
}
void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast) {
canvas_.fillSprite(theme::kBackground);
const auto& area = theme::kContent;
App& app = apps.foreground();
// An App that keeps what it drew: only the Status Bar is cleared, unless the App has changed
// or a Toast or the help panel, drawn over it, has just gone.
bool overlay = apps.help().isOpen() || toast, retains = app.retainsContent();
if (overlayWas_ && !overlay) lost_ = true;
if (retains && &app == lastApp_ && !lost_) {
canvas_.fillRect(0, 0, theme::kWidth, area.y, theme::kBackground);
} else {
canvas_.fillSprite(theme::kBackground);
if (retains) app.contentLost();
}
lastApp_ = &app;
lost_ = false;
overlayWas_ = overlay && retains;
canvas_.setClipRect(area.x, area.y, area.w, area.h);
apps.foreground().draw(canvas_);
app.draw(canvas_);
if (apps.help().isOpen()) widgets::help(canvas_, apps.help()); // over the App, under the Status Bar
canvas_.clearClipRect();
widgets::statusBar(canvas_, status);
@@ -25,6 +38,7 @@ void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toa
}
void Screen::renderUpdate(const std::string& title, const std::string& detail, int percent) {
lost_ = true;
canvas_.fillSprite(theme::kBackground);
canvas_.setTextDatum(top_center);
canvas_.setFont(&fonts::bold);
+2
View File
@@ -17,6 +17,8 @@ class Screen {
private:
Canvas canvas_;
const App* lastApp_ = nullptr; // for an App that keeps what it drew (App::retainsContent)
bool lost_ = true, overlayWas_ = false;
};
} // namespace roro
+5
View File
@@ -48,6 +48,11 @@ void statusBar(Canvas& c, const StatusInfo& info) {
case StatusInfo::Wifi::Monitoring: right("MON", kAccent); break;
case StatusInfo::Wifi::None: break;
}
switch (info.vpn) { // Q252: there while the tunnel is wanted, bright once the peer has answered
case StatusInfo::Vpn::Trying: right("VPN", kMuted); break;
case StatusInfo::Vpn::Up: right("VPN", kAccent); break;
case StatusInfo::Vpn::Off: break;
}
switch (info.debug) { // Q190: there while the console listens, bright with someone connected
case StatusInfo::Debug::On: right("DBG", kMuted); break;
case StatusInfo::Debug::Client: right("DBG", kAccent); break;
+2 -1
View File
@@ -31,12 +31,13 @@ struct StatusInfo {
enum class Radio { None, Listening, Packet, Sweep } radio = Radio::None; // M3, Q101: Packet flashes
bool capturing = false; // a LoRa Capture is recording (Q97)
enum class Debug { Off, On, Client } debug = Debug::Off; // the Debug Console listens (ADR 0010, Q190)
enum class Vpn { Off, Trying, Up } vpn = Vpn::Off; // the WireGuard tunnel (issue #8, Q252)
bool operator==(const StatusInfo& o) const {
return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock &&
sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose && wifi == o.wifi &&
wifiBars == o.wifiBars && unread == o.unread && gnss == o.gnss && gnssSatellites == o.gnssSatellites &&
tracking == o.tracking && radio == o.radio && capturing == o.capturing && debug == o.debug;
tracking == o.tracking && radio == o.radio && capturing == o.capturing && debug == o.debug && vpn == o.vpn;
}
bool operator!=(const StatusInfo& o) const { return !(*this == o); }
};
File diff suppressed because one or more lines are too long
+293
View File
@@ -0,0 +1,293 @@
#include <unity.h>
#include <cstring>
#include <string>
#include <vector>
#include "image_file.h"
#include "images.h"
#include "png_rgb332.h"
using namespace roro::files;
void setUp() {}
void tearDown() {}
namespace {
ImageRead from(const std::string& bytes) {
return [&bytes](uint32_t at, uint8_t* into, size_t len) -> size_t {
if (at >= bytes.size()) return 0;
size_t n = std::min(len, bytes.size() - at);
std::memcpy(into, bytes.data() + at, n);
return n;
};
}
template <size_t N>
std::string str(const uint8_t (&a)[N]) { return std::string(reinterpret_cast<const char*>(a), N); }
// The palette and the two patterns the test files were drawn with.
void colour(int i, uint8_t* rgb) {
rgb[0] = static_cast<uint8_t>((i * 7) % 256);
rgb[1] = static_cast<uint8_t>((255 - i * 3) % 256);
rgb[2] = static_cast<uint8_t>((i * 13 + 40) % 256);
}
int small(int x, int y) { return (x * 3 + y * 5) % 7; }
int big(int x, int y) { return (x * x + y * y * 3 + x * y) & 0xFF; }
struct Picture {
int w, h;
std::vector<int> rgb; // -1: never drawn
Picture(int width, int height) : w(width), h(height), rgb(static_cast<size_t>(width * height), -1) {}
ImagePixels sink() {
return [this](int x, int y, int count, const uint8_t* p) {
for (int i = 0; i < count; i++) {
TEST_ASSERT_TRUE(x + i >= 0 && x + i < w && y >= 0 && y < h);
rgb[static_cast<size_t>(y * w + x + i)] = (p[i * 3] << 16) | (p[i * 3 + 1] << 8) | p[i * 3 + 2];
}
};
}
};
void checkGif(const std::string& file, int w, int h, int (*index)(int, int), int transparent = -1) {
Picture p(w, h);
TEST_ASSERT_EQUAL_STRING("", readGif(from(file), static_cast<uint32_t>(file.size()), p.sink()).c_str());
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++) {
uint8_t c[3];
colour(index(x, y), c);
int want = index(x, y) == transparent ? -1 : (c[0] << 16) | (c[1] << 8) | c[2];
char msg[40];
std::snprintf(msg, sizeof msg, "at %d,%d", x, y);
TEST_ASSERT_EQUAL_HEX32_MESSAGE(want, p.rgb[static_cast<size_t>(y * w + x)], msg);
}
}
} // namespace
void test_kinds() {
TEST_ASSERT_TRUE(imageKindOfName("Photo.JPG") == ImageKind::Jpeg);
TEST_ASSERT_TRUE(imageKindOfName("a.jpeg") == ImageKind::Jpeg);
TEST_ASSERT_TRUE(imageKindOfName("20261007-104357.png") == ImageKind::Png);
TEST_ASSERT_TRUE(imageKindOfName("x.bmp") == ImageKind::Bmp);
TEST_ASSERT_TRUE(imageKindOfName("x.gif") == ImageKind::Gif);
TEST_ASSERT_TRUE(imageKindOfName("notes.txt") == ImageKind::None);
const uint8_t jpeg[] = {0xFF, 0xD8, 0xFF, 0xE0};
TEST_ASSERT_TRUE(imageKindOfBytes(jpeg, 4) == ImageKind::Jpeg);
TEST_ASSERT_TRUE(imageKindOfBytes(kGifSmall, 6) == ImageKind::Gif);
TEST_ASSERT_TRUE(imageKindOfBytes(kBmp24, 8) == ImageKind::Bmp);
TEST_ASSERT_TRUE(imageKindOfBytes(reinterpret_cast<const uint8_t*>("Hello"), 5) == ImageKind::None);
}
void test_sizes() {
ImageInfo info;
std::string gif = str(kGifBig), bmp = str(kBmp24);
TEST_ASSERT_EQUAL_STRING("", imageInfo(from(gif), static_cast<uint32_t>(gif.size()), info).c_str());
TEST_ASSERT_TRUE(info.kind == ImageKind::Gif && info.width == 96 && info.height == 64);
TEST_ASSERT_EQUAL_STRING("", imageInfo(from(bmp), static_cast<uint32_t>(bmp.size()), info).c_str());
TEST_ASSERT_TRUE(info.kind == ImageKind::Bmp && info.width == 13 && info.height == 7);
// A JPEG: start, an application segment, a table, then the frame header with the size.
std::string jpeg("\xFF\xD8", 2);
jpeg += std::string("\xFF\xE1\x00\x08" "Exif\0\0", 10);
jpeg += std::string("\xFF\xDB\x00\x04\x00\x00", 6);
std::string frame("\xFF\xC0\x00\x11\x08\x0B\xB8\x0F\xA0\x03", 10); // 3000 high, 4000 wide
std::string baseline = jpeg + frame + std::string(20, '\0');
TEST_ASSERT_EQUAL_STRING("", imageInfo(from(baseline), static_cast<uint32_t>(baseline.size()), info).c_str());
TEST_ASSERT_TRUE(info.kind == ImageKind::Jpeg && info.width == 4000 && info.height == 3000);
frame[1] = '\xC2';
std::string progressive = jpeg + frame + std::string(20, '\0');
TEST_ASSERT_EQUAL_STRING("A progressive JPEG can't be shown", imageInfo(from(progressive), static_cast<uint32_t>(progressive.size()), info).c_str());
std::string cut = jpeg;
TEST_ASSERT_EQUAL_STRING("This JPEG is damaged", imageInfo(from(cut), static_cast<uint32_t>(cut.size()), info).c_str());
std::string text = "Just some words, not a picture at all.";
TEST_ASSERT_EQUAL_STRING("Not a picture this can show", imageInfo(from(text), static_cast<uint32_t>(text.size()), info).c_str());
}
void test_a_screenshot_is_read_without_decoding() {
const int w = 240, h = 135;
std::string file;
roro::png::Rgb332Writer writer(w, h, [&](const uint8_t* d, size_t n) {
file.append(reinterpret_cast<const char*>(d), n);
return true;
});
TEST_ASSERT_TRUE(writer.begin());
std::vector<uint8_t> row(w);
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) row[static_cast<size_t>(x)] = static_cast<uint8_t>(x * 3 + y * 7);
TEST_ASSERT_TRUE(writer.row(row.data()));
}
TEST_ASSERT_TRUE(writer.end());
ImageInfo info;
TEST_ASSERT_EQUAL_STRING("", imageInfo(from(file), static_cast<uint32_t>(file.size()), info).c_str());
TEST_ASSERT_TRUE(info.kind == ImageKind::Png && info.width == w && info.height == h);
uint32_t at = screenshotPixelsAt(from(file), static_cast<uint32_t>(file.size()), w, h);
TEST_ASSERT_TRUE(at > 0);
for (int y : {0, 1, 77, 134})
for (int x : {0, 5, 239})
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(x * 3 + y * 7), static_cast<uint8_t>(file[at + static_cast<uint32_t>(y * (w + 1) + x)]));
// Another PNG isn't taken for one: a byte more, a compressed block.
std::string longer = file + "x";
TEST_ASSERT_EQUAL_UINT32(0, screenshotPixelsAt(from(longer), static_cast<uint32_t>(longer.size()), w, h));
std::string other = file;
other[8 + 25 + 780 + 10] = 0x05;
TEST_ASSERT_EQUAL_UINT32(0, screenshotPixelsAt(from(other), static_cast<uint32_t>(other.size()), w, h));
}
void test_dithering_leaves_the_screens_own_colours_alone() {
for (int v = 0; v < 256; v++) {
uint8_t r = static_cast<uint8_t>((v >> 5) * 255 / 7), g = static_cast<uint8_t>(((v >> 2) & 7) * 255 / 7), b = static_cast<uint8_t>((v & 3) * 85);
for (int y = 0; y < 4; y++)
for (int x = 0; x < 4; x++) TEST_ASSERT_EQUAL_UINT8(v, rgb332Dithered(r, g, b, x, y));
}
// A grey between two levels comes out as a mix of both, in proportion.
int high = 0;
for (int y = 0; y < 4; y++)
for (int x = 0; x < 4; x++) {
int red = rgb332Dithered(54, 0, 0, x, y) >> 5; // half way from 36 to 72
TEST_ASSERT_TRUE(red == 1 || red == 2);
high += red == 2;
}
TEST_ASSERT_TRUE(high >= 6 && high <= 10);
TEST_ASSERT_EQUAL_UINT8(0xFF, rgb332Dithered(255, 255, 255, 1, 2));
TEST_ASSERT_EQUAL_UINT8(0x00, rgb332Dithered(0, 0, 0, 3, 3));
}
void test_a_small_picture_sits_in_the_middle() {
ImageFrame f(100, 50, 0, 12, 240, 123);
TEST_ASSERT_FALSE(f.bigger());
TEST_ASSERT_EQUAL_INT(100, f.percent());
ImageMap m = f.map();
int tx, ty;
TEST_ASSERT_TRUE(m.at(0, 0, tx, ty));
TEST_ASSERT_EQUAL_INT(70, tx);
TEST_ASSERT_EQUAL_INT(12 + 36, ty);
TEST_ASSERT_TRUE(m.at(99, 49, tx, ty));
TEST_ASSERT_EQUAL_INT(169, tx);
TEST_ASSERT_FALSE(m.at(-1, 0, tx, ty));
f.toggle(); // nothing to zoom
TEST_ASSERT_FALSE(f.actual());
TEST_ASSERT_EQUAL_INT(0, f.jpegShrink());
}
void test_a_big_picture_is_shrunk_to_fit_each_screen_pixel_once() {
ImageFrame f(4000, 3000, 0, 12, 240, 123);
TEST_ASSERT_TRUE(f.bigger());
TEST_ASSERT_EQUAL_INT(4, f.percent()); // 123 / 3000
ImageMap m = f.map();
std::vector<int> hits(240 * 135, 0);
int rows = 0;
for (int y = 0; y < 3000; y++) {
if (!m.rowUsed(y)) continue;
rows++;
for (int x = 0; x < 4000; x++) {
int tx, ty;
if (m.at(x, y, tx, ty)) hits[static_cast<size_t>(ty * 240 + tx)]++;
}
}
TEST_ASSERT_EQUAL_INT(123, rows);
int drawn = 0;
for (int ty = 0; ty < 135; ty++)
for (int tx = 0; tx < 240; tx++) {
int n = hits[static_cast<size_t>(ty * 240 + tx)];
TEST_ASSERT_TRUE(n <= 1);
drawn += n;
if (n) TEST_ASSERT_TRUE(ty >= 12 && tx >= 38 && tx < 202); // 164 wide, centred
}
TEST_ASSERT_EQUAL_INT(164 * 123, drawn);
// A JPEG decoder may halve it three times first; the map then takes those pixels.
TEST_ASSERT_EQUAL_INT(3, f.jpegShrink());
ImageMap j = f.map(3);
int tx, ty, again = 0;
for (int y = 0; y < 375; y++)
for (int x = 0; x < 500; x++) again += j.at(x, y, tx, ty);
TEST_ASSERT_EQUAL_INT(164 * 123, again);
TEST_ASSERT_EQUAL_INT(0, ImageFrame(300, 200, 0, 12, 240, 123).jpegShrink()); // not by half: it would be too small
TEST_ASSERT_EQUAL_INT(1, ImageFrame(480, 246, 0, 12, 240, 123).jpegShrink());
}
void test_at_its_own_size_it_moves_half_a_screen_at_a_time() {
ImageFrame f(800, 600, 0, 12, 240, 123);
f.toggle();
TEST_ASSERT_TRUE(f.actual());
TEST_ASSERT_EQUAL_INT(100, f.percent());
ImageMap m = f.map();
int tx, ty;
TEST_ASSERT_TRUE(m.at(280, 238, tx, ty)); // the middle first
TEST_ASSERT_EQUAL_INT(0, tx);
TEST_ASSERT_EQUAL_INT(12, ty);
TEST_ASSERT_FALSE(m.at(279, 238, tx, ty));
TEST_ASSERT_FALSE(m.below(238 + 122)); // the last row on screen
TEST_ASSERT_TRUE(m.below(238 + 123)); // from here on a decoder can stop
TEST_ASSERT_TRUE(f.pan(1, 0));
TEST_ASSERT_TRUE(f.map().at(400, 238, tx, ty));
TEST_ASSERT_EQUAL_INT(0, tx);
for (int i = 0; i < 10; i++) f.pan(1, 1);
TEST_ASSERT_FALSE(f.pan(1, 0)); // the corner: no further
TEST_ASSERT_TRUE(f.map().at(799, 599, tx, ty));
TEST_ASSERT_EQUAL_INT(239, tx);
TEST_ASSERT_EQUAL_INT(12 + 122, ty);
f.toggle();
TEST_ASSERT_FALSE(f.actual());
TEST_ASSERT_FALSE(f.pan(1, 0)); // fitted: nothing to move
}
void test_gif() {
checkGif(str(kGifSmall), 16, 12, small);
checkGif(str(kGifSmallInterlaced), 16, 12, small);
checkGif(str(kGifBig), 96, 64, big); // long enough for the codes to grow to 12 bits and start over
checkGif(str(kGifBigInterlaced), 96, 64, big);
checkGif(str(kGifTransparent), 16, 12, small, 3);
}
void test_gif_damaged() {
Picture p(96, 64);
std::string cut = str(kGifBig).substr(0, 3000);
TEST_ASSERT_EQUAL_STRING("This GIF is cut short", readGif(from(cut), static_cast<uint32_t>(cut.size()), p.sink()).c_str());
std::string head = str(kGifBig).substr(0, 10);
TEST_ASSERT_EQUAL_STRING("This GIF is damaged", readGif(from(head), static_cast<uint32_t>(head.size()), p.sink()).c_str());
// Any byte changed: an answer, never a crash or a pixel outside the picture (the sink checks).
std::string file = str(kGifBig);
for (size_t i = 0; i < file.size(); i += 37) {
std::string bad = file;
bad[i] = static_cast<char>(bad[i] ^ 0x5A);
Picture q(96, 64);
readGif(from(bad), static_cast<uint32_t>(bad.size()), q.sink());
}
}
void test_bmp() {
std::string file = str(kBmp24);
Picture p(13, 7);
TEST_ASSERT_EQUAL_STRING("", readBmp(from(file), static_cast<uint32_t>(file.size()), p.sink()).c_str());
for (int y = 0; y < 7; y++)
for (int x = 0; x < 13; x++)
TEST_ASSERT_EQUAL_HEX32((((x * 9) % 256) << 16) | (((y * 17) % 256) << 8) | ((x + y) % 256), p.rgb[static_cast<size_t>(y * 13 + x)]);
std::string file8 = str(kBmp8);
Picture q(13, 7);
TEST_ASSERT_EQUAL_STRING("", readBmp(from(file8), static_cast<uint32_t>(file8.size()), q.sink()).c_str());
for (int i = 0; i < 13 * 7; i++)
TEST_ASSERT_EQUAL_HEX32((kBmp8Pixels[i * 3] << 16) | (kBmp8Pixels[i * 3 + 1] << 8) | kBmp8Pixels[i * 3 + 2], q.rgb[static_cast<size_t>(i)]);
// Only the rows asked for are read.
Picture r(13, 7);
readBmp(from(file), static_cast<uint32_t>(file.size()), r.sink(), [](int y) { return y == 2; });
TEST_ASSERT_EQUAL_INT(-1, r.rgb[0]);
TEST_ASSERT_TRUE(r.rgb[2 * 13] >= 0);
std::string cut = file.substr(0, 200);
TEST_ASSERT_EQUAL_STRING("This BMP is cut short", readBmp(from(cut), static_cast<uint32_t>(cut.size()), p.sink()).c_str());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_kinds);
RUN_TEST(test_sizes);
RUN_TEST(test_a_screenshot_is_read_without_decoding);
RUN_TEST(test_dithering_leaves_the_screens_own_colours_alone);
RUN_TEST(test_a_small_picture_sits_in_the_middle);
RUN_TEST(test_a_big_picture_is_shrunk_to_fit_each_screen_pixel_once);
RUN_TEST(test_at_its_own_size_it_moves_half_a_screen_at_a_time);
RUN_TEST(test_gif);
RUN_TEST(test_gif_damaged);
RUN_TEST(test_bmp);
return UNITY_END();
}
+17
View File
@@ -231,6 +231,22 @@ void test_fn_h_is_help_in_both_modes() {
}
}
// Issue #83: Fn+p everywhere.
void test_fn_p_is_a_screenshot_in_both_modes() {
for (bool typing : {true, false}) {
KeyMapper m;
m.setTextEntry(typing);
auto ev = press(m, withFn({'p'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(static_cast<int>(Key::Screenshot), static_cast<int>(ev[0].key));
release(m);
ev = press(m, chars({'p'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
TEST_ASSERT_EQUAL('p', ev[0].ch);
}
}
void test_plain_h_still_types() {
for (bool typing : {true, false}) {
KeyMapper m;
@@ -278,6 +294,7 @@ int main() {
RUN_TEST(test_other_characters_still_type_when_not_typing);
RUN_TEST(test_shifted_arrow_keys_type_their_symbols_when_not_typing);
RUN_TEST(test_fn_h_is_help_in_both_modes);
RUN_TEST(test_fn_p_is_a_screenshot_in_both_modes);
RUN_TEST(test_plain_h_still_types);
RUN_TEST(test_question_mark_is_help_only_when_not_typing);
return UNITY_END();
File diff suppressed because one or more lines are too long
+191
View File
@@ -0,0 +1,191 @@
#include <unity.h>
#include <cstring>
#include <string>
#include <vector>
#include "png_reader.h"
#include "png_rgb332.h"
#include "pngs.h"
using namespace roro::files;
void setUp() {}
void tearDown() {}
namespace {
ImageRead from(const std::string& bytes) {
return [&bytes](uint32_t at, uint8_t* into, size_t len) -> size_t {
if (at >= bytes.size()) return 0;
size_t n = std::min(len, bytes.size() - at);
std::memcpy(into, bytes.data() + at, n);
return n;
};
}
template <size_t N>
std::string str(const uint8_t (&a)[N]) { return std::string(reinterpret_cast<const char*>(a), N); }
struct Picture {
int w, h;
std::vector<int> rgb; // -1: never drawn
Picture(int width, int height) : w(width), h(height), rgb(static_cast<size_t>(width * height), -1) {}
ImagePixels sink() {
return [this](int x, int y, int count, const uint8_t* p) {
for (int i = 0; i < count; i++) {
TEST_ASSERT_TRUE(x + i >= 0 && x + i < w && y >= 0 && y < h);
rgb[static_cast<size_t>(y * w + x + i)] = (p[i * 3] << 16) | (p[i * 3 + 1] << 8) | p[i * 3 + 2];
}
};
}
int at(int x, int y) const { return rgb[static_cast<size_t>(y * w + x)]; }
};
Picture decode(const std::string& file, int w, int h) {
Picture p(w, h);
TEST_ASSERT_EQUAL_STRING("", readPng(from(file), static_cast<uint32_t>(file.size()), p.sink()).c_str());
return p;
}
int colour(int x, int y) { return (((x * 9 + y) % 256) << 16) | (((y * 17) % 256) << 8) | ((x * x + y * y) % 256); }
int grey(int v) { return (v << 16) | (v << 8) | v; }
} // namespace
void test_rgb() {
Picture p = decode(str(kPngRgb), 96, 64);
for (int y = 0; y < 64; y++)
for (int x = 0; x < 96; x++) TEST_ASSERT_EQUAL_HEX32(colour(x, y), p.at(x, y));
}
void test_a_noisy_picture_with_every_kind_of_row() {
Picture p = decode(str(kPngPhoto), 64, 48);
for (int i = 0; i < 64 * 48; i++)
TEST_ASSERT_EQUAL_HEX32((kPngPhotoPixels[i * 3] << 16) | (kPngPhotoPixels[i * 3 + 1] << 8) | kPngPhotoPixels[i * 3 + 2], p.rgb[static_cast<size_t>(i)]);
}
void test_stored_not_compressed() {
Picture p = decode(str(kPngStored), 16, 12);
for (int y = 0; y < 12; y++)
for (int x = 0; x < 16; x++) TEST_ASSERT_EQUAL_HEX32(colour(x, y), p.at(x, y));
}
void test_references_far_back() {
Picture p = decode(str(kPngFarBack), 120, 90);
uint32_t total = 0;
for (int v : p.rgb) {
TEST_ASSERT_TRUE(v >= 0);
total += static_cast<uint32_t>((v >> 16) + ((v >> 8) & 255) + (v & 255));
}
TEST_ASSERT_EQUAL_UINT32((kPngFarBackSum[0] << 24) | (kPngFarBackSum[1] << 16) | (kPngFarBackSum[2] << 8) | kPngFarBackSum[3], total);
for (int x = 0; x < 120; x++) TEST_ASSERT_EQUAL_HEX32(p.at(x, 0) + 1, p.at(x, 60)); // the same row, its blue one more
}
void test_transparent_pixels_are_left_out() {
Picture p = decode(str(kPngRgba), 16, 12);
for (int y = 0; y < 12; y++)
for (int x = 0; x < 16; x++) TEST_ASSERT_EQUAL_HEX32((x + y) % 5 == 0 ? -1 : colour(x, y), p.at(x, y));
Picture g = decode(str(kPngGrayAlpha), 16, 12);
for (int y = 0; y < 12; y++)
for (int x = 0; x < 16; x++) TEST_ASSERT_EQUAL_HEX32(x % 2 ? grey((x * 16 + y * 3) % 256) : -1, g.at(x, y));
}
void test_palettes() {
auto entry = [](int i) { return (((i * 7) % 256) << 16) | (((255 - i * 3) % 256) << 8) | ((i * 13 + 40) % 256); };
Picture p = decode(str(kPngPalette), 16, 12);
for (int y = 0; y < 12; y++)
for (int x = 0; x < 16; x++) {
int i = (x * 3 + y * 5) % 7;
TEST_ASSERT_EQUAL_HEX32(i == 3 ? -1 : entry(i), p.at(x, y)); // index 3 is the transparent one
}
Picture q = decode(str(kPngPalette4), 17, 5); // four bits a pixel, a row that ends in half a byte
for (int y = 0; y < 5; y++)
for (int x = 0; x < 17; x++) TEST_ASSERT_EQUAL_HEX32(entry((x + y * 3) % 13), q.at(x, y));
}
void test_greys() {
Picture g = decode(str(kPngGray), 16, 12);
Picture one = decode(str(kPngGray1), 19, 7);
Picture deep = decode(str(kPngGray16), 16, 12);
for (int y = 0; y < 12; y++)
for (int x = 0; x < 16; x++) {
TEST_ASSERT_EQUAL_HEX32(grey((x * 16 + y * 3) % 256), g.at(x, y));
TEST_ASSERT_EQUAL_HEX32(grey(((x * 4000 + y * 300) % 65536) >> 8), deep.at(x, y));
}
for (int y = 0; y < 7; y++)
for (int x = 0; x < 19; x++) TEST_ASSERT_EQUAL_HEX32((x + y) % 2 ? 0xFFFFFF : 0, one.at(x, y));
}
void test_one_of_the_firmwares_own_screenshots() {
std::string file;
roro::png::Rgb332Writer writer(40, 9, [&](const uint8_t* d, size_t n) {
file.append(reinterpret_cast<const char*>(d), n);
return true;
});
writer.begin();
std::vector<uint8_t> row(40);
for (int y = 0; y < 9; y++) {
for (int x = 0; x < 40; x++) row[static_cast<size_t>(x)] = static_cast<uint8_t>(x * 6 + y);
writer.row(row.data());
}
writer.end();
Picture p = decode(file, 40, 9);
for (int y = 0; y < 9; y++)
for (int x = 0; x < 40; x++) {
int v = (x * 6 + y) & 0xFF;
TEST_ASSERT_EQUAL_HEX32((((v >> 5) * 255 / 7) << 16) | ((((v >> 2) & 7) * 255 / 7) << 8) | ((v & 3) * 85), p.at(x, y));
}
}
void test_rows_can_be_left_out_and_the_end_cut_off() {
std::string file = str(kPngRgb);
Picture p(96, 64);
int reads = 0;
ImageRead counted = [&](uint32_t at, uint8_t* into, size_t len) {
reads++;
return from(file)(at, into, len);
};
TEST_ASSERT_EQUAL_STRING("", readPng(counted, static_cast<uint32_t>(file.size()), p.sink(), [](int y) { return y % 2 == 0; }, [](int y) { return y >= 10; }).c_str());
TEST_ASSERT_EQUAL_HEX32(colour(5, 8), p.at(5, 8));
TEST_ASSERT_EQUAL_INT(-1, p.at(5, 9)); // left out
TEST_ASSERT_EQUAL_INT(-1, p.at(5, 10)); // and nothing from the tenth on
Picture all(96, 64);
int allReads = 0;
ImageRead countAll = [&](uint32_t at, uint8_t* into, size_t len) {
allReads++;
return from(file)(at, into, len);
};
readPng(countAll, static_cast<uint32_t>(file.size()), all.sink());
TEST_ASSERT_TRUE(reads < allReads / 2); // it stopped reading the file too
}
void test_what_it_refuses() {
Picture p(96, 64);
std::string interlaced = str(kPngRgb);
interlaced[28] = 1;
TEST_ASSERT_EQUAL_STRING("An interlaced PNG can't be shown", readPng(from(interlaced), static_cast<uint32_t>(interlaced.size()), p.sink()).c_str());
std::string cut = str(kPngRgb).substr(0, 2000);
TEST_ASSERT_EQUAL_STRING("This PNG is cut short", readPng(from(cut), static_cast<uint32_t>(cut.size()), p.sink()).c_str());
std::string notPng = "GIF89a and then some more bytes that are not a PNG";
TEST_ASSERT_EQUAL_STRING("This PNG is damaged", readPng(from(notPng), static_cast<uint32_t>(notPng.size()), p.sink()).c_str());
// Any byte changed: an answer, never a crash or a pixel outside the picture (the sink checks).
std::string file = str(kPngPhoto);
for (size_t i = 8; i < file.size(); i += 7) {
std::string bad = file;
bad[i] = static_cast<char>(bad[i] ^ 0xA5);
Picture q(64, 48);
readPng(from(bad), static_cast<uint32_t>(bad.size()), q.sink());
}
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_rgb);
RUN_TEST(test_a_noisy_picture_with_every_kind_of_row);
RUN_TEST(test_stored_not_compressed);
RUN_TEST(test_references_far_back);
RUN_TEST(test_transparent_pixels_are_left_out);
RUN_TEST(test_palettes);
RUN_TEST(test_greys);
RUN_TEST(test_one_of_the_firmwares_own_screenshots);
RUN_TEST(test_rows_can_be_left_out_and_the_end_cut_off);
RUN_TEST(test_what_it_refuses);
return UNITY_END();
}
+101
View File
@@ -0,0 +1,101 @@
#include <unity.h>
#include <string>
#include "share_rules.h"
using namespace roro::files;
void setUp() {}
void tearDown() {}
void test_what_a_request_asks_for() {
TEST_ASSERT_EQUAL_STRING("/notes/my list.txt", urlDecode("%2Fnotes%2Fmy%20list.txt").c_str());
TEST_ASSERT_EQUAL_STRING("a+b", urlDecode("a+b").c_str());
TEST_ASSERT_EQUAL_STRING("100%", urlDecode("100%").c_str());
TEST_ASSERT_EQUAL_STRING("%zz", urlDecode("%zz").c_str());
TEST_ASSERT_EQUAL_STRING("\xC3\xA9t\xC3\xA9", urlDecode("%C3%A9t%C3%a9").c_str());
std::string v;
TEST_ASSERT_TRUE(queryParam("path=%2Fnotes&replace=1", "path", v));
TEST_ASSERT_EQUAL_STRING("/notes", v.c_str());
TEST_ASSERT_TRUE(queryParam("path=%2Fnotes&replace=1", "replace", v));
TEST_ASSERT_EQUAL_STRING("1", v.c_str());
TEST_ASSERT_FALSE(queryParam("xpath=1&pathx=2", "path", v));
TEST_ASSERT_FALSE(queryParam("", "path", v));
TEST_ASSERT_TRUE(queryParam("a=&path=", "path", v));
TEST_ASSERT_EQUAL_STRING("", v.c_str());
TEST_ASSERT_EQUAL_STRING("abc123", cookieValue("theme=dark; s=abc123; x=1", "s").c_str());
TEST_ASSERT_EQUAL_STRING("abc123", cookieValue("s=abc123", "s").c_str());
TEST_ASSERT_EQUAL_STRING("", cookieValue("ss=abc123; xs=1", "s").c_str());
TEST_ASSERT_EQUAL_STRING("", cookieValue("", "s").c_str());
}
void test_paths_a_browser_may_name() {
TEST_ASSERT_EQUAL_STRING("", checkSharePath("/").c_str());
TEST_ASSERT_EQUAL_STRING("", checkSharePath("/notes/my list (2).txt").c_str());
TEST_ASSERT_EQUAL_STRING("", checkSharePath("/gemini/saved/\xC3\xA9t\xC3\xA9.gmi").c_str());
for (const char* bad : {"", "notes", "/notes/", "/notes/../wifi", "/..", "/a//b", "/a/./b", "/a\\b", "/a/b\n", "/a:b", "/what?", "/a*"})
TEST_ASSERT_TRUE_MESSAGE(!checkSharePath(bad).empty(), bad);
TEST_ASSERT_TRUE(!checkSharePath("/" + std::string(300, 'a')).empty());
}
void test_json() {
TEST_ASSERT_EQUAL_STRING("\"plain\"", jsonString("plain").c_str());
TEST_ASSERT_EQUAL_STRING("\"a \\\"b\\\" \\\\ c\"", jsonString("a \"b\" \\ c").c_str());
TEST_ASSERT_EQUAL_STRING("\"tab\\u0009\"", jsonString("tab\t").c_str());
TEST_ASSERT_EQUAL_STRING("\"\xC3\xA9\"", jsonString("\xC3\xA9").c_str());
ShareListing empty("/");
TEST_ASSERT_EQUAL_STRING("{\"path\":\"/\",\"items\":[],\"more\":false}", empty.json(false).c_str());
ShareListing l("/notes");
l.add("a \"b\".txt", 12, false, 1791400000);
l.add("old", 0, true, 0);
TEST_ASSERT_EQUAL_size_t(2, l.count());
TEST_ASSERT_EQUAL_STRING("{\"path\":\"/notes\",\"items\":[{\"n\":\"a \\\"b\\\".txt\",\"s\":12,\"d\":0,\"t\":1791400000},{\"n\":\"old\",\"s\":0,\"d\":1,\"t\":0}],\"more\":true}",
l.json(true).c_str());
}
void test_the_code_and_the_token() {
ShareAuth auth;
std::string token;
const uint8_t r16[16] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff};
TEST_ASSERT_TRUE(auth.login("000000", 0, r16, token) == ShareAuth::Result::Locked); // not started: nobody gets in
TEST_ASSERT_FALSE(auth.allowed(""));
const uint8_t r4[4] = {0x00, 0x00, 0x30, 0x39}; // 12345
auth.begin(r4);
TEST_ASSERT_EQUAL_STRING("012345", auth.code().c_str());
TEST_ASSERT_FALSE(auth.allowed(""));
TEST_ASSERT_TRUE(auth.login("12345", 1000, r16, token) == ShareAuth::Result::Wrong); // the leading zero counts
TEST_ASSERT_TRUE(token.empty());
TEST_ASSERT_TRUE(auth.login("012 345", 2000, r16, token) == ShareAuth::Result::Ok); // typed as the screen groups it
TEST_ASSERT_EQUAL_STRING("00112233445566778899aabbccddeeff", token.c_str());
TEST_ASSERT_TRUE(auth.allowed(token));
TEST_ASSERT_FALSE(auth.allowed(token + "0"));
TEST_ASSERT_FALSE(auth.allowed("00112233445566778899aabbccddeef0"));
// Sharing started again: a new code, and yesterday's browser is out.
const uint8_t again[4] = {0xFF, 0xFF, 0xFF, 0xFF};
auth.begin(again);
TEST_ASSERT_EQUAL_size_t(6, auth.code().size());
TEST_ASSERT_FALSE(auth.allowed(token));
}
void test_five_wrong_codes_close_it_for_a_minute() {
ShareAuth auth;
const uint8_t r4[4] = {0, 0, 0, 7};
const uint8_t r16[16] = {0};
auth.begin(r4);
std::string token;
for (int i = 0; i < 4; i++) TEST_ASSERT_TRUE(auth.login("999999", 1000, r16, token) == ShareAuth::Result::Wrong);
TEST_ASSERT_TRUE(auth.login("999999", 1000, r16, token) == ShareAuth::Result::Locked);
TEST_ASSERT_TRUE(auth.login("000007", 30000, r16, token) == ShareAuth::Result::Locked); // the right one too
TEST_ASSERT_TRUE(auth.login("000007", 62000, r16, token) == ShareAuth::Result::Ok);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_what_a_request_asks_for);
RUN_TEST(test_paths_a_browser_may_name);
RUN_TEST(test_json);
RUN_TEST(test_the_code_and_the_token);
RUN_TEST(test_five_wrong_codes_close_it_for_a_minute);
return UNITY_END();
}
+162
View File
@@ -0,0 +1,162 @@
#include <unity.h>
#include <string>
#include "ipv4.h"
#include "wg_config.h"
using namespace roro::net;
void setUp() {}
void tearDown() {}
namespace {
// Keys made for these tests: they open nothing.
const char* const kPriv = "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789+/aBcDE=";
const char* const kPub = "h+vdhuWJykaesw515qrYYGNdg2pGvE7JU5PXIAV2YzE=";
const char* const kPsk = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
std::string conf(const std::string& allowed = "10.9.0.0/24", const std::string& more = "") {
return std::string("[Interface]\nPrivateKey = ") + kPriv + "\nAddress = 10.9.0.2/24\nDNS = 10.9.0.1\n" + more + "\n[Peer]\nPublicKey = " + kPub +
"\nEndpoint = vpn.example.org:51820\nAllowedIPs = " + allowed + "\n";
}
uint32_t ip(const char* text) {
uint32_t v = 0;
TEST_ASSERT_TRUE(parseIpv4(text, v));
return v;
}
} // namespace
void test_a_usual_file() {
WgConfig c;
TEST_ASSERT_EQUAL_STRING("", parseWgConf(conf(), c).c_str());
TEST_ASSERT_EQUAL_STRING(kPriv, c.privateKey.c_str());
TEST_ASSERT_EQUAL_STRING(kPub, c.peerKey.c_str());
TEST_ASSERT_EQUAL_UINT32(ip("10.9.0.2"), c.address);
TEST_ASSERT_EQUAL_INT(24, c.prefix);
TEST_ASSERT_EQUAL_UINT32(ip("10.9.0.1"), c.dns[0]);
TEST_ASSERT_EQUAL_UINT32(0, c.dns[1]);
TEST_ASSERT_EQUAL_STRING("vpn.example.org", c.endpointHost.c_str());
TEST_ASSERT_EQUAL_UINT16(51820, c.endpointPort);
TEST_ASSERT_EQUAL_INT(1, c.allowedCount);
TEST_ASSERT_EQUAL_INT(25, c.keepalive); // none given: this device is behind a NAT
TEST_ASSERT_EQUAL_INT(0, c.mtu);
TEST_ASSERT_TRUE(c.presharedKey.empty());
}
void test_as_people_write_them() {
std::string text = std::string("# my phone's old config\r\n[interface]\r\n privatekey=") + kPriv +
" ; secret\r\nAddress = fd00::2/64, 192.168.77.5\r\nDNS = dns.example, 2001:db8::1, 9.9.9.9, 1.1.1.1, 8.8.8.8\r\nMTU = 1280\r\nListenPort = 51820\r\n"
"PostUp = iptables -A FORWARD\r\n\r\n[PEER]\r\nPublicKey = " + kPub + "\r\nPresharedKey = " + kPsk +
"\r\nEndpoint = 203.0.113.9:4500\r\nAllowedIPs = 0.0.0.0/0, ::/0\r\nPersistentKeepalive = 0\r\n";
WgConfig c;
TEST_ASSERT_EQUAL_STRING("", parseWgConf(text, c).c_str());
TEST_ASSERT_EQUAL_UINT32(ip("192.168.77.5"), c.address); // the IPv4 one, and alone it is a /32
TEST_ASSERT_EQUAL_INT(32, c.prefix);
TEST_ASSERT_EQUAL_UINT32(ip("9.9.9.9"), c.dns[0]); // names and IPv6 left out, two kept
TEST_ASSERT_EQUAL_UINT32(ip("1.1.1.1"), c.dns[1]);
TEST_ASSERT_EQUAL_INT(1280, c.mtu);
TEST_ASSERT_EQUAL_UINT16(51820, c.listenPort);
TEST_ASSERT_EQUAL_STRING(kPsk, c.presharedKey.c_str());
TEST_ASSERT_EQUAL_STRING("203.0.113.9", c.endpointHost.c_str());
TEST_ASSERT_EQUAL_UINT16(4500, c.endpointPort);
TEST_ASSERT_EQUAL_INT(1, c.allowedCount);
TEST_ASSERT_EQUAL_INT(0, c.allowed[0].prefix);
TEST_ASSERT_EQUAL_INT(0, c.keepalive); // said so
}
void test_it_survives_being_stored() {
WgConfig a, b;
TEST_ASSERT_EQUAL_STRING("", parseWgConf(conf("10.9.0.0/24, 192.168.1.77/24", std::string("MTU = 1300\nListenPort = 4242\n")), a).c_str());
a.presharedKey = kPsk;
std::string stored = toWgConf(a);
TEST_ASSERT_EQUAL_STRING("", parseWgConf(stored, b).c_str());
TEST_ASSERT_EQUAL_STRING(stored.c_str(), toWgConf(b).c_str());
TEST_ASSERT_EQUAL_UINT32(ip("192.168.1.0"), b.allowed[1].address); // a range is kept as its network
TEST_ASSERT_EQUAL_INT(1300, b.mtu);
TEST_ASSERT_EQUAL_UINT16(4242, b.listenPort);
TEST_ASSERT_EQUAL_STRING(kPsk, b.presharedKey.c_str());
}
void test_what_is_refused_and_why() {
WgConfig c;
c.endpointHost = "untouched";
auto why = [&](const std::string& text) { return parseWgConf(text, c); };
TEST_ASSERT_EQUAL_STRING("no PrivateKey under [Interface]", why("[Interface]\nAddress = 10.0.0.2/24\n").c_str());
TEST_ASSERT_EQUAL_STRING("line 2: PrivateKey isn't a key", why("[Interface]\nPrivateKey = tooshort=\n").c_str());
TEST_ASSERT_EQUAL_STRING("line 3: Address has no IPv4 address", why(std::string("[Interface]\nPrivateKey = ") + kPriv + "\nAddress = fd00::2/64\n").c_str());
std::string base = std::string("[Interface]\nPrivateKey = ") + kPriv + "\nAddress = 10.0.0.2/24\n[Peer]\nPublicKey = " + kPub + "\n";
TEST_ASSERT_EQUAL_STRING("no Endpoint under [Peer]", why(base + "AllowedIPs = 10.0.0.0/24\n").c_str());
TEST_ASSERT_EQUAL_STRING("no IPv4 range in AllowedIPs", why(base + "Endpoint = a.example:1\nAllowedIPs = ::/0\n").c_str());
TEST_ASSERT_EQUAL_STRING("line 6: an IPv6 Endpoint: IPv4 or a name only", why(base + "Endpoint = [2001:db8::1]:51820\n").c_str());
TEST_ASSERT_EQUAL_STRING("line 6: Endpoint must be host:port", why(base + "Endpoint = vpn.example.org\n").c_str());
TEST_ASSERT_EQUAL_STRING("line 6: AllowedIPs has something that isn't an address range", why(base + "AllowedIPs = 10.0.0.0/33\n").c_str());
TEST_ASSERT_EQUAL_STRING("line 6: AllowedIPs: four IPv4 ranges at most", why(base + "AllowedIPs = 10.0.0.0/24, 10.0.1.0/24, 10.0.2.0/24, 10.0.3.0/24, 10.0.4.0/24\n").c_str());
TEST_ASSERT_EQUAL_STRING("line 8: a second peer: this device has one tunnel to one peer",
why(base + "Endpoint = a.example:1\nAllowedIPs = 10.0.0.0/24\n[Peer]\nPublicKey = " + kPub + "\n").c_str());
TEST_ASSERT_EQUAL_STRING("line 1: a setting before [Interface]", why("PrivateKey = x\n").c_str());
TEST_ASSERT_EQUAL_STRING("line 4: MTU must be 576 to 1500", why(std::string("[Interface]\nPrivateKey = ") + kPriv + "\nAddress = 10.0.0.2\nMTU = 9000\n").c_str());
TEST_ASSERT_EQUAL_STRING("untouched", c.endpointHost.c_str()); // a refused file changes nothing
// No message carries a key.
std::string bad = std::string("[Interface]\nPrivateKey = ") + kPriv + "x\n";
TEST_ASSERT_TRUE(why(bad).find("aBcD") == std::string::npos);
}
void test_keys() {
TEST_ASSERT_TRUE(validWgKey(kPriv));
TEST_ASSERT_TRUE(validWgKey(kPub));
TEST_ASSERT_FALSE(validWgKey(""));
TEST_ASSERT_FALSE(validWgKey(std::string(kPub).substr(0, 43)));
TEST_ASSERT_FALSE(validWgKey(std::string(kPub).substr(0, 43) + "A")); // no padding
TEST_ASSERT_FALSE(validWgKey("h+vdhuWJykaesw515qrYYGNdg2pGvE7JU5PXIAV2Yz!=")); // not base64
TEST_ASSERT_FALSE(validWgKey("h+vdhuWJykaesw515qrYYGNdg2pGvE7JU5PXIAV2YzF=")); // bits past the 32nd byte
}
void test_what_goes_through_it() {
WgConfig c;
parseWgConf(conf("10.9.0.0/24"), c);
WgRouting r = routingOf(c);
TEST_ASSERT_FALSE(r.full);
TEST_ASSERT_EQUAL_INT(24, r.prefix);
TEST_ASSERT_EQUAL_INT(0, r.unreachable);
TEST_ASSERT_TRUE(wgReaches(c, ip("10.9.0.1")));
TEST_ASSERT_FALSE(wgReaches(c, ip("10.9.1.1")));
TEST_ASSERT_FALSE(wgReaches(c, ip("93.184.216.34")));
TEST_ASSERT_EQUAL_STRING("10.9.0.0/24", describeWgRouting(c).c_str());
parseWgConf(conf("0.0.0.0/0"), c);
TEST_ASSERT_TRUE(routingOf(c).full);
TEST_ASSERT_TRUE(wgReaches(c, ip("93.184.216.34")));
TEST_ASSERT_EQUAL_STRING("everything", describeWgRouting(c).c_str());
// A home network behind the server can't be reached without the full tunnel: said, not hidden.
parseWgConf(conf("10.9.0.0/24, 192.168.1.0/24"), c);
r = routingOf(c);
TEST_ASSERT_EQUAL_INT(24, r.prefix);
TEST_ASSERT_EQUAL_INT(1, r.unreachable);
TEST_ASSERT_FALSE(wgReaches(c, ip("192.168.1.10")));
TEST_ASSERT_EQUAL_STRING("10.9.0.0/24, not 1 other range", describeWgRouting(c).c_str());
// The widest allowed range that holds this device's address is the tunnel's subnet.
parseWgConf(conf("10.0.0.0/8"), c);
TEST_ASSERT_EQUAL_INT(8, routingOf(c).prefix);
TEST_ASSERT_TRUE(wgReaches(c, ip("10.200.3.4")));
TEST_ASSERT_EQUAL_INT(0, routingOf(c).unreachable);
// Only the server itself allowed: the Address line's own subnet, and that one host outside it.
parseWgConf(conf("172.16.5.1/32"), c);
r = routingOf(c);
TEST_ASSERT_EQUAL_INT(24, r.prefix);
TEST_ASSERT_EQUAL_INT(1, r.unreachable);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_a_usual_file);
RUN_TEST(test_as_people_write_them);
RUN_TEST(test_it_survives_being_stored);
RUN_TEST(test_what_is_refused_and_why);
RUN_TEST(test_keys);
RUN_TEST(test_what_goes_through_it);
return UNITY_END();
}