Compare commits

...
Author SHA1 Message Date
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
twislaandClaude Opus 5.5 834c6eb0f2 Site: search over the documentation (#60)
Site / build (pull_request) Successful in 12s
A search page whose index is the page itself: one item for each page and
each heading of the guide, the how-tos, the FAQ and the developer docs,
written by Zola from the pages' own content. A small script filters and
ranks them as you type. Nothing is fetched, so the Content-Security-Policy
needs nothing new; without JavaScript the page is a list of every heading.

The navigation gets a link, and the documentation's index pages a box that
is a plain form to /search/?q=. The devlog is not searched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 18:50:33 +02:00
twisla 5823584bfd Merge pull request 'Devlog: "It said \"No\"" (v0.13.0 to v0.15.0)' (#82) from devlog-three-things into main
Site / build (push) Successful in 13s
Reviewed-on: #82
2026-10-07 16:08:50 +00:00
twislaandClaude Opus 5.5 35f0d5959c Devlog: "It said "No"", the help key, the Shell, notes of any size and a site that publishes itself (v0.13.0 to v0.15.0)
Site / build (pull_request) Successful in 9s
Also corrects one sentence in W1.md that claimed more than was known about
how publishing by hand had gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 17:56:52 +02:00
twisla dd4e6c31ed Merge pull request 'Notes: edit a text file of any size (#47)' (#81) from notes-any-size into main
Site / build (push) Successful in 17s
CI / build (push) Successful in 3m1s
Reviewed-on: #81
2026-10-07 13:41:36 +00:00
twislaandClaude Opus 5.5 de8af6ed92 Notes: edit a text file of any size (#47)
CI / build (pull_request) Successful in 1m52s
Site / build (pull_request) Successful in 12s
The editor held the whole note in memory and stopped at 16 KB. It now keeps
a window of the file around the cursor, and the rest on the card as a list
of pieces (notes::NoteDocument). Memory with a note open is what it was.

Up to 64 KB a save rewrites the file, as before. Above, the five-second
save appends what changed to <note>.edit, and the file is rewritten on
leaving the note, with a progress bar. After a power cut, opening the note
picks the edit up where it was saved; a rewrite cut short is finished or
dropped, never half applied.

Also: Ctrl with Fn+Up/Down go to the start and end of the note; the
consoles' `key` command takes ctrl-, alt- and shift-; the Storage App's
`e` no longer refuses a big file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 15:39:45 +02:00
twisla 10c5291e15 Merge pull request 'Site: published by CI after a push to main and after a release (#79)' (#80) from site-publish into main
CI / build (push) Successful in 59s
Site / build (push) Successful in 15s
Reviewed-on: #80
2026-10-07 12:49:19 +00:00
twislaandClaude Opus 5.5 12c88c98d3 Site: published by CI after a push to main and after a release (#79)
CI / build (pull_request) Successful in 1m20s
Site / build (pull_request) Successful in 11s
The Site workflow's last step, and the release workflow after publishing,
ask the web server over SSH to rebuild the site. The key CI holds is tied
on the server to one forced command (restrict,command=...), so CI sends no
command and a leaked key can only refresh the site. The server, the user,
the key and the server's host key are Gitea secrets; with none of them set
the step does nothing.

scripts/site_refresh.sh is what both workflows run;
scripts/site_deploy_keygen.sh makes the key and prints where each half goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 14:34:02 +02:00
twisla 55c9ad2eb4 Merge pull request 'Shell: the console's commands on the device's own screen and keyboard (#67)' (#78) from shell-app into main
Site / build (push) Successful in 9s
CI / build (push) Successful in 2m50s
Reviewed-on: #78
2026-10-07 11:04:58 +00:00
twislaandClaude Opus 5.5 0fdbb5b1ed Shell: Tab completes every word of a command, and * and ? stand for several files (#67)
CI / build (pull_request) Successful in 1m38s
Site / build (pull_request) Successful in 9s
Tab used to complete a command's first word only. It now follows the help
text word by word: `lora st` gives `lora status`, `gnss track ` lists
`start  stop`. The words are read from the help text as written, so a new
command completes with no table to keep; the Shell's own words are added in
the same notation.

`*` and `?` in the last part of a path, for ls, du, rm, cp and mv, from the
Shell and both consoles. The command runs once for each name matched, lined
up and run by the main loop as each finishes; `cancel` empties the line-up.
64 matches at most, refused whole past that. In the Shell, rm with a pattern
asks once, with the count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 12:47:10 +02:00
twislaandClaude Opus 5.5 d17d10948d Shell: Tab completes a path on the SD card (#67)
CI / build (pull_request) Successful in 1m48s
Site / build (pull_request) Successful in 10s
Past the command's name, Tab completes the word being typed as a path: a
folder keeps its slash to go on from, a file completed whole gets a space,
several candidates are listed. Any case typed, the name's own is taken.
After a file command the first slash is understood (`cat no` is /no).
The folder is read on the storage task, bounded to 400 entries looked at
and 24 candidates.

489 host tests (2 new). Checked on the device: a folder, a file inside it,
several candidates, no slash, another case, nothing matching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 12:03:44 +02:00
twislaandClaude Opus 5.5 7b5df713ad Shell: only its own replies, Apps by their names, and rm as Unix has it (#67)
CI / build (pull_request) Successful in 1m38s
Site / build (pull_request) Successful in 10s
The Shell shows the replies to its own commands and nothing else. The
console knows who each line is printed for (Console::As, Console::origin):
a command run from the Shell prints as the Shell's, and what answers it
later from another task carries that along (ls, tasks, du, cp, update
check, sd list, screenshot, gemini get). Ctrl+b shows everything instead.
This replaces the ten-second window, which was a guess.

An App's name with a capital opens it (Notes, Irc, Wifi, Gnss, Gemini, Lora,
Storage, Shell, System, Settings), from the Shell and from the consoles.

rm needs -r for a folder, here and over the consoles. In the Shell a file,
or a folder with something in it, is asked about unless -f; an empty folder
with -r goes without a word.

The Shell now hands its line to the main loop to run: run from inside the
key handler, rm on a folder overflowed the loop's stack and crashed the
device. `info` says which App is in front.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 11:35:29 +02:00
twislaandClaude Opus 5.5 3863d28593 Shell: the console's commands on the device's own screen and keyboard (#67)
CI / build (pull_request) Successful in 1m48s
Site / build (pull_request) Successful in 9s
An App in the Launcher that runs the same commands as USB serial and the
Debug Console, trusted like the first. It shows what the console prints
while it is open, through a second ring of the console's that exists only
meanwhile; Ctrl+b keeps only what follows your own commands. Tab completes
a command's name from the firmware's help text, Fn with up and down recalls
earlier lines, Alt with up and down scrolls back. `rm` asks first in the
Shell, `rm -f` doesn't. Nothing is kept once the App is left.

`screenshot [seconds]` saves the screen as a PNG in /screenshots on the
card, now or after a pause: written a row at a time, indexed colour with
RGB332 as the palette, in one stored deflate block.

487 host tests (11 new: the PNG writer, the Shell's log filter, Tab).
Checked on the device over the Debug Console: commands, Tab, history, a
screenshot fetched and decoded on the PC, rm with and without the question,
a delayed screenshot of another screen. 12 KB of flash; 7 KB of heap while
open. Decisions Q204 to Q212 in docs/milestones/S1.md. Safe Mode: #77.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 10:53:31 +02:00
twisla c278a06ca1 Merge pull request 'CI: stop rebuilding the framework at every run; a build cache and ccache (#74)' (#76) from ci-speed into main
Site / build (push) Successful in 9s
CI / build (push) Successful in 2m42s
2026-10-07 02:08:49 +00:00
twislaandClaude Opus 5.5 828f7ce821 R1: the CI timings measured on the runner
CI / build (pull_request) Successful in 1m17s
Site / build (pull_request) Successful in 8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 02:22:15 +02:00
twislaandClaude Opus 5.5 ca5874fe70 CI: say how to start the caches again from nothing
Site / build (pull_request) Successful in 9s
CI / build (pull_request) Successful in 1m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 02:18:05 +02:00
twislaandClaude Opus 5.5 07ac7ba457 CI: stop rebuilding the framework at every run; a build cache, ccache, and the version out of the compiler flags (#74)
CI / build (pull_request) Successful in 7m11s
Site / build (pull_request) Successful in 9s
A pull request's firmware step took 358 s: 260 of them rebuilding the
framework that was already in the volume, because the platform decides by
sdkconfig.defaults in the project folder, which is generated and not in git,
so no fresh checkout had it. It is now kept in the volume, inside the
libraries it describes, and copied into the checkout; the platform still
checks its hash against platformio.ini.

The version was a -D on every command line: each commit recompiled
everything, and no cache could help. scripts/version.py now writes one
generated header, read by one file.

PlatformIO's build cache in the volume for pull requests' firmware, ccache
for the host tests (built for coverage, which the build cache can't keep),
the tools in a venv in the volume, and a tag builds its firmware once.
A release still compiles its own sources from nothing.

Measured on fresh copies of the tree: the firmware step 358 s to 27 s (a new
version and one changed file), tests and coverage 49 s to 33 s, a local
rebuild with nothing changed 77 s to 13 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 02:10:34 +02:00
twisla 942725a047 Merge pull request 'Keys: one table file for the device's help panel and the website's key tables (#72)' (#75) from keys-tables into main
CI / build (push) Successful in 1m11s
Site / build (push) Successful in 9s
2026-10-06 23:50:58 +00:00
twislaandClaude Opus 5.5 34e6714785 Keys: one table file for the device's help panel and the website's key tables (#72)
CI / build (pull_request) Successful in 7m14s
Site / build (pull_request) Successful in 8s
Every screen's keys are constant tables in lib/core/src/app_keys.h (52 of
them, each under an `// id: Title` comment). An App's help() picks the table
of the state it is in. site/tools/gen_dev_docs.py reads the same file and
writes site/data/keys.toml; the `keys` shortcode shows a screen's tables on
its guide page, and /guide/keys/ shows all of them.

The Site job fails when the data file is out of date or a page asks for a
table that doesn't exist, and now also runs when app_keys.h changes. A key
added to an App shows up on the website without anyone editing a page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 01:42:48 +02:00
twisla 82023d36b3 Merge pull request 'Help: Fn+h lists the keys of the screen you're on; no screen names its keys any more (#69)' (#73) from help-key into main
CI / build (push) Successful in 1m19s
Site / build (push) Successful in 16s
2026-10-06 23:38:49 +00:00
twislaandClaude Opus 5.5 7fe8b3d22a Help: Fn+h lists the keys of the screen you're on, and no screen names its keys any more (#69)
CI / build (pull_request) Successful in 7m22s
Site / build (pull_request) Successful in 14s
Fn+h on any screen, text fields included, and ? outside Text Entry, open a
panel over the content area: the screen's own keys, then the ones that work
everywhere. Every App declares its keys for the state it is in (pages,
viewers, dialogs and text fields answer for themselves); the App manager
opens the panel and takes every key while it is open.

About 30 hint lines are gone, from every App. What stays on a screen is
state. The first-start Setup keeps its hints and teaches the key; a device
set up before gets one Toast, once. The guide and the FAQ open with it.
`key help` over the consoles.

476 host tests (8 new). Checked on the device with key help and screenshots:
the Launcher, all nine Apps and several of their states. 8.5 KB of flash and
40 bytes of static RAM. Decisions Q196 to Q203 in docs/milestones/U1.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 01:29:51 +02:00
twisla 1c6ae0e04f Merge pull request 'Devlog: "It was off" (the website, and one firmware with the Debug Console in it)' (#71) from devlog-console into main
Site / build (push) Successful in 9s
Reviewed-on: #71
2026-10-06 22:20:56 +00:00
180 changed files with 11066 additions and 312 deletions
+61 -8
View File
@@ -1,14 +1,32 @@
# CI and releases (docs/milestones/R1.md).
# A push to main: the host tests, with their coverage of lib/, and the README's badges
# published to the branch `badges`.
# A pull request: the same tests and coverage, then the firmware.
# A pull request: the same tests and coverage, then the firmware (from the build cache).
# A branch's pushes run nothing by themselves: its pull request runs, once.
# A tag v*: all of it, then a Gitea release with the signed Update File.
# A tag v*: the tests, then the firmware built once, clean, signed and published as a
# Gitea release. The site is then rebuilt: its home page and Downloads name
# the latest release when they are built (issue #79).
# Run by hand: the release of a tag that exists already (the ones from before CI).
#
# The job runs in a plain Python image, as scripts/ci.sh does on a developer's machine, with the
# toolchains in a Docker volume the runner allows (container.valid_volumes: roro9stack-pio): that
# volume is the cache. No JavaScript actions, so the image needs no Node: the checkout is git.
#
# What the volume keeps between runs, and what makes each go stale (issue #74, R1.md):
# /pio/packages, /pio/platforms toolchains and the framework: by their versions in platformio.ini
# .../framework-arduinoespressif32-libs/.roro-sdkconfig.defaults
# the mark that the framework is already rebuilt with our SDK settings: the
# project's sdkconfig.defaults, which isn't in git, so that every fresh
# checkout rebuilt the framework (260 s). The platform checks its hash
# against platformio.ini's settings, and rebuilds if they differ. It is kept
# inside the libraries it describes, so it goes when they are reinstalled.
# /pio/ci/build-cache PlatformIO's build cache (SCons): objects by the signature of their
# sources and command line. For pull requests only: a release compiles
# its own sources from nothing.
# /pio/ci/ccache the host tests' objects (they're built for coverage, which the build
# cache can't keep: it would lose the .gcno files)
# /pio/ci/venv PlatformIO and gcovr: delete the folder to upgrade them
# To start from nothing (a slow run, about 7 minutes): delete /pio/ci and that .roro-sdkconfig.defaults file.
name: CI
on:
push:
@@ -37,13 +55,23 @@ jobs:
env:
PLATFORMIO_CORE_DIR: /pio
RORO_NO_DOCKER: 1
SDK_MARK: /pio/packages/framework-arduinoespressif32-libs/.roro-sdkconfig.defaults
CCACHE_DIR: /pio/ci/ccache
CCACHE_MAXSIZE: 1G
steps:
- name: Tools
run: |
apt-get update -qq
apt-get install -y -qq --no-install-recommends git build-essential openssl >/dev/null
pip install -q --no-cache-dir --root-user-action=ignore platformio gcovr
pio --version; df -h /pio | tail -1; ls /pio | head
apt-get install -y -qq --no-install-recommends git build-essential openssl ccache >/dev/null
mkdir -p /pio/ci
if [ ! -x /pio/ci/venv/bin/pio ]; then
python -m venv /pio/ci/venv
/pio/ci/venv/bin/pip install -q --no-cache-dir platformio gcovr
fi
ln -sf /pio/ci/venv/bin/pio /pio/ci/venv/bin/gcovr /usr/local/bin/
pio --version; df -h /pio | tail -1; du -sh /pio/ci/* 2>/dev/null || true
# The build cache only grows: start it again past 3 GB (a full set of objects is 160 MB, and each run adds about 40).
if [ "$(du -sm /pio/ci/build-cache 2>/dev/null | cut -f1)" -gt 3072 ] 2>/dev/null; then rm -rf /pio/ci/build-cache; fi
- name: Check out
run: |
@@ -57,11 +85,20 @@ jobs:
- name: Host tests, and their coverage of lib/
if: github.event_name != 'workflow_dispatch'
run: scripts/coverage.sh
run: |
export PATH="/usr/lib/ccache:$PATH" # gcc and g++ through ccache
scripts/coverage.sh
ccache -s | grep -E 'Hits|Misses' | head -2
# A pull request only: a tag's firmware is built once, by the release step below.
- name: The firmware
if: github.event_name == 'pull_request' || github.ref_type == 'tag'
run: scripts/ci.sh builds
if: github.event_name == 'pull_request'
env:
PLATFORMIO_BUILD_CACHE_DIR: /pio/ci/build-cache
run: |
[ ! -f "$SDK_MARK" ] || cp "$SDK_MARK" sdkconfig.defaults
scripts/ci.sh builds
cp sdkconfig.defaults "$SDK_MARK" # what the framework in the volume is rebuilt with, now
# The README's badges are files on a branch of their own, replaced at each push to main and
# at each tag (the release badge says which tag is the latest)
@@ -103,7 +140,12 @@ jobs:
trap 'rm -f "$RORO_OTA_KEY"' EXIT
printf '%s\n' "$OTA_SIGNING_KEY" > "$RORO_OTA_KEY"
umask 022
# The framework rebuilt with our settings is reused if it matches (the platform checks);
# the release's own sources are compiled from nothing, with no build cache.
[ ! -f "$SDK_MARK" ] || cp "$SDK_MARK" /tmp/release-src/sdkconfig.defaults
scripts/release_build.sh /tmp/release-src dist
# An old tag has no SDK settings of its own, and no sdkconfig.defaults afterwards: nothing to mark.
[ ! -f /tmp/release-src/sdkconfig.defaults ] || [ ! -d "$(dirname "$SDK_MARK")" ] || cp /tmp/release-src/sdkconfig.defaults "$SDK_MARK"
- name: Publish the release
if: steps.release.outputs.tag != ''
@@ -112,3 +154,14 @@ jobs:
GITEA_REPO: ${{ github.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: scripts/release_publish.py dist
# The site names the latest release on its home page and lists them all on Downloads, both
# read when it is built: so it is rebuilt now (issue #79, as the Site workflow does).
- name: Refresh the site
if: steps.release.outputs.tag != ''
env:
SITE_DEPLOY_KEY: ${{ secrets.SITE_DEPLOY_KEY }}
SITE_DEPLOY_HOST: ${{ secrets.SITE_DEPLOY_HOST }}
SITE_DEPLOY_USER: ${{ secrets.SITE_DEPLOY_USER }}
SITE_DEPLOY_KNOWN_HOSTS: ${{ secrets.SITE_DEPLOY_KNOWN_HOSTS }}
run: scripts/site_refresh.sh
+18 -5
View File
@@ -1,17 +1,19 @@
# The project site (docs/milestones/W1.md): built with Zola to see that it builds and that its pages
# are sound. Publishing is the maintainer's: the web server pulls main and runs `zola build`.
# are sound. After a push to main it is then published: the job asks the web server, over SSH, to
# pull main and rebuild (issue #79, scripts/site_refresh.sh). The key it holds can run that one
# command there and nothing else; the server, the user and the keys are secrets, not in this file.
#
# It runs when the site, or a document the site is built from, changes (a pull request, or a push to
# main); the firmware workflow (ci.yml) skips a change that touches only these files. A change that
# touches both runs both. src/main.cpp is here too: the site's command reference is generated from the
# firmware's own `help` text, and this job checks that it is still current.
# touches both runs both. src/main.cpp and lib/core/src/app_keys.h are here too: the site's command
# reference and its key tables are generated from them, and this job checks that they are still current.
name: Site
on:
push:
branches: [main]
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', '.gitea/workflows/site.yml']
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', 'lib/core/src/app_keys.h', '.gitea/workflows/site.yml', 'scripts/site_refresh.sh']
pull_request:
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', '.gitea/workflows/site.yml']
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', 'lib/core/src/app_keys.h', '.gitea/workflows/site.yml', 'scripts/site_refresh.sh']
jobs:
build:
@@ -51,3 +53,14 @@ jobs:
- name: Check the pages
run: python3 site/tools/check_site.py /tmp/site-out
# Only what has been merged, and only once it has built and passed the checks above. A pull
# request never gets here, and the secrets are given to this step alone.
- name: Publish the site
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
SITE_DEPLOY_KEY: ${{ secrets.SITE_DEPLOY_KEY }}
SITE_DEPLOY_HOST: ${{ secrets.SITE_DEPLOY_HOST }}
SITE_DEPLOY_USER: ${{ secrets.SITE_DEPLOY_USER }}
SITE_DEPLOY_KNOWN_HOSTS: ${{ secrets.SITE_DEPLOY_KNOWN_HOSTS }}
run: scripts/site_refresh.sh
+3
View File
@@ -12,3 +12,6 @@ sdkconfig.*
# The built site (site/config.toml sends it here)
/public/
# The version, written by scripts/version.py before each build
lib/version/src/version_generated.h
+22
View File
@@ -106,6 +106,26 @@ The regulatory band plan the device transmits under (here EU868). It sets the al
**Duty Cycle Budget**:
The share of airtime the Region allows this device to transmit. When it's used up, outgoing traffic waits.
**Shell**:
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
**Text Entry**:
When an App is editing text. During Text Entry, `;` `.` `,` `/` type their characters and Fn makes them arrows. Otherwise they are arrows on their own.
_Avoid_: edit mode, insert mode
@@ -180,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
+46 -8
View File
@@ -2,12 +2,34 @@
[![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/)
- Milestones: [docs/milestones/](docs/milestones/)
## 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
Only **Docker** is needed. PlatformIO and the ESP32 toolchain run inside a container, and are cached in the `roro9stack-pio` Docker volume. The first build downloads about 1 GB and takes a few minutes.
@@ -22,7 +44,7 @@ This runs the host-side unit tests (`test/`, `native` environment), then builds
`scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure.
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 4 minutes; later builds take under a minute.
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 6 minutes; later builds take about 15 seconds when little has changed. The platform knows the framework is already rebuilt by `sdkconfig.defaults` in the project folder, which it writes and git ignores: delete it and the next build rebuilds the framework. CI keeps that file, a build cache and ccache in its volume (docs/milestones/R1.md).
## CI and releases
@@ -132,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.
@@ -146,11 +171,21 @@ At the top of the card the last row, **Maintenance** (also `m`), holds the card'
The Notes App (docs/milestones/F1.md) keeps plain text notes in `/notes` on the SD card. The list shows each note's first line and its date, newest first; `s` switches to by file name. `n` starts a note, Enter opens one, `r` renames its file, `d` deletes it after asking.
In the editor, type. Enter starts a line, Del deletes backwards, Fn with the arrows moves the cursor through the wrapped text, Ctrl+A and Ctrl+E go to the start and the end of the line, Tab types two spaces, and the Compose Key gives accents as everywhere. **There's no save key:** the note is written five seconds after the last key, on Back, on leaving the App, when the screen turns off and before the device powers off. The top line says "typing" or "saved". Each save writes a temporary file and then puts it in the note's place, so a power cut costs a few seconds of typing and never the note; if a save was cut short, opening the note offers its copy back.
In the editor, type. Enter starts a line, Del deletes backwards, Fn with the arrows moves the cursor through the wrapped text, Ctrl+A and Ctrl+E go to the start and the end of the line, Ctrl with Fn+Up and Fn+Down to the start and the end of the note, Tab types two spaces, and the Compose Key gives accents as everywhere. **There's no save key:** the note is written five seconds after the last key, on Back, on leaving the App, when the screen turns off and before the device powers off. The top line says "typing" or "saved". Each save writes a temporary file and then puts it in the note's place, so a power cut costs a few seconds of typing and never the note; if a save was cut short, opening the note offers its copy back.
A new note has no file until something is typed; its file is then named after its first line (`shopping-list.txt`), or `note-<date>-<time>.txt`.
A note holds up to 16 KB while it's edited. A bigger text file opens read-only in the Storage App (editing any size is issue #47). The Storage App's text viewer has `e` to edit a file with the same editor, anywhere on the card, unless the file is read-only.
**A note can be any size** (issue #47): the editor keeps a window of about 8 KB around the cursor in memory and the rest on the card, so a megabyte opens as fast as a line and uses the same 17 KB. Up to 64 KB a save rewrites the file. Above, the five-second save writes only what changed to a side file, `<note>.edit`, and the file itself is rewritten when the note is left, with a progress bar (about 450 KB a second). After a power cut, opening the note picks the edit up where it was saved. Saving needs room on the card for a second copy. The Storage App's text viewer has `e` to edit a file with the same editor, anywhere on the card, unless the file is read-only.
## 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.
## Development aids
@@ -159,7 +194,7 @@ A note holds up to 16 KB while it's edited. A bigger text file opens read-only i
| Command | Effect |
|---|---|
| `burst` | Publishes 5 Notifications at once |
| `key up\|down\|left\|right\|select\|back\|home`, or `key <char>` | Injects a key press |
| `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) |
@@ -177,13 +212,15 @@ A note holds up to 16 KB while it's edited. A bigger text file opens read-only i
| `irc say <buffer> <text>` | Types into a Buffer, commands included (`irc say 0 /join #test`) |
| `irc dump` | Prints IRC status, memory, and the last lines of each Buffer |
| `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap, then the address, gateway, DNS and NTP servers in use and where each came from |
| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, and both app slots with their versions and OTA states |
| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, **which App is in front**, and both app slots with their versions and OTA states |
| `Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Shell`, `System`, `Settings` | Opens that App: a capital letter is an App, not a command |
| `tasks` | FreeRTOS tasks over the next second: state, priority, lowest free stack, share of a core, each core's load, and how many passes the main loop made |
| `net` | Bytes each network service has read and written since boot |
| `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) |
| `log level <0-5>` | ESP-IDF log level |
| `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path |
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (a folder with what's in it), new folder. `-f` replaces a file that's in the way; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
| `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead |
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
| `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does |
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
@@ -204,6 +241,7 @@ A note holds up to 16 KB while it's edited. A bigger text file opens read-only i
| `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 |
+176 -2
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).
@@ -98,7 +98,7 @@ Plain text notes on the SD card, written on the device. Q30 settled the base: `.
| Q141 | A **Notes** App in the Launcher. One row per note: its first line as the title, then the date. Newest first; `s` switches to by name. `n` new, Enter opens, `d` deletes after a confirmation, `r` renames the file. |
| Q142 | A new note's file name is never typed: it comes from the first line when the note is first saved (`shopping-list.txt`), or `note-20261006-0919.txt` if that line is empty. It doesn't change afterwards unless the note is renamed. |
| Q143 | **Autosave, no "discard changes?" prompt:** five seconds after the last key, on leaving the note or the App, and when the screen turns off. A save writes a temporary file and renames it over the note, so a power cut loses the last few seconds at most. A temporary file left behind is offered back at the next open. |
| Q144 | The whole note is in memory while it's edited, up to **16 KB**. A bigger text file opens read-only in the Storage App's viewer. The App refuses to open below the memory floors (Q86). **Editing files of any size must come in a later release: issue #47.** |
| Q144 | The whole note is in memory while it's edited, up to **16 KB**. A bigger text file opens read-only in the Storage App's viewer. The App refuses to open below the memory floors (Q86). *(Lifted by issue #47: see "Notes of any size" below.)* **Editing files of any size must come in a later release: issue #47.** |
| Q145 | The editor wraps at spaces, 38 columns by 8 rows, with a line for the name and the state. Enter is a new line, Del deletes backwards, Fn+arrows move (the Text Entry rule), Ctrl+A and Ctrl+E go to the start and the end of the line, Tab types two spaces, Back saves and returns. The Compose Key works as elsewhere. |
| Q146 | The Storage App's text viewer gets `e`: edit this file with the same editor, for a text file up to 16 KB that isn't read-only. That lifts Q135 without Apps opening each other (#43 stays). |
| Q147 | The list is flat: the files directly in `/notes`. Sub-folders are reached through the Storage App. |
@@ -159,3 +159,177 @@ Test notes were made in `/notes` and removed afterwards; the folder is left, emp
**Not checked:** accents through the Compose Key and Ctrl+A / Ctrl+E (the remote `key` command can't send them; the model's tests cover both), the power button's save (it needs a hand on the device), a missing card, and how typing feels on the keyboard itself.
**One slip during the checks:** a key sequence sent right after a restart opened IRC instead of Notes, and the test letters went into IRC's input line. Nothing was sent: the line was cleared and the App left. IRC connected to Libera as it does when opened.
## Notes of any size (issue #47)
Q144 held the whole note in memory and stopped at 16 KB, for the first version only. This lifts it: the editor opens a text file whatever its size.
### Decisions (design round 2026-10-07)
| # | Decision |
|---|---|
| Q223 | **The note is the file on the card plus one window in memory.** The window is the `NoteText` of before, up to 16 KB around the cursor; the rest is a list of pieces: runs of the file, and runs of a side file. The cursor leaving the window writes it to the side file if it was changed, and loads the next. Typing never fills a note: a full window is written away and loaded smaller. |
| Q224 | **The five-second save:** up to 64 KB it rewrites the file, as before (about 150 ms). Above, it appends the window and the list of pieces to `<note>.edit`: 8 KB or so, whatever the note's size. "saved" means "on the card" either way. |
| Q225 | **The file itself is rewritten on leaving the note** (Back, Home, another App), with a progress bar. The screen turning off and the device powering off write the side file only: powering off never waits. |
| Q226 | **After a power cut, opening the note picks the edit up** where it was last saved, without a question, and says so. Until then the file has the old text for anything else that reads it. |
| Q227 | If the file was changed elsewhere meanwhile, the side file no longer fits it: it is **kept as `<note>.edit.lost`** and the editor says so. Typed text is never deleted without a word. |
| Q228 | **No limit but the card:** a note over 16 KB needs room for a second copy to be opened for editing. No warning for a big file; the progress bar on leaving tells the cost. |
| Q229 | A side file over 1 MB, or a list of over 256 pieces, makes the next save a rewrite. |
| Q230 | **CRLF becomes LF** (Q148) for a long file too: in the window as it is read, and in the rest of the file as the rewrite streams it, so a saved file is never of both kinds. |
| Q231 | **One path.** A 16 KB note is the case with no pieces: there is no second editor for small notes. |
| Q232 | Notes, and `e` in the Storage App's viewer, which no longer says "Too big to edit". |
### As built
- **`NoteDocument`** (`lib/notes/src/note_document.h`, host-tested against a card in memory) is the list of pieces, the window's moves, the side file and the recovery. `NoteText` is unchanged but for being refilled.
- **The window moves** when the cursor comes within 2 KB of an end of it that isn't an end of the note: it is then 4 KB on each side of the cursor. It starts where a line starts on screen whenever that can be known (after a newline, or where the window before had a line start), so the same text wraps the same from one window to the next, and never in the middle of a character. The cursor keeps its row on screen.
- **Looking writes nothing:** a window that wasn't changed goes back as the pieces it was read from.
- **The side file** starts with a line of text, the note's size and checksums of its first and last kilobyte, which is how a file changed elsewhere is told. After that, text that left a window, and snapshots of the list of pieces, each with its checksum. The newest snapshot that checks out is the note as last saved; anything after it is ignored.
- **The rewrite** streams the pieces and the window into `<note>.tmp`, checks its size, then writes a mark at the end of the side file: from that mark on, the rewrite counts as done, and opening the note finishes it whatever was cut (remove the old file, rename, remove the side file). Before the mark, the note and its side file are still the truth and the temporary file is dropped.
- **On the device** the card is reached through an adapter that keeps the file being read and the file being appended to open between calls; every call runs on the storage task while the main loop waits. The rewrite runs in steps of 64 KB with the progress drawn between them.
- **The Notes list** doesn't show `.edit` and `.edit.lost` files, and a note's side file is deleted and renamed with it.
- **Ctrl with Fn+Up and Fn+Down** go to the start and the end of the note.
- **`key ctrl-down`**: the consoles' `key` command takes `ctrl-`, `alt-` and `shift-`, which these checks needed. It also lets the checks S1 couldn't make (Ctrl+b, the Alt scroll) be made.
- **Cost:** 15 KB of flash. Memory with a note open is what it was: 17.5 KB, for 62 bytes or for 1.2 MB.
### Host tests (15, `test/test_note_document`)
A walk down 3,000 lines and back up through the windows; start and end; an edit in the middle rewritten into the file; 48 KB typed into a new note; a journal picked up after a cut; **a cut at every 997th byte of a sequence of two saves and a rewrite**, after which the note is always one of the three texts it should be, what was reported saved is there, and no stray file is left; a file changed elsewhere; CRLF; windows on text with no space and no newline, made of 2, 3 and 4-byte characters; a full card; and **36,000 random keys** (typing, deleting, moving, jumping, saving, power cuts) on six notes of 30 to 130 KB, compared with a plain string after every key.
### Checks on the device (2026-10-07, driven over the Debug Console)
Test notes were copied to `/notes` and removed afterwards; the note that was already there was not touched.
| Check | Result |
|---|---|
| A 36 KB note | Opens (it was refused before). Two letters at the top, 400 lines down across the windows, four more: the file fetched back is exactly that, and no other file is left |
| A 1.2 MB note | Opens at once. Free memory 104.2 KB before, 86.7 KB with it open |
| Its five-second save | `zz-big.txt.edit`, 4 KB; the note's file untouched |
| Ctrl with Down, Ctrl with Up | The end and the start, as fast as any key |
| A restart with unsaved keys | "Your unsaved changes are back", the cursor where it was, the unsaved keys gone and nothing else |
| Leaving it | The progress bar, then one file: **1.2 MB rewritten in 2.6 s**. Fetched back: the original with what was typed at both ends, byte for byte |
| A restart in the middle of that rewrite | The note, its side file and an empty `.tmp` remain; opening picks the edit up, leaving rewrites it, the result is right |
| A new note | No file until typed in, then `zz-test-note.txt` from its first line |
| `e` in the Storage App on the 1.2 MB file | The same editor; edited and rewritten |
| The Notes list | Side files are not listed as notes |
**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.
+60 -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.
@@ -172,3 +172,62 @@ The issue asked for a token that could be set, so that Debug Builds could be pub
**One false alarm:** after the tests the console "wouldn't come back on". It was off: the setting had been left off by `debug off`, and the page's "Switch it on?" dialog opens on **Cancel**, so Enter twice leaves it off. Nothing was lost.
**Not checked:** `scripts/flash.sh --debug` over USB (no device on USB here); "New token" and "Type a token" from the page (the code paths are the ones `debug token` uses, which need the cable); the Toast itself on screen (the console is closed while it shows; its Notification is in the log); free memory with the console off, which only the serial port could say (the static figures above are the evidence); fetching a release's ELF, which needs a crash on a released version.
## CI that doesn't rebuild the world (issue #74)
A pull request's run took over seven minutes, a tag's thirteen and a half. The goal: a firmware build under a minute.
### Where the time went (run 81, a pull request, 2026-10-06)
| Step | Time |
|---|---|
| Tools (apt, pip) | 15 s |
| Check out | 2 s |
| Host tests and coverage | 55 s |
| **The firmware** | **358 s** |
| of which: CMake configuring ESP-IDF | 87 s |
| of which: compiling ESP-IDF's libraries | 171 s |
| of which: our own build (the Arduino core, the libraries, `src/`) | 91 s |
A tag's run did the firmware step, then built the same commit again for the release: twice 356 s.
### Why the framework was rebuilt every time
The framework is rebuilt with our SDK settings (ADR 0006), and the rebuilt libraries stay in the toolchain volume. But the platform decides whether they match by reading **`sdkconfig.defaults` in the project folder**, whose first line carries a hash of the settings. That file is generated, and not in git. A fresh checkout has none, so the platform concluded "different settings", **reinstalled the framework and rebuilt it**: 260 seconds, at every run, to arrive at the libraries that were already there. On a developer's machine the file is simply still there from the last build, which is why nobody saw it.
### What changed
| Change | Effect |
|---|---|
| **The file is kept in the volume, inside the libraries it describes** (`framework-arduinoespressif32-libs/.roro-sdkconfig.defaults`), copied into the checkout before a build and back after one that passed. The platform still checks its hash against `platformio.ini`: changed settings rebuild, as they must. Kept there and not beside them, it disappears when the libraries are reinstalled, so it can't describe libraries that are gone | 358 s to 92 s |
| **The version is no longer a `-D` on every compiler command line.** `scripts/version.py` writes `lib/version/src/version_generated.h` (not in git, written only when it changes), read by one file. Before, every commit recompiled everything, on a developer's machine too, and no cache could have helped | A rebuild with nothing changed: 77 s to 13 s, locally |
| **PlatformIO's build cache** (`PLATFORMIO_BUILD_CACHE_DIR`, SCons's CacheDir) in the volume, for the firmware of pull requests: objects by the signature of their sources and command line | 92 s to 26 s, with a new version and one changed file |
| **ccache for the host tests.** They are built with coverage counters, and the build cache would return objects without their `.gcno` files; ccache keeps both | 49 s to 33 s. What's left is PlatformIO starting 51 test programs |
| **A tag builds its firmware once**, in the release step | minus 6 minutes |
| PlatformIO and gcovr in a virtual environment in the volume | a few seconds |
**A release compiles its own sources from nothing:** it reuses the rebuilt framework (the platform checks the hash) but not the build cache, so no published file contains an object that came from another commit's build.
### Measured (a development machine, fresh copies of the tree, the same volume)
| | Before | After |
|---|---|---|
| The firmware, fresh checkout, nothing cached for it | 358 s | 82 s (it fills the cache) |
| The firmware, fresh checkout, a new version and one file changed | 358 s | **27 s** |
| Host tests and coverage | 49 s | 33 s |
| Rebuilding locally with nothing changed | 77 s | 13 s |
### Measured on the runner (pull request #76, 2026-10-07)
| Run | Tools | Tests and coverage | The firmware | The whole job |
|---|---|---|---|---|
| Before (run 81) | 15 s | 55 s | 358 s | 434 s |
| The first with the new workflow: no mark yet, the framework is rebuilt once more and the caches fill | 16 s | 59 s | 354 s | 431 s |
| The next commit (only the workflow changed) | 10 s | 36 s | **51 s** | **100 s** |
| The same commit again | 10 s | 36 s | **18 s** | **66 s** |
In the 51-second run, 245 objects came from the cache and 43 were compiled: `version.cpp`, as expected, and all 42 files of `src/`, which had not changed. In the run after it, all 290 came from the cache. So the objects of `src/` made by the run that rebuilt the framework were not reusable by a normal run, and those of a normal run are: the two-pass build that rebuilds the framework compiles `src/` with something different on its command line. It costs one 51-second run after each framework rebuild, which is rare; I did not look for what differs.
The firmware step with everything cached is 18 seconds: the libraries are downloaded and unpacked (4 s), the dependency scan (5 s), fetching 290 objects, the link and the image (the last 11 s). A pull request that changes a few files should land between that and 51 seconds.
**What it costs:** the build cache grows by about 40 MB a run (each linked firmware is kept) and is started again past 3 GB; ccache is held to 1 GB.
+67 -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.
@@ -136,3 +136,69 @@ M3 found the LoRa radio's noise floor about 15 dB above what the chip hears alon
**Settings > Pause GNSS for LoRa**, off by default: while the LoRa radio listens or sweeps, the GNSS receiver waits in standby, and wakes when the radio goes back to sleep (a Fix again after about 7 s here). Never during a Track. The GNSS App says "GNSS is paused" meanwhile. `gnss quiet on|off` on the console.
It's off by default because GNSS on by default was decided in M2 (Q58), and from M4 the radio listens all the time: then "pause while listening" means GNSS mostly off, which is a decision about position, the clock and Tracks, for M4's design round (issue #23).
## The Shell (issue #67)
The console's commands could only be typed on a PC: over USB, or over Wi-Fi with the Debug Console. A device in a bag, or on a network that is down, could not be asked anything. The Shell is an App that runs the same commands on the device's own screen and keyboard.
### Decisions (design round 2026-10-07)
| # | Decision |
|---|---|
| Q204 | **An App, "Shell", in the Launcher**, in every firmware: its commands already work over USB for anyone holding the device. |
| Q205 | **Trusted like USB serial**, not like the network: `debug on` and `debug token` work from it, as they do in Settings. The token is never shown. |
| Q206 | *Revised the same day, after trying it.* **It shows the replies to its own commands, and only those.** The console knows who each line is printed for (`Console::Origin`): a command run from the Shell prints as the Shell's, and so does what answers it later from another task, which notes who asked and takes it back when it prints (`ls`, `tasks`, `du`, `cp`, `update check`, `sd list`, `screenshot`, `gemini get`). What USB or the Debug Console asked for, and the system's own lines, are not the Shell's. **Ctrl+b shows everything** instead. The first version kept whatever was printed in the ten seconds after a command, which was a guess, and a noisy one. |
| Q207 | **Nothing while it's closed.** Open, a 4 KB ring of the console's and up to 4 KB of lines; both go when the App is left, with the list of commands for Tab. |
| Q208 | Enter runs the line; Fn with up and down recalls the last 16; Alt with up and down scrolls back. **Tab completes** the command, **every word of it** *(the first only, at first)*: `lora st` gives `lora status`, `gnss track ` lists `start stop`, `key ` its eleven names. The words come from the firmware's `help` text, read as it is written, so a new command completes without a table to keep. *(Added the same day)* **past the command, a path on the SD card**: a folder keeps its slash to go on from, a file completed whole gets a space, several candidates are listed. Whatever the case typed, the name's own is taken, since the card doesn't tell them apart. After a file command the first slash is understood (`cat no` is `/no`). A name with a space in it isn't completed. |
| Q209 | *Revised the same day.* **`rm` is Unix's, with a question.** A folder needs `-r`, here and over the consoles (where `rm <folder>` used to remove it with what was in it). In the Shell, a file, or a folder with something in it, is asked about unless `-f` (`-rf`, `-r -f`); an empty folder with `-r` goes without a word; what `rm` would refuse anyway, it refuses itself. Over the consoles nothing is asked: scripts delete as before. **`screenshot [seconds]`** saves the screen as a PNG in `/screenshots`, now or after a pause, since from the Shell "now" is the Shell. `get`, `put`, `coredump get` and `reset` answer `Debug Console only`. *(Added the same day)* **`*` and `?` in a name**, for `ls`, `du`, `rm`, `cp` and `mv`, here and over the consoles: `rm /notes/*.txt`, `cp /gnss/2026-10-0?.gpx /backup`. In the last part of the path only, any case, as the card has it. It is the same command once for each name matched, in the name's order; `cp` and `mv` then need a folder that exists to put them in. Over 64 matches is refused whole, as is none. In the Shell `rm` with a pattern asks **once**, with the count. |
| Q210 | Commands are echoed as `> command` into the console, so a session reads the same from afar. **A token being set is not echoed.** |
| Q211 | **Not in Safe Mode**, which starts no Apps: issue #77. |
| Q212 | Its keys are a table in `app_keys.h`, so the help panel and the website have them; a page in the user guide. |
| Q213 | *Added the same day.* **An App's name with a capital opens it:** `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Notes`, `Shell`, `System`, `Settings` (the App's id, up to its first dash). From the Shell, without going back to the Launcher, and from the consoles too. The capital says "an App": every command of the firmware is in small letters. Tab completes them. |
### As built
- **`ShellApp`** (`src/apps/shell_app.cpp`), with its model host-tested in `lib/apps_model/src/shell_log.h`: lines arriving in pieces, the 4 KB limit, the words of every command out of the `help` text (Apps' names included), and Tab.
- **Who a line is for:** `Console::As` marks the calling task as printing for the Shell while it lives, and `Console::origin()` lets a command that answers later carry that to wherever it prints. The console has a second ring for the Shell, which gets the lines marked so, or everything.
- **The Shell hands its lines to the main loop**, which runs them like the consoles' commands. See below for why.
- **`info` says which App is in front** (`app: Shell`): so that a hand driving the device from afar can look before it types.
- **`screenshot`** writes the PNG a row at a time with no buffer: 8-bit indexed colour, the 256 colours of RGB332 as the palette, the pixels in one stored deflate block (`lib/files/src/png_rgb332.h`, 4 tests). 33,383 bytes for the 240 x 135 screen. A Toast says so once it is on the card, so the Toast is never in the picture.
- **The `help` text lost its shorthand** (`update check | list | status` is written out), since Tab reads its words from there: `|` between two commands, `on|off` between two words, three spaces before the description. The Shell's own words (`clear`, `quit`, `key`'s names) are added in the same notation.
- **Tab on a path** reads the folder on the storage task while the main loop waits, so it is bounded: 400 entries looked at, 24 candidates given back, and `...` after the list when there were more.
- **A pattern** is matched in `lib/files/src/file_names.h` (`globMatch`, host-tested), and the names it matches are lined up as so many commands, which the main loop runs one after the other as each finishes. `cancel` empties the line-up. 2000 entries looked at, 64 matches at most.
- **Cost:** 21 KB of flash, 96 bytes of static RAM. Open: 7 KB of heap (107.6 KB free before, 100.7 with it open, 106.1 after leaving).
### What went wrong while building it
**The device crashed, and the test sent a message to an IRC channel.** The first version ran a command from inside the key handler. Driven from the Debug Console, that is: the main loop, a remote command, `key select`, the App manager, the Shell, `runCommand` a second time, the file command, and `printf` under all of it. The main loop has under 2 KB of stack to spare; `rm` on a folder went past it. The crash report decoded to exactly that chain.
The device restarted into the Launcher, and the test script, which did not look, went on typing. Its next Enter opened IRC, which connected, and a few lines later it typed "No" into a channel and pressed Enter. One word, sent to real people, that can't be taken back.
Two changes came of it. The Shell now **queues** its line and the main loop runs it, at the same stack depth as a console's command. And `info` reports the App in front, which the test script now checks before every line it types.
### Checks on the device (2026-10-07, driven over the Debug Console with `key`)
| Check | Result |
|---|---|
| Open it from the Launcher, type `ls /`, Enter | The folders are listed |
| Tab on `in` | `info install` is shown and the line stays; on `u` it becomes `update `; on `l`, `log ls lora loop` |
| Up | The line before comes back |
| `screenshot` | `/screenshots/20261007-104357.png`, 33,383 bytes. Fetched and decoded on the PC: 240 x 135, indexed, every chunk's CRC right, the pixels the Shell's screen |
| Output | With a Debug Console client connecting and disconnecting for every key, the Shell shows the commands and their replies and nothing else: `info`, `ls /` (answered by the storage task), `tasks` (answered a second later) |
| `rm` on a folder, without `-r` | Refused, the folder stays |
| `rm -r` on an empty folder | Removed, no question |
| `rm -r` on a folder with files | Asks; Cancel leaves it. `rm -rf` removes it without asking |
| `rm` on a file | Asks; Delete removes it |
| Tab on a path | `ls /no` becomes `ls /notes/`, and again, with one note in it, the note's whole name and a space. `ls /g` lists `gemini/ gnss/`. `cat no` becomes `cat /notes/`. `rm -r /CAP` becomes `rm -r /captures/`. `ls /zz` stays as it is |
| Tab past the first word | `lora st` becomes `lora status `; `gnss tr` becomes `gnss track ` and Tab again lists `start stop`; `key ` lists its eleven names; `upd` becomes `update ` and lists `check list status install` |
| `ls` with a pattern | `ls /gt/*.txt` the five files, `ls /gt/*2*` the one, `ls /gt3/*6?.txt` ten of seventy. `ls /g*/x`: refused, a pattern goes in the last part |
| `cp /gt/file-000?.txt /gt2`, `du /gt2/*1*` | Five copies; one size |
| `rm /gt2/*` in the Shell | "The 5 that match /gt2/\*": Cancel leaves the five. `rm /gt3/*6?.txt`, Delete: ten gone, sixty left. `rm -f /gt2/*`: gone without a question |
| `rm /gt/*` where one match is a folder | The files go, the folder stays (no `-r`) |
| No match, and seventy | `rm: error nothing matches`; `rm: error more than 64 match: a narrower pattern, please`, and all seventy still there |
| `No`, Tab, Enter | Completes to `Notes ` and opens Notes. `Shell` from the Debug Console opens the Shell |
| `screenshot 4`, then Home | The picture, taken four seconds later, is of the Launcher: the pause works, and the Toast isn't in it |
| `quit` | Back to the Launcher, and the memory comes back |
| The help panel in the Shell | Its keys, then the ones that work everywhere |
**Not checked:** Ctrl+b and the Alt scroll, which `key` can't press (no modifiers): the filter itself is host-tested, the key that flips it is not; the real keyboard altogether; the Toast after a screenshot, which was published but not looked at; and `mv` with a pattern and `cancel` in the middle of a line-up, which share their code with `cp` and `rm` but were not run. The main loop's lowest free stack after all of it: 1.8 KB, where it was.
+61
View File
@@ -0,0 +1,61 @@
# U1 — Look and feel
**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). Not started: screen recording (#17), a Launcher of tiles (#9), themes (#10).
**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.
## The help key (issue #69)
Every screen used to say something about its keys, differently: a footer of abbreviations in one place (`c x v:paste r:name d:del n:new i:info s:sort`), a line under a text field in another (`Enter: save \`: cancel`), `Tab: sky` in a corner, and nothing at all in several. About 30 such strings, each costing a line of a small screen, and none of them complete.
### Decisions (design round 2026-10-07)
| # | Decision |
|---|---|
| Q196 | **Fn+h, on every screen,** text fields included (Fn is held, so nothing is typed). **`?` too, outside Text Entry.** |
| Q197 | It opens **a panel over the content area**, titled with where you are: the screen's own keys, then an "Everywhere" group (Back, Home, the arrows, the help key). The arrows scroll it; any other key closes it and is not passed on. |
| Q198 | **Each App answers "what are your keys right now?"** for the state it is in; pages, viewers, dialogs and text fields answer for themselves, with shared lists for dialogs, lists and text entry. The lists are constants; the panel's rows exist only while it is open. |
| Q199 | **Every hint that names a key goes,** text fields included. What stays is state: `REC 12 points`, `LOG 42`, `sort:signal`, `typing`/`saved`, what is waiting to be pasted, the Sweep's floor. Messages were reworded where they named a key ("v pastes a copy of…" is "Copied …: paste it where you like"). |
| Q200 | **The first-start Setup keeps its hints,** and is the one place that does: someone in their first minute doesn't know the help key yet. It tells them about it on its first and last screens. |
| Q201 | **Loud everywhere else:** the user guide opens with it, the FAQ has it first, and a device set up before this firmware gets one Toast, once: "Fn+h: the keys of any screen". |
| Q202 | `key help` over the consoles. Generating the website's key tables from the same lists is a follow-up, not this issue. |
| Q203 | The key and the panel first, host-tested; then one App at a time, declaring its keys and losing its hints in the same step; then every screen looked at on the device. |
### As built
- **`Key::Help`** from the key mapper: Fn+h in both modes, `?` only outside Text Entry (`lib/input`, 3 tests).
- **`App::help()` and `App::helpTitle()`** (`lib/core/src/app.h`), `KeyHelp` rows and `HelpModel` (`key_help.h`). The **App manager** opens the panel, appends the "Everywhere" group, and while it is open takes every key: nothing reaches the App, Home included. It closes when the App changes (5 tests).
- **Every App declares its keys by state:** the Launcher, IRC (chat, settings, a field), Wi-Fi Tools (4 views), GNSS, Gemini (page, saved page, address, answer, dialogs), the LoRa Scanner (4 views), Storage (browse, details, a name, the viewer's 6 modes, the editor, Maintenance, busy), Notes (list, editor, a file name), System (5 views), Settings (menu, text, choice, and the Wi-Fi, Firmware and Debug Console pages with their own states), Setup and the widget demo.
- **The hints are gone** from all of them. The footers that remain say state only.
- **It costs** 8.5 KB of flash and 40 bytes of static RAM.
### Checks
| Check | Result |
|---|---|
| Host tests | 476 pass (468 before) |
| On the device, `key help` and a screenshot | The Launcher and all nine Apps, and these states: Storage scrolled, System's tasks, a Settings text field, Wi-Fi Tools' networks. The panel is titled with the scope, lists the right keys, scrolls, and closes on Tab |
| The one-time Toast | `notification: Fn+h: the keys of any screen` on the first start after the update |
### One source for the device and the website (issue #72)
The lists first lived in each App's `help()`, as code. They are now **data, in one file**: `lib/core/src/app_keys.h`, 52 constant tables, each under a comment `// id: Title`. An App's `help()` picks the table of the state it is in. `site/tools/gen_dev_docs.py` reads the same file and writes `site/data/keys.toml`; the `keys` shortcode shows a screen's tables on its guide page, and `/guide/keys/` shows all of them. The Site job fails when the data file is out of date, or when a page asks for a table that doesn't exist, and it now runs when `app_keys.h` changes. A key added to an App shows up on the website without anyone editing a page.
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.
+59 -2
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.
@@ -21,7 +21,7 @@ The home page was designed on a canvas in a Claude chat (a dark and a light them
| Q175 | The site lives in this repository, in `site/`, so the documentation is built from `docs/`, `CONTEXT.md` and the README instead of being copied. |
| Q176 | **Zola,** like the blog. The design becomes a template, its tokens CSS custom properties. Dark and light follow the visitor's setting, with a visible switch. No JavaScript except the flasher's. |
| Q177 | Domain: **roro9stack.net.** The blog stays at experiments.twis.la. |
| Q178 | **Publishing is the blog's way:** the web server pulls `main` and runs `zola build`; that part is the maintainer's. Changes reach `main` through pull requests as everywhere. **CI is split:** a dedicated `site` job builds the site (`zola build`) when `site/`, `docs/`, `README.md` or `CONTEXT.md` change, and the firmware tests and builds skip a change that touches nothing else. A change that touches both runs both. |
| Q178 | **Publishing is the blog's way:** the web server pulls `main` and runs `zola build`; that part is the maintainer's. *(Since issue #79, CI asks the server to do it: see "Published by CI" below.)* Changes reach `main` through pull requests as everywhere. **CI is split:** a dedicated `site` job builds the site (`zola build`) when `site/`, `docs/`, `README.md` or `CONTEXT.md` change, and the firmware tests and builds skip a change that touches nothing else. A change that touches both runs both. |
| Q179 | Phases, each its own pull request: **1.** the CI split, the home page, an Install page with the browser flasher, downloads and the changelog. **2.** a user guide page per App. **3.** how-tos and the FAQ. **4.** developer docs generated from the repository. |
| Q180 | **A browser flasher** (ESP Web Tools), **without copying the firmware.** Caddy, in front of Gitea, adds `Access-Control-Allow-Origin: https://roro9stack.net` (and `Vary: Origin`) to GET and HEAD on `/twisla/roro9stack/releases/download/*` and `/api/v1/repos/twisla/roro9stack/releases*`: both are public already. The Install page asks the API for the latest release in the browser, finds the asset ending `-factory.bin`, and gives ESP Web Tools a manifest built on the spot, so it offers a new release as soon as it exists, with no rebuild. The library is **vendored** into `site/static/` (Apache-2.0), not loaded from a CDN. The file's SHA-256 is shown on the page. Chrome or Edge on a desktop only; other browsers, and visitors without JavaScript, get the `esptool` steps on the same page. |
| Q181 | Docs for the latest version only. The changelog is the Gitea releases, read at build time. |
@@ -125,3 +125,60 @@ Not one of the planned phases: the blog's seven roro9stack posts, imported into
- **Left out on purpose:** the M0 and M1 milestone documents and `CONTEXT.md` (the glossary) describe Wi-Fi monitoring, which the site does not publish. They stay in the repository.
- **The Debug Console pages were written against the source and the live console:** the protocol (the token line, the banner, the 4 KB backlog, one client, 8 queued commands, 240-byte lines, `denied` after a second) and the replies shown were checked on a Debug Build, v0.11.0-3, over Wi-Fi. Not run: `crash abort`, `crash wdt` and Safe Mode, which are described from ADR 0005 and the code.
- **Found while writing it:** the README's table lacked the `gnss` commands (rows added); piping commands into `rdbg.py` returns before the replies unless the input stays open (documented, not changed); `update install` on a Debug Build needs `force` (documented).
## Published by CI (issue #79, design round 2026-10-07)
Q178 left publishing to the maintainer: a merge, then a command typed on the web server, each time.
| # | Decision |
|---|---|
| Q214 | **A plain ed25519 key with a forced command**, not a certificate: one line in the web server user's `authorized_keys`, `restrict,command="/full/path/to/the/refresh"`. `restrict` takes away the terminal and every forwarding. A certificate could carry the same and an expiry date, at the price of a CA to keep and a key to sign again each time: too much for one key and one command. |
| Q215 | **The CI sends no command.** The server runs the forced one whatever is asked for, so there is nothing to keep secret about it and nothing a leaked key could choose. The full path is written once, on the server (a command over SSH doesn't get the user's login `PATH`). |
| Q216 | Four secrets: `SITE_DEPLOY_KEY`, `SITE_DEPLOY_HOST` (or `host:port`), `SITE_DEPLOY_USER`, and **`SITE_DEPLOY_KNOWN_HOSTS`**, the server's host key: the job connects to that server or to nothing. None of them is in the repository, which is public. |
| Q217 | `from="<the runner's address>"` on the same line: the key works from the runner only. |
| Q218 | **The last step of the Site workflow**, after the build and the checks, on a push to `main` only. A pull request never reaches it, and the secrets are given to that step alone. |
| Q219 | **After a release too.** The Install page asks Gitea for the latest release when it is opened, but the home page and Downloads read it when the site is built: so the release workflow refreshes the site once the release is published. |
| Q220 | A refresh that fails makes the run red, with what the server's script printed: it has to exit with an error when it fails. |
| Q221 | Two refreshes at once are the server script's to refuse or queue (`flock`). |
| Q222 | The key is a file only while the step runs, in the job's container, as the signing key is. |
### As built
- **`scripts/site_refresh.sh`** is what both workflows run: it writes the key and the host key to a temporary folder, connects with no configuration but its own line (`-F none`, strict host key checking, that one key, no command), and removes them. With none of the four secrets it does nothing and says so (a fork, or a repository without them); with only some it fails.
- **`scripts/site_deploy_keygen.sh`** makes the key pair once, in `~/.config/roro9stack/`, and prints the `authorized_keys` line and what goes in each secret. It never prints the private key.
- **The server's script** should start like this, for Q220 and Q221:
```sh
#!/bin/sh
set -e
exec 9>/tmp/rororefresh.lock
flock -w 120 9
```
### Checks (2026-10-07, against an SSH server in a throwaway container)
| Check | Result |
|---|---|
| The refresh | The forced command runs as the server's user; the script ends with `site refresh: done` |
| The same key, asking for `id; cat /etc/passwd` | The refresh runs instead; what was asked for is only handed to it as text |
| A terminal | Refused: `PTY allocation request failed` |
| `scp` with the key | Nothing is copied |
| Another host key in the secret | `Host key verification failed`, the run fails, nothing is sent |
| The server's script exits with an error | So does the step |
| No secrets at all; only one of the four | Does nothing and says so; fails and says which are needed |
**Not checked:** the real web server and the runner, which wait for the key to be installed: whether the runner reaches the server's SSH port is the first thing the first run will tell. Port forwarding, which `restrict` switches off, was not tried. `from=` was not tried either.
## Search (issue #60)
A search over the documentation: the user guide, the how-tos, the questions and answers, and the developer docs. Not the devlog.
- **The index is the search page itself** (`/search/`, `templates/search.html`): one list item for each page and for each `##` heading of it, with that part's text, written by Zola from the pages' own content when the site is built. Nothing is fetched and nothing typed leaves the browser, so the Content-Security-Policy needs nothing new, and the web server still only runs `zola build`.
- **Without JavaScript** the page is a list of every page and heading of the documentation, each a link.
- **With it**, `js/search.js` filters and ranks the items as you type: every word has to be in the part; a word in a heading counts for most, the words side by side for more than scattered, and the user guide, the how-tos and the FAQ come before the developer docs, the milestones last. A result links to its heading, with the text around the match.
- **The content pages get no script for it:** the navigation has a link, and the index pages of the guide, the how-tos and the developer docs have a box that is a plain form to `/search/?q=`.
- **Size:** about 245 items, about 310 KB of HTML, under 100 KB compressed, loaded only by who searches.
**Checked** in Chromium with the production Content-Security-Policy on every response, no violation: "probation" (the guide's "Probation and Rollback" first), "safe mode", "rm -r", "how big can a note" (the FAQ's question first), a word that isn't there; typing, following a result to its heading, the box on the guide's index, 390 px wide with no sideways scroll, and JavaScript off. `check_site.py` follows every link of the page, so an index entry can't point at a heading that doesn't exist.
**Not checked:** other browsers, and a screen reader.
+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) {}
+171
View File
@@ -0,0 +1,171 @@
#include "shell_log.h"
#include <algorithm>
namespace roro {
void ShellLog::push(const std::string& line) {
lines_.push_back(line);
bytes_ += line.size() + 1;
while (bytes_ > kMaxBytes && lines_.size() > 1) {
bytes_ -= lines_.front().size() + 1;
lines_.pop_front();
}
revision_++;
}
void ShellLog::add(const std::string& line) { push(line); }
void ShellLog::feed(const char* data, size_t len) {
for (size_t i = 0; i < len; i++) {
char c = data[i];
if (c == '\r') continue;
if (c != '\n') {
if (partial_.size() < 512) partial_ += c; // a line that never ends doesn't take the heap
continue;
}
if (partial_.rfind("status: heap ", 0) != 0) push(partial_);
partial_.clear();
}
}
void ShellLog::clear() {
lines_.clear();
partial_.clear();
bytes_ = 0;
revision_++;
}
namespace {
bool isWord(const std::string& w) {
if (w.empty()) return false;
for (size_t i = 0; i < w.size(); i++) {
bool small = w[i] >= 'a' && w[i] <= 'z', capital = w[i] >= 'A' && w[i] <= 'Z';
if (!(small || (i == 0 && capital))) return false;
}
return true;
}
std::vector<std::string> split(const std::string& text, const std::string& by) {
std::vector<std::string> out;
size_t at = 0;
for (;;) {
size_t next = text.find(by, at);
out.push_back(text.substr(at, next == std::string::npos ? std::string::npos : next - at));
if (next == std::string::npos) return out;
at = next + by.size();
}
}
// The words a command can have at `index`, given the `index` words before it: added to `out`.
void nextWords(const std::string& command, const std::vector<std::string>& before, size_t index, std::vector<std::string>& out) {
std::vector<std::string> tokens;
for (auto& t : split(command, " "))
if (!t.empty()) tokens.push_back(t);
for (size_t i = 0; i < tokens.size(); i++) {
std::vector<std::string> either = split(tokens[i], "|"); // on|off: either of them
bool words = true;
for (auto& w : either) words = words && isWord(w);
if (!words) return; // an <argument>, an [option], "...": the command's words end here
if (i == index) {
for (auto& w : either)
if (std::find(out.begin(), out.end(), w) == out.end()) out.push_back(w);
return;
}
if (std::find(either.begin(), either.end(), before[i]) == either.end()) return; // another command
}
}
} // namespace
std::string completeWords(const std::string& typed, const char* helpText, std::vector<std::string>& matches) {
matches.clear();
std::vector<std::string> words = split(typed, " ");
for (size_t i = 0; i + 1 < words.size(); i++)
if (words[i].empty()) return typed; // two spaces: not ours to guess
const std::string last = words.back();
words.pop_back();
if (words.empty() && last.empty()) return typed;
std::vector<std::string> next;
for (auto& line : split(helpText ? helpText : "", "\n")) {
std::string commands = line.substr(0, line.find(" ")); // the description starts at three spaces
for (auto& command : split(commands, " | ")) nextWords(command, words, words.size(), next);
}
for (auto& w : next)
if (w.rfind(last, 0) == 0) matches.push_back(w);
if (matches.empty()) return typed;
std::string common = matches[0];
for (auto& m : matches) {
size_t n = 0;
while (n < common.size() && n < m.size() && common[n] == m[n]) n++;
common.resize(n);
}
std::string head = typed.substr(0, typed.size() - last.size());
if (matches.size() == 1) {
matches.clear();
return head + common + " ";
}
return head + common;
}
namespace {
char lower(char c) { return c >= 'A' && c <= 'Z' ? static_cast<char>(c - 'A' + 'a') : c; }
bool startsWithNoCase(const std::string& name, const std::string& prefix) {
if (name.size() < prefix.size()) return false;
for (size_t i = 0; i < prefix.size(); i++)
if (lower(name[i]) != lower(prefix[i])) return false;
return true;
}
bool takesAPath(const std::string& command) {
static const char* const kCommands[] = {"ls", "du", "mkdir", "rm", "cp", "mv", "cat", "install"};
for (auto c : kCommands)
if (command == c) return true;
return false;
}
} // namespace
bool splitForPath(const std::string& typed, PathToComplete& out) {
size_t space = typed.rfind(' ');
if (space == std::string::npos) return false; // still the command's own word
std::string word = typed.substr(space + 1);
if (!word.empty() && word[0] == '-') return false; // a switch
if (word.empty() || word[0] != '/') {
if (!takesAPath(typed.substr(0, typed.find(' ')))) return false;
word = "/" + word;
}
size_t slash = word.rfind('/');
out.head = typed.substr(0, space + 1);
out.folder = word.substr(0, slash + 1);
out.prefix = word.substr(slash + 1);
return true;
}
std::string completePath(const PathToComplete& what, const std::vector<std::string>& names, std::vector<std::string>& matches) {
matches.clear();
for (auto& n : names)
if (startsWithNoCase(n, what.prefix)) matches.push_back(n);
if (matches.empty()) return what.head + what.folder + what.prefix;
std::string common = matches[0];
for (auto& m : matches) {
size_t n = 0;
while (n < common.size() && n < m.size() && lower(common[n]) == lower(m[n])) n++;
common.resize(n);
}
if (matches.size() == 1) {
bool folder = !common.empty() && common.back() == '/';
matches.clear();
return what.head + what.folder + common + (folder ? "" : " ");
}
// Several: never shorter than what was typed (cases may differ past the prefix).
if (common.size() < what.prefix.size()) common = what.prefix;
return what.head + what.folder + common;
}
} // namespace roro
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <deque>
#include <string>
#include <vector>
namespace roro {
// What the Shell App shows (issue #67): the lines it was given, with the oldest dropped past a size.
// Which lines it is given is the console's business: by default, only what is printed for the
// Shell's own commands (Console::Origin).
class ShellLog {
public:
static constexpr size_t kMaxBytes = 4096;
// Bytes as the console printed them: lines may arrive in pieces. The line with the free heap
// every ten seconds is never kept: it would push everything else off a ten-line screen.
void feed(const char* data, size_t len);
// A line the Shell adds itself.
void add(const std::string& line);
const std::deque<std::string>& lines() const { return lines_; }
void clear();
uint32_t revision() const { return revision_; } // changes when the lines do
private:
void push(const std::string& line);
std::deque<std::string> lines_;
std::string partial_;
size_t bytes_ = 0;
uint32_t revision_ = 0;
};
// Tab on a command (issue #67): every word of it, read from the firmware's `help` text each time it
// is asked, so nothing is kept in memory for it. A line of that text is commands, three spaces, then
// what they do; commands are separated by " | ", a word like on|off is either of them, and a command
// stops being words at its first <argument>, [option] or "...":
// lora rx on|off | lora preset <name> the radio
// gives "lora rx on", "lora rx off" and "lora preset". An App's name starts with a capital.
//
// The line with its last word completed as far as the commands that fit agree; a word completed
// whole gets a space after it. `matches` gets the candidates when there are several. Unchanged, with
// no matches, when no command goes on that way: then it may be a path (below).
std::string completeWords(const std::string& typed, const char* helpText, std::vector<std::string>& matches);
// Tab on a later word: a path on the SD card (issue #67). What is being completed, taken apart:
// `rm -r /notes/sh` is head "rm -r ", folder "/notes/", prefix "sh". False when the cursor is still
// on the first word, when the word is a switch (-r), or when it isn't a path and the command doesn't
// take one. After a file command a path may be started without its slash: `cat no` is /no.
struct PathToComplete {
std::string head, folder, prefix;
};
bool splitForPath(const std::string& typed, PathToComplete& out);
// `names` are the folder's entries, a folder's with a slash at its end. The line with the path
// completed as far as the entries that start with the prefix agree, whatever their case (the card
// doesn't tell cases apart, so the name's own case is taken). A file completed whole gets a space
// after it; a folder keeps its slash, to go on from. `matches` gets the candidates when there are
// several. Unchanged when nothing matches.
std::string completePath(const PathToComplete& what, const std::vector<std::string>& names, std::vector<std::string>& matches);
} // namespace roro
+20
View File
@@ -2,7 +2,10 @@
#include <cstdint>
#include <vector>
#include "key_event.h"
#include "key_help.h"
namespace roro {
@@ -25,11 +28,28 @@ class App {
// True while the App is editing text: the arrow keys then type ; . , / and need Fn to move.
virtual bool textEntryActive() const { return false; }
// The keys that work right now, for the help panel (Fn+h): the App's own, in the state it's
// in. Back, Home and the arrows are added for it. No screen names keys any other way.
virtual void help(std::vector<KeyHelp>& out) const { (void)out; }
// What the panel is titled with, when the App's name isn't enough ("Notes: editor").
virtual const char* helpTitle() const { return nullptr; }
// Called every main-loop pass while in the foreground (e.g. to refresh live values).
virtual void update(uint32_t nowMs) { (void)nowMs; }
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() {
+473
View File
@@ -0,0 +1,473 @@
#pragma once
#include <cstddef>
#include <vector>
#include "key_help.h"
// Every key of every screen, as data (issues #69 and #72). The help panel (Fn+h) shows the table of
// the state an App is in; site/tools/gen_dev_docs.py reads this file to write the website's key
// tables, so the two can't drift.
//
// The format is read by that script, so keep it: a comment `// id: Title`, then one table,
// inline constexpr KeyHelp kName[] = {
// {"keys", "what they do"},
// };
// with one row a line and plain string literals. `id` is what a guide page asks for.
namespace roro::keys {
template <size_t N>
inline void add(std::vector<KeyHelp>& out, const KeyHelp (&rows)[N]) {
out.insert(out.end(), rows, rows + N);
}
// everywhere: Everywhere
inline constexpr KeyHelp kEverywhere[] = {
{"`", "back"},
{"Fn `", "home, the Launcher"},
{"; . , /", "arrows (Fn+ while typing)"},
{"Fn h ?", "these keys (? not typing)"},
{"Fn p", "a screenshot, on the card"},
};
// dialog: A question
inline constexpr KeyHelp kDialog[] = {
{", /", "the other answer"},
{"Enter", "choose it"},
{"`", "cancel"},
};
// text: A text field
inline constexpr KeyHelp kText[] = {
{"Enter", "save"},
{"`", "cancel"},
{"Del", "delete backwards"},
{"Fn , /", "move the cursor"},
{"opt ' e", "an accent: \xC3\xA9"},
};
// launcher: The Launcher
inline constexpr KeyHelp kLauncher[] = {
{"; .", "up, down"},
{"Enter", "open the App"},
};
// setup: Setup, a step
inline constexpr KeyHelp kSetup[] = {
{"Enter", "continue"},
{"`", "the step before"},
};
// setup-choice: Setup, a choice
inline constexpr KeyHelp kSetupChoice[] = {
{"; .", "up, down"},
{"Enter", "choose it, next step"},
{"`", "the step before"},
};
// setup-text: Setup, a name
inline constexpr KeyHelp kSetupText[] = {
{"Enter", "next step"},
{"`", "the step before"},
{"Del", "delete backwards"},
{"Fn , /", "move the cursor"},
{"opt ' e", "an accent: \xC3\xA9"},
};
// irc: IRC
inline constexpr KeyHelp kIrc[] = {
{"Enter", "send the line"},
{"Tab", "the next buffer"},
{"Alt ; .", "scroll back, forward"},
{"Fn ; .", "lines you sent before"},
{"Fn , /", "move the cursor"},
{"Del", "delete backwards"},
{"/settings", "server, nick, passwords"},
{"/join #x", "join a channel"},
{"/part", "leave it"},
{"/msg nick", "a private chat"},
{"/me", "an action"},
{"/nick", "change your nick"},
{"/topic", "see or set the topic"},
{"/names", "who is there"},
{"/quit", "disconnect, and stay so"},
{"/raw", "a line as it is"},
{"`", "leave: IRC stays connected"},
};
// irc-settings: IRC settings
inline constexpr KeyHelp kIrcSettings[] = {
{"; .", "up, down"},
{"Enter", "edit, switch, or save"},
{"`", "leave without saving"},
};
// irc-field: IRC, a setting
inline constexpr KeyHelp kIrcField[] = {
{"Enter", "keep it"},
{"`", "cancel"},
{"Del", "delete backwards"},
{"Fn , /", "move the cursor"},
};
// wifi-tools: Wi-Fi Tools
inline constexpr KeyHelp kWifiTools[] = {
{"; .", "up, down"},
{"Enter", "open"},
};
// wifi-networks: Networks nearby
inline constexpr KeyHelp kWifiNetworks[] = {
{"; .", "up, down"},
{"Enter", "track its signal"},
{"s", "sort: signal, channel, name"},
{"o", "open networks only"},
{"h", "hide the hidden ones"},
{"w", "strong ones only"},
{"l", "log the scans to the card"},
};
// wifi-tracker: Signal tracker
inline constexpr KeyHelp kWifiTracker[] = {
{"m", "clicks on or off"},
};
// gnss: GNSS
inline constexpr KeyHelp kGnss[] = {
{"Tab", "the position, or the sky"},
{"r", "record a Track, or stop it"},
};
// gemini: Gemini, a page
inline constexpr KeyHelp kGemini[] = {
{"Tab", "the next link"},
{"Aa Tab", "the link before"},
{"Enter", "follow the link"},
{"` Del", "the page before"},
{"; .", "scroll"},
{"Space", "a page down"},
{", /", "sideways, in wide blocks"},
{"g", "type an address"},
{"b", "bookmark this page"},
{"s", "save the page to the card"},
{"S", "...with the pages it links to"},
};
// gemini-saved: Gemini, a Saved Page
inline constexpr KeyHelp kGeminiSaved[] = {
{"Tab", "the next link"},
{"Aa Tab", "the link before"},
{"Enter", "follow the link"},
{"` Del", "the page before"},
{"; .", "scroll"},
{"Space", "a page down"},
{", /", "sideways, in wide blocks"},
{"g", "type an address"},
{"b", "bookmark this page"},
{"r", "refresh this Saved Page"},
{"d", "delete this Saved Page"},
};
// gemini-address: Gemini, an address
inline constexpr KeyHelp kGeminiAddress[] = {
{"Enter", "go there"},
{"`", "cancel"},
{"Del", "delete backwards"},
{"Fn , /", "move the cursor"},
};
// gemini-answer: Gemini, an answer to a page
inline constexpr KeyHelp kGeminiAnswer[] = {
{"Enter", "send it"},
{"`", "cancel"},
{"Del", "delete backwards"},
{"Fn , /", "move the cursor"},
};
// lora: LoRa Scanner, the packets
inline constexpr KeyHelp kLora[] = {
{"; .", "up, down"},
{"Enter", "the packet's details"},
{"p", "pick a Meshtastic preset"},
{"c", "start a Capture, or stop it"},
{"Tab", "the Sweep"},
};
// lora-packet: LoRa Scanner, a packet
inline constexpr KeyHelp kLoraPacket[] = {
{"; .", "scroll"},
{"Enter", "back to the list"},
};
// lora-presets: LoRa Scanner, the presets
inline constexpr KeyHelp kLoraPresets[] = {
{"; .", "up, down"},
{"Enter", "listen with this preset"},
};
// lora-sweep: LoRa Scanner, the Sweep
inline constexpr KeyHelp kLoraSweep[] = {
{"Tab", "the Sniffer"},
};
// storage: Storage, a folder
inline constexpr KeyHelp kStorage[] = {
{"; .", "up, down"},
{"Enter", "open the folder or the file"},
{", /", "a page up, down"},
{"c x", "copy, cut"},
{"v", "paste here"},
{"r", "rename"},
{"d Del", "delete, after asking"},
{"n", "a new folder"},
{"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"},
{"Enter", "back to the folder"},
};
// storage-name: Storage, a name
inline constexpr KeyHelp kStorageName[] = {
{"Enter", "rename it, or make the folder"},
{"`", "cancel"},
{"Del", "delete backwards"},
{"Fn , /", "move the cursor"},
};
// storage-busy: Storage, while it copies or deletes
inline constexpr KeyHelp kStorageBusy[] = {
{"`", "stop the copy or the delete"},
};
// maintenance: Storage, Maintenance
inline constexpr KeyHelp kMaintenance[] = {
{"; .", "up, down"},
{"Enter", "open, or choose"},
};
// viewer-text: A file, as text
inline constexpr KeyHelp kViewerText[] = {
{"; .", "a line up, down"},
{", /", "a page up, down"},
{"t b", "the top, the end"},
{"e", "edit it"},
{"Tab", "the file as hex, or back"},
};
// viewer-hex: A file, as hex
inline constexpr KeyHelp kViewerHex[] = {
{"; .", "a line up, down"},
{", /", "a page up, down"},
{"t b", "the top, the end"},
{"Tab", "the file as text, or back"},
};
// viewer-pcap: A Capture
inline constexpr KeyHelp kViewerPcap[] = {
{"; .", "up, down"},
{"Enter", "the packet"},
{", /", "a page up, down"},
{"Tab", "the file as hex"},
};
// viewer-packet: A Capture's packet
inline constexpr KeyHelp kViewerPacket[] = {
{"; .", "scroll"},
{"Enter", "back to the packets"},
};
// viewer-gpx: A Track
inline constexpr KeyHelp kViewerGpx[] = {
{"Tab", "the file as text"},
};
// viewer-ota: An Update File
inline constexpr KeyHelp kViewerOta[] = {
{"Enter", "install it, if it's genuine"},
{"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"},
{"Enter", "open the note"},
{", /", "a page up, down"},
{"n", "a new note"},
{"r", "rename its file"},
{"d Del", "delete it"},
{"s", "sort: newest, or by name"},
};
// notes-editor: Notes, the editor
inline constexpr KeyHelp kNotesEditor[] = {
{"Enter", "a new line"},
{"Del", "delete backwards"},
{"Tab", "two spaces"},
{"Fn ; . , /", "move the cursor"},
{"Alt Fn ; .", "a page up, down"},
{"Ctrl Fn ; .", "start, end of the note"},
{"Ctrl a e", "start, end of the line"},
{"opt ' e", "an accent: \xC3\xA9"},
{"`", "done: it saves by itself"},
};
// notes-name: Notes, a file name
inline constexpr KeyHelp kNotesName[] = {
{"Enter", "rename the file"},
{"`", "cancel"},
{"Del", "delete backwards"},
{"Fn , /", "move the cursor"},
};
// shell: Shell
inline constexpr KeyHelp kShell[] = {
{"Enter", "run the line"},
{"Tab", "complete: a command, a path"},
{"* ?", "several files: /notes/*.txt"},
{"Fn ; .", "lines you typed before"},
{"Alt ; .", "scroll back, forward"},
{"Ctrl b", "your replies only, or all"},
{"Fn , /", "move the cursor"},
{"Del", "delete backwards"},
{"help", "every command"},
{"clear", "an empty screen"},
{"Notes", "an App, by its name"},
{"rm -rf", "delete without being asked"},
{"quit `", "leave the Shell"},
};
// system: System, any view
inline constexpr KeyHelp kSystem[] = {
{"Tab", "the next view"},
{"Aa Tab", "the view before"},
};
// system-tasks: System, the tasks
inline constexpr KeyHelp kSystemTasks[] = {
{"Tab", "the next view"},
{"Aa Tab", "the view before"},
{"; .", "scroll"},
{"s", "sort: cpu, stack, name"},
};
// system-system: System, the system view
inline constexpr KeyHelp kSystemSystem[] = {
{"Tab", "the next view"},
{"Aa Tab", "the view before"},
{"; .", "scroll"},
};
// settings: Settings
inline constexpr KeyHelp kSettings[] = {
{"; .", "up, down"},
{"Enter", "edit, or open the page"},
{", /", "change a switch or a slider"},
};
// settings-choice: Settings, a choice
inline constexpr KeyHelp kSettingsChoice[] = {
{"; .", "up, down"},
{"Enter", "choose it"},
};
// wifi: Settings, Wi-Fi
inline constexpr KeyHelp kWifi[] = {
{"; .", "up, down"},
{"Enter", "open, or change"},
{", /", "switch Wi-Fi on or off"},
};
// wifi-servers: Settings, DNS and NTP
inline constexpr KeyHelp kWifiServers[] = {
{"; .", "up, down"},
{"Enter", "edit"},
{", /", "Always use my DNS: on, off"},
};
// wifi-network: Settings, a saved network
inline constexpr KeyHelp kWifiNetwork[] = {
{"; .", "up, down"},
{"Enter", "edit, or forget"},
{", /", "Automatic or Fixed"},
};
// wifi-status: Settings, the Wi-Fi status
inline constexpr KeyHelp kWifiStatus[] = {
{"Enter", "back"},
};
// wifi-scan: Settings, adding a network
inline constexpr KeyHelp kWifiScan[] = {
{"; .", "up, down"},
{"Enter", "choose this network"},
};
// wifi-name: Settings, a hidden network's name
inline constexpr KeyHelp kWifiName[] = {
{"Enter", "next: the password"},
{"`", "cancel"},
{"Del", "delete backwards"},
{"Fn , /", "move the cursor"},
};
// firmware: Settings, Firmware
inline constexpr KeyHelp kFirmware[] = {
{"; .", "up, down"},
{"Enter", "check, open, or install"},
{"c", "look for a newer release"},
};
// firmware-release: Settings, a release
inline constexpr KeyHelp kFirmwareRelease[] = {
{"; .", "scroll"},
{"Enter", "install it"},
{"c", "check again"},
};
// firmware-older: Settings, older releases
inline constexpr KeyHelp kFirmwareOlder[] = {
{"; .", "up, down"},
{"Enter", "its details"},
{"c", "read the list again"},
};
// debug-console: Settings, Debug Console
inline constexpr KeyHelp kDebugConsole[] = {
{"; .", "up, down"},
{"Enter", "switch, or open"},
{", /", "switch the console on or off"},
};
// demo: The widget demo
inline constexpr KeyHelp kDemo[] = {
{"; .", "up, down"},
{"Enter", "try the widget"},
};
} // namespace roro::keys
+32
View File
@@ -1,5 +1,7 @@
#include "app_manager.h"
#include "app_keys.h"
#include <cstring>
namespace roro {
@@ -52,6 +54,22 @@ void AppManager::endModal() {
}
void AppManager::handleKey(const KeyEvent& event) {
if (help_.isOpen()) { // scrolls or closes; nothing reaches the App, Home included
help_.onKey(event);
redraw_ = true;
return;
}
if (event.key == Key::Help) {
std::vector<KeyHelp> rows;
foreground_->help(rows);
rows.push_back({"Everywhere", nullptr});
keys::add(rows, keys::kEverywhere);
const char* scope = foreground_->helpTitle();
const char* app = foregroundTitle();
help_.open(scope ? scope : app ? app : "Launcher", std::move(rows));
redraw_ = true;
return;
}
if (modal_) {
foreground_->onKey(event);
return;
@@ -64,6 +82,19 @@ void AppManager::handleKey(const KeyEvent& event) {
if (!consumed && event.key == Key::Back) home();
}
std::string AppManager::commandFor(const char* id) {
std::string command;
for (const char* p = id; *p && *p != '-'; p++) command += *p;
if (!command.empty() && command[0] >= 'a' && command[0] <= 'z') command[0] = static_cast<char>(command[0] - 'a' + 'A');
return command;
}
const AppInfo* AppManager::byCommand(const std::string& command) const {
for (auto& info : apps_)
if (!info.hidden && commandFor(info.id) == command) return &info;
return nullptr;
}
const char* AppManager::foregroundTitle() const {
for (auto& info : apps_)
if (info.app == foreground_) return info.title;
@@ -77,6 +108,7 @@ bool AppManager::takeRedraw() {
}
void AppManager::switchTo(App& app) {
help_.close(); // an App opened from elsewhere (a Notification, a command): its keys, not the last one's
if (&app == foreground_) return;
foreground_->onExit();
foreground_ = &app;
+12
View File
@@ -1,5 +1,6 @@
#pragma once
#include <string>
#include <vector>
#include "app.h"
@@ -34,9 +35,19 @@ class AppManager {
void handleKey(const KeyEvent& event);
void update(uint32_t nowMs) { foreground_->update(nowMs); }
// The command that opens an App from the consoles and the Shell (issue #67): its id with a
// capital letter, up to the first dash. "notes" is Notes, "wifi-tools" is Wifi. The capital says
// "an App", where every command of the firmware is in small letters.
static std::string commandFor(const char* id);
// The App that command names, among the ones the Launcher lists; nullptr if there's none.
const AppInfo* byCommand(const std::string& command) const;
App& foreground() const { return *foreground_; }
const char* foregroundTitle() const; // nullptr for the Launcher
// The help panel (Fn+h): open, it takes every key, and the App sees none of them.
const HelpModel& help() const { return help_; }
// True once after the screen needs redrawing (App switch, or the App asked for it).
bool takeRedraw();
@@ -47,6 +58,7 @@ class AppManager {
App& launcher_;
App* foreground_;
std::vector<AppInfo> apps_;
HelpModel help_;
bool redraw_ = true;
bool modal_ = false;
};
+2
View File
@@ -16,6 +16,8 @@ enum class Key : uint8_t {
Home,
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 {
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include <algorithm>
#include <string>
#include <vector>
#include "key_event.h"
// The help panel (issue #69, docs/milestones/U1.md): Fn+h on any screen lists the keys that work
// there. Every App says what its keys are in the state it's in; nothing on a screen names keys.
namespace roro {
// One line of the panel: a key (or keys) and what it does. A line with no action is a heading.
struct KeyHelp {
const char* keys;
const char* action;
};
// The panel itself: what it lists, and how far it's scrolled. The keys it takes while open are the
// arrows; any other key closes it, and none reaches the App.
class HelpModel {
public:
explicit HelpModel(int visibleRows = 8) : visible_(visibleRows) {}
void open(const std::string& title, std::vector<KeyHelp> rows) {
title_ = title;
rows_ = std::move(rows);
top_ = 0;
open_ = true;
}
void close() {
open_ = false;
rows_.clear();
rows_.shrink_to_fit(); // nothing is kept while it's closed
}
bool isOpen() const { return open_; }
void onKey(const KeyEvent& e) {
int last = std::max(0, static_cast<int>(rows_.size()) - visible_);
switch (e.key) {
case Key::Up: top_ = std::max(0, top_ - 1); break;
case Key::Down: top_ = std::min(last, top_ + 1); break;
case Key::Left: top_ = std::max(0, top_ - visible_); break;
case Key::Right: top_ = std::min(last, top_ + visible_); break;
default: close(); break;
}
}
const std::string& title() const { return title_; }
const std::vector<KeyHelp>& rows() const { return rows_; }
int top() const { return top_; }
int visibleRows() const { return visible_; }
private:
std::string title_;
std::vector<KeyHelp> rows_;
int top_ = 0;
int visible_;
bool open_ = false;
};
} // namespace roro
+49 -1
View File
@@ -4,7 +4,7 @@
namespace roro::files {
const char* const kFirmwareFolders[] = {"/irc", "/wifi", "/updates", "/gnss", "/gemini", "/captures", "/notes"};
const char* const kFirmwareFolders[] = {"/irc", "/wifi", "/updates", "/gnss", "/gemini", "/captures", "/notes", "/screenshots"};
const size_t kFirmwareFolderCount = sizeof kFirmwareFolders / sizeof kFirmwareFolders[0];
std::string parentOf(const std::string& path) {
@@ -96,4 +96,52 @@ bool looksLikeText(const uint8_t* data, size_t len) {
return odd * 20 <= len; // a stray control character or two is still text
}
RmArgs parseRm(const std::string& args) {
RmArgs out;
size_t at = 0;
while (at < args.size()) {
while (at < args.size() && args[at] == ' ') at++;
if (at >= args.size() || args[at] != '-') break;
size_t end = args.find(' ', at);
std::string flags = args.substr(at + 1, end == std::string::npos ? std::string::npos : end - at - 1);
bool known = !flags.empty();
for (char c : flags) known = known && (c == 'r' || c == 'R' || c == 'f');
if (!known) break; // a name that starts with a dash
for (char c : flags) (c == 'f' ? out.force : out.recursive) = true;
at = end == std::string::npos ? args.size() : end;
}
out.path = at < args.size() ? args.substr(at) : "";
while (!out.path.empty() && out.path.back() == ' ') out.path.pop_back();
return out;
}
bool hasGlob(const std::string& text) { return text.find_first_of("*?") != std::string::npos; }
bool globMatch(const std::string& pattern, const std::string& name) {
auto lower = [](char c) { return c >= 'A' && c <= 'Z' ? static_cast<char>(c - 'A' + 'a') : c; };
size_t p = 0, n = 0, star = std::string::npos, mark = 0;
while (n < name.size()) {
if (p < pattern.size() && (pattern[p] == '?' || lower(pattern[p]) == lower(name[n]))) {
p++;
n++;
} else if (p < pattern.size() && pattern[p] == '*') {
star = p++; // try it as nothing first; come back here to let it take one more
mark = n;
} else if (star != std::string::npos) {
p = star + 1;
n = ++mark;
} else return false;
}
while (p < pattern.size() && pattern[p] == '*') p++;
return p == pattern.size();
}
bool splitGlob(const std::string& path, std::string& folder, std::string& pattern) {
size_t slash = path.rfind('/');
if (slash == std::string::npos) return false;
pattern = path.substr(slash + 1);
folder = slash == 0 ? "/" : path.substr(0, slash);
return hasGlob(pattern) && !hasGlob(folder);
}
} // namespace roro::files
+16
View File
@@ -39,4 +39,20 @@ FileKind kindOf(const std::string& name);
bool opensAtEnd(const std::string& name); // logs
bool looksLikeText(const uint8_t* data, size_t len);
// `rm`'s arguments, as Unix has them (issue #67): -r for a folder and what's in it, -f for no
// question, alone or together (-rf, -fr, -r -f), then the path, which may hold spaces.
struct RmArgs {
bool recursive = false, force = false;
std::string path;
};
RmArgs parseRm(const std::string& args);
// Patterns in a path (issue #67): * for any run of characters, ? for one, in the last part of the
// path only (/notes/*.txt, not /*/a.txt). Cases aren't told apart, as on the card.
bool hasGlob(const std::string& text);
bool globMatch(const std::string& pattern, const std::string& name);
// "/notes/*.txt" taken apart: the folder ("/notes", or "/" at the top) and the pattern ("*.txt").
// False if there's no pattern in it, or if the folder has one too.
bool splitGlob(const std::string& path, std::string& folder, std::string& pattern);
} // namespace roro::files
+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
+118
View File
@@ -0,0 +1,118 @@
#include "png_rgb332.h"
namespace roro::png {
uint32_t crc32(uint32_t crc, const uint8_t* data, size_t len) {
crc = ~crc;
for (size_t i = 0; i < len; i++) {
crc ^= data[i];
for (int bit = 0; bit < 8; bit++) crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1)));
}
return ~crc;
}
uint32_t adler32(uint32_t adler, const uint8_t* data, size_t len) {
uint32_t a = adler & 0xFFFF, b = adler >> 16;
for (size_t i = 0; i < len; i++) {
a = (a + data[i]) % 65521;
b = (b + a) % 65521;
}
return (b << 16) | a;
}
namespace {
void be32(uint8_t* out, uint32_t v) {
out[0] = static_cast<uint8_t>(v >> 24);
out[1] = static_cast<uint8_t>(v >> 16);
out[2] = static_cast<uint8_t>(v >> 8);
out[3] = static_cast<uint8_t>(v);
}
size_t rawSize(int w, int h) { return static_cast<size_t>(w + 1) * h; } // a filter byte before each row
size_t idatSize(int w, int h) { return 2 + 5 + rawSize(w, h) + 4; } // zlib header, block header, data, adler
} // namespace
size_t Rgb332Writer::fileSize(int w, int h) {
return 8 + (12 + 13) + (12 + 768) + (12 + idatSize(w, h)) + 12; // signature, IHDR, PLTE, IDAT, IEND
}
bool Rgb332Writer::put(const uint8_t* data, size_t len, bool inIdat) {
if (inIdat) crc_ = crc32(crc_, data, len);
return sink_(data, len);
}
bool Rgb332Writer::put32(uint32_t value, bool inIdat) {
uint8_t b[4];
be32(b, value);
return put(b, 4, inIdat);
}
bool Rgb332Writer::begin() {
if (w_ <= 0 || h_ <= 0 || rawSize(w_, h_) > 65535) return false;
static const uint8_t signature[] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
if (!sink_(signature, sizeof signature)) return false;
uint8_t ihdr[4 + 13] = {'I', 'H', 'D', 'R'};
be32(ihdr + 4, static_cast<uint32_t>(w_));
be32(ihdr + 8, static_cast<uint32_t>(h_));
ihdr[12] = 8; // bits a pixel
ihdr[13] = 3; // indexed colour
ihdr[14] = ihdr[15] = ihdr[16] = 0;
uint8_t word[4];
be32(word, 13);
if (!sink_(word, 4) || !sink_(ihdr, sizeof ihdr)) return false;
be32(word, crc32(0, ihdr, sizeof ihdr));
if (!sink_(word, 4)) return false;
// The palette: every RGB332 value is its own index, as scripts/rdbg.py expands them. Sixteen
// colours at a time: this runs on a task with a small stack.
be32(word, 768);
const uint8_t plteKind[] = {'P', 'L', 'T', 'E'};
if (!sink_(word, 4) || !sink_(plteKind, 4)) return false;
uint32_t plteCrc = crc32(0, plteKind, 4);
for (int first = 0; first < 256; first += 16) {
uint8_t piece[48];
for (int i = 0; i < 16; i++) {
int v = first + i;
piece[i * 3] = static_cast<uint8_t>((v >> 5) * 255 / 7);
piece[i * 3 + 1] = static_cast<uint8_t>(((v >> 2) & 7) * 255 / 7);
piece[i * 3 + 2] = static_cast<uint8_t>((v & 3) * 255 / 3);
}
plteCrc = crc32(plteCrc, piece, sizeof piece);
if (!sink_(piece, sizeof piece)) return false;
}
be32(word, plteCrc);
if (!sink_(word, 4)) return false;
// IDAT: a zlib stream of one stored block. Its length is known, so it can be written first.
size_t raw = rawSize(w_, h_);
be32(word, static_cast<uint32_t>(idatSize(w_, h_)));
if (!sink_(word, 4)) return false;
crc_ = 0;
const uint8_t head[] = {'I', 'D', 'A', 'T', 0x78, 0x01, 0x01, static_cast<uint8_t>(raw), static_cast<uint8_t>(raw >> 8),
static_cast<uint8_t>(~raw), static_cast<uint8_t>(~raw >> 8)};
return put(head, sizeof head, true);
}
bool Rgb332Writer::row(const uint8_t* pixels) {
if (rows_ >= h_) return false;
rows_++;
const uint8_t filter = 0; // none
adler_ = adler32(adler_, &filter, 1);
adler_ = adler32(adler_, pixels, static_cast<size_t>(w_));
return put(&filter, 1, true) && put(pixels, static_cast<size_t>(w_), true);
}
bool Rgb332Writer::end() {
if (rows_ != h_) return false;
if (!put32(adler_, true)) return false;
uint8_t word[4];
be32(word, crc_);
if (!sink_(word, 4)) return false;
static const uint8_t iend[] = {0, 0, 0, 0, 'I', 'E', 'N', 'D', 0xAE, 0x42, 0x60, 0x82};
return sink_(iend, sizeof iend);
}
} // namespace roro::png
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <functional>
// A PNG of the screen, written a row at a time with almost no memory (issue #67, Q209): 8-bit
// indexed colour with the 256 colours of RGB332 as its palette, and the pixels stored, not
// compressed (a "stored" deflate block), so there is nothing to compress with and nothing to buffer.
// One block holds at most 65,535 bytes: enough for the 240 x 135 screen (32,535 with its row bytes).
namespace roro::png {
uint32_t crc32(uint32_t crc, const uint8_t* data, size_t len); // running; start from 0
uint32_t adler32(uint32_t adler, const uint8_t* data, size_t len); // running; start from 1
class Rgb332Writer {
public:
using Sink = std::function<bool(const uint8_t* data, size_t len)>; // false: writing failed
Rgb332Writer(int width, int height, Sink sink) : w_(width), h_(height), sink_(std::move(sink)) {}
// The file's size, known before a byte is written.
static size_t fileSize(int width, int height);
bool begin(); // false: too big for one block, or the sink refused
bool row(const uint8_t* pixels); // `width` bytes, RRRGGGBB each
bool end();
private:
bool put(const uint8_t* data, size_t len, bool inIdat);
bool put32(uint32_t value, bool inIdat);
int w_, h_, rows_ = 0;
Sink sink_;
uint32_t crc_ = 0, adler_ = 1;
};
} // namespace roro::png
+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
+21
View File
@@ -99,6 +99,27 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
return;
}
break;
case 'h':
case 'H':
if (keys.fn) { // Fn+h: help, while typing too
out.push_back(KeyEvent::of(Key::Help));
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));
return;
}
if (keys.fn) return;
break;
default:
if (keys.fn) return; // other Fn combos are unassigned
break;
+1 -1
View File
@@ -23,7 +23,7 @@ struct RawKeys {
// Turns keyboard state changes into logical KeyEvents: only newly pressed keys produce events;
// Fn + ; . , / are arrows, and so are ; . , / alone when no text is being entered; ` is Back and
// Fn + ` is Home; the Compose Key (opt) followed by an accent and a letter types the accented
// Fn + ` is Home; Fn + h is Help anywhere, and so is ? when no text is being entered; the Compose Key (opt) followed by an accent and a letter types the accented
// letter (opt ' e -> é).
class KeyMapper {
public:
+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
+622
View File
@@ -0,0 +1,622 @@
#include "note_document.h"
#include <algorithm>
#include <cstring>
namespace roro::notes {
namespace {
// The side file: this line, the note's size and two checksums of it (its first and last
// kilobyte), then, in any order, text that left a window and snapshots of the list of pieces.
// snapshot: "RSNP" cursor count { src at len }... crc32 length "PNSR" (numbers: 32 bits, low byte first)
// The newest snapshot that checks out is the note as it was last saved. kDone at the very end:
// the rewrite this file was for is complete in `<note>.tmp`, and only has to take the note's place.
const char kMagic[] = "roro9stack note edits 1\n";
constexpr size_t kMagicLen = sizeof(kMagic) - 1;
constexpr size_t kHeaderLen = kMagicLen + 12;
const char kSnap[] = "RSNP", kSnapEnd[] = "PNSR", kDone[] = "RDONE1\n\n";
constexpr size_t kDoneLen = 8;
constexpr size_t kCheck = 1024; // of each end of the note, in the header
constexpr uint32_t kStepBytes = 64 * 1024; // a rewrite's step
constexpr size_t kBlock = 4096;
constexpr uint32_t kSeekNewline = 1024;
constexpr size_t kMaxSnapshot = 12 + 9 * 4096 + 12;
bool continuation(int c) { return (c & 0xC0) == 0x80; }
uint32_t crc32(uint32_t crc, const uint8_t* data, size_t len) {
crc = ~crc;
for (size_t i = 0; i < len; i++) {
crc ^= data[i];
for (int k = 0; k < 8; k++) crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1)));
}
return ~crc;
}
void put32(std::string& s, uint32_t v) {
for (int i = 0; i < 4; i++) s += static_cast<char>((v >> (8 * i)) & 0xFF);
}
uint32_t get32(const uint8_t* p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (static_cast<uint32_t>(p[3]) << 24); }
const uint8_t* bytes(const std::string& s) { return reinterpret_cast<const uint8_t*>(s.data()); }
} // namespace
NoteDocument::NoteDocument(NoteCard& card, int cols, int rows) : card_(card), text_(cols, rows) { openNew(); }
void NoteDocument::reset() {
path_.clear();
sidePath_.clear();
pieces_.clear();
loaded_.clear();
win_ = 0;
windowLoaded_ = false;
before_ = after_ = 0;
droppedAtLoad_ = newlinesAtLoad_ = 0;
flushedSinceSave_ = sidePending_ = false;
sideSize_ = 0;
windowSaved_.valid = false;
rw_.active = false;
std::vector<uint8_t>().swap(rw_.block);
}
void NoteDocument::openNew() {
reset();
text_.buffer().clear();
text_.refilled(0, 0);
windowLoaded_ = true;
loadedRevision_ = savedRevision_ = text_.revision();
}
std::string NoteDocument::open(const std::string& path, std::string* told) {
openNew();
path_ = path;
sidePath_ = side();
uint32_t sideSize = 0, fileSize = 0, other = 0;
bool hasSide = card_.size(sidePath_, sideSize);
if (hasSide && sideIsDone(sideSize)) { // a rewrite was cut after its last write: finish it
if (card_.size(tmp(), other)) {
if (card_.size(path_, fileSize)) card_.remove(path_);
card_.rename(tmp(), path_);
}
card_.remove(sidePath_);
hasSide = false;
}
if (!card_.size(path_, fileSize)) {
card_.done();
openNew();
return "The card refused to open it";
}
if (fileSize > NoteText::kMaxBytes && card_.freeBytes() < static_cast<uint64_t>(fileSize) + 16 * 1024) {
card_.done();
openNew();
return "Not enough room on the card: saving it needs a second copy";
}
uint32_t cursor = 0;
bool resumed = false;
if (hasSide) {
if (card_.size(tmp(), other)) card_.remove(tmp()); // a rewrite that didn't get that far
sideSize_ = sideSize;
Resume r = resume(fileSize, cursor);
if (r == Resume::Ok) {
resumed = sidePending_ = true;
if (told) *told = "Your unsaved changes are back";
} else {
sideSize_ = 0;
pieces_.clear();
if (r == Resume::Mismatch) { // typed text is never thrown away without a word (Q227)
std::string lost = sidePath_ + ".lost";
card_.remove(lost);
card_.rename(sidePath_, lost);
if (told) *told = "The file changed: unsaved edits kept as .edit.lost";
} else {
card_.remove(sidePath_);
}
}
}
if (!resumed && fileSize) pieces_.push_back({0, 0, fileSize});
windowLoaded_ = false;
bool ok = load(cursor, cursor ? 1000 : 0, -1); // an edit picked up: its last lines above the cursor
card_.done();
if (!ok) {
openNew();
return "The card refused to read it";
}
savedRevision_ = text_.revision();
return "";
}
uint32_t NoteDocument::piecesBytes() const {
uint32_t n = 0;
for (const Piece& p : pieces_) n += p.len;
return n;
}
int NoteDocument::percent() const {
uint32_t all = size();
return all ? static_cast<int>(static_cast<uint64_t>(before_ + text_.top()) * 100 / all) : 0;
}
size_t NoteDocument::readDoc(uint32_t at, uint8_t* into, size_t len) {
size_t got = 0;
uint32_t pos = 0;
for (const Piece& p : pieces_) {
if (got == len) break;
if (at < pos + p.len) {
uint32_t skip = at - pos;
size_t n = std::min<size_t>(len - got, p.len - skip);
size_t r = card_.read(fileOf(p), p.at + skip, into + got, n);
got += r;
at += static_cast<uint32_t>(r);
if (r != n) break;
}
pos += p.len;
}
return got;
}
int NoteDocument::byteAt(uint32_t at) {
uint8_t b;
return readDoc(at, &b, 1) == 1 ? b : -1;
}
size_t NoteDocument::splitAt(uint32_t at) {
uint32_t pos = 0;
for (size_t i = 0; i < pieces_.size(); i++) {
if (at == pos) return i;
Piece& p = pieces_[i];
if (at < pos + p.len) {
uint32_t first = at - pos;
Piece rest{p.src, p.at + first, p.len - first};
p.len = first;
pieces_.insert(pieces_.begin() + static_cast<long>(i) + 1, rest);
return i + 1;
}
pos += p.len;
}
return pieces_.size();
}
void NoteDocument::merge() {
size_t kept = 0;
for (size_t i = 0; i < pieces_.size(); i++) {
const Piece p = pieces_[i];
if (!p.len) continue;
if (kept && pieces_[kept - 1].src == p.src && pieces_[kept - 1].at + pieces_[kept - 1].len == p.at) pieces_[kept - 1].len += p.len;
else pieces_[kept++] = p;
}
pieces_.resize(kept);
}
// The window dropped the CRs of the file's CRLFs when it was read. If it's put back untouched,
// the cursor is further along in the file than in the window: by one for each line before it.
uint32_t NoteDocument::noteCursor() const {
size_t c = text_.cursor();
if (windowLoaded_ && droppedAtLoad_ && text_.revision() == loadedRevision_) {
const std::string& t = text_.text();
if (droppedAtLoad_ == newlinesAtLoad_) c += static_cast<size_t>(std::count(t.begin(), t.begin() + static_cast<long>(c), '\n'));
else if (!t.empty()) c += droppedAtLoad_ * c / t.size(); // a file of both kinds of line: near enough
}
return before_ + static_cast<uint32_t>(c);
}
bool NoteDocument::headerFor(std::string& header) {
uint32_t fileSize = 0;
if (path_.empty() || !card_.size(path_, fileSize)) return false;
std::vector<uint8_t> buf(kCheck);
size_t n = std::min<size_t>(kCheck, fileSize);
if (card_.read(path_, 0, buf.data(), n) != n) return false;
uint32_t head = crc32(0, buf.data(), n);
if (card_.read(path_, fileSize - static_cast<uint32_t>(n), buf.data(), n) != n) return false;
uint32_t tail = crc32(0, buf.data(), n);
header.assign(kMagic, kMagicLen);
put32(header, fileSize);
put32(header, head);
put32(header, tail);
return true;
}
bool NoteDocument::ensureSide(std::string& why) {
if (sideSize_) return true;
std::string header;
if (!headerFor(header)) {
why = path_.empty() ? "the note has no file yet" : "the card refused to read the note";
return false;
}
if (!card_.create(sidePath_) || !card_.append(sidePath_, bytes(header), header.size())) {
card_.remove(sidePath_);
why = "the card refused a write";
return false;
}
sideSize_ = static_cast<uint32_t>(header.size());
return true;
}
bool NoteDocument::putBack(std::string& why) {
if (!windowLoaded_) return true;
if (text_.revision() == loadedRevision_) {
pieces_.insert(pieces_.begin() + static_cast<long>(win_), loaded_.begin(), loaded_.end());
} else {
Piece p{1, 0, static_cast<uint32_t>(text_.size())};
if (windowSaved_.valid && windowSaved_.revision == text_.revision()) {
p.at = windowSaved_.at;
} else if (p.len) {
if (!ensureSide(why)) return false;
if (!card_.append(sidePath_, bytes(text_.text()), p.len)) {
card_.done();
if (!card_.size(sidePath_, sideSize_)) sideSize_ = 0;
why = "the card refused a write";
return false;
}
p.at = sideSize_;
sideSize_ += p.len;
}
if (p.len) pieces_.insert(pieces_.begin() + static_cast<long>(win_), p);
flushedSinceSave_ = sidePending_ = true;
}
windowLoaded_ = false;
loaded_.clear();
windowSaved_.valid = false;
before_ = after_ = 0;
merge();
return true;
}
// The window's start is where a line starts on screen whenever that can be known: after a
// newline, or where the window before had a line start. Otherwise the same text could wrap
// differently from one window to the next.
bool NoteDocument::load(uint32_t cursor, int row, int64_t startHint) {
uint32_t total = piecesBytes();
cursor = std::min(cursor, total);
uint32_t s = 0, e = total;
if (total > NoteText::kMaxBytes - kEdge) {
uint32_t c = cursor > kHalf ? cursor - kHalf : 0;
if (c == 0) {
s = 0;
} else if (startHint >= 0 && startHint <= static_cast<int64_t>(c)) {
s = static_cast<uint32_t>(startHint);
} else {
uint8_t buf[128];
uint32_t at = c, limit = std::min(c + kSeekNewline, cursor);
bool found = false;
while (at < limit && !found) {
size_t n = readDoc(at, buf, std::min<size_t>(sizeof buf, limit - at));
if (!n) break;
for (size_t i = 0; i < n && !found; i++)
if (buf[i] == '\n') {
s = at + static_cast<uint32_t>(i) + 1;
found = true;
}
at += static_cast<uint32_t>(n);
}
if (!found) {
s = c;
for (int k = 0; k < 3 && s < cursor && continuation(byteAt(s)); k++) s++;
}
}
e = std::min(total, cursor + kHalf);
for (int k = 0; k < 3 && e < total && continuation(byteAt(e)); k++) e++;
if (e < total && e > 0 && byteAt(e) == '\n' && byteAt(e - 1) == '\r') e++;
e = std::min<uint32_t>(e, s + NoteText::kMaxBytes);
}
size_t i0 = splitAt(s), i1 = splitAt(e);
loaded_.assign(pieces_.begin() + static_cast<long>(i0), pieces_.begin() + static_cast<long>(i1));
pieces_.erase(pieces_.begin() + static_cast<long>(i0), pieces_.begin() + static_cast<long>(i1));
win_ = i0;
before_ = s;
after_ = total - e;
std::string& b = text_.buffer();
b.resize(e - s);
size_t got = 0;
bool ok = true;
for (const Piece& p : loaded_) {
size_t n = card_.read(fileOf(p), p.at, reinterpret_cast<uint8_t*>(&b[got]), p.len);
got += n;
if (n != p.len) {
ok = false;
break;
}
}
if (!ok) { // the note is whole in its pieces: stand on an empty window where the cursor was
pieces_.insert(pieces_.begin() + static_cast<long>(i0), loaded_.begin(), loaded_.end());
loaded_.clear();
win_ = splitAt(cursor);
before_ = cursor;
after_ = total - cursor;
s = cursor;
b.clear();
}
droppedAtLoad_ = text_.refilled(cursor - s, row);
newlinesAtLoad_ = static_cast<size_t>(std::count(b.begin(), b.end(), '\n'));
loadedRevision_ = text_.revision();
windowLoaded_ = true;
windowSaved_.valid = false;
return ok;
}
bool NoteDocument::wantsMove() const {
if (!windowLoaded_) return true;
size_t n = text_.size(), c = text_.cursor();
if (n + kSpare >= NoteText::kMaxBytes) return true;
if (before_ && c < kEdge) return true;
return after_ && n - c < kEdge;
}
bool NoteDocument::move(std::string& why) {
uint32_t cursor = noteCursor();
int row = text_.cursorRow();
int64_t hint = -1;
if (windowLoaded_ && !droppedAtLoad_ && cursor > kHalf) {
uint32_t c = cursor - kHalf;
if (c >= before_ && c < before_ + text_.size()) hint = static_cast<int64_t>(before_) + static_cast<int64_t>(text_.startOfLine(c - before_));
}
if (!putBack(why)) return false;
bool ok = load(cursor, row, hint);
card_.done();
if (!ok) why = "the card refused to read";
return ok;
}
bool NoteDocument::jump(uint32_t to, std::string& why) {
to = std::min(to, size());
if (windowLoaded_ && to == 0 && !before_) return text_.toStart(), true;
if (windowLoaded_ && to == size() && !after_) return text_.toEnd(), true;
if (!putBack(why)) return false;
bool ok = load(to, to ? 1000 : 0, -1);
card_.done();
if (!ok) why = "the card refused to read";
return ok;
}
bool NoteDocument::wantsRewrite() const { return size() <= kWholeLimit || sideSize_ > kSideLimit || pieces_.size() > kManyPieces; }
bool NoteDocument::journal(std::string& why) {
if (path_.empty()) {
why = "the note has no file yet";
return false;
}
bool modified = text_.revision() != loadedRevision_;
Piece w{1, 0, static_cast<uint32_t>(text_.size())};
uint32_t cursor = noteCursor();
if (!ensureSide(why)) return false;
bool wrote = true;
if (modified && w.len) {
if (windowSaved_.valid && windowSaved_.revision == text_.revision()) {
w.at = windowSaved_.at;
} else if ((wrote = card_.append(sidePath_, bytes(text_.text()), w.len))) {
w.at = sideSize_;
sideSize_ += w.len;
windowSaved_.valid = true;
windowSaved_.at = w.at;
windowSaved_.len = w.len;
windowSaved_.revision = text_.revision();
}
}
if (wrote) {
std::vector<Piece> all(pieces_.begin(), pieces_.begin() + static_cast<long>(win_));
if (!modified) all.insert(all.end(), loaded_.begin(), loaded_.end());
else if (w.len) all.push_back(w);
all.insert(all.end(), pieces_.begin() + static_cast<long>(win_), pieces_.end());
std::string rec(kSnap, 4);
put32(rec, cursor);
put32(rec, static_cast<uint32_t>(all.size()));
for (const Piece& p : all) {
rec += static_cast<char>(p.src);
put32(rec, p.at);
put32(rec, p.len);
}
put32(rec, crc32(0, bytes(rec), rec.size()));
put32(rec, static_cast<uint32_t>(rec.size()) + 8);
rec.append(kSnapEnd, 4);
wrote = card_.append(sidePath_, bytes(rec), rec.size());
if (wrote) sideSize_ += static_cast<uint32_t>(rec.size());
}
card_.done();
if (!wrote) {
windowSaved_.valid = false;
if (!card_.size(sidePath_, sideSize_)) sideSize_ = 0;
why = "the card refused a write";
return false;
}
savedRevision_ = text_.revision();
flushedSinceSave_ = false;
sidePending_ = true;
return true;
}
bool NoteDocument::rewriteStart(std::string& why) {
if (path_.empty()) {
why = "the note has no file yet";
return false;
}
if (card_.freeBytes() < static_cast<uint64_t>(size()) + 16 * 1024) {
why = "the card is full";
return false;
}
if (!card_.create(tmp())) {
why = "the card refused to open a file";
return false;
}
rw_.active = true;
rw_.stage = 0;
rw_.index = 0;
rw_.offset = rw_.done = rw_.wrote = rw_.wroteBefore = rw_.wroteAfter = 0;
rw_.total = size();
rw_.revision = text_.revision();
rw_.carry = false;
rw_.block.resize(kBlock);
return true;
}
int NoteDocument::rewriteStep(std::string& why) {
if (!rw_.active) return -1;
auto failed = [&](const char* what) {
card_.done();
card_.remove(tmp());
rw_.active = false;
std::vector<uint8_t>().swap(rw_.block);
why = what;
return -1;
};
auto out = [&](const uint8_t* data, size_t len) {
if (!len) return true;
if (!card_.append(tmp(), data, len)) return false;
rw_.wrote += static_cast<uint32_t>(len);
if (rw_.stage == 0) rw_.wroteBefore += static_cast<uint32_t>(len);
if (rw_.stage == 2) rw_.wroteAfter += static_cast<uint32_t>(len);
return true;
};
const uint8_t cr = '\r';
uint32_t budget = kStepBytes;
while (budget > 0 && rw_.stage < 3) {
if (rw_.stage == 1) {
uint32_t left = static_cast<uint32_t>(text_.size()) - rw_.offset;
if (!left) {
rw_.stage = 2;
rw_.index = win_;
rw_.offset = 0;
continue;
}
uint32_t n = std::min(left, budget);
if (!out(bytes(text_.text()) + rw_.offset, n)) return failed("the card refused a write");
rw_.offset += n;
rw_.done += n;
budget -= n;
continue;
}
size_t end = rw_.stage == 0 ? win_ : pieces_.size();
bool pieceOver = rw_.index < end && rw_.offset >= pieces_[rw_.index].len;
if (rw_.index >= end || pieceOver) {
if (rw_.carry && !out(&cr, 1)) return failed("the card refused a write"); // a CR that ended its piece stays
rw_.carry = false;
rw_.offset = 0;
if (pieceOver) rw_.index++;
else rw_.stage++;
continue;
}
const Piece& p = pieces_[rw_.index];
uint32_t n = std::min<uint32_t>(std::min<uint32_t>(p.len - rw_.offset, budget), static_cast<uint32_t>(rw_.block.size()));
uint8_t* b = rw_.block.data();
if (card_.read(fileOf(p), p.at + rw_.offset, b, n) != n) return failed("the card refused to read the note");
size_t m = n;
if (p.src == 0) { // CRLF becomes LF (Q148, Q230), in the file's own text: what was typed has none
if (rw_.carry && b[0] != '\n' && !out(&cr, 1)) return failed("the card refused a write");
rw_.carry = false;
m = 0;
for (uint32_t i = 0; i < n; i++) {
if (b[i] == '\r') {
if (i + 1 == n) {
rw_.carry = true;
continue;
}
if (b[i + 1] == '\n') continue;
}
b[m++] = b[i];
}
}
if (!out(b, m)) return failed("the card refused a write");
rw_.offset += n;
rw_.done += n;
budget -= n;
}
if (rw_.stage < 3) return std::min(99, static_cast<int>(static_cast<uint64_t>(rw_.done) * 100 / std::max<uint32_t>(1, rw_.total)));
// All of it is in the temporary file. From the mark in the side file on, the rewrite counts as
// done: whatever is cut after that, opening the note finishes it.
card_.done();
uint32_t written = 0, old = 0;
if (!card_.size(tmp(), written) || written != rw_.wrote) return failed("the card refused a write");
if (sideSize_) {
if (!card_.append(sidePath_, reinterpret_cast<const uint8_t*>(kDone), kDoneLen)) return failed("the card refused a write");
sideSize_ += kDoneLen;
card_.done();
}
rw_.active = false;
std::vector<uint8_t>().swap(rw_.block);
// FAT can't rename onto a file. Between these two lines only the temporary file exists: the
// Notes list puts such a file back under its name.
if ((card_.size(path_, old) && !card_.remove(path_)) || !card_.rename(tmp(), path_)) {
card_.done();
why = "the card refused to replace the note";
return -1;
}
if (sideSize_) card_.remove(sidePath_);
card_.done();
sideSize_ = 0;
uint32_t window = static_cast<uint32_t>(text_.size());
pieces_.clear();
loaded_.clear();
if (rw_.wroteBefore) pieces_.push_back({0, 0, rw_.wroteBefore});
win_ = pieces_.size();
if (rw_.wroteAfter) pieces_.push_back({0, rw_.wroteBefore + window, rw_.wroteAfter});
if (window) loaded_.push_back({0, rw_.wroteBefore, window});
before_ = rw_.wroteBefore;
after_ = rw_.wroteAfter;
loadedRevision_ = savedRevision_ = rw_.revision;
droppedAtLoad_ = 0;
windowLoaded_ = true;
flushedSinceSave_ = sidePending_ = false;
windowSaved_.valid = false;
return 100;
}
bool NoteDocument::sideIsDone(uint32_t sideSize) {
uint8_t tail[kDoneLen];
return sideSize >= kHeaderLen + kDoneLen && card_.read(sidePath_, sideSize - kDoneLen, tail, kDoneLen) == kDoneLen &&
std::memcmp(tail, kDone, kDoneLen) == 0;
}
NoteDocument::Resume NoteDocument::resume(uint32_t fileSize, uint32_t& cursor) {
uint8_t header[kHeaderLen];
if (sideSize_ < kHeaderLen || card_.read(sidePath_, 0, header, kHeaderLen) != kHeaderLen || std::memcmp(header, kMagic, kMagicLen) != 0)
return Resume::Nothing;
// The newest snapshot that checks out, looking back from the end: after it there may be text
// that left a window, or a write the power cut short.
auto snapshotEndingAt = [&](uint32_t end) {
uint8_t lenBytes[4];
if (end < kHeaderLen + 24 || card_.read(sidePath_, end - 8, lenBytes, 4) != 4) return false;
uint32_t len = get32(lenBytes);
if (len < 24 || len > kMaxSnapshot || len > end - kHeaderLen || (len - 24) % 9) return false;
std::string rec(len, '\0');
if (card_.read(sidePath_, end - len, reinterpret_cast<uint8_t*>(&rec[0]), len) != len) return false;
const uint8_t* r = bytes(rec);
if (std::memcmp(r, kSnap, 4) != 0 || get32(r + len - 12) != crc32(0, r, len - 12)) return false;
uint32_t count = get32(r + 8);
if (count != (len - 24) / 9) return false;
std::vector<Piece> list;
list.reserve(count);
for (uint32_t i = 0; i < count; i++) {
const uint8_t* q = r + 12 + 9 * i;
Piece p{q[0], get32(q + 1), get32(q + 5)};
uint64_t stop = static_cast<uint64_t>(p.at) + p.len;
if (p.src > 1 || !p.len) return false;
if (p.src == 1 && (p.at < kHeaderLen || stop > end - len)) return false;
list.push_back(p);
}
pieces_.swap(list);
cursor = get32(r + 4);
return true;
};
bool found = false;
std::vector<uint8_t> buf(1024 + 3);
for (uint32_t end = sideSize_; end > kHeaderLen && !found;) {
uint32_t a = end > 1024 + kHeaderLen ? end - 1024 : static_cast<uint32_t>(kHeaderLen);
size_t n = card_.read(sidePath_, a, buf.data(), std::min<size_t>(buf.size(), sideSize_ - a));
for (size_t i = n >= 4 ? n - 4 + 1 : 0; i-- > 0 && !found;)
if (std::memcmp(buf.data() + i, kSnapEnd, 4) == 0) found = snapshotEndingAt(a + static_cast<uint32_t>(i) + 4);
end = a;
}
if (!found) return Resume::Nothing;
std::string expect;
if (!headerFor(expect) || std::memcmp(header, expect.data(), kHeaderLen) != 0) {
pieces_.clear();
return Resume::Mismatch;
}
for (const Piece& p : pieces_)
if (p.src == 0 && static_cast<uint64_t>(p.at) + p.len > fileSize) return pieces_.clear(), Resume::Mismatch;
merge();
return Resume::Ok;
}
} // namespace roro::notes
+130
View File
@@ -0,0 +1,130 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#include "note_text.h"
namespace roro::notes {
// The card, as a note needs it. On the device every call is made on the storage task.
class NoteCard {
public:
virtual ~NoteCard() = default;
virtual bool size(const std::string& path, uint32_t& size) = 0; // false: no such file
virtual size_t read(const std::string& path, uint32_t at, uint8_t* into, size_t len) = 0;
virtual bool create(const std::string& path) = 0; // an empty file, in place of what was there
virtual bool append(const std::string& path, const uint8_t* data, size_t len) = 0;
virtual bool remove(const std::string& path) = 0;
virtual bool rename(const std::string& from, const std::string& to) = 0;
virtual uint64_t freeBytes() = 0;
virtual void done() {} // what was appended is on the card now (files kept open are closed)
};
// A text file of any size, edited (issue #47, F1 Q223-Q232). The file stays on the card; what is
// in memory is one window of it, a NoteText of up to 16 KB around the cursor, and a list of pieces
// saying what the rest is made of: runs of bytes of the file, and runs of the side file
// `<note>.edit`, where a window that was changed is written when the cursor leaves it.
//
// the note = pieces before the window + the window + pieces after it
//
// Saving comes in two kinds. `journal` appends the window and the list of pieces to the side
// file: quick whatever the note's size, and enough to pick the edit up after a power cut.
// `rewrite` streams the whole note into `<note>.tmp` and puts it in the note's place: the file is
// then the note again, and the side file goes. A note of up to 64 KB is always rewritten.
//
// Every method marked [card] reads or writes the card.
class NoteDocument {
public:
static constexpr uint32_t kHalf = 4096; // loaded on each side of the cursor
static constexpr uint32_t kEdge = 2048; // this near an end of the window, it moves
static constexpr uint32_t kSpare = 256; // this near full, it moves
static constexpr uint32_t kWholeLimit = 64 * 1024; // up to here a save is a rewrite
static constexpr uint32_t kSideLimit = 1024 * 1024; // a side file this big asks for a rewrite
static constexpr size_t kManyPieces = 256; // and so does a list this long
NoteDocument(NoteCard& card, int cols, int rows);
// [card] "" or why not. `told`: something the user should read (an edit picked up, or set aside).
std::string open(const std::string& path, std::string* told = nullptr);
void openNew(); // nothing on the card until the first rewrite
const std::string& path() const { return path_; }
void setPath(const std::string& path) { // a new note's, before its first rewrite
path_ = path;
sidePath_ = path.empty() ? "" : side();
}
NoteText& text() { return text_; }
const NoteText& text() const { return text_; }
uint32_t size() const { return before_ + static_cast<uint32_t>(text_.size()) + after_; }
uint32_t cursor() const { return before_ + static_cast<uint32_t>(text_.cursor()); } // in the note
int percent() const;
bool windowed() const { return before_ || after_; } // the note is more than its window
// After each key: the cursor is near an end of the window that isn't an end of the note, or
// the window is nearly full.
bool wantsMove() const;
bool move(std::string& why); // [card] the window, around the cursor
bool jump(uint32_t to, std::string& why); // [card] the cursor, anywhere in the note
bool dirty() const { return text_.revision() != savedRevision_ || flushedSinceSave_; } // the card doesn't have it
bool filePending() const { return sidePending_; } // saved, but in the side file: a rewrite is owed
bool wantsRewrite() const; // the next save should be a rewrite
bool journal(std::string& why); // [card]
bool rewriteStart(std::string& why); // [card]
int rewriteStep(std::string& why); // [card] percent done; 100: the file is the note; -1: failed
bool rewriting() const { return rw_.active; }
private:
struct Piece {
uint8_t src; // 0: the note's file, 1: the side file
uint32_t at, len;
};
enum class Resume { Ok, Mismatch, Nothing };
std::string side() const { return path_ + ".edit"; }
std::string tmp() const { return path_ + ".tmp"; }
const std::string& fileOf(const Piece& p) const { return p.src ? sidePath_ : path_; }
uint32_t piecesBytes() const;
size_t readDoc(uint32_t at, uint8_t* into, size_t len); // from the pieces: the window is put back first
int byteAt(uint32_t at);
size_t splitAt(uint32_t at); // the index of the piece that starts there
void merge();
uint32_t noteCursor() const; // where the cursor is among the pieces once the window is put back
bool putBack(std::string& why);
bool load(uint32_t cursor, int row, int64_t startHint); // false: the card refused, and the window is empty
bool ensureSide(std::string& why);
bool headerFor(std::string& header);
Resume resume(uint32_t fileSize, uint32_t& cursor);
bool sideIsDone(uint32_t sideSize);
void reset();
NoteCard& card_;
NoteText text_;
std::string path_, sidePath_;
std::vector<Piece> pieces_; // without the window while it's loaded
std::vector<Piece> loaded_; // what the window was read from
size_t win_ = 0; // the window sits before pieces_[win_]
bool windowLoaded_ = false;
uint32_t before_ = 0, after_ = 0;
uint32_t loadedRevision_ = 0, savedRevision_ = 0;
size_t droppedAtLoad_ = 0, newlinesAtLoad_ = 0;
bool flushedSinceSave_ = false, sidePending_ = false;
uint32_t sideSize_ = 0; // 0: no side file
struct {
bool valid = false;
uint32_t at = 0, len = 0, revision = 0;
} windowSaved_; // the window as the side file already has it
struct {
bool active = false;
int stage = 0; // 0: pieces before, 1: the window, 2: pieces after
size_t index = 0;
uint32_t offset = 0, done = 0, total = 0, wrote = 0, wroteBefore = 0, wroteAfter = 0, revision = 0;
bool carry = false; // a CR at the end of a block, waiting to see what follows
std::vector<uint8_t> block;
} rw_;
};
} // namespace roro::notes
+17 -4
View File
@@ -16,11 +16,24 @@ NoteText::NoteText(int cols, int rows, std::string&& text) : cols_(std::max(1, c
text_.reserve(kMaxBytes);
}
void NoteText::dropCarriageReturns() {
size_t kept = 0;
for (size_t i = 0; i < text_.size(); i++)
if (!(text_[i] == '\r' && i + 1 < text_.size() && text_[i + 1] == '\n')) text_[kept++] = text_[i];
size_t NoteText::dropCarriageReturns(size_t* follow) {
size_t kept = 0, size = text_.size(), place = follow ? *follow : 0;
for (size_t i = 0; i < size; i++) {
if (follow && i == place) *follow = kept;
if (!(text_[i] == '\r' && i + 1 < size && text_[i + 1] == '\n')) text_[kept++] = text_[i];
}
if (follow && place >= size) *follow = kept;
text_.resize(kept);
return size - kept;
}
size_t NoteText::refilled(size_t cursor, int row) {
size_t dropped = dropCarriageReturns(&cursor);
cursor_ = std::min(cursor, text_.size());
while (cursor_ > 0 && cursor_ < text_.size() && continuation(text_[cursor_])) cursor_--;
top_ = lineOf(cursor_);
for (int i = 0; i < row && top_ > 0; i++) top_ = lineOf(top_ - 1);
return dropped;
}
bool NoteText::setText(const std::string& text) {
+13 -3
View File
@@ -7,8 +7,9 @@
namespace roro::notes {
// The text of a note while it's edited (F1, Q144, Q145): UTF-8 held whole in memory, a cursor, and
// the part of it on screen. Lines wrap at spaces, `cols` characters wide; a line owns the space or
// The text of a note while it's edited (F1, Q144, Q145): UTF-8 held in memory, a cursor, and the
// part of it on screen. Up to 16 KB: a longer note is edited through NoteDocument (note_document.h),
// which keeps this as its window on the file. Lines wrap at spaces, `cols` characters wide; a line owns the space or
// the newline it ends with, so every byte of the text belongs to exactly one line. No index of
// lines is kept (a note of newlines alone would need twice its size): where a line starts is
// worked out from the start of its paragraph, which is never far.
@@ -27,8 +28,17 @@ class NoteText {
bool setText(const std::string& text);
const std::string& text() const { return text_; }
size_t cursor() const { return cursor_; }
size_t size() const { return text_.size(); }
uint32_t revision() const { return revision_; } // changes with every edit: is it saved?
// For a window on a longer text (issue #47): the caller refills the buffer, then says where
// the cursor is in it and which row of the screen it should be on. CRLF becomes LF as in
// setText; returns how many CRs went. The revision doesn't change: nothing was edited.
std::string& buffer() { return text_; }
size_t refilled(size_t cursor, int row);
size_t startOfLine(size_t pos) const { return lineOf(pos); }
size_t top() const { return top_; }
bool insert(uint32_t codePoint); // false: the note is full
bool insertText(const std::string& s); // all of it or nothing
void backspace();
@@ -61,7 +71,7 @@ class NoteText {
bool hasLineAfter(size_t start) const;
void moved(bool keepGoal = false);
void follow(); // scrolls so the cursor is on screen
void dropCarriageReturns();
size_t dropCarriageReturns(size_t* follow = nullptr); // how many; `follow` is a place in the text, kept on its character
int cols_, rows_;
std::string text_;
+8
View File
@@ -2,6 +2,7 @@
#include "debug_auth.h"
#include "ipv4.h"
#include "wg_config.h"
namespace roro {
@@ -44,6 +45,9 @@ const Definition kDefinitions[] = {
{"check_updates", Kind::Bool, 1, nullptr, 0, 1}, // on: it only looks, and says so (R1, Q165)
{"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");
@@ -102,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;
}
+3
View File
@@ -33,6 +33,9 @@ enum class Setting : uint8_t {
CheckUpdates, // bool: look for a newer release on Gitea once a day (R1, Q165)
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
};
+15
View File
@@ -0,0 +1,15 @@
#include "version.h"
// Written by scripts/version.py before each build; not in git.
#if __has_include("version_generated.h")
#include "version_generated.h"
#endif
#ifndef RORO_VERSION
#define RORO_VERSION "unknown"
#endif
namespace roro {
const char* versionString() { return RORO_VERSION; }
} // namespace roro
+4 -6
View File
@@ -1,14 +1,12 @@
#pragma once
#ifndef RORO_VERSION
#define RORO_VERSION "unknown"
#endif
namespace roro {
constexpr const char* kProductName = "roro9stack";
// "roro9stack v0.1.0" — used on the boot screen and in About.
inline const char* versionString() { return RORO_VERSION; }
// "v0.1.0", from `git describe` (scripts/version.py): used on the boot screen and in About.
// A function in one file, not a macro on every compiler command line: a new commit then recompiles
// that one file, and everything else comes from the build cache (issue #74).
const char* versionString();
} // namespace roro
+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
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Creates the key CI uses to ask the web server for a site refresh, once (issue #79,
# docs/milestones/W1.md), and says where each half goes. The private key stays in
# ~/.config/roro9stack/ until it is pasted into the Gitea secret; it is never committed and this
# script doesn't print it.
#
# scripts/site_deploy_keygen.sh [/full/path/to/rororefresh.sh] [the runner's address]
set -euo pipefail
KEY="${RORO_SITE_DEPLOY_KEY:-$HOME/.config/roro9stack/site-deploy-key}"
COMMAND="${1:-/full/path/to/rororefresh.sh}"
FROM="${2:-}"
if [ -e "$KEY" ]; then
echo "A site deploy key already exists at $KEY; not overwriting it." >&2
else
mkdir -p "$(dirname "$KEY")"
( umask 077; ssh-keygen -q -t ed25519 -N "" -C roro9stack-ci-site-refresh -f "$KEY" )
fi
options="restrict,command=\"$COMMAND\""
[ -z "$FROM" ] || options="from=\"$FROM\",$options"
cat <<TEXT
1. On the web server, as the user that runs the refresh, add this one line to ~/.ssh/authorized_keys:
$options $(cat "$KEY.pub")
restrict: no terminal, no forwarding of any kind. command=: whatever the client asks for, this
runs instead.$([ -n "$FROM" ] || printf '\n Give the runner'"'"'s address as the second argument to add from="...": the key then works from there only.')
2. In Gitea, the repository's Settings > Actions > Secrets:
SITE_DEPLOY_KEY the whole of $KEY (the private key, with its BEGIN and END lines)
SITE_DEPLOY_HOST the server's address as the runner reaches it, or address:port
SITE_DEPLOY_USER that user's name
SITE_DEPLOY_KNOWN_HOSTS the server's host key, one line, from a machine you trust the network of:
ssh-keyscan -t ed25519 <address> (or: -p <port> <address>)
and compare it with the server's own:
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub (on the server)
ssh-keyscan -t ed25519 <address> | ssh-keygen -lf - (here)
3. Try it, from here, with the same four values in the environment:
SITE_DEPLOY_KEY="\$(cat $KEY)" SITE_DEPLOY_HOST=... SITE_DEPLOY_USER=... \\
SITE_DEPLOY_KNOWN_HOSTS="\$(ssh-keyscan -t ed25519 ... 2>/dev/null)" scripts/site_refresh.sh
(with from= set, this works from the runner's address only.) Then, to see that the key can do
nothing else: ssh -i $KEY <user>@<address> id must run the refresh, not \`id\`.
Once the secret is in Gitea, the copy at $KEY can be deleted.
TEXT
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Asks the web server to rebuild the site (issue #79, docs/milestones/W1.md). Run by CI after a push
# to main that changed the site, and after a release is published (the home page and Downloads
# name the latest release when they are built).
#
# It only connects: the server's authorized_keys line forces the one command this key may run, so
# nothing sent from here chooses what happens there. From the environment (Gitea secrets):
# SITE_DEPLOY_KEY the private key (scripts/site_deploy_keygen.sh makes it)
# SITE_DEPLOY_HOST the server, or server:port
# SITE_DEPLOY_USER the user there
# SITE_DEPLOY_KNOWN_HOSTS the server's host key, as a known_hosts line: nothing else is trusted
# With none of them set it does nothing (a fork, or before the key is installed); with only some, it fails.
set -euo pipefail
set_count=0
for v in SITE_DEPLOY_KEY SITE_DEPLOY_HOST SITE_DEPLOY_USER SITE_DEPLOY_KNOWN_HOSTS; do
[ -z "${!v:-}" ] || set_count=$((set_count + 1))
done
if [ "$set_count" = 0 ]; then
echo "site refresh: no SITE_DEPLOY_* secrets here, nothing done"
exit 0
fi
if [ "$set_count" != 4 ]; then
echo "site refresh: SITE_DEPLOY_KEY, _HOST, _USER and _KNOWN_HOSTS are needed, and only $set_count of them are set" >&2
exit 1
fi
if ! command -v ssh >/dev/null; then
apt-get update -qq
apt-get install -y -qq --no-install-recommends openssh-client >/dev/null
fi
host="$SITE_DEPLOY_HOST" port=22
case "$host" in
*:*) port="${host##*:}" host="${host%:*}" ;;
esac
# The key and the host key exist as files only while this runs, in a container that goes with the job.
umask 077
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
printf '%s\n' "$SITE_DEPLOY_KEY" > "$tmp/key"
printf '%s\n' "$SITE_DEPLOY_KNOWN_HOSTS" > "$tmp/known_hosts"
# -F none: no configuration but this line. -T and no command: the server's forced command runs.
ssh -F none -T -p "$port" -i "$tmp/key" \
-o IdentitiesOnly=yes -o BatchMode=yes \
-o StrictHostKeyChecking=yes -o UserKnownHostsFile="$tmp/known_hosts" -o GlobalKnownHostsFile=/dev/null \
-o ConnectTimeout=20 -o ServerAliveInterval=15 -o ServerAliveCountMax=8 \
"$SITE_DEPLOY_USER@$host"
echo "site refresh: done"
+9 -1
View File
@@ -10,7 +10,15 @@ try:
except Exception:
version = "unknown"
env.Append(CPPDEFINES=[("RORO_VERSION", '\\"%s\\"' % version)]) # noqa: F821
# The version goes into one generated header, read by one file (lib/version/src/version.cpp). As a -D
# on every command line it made each new commit recompile everything, and no build cache could help
# (issue #74). Written only when it changes, so an unchanged version rebuilds nothing.
import os
_header = os.path.join(env.subst("$PROJECT_DIR"), "lib", "version", "src", "version_generated.h") # noqa: F821
_text = '#define RORO_VERSION "%s"\n' % version
if not os.path.exists(_header) or open(_header).read() != _text:
open(_header, "w").write(_text)
# Keep every build's ELF, named by version and the first 16 hex digits of its SHA-256 (the core dump
+1 -1
View File
@@ -8,6 +8,6 @@ sort_by = "weight"
eyebrow = "Developer docs"
+++
The firmware is open source (GPL-3.0) and lives on [Gitea](https://git.twis.la/twisla/roro9stack). It is built for one device, the M5Stack Cardputer ADV with the Cap LoRa-1262, and it is built to be **worked on without touching the device**: install a build over Wi-Fi, read its console, press its keys, take screenshots of it, copy files to and from its SD card, and fetch its crash dumps, all from a PC on the same network. The first section is about exactly that.
The firmware is open source (GPL-3.0) and lives on [Gitea](https://git.twis.la/twisla/roro9stack). It is built for one device, the M5Stack Cardputer ADV with the Cap LoRa-1262, and it is built to be **worked on without touching the device**: install a build over Wi-Fi, read its console, press its keys, take screenshots of it, copy files to and from its SD card, and fetch its crash dumps, all from a PC on the same network. The first section is about exactly that. The same commands also run on the device itself, in the [Shell](/guide/shell/).
Some of these pages are written by hand. The rest are **generated from the repository's own documents** (the decisions, the milestone plans, the README and the firmware's own `help` text), so they are never out of date: each says which file it comes from.
+1 -1
View File
@@ -22,7 +22,7 @@ This runs the host-side unit tests (`test/`, `native` environment), then builds
`scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure.
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 4 minutes; later builds take under a minute.
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 6 minutes; later builds take about 15 seconds when little has changed. The platform knows the framework is already rebuilt by `sdkconfig.defaults` in the project folder, which it writes and git ignores: delete it and the next build rebuilds the framework. CI keeps that file, a build cache and ccache in its volume (docs/milestones/R1.md).
## CI and releases
+14 -8
View File
@@ -19,24 +19,27 @@ net bytes each network service has read and written since boot
reboot restart
boot other restart into the other app slot (manual Rollback)
log level <0-5> ESP-IDF log level (0 none ... 5 verbose)
ls [folder] | du <path> | mkdir <path> | rm <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules
ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask; * and ? in a name: /notes/*.txt)
screenshot [seconds] the screen as a PNG in /screenshots on the card, now or after a pause
lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only
lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)
lora sweep on [from MHz] [to MHz] [step kHz] | off | dump RSSI across a band (863 870 100)
lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100)
lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)
gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)
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, or one character
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
gemini get <url> fetch a Gemini page and report header, size, certificate, heap
irc start | irc stop | irc dump | irc say <buffer> <text>
install <path.ota> Update from SD
update check | list | status | install <tag> the project's releases on Gitea
update check | update list | update status | update install <tag> the project's releases on Gitea
sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal
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)
@@ -47,7 +50,7 @@ lora inject <hex> [rssi] [snr] a packet into the LoRa Scanner as if received (
sd fill <folder> <count> makes that many small files there, to test a crowded folder
coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump
reset (Debug Console only) restart at once, even if the main loop is stuck
get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py
get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py: there, a bare `screenshot` sends the screen instead of saving it
quit close the Debug Console connection
```
@@ -60,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`, or `key <char>` | Injects a key press |
| `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) |
@@ -78,13 +81,15 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| `irc say <buffer> <text>` | Types into a Buffer, commands included (`irc say 0 /join #test`) |
| `irc dump` | Prints IRC status, memory, and the last lines of each Buffer |
| `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap, then the address, gateway, DNS and NTP servers in use and where each came from |
| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, and both app slots with their versions and OTA states |
| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, **which App is in front**, and both app slots with their versions and OTA states |
| `Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Shell`, `System`, `Settings` | Opens that App: a capital letter is an App, not a command |
| `tasks` | FreeRTOS tasks over the next second: state, priority, lowest free stack, share of a core, each core's load, and how many passes the main loop made |
| `net` | Bytes each network service has read and written since boot |
| `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) |
| `log level <0-5>` | ESP-IDF log level |
| `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path |
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (a folder with what's in it), new folder. `-f` replaces a file that's in the way; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
| `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead |
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
| `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does |
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
@@ -105,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 |
+15 -2
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
key up|down|left|right|select|back|home|del|tab|space|help|shot
key a # any single character: it is typed
```
@@ -20,7 +20,19 @@ Two things to know before you use them:
1. **A name `key` does not know is `select`.** `key sleect` presses Enter. A single character is typed as that character; anything else that is not a known name is treated as Enter. Check what you type.
2. **A key that wakes a dark screen only wakes it.** The device's power policy swallows the key press that turns the screen back on, as it does for the real keyboard: the first `key` after the screen went off does nothing else. Send `key back` (harmless) first, or keep the screen on with the `normal` and `short` commands below.
`Fn` combinations, modifiers and the compose key have no command: the arrows are `key up|down|left|right`, and `key back` is the back key (`` ` `` on the device). Text is typed one character at a time.
**Ctrl, Alt and Shift** go before the name: `key ctrl-down`, `key alt-up`, `key ctrl-b`, `key shift-alt-down`. `Fn` combinations and the compose key have no command: the arrows are `key up|down|left|right` (what `Fn` with `;` `.` `,` `/` gives on the device), and `key back` is the back key (`` ` `` on the device). Text is typed one character at a time.
**`key help` opens the help panel** (<kbd>Fn</kbd>+<kbd>h</kbd> on the device): the keys of the screen that is showing. A screenshot of it is the quickest way to learn what a screen accepts, and it is how every screen's list was checked. Any key but the arrows closes it.
## Open an App by its name
```
Notes # an App's name, with a capital: opens it
Shell # Irc Wifi Gnss Gemini Lora Storage Notes Shell System Settings
info # ...and `app: Notes` says which App is in front
```
Far better than `key home`, some `key down` and `key select`: it doesn't depend on where the Launcher's selection was.
## Look before you press
@@ -34,6 +46,7 @@ scripts/rdbg.py key select
scripts/rdbg.py screenshot b.png # look again before the next destructive step
```
- **Check the App in front before typing anything.** `info` prints `app: <name>`. A crash restarts the device into the Launcher, and a script that goes on typing is typing somewhere else: one of this project's own test scripts sent a word to an IRC channel that way.
- Prefer **reading a state** to assuming it: `info`, `ls <folder>`, `cat <file>`, `irc dump`, `gnss status`, `lora status`, `wifi status`, `update status`.
- Test on a **scratch folder** on the card, not on your real files.
- For anything that deletes (`rm`, a delete dialog), `ls` first and `ls` after.
@@ -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
@@ -45,6 +47,7 @@ The device sends `screenshot: rgb332 <width> <height>` and then **one byte per p
- It is read **as it stands**, while the UI may be drawing, so it can **tear**. It is for looking at, not for pixel-exact comparison.
- It is the real thing: the screenshots on this site, in the [user guide](/guide/) and the [devlog](/devlog/), were taken this way.
- **With a number, it saves to the card instead:** `screenshot 5` (or `screenshot 0`) writes a PNG to `/screenshots` on the SD card after that many seconds, as the [Shell](/guide/shell/) does. A bare `screenshot` over the console is the binary one above.
- Its main use is in a loop: send a key, wait a moment, take a screenshot, look. See [Drive the UI](/dev/debug/drive-the-ui/).
## `coredump get`: the crash dump
+176 -2
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).
@@ -106,7 +106,7 @@ Plain text notes on the SD card, written on the device. Q30 settled the base: `.
| Q141 | A **Notes** App in the Launcher. One row per note: its first line as the title, then the date. Newest first; `s` switches to by name. `n` new, Enter opens, `d` deletes after a confirmation, `r` renames the file. |
| Q142 | A new note's file name is never typed: it comes from the first line when the note is first saved (`shopping-list.txt`), or `note-20261006-0919.txt` if that line is empty. It doesn't change afterwards unless the note is renamed. |
| Q143 | **Autosave, no "discard changes?" prompt:** five seconds after the last key, on leaving the note or the App, and when the screen turns off. A save writes a temporary file and renames it over the note, so a power cut loses the last few seconds at most. A temporary file left behind is offered back at the next open. |
| Q144 | The whole note is in memory while it's edited, up to **16 KB**. A bigger text file opens read-only in the Storage App's viewer. The App refuses to open below the memory floors (Q86). **Editing files of any size must come in a later release: issue #47.** |
| Q144 | The whole note is in memory while it's edited, up to **16 KB**. A bigger text file opens read-only in the Storage App's viewer. The App refuses to open below the memory floors (Q86). *(Lifted by issue #47: see "Notes of any size" below.)* **Editing files of any size must come in a later release: issue #47.** |
| Q145 | The editor wraps at spaces, 38 columns by 8 rows, with a line for the name and the state. Enter is a new line, Del deletes backwards, Fn+arrows move (the Text Entry rule), Ctrl+A and Ctrl+E go to the start and the end of the line, Tab types two spaces, Back saves and returns. The Compose Key works as elsewhere. |
| Q146 | The Storage App's text viewer gets `e`: edit this file with the same editor, for a text file up to 16 KB that isn't read-only. That lifts Q135 without Apps opening each other (#43 stays). |
| Q147 | The list is flat: the files directly in `/notes`. Sub-folders are reached through the Storage App. |
@@ -167,3 +167,177 @@ Test notes were made in `/notes` and removed afterwards; the folder is left, emp
**Not checked:** accents through the Compose Key and Ctrl+A / Ctrl+E (the remote `key` command can't send them; the model's tests cover both), the power button's save (it needs a hand on the device), a missing card, and how typing feels on the keyboard itself.
**One slip during the checks:** a key sequence sent right after a restart opened IRC instead of Notes, and the test letters went into IRC's input line. Nothing was sent: the line was cleared and the App left. IRC connected to Libera as it does when opened.
## Notes of any size (issue #47)
Q144 held the whole note in memory and stopped at 16 KB, for the first version only. This lifts it: the editor opens a text file whatever its size.
### Decisions (design round 2026-10-07)
| # | Decision |
|---|---|
| Q223 | **The note is the file on the card plus one window in memory.** The window is the `NoteText` of before, up to 16 KB around the cursor; the rest is a list of pieces: runs of the file, and runs of a side file. The cursor leaving the window writes it to the side file if it was changed, and loads the next. Typing never fills a note: a full window is written away and loaded smaller. |
| Q224 | **The five-second save:** up to 64 KB it rewrites the file, as before (about 150 ms). Above, it appends the window and the list of pieces to `<note>.edit`: 8 KB or so, whatever the note's size. "saved" means "on the card" either way. |
| Q225 | **The file itself is rewritten on leaving the note** (Back, Home, another App), with a progress bar. The screen turning off and the device powering off write the side file only: powering off never waits. |
| Q226 | **After a power cut, opening the note picks the edit up** where it was last saved, without a question, and says so. Until then the file has the old text for anything else that reads it. |
| Q227 | If the file was changed elsewhere meanwhile, the side file no longer fits it: it is **kept as `<note>.edit.lost`** and the editor says so. Typed text is never deleted without a word. |
| Q228 | **No limit but the card:** a note over 16 KB needs room for a second copy to be opened for editing. No warning for a big file; the progress bar on leaving tells the cost. |
| Q229 | A side file over 1 MB, or a list of over 256 pieces, makes the next save a rewrite. |
| Q230 | **CRLF becomes LF** (Q148) for a long file too: in the window as it is read, and in the rest of the file as the rewrite streams it, so a saved file is never of both kinds. |
| Q231 | **One path.** A 16 KB note is the case with no pieces: there is no second editor for small notes. |
| Q232 | Notes, and `e` in the Storage App's viewer, which no longer says "Too big to edit". |
### As built
- **`NoteDocument`** (`lib/notes/src/note_document.h`, host-tested against a card in memory) is the list of pieces, the window's moves, the side file and the recovery. `NoteText` is unchanged but for being refilled.
- **The window moves** when the cursor comes within 2 KB of an end of it that isn't an end of the note: it is then 4 KB on each side of the cursor. It starts where a line starts on screen whenever that can be known (after a newline, or where the window before had a line start), so the same text wraps the same from one window to the next, and never in the middle of a character. The cursor keeps its row on screen.
- **Looking writes nothing:** a window that wasn't changed goes back as the pieces it was read from.
- **The side file** starts with a line of text, the note's size and checksums of its first and last kilobyte, which is how a file changed elsewhere is told. After that, text that left a window, and snapshots of the list of pieces, each with its checksum. The newest snapshot that checks out is the note as last saved; anything after it is ignored.
- **The rewrite** streams the pieces and the window into `<note>.tmp`, checks its size, then writes a mark at the end of the side file: from that mark on, the rewrite counts as done, and opening the note finishes it whatever was cut (remove the old file, rename, remove the side file). Before the mark, the note and its side file are still the truth and the temporary file is dropped.
- **On the device** the card is reached through an adapter that keeps the file being read and the file being appended to open between calls; every call runs on the storage task while the main loop waits. The rewrite runs in steps of 64 KB with the progress drawn between them.
- **The Notes list** doesn't show `.edit` and `.edit.lost` files, and a note's side file is deleted and renamed with it.
- **Ctrl with Fn+Up and Fn+Down** go to the start and the end of the note.
- **`key ctrl-down`**: the consoles' `key` command takes `ctrl-`, `alt-` and `shift-`, which these checks needed. It also lets the checks S1 couldn't make (Ctrl+b, the Alt scroll) be made.
- **Cost:** 15 KB of flash. Memory with a note open is what it was: 17.5 KB, for 62 bytes or for 1.2 MB.
### Host tests (15, `test/test_note_document`)
A walk down 3,000 lines and back up through the windows; start and end; an edit in the middle rewritten into the file; 48 KB typed into a new note; a journal picked up after a cut; **a cut at every 997th byte of a sequence of two saves and a rewrite**, after which the note is always one of the three texts it should be, what was reported saved is there, and no stray file is left; a file changed elsewhere; CRLF; windows on text with no space and no newline, made of 2, 3 and 4-byte characters; a full card; and **36,000 random keys** (typing, deleting, moving, jumping, saving, power cuts) on six notes of 30 to 130 KB, compared with a plain string after every key.
### Checks on the device (2026-10-07, driven over the Debug Console)
Test notes were copied to `/notes` and removed afterwards; the note that was already there was not touched.
| Check | Result |
|---|---|
| A 36 KB note | Opens (it was refused before). Two letters at the top, 400 lines down across the windows, four more: the file fetched back is exactly that, and no other file is left |
| A 1.2 MB note | Opens at once. Free memory 104.2 KB before, 86.7 KB with it open |
| Its five-second save | `zz-big.txt.edit`, 4 KB; the note's file untouched |
| Ctrl with Down, Ctrl with Up | The end and the start, as fast as any key |
| A restart with unsaved keys | "Your unsaved changes are back", the cursor where it was, the unsaved keys gone and nothing else |
| Leaving it | The progress bar, then one file: **1.2 MB rewritten in 2.6 s**. Fetched back: the original with what was typed at both ends, byte for byte |
| A restart in the middle of that rewrite | The note, its side file and an empty `.tmp` remain; opening picks the edit up, leaving rewrites it, the result is right |
| A new note | No file until typed in, then `zz-test-note.txt` from its first line |
| `e` in the Storage App on the 1.2 MB file | The same editor; edited and rewritten |
| The Notes list | Side files are not listed as notes |
**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.
+60 -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.
@@ -180,3 +180,62 @@ The issue asked for a token that could be set, so that Debug Builds could be pub
**One false alarm:** after the tests the console "wouldn't come back on". It was off: the setting had been left off by `debug off`, and the page's "Switch it on?" dialog opens on **Cancel**, so Enter twice leaves it off. Nothing was lost.
**Not checked:** `scripts/flash.sh --debug` over USB (no device on USB here); "New token" and "Type a token" from the page (the code paths are the ones `debug token` uses, which need the cable); the Toast itself on screen (the console is closed while it shows; its Notification is in the log); free memory with the console off, which only the serial port could say (the static figures above are the evidence); fetching a release's ELF, which needs a crash on a released version.
## CI that doesn't rebuild the world (issue #74)
A pull request's run took over seven minutes, a tag's thirteen and a half. The goal: a firmware build under a minute.
### Where the time went (run 81, a pull request, 2026-10-06)
| Step | Time |
|---|---|
| Tools (apt, pip) | 15 s |
| Check out | 2 s |
| Host tests and coverage | 55 s |
| **The firmware** | **358 s** |
| of which: CMake configuring ESP-IDF | 87 s |
| of which: compiling ESP-IDF's libraries | 171 s |
| of which: our own build (the Arduino core, the libraries, `src/`) | 91 s |
A tag's run did the firmware step, then built the same commit again for the release: twice 356 s.
### Why the framework was rebuilt every time
The framework is rebuilt with our SDK settings (ADR 0006), and the rebuilt libraries stay in the toolchain volume. But the platform decides whether they match by reading **`sdkconfig.defaults` in the project folder**, whose first line carries a hash of the settings. That file is generated, and not in git. A fresh checkout has none, so the platform concluded "different settings", **reinstalled the framework and rebuilt it**: 260 seconds, at every run, to arrive at the libraries that were already there. On a developer's machine the file is simply still there from the last build, which is why nobody saw it.
### What changed
| Change | Effect |
|---|---|
| **The file is kept in the volume, inside the libraries it describes** (`framework-arduinoespressif32-libs/.roro-sdkconfig.defaults`), copied into the checkout before a build and back after one that passed. The platform still checks its hash against `platformio.ini`: changed settings rebuild, as they must. Kept there and not beside them, it disappears when the libraries are reinstalled, so it can't describe libraries that are gone | 358 s to 92 s |
| **The version is no longer a `-D` on every compiler command line.** `scripts/version.py` writes `lib/version/src/version_generated.h` (not in git, written only when it changes), read by one file. Before, every commit recompiled everything, on a developer's machine too, and no cache could have helped | A rebuild with nothing changed: 77 s to 13 s, locally |
| **PlatformIO's build cache** (`PLATFORMIO_BUILD_CACHE_DIR`, SCons's CacheDir) in the volume, for the firmware of pull requests: objects by the signature of their sources and command line | 92 s to 26 s, with a new version and one changed file |
| **ccache for the host tests.** They are built with coverage counters, and the build cache would return objects without their `.gcno` files; ccache keeps both | 49 s to 33 s. What's left is PlatformIO starting 51 test programs |
| **A tag builds its firmware once**, in the release step | minus 6 minutes |
| PlatformIO and gcovr in a virtual environment in the volume | a few seconds |
**A release compiles its own sources from nothing:** it reuses the rebuilt framework (the platform checks the hash) but not the build cache, so no published file contains an object that came from another commit's build.
### Measured (a development machine, fresh copies of the tree, the same volume)
| | Before | After |
|---|---|---|
| The firmware, fresh checkout, nothing cached for it | 358 s | 82 s (it fills the cache) |
| The firmware, fresh checkout, a new version and one file changed | 358 s | **27 s** |
| Host tests and coverage | 49 s | 33 s |
| Rebuilding locally with nothing changed | 77 s | 13 s |
### Measured on the runner (pull request #76, 2026-10-07)
| Run | Tools | Tests and coverage | The firmware | The whole job |
|---|---|---|---|---|
| Before (run 81) | 15 s | 55 s | 358 s | 434 s |
| The first with the new workflow: no mark yet, the framework is rebuilt once more and the caches fill | 16 s | 59 s | 354 s | 431 s |
| The next commit (only the workflow changed) | 10 s | 36 s | **51 s** | **100 s** |
| The same commit again | 10 s | 36 s | **18 s** | **66 s** |
In the 51-second run, 245 objects came from the cache and 43 were compiled: `version.cpp`, as expected, and all 42 files of `src/`, which had not changed. In the run after it, all 290 came from the cache. So the objects of `src/` made by the run that rebuilt the framework were not reusable by a normal run, and those of a normal run are: the two-pass build that rebuilds the framework compiles `src/` with something different on its command line. It costs one 51-second run after each framework rebuild, which is rare; I did not look for what differs.
The firmware step with everything cached is 18 seconds: the libraries are downloaded and unpacked (4 s), the dependency scan (5 s), fetching 290 objects, the link and the image (the last 11 s). A pull request that changes a few files should land between that and 51 seconds.
**What it costs:** the build cache grows by about 40 MB a run (each linked firmware is kept) and is started again past 3 GB; ccache is held to 1 GB.
+67 -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.
@@ -144,3 +144,69 @@ M3 found the LoRa radio's noise floor about 15 dB above what the chip hears alon
**Settings > Pause GNSS for LoRa**, off by default: while the LoRa radio listens or sweeps, the GNSS receiver waits in standby, and wakes when the radio goes back to sleep (a Fix again after about 7 s here). Never during a Track. The GNSS App says "GNSS is paused" meanwhile. `gnss quiet on|off` on the console.
It's off by default because GNSS on by default was decided in M2 (Q58), and from M4 the radio listens all the time: then "pause while listening" means GNSS mostly off, which is a decision about position, the clock and Tracks, for M4's design round (issue #23).
## The Shell (issue #67)
The console's commands could only be typed on a PC: over USB, or over Wi-Fi with the Debug Console. A device in a bag, or on a network that is down, could not be asked anything. The Shell is an App that runs the same commands on the device's own screen and keyboard.
### Decisions (design round 2026-10-07)
| # | Decision |
|---|---|
| Q204 | **An App, "Shell", in the Launcher**, in every firmware: its commands already work over USB for anyone holding the device. |
| Q205 | **Trusted like USB serial**, not like the network: `debug on` and `debug token` work from it, as they do in Settings. The token is never shown. |
| Q206 | *Revised the same day, after trying it.* **It shows the replies to its own commands, and only those.** The console knows who each line is printed for (`Console::Origin`): a command run from the Shell prints as the Shell's, and so does what answers it later from another task, which notes who asked and takes it back when it prints (`ls`, `tasks`, `du`, `cp`, `update check`, `sd list`, `screenshot`, `gemini get`). What USB or the Debug Console asked for, and the system's own lines, are not the Shell's. **Ctrl+b shows everything** instead. The first version kept whatever was printed in the ten seconds after a command, which was a guess, and a noisy one. |
| Q207 | **Nothing while it's closed.** Open, a 4 KB ring of the console's and up to 4 KB of lines; both go when the App is left, with the list of commands for Tab. |
| Q208 | Enter runs the line; Fn with up and down recalls the last 16; Alt with up and down scrolls back. **Tab completes** the command, **every word of it** *(the first only, at first)*: `lora st` gives `lora status`, `gnss track ` lists `start stop`, `key ` its eleven names. The words come from the firmware's `help` text, read as it is written, so a new command completes without a table to keep. *(Added the same day)* **past the command, a path on the SD card**: a folder keeps its slash to go on from, a file completed whole gets a space, several candidates are listed. Whatever the case typed, the name's own is taken, since the card doesn't tell them apart. After a file command the first slash is understood (`cat no` is `/no`). A name with a space in it isn't completed. |
| Q209 | *Revised the same day.* **`rm` is Unix's, with a question.** A folder needs `-r`, here and over the consoles (where `rm <folder>` used to remove it with what was in it). In the Shell, a file, or a folder with something in it, is asked about unless `-f` (`-rf`, `-r -f`); an empty folder with `-r` goes without a word; what `rm` would refuse anyway, it refuses itself. Over the consoles nothing is asked: scripts delete as before. **`screenshot [seconds]`** saves the screen as a PNG in `/screenshots`, now or after a pause, since from the Shell "now" is the Shell. `get`, `put`, `coredump get` and `reset` answer `Debug Console only`. *(Added the same day)* **`*` and `?` in a name**, for `ls`, `du`, `rm`, `cp` and `mv`, here and over the consoles: `rm /notes/*.txt`, `cp /gnss/2026-10-0?.gpx /backup`. In the last part of the path only, any case, as the card has it. It is the same command once for each name matched, in the name's order; `cp` and `mv` then need a folder that exists to put them in. Over 64 matches is refused whole, as is none. In the Shell `rm` with a pattern asks **once**, with the count. |
| Q210 | Commands are echoed as `> command` into the console, so a session reads the same from afar. **A token being set is not echoed.** |
| Q211 | **Not in Safe Mode**, which starts no Apps: issue #77. |
| Q212 | Its keys are a table in `app_keys.h`, so the help panel and the website have them; a page in the user guide. |
| Q213 | *Added the same day.* **An App's name with a capital opens it:** `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Notes`, `Shell`, `System`, `Settings` (the App's id, up to its first dash). From the Shell, without going back to the Launcher, and from the consoles too. The capital says "an App": every command of the firmware is in small letters. Tab completes them. |
### As built
- **`ShellApp`** (`src/apps/shell_app.cpp`), with its model host-tested in `lib/apps_model/src/shell_log.h`: lines arriving in pieces, the 4 KB limit, the words of every command out of the `help` text (Apps' names included), and Tab.
- **Who a line is for:** `Console::As` marks the calling task as printing for the Shell while it lives, and `Console::origin()` lets a command that answers later carry that to wherever it prints. The console has a second ring for the Shell, which gets the lines marked so, or everything.
- **The Shell hands its lines to the main loop**, which runs them like the consoles' commands. See below for why.
- **`info` says which App is in front** (`app: Shell`): so that a hand driving the device from afar can look before it types.
- **`screenshot`** writes the PNG a row at a time with no buffer: 8-bit indexed colour, the 256 colours of RGB332 as the palette, the pixels in one stored deflate block (`lib/files/src/png_rgb332.h`, 4 tests). 33,383 bytes for the 240 x 135 screen. A Toast says so once it is on the card, so the Toast is never in the picture.
- **The `help` text lost its shorthand** (`update check | list | status` is written out), since Tab reads its words from there: `|` between two commands, `on|off` between two words, three spaces before the description. The Shell's own words (`clear`, `quit`, `key`'s names) are added in the same notation.
- **Tab on a path** reads the folder on the storage task while the main loop waits, so it is bounded: 400 entries looked at, 24 candidates given back, and `...` after the list when there were more.
- **A pattern** is matched in `lib/files/src/file_names.h` (`globMatch`, host-tested), and the names it matches are lined up as so many commands, which the main loop runs one after the other as each finishes. `cancel` empties the line-up. 2000 entries looked at, 64 matches at most.
- **Cost:** 21 KB of flash, 96 bytes of static RAM. Open: 7 KB of heap (107.6 KB free before, 100.7 with it open, 106.1 after leaving).
### What went wrong while building it
**The device crashed, and the test sent a message to an IRC channel.** The first version ran a command from inside the key handler. Driven from the Debug Console, that is: the main loop, a remote command, `key select`, the App manager, the Shell, `runCommand` a second time, the file command, and `printf` under all of it. The main loop has under 2 KB of stack to spare; `rm` on a folder went past it. The crash report decoded to exactly that chain.
The device restarted into the Launcher, and the test script, which did not look, went on typing. Its next Enter opened IRC, which connected, and a few lines later it typed "No" into a channel and pressed Enter. One word, sent to real people, that can't be taken back.
Two changes came of it. The Shell now **queues** its line and the main loop runs it, at the same stack depth as a console's command. And `info` reports the App in front, which the test script now checks before every line it types.
### Checks on the device (2026-10-07, driven over the Debug Console with `key`)
| Check | Result |
|---|---|
| Open it from the Launcher, type `ls /`, Enter | The folders are listed |
| Tab on `in` | `info install` is shown and the line stays; on `u` it becomes `update `; on `l`, `log ls lora loop` |
| Up | The line before comes back |
| `screenshot` | `/screenshots/20261007-104357.png`, 33,383 bytes. Fetched and decoded on the PC: 240 x 135, indexed, every chunk's CRC right, the pixels the Shell's screen |
| Output | With a Debug Console client connecting and disconnecting for every key, the Shell shows the commands and their replies and nothing else: `info`, `ls /` (answered by the storage task), `tasks` (answered a second later) |
| `rm` on a folder, without `-r` | Refused, the folder stays |
| `rm -r` on an empty folder | Removed, no question |
| `rm -r` on a folder with files | Asks; Cancel leaves it. `rm -rf` removes it without asking |
| `rm` on a file | Asks; Delete removes it |
| Tab on a path | `ls /no` becomes `ls /notes/`, and again, with one note in it, the note's whole name and a space. `ls /g` lists `gemini/ gnss/`. `cat no` becomes `cat /notes/`. `rm -r /CAP` becomes `rm -r /captures/`. `ls /zz` stays as it is |
| Tab past the first word | `lora st` becomes `lora status `; `gnss tr` becomes `gnss track ` and Tab again lists `start stop`; `key ` lists its eleven names; `upd` becomes `update ` and lists `check list status install` |
| `ls` with a pattern | `ls /gt/*.txt` the five files, `ls /gt/*2*` the one, `ls /gt3/*6?.txt` ten of seventy. `ls /g*/x`: refused, a pattern goes in the last part |
| `cp /gt/file-000?.txt /gt2`, `du /gt2/*1*` | Five copies; one size |
| `rm /gt2/*` in the Shell | "The 5 that match /gt2/\*": Cancel leaves the five. `rm /gt3/*6?.txt`, Delete: ten gone, sixty left. `rm -f /gt2/*`: gone without a question |
| `rm /gt/*` where one match is a folder | The files go, the folder stays (no `-r`) |
| No match, and seventy | `rm: error nothing matches`; `rm: error more than 64 match: a narrower pattern, please`, and all seventy still there |
| `No`, Tab, Enter | Completes to `Notes ` and opens Notes. `Shell` from the Debug Console opens the Shell |
| `screenshot 4`, then Home | The picture, taken four seconds later, is of the Launcher: the pause works, and the Toast isn't in it |
| `quit` | Back to the Launcher, and the memory comes back |
| The help panel in the Shell | Its keys, then the ones that work everywhere |
**Not checked:** Ctrl+b and the Alt scroll, which `key` can't press (no modifiers): the filter itself is host-tested, the key that flips it is not; the real keyboard altogether; the Toast after a screenshot, which was published but not looked at; and `mv` with a pattern and `cancel` in the middle of a line-up, which share their code with `cp` and `rm` but were not run. The main loop's lowest free stack after all of it: 1.8 KB, where it was.
+69
View File
@@ -0,0 +1,69 @@
+++
title = "Look and feel"
description = "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."
weight = 90
[extra]
docs = true
source = "docs/milestones/U1.md"
tag = "U1"
+++
**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). Not started: screen recording (#17), a Launcher of tiles (#9), themes (#10).
**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.
## The help key (issue #69)
Every screen used to say something about its keys, differently: a footer of abbreviations in one place (`c x v:paste r:name d:del n:new i:info s:sort`), a line under a text field in another (`Enter: save \`: cancel`), `Tab: sky` in a corner, and nothing at all in several. About 30 such strings, each costing a line of a small screen, and none of them complete.
### Decisions (design round 2026-10-07)
| # | Decision |
|---|---|
| Q196 | **Fn+h, on every screen,** text fields included (Fn is held, so nothing is typed). **`?` too, outside Text Entry.** |
| Q197 | It opens **a panel over the content area**, titled with where you are: the screen's own keys, then an "Everywhere" group (Back, Home, the arrows, the help key). The arrows scroll it; any other key closes it and is not passed on. |
| Q198 | **Each App answers "what are your keys right now?"** for the state it is in; pages, viewers, dialogs and text fields answer for themselves, with shared lists for dialogs, lists and text entry. The lists are constants; the panel's rows exist only while it is open. |
| Q199 | **Every hint that names a key goes,** text fields included. What stays is state: `REC 12 points`, `LOG 42`, `sort:signal`, `typing`/`saved`, what is waiting to be pasted, the Sweep's floor. Messages were reworded where they named a key ("v pastes a copy of…" is "Copied …: paste it where you like"). |
| Q200 | **The first-start Setup keeps its hints,** and is the one place that does: someone in their first minute doesn't know the help key yet. It tells them about it on its first and last screens. |
| Q201 | **Loud everywhere else:** the user guide opens with it, the FAQ has it first, and a device set up before this firmware gets one Toast, once: "Fn+h: the keys of any screen". |
| Q202 | `key help` over the consoles. Generating the website's key tables from the same lists is a follow-up, not this issue. |
| Q203 | The key and the panel first, host-tested; then one App at a time, declaring its keys and losing its hints in the same step; then every screen looked at on the device. |
### As built
- **`Key::Help`** from the key mapper: Fn+h in both modes, `?` only outside Text Entry (`lib/input`, 3 tests).
- **`App::help()` and `App::helpTitle()`** (`lib/core/src/app.h`), `KeyHelp` rows and `HelpModel` (`key_help.h`). The **App manager** opens the panel, appends the "Everywhere" group, and while it is open takes every key: nothing reaches the App, Home included. It closes when the App changes (5 tests).
- **Every App declares its keys by state:** the Launcher, IRC (chat, settings, a field), Wi-Fi Tools (4 views), GNSS, Gemini (page, saved page, address, answer, dialogs), the LoRa Scanner (4 views), Storage (browse, details, a name, the viewer's 6 modes, the editor, Maintenance, busy), Notes (list, editor, a file name), System (5 views), Settings (menu, text, choice, and the Wi-Fi, Firmware and Debug Console pages with their own states), Setup and the widget demo.
- **The hints are gone** from all of them. The footers that remain say state only.
- **It costs** 8.5 KB of flash and 40 bytes of static RAM.
### Checks
| Check | Result |
|---|---|
| Host tests | 476 pass (468 before) |
| On the device, `key help` and a screenshot | The Launcher and all nine Apps, and these states: Storage scrolled, System's tasks, a Settings text field, Wi-Fi Tools' networks. The panel is titled with the scope, lists the right keys, scrolls, and closes on Tab |
| The one-time Toast | `notification: Fn+h: the keys of any screen` on the first start after the update |
### One source for the device and the website (issue #72)
The lists first lived in each App's `help()`, as code. They are now **data, in one file**: `lib/core/src/app_keys.h`, 52 constant tables, each under a comment `// id: Title`. An App's `help()` picks the table of the state it is in. `site/tools/gen_dev_docs.py` reads the same file and writes `site/data/keys.toml`; the `keys` shortcode shows a screen's tables on its guide page, and `/guide/keys/` shows all of them. The Site job fails when the data file is out of date, or when a page asks for a table that doesn't exist, and it now runs when `app_keys.h` changes. A key added to an App shows up on the website without anyone editing a page.
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.
+59 -2
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.
@@ -29,7 +29,7 @@ The home page was designed on a canvas in a Claude chat (a dark and a light them
| Q175 | The site lives in this repository, in `site/`, so the documentation is built from `docs/`, `CONTEXT.md` and the README instead of being copied. |
| Q176 | **Zola,** like the blog. The design becomes a template, its tokens CSS custom properties. Dark and light follow the visitor's setting, with a visible switch. No JavaScript except the flasher's. |
| Q177 | Domain: **roro9stack.net.** The blog stays at experiments.twis.la. |
| Q178 | **Publishing is the blog's way:** the web server pulls `main` and runs `zola build`; that part is the maintainer's. Changes reach `main` through pull requests as everywhere. **CI is split:** a dedicated `site` job builds the site (`zola build`) when `site/`, `docs/`, `README.md` or `CONTEXT.md` change, and the firmware tests and builds skip a change that touches nothing else. A change that touches both runs both. |
| Q178 | **Publishing is the blog's way:** the web server pulls `main` and runs `zola build`; that part is the maintainer's. *(Since issue #79, CI asks the server to do it: see "Published by CI" below.)* Changes reach `main` through pull requests as everywhere. **CI is split:** a dedicated `site` job builds the site (`zola build`) when `site/`, `docs/`, `README.md` or `CONTEXT.md` change, and the firmware tests and builds skip a change that touches nothing else. A change that touches both runs both. |
| Q179 | Phases, each its own pull request: **1.** the CI split, the home page, an Install page with the browser flasher, downloads and the changelog. **2.** a user guide page per App. **3.** how-tos and the FAQ. **4.** developer docs generated from the repository. |
| Q180 | **A browser flasher** (ESP Web Tools), **without copying the firmware.** Caddy, in front of Gitea, adds `Access-Control-Allow-Origin: https://roro9stack.net` (and `Vary: Origin`) to GET and HEAD on `/twisla/roro9stack/releases/download/*` and `/api/v1/repos/twisla/roro9stack/releases*`: both are public already. The Install page asks the API for the latest release in the browser, finds the asset ending `-factory.bin`, and gives ESP Web Tools a manifest built on the spot, so it offers a new release as soon as it exists, with no rebuild. The library is **vendored** into `site/static/` (Apache-2.0), not loaded from a CDN. The file's SHA-256 is shown on the page. Chrome or Edge on a desktop only; other browsers, and visitors without JavaScript, get the `esptool` steps on the same page. |
| Q181 | Docs for the latest version only. The changelog is the Gitea releases, read at build time. |
@@ -133,3 +133,60 @@ Not one of the planned phases: the blog's seven roro9stack posts, imported into
- **Left out on purpose:** the M0 and M1 milestone documents and `CONTEXT.md` (the glossary) describe Wi-Fi monitoring, which the site does not publish. They stay in the repository.
- **The Debug Console pages were written against the source and the live console:** the protocol (the token line, the banner, the 4 KB backlog, one client, 8 queued commands, 240-byte lines, `denied` after a second) and the replies shown were checked on a Debug Build, v0.11.0-3, over Wi-Fi. Not run: `crash abort`, `crash wdt` and Safe Mode, which are described from ADR 0005 and the code.
- **Found while writing it:** the README's table lacked the `gnss` commands (rows added); piping commands into `rdbg.py` returns before the replies unless the input stays open (documented, not changed); `update install` on a Debug Build needs `force` (documented).
## Published by CI (issue #79, design round 2026-10-07)
Q178 left publishing to the maintainer: a merge, then a command typed on the web server, each time.
| # | Decision |
|---|---|
| Q214 | **A plain ed25519 key with a forced command**, not a certificate: one line in the web server user's `authorized_keys`, `restrict,command="/full/path/to/the/refresh"`. `restrict` takes away the terminal and every forwarding. A certificate could carry the same and an expiry date, at the price of a CA to keep and a key to sign again each time: too much for one key and one command. |
| Q215 | **The CI sends no command.** The server runs the forced one whatever is asked for, so there is nothing to keep secret about it and nothing a leaked key could choose. The full path is written once, on the server (a command over SSH doesn't get the user's login `PATH`). |
| Q216 | Four secrets: `SITE_DEPLOY_KEY`, `SITE_DEPLOY_HOST` (or `host:port`), `SITE_DEPLOY_USER`, and **`SITE_DEPLOY_KNOWN_HOSTS`**, the server's host key: the job connects to that server or to nothing. None of them is in the repository, which is public. |
| Q217 | `from="<the runner's address>"` on the same line: the key works from the runner only. |
| Q218 | **The last step of the Site workflow**, after the build and the checks, on a push to `main` only. A pull request never reaches it, and the secrets are given to that step alone. |
| Q219 | **After a release too.** The Install page asks Gitea for the latest release when it is opened, but the home page and Downloads read it when the site is built: so the release workflow refreshes the site once the release is published. |
| Q220 | A refresh that fails makes the run red, with what the server's script printed: it has to exit with an error when it fails. |
| Q221 | Two refreshes at once are the server script's to refuse or queue (`flock`). |
| Q222 | The key is a file only while the step runs, in the job's container, as the signing key is. |
### As built
- **`scripts/site_refresh.sh`** is what both workflows run: it writes the key and the host key to a temporary folder, connects with no configuration but its own line (`-F none`, strict host key checking, that one key, no command), and removes them. With none of the four secrets it does nothing and says so (a fork, or a repository without them); with only some it fails.
- **`scripts/site_deploy_keygen.sh`** makes the key pair once, in `~/.config/roro9stack/`, and prints the `authorized_keys` line and what goes in each secret. It never prints the private key.
- **The server's script** should start like this, for Q220 and Q221:
```sh
#!/bin/sh
set -e
exec 9>/tmp/rororefresh.lock
flock -w 120 9
```
### Checks (2026-10-07, against an SSH server in a throwaway container)
| Check | Result |
|---|---|
| The refresh | The forced command runs as the server's user; the script ends with `site refresh: done` |
| The same key, asking for `id; cat /etc/passwd` | The refresh runs instead; what was asked for is only handed to it as text |
| A terminal | Refused: `PTY allocation request failed` |
| `scp` with the key | Nothing is copied |
| Another host key in the secret | `Host key verification failed`, the run fails, nothing is sent |
| The server's script exits with an error | So does the step |
| No secrets at all; only one of the four | Does nothing and says so; fails and says which are needed |
**Not checked:** the real web server and the runner, which wait for the key to be installed: whether the runner reaches the server's SSH port is the first thing the first run will tell. Port forwarding, which `restrict` switches off, was not tried. `from=` was not tried either.
## Search (issue #60)
A search over the documentation: the user guide, the how-tos, the questions and answers, and the developer docs. Not the devlog.
- **The index is the search page itself** (`/search/`, `templates/search.html`): one list item for each page and for each `##` heading of it, with that part's text, written by Zola from the pages' own content when the site is built. Nothing is fetched and nothing typed leaves the browser, so the Content-Security-Policy needs nothing new, and the web server still only runs `zola build`.
- **Without JavaScript** the page is a list of every page and heading of the documentation, each a link.
- **With it**, `js/search.js` filters and ranks the items as you type: every word has to be in the part; a word in a heading counts for most, the words side by side for more than scattered, and the user guide, the how-tos and the FAQ come before the developer docs, the milestones last. A result links to its heading, with the text around the match.
- **The content pages get no script for it:** the navigation has a link, and the index pages of the guide, the how-tos and the developer docs have a box that is a plain form to `/search/?q=`.
- **Size:** about 245 items, about 310 KB of HTML, under 100 KB compressed, loaded only by who searches.
**Checked** in Chromium with the production Content-Security-Policy on every response, no violation: "probation" (the guide's "Probation and Rollback" first), "safe mode", "rm -r", "how big can a note" (the FAQ's question first), a word that isn't there; typing, following a result to its heading, the box on the guide's index, 390 px wide with no sideways scroll, and JavaScript off. `check_site.py` follows every link of the page, so an index entry can't point at a heading that doesn't exist.
**Not checked:** other browsers, and a screen reader.
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,264 @@
+++
title = '''It said "No"'''
description = '''The last post listed three things as next for roro9stack: one help key, a shell on the device, notes of any size. All three shipped in a day, with a CI that stopped rebuilding the world and a website that publishes itself. On the way, a test script kept typing after the device had crashed, and sent one word to an IRC channel full of people.'''
date = 2026-10-07T18:00:00+02:00
[extra]
topics = '''ESP32-S3 · Testing · Editors'''
read_label = '''Read who it said it to →'''
uid = '''<b>app:</b> Shell &nbsp; <b>heap:</b> 104 KB free'''
dek = "Three releases of [roro9stack](/devlog/roro9stack/) in one day: v0.13.0, v0.14.0 and v0.15.0. A help key that replaces every hint line, the firmware's console on the device's own screen, and an editor that opens a megabyte in the memory it used for a shopping list. Most of what went wrong was me being wrong about what the device had done. One thing was the device doing exactly what my script told it to, in the wrong App."
byline = '''designed by interrogation, rounds thirteen to sixteen: thirty-seven questions, two of them answered twice'''
[extra.sign]
label = "Messages sent to real people by a test script"
note = "One word, in an IRC channel, after a crash the script didn't notice."
count = "1"
tone = "red"
[[extra.cast]]
name = "Fn+h"
role = "the help key, on every screen"
text = "Lists the keys that work where you are. Every screen had a line at the bottom doing that, differently and never completely. Those lines are gone."
[[extra.cast]]
name = "The Shell"
role = "an App, since v0.14.0"
text = "The commands I had been typing from a PC over Wi-Fi, on the device's own keyboard. It took more than one try to decide what it should show, and one crash to decide where its commands run."
[[extra.cast]]
name = "The test script"
role = "types keys over the Debug Console"
text = "Tireless, exact, and with no idea what is on the screen. It typed the right letters. The App under them had changed."
[[extra.cast]]
name = "The window"
role = "8 KB of a note, around the cursor"
text = "All of a note that is in memory. The rest stays on the card, described by a short list. It moves when the cursor nears its edge, and nobody is meant to notice."
[[extra.cast]]
name = "<note>.edit"
role = "the side file"
text = "Where a long note's changes wait, four kilobytes at a time, until the note is left and rewritten. Also what a power cut leaves behind, on purpose."
+++
## TL;DR
- The [last post](/devlog/roro9stack-console/) ended with three things as "next". **All three are in**: a help key (**v0.13.0**), a shell on the device (**v0.14.0**), notes of any size (**v0.15.0**).
- **Fn+h lists the keys of the screen you're on**, and every hint line is gone. The same lists make the key tables on this website.
- **CI went from over seven minutes to about one** for a pull request. It had been rebuilding the whole framework at every run because of one file that isn't in git.
- **The Shell** runs the firmware's commands on the device: Tab completes every word and paths on the card, `rm` behaves like Unix's and asks first, `*` and `?` work.
- **The editor opens any file.** A 1.2 MB note uses the same 17.5 KB as a 62-byte one, saves in 4 KB pieces, and is rewritten in 2.6 s when you leave it. A power cut at any byte leaves the note or the last save, never something in between.
- **This site publishes itself** when a change is merged, through an SSH key that can do exactly one thing.
- A test script **sent the word "No" to an IRC channel**. That one can't be fixed, only prevented.
- 507 host tests, 39 more than last time.
## The cast
{{ cast() }}
## One key instead of a hint line on every screen
Every screen had a line at the bottom: `Enter open d delete r rename`. Each was written by hand, each was different, and none had room for everything. The screen is 240 pixels wide.
So: **Fn+h, everywhere**, and `?` wherever you aren't typing text. It opens a panel over the App, titled with where you are, listing that screen's keys and then the ones that work everywhere. Any other key closes it.
{{ figure(src="help.png", alt="The Cardputer's screen at 2x: a panel titled Keys: Shell, listing Enter run the line, Tab complete the command, Fn semicolon and period lines you typed before, Alt semicolon and period scroll back and forward, Ctrl b, Fn comma and slash move the cursor, Del delete backwards, help every command.", width=480, height=270, caption="The Shell's keys, from the first build that had a Shell. The line that runs off the edge was reworded the same afternoon.") }}
The hint lines went, all of them, with one exception: the first-start Setup keeps its own, because someone in their first minute doesn't know the help key exists. It tells them on its first and last screens.
The lists started as code inside each App. They are now **data in one file**, 52 small tables, and the same script that builds the [developer docs](/dev/) reads that file and writes the key tables in the [user guide](/guide/). CI fails if the site's copy is out of date, so the guide can't list a key the firmware doesn't have.
## The framework that was rebuilt every time
A pull request took over seven minutes to check, and a release thirteen and a half. For a firmware that builds in 77 seconds on my machine.
Where the time went, for one pull request:
{% table() %}
| Step | Time |
|---|---|
| Tools | 15 s |
| Host tests and coverage | 55 s |
| **The firmware** | **358 s** |
| of which: configuring ESP-IDF | 87 s |
| of which: compiling ESP-IDF's libraries | 171 s |
| of which: our own code | 91 s |
{% end %}
roro9stack rebuilds the Arduino framework with its own settings, for [smaller TLS buffers](/dev/decisions/0006-framework-rebuilt-for-smaller-tls-buffers/). The rebuilt libraries were sitting in the runner's cache the whole time. But the build system decides whether they still match by reading a file in the project folder, and that file is generated: it isn't in git. Every fresh checkout had no such file, so every run concluded the libraries were stale and rebuilt them. 260 seconds, each time, to produce what was already there.
The fix is to keep that file with the libraries it describes. Two more things came out of looking:
- **The version was a `-D` flag on every compiler command line.** Every commit changes the version, so every commit recompiled every file, on my machine too, and no cache could ever have helped. It's now one generated header that one file includes.
- **A release built the firmware twice**: once to check it, once to sign it.
With a build cache for pull requests on top: **27 seconds** for the firmware with one file changed, and about a minute for the whole run on the real runner. A release takes under three minutes, and still compiles its own sources from nothing: no published file contains an object built for another commit.
## A shell, and what it should show
The Debug Console's commands are the tool I use most, and they needed a PC. The Shell is an App that runs them on the device.
The first version was an afternoon's work and wrong in three ways.
**It showed too much.** The firmware prints all the time: IRC connecting, a packet heard, whatever a PC on the USB port is asking for. Version one showed everything printed in the ten seconds after a command, on the theory that the reply would be in there somewhere. It was, among everything else. The fix was to stop guessing: the console now knows **who each line is for**. A command run from the Shell prints as the Shell's, and so does an answer that arrives a second later from another task, which notes who asked. Ctrl+b shows everything, for when that's what you want.
**`rm` was dangerous in a new way.** Over the console, `rm <folder>` had always removed the folder and everything in it, which is fine for a script and less fine for a thumb on a small keyboard. It's now Unix's: a folder needs `-r`, and in the Shell it asks unless you say `-f`.
**Tab did one word.** It now follows the firmware's own `help` text, word by word: `lora st` becomes `lora status`, `gnss track ` lists `start stop`. The words are read from the help text as it is written, so a new command completes without anyone maintaining a table. Past the command, it completes paths on the SD card. And `*` and `?` work in file names.
{{ figure(src="rm.png", alt="The Cardputer's screen at 2x: a dialog titled Delete? reading The 5 that match /gt2/*. It can't be undone. with two buttons, Cancel selected and Delete.", width=480, height=270, caption="`rm /gt2/*` in the Shell: one question for all five, with Cancel selected. `/gt2` is a scratch folder, made for the purpose.") }}
One more addition, small and my favourite: **an App's name with a capital letter opens it.** `Notes`, `Irc`, `Storage`. Every command is lowercase, so the capital is the whole syntax.
## It said "No"
The Shell's first version ran each command from inside the key handler. I test the UI by sending key presses over the Debug Console, so the call chain was: the main loop, a remote command, a key, the App manager, the Shell, the command interpreter *a second time*, the file command, and `printf` under all of it. The main loop has under 2 KB of stack to spare. `rm` on a folder went past it.
The device crashed, and restarted, as it should. It came back up in the Launcher.
My test script didn't know. It had a list of keys to send and it sent them. Its next Enter, meant for the Shell, landed in the Launcher and opened the first App in the list. That is IRC, which connected, as it's configured to, and joined its channels.
A few lines later the script reached its test of the capital-letter feature: type `No`, press Tab to complete it to `Notes`, press Enter. Tab completes nothing in IRC. Enter sends.
One word, to a channel of real people, from my nick. Not harmful, not explainable either, and not something any commit can take back.
Two things changed that afternoon:
- **The Shell hands its line to the main loop**, which runs it at the same depth as any console command. The crash is gone, and the crash report had decoded to exactly that chain of calls.
- **`info` reports the App in front**, and the test helper checks it before every line it types. A script that survives a restart is typing somewhere else, and now it stops.
The rule I'd had since the day before was "take a screenshot before any key that deletes something". It was the right rule for the wrong failure. Typing is also an action.
## A megabyte in 17 KB
The Notes editor held the whole note in memory and stopped at 16 KB. That was a limit for the first version only; [F1's notes](/dev/milestones/f1/) say so in bold.
The device has no spare memory to throw at this, so the design is the old one from editors that ran on less: **the note is the file on the card, plus one window in memory.** The window is about 8 KB around the cursor. Everything else is a list of pieces: "bytes 0 to 40,000 of the file", "then 9,000 bytes of what was typed". When the cursor nears the window's edge, the window is written away if it changed, and the next one is loaded.
What makes it usable is what it writes, and when:
{% table() %}
| | Up to 64 KB | Above |
|---|---|---|
| The save, five seconds after the last key | The whole file, as before | What changed, appended to `<note>.edit`: about 4 KB |
| Leaving the note | Nothing more to do | The file is rewritten, with a progress bar |
| After a power cut | The note as last saved | The note opens with the saved changes back |
{% end %}
{{ figure(src="saving.png", alt="The Cardputer's screen at 2x, all black with Saving in blue, the file name zz-big.txt, a progress bar a little over half full, and 60%.", width=480, height=270, caption="Leaving a 1.2 MB note. This takes 2.6 seconds, which is long enough to deserve a bar and short enough that I had to race the screenshot.") }}
Memory with a note open is 17.5 KB, for a note of 62 bytes or of 1.2 MB. Going to the end of the megabyte takes as long as any other key.
### The part that has to be right
An editor that loses text is worse than no editor, and this one now has a side file, a temporary file and the note itself, any of which can be half written when the power goes. So the rewrite ends with a mark: once the complete new file is on the card, one small write to the side file says "done". Before that mark, the old note and its side file are the truth. After it, the new file is, and whatever was interrupted is finished the next time the note is opened.
That is a claim, and it's the kind I don't trust until something has tried to break it. Two tests do:
- **A power cut at every 997th byte** of two saves and a rewrite. After each, the note has to be one of exactly three texts, the one that was reported as saved has to be there, and no stray file may be left.
- **36,000 random keys** on six notes, typing, deleting, moving, jumping, saving and cutting the power, compared with a plain string after every key.
They pass. But the first run had three failures, and they're worth a line each, because only one of them was the editor's:
1. I had worked out by hand where the cursor should be after a recovery, and got it wrong by four.
2. I had assumed windows would break between groups of three characters in my test text. They break between characters, which is all they promise.
3. **A file replaced by a shorter one lost its pending edits without a word.** The design says they are set aside as `.edit.lost` and the editor tells you. The code checked the pieces against the new file's length first, found them out of range, concluded there was nothing valid to keep, and deleted them. A real bug, in exactly the path that exists to never delete typed text.
Two of three were the test being wrong. The third is why the tests exist.
{{ figure(src="back.png", alt="The Cardputer's Notes editor at 2x, showing zz-big.txt, 1.1 MB, saved. The first line reads YTOP line 000000, followed by line 000001 to line 000007. At the bottom, in orange: Your unsaved changes are back.", width=480, height=270, caption="After a restart in the middle of a rewrite. The `Y` was typed, saved to the side file, and the device was reset while it was writing the megabyte. It's there.") }}
### What I had promised, and what I measured
I'd said the rewrite would take about two and a half seconds a megabyte. The first build took 3.5 to 4.5 seconds for 1.2 MB. It was copying in 2 KB blocks. With 4 KB blocks it takes 2.6, about 450 KB a second, which is what this card gives a plain copy.
## A site that publishes itself
Until this morning, publishing this site meant logging into the web server and running a script, by hand, after every merge.
Now CI does it, after a merge and after a release. The interesting part is what the key in CI is allowed to do, which is one thing:
{% code(caption="One line of `authorized_keys` on the web server. Whatever the client asks for, this runs instead.") %}
```
from="<the runner>",restrict,command="/path/to/rororefresh.sh" ssh-ed25519 AAAA… roro9stack-ci
```
{% end %}
My first plan had the command as a secret in CI, next to the key. With a forced command there is nothing to keep secret: CI connects and sends no command at all, and a stolen key can refresh the website and do nothing else. The server's address, the user, the key and the server's host key are secrets; the repository is public and none of them is in it.
Checked against a throwaway SSH server before the real one: asking for `id; cat /etc/passwd` runs the refresh. No terminal. `scp` copies nothing. A different host key stops the run.
On its first real run, the live page changed fifteen seconds after the run started. This post got here that way.
## The device was right
A pattern from the day, three times over.
**The keys that vanished.** Twice, the first key my script sent after a quiet minute did nothing. The [F1 notes](/dev/milestones/f1/) describe that exact bug, fixed. I wrote it up as a possible regression. It isn't: a key that wakes a dark screen only wakes it, same as on the real keyboard. That's documented, on a page of this site, which I wrote.
**The letters in the wrong place.** In the editor test I typed two letters at the top of a note, moved 400 lines down, typed four more, fetched the file and compared it with what I expected. It differed: `liMID ne 000400` where I expected `MID line 000400`. The file matched the screen exactly. Moving down keeps the cursor's column, as it should, and I had typed two letters first.
**The "No".** The device did what it was sent. Every key arrived, in order.
Three times the instrument was right and the reading was wrong. The remote `key` command now takes `ctrl-`, `alt-` and `shift-`, by the way, because checking the editor's jump to the end of a note needed Ctrl, and "the remote `key` command can't send that" had been in the not-checked list of every milestone since the editor existed.
## What I didn't check
- **The real keyboard**, for Fn+h, `?`, Ctrl+b and the Alt scroll. Everything was driven by remote keys.
- **The power button's path** in the editor, which writes the side file only, and the screen turning off.
- **Memory with IRC connected** and a long note open. After the morning, I didn't connect IRC.
- **A file of tens of megabytes.**
- Renaming or deleting a note from the Storage App leaves its side file behind. The Notes App handles both.
## By the numbers
{% table() %}
| | |
|---|---|
| Releases | 3 |
| Design questions | 37 |
| Host tests | 507 |
| A pull request's CI run, before and after | over 7 min, about 1 |
| Seconds spent rebuilding what was already built, per run | 260 |
| Flash the Shell costs | 21 KB |
| Flash notes of any size cost | 15 KB |
| Memory with a note open, 62 bytes or 1.2 MB | 17.5 KB |
| Bytes a long note's save writes | about 4,000 |
| Random keys the editor was compared against a string for | 36,000 |
| Bugs those tests found in the editor | 1 |
| Bugs they found in my arithmetic | 2 |
| Words sent to an IRC channel | 1 |
{% 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. ~~One help key, and CI in a minute.~~ v0.13.0, this post.
10. ~~The Shell.~~ v0.14.0, this post.
11. ~~Notes of any size, and a site that publishes itself.~~ v0.15.0, this post.
12. Next: M4, the mesh, which still wants a second node. And the editor, now that it opens anything, plainly lacks undo.
{% end %}
{% signoff() %}
The last post promised three things and this one delivers them, which would be a tidy story if a script of mine hadn't said "No" to a room of strangers halfway through. The device did nothing wrong all day. It crashed where I had written a crash, restarted as designed, and typed what it was sent.
{% end %}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+19 -1
View File
@@ -6,6 +6,10 @@ template = "guide-page.html"
toc = true
+++
## Which keys work on this screen?
Press **<kbd>Fn</kbd> + <kbd>h</kbd>**, on any screen: it lists the keys that work there, then the ones that work everywhere. <kbd>?</kbd> does the same when you are not typing text. The screens themselves never name their keys, so this is the one key to remember. See [The basics](/guide/basics/).
## Can I send messages over the mesh?
**Not yet.** The LoRa Scanner **listens** to Meshtastic traffic and shows what it hears, but nothing is transmitted. A mesh messenger is the goal and is planned in two milestones, one for receiving and one for transmitting; it waits for a second node to test against.
@@ -49,19 +53,33 @@ 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/).
## How big can a note be?
Up to 16 KB while it is edited. A larger text file opens read-only in the [Storage App](/guide/storage/). Editing a text file of any size is planned.
Any size the card has room for. The editor only keeps the part around the cursor in memory, so a megabyte of text opens at once. A long note is rewritten when you leave it, which takes about a second for each 450 KB. See [Long notes](/guide/notes/#long-notes).
## Why can't I rename or delete some folders?
+3 -1
View File
@@ -6,10 +6,12 @@ sort_by = "weight"
page_template = "guide-page.html"
+++
This guide says what the firmware does **today** and nothing else. Start with the basics (the keys, the Launcher, the first start), then read the page of any App. It describes the latest release; the numbers and key names come from the firmware's own source.
This guide says what the firmware does **today** and nothing else. Start with the basics (the keys, the Launcher, the first start), then read the page of any App. **On the device itself, <kbd>Fn</kbd> + <kbd>h</kbd> lists the keys of whatever screen you are on:** nothing else on a screen names them. It describes the latest release; the numbers and key names come from the firmware's own source.
**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.
+22 -4
View File
@@ -4,8 +4,17 @@ 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
**<kbd>Fn</kbd> + <kbd>h</kbd>, on any screen, lists the keys that work there.** No screen names its keys: that key does. It works everywhere, in a text field too, and <kbd>?</kbd> does the same whenever you are not typing. The arrows scroll the list; any other key closes it.
The list is for *the screen you are on*: in Storage it is the file keys, in a dialog it is the dialog's, in a text field it is the editing keys. Each list ends with the keys that work everywhere.
If you only read one paragraph of this guide, this was it.
## The keys
The Cardputer's keyboard has no arrow keys and no Escape, so the firmware gives a few keys a second job:
@@ -17,11 +26,13 @@ The Cardputer's keyboard has no arrow keys and no Escape, so the firmware gives
| <kbd>Fn</kbd> + <kbd>`</kbd> | **Home**: back to the Launcher |
| <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 é |
While you type text (a note, an IRC line, a setting), `;` `.` `,` `/` type their own characters and you need <kbd>Fn</kbd> for the arrows. The bottom of the screen shows `opt` while a compose is waiting for its letter.
While you type text (a note, an IRC line, a setting), `;` `.` `,` `/` type their own characters and you need <kbd>Fn</kbd> for the arrows. The Status Bar shows `opt` while a compose is waiting for its letter.
## The Launcher
@@ -38,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 |
@@ -50,7 +62,7 @@ News from a background service (an IRC mention, a Storage warning, an update tha
## The first start
On a new device a short Setup asks four things, then never appears again:
On a new device a short Setup asks four things, then never appears again. It also tells you about <kbd>Fn</kbd> + <kbd>h</kbd>, twice, and it is the only part of the firmware that names keys on the screen:
1. **Long name**, up to 39 bytes.
2. **Short name**, up to 4 characters.
@@ -59,6 +71,12 @@ On a new device a short Setup asks four things, then never appears again:
## The SD card
Put a microSD card in the Cardputer. Without one the radio, GNSS, Wi-Fi and IRC still work, but nothing can be saved: no notes, IRC logs, Wi-Fi scan logs, GPX tracks, LoRa captures or saved Gemini pages. The [Storage App](/guide/storage/) shows what is on the card.
Put a microSD card in the Cardputer. Without one the radio, GNSS, Wi-Fi and IRC still work, but nothing can be saved: no notes, IRC logs, Wi-Fi scan logs, GPX tracks, LoRa captures, screenshots or saved Gemini pages. The [Storage App](/guide/storage/) shows what is on the card.
The firmware keeps its own folders at the top of the card (`captures`, `gemini`, `gnss`, `irc`, `updates`, `wifi`, plus `notes`). You can use the card in a computer too, but those names are the firmware's.
The firmware keeps its own folders at the top of the card (`captures`, `gemini`, `gnss`, `irc`, `notes`, `screenshots`, `updates`, `wifi`). You can use the card in a computer too, but those names are the firmware's.
## The keys, as the device lists them
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=["launcher", "everywhere", "dialog", "text"]) }}
+6
View File
@@ -40,3 +40,9 @@ Most capsules sign their own certificate. The first certificate seen for a host
## Big pages and memory
With a card, every page streams to the card first, so a page larger than the device's memory still arrives whole and is read from the card as you scroll. Without a card, a page is limited to what fits in memory. If there is not enough free memory to open a secure connection, the fetch says so instead of failing silently; stopping IRC frees the most.
## The keys, as the device lists them
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=["gemini", "gemini-saved", "gemini-address", "gemini-answer"]) }}
+6
View File
@@ -28,3 +28,9 @@ Once there is a fix, the device's clock follows it.
## Tracks
<kbd>r</kbd> starts recording a **Track**: the route is saved as a **GPX** file on the SD card, in `/gnss/tracks` (named by date and time), and the Status Bar shows `REC`. Press <kbd>r</kbd> again to stop. A Track keeps recording with the App closed. It needs a card and a clock (a fix or Wi-Fi sets it); if it cannot start, the App says why: `GNSS is off`, `No SD card` or `Waiting for the time`. The [Storage App](/guide/storage/) opens a `.gpx` file and shows its points, start, duration and distance.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["gnss"]) }}
+6
View File
@@ -49,3 +49,9 @@ Every buffer is logged to the SD card, one file per day, under `/irc`. Logs stop
- IRC **pauses** while Wi-Fi is monitoring (the Status Bar shows `MON`) and while the device **installs an update**. It reconnects and rejoins its channels afterwards; the App says `paused` in the meantime.
- A secure connection costs memory: IRC's takes about 40 KB of the 107 KB the device has, and an update's download needs about 52 KB more. That is why IRC steps aside for an update, and why the daily update check waits for IRC to be disconnected (see [Updates](/guide/updates/)).
## The keys, as the device lists them
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=["irc", "irc-settings", "irc-field"]) }}
+13
View File
@@ -0,0 +1,13 @@
+++
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 = 14
[extra]
tag = "Reference"
+++
On the device, <kbd>Fn</kbd> + <kbd>h</kbd> lists the keys of the screen you are on (and <kbd>?</kbd> does, when you are not typing). This page is all of those lists at once, generated from the same source the firmware reads: `lib/core/src/app_keys.h`.
In the tables, `; . , /` are the arrow keys (up, down, left, right): alone when you are not typing, with <kbd>Fn</kbd> when you are. `` ` `` is Back, `Aa` is Shift, and two keys separated by spaces are two keys that do the two things listed.
{{ keys(all=true) }}
+6
View File
@@ -33,3 +33,9 @@ A capture records packets into a **pcap** file with LoRaTap headers in `/capture
## The GNSS receiver raises the noise
The GNSS receiver on the same Cap makes the radio's noise floor about **8 dB** worse while it runs. **Settings → Pause GNSS for LoRa** (off by default) puts the receiver on standby while the radio listens, except while a Track is being recorded.
## The keys, as the device lists them
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=["lora", "lora-packet", "lora-presets", "lora-sweep"]) }}
+20 -2
View File
@@ -22,7 +22,7 @@ Each note shows its first line and its date, newest first. <kbd>s</kbd> switches
## The editor
Type. <kbd>Enter</kbd> starts a line and <kbd>Del</kbd> deletes backwards. <kbd>Fn</kbd> with the arrow keys moves the cursor through the wrapped text, <kbd>Ctrl</kbd>+<kbd>A</kbd> and <kbd>Ctrl</kbd>+<kbd>E</kbd> go to the start and the end of the line, <kbd>Tab</kbd> types two spaces, and the compose key gives accents as everywhere.
Type. <kbd>Enter</kbd> starts a line and <kbd>Del</kbd> deletes backwards. <kbd>Fn</kbd> with the arrow keys moves the cursor through the wrapped text, <kbd>Ctrl</kbd>+<kbd>A</kbd> and <kbd>Ctrl</kbd>+<kbd>E</kbd> go to the start and the end of the line, <kbd>Ctrl</kbd> with <kbd>Fn</kbd> and up or down to the start and the end of the note, <kbd>Tab</kbd> types two spaces, and the compose key gives accents as everywhere.
**There is no save key.** The note is written five seconds after your last key, when you press Back, when you leave the App, when the screen turns off and before the device powers off. The top line says `typing` or `saved`.
@@ -32,6 +32,24 @@ Each save writes a temporary file and then puts it in the note's place, so a pow
A new note has no file until you type something. The file is then named after its first line (`shopping-list.txt`), or `note-<date>-<time>.txt` if that line gives no usable name.
## Long notes
**A note can be any size.** The editor keeps the part around the cursor in memory and the rest on the card, so a file of a megabyte opens as fast as a short one and uses no more memory.
What changes with size is how it is saved:
- **Up to 64 KB**, every save rewrites the file, as above.
- **Above**, the five-second save writes only what you changed, to a file next to the note (`<note>.edit`). The note itself is rewritten **when you leave it**, with a progress bar: about a second for each 450 KB.
- **After a power cut**, or if the device was switched off with the note open, opening the note again brings your saved changes back, and says so. Until then the file itself still has the old text, if you look at it from a computer.
Saving a long note needs room on the card for a second copy of it. If the file was replaced by something else while its changes were waiting, they can't be applied: they are kept as `<note>.edit.lost` and the editor tells you.
## Limits
A note holds up to **16 KB** while it is edited. A bigger text file opens read-only in the [Storage App](/guide/storage/); editing a file of any size is planned. In Storage, <kbd>e</kbd> on a text file opens it in the same editor, anywhere on the card, unless the file is read-only. Notes are never offered for deletion by the clean-up.
In Storage, <kbd>e</kbd> on a text file opens it in the same editor, anywhere on the card, unless the file is read-only. Notes are never offered for deletion by the clean-up.
## The keys, as the device lists them
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=["notes", "notes-editor", "notes-name"]) }}
+9 -2
View File
@@ -1,7 +1,7 @@
+++
title = "Settings"
description = "The device's names, region, screen, sound, GNSS and Wi-Fi, and where firmware updates are found."
weight = 10
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 |
@@ -41,3 +42,9 @@ A network normally gives the device its address by itself (DHCP, **Automatic**).
### DNS and NTP
Two DNS servers (9.9.9.9 and 1.1.1.1 by default), used on Fixed networks, or on every network if **Always use my DNS** is on; and two NTP servers (pool.ntp.org and time.cloudflare.com), used after any that the network's DHCP offers. <kbd>Enter</kbd> on **Status** shows what is in use and where each value came from.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["settings", "settings-choice", "wifi", "wifi-servers", "wifi-network", "wifi-status", "wifi-scan", "wifi-name", "vpn", "debug-console"]) }}
+91
View File
@@ -0,0 +1,91 @@
+++
title = "Shell"
description = "The firmware's own commands, typed on the device: look at its state, the SD card, the radio and the network with no PC and no cable."
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.
It can do real damage: `rm` deletes, `reboot` restarts, `debug on` opens the device to the network. It is the same trust as holding the device, and nothing more.
## Using it
Type a command and press <kbd>Enter</kbd>. `help` lists them all; the [command reference](/dev/debug/commands/) says what each does. A few to start with:
| Command | Shows |
|---|---|
| `info` | The firmware's version, uptime, memory, Wi-Fi, the SD card and both firmware slots |
| `wifi status` | The network, the address, and where the DNS and time servers came from |
| `ls /notes` | A folder of the SD card, with sizes and dates |
| `crash` | The last crash, if there was one |
| `update check` | Whether a newer release exists |
| `lora status` | What the radio is set to and what it has heard |
- <kbd>Tab</kbd> **completes** what you are typing: the command, word by word (`lora st` gives `lora status`, and `gnss track ` with Tab lists `start stop`), then **a path on the SD card**. `ls /no` and Tab gives `ls /notes/`; Tab again goes on inside the folder. If several names fit, it completes as far as they agree and lists them. You can type the name in any case, and after a file command you can leave out the first slash: `cat no` and Tab gives `cat /notes/`. A name with a space in it isn't completed.
- <kbd>Fn</kbd> with up and down brings back **lines you typed before**.
- <kbd>Alt</kbd> with up and down **scrolls back** through what was printed.
- `clear` empties the screen, and `quit` (or Back) leaves.
## What you see
**The replies to your own commands, and nothing else.** The firmware prints a lot besides: IRC connecting, a packet received, whatever a PC on the USB port or the Debug Console is asking for. None of that reaches the Shell. The firmware knows who each line is printed for, so an answer that comes a moment later from another part of it (a folder listing, `tasks`) is still yours.
<kbd>Ctrl</kbd> + <kbd>b</kbd> shows **everything** the firmware prints instead, and `all` shows in the corner. Press it again to go back.
## Opening an App
Type an App's name **with a capital letter** to open it, without going back to the Launcher:
`Irc` `Wifi` `Gnss` `Gemini` `Lora` `Storage` `Notes` `System` `Settings`
The capital is the difference: every command is in small letters, every App starts with a capital. <kbd>Tab</kbd> completes them too.
## Deleting
`rm` works as it does on Unix, with one addition: it asks.
| You type | What happens |
|---|---|
| `rm /notes/a.txt` | Asks, then deletes the file |
| `rm -f /notes/a.txt` | Deletes it without asking |
| `rm /captures/old` | Refused: it is a folder, and a folder needs `-r` |
| `rm -r /captures/old` | Removed at once if it is empty. If not, asks first |
| `rm -rf /captures/old` | Removed with everything in it, without asking |
## Several files at once
`*` stands for any run of characters in a name and `?` for exactly one, in `ls`, `du`, `rm`, `cp` and `mv`:
| You type | What happens |
|---|---|
| `ls /notes/*.txt` | Only the notes ending in `.txt` |
| `du /captures/lora/*.pcap` | The size of each capture |
| `cp /gnss/2026-10-0?.gpx /backup` | Copies the tracks of the 1st to the 9th into `/backup`, which must exist |
| `rm /screenshots/2026*` | Asks **once**, saying how many match, then deletes them |
| `rm -f /screenshots/*` | Deletes them all without asking |
The pattern goes in the **last part** of the path (`/notes/*.txt`, not `/*/a.txt`), and capitals don't matter. A folder that matches is left alone by `rm` unless you add `-r`. At most 64 names at a time: past that nothing is done, and you are asked for a narrower pattern. `cancel` stops what is left.
The folders the firmware keeps its own files in can't be removed, as in the [Storage App](/guide/storage/).
## Screenshots
```
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, 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
Nothing while it is closed. Open, about 7 KB of memory, given back when you leave: with IRC connected and a Gemini page open, that can be the difference (see [the memory limit](/howto/not-enough-memory/)).
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on this screen. This table is generated from the firmware's own lists, so it is always the current one.
{{ keys(scopes=["shell"]) }}
+36 -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.
@@ -13,7 +13,7 @@ Storage shows what is on the SD card: each folder's entries with their size and
| Key | Does |
|---|---|
| <kbd>c</kbd> / <kbd>x</kbd> | Copies or cuts the selected file or folder; the footer shows what <kbd>v</kbd> would paste |
| <kbd>c</kbd> / <kbd>x</kbd> | Copies or cuts the selected file or folder; the footer shows what is waiting to be pasted |
| <kbd>v</kbd> | Pastes it into the folder shown. A copy next to its original is named `name (2).txt`; anything in the way is asked about first |
| <kbd>r</kbd> | Renames |
| <kbd>d</kbd> | Deletes, after saying what is inside: "Delete saved and its 42 files (1.2 MB)?" |
@@ -34,14 +34,47 @@ 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.
The firmware warns once per start when the card passes **80%** full; past **90%**, logs stop being written so that the rest is kept for captures.
## The keys, as the device lists them
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", "viewer-image", "storage-share"]) }}
+7 -1
View File
@@ -1,7 +1,7 @@
+++
title = "System"
description = "What the device is doing right now: load, tasks, memory, network traffic, battery and temperature. Live, and read-only."
weight = 9
weight = 10
[extra]
tag = "System"
screens = ["system.png"]
@@ -18,3 +18,9 @@ System changes nothing: it shows. It samples once a second and keeps its history
## Why it exists
The device has about 107 KB of free memory and no PSRAM, so memory is the resource that decides what can run together: a secure connection takes about 52 KB at its peak. System makes that visible, and is how the project measures its own changes.
## The keys, as the device lists them
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=["system", "system-tasks", "system-system"]) }}
+7 -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 = 11
weight = 13
[extra]
tag = "Firmware"
screens = ["update.png"]
@@ -42,3 +42,9 @@ The connection to the project's server is checked against the two root certifica
## For developers
Updates can also be pushed from a PC over Wi-Fi, with `scripts/flash.sh --ota <ip>`, or put on the card with `scripts/sd_put.sh`: see the [README](https://git.twis.la/twisla/roro9stack#firmware-updates-over-wi-fi-ota). The firmware's console can be reached over Wi-Fi too: see [The Debug Console](/dev/debug/).
## The keys, as the device lists them
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=["firmware", "firmware-release", "firmware-older"]) }}
+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"]) }}
+6
View File
@@ -34,3 +34,9 @@ One bar for each of the 13 Wi-Fi channels shows how busy it is: how many network
## Signal tracker
Follows one network: its name, address and channel, then the signal in dBm in large type, a strength bar, and a graph of the recent readings, so you can walk towards the strongest signal. <kbd>m</kbd> turns audible clicks on and off. If the network is no longer heard the screen says `lost`. <kbd>`</kbd> (Back) returns to the list.
## The keys, as the device lists them
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=["wifi-tools", "wifi-networks", "wifi-tracker"]) }}
+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 -1
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,6 +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](/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.
+5
View File
@@ -0,0 +1,5 @@
+++
title = "Search"
description = "Find a word in the user guide, the how-tos, the questions and answers, and the developer docs."
template = "search.html"
+++
+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
+563
View File
@@ -0,0 +1,563 @@
# Generated by site/tools/gen_dev_docs.py from lib/core/src/app_keys.h: the keys of every screen, as the
# help panel (Fn+h) lists them on the device. Edit that header, not this file.
[[scope]]
id = "everywhere"
title = "Everywhere"
rows = [
["`", "back"],
["Fn `", "home, the Launcher"],
["; . , /", "arrows (Fn+ while typing)"],
["Fn h ?", "these keys (? not typing)"],
["Fn p", "a screenshot, on the card"],
]
[[scope]]
id = "dialog"
title = "A question"
rows = [
[", /", "the other answer"],
["Enter", "choose it"],
["`", "cancel"],
]
[[scope]]
id = "text"
title = "A text field"
rows = [
["Enter", "save"],
["`", "cancel"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
["opt ' e", "an accent: é"],
]
[[scope]]
id = "launcher"
title = "The Launcher"
rows = [
["; .", "up, down"],
["Enter", "open the App"],
]
[[scope]]
id = "setup"
title = "Setup, a step"
rows = [
["Enter", "continue"],
["`", "the step before"],
]
[[scope]]
id = "setup-choice"
title = "Setup, a choice"
rows = [
["; .", "up, down"],
["Enter", "choose it, next step"],
["`", "the step before"],
]
[[scope]]
id = "setup-text"
title = "Setup, a name"
rows = [
["Enter", "next step"],
["`", "the step before"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
["opt ' e", "an accent: é"],
]
[[scope]]
id = "irc"
title = "IRC"
rows = [
["Enter", "send the line"],
["Tab", "the next buffer"],
["Alt ; .", "scroll back, forward"],
["Fn ; .", "lines you sent before"],
["Fn , /", "move the cursor"],
["Del", "delete backwards"],
["/settings", "server, nick, passwords"],
["/join #x", "join a channel"],
["/part", "leave it"],
["/msg nick", "a private chat"],
["/me", "an action"],
["/nick", "change your nick"],
["/topic", "see or set the topic"],
["/names", "who is there"],
["/quit", "disconnect, and stay so"],
["/raw", "a line as it is"],
["`", "leave: IRC stays connected"],
]
[[scope]]
id = "irc-settings"
title = "IRC settings"
rows = [
["; .", "up, down"],
["Enter", "edit, switch, or save"],
["`", "leave without saving"],
]
[[scope]]
id = "irc-field"
title = "IRC, a setting"
rows = [
["Enter", "keep it"],
["`", "cancel"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
]
[[scope]]
id = "wifi-tools"
title = "Wi-Fi Tools"
rows = [
["; .", "up, down"],
["Enter", "open"],
]
[[scope]]
id = "wifi-networks"
title = "Networks nearby"
rows = [
["; .", "up, down"],
["Enter", "track its signal"],
["s", "sort: signal, channel, name"],
["o", "open networks only"],
["h", "hide the hidden ones"],
["w", "strong ones only"],
["l", "log the scans to the card"],
]
[[scope]]
id = "wifi-tracker"
title = "Signal tracker"
rows = [
["m", "clicks on or off"],
]
[[scope]]
id = "gnss"
title = "GNSS"
rows = [
["Tab", "the position, or the sky"],
["r", "record a Track, or stop it"],
]
[[scope]]
id = "gemini"
title = "Gemini, a page"
rows = [
["Tab", "the next link"],
["Aa Tab", "the link before"],
["Enter", "follow the link"],
["` Del", "the page before"],
["; .", "scroll"],
["Space", "a page down"],
[", /", "sideways, in wide blocks"],
["g", "type an address"],
["b", "bookmark this page"],
["s", "save the page to the card"],
["S", "...with the pages it links to"],
]
[[scope]]
id = "gemini-saved"
title = "Gemini, a Saved Page"
rows = [
["Tab", "the next link"],
["Aa Tab", "the link before"],
["Enter", "follow the link"],
["` Del", "the page before"],
["; .", "scroll"],
["Space", "a page down"],
[", /", "sideways, in wide blocks"],
["g", "type an address"],
["b", "bookmark this page"],
["r", "refresh this Saved Page"],
["d", "delete this Saved Page"],
]
[[scope]]
id = "gemini-address"
title = "Gemini, an address"
rows = [
["Enter", "go there"],
["`", "cancel"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
]
[[scope]]
id = "gemini-answer"
title = "Gemini, an answer to a page"
rows = [
["Enter", "send it"],
["`", "cancel"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
]
[[scope]]
id = "lora"
title = "LoRa Scanner, the packets"
rows = [
["; .", "up, down"],
["Enter", "the packet's details"],
["p", "pick a Meshtastic preset"],
["c", "start a Capture, or stop it"],
["Tab", "the Sweep"],
]
[[scope]]
id = "lora-packet"
title = "LoRa Scanner, a packet"
rows = [
["; .", "scroll"],
["Enter", "back to the list"],
]
[[scope]]
id = "lora-presets"
title = "LoRa Scanner, the presets"
rows = [
["; .", "up, down"],
["Enter", "listen with this preset"],
]
[[scope]]
id = "lora-sweep"
title = "LoRa Scanner, the Sweep"
rows = [
["Tab", "the Sniffer"],
]
[[scope]]
id = "storage"
title = "Storage, a folder"
rows = [
["; .", "up, down"],
["Enter", "open the folder or the file"],
[", /", "a page up, down"],
["c x", "copy, cut"],
["v", "paste here"],
["r", "rename"],
["d Del", "delete, after asking"],
["n", "a new folder"],
["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"
rows = [
["; .", "scroll"],
["Enter", "back to the folder"],
]
[[scope]]
id = "storage-name"
title = "Storage, a name"
rows = [
["Enter", "rename it, or make the folder"],
["`", "cancel"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
]
[[scope]]
id = "storage-busy"
title = "Storage, while it copies or deletes"
rows = [
["`", "stop the copy or the delete"],
]
[[scope]]
id = "maintenance"
title = "Storage, Maintenance"
rows = [
["; .", "up, down"],
["Enter", "open, or choose"],
]
[[scope]]
id = "viewer-text"
title = "A file, as text"
rows = [
["; .", "a line up, down"],
[", /", "a page up, down"],
["t b", "the top, the end"],
["e", "edit it"],
["Tab", "the file as hex, or back"],
]
[[scope]]
id = "viewer-hex"
title = "A file, as hex"
rows = [
["; .", "a line up, down"],
[", /", "a page up, down"],
["t b", "the top, the end"],
["Tab", "the file as text, or back"],
]
[[scope]]
id = "viewer-pcap"
title = "A Capture"
rows = [
["; .", "up, down"],
["Enter", "the packet"],
[", /", "a page up, down"],
["Tab", "the file as hex"],
]
[[scope]]
id = "viewer-packet"
title = "A Capture's packet"
rows = [
["; .", "scroll"],
["Enter", "back to the packets"],
]
[[scope]]
id = "viewer-gpx"
title = "A Track"
rows = [
["Tab", "the file as text"],
]
[[scope]]
id = "viewer-ota"
title = "An Update File"
rows = [
["Enter", "install it, if it's genuine"],
["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"
rows = [
["; .", "up, down"],
["Enter", "open the note"],
[", /", "a page up, down"],
["n", "a new note"],
["r", "rename its file"],
["d Del", "delete it"],
["s", "sort: newest, or by name"],
]
[[scope]]
id = "notes-editor"
title = "Notes, the editor"
rows = [
["Enter", "a new line"],
["Del", "delete backwards"],
["Tab", "two spaces"],
["Fn ; . , /", "move the cursor"],
["Alt Fn ; .", "a page up, down"],
["Ctrl Fn ; .", "start, end of the note"],
["Ctrl a e", "start, end of the line"],
["opt ' e", "an accent: é"],
["`", "done: it saves by itself"],
]
[[scope]]
id = "notes-name"
title = "Notes, a file name"
rows = [
["Enter", "rename the file"],
["`", "cancel"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
]
[[scope]]
id = "shell"
title = "Shell"
rows = [
["Enter", "run the line"],
["Tab", "complete: a command, a path"],
["* ?", "several files: /notes/*.txt"],
["Fn ; .", "lines you typed before"],
["Alt ; .", "scroll back, forward"],
["Ctrl b", "your replies only, or all"],
["Fn , /", "move the cursor"],
["Del", "delete backwards"],
["help", "every command"],
["clear", "an empty screen"],
["Notes", "an App, by its name"],
["rm -rf", "delete without being asked"],
["quit `", "leave the Shell"],
]
[[scope]]
id = "system"
title = "System, any view"
rows = [
["Tab", "the next view"],
["Aa Tab", "the view before"],
]
[[scope]]
id = "system-tasks"
title = "System, the tasks"
rows = [
["Tab", "the next view"],
["Aa Tab", "the view before"],
["; .", "scroll"],
["s", "sort: cpu, stack, name"],
]
[[scope]]
id = "system-system"
title = "System, the system view"
rows = [
["Tab", "the next view"],
["Aa Tab", "the view before"],
["; .", "scroll"],
]
[[scope]]
id = "settings"
title = "Settings"
rows = [
["; .", "up, down"],
["Enter", "edit, or open the page"],
[", /", "change a switch or a slider"],
]
[[scope]]
id = "settings-choice"
title = "Settings, a choice"
rows = [
["; .", "up, down"],
["Enter", "choose it"],
]
[[scope]]
id = "wifi"
title = "Settings, Wi-Fi"
rows = [
["; .", "up, down"],
["Enter", "open, or change"],
[", /", "switch Wi-Fi on or off"],
]
[[scope]]
id = "wifi-servers"
title = "Settings, DNS and NTP"
rows = [
["; .", "up, down"],
["Enter", "edit"],
[", /", "Always use my DNS: on, off"],
]
[[scope]]
id = "wifi-network"
title = "Settings, a saved network"
rows = [
["; .", "up, down"],
["Enter", "edit, or forget"],
[", /", "Automatic or Fixed"],
]
[[scope]]
id = "wifi-status"
title = "Settings, the Wi-Fi status"
rows = [
["Enter", "back"],
]
[[scope]]
id = "wifi-scan"
title = "Settings, adding a network"
rows = [
["; .", "up, down"],
["Enter", "choose this network"],
]
[[scope]]
id = "wifi-name"
title = "Settings, a hidden network's name"
rows = [
["Enter", "next: the password"],
["`", "cancel"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
]
[[scope]]
id = "firmware"
title = "Settings, Firmware"
rows = [
["; .", "up, down"],
["Enter", "check, open, or install"],
["c", "look for a newer release"],
]
[[scope]]
id = "firmware-release"
title = "Settings, a release"
rows = [
["; .", "scroll"],
["Enter", "install it"],
["c", "check again"],
]
[[scope]]
id = "firmware-older"
title = "Settings, older releases"
rows = [
["; .", "up, down"],
["Enter", "its details"],
["c", "read the list again"],
]
[[scope]]
id = "debug-console"
title = "Settings, Debug Console"
rows = [
["; .", "up, down"],
["Enter", "switch, or open"],
[", /", "switch the console on or off"],
]
[[scope]]
id = "demo"
title = "The widget demo"
rows = [
["; .", "up, down"],
["Enter", "try the widget"],
]
+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)"
+32
View File
@@ -254,3 +254,35 @@ footer small { display: block; max-width: 760px; }
.source { max-width: 820px; font-size: 14px; line-height: 20px; }
.source code { background: var(--s2); padding: 0 6px; }
.card.featured { flex-basis: 100%; }
/* The keys of a screen, as the device's help panel lists them (the `keys` shortcode) */
.keys { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px 32px; }
.keys-scope { background: var(--s2); padding: 16px 20px; }
.keys-scope h4 { font: 500 12px/16px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--cyantext); margin: 0 0 8px; }
.prose .keys table { width: 100%; }
.prose .keys td { padding: 4px 8px 4px 0; border-bottom: 0; font-size: 15px; }
.prose .keys td:first-child { white-space: nowrap; width: 1%; padding-right: 16px; }
.prose .keys kbd { white-space: pre; }
/* Search (issue #60): the search page's box, its results and its index; the small box on the
documentation's index pages. */
.search-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 24px 0 8px; max-width: 720px; }
.search-form label { flex-basis: 100%; font: 400 14px/20px var(--mono); color: var(--muted); }
.search-form input { flex: 1 1 220px; min-width: 0; min-height: 44px; padding: 0 12px; font: 400 16px/24px var(--sans); color: var(--ink); background: var(--s2); border: 1px solid var(--line); }
.search-form input:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
.search-mini { margin: 16px 0 32px; max-width: 520px; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.s-status { min-height: 24px; margin: 8px 0; }
.s-results, .s-index ul { list-style: none; margin: 0; padding: 0; max-width: 720px; }
.s-results li { padding: 16px 0; border-top: 1px solid var(--line); }
.s-results a { font: 500 18px/24px var(--sans); }
.s-results p { margin: 4px 0 0; color: var(--muted); overflow-wrap: anywhere; }
.s-results mark { background: none; color: var(--orangetext); font-weight: 600; }
.s-where { display: block; font: 400 12px/16px var(--mono); color: var(--muted); }
.s-index section { margin: 0 0 32px; }
.s-index h2 { font: 500 14px/20px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--cyantext); }
.s-index li { padding: 2px 0; }
.s-index li.s-part { padding-left: 20px; }
.s-index .s-where, .s-index .s-text { display: none; }
.s-index a:hover { text-decoration: underline; text-underline-offset: 4px; }
.js .s-nojs { display: none; }
+120
View File
@@ -0,0 +1,120 @@
// The search page (issue #60). The index is the page itself: one list item for each page of the
// documentation and each of its headings, with that part's text. This filters and ranks them.
// Nothing is fetched, and nothing typed here leaves the browser.
(function () {
// Where a match counts for more: what a user came for before what a developer wrote down.
var weight = { "Guide": 1.4, "How-to": 1.3, "FAQ": 1.3, "Decisions": 0.8, "Milestones": 0.5 };
var kMax = 40, kAround = 90;
document.addEventListener("DOMContentLoaded", function () {
var input = document.getElementById("q"), results = document.getElementById("s-results");
var status = document.getElementById("s-status"), index = document.getElementById("s-index");
if (!input || !results || !index) return;
var entries = Array.prototype.map.call(index.querySelectorAll(".s-entry"), function (li) {
var link = li.querySelector("a"), where = li.querySelector(".s-where"), text = li.querySelector(".s-text");
var body = text ? text.textContent.replace(/\s+/g, " ").trim() : "";
return {
href: link.getAttribute("href"), title: link.textContent, where: where ? where.textContent : "",
body: body, titleLow: link.textContent.toLowerCase(), whereLow: (where ? where.textContent : "").toLowerCase(),
bodyLow: body.toLowerCase(), weight: weight[li.getAttribute("data-group")] || 1
};
});
function count(hay, word) {
var n = 0, at = hay.indexOf(word);
while (at >= 0 && n < 5) { n++; at = hay.indexOf(word, at + word.length); }
return n;
}
function search(words) {
var hits = [], phrase = words.join(" ");
entries.forEach(function (e) {
// The words as typed, side by side, count for more than the same words scattered.
var score = words.length > 1 ? (e.titleLow.indexOf(phrase) >= 0 ? 30 : 0) + (e.bodyLow.indexOf(phrase) >= 0 ? 12 : 0) : 0;
if (e.titleLow === phrase) score += 20;
for (var i = 0; i < words.length; i++) {
var w = words[i], inTitle = e.titleLow.indexOf(w) >= 0, inWhere = e.whereLow.indexOf(w) >= 0, n = count(e.bodyLow, w);
if (!inTitle && !inWhere && !n) return; // every word has to be there
score += (inTitle ? 20 : 0) + (inWhere ? 3 : 0) + n;
}
hits.push({ entry: e, score: score * e.weight });
});
hits.sort(function (a, b) { return b.score - a.score; });
return hits;
}
// The text around the first word found, with every word marked.
function snippet(e, words) {
var p = document.createElement("p"), first = -1;
words.forEach(function (w) {
var at = e.bodyLow.indexOf(w);
if (at >= 0 && (first < 0 || at < first)) first = at;
});
var from = Math.max(0, (first < 0 ? 0 : first) - kAround), to = Math.min(e.body.length, from + 2 * kAround + 40);
if (from > 0) { var space = e.body.indexOf(" ", from); if (space >= 0 && space < from + 20) from = space + 1; }
var piece = e.body.slice(from, to), low = piece.toLowerCase(), at = 0;
if (from > 0) p.appendChild(document.createTextNode("… "));
while (at < piece.length) {
var next = -1, len = 0;
words.forEach(function (w) {
var i = low.indexOf(w, at);
if (i >= 0 && (next < 0 || i < next)) { next = i; len = w.length; }
});
if (next < 0) { p.appendChild(document.createTextNode(piece.slice(at))); break; }
if (next > at) p.appendChild(document.createTextNode(piece.slice(at, next)));
var mark = document.createElement("mark");
mark.textContent = piece.slice(next, next + len);
p.appendChild(mark);
at = next + len;
}
if (to < e.body.length) p.appendChild(document.createTextNode(" …"));
return p;
}
function show() {
var q = input.value.trim(), words = q.toLowerCase().split(/\s+/).filter(function (w) { return w.length > 0; });
while (results.firstChild) results.removeChild(results.firstChild);
try { history.replaceState(null, "", q ? "?q=" + encodeURIComponent(q) : location.pathname); } catch (e) { /* a file: page */ }
if (!words.length) {
results.hidden = true;
index.hidden = false;
status.textContent = "";
return;
}
var hits = search(words);
hits.slice(0, kMax).forEach(function (h) {
var li = document.createElement("li"), a = document.createElement("a"), where = document.createElement("span");
a.href = h.entry.href;
a.className = "accent-link";
a.textContent = h.entry.title;
where.className = "s-where";
where.textContent = h.entry.where;
li.appendChild(a);
li.appendChild(where);
li.appendChild(snippet(h.entry, words));
results.appendChild(li);
});
results.hidden = false;
index.hidden = true;
status.textContent = !hits.length ? "Nothing found for “" + q + "”. Every word has to be on the page."
: (hits.length > kMax ? "The first " + kMax + " of " + hits.length : hits.length === 1 ? "1 place" : hits.length + " places") + " for “" + q + "”.";
}
var timer = 0;
input.addEventListener("input", function () {
clearTimeout(timer);
timer = setTimeout(show, 80);
});
input.form.addEventListener("submit", function (e) {
e.preventDefault();
show();
});
var asked = /[?&]q=([^&]*)/.exec(location.search);
if (asked) {
try { input.value = decodeURIComponent(asked[1].replace(/\+/g, " ")); } catch (e) { /* a bad escape: an empty box */ }
}
show();
input.focus();
});
})();
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

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