The SSH App opens one session to a shell, over libssh (LibSSH-ESP32 5.10.0)
on mbedTLS. A server is trusted the first time on its fingerprint, and a
changed key is a warning with Cancel selected. The password is typed each
time and kept nowhere; or the device makes itself an Ed25519 key, whose
public half is shown, written to /ssh/id_ed25519.pub and printed by
`ssh status`.
lib/term is the terminal: what a shell, less, top, nano and vim send, with
sixteen colours, scroll regions, the alternate screen and 100 lines of
scrollback. Five text sizes with Ctrl and + or -, from 60x20 to 26x8, told
to the far end. The session goes on when the App is left; SSH shows in the
Status Bar. `ssh user@host` in the Shell opens the App.
Also:
- Keys that aren't characters carry Shift, Ctrl and Alt. The terminal needs
it, and it makes Ctrl+Fn+up/down in a note and Shift+Tab in Gemini work
from the real keyboard.
- IRC doesn't try to connect under 60 KB free: started with a session open,
its TLS handshake took the heap down to 236 bytes.
- libssh's own curve25519 is left out of the build (scripts/libssh_filter.py):
libsodium's has the same names.
Costs 292 KB of flash and about 50 KB of heap while a session is open; not
started under 75 KB free.
Docs: guide page, how-to, FAQ, home page, Status Bar, SD card folders, the
memory how-to, README, glossary, N1 notes with Q254 to Q266 and the checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
The rest of the issue's list. tls makes a handshake that checks nothing,
then says the certificate in words: who it is for, who signed it, until
when, and whether this device's roots and the name asked for accept it,
with the reason when they don't. ntp compares a time server's clock with
the device's. netstat lists what listens and what is connected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Network troubleshooting from the device itself, in the Shell and over both
consoles. ping, nslookup, port and traceroute each run on a task of their
own and print as they go, to the console that asked; one at a time, and
`cancel` stops it. ifconfig and arp answer at once: the interfaces (Wi-Fi
and the VPN), which is the default route, the DNS servers, the neighbours.
nslookup asks a DNS server itself, so it can say which server answered and
in how long, and ask another. A sized ping finds what a tunnel really
carries.
With a how-to, "When the network doesn't work", and the rest of the docs.
tls, ntp and netstat from the issue's list are not in this.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
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
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
`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
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
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
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
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
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
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
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
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
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
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
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
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
The framework's server begin() fails without a word: the console's task now
asks whether it listens, says so, and tries again. `debug off <seconds>`
closes the console and reopens it after the pause, which is the only way to
test its closing and reopening from afar.
Checked on the device: 25 closings and reopenings, each back a second after
the pause. Free heap dips about 270 bytes for each connection the device
closes and is all back two minutes later (TCP keeps a closed connection that
long): not a leak.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT