Compare commits

..
14 Commits
Author SHA1 Message Date
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
81 changed files with 3469 additions and 309 deletions
+61 -8
View File
@@ -1,14 +1,32 @@
# CI and releases (docs/milestones/R1.md). # CI and releases (docs/milestones/R1.md).
# A push to main: the host tests, with their coverage of lib/, and the README's badges # A push to main: the host tests, with their coverage of lib/, and the README's badges
# published to the branch `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 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). # 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 # 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 # 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. # 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 name: CI
on: on:
push: push:
@@ -37,13 +55,23 @@ jobs:
env: env:
PLATFORMIO_CORE_DIR: /pio PLATFORMIO_CORE_DIR: /pio
RORO_NO_DOCKER: 1 RORO_NO_DOCKER: 1
SDK_MARK: /pio/packages/framework-arduinoespressif32-libs/.roro-sdkconfig.defaults
CCACHE_DIR: /pio/ci/ccache
CCACHE_MAXSIZE: 1G
steps: steps:
- name: Tools - name: Tools
run: | run: |
apt-get update -qq apt-get update -qq
apt-get install -y -qq --no-install-recommends git build-essential openssl >/dev/null apt-get install -y -qq --no-install-recommends git build-essential openssl ccache >/dev/null
pip install -q --no-cache-dir --root-user-action=ignore platformio gcovr mkdir -p /pio/ci
pio --version; df -h /pio | tail -1; ls /pio | head 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 - name: Check out
run: | run: |
@@ -57,11 +85,20 @@ jobs:
- name: Host tests, and their coverage of lib/ - name: Host tests, and their coverage of lib/
if: github.event_name != 'workflow_dispatch' if: github.event_name != 'workflow_dispatch'
run: 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 - name: The firmware
if: github.event_name == 'pull_request' || github.ref_type == 'tag' if: github.event_name == 'pull_request'
run: scripts/ci.sh builds 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 # 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) # at each tag (the release badge says which tag is the latest)
@@ -103,7 +140,12 @@ jobs:
trap 'rm -f "$RORO_OTA_KEY"' EXIT trap 'rm -f "$RORO_OTA_KEY"' EXIT
printf '%s\n' "$OTA_SIGNING_KEY" > "$RORO_OTA_KEY" printf '%s\n' "$OTA_SIGNING_KEY" > "$RORO_OTA_KEY"
umask 022 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 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 - name: Publish the release
if: steps.release.outputs.tag != '' if: steps.release.outputs.tag != ''
@@ -112,3 +154,14 @@ jobs:
GITEA_REPO: ${{ github.repository }} GITEA_REPO: ${{ github.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: scripts/release_publish.py dist 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 # 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 # 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 # 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 # touches both runs both. src/main.cpp and lib/core/src/app_keys.h are here too: the site's command
# firmware's own `help` text, and this job checks that it is still current. # reference and its key tables are generated from them, and this job checks that they are still current.
name: Site name: Site
on: on:
push: push:
branches: [main] 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: 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: jobs:
build: build:
@@ -51,3 +53,14 @@ jobs:
- name: Check the pages - name: Check the pages
run: python3 site/tools/check_site.py /tmp/site-out 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) # The built site (site/config.toml sends it here)
/public/ /public/
# The version, written by scripts/version.py before each build
lib/version/src/version_generated.h
+4
View File
@@ -106,6 +106,10 @@ The regulatory band plan the device transmits under (here EU868). It sets the al
**Duty Cycle Budget**: **Duty Cycle Budget**:
The share of airtime the Region allows this device to transmit. When it's used up, outgoing traffic waits. 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
**Help panel**: **Help panel**:
The list of the keys that work on the screen you are on, opened with Fn+h anywhere (or `?` outside Text Entry). Each App answers for its current state; no screen names keys any other way, except the first-start Setup. The list of the keys that work on the screen you are on, opened with Fn+h anywhere (or `?` outside Text Entry). Each App answers for its current state; no screen names keys any other way, except the first-start Setup.
_Avoid_: hints, cheat sheet, shortcuts bar _Avoid_: hints, cheat sheet, shortcuts bar
+10 -4
View File
@@ -10,7 +10,7 @@ A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262*
## On the device: one key ## On the device: one key
**Fn+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): each App declares them for the state it is in, and the help panel shows them, followed by the ones that work everywhere. **Fn+h, on any screen, lists the keys that work there** (`?` does the same outside a text field). No screen names its keys itself (docs/milestones/U1.md). Every screen's keys are one table in `lib/core/src/app_keys.h`: the help panel shows the table of the state an App is in, and the website's key tables are generated from the same file.
## Requirements ## Requirements
@@ -26,7 +26,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. `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 ## CI and releases
@@ -156,6 +156,10 @@ A new note has no file until something is typed; its file is then named after it
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 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.
## 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 ## Development aids
`scripts/serial_log.sh [seconds] [command…]` records the serial output, and can send commands to the firmware first. For example, `scripts/serial_log.sh 30 short sleep:12 burst` sets short screen timeouts, waits 12 s, then sends a burst of Toasts. `scripts/serial_log.sh [seconds] [command…]` records the serial output, and can send commands to the firmware first. For example, `scripts/serial_log.sh 30 short sleep:12 burst` sets short screen timeouts, waits 12 s, then sends a burst of Toasts.
@@ -181,13 +185,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 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 | | `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 | | `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 | | `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 | | `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) | | `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) |
| `log level <0-5>` | ESP-IDF log level | | `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 | | `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 | | `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 check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again | | `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
+59
View File
@@ -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. **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. **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.
+66
View File
@@ -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. **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). 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.
+7 -1
View File
@@ -1,6 +1,6 @@
# U1 — Look and feel # U1 — Look and feel
**Status:** in progress. The help key (issue #69) is built and checked on the device, in a pull request. Screen recording (#17) and the rest of the milestone are not started. **Status:** in progress. The help key (issue #69) is merged; the website's key tables generated from the same lists (issue #72) are in a pull request. Screen recording (#17) and the rest of the milestone are not started.
**Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content. **Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content.
@@ -37,4 +37,10 @@ Every screen used to say something about its keys, differently: a footer of abbr
| 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 | | 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 | | 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. **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.
+44 -1
View File
@@ -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. | | 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. | | 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. | | 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. | | 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. | | 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. | | Q181 | Docs for the latest version only. The changelog is the Gitea releases, read at build time. |
@@ -125,3 +125,46 @@ 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. - **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. - **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). - **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. It was forgotten often enough.
| # | 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.
+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
+451
View File
@@ -0,0 +1,451 @@
#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)"},
};
// 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"},
{"`", "the folder above"},
};
// 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 (up to 16 KB)"},
{"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"},
};
// 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 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
+17 -1
View File
@@ -1,5 +1,7 @@
#include "app_manager.h" #include "app_manager.h"
#include "app_keys.h"
#include <cstring> #include <cstring>
namespace roro { namespace roro {
@@ -60,7 +62,8 @@ void AppManager::handleKey(const KeyEvent& event) {
if (event.key == Key::Help) { if (event.key == Key::Help) {
std::vector<KeyHelp> rows; std::vector<KeyHelp> rows;
foreground_->help(rows); foreground_->help(rows);
help::everywhere(rows); rows.push_back({"Everywhere", nullptr});
keys::add(rows, keys::kEverywhere);
const char* scope = foreground_->helpTitle(); const char* scope = foreground_->helpTitle();
const char* app = foregroundTitle(); const char* app = foregroundTitle();
help_.open(scope ? scope : app ? app : "Launcher", std::move(rows)); help_.open(scope ? scope : app ? app : "Launcher", std::move(rows));
@@ -79,6 +82,19 @@ void AppManager::handleKey(const KeyEvent& event) {
if (!consumed && event.key == Key::Back) home(); 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 { const char* AppManager::foregroundTitle() const {
for (auto& info : apps_) for (auto& info : apps_)
if (info.app == foreground_) return info.title; if (info.app == foreground_) return info.title;
+8
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <string>
#include <vector> #include <vector>
#include "app.h" #include "app.h"
@@ -34,6 +35,13 @@ class AppManager {
void handleKey(const KeyEvent& event); void handleKey(const KeyEvent& event);
void update(uint32_t nowMs) { foreground_->update(nowMs); } 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_; } App& foreground() const { return *foreground_; }
const char* foregroundTitle() const; // nullptr for the Launcher const char* foregroundTitle() const; // nullptr for the Launcher
-29
View File
@@ -16,35 +16,6 @@ struct KeyHelp {
const char* action; const char* action;
}; };
namespace help {
// Lists that several screens share.
inline void list(std::vector<KeyHelp>& out, const char* enter = "open") {
out.push_back({"; .", "up, down"});
out.push_back({"Enter", enter});
}
inline void dialog(std::vector<KeyHelp>& out) {
out.push_back({", /", "the other answer"});
out.push_back({"Enter", "choose it"});
out.push_back({"`", "cancel"});
}
inline void textEntry(std::vector<KeyHelp>& out, const char* enter = "save") {
out.push_back({"Enter", enter});
out.push_back({"`", "cancel"});
out.push_back({"Del", "delete backwards"});
out.push_back({"Fn , /", "move the cursor"});
out.push_back({"opt ' e", "an accent: \xC3\xA9"});
}
// What works on every screen: the end of every panel.
inline void everywhere(std::vector<KeyHelp>& out) {
out.push_back({"Everywhere", nullptr});
out.push_back({"`", "back"});
out.push_back({"Fn `", "home, the Launcher"});
out.push_back({"; . , /", "arrows (Fn+ while typing)"});
out.push_back({"Fn h ?", "these keys (? not typing)"});
}
} // namespace help
// The panel itself: what it lists, and how far it's scrolled. The keys it takes while open are the // 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. // arrows; any other key closes it, and none reaches the App.
+49 -1
View File
@@ -4,7 +4,7 @@
namespace roro::files { 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]; const size_t kFirmwareFolderCount = sizeof kFirmwareFolders / sizeof kFirmwareFolders[0];
std::string parentOf(const std::string& path) { 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 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 } // namespace roro::files
+16
View File
@@ -39,4 +39,20 @@ FileKind kindOf(const std::string& name);
bool opensAtEnd(const std::string& name); // logs bool opensAtEnd(const std::string& name); // logs
bool looksLikeText(const uint8_t* data, size_t len); 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 } // 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
+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 #pragma once
#ifndef RORO_VERSION
#define RORO_VERSION "unknown"
#endif
namespace roro { namespace roro {
constexpr const char* kProductName = "roro9stack"; constexpr const char* kProductName = "roro9stack";
// "roro9stack v0.1.0" — used on the boot screen and in About. // "v0.1.0", from `git describe` (scripts/version.py): used on the boot screen and in About.
inline const char* versionString() { return RORO_VERSION; } // 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 } // namespace roro
+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: except Exception:
version = "unknown" 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 # 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" 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. 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. `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 ## CI and releases
+10 -6
View File
@@ -19,10 +19,11 @@ net bytes each network service has read and written since boot
reboot restart reboot restart
boot other restart into the other app slot (manual Rollback) boot other restart into the other app slot (manual Rollback)
log level <0-5> ESP-IDF log level (0 none ... 5 verbose) log level <0-5> ESP-IDF log level (0 none ... 5 verbose)
ls [folder] | du <path> | mkdir <path> | rm <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 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 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) lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)
gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB) gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)
gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum> gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>
@@ -35,8 +36,9 @@ wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP serve
gemini get <url> fetch a Gemini page and report header, size, certificate, heap gemini get <url> fetch a Gemini page and report header, size, certificate, heap
irc start | irc stop | irc dump | irc say <buffer> <text> irc start | irc stop | irc dump | irc say <buffer> <text>
install <path.ota> Update from SD install <path.ota> Update from SD
update check | 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 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
debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back
debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token
crash abort|wdt crash on purpose (to test crash reports and Safe Mode) crash abort|wdt crash on purpose (to test crash reports and Safe Mode)
@@ -47,7 +49,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 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 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 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 quit close the Debug Console connection
``` ```
@@ -78,13 +80,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 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 | | `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 | | `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 | | `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 | | `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) | | `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) |
| `log level <0-5>` | ESP-IDF log level | | `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 | | `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 | | `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 check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again | | `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
+11
View File
@@ -24,6 +24,16 @@ Two things to know before you use them:
**`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. **`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 ## Look before you press
**Take a screenshot before any key that deletes, renames or installs.** A blind sequence of `key` commands goes wrong the moment the screen is not where you think it is, and the screen is often not where you think it is: a Toast, a dialog that has not closed, a different App. A sequence that was meant to open a note once renamed real data instead. **Take a screenshot before any key that deletes, renames or installs.** A blind sequence of `key` commands goes wrong the moment the screen is not where you think it is, and the screen is often not where you think it is: a Toast, a dialog that has not closed, a different App. A sequence that was meant to open a note once renamed real data instead.
@@ -36,6 +46,7 @@ scripts/rdbg.py key select
scripts/rdbg.py screenshot b.png # look again before the next destructive step 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`. - 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. - 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. - For anything that deletes (`rm`, a delete dialog), `ls` first and `ls` after.
@@ -45,6 +45,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 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. - 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/). - 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 ## `coredump get`: the crash dump
+59
View File
@@ -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. **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. **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.
+66
View File
@@ -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. **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). 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.
+7 -1
View File
@@ -8,7 +8,7 @@ docs = true
source = "docs/milestones/U1.md" source = "docs/milestones/U1.md"
tag = "U1" tag = "U1"
+++ +++
**Status:** in progress. The help key (issue #69) is built and checked on the device, in a pull request. Screen recording (#17) and the rest of the milestone are not started. **Status:** in progress. The help key (issue #69) is merged; the website's key tables generated from the same lists (issue #72) are in a pull request. Screen recording (#17) and the rest of the milestone are not started.
**Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content. **Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content.
@@ -45,4 +45,10 @@ Every screen used to say something about its keys, differently: a footer of abbr
| 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 | | 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 | | 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. **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.
+44 -1
View File
@@ -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. | | 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. | | 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. | | 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. | | 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. | | 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. | | Q181 | Docs for the latest version only. The changelog is the Gitea releases, read at build time. |
@@ -133,3 +133,46 @@ 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. - **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. - **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). - **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. It was forgotten often enough.
| # | 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.
+8 -2
View File
@@ -68,6 +68,12 @@ On a new device a short Setup asks four things, then never appears again. It als
## The SD card ## 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 ## 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. 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 ## 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. <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. - 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/)). - 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 = 13
[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 raises the noise
The GNSS receiver on the same Cap makes the radio's noise floor about **8 dB** worse while it runs. **Settings → Pause GNSS for LoRa** (off by default) puts the receiver on standby while the radio listens, except while a Track is being recorded. The GNSS receiver on the same Cap makes the radio's noise floor about **8 dB** worse while it runs. **Settings → 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"]) }}
+6
View File
@@ -35,3 +35,9 @@ A new note has no file until you type something. The file is then named after it
## Limits ## 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. 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.
## 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"]) }}
+7 -1
View File
@@ -1,7 +1,7 @@
+++ +++
title = "Settings" title = "Settings"
description = "The device's names, region, screen, sound, GNSS and Wi-Fi, and where firmware updates are found." description = "The device's names, region, screen, sound, GNSS and Wi-Fi, and where firmware updates are found."
weight = 10 weight = 11
[extra] [extra]
tag = "Settings" tag = "Settings"
+++ +++
@@ -41,3 +41,9 @@ A network normally gives the device its address by itself (DHCP, **Automatic**).
### DNS and NTP ### 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. 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", "debug-console"]) }}
+90
View File
@@ -0,0 +1,90 @@
+++
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"
+++
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. The [Storage App](/guide/storage/) shows the files; to look at them, take the card to a computer.
## 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"]) }}
+6
View File
@@ -45,3 +45,9 @@ The App says why when it refuses.
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. 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 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"]) }}
+7 -1
View File
@@ -1,7 +1,7 @@
+++ +++
title = "System" title = "System"
description = "What the device is doing right now: load, tasks, memory, network traffic, battery and temperature. Live, and read-only." 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] [extra]
tag = "System" tag = "System"
screens = ["system.png"] screens = ["system.png"]
@@ -18,3 +18,9 @@ System changes nothing: it shows. It samples once a second and keeps its history
## Why it exists ## 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 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" 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." 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 = 12
[extra] [extra]
tag = "Firmware" tag = "Firmware"
screens = ["update.png"] screens = ["update.png"]
@@ -42,3 +42,9 @@ The connection to the project's server is checked against the two root certifica
## For developers ## 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/). 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"]) }}
+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 ## 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. 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
View File
@@ -19,6 +19,7 @@ Everything the firmware writes goes in a folder at the top of the card. Switch t
| Gemini bookmarks | `/gemini/bookmarks.gmi` | gemtext | | Gemini bookmarks | `/gemini/bookmarks.gmi` | gemtext |
| Files saved from Gemini that are not text | `/gemini/downloads` | whatever they were | | Files saved from Gemini that are not text | `/gemini/downloads` | whatever they were |
| Update files | `/updates` | `.ota` | | Update files | `/updates` | `.ota` |
| Screenshots (the Shell's `screenshot`) | `/screenshots` | `.png`, named by date and time |
## Rules worth knowing ## Rules worth knowing
+535
View File
@@ -0,0 +1,535 @@
# 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)"],
]
[[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"],
["`", "the folder above"],
]
[[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 (up to 16 KB)"],
["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 = "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 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"],
]
+9
View File
@@ -254,3 +254,12 @@ footer small { display: block; max-width: 760px; }
.source { max-width: 820px; font-size: 14px; line-height: 20px; } .source { max-width: 820px; font-size: 14px; line-height: 20px; }
.source code { background: var(--s2); padding: 0 6px; } .source code { background: var(--s2); padding: 0 6px; }
.card.featured { flex-basis: 100%; } .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; }
+1 -1
View File
@@ -68,7 +68,7 @@
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
<p class="cards-note">Plus Settings, with its Wi-Fi and Firmware pages. Every App has a page in the <a class="accent-link" href="/guide/">user guide</a>.</p> <p class="cards-note">Plus a Shell that runs the firmware's commands on the device, and Settings, with its Wi-Fi and Firmware pages. Every App has a page in the <a class="accent-link" href="/guide/">user guide</a>.</p>
</section> </section>
<section class="wrap" id="screens" aria-labelledby="screens-title"> <section class="wrap" id="screens" aria-labelledby="screens-title">
+18
View File
@@ -0,0 +1,18 @@
{#- The keys of one or more screens, as the help panel (Fn+h) lists them on the device:
{{ keys(scopes=["storage", "storage-details"]) }}, or {{ keys(all=true) }} for every screen.
The tables come from site/data/keys.toml, generated from lib/core/src/app_keys.h (issue #72):
a key added to an App shows up here without anyone editing a page. -#}
{%- set data = load_data(path="data/keys.toml", format="toml") -%}
{%- set everything = all is defined and all -%}
<div class="keys">
{%- for s in data.scope %}{% if everything or (scopes is defined and s.id in scopes) %}
<div class="keys-scope">
<h4 id="keys-{{ s.id }}">{{ s.title }}</h4>
<table>
{%- for r in s.rows %}
<tr><td><kbd>{{ r.0 }}</kbd></td><td>{{ r.1 }}</td></tr>
{%- endfor %}
</table>
</div>
{%- endif %}{% endfor %}
</div>
+52 -4
View File
@@ -9,6 +9,8 @@ Zola cannot read a file outside its own folder, so the pages are generated and c
milestones/ one page for each docs/milestones/*.md milestones/ one page for each docs/milestones/*.md
build/build-and-test, build/flash sections of README.md build/build-and-test, build/flash sections of README.md
debug/commands the commands the firmware's `help` prints (parsed from src/main.cpp), then README's table of them debug/commands the commands the firmware's `help` prints (parsed from src/main.cpp), then README's table of them
site/data/keys.toml every screen's keys, from lib/core/src/app_keys.h: what the help panel (Fn+h) shows on the
device, for the `keys` shortcode of the user guide (issue #72)
Every generated page says where it comes from; edit that file, not the page. Hand-written pages sit next to them. Every generated page says where it comes from; edit that file, not the page. Hand-written pages sit next to them.
""" """
import json import json
@@ -139,8 +141,38 @@ def safe_mode_commands():
return exact, prefix return exact, prefix
def key_tables():
"""Every table of lib/core/src/app_keys.h: [(id, title, [(keys, action), ...])], in the file's order."""
src = (REPO / "lib" / "core" / "src" / "app_keys.h").read_text()
def text(literal): # a C string literal's contents: \xHH bytes are UTF-8
raw = re.sub(r"\\x([0-9A-Fa-f]{2})", lambda m: chr(int(m.group(1), 16)), literal).replace('\\"', '"')
return raw.encode("latin-1").decode("utf-8")
tables = []
for m in re.finditer(r"// ([a-z0-9-]+): ([^\n]+)\ninline constexpr KeyHelp k\w+\[\] = \{\n(.*?)\n\};", src, re.S):
rows = [(text(k), text(a)) for k, a in re.findall(r'\{"((?:[^"\\]|\\.)*)", "((?:[^"\\]|\\.)*)"\},', m.group(3))]
if len(rows) != len([l for l in m.group(3).splitlines() if l.strip()]):
sys.exit(f"gen_dev_docs: a row of `{m.group(1)}` in app_keys.h isn't in the form {{\"keys\", \"action\"}},")
tables.append((m.group(1), m.group(2).strip(), rows))
declared = len(re.findall(r"^inline constexpr KeyHelp k\w+\[\]", src, re.M))
if len(tables) != declared or len({t[0] for t in tables}) != len(tables):
sys.exit(f"gen_dev_docs: app_keys.h has {declared} tables, {len(tables)} with an `// id: Title` comment and a unique id")
return tables
def keys_toml():
out = ["# 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.", ""]
for ident, title, rows in key_tables():
out += ["[[scope]]", f"id = {json.dumps(ident)}", f"title = {json.dumps(title, ensure_ascii=False)}", "rows = ["]
out += [f" [{json.dumps(k, ensure_ascii=False)}, {json.dumps(a, ensure_ascii=False)}]," for k, a in rows]
out += ["]", ""]
return "\n".join(out)
def build(): def build():
pages = {} pages = {"../../data/keys.toml": keys_toml()}
for path in sorted((REPO / "docs" / "adr").glob("*.md")): for path in sorted((REPO / "docs" / "adr").glob("*.md")):
title, body = title_and_body(path.read_text()) title, body = title_and_body(path.read_text())
@@ -179,9 +211,25 @@ def build():
return pages return pages
def unknown_scopes():
"""Scopes a page asks the `keys` shortcode for that app_keys.h doesn't have."""
known = {t[0] for t in key_tables()}
bad = []
for path in sorted((SITE / "content").rglob("*.md")):
for call in re.findall(r"keys\(scopes=\[([^\]]*)\]", path.read_text()):
for ident in re.findall(r'"([^"]+)"', call):
if ident not in known:
bad.append(f"{path.relative_to(SITE)}: no key table `{ident}` in lib/core/src/app_keys.h")
return bad
def main(): def main():
check = "--check" in sys.argv check = "--check" in sys.argv
pages = build() pages = build()
for problem in unknown_scopes():
print("gen_dev_docs:", problem)
if unknown_scopes():
sys.exit(1)
stale = [] stale = []
for rel, text in sorted(pages.items()): for rel, text in sorted(pages.items()):
path = OUT / rel path = OUT / rel
@@ -193,10 +241,10 @@ def main():
path.write_text(text) path.write_text(text)
if check: if check:
for rel in stale: for rel in stale:
print(f"gen_dev_docs: content/dev/{rel} is out of date: run site/tools/gen_dev_docs.py and commit the result") print(f"gen_dev_docs: {os.path.normpath(os.path.join('site/content/dev', rel))} is out of date: run site/tools/gen_dev_docs.py and commit the result")
print(f"gen_dev_docs: {len(pages)} pages, {len(stale)} out of date") print(f"gen_dev_docs: {len(pages)} files, {len(stale)} out of date")
sys.exit(1 if stale else 0) sys.exit(1 if stale else 0)
print(f"gen_dev_docs: {len(pages)} pages, {len(stale)} written") print(f"gen_dev_docs: {len(pages)} files, {len(stale)} written")
if __name__ == "__main__": if __name__ == "__main__":
+4 -4
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "apps/debug_console_page.h" #include "apps/debug_console_page.h"
#include "debug_auth.h" #include "debug_auth.h"
@@ -75,10 +76,9 @@ bool DebugConsolePage::onKey(const KeyEvent& e) {
} }
void DebugConsolePage::help(std::vector<KeyHelp>& out) const { void DebugConsolePage::help(std::vector<KeyHelp>& out) const {
if (confirm_) return help::dialog(out); if (confirm_) return keys::add(out, keys::kDialog);
if (typing_) return help::textEntry(out); if (typing_) return keys::add(out, keys::kText);
help::list(out, "switch, or open"); keys::add(out, keys::kDebugConsole);
out.push_back({", /", "switch the console on or off"});
} }
void DebugConsolePage::draw(Canvas& c) { void DebugConsolePage::draw(Canvas& c) {
+4 -6
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "demo_app.h" #include "demo_app.h"
#include "ui/widgets.h" #include "ui/widgets.h"
@@ -26,12 +27,9 @@ void DemoApp::notify(const char* text, NotificationLevel level) {
} }
void DemoApp::help(std::vector<KeyHelp>& out) const { void DemoApp::help(std::vector<KeyHelp>& out) const {
if (dialog_) return help::dialog(out); if (dialog_) return keys::add(out, keys::kDialog);
switch (page_) { if (page_ == Page::Editor) return keys::add(out, keys::kText);
case Page::Menu: help::list(out, "try the widget"); break; if (page_ == Page::Menu) keys::add(out, keys::kDemo);
case Page::Text: out.push_back({"; .", "scroll"}); break;
case Page::Editor: help::textEntry(out, "show the text as a Toast"); break;
}
} }
bool DemoApp::onKey(const KeyEvent& e) { bool DemoApp::onKey(const KeyEvent& e) {
+8 -19
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "file_viewer.h" #include "file_viewer.h"
#include <Arduino.h> #include <Arduino.h>
@@ -266,27 +267,15 @@ void FileViewer::openPacket(int index) {
} }
void FileViewer::help(std::vector<KeyHelp>& out) const { void FileViewer::help(std::vector<KeyHelp>& out) const {
if (confirm_) return help::dialog(out); if (confirm_) return keys::add(out, keys::kDialog);
switch (mode_) { switch (mode_) {
case Mode::Packet: case Mode::Text: keys::add(out, keys::kViewerText); break;
out.push_back({"; .", "scroll"}); case Mode::Hex: keys::add(out, keys::kViewerHex); break;
out.push_back({"Enter", "back to the packets"}); case Mode::Pcap: keys::add(out, keys::kViewerPcap); break;
return; case Mode::Packet: keys::add(out, keys::kViewerPacket); break;
case Mode::Text: case Mode::Gpx: keys::add(out, keys::kViewerGpx); break;
case Mode::Hex: case Mode::Ota: keys::add(out, keys::kViewerOta); break;
out.push_back({"; .", "a line up, down"});
out.push_back({", /", "a page up, down"});
out.push_back({"t b", "the top, the end"});
if (mode_ == Mode::Text) out.push_back({"e", "edit it (up to 16 KB)"});
break;
case Mode::Pcap:
help::list(out, "the packet");
out.push_back({", /", "a page up, down"});
break;
case Mode::Ota: out.push_back({"Enter", "install it, if it's genuine"}); break;
case Mode::Gpx: break;
} }
out.push_back({"Tab", mode_ != base_ ? "back to the file's own view" : base_ == Mode::Hex || base_ == Mode::Gpx ? "the file as text" : "the file as hex"});
} }
bool FileViewer::onKey(const KeyEvent& e) { bool FileViewer::onKey(const KeyEvent& e) {
+5 -14
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "firmware_page.h" #include "firmware_page.h"
#include <SD.h> #include <SD.h>
@@ -235,21 +236,11 @@ void FirmwarePage::drawOlder(Canvas& c) {
} }
void FirmwarePage::help(std::vector<KeyHelp>& out) const { void FirmwarePage::help(std::vector<KeyHelp>& out) const {
if (confirm_) return help::dialog(out); if (confirm_) return keys::add(out, keys::kDialog);
switch (view_) { switch (view_) {
case View::Main: case View::Main: keys::add(out, keys::kFirmware); break;
help::list(out, "check, open, or install"); case View::Release: keys::add(out, keys::kFirmwareRelease); break;
out.push_back({"c", "look for a newer release"}); case View::Older: keys::add(out, keys::kFirmwareOlder); break;
break;
case View::Release:
out.push_back({"; .", "scroll"});
out.push_back({"Enter", "install it"});
out.push_back({"c", "check again"});
break;
case View::Older:
help::list(out, "its details");
out.push_back({"c", "read the list again"});
break;
} }
} }
+6 -19
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "gemini_app.h" #include "gemini_app.h"
#include <Arduino.h> #include <Arduino.h>
@@ -303,25 +304,11 @@ void GeminiApp::selectLink(int direction) {
} }
void GeminiApp::help(std::vector<KeyHelp>& out) const { void GeminiApp::help(std::vector<KeyHelp>& out) const {
if (certDialog_ || deleteDialog_) return help::dialog(out); if (certDialog_ || deleteDialog_) return keys::add(out, keys::kDialog);
if (inputOpen_) return help::textEntry(out, "send it"); if (inputOpen_) return keys::add(out, keys::kGeminiAnswer);
if (addressOpen_) return help::textEntry(out, "go there"); if (addressOpen_) return keys::add(out, keys::kGeminiAddress);
out.push_back({"Tab", "the next link"}); if (isSaved()) keys::add(out, keys::kGeminiSaved);
out.push_back({"Aa Tab", "the link before"}); else keys::add(out, keys::kGemini);
out.push_back({"Enter", "follow the link"});
out.push_back({"` Del", "the page before"});
out.push_back({"; .", "scroll"});
out.push_back({"Space", "a page down"});
out.push_back({", /", "sideways, in wide blocks"});
out.push_back({"g", "type an address"});
out.push_back({"b", "bookmark this page"});
if (isSaved()) {
out.push_back({"r", "refresh this Saved Page"});
out.push_back({"d", "delete this Saved Page"});
} else {
out.push_back({"s", "save the page to the card"});
out.push_back({"S", "...with the pages it links to"});
}
} }
const char* GeminiApp::helpTitle() const { const char* GeminiApp::helpTitle() const {
+2 -2
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "gnss_app.h" #include "gnss_app.h"
#include <Arduino.h> #include <Arduino.h>
@@ -83,8 +84,7 @@ void GnssApp::draw(Canvas& c) {
} }
void GnssApp::help(std::vector<KeyHelp>& out) const { void GnssApp::help(std::vector<KeyHelp>& out) const {
out.push_back({"Tab", sky_ ? "the position" : "the sky"}); keys::add(out, keys::kGnss);
out.push_back({"r", gnss_.tracking() ? "stop the Track" : "record a Track"});
} }
void GnssApp::drawPosition(Canvas& c) { void GnssApp::drawPosition(Canvas& c) {
+4 -23
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "irc_app.h" #include "irc_app.h"
#include "ui/fonts.h" #include "ui/fonts.h"
@@ -300,29 +301,9 @@ void IrcApp::drawChat(Canvas& c) {
} }
void IrcApp::help(std::vector<KeyHelp>& out) const { void IrcApp::help(std::vector<KeyHelp>& out) const {
if (page_ == Page::Settings) { if (page_ == Page::Chat) return keys::add(out, keys::kIrc);
if (editing_) return help::textEntry(out, "keep it"); if (editing_) return keys::add(out, keys::kIrcField);
help::list(out, "edit, switch, or save"); keys::add(out, keys::kIrcSettings);
out.push_back({"`", "leave without saving"});
return;
}
out.push_back({"Enter", "send the line"});
out.push_back({"Tab", "the next buffer"});
out.push_back({"Alt ; .", "scroll back, forward"});
out.push_back({"Fn ; .", "lines you sent before"});
out.push_back({"Fn , /", "move the cursor"});
out.push_back({"Del", "delete backwards"});
out.push_back({"/settings", "server, nick, passwords"});
out.push_back({"/join #x", "join a channel"});
out.push_back({"/part", "leave it"});
out.push_back({"/msg nick", "a private chat"});
out.push_back({"/me", "an action"});
out.push_back({"/nick", "change your nick"});
out.push_back({"/topic", "see or set the topic"});
out.push_back({"/names", "who is there"});
out.push_back({"/quit", "disconnect, and stay so"});
out.push_back({"/raw", "a line as it is"});
out.push_back({"`", "leave: IRC stays connected"});
} }
const char* IrcApp::helpTitle() const { const char* IrcApp::helpTitle() const {
+2 -1
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "app.h" #include "app.h"
#include "app_keys.h"
#include "app_manager.h" #include "app_manager.h"
#include "list_model.h" #include "list_model.h"
#include "ui/theme.h" #include "ui/theme.h"
@@ -14,7 +15,7 @@ class LauncherApp : public App {
void onEnter() override; void onEnter() override;
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override { help::list(out, "open the App"); } void help(std::vector<KeyHelp>& out) const override { keys::add(out, keys::kLauncher); }
private: private:
AppManager* manager_ = nullptr; AppManager* manager_ = nullptr;
+5 -12
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "lora_scanner_app.h" #include "lora_scanner_app.h"
#include <Arduino.h> #include <Arduino.h>
@@ -167,18 +168,10 @@ void LoraScannerApp::update(uint32_t nowMs) {
void LoraScannerApp::help(std::vector<KeyHelp>& out) const { void LoraScannerApp::help(std::vector<KeyHelp>& out) const {
switch (view_) { switch (view_) {
case View::Packets: case View::Packets: keys::add(out, keys::kLora); break;
help::list(out, "the packet's details"); case View::Details: keys::add(out, keys::kLoraPacket); break;
out.push_back({"p", "pick a Meshtastic preset"}); case View::Presets: keys::add(out, keys::kLoraPresets); break;
out.push_back({"c", capture_.capturing() ? "stop the Capture" : "start a Capture (pcap)"}); case View::Sweep: keys::add(out, keys::kLoraSweep); break;
out.push_back({"Tab", "the Sweep"});
break;
case View::Details:
out.push_back({"; .", "scroll"});
out.push_back({"Enter", "back to the list"});
break;
case View::Presets: help::list(out, "listen with this preset"); break;
case View::Sweep: out.push_back({"Tab", "the Sniffer"}); break;
} }
} }
+3 -2
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "maintenance_page.h" #include "maintenance_page.h"
#include "ui/widgets.h" #include "ui/widgets.h"
@@ -36,8 +37,8 @@ CleanupPlan MaintenancePage::planFor(int age) const {
} }
void MaintenancePage::help(std::vector<KeyHelp>& out) const { void MaintenancePage::help(std::vector<KeyHelp>& out) const {
if (dialog_) return help::dialog(out); if (dialog_) return keys::add(out, keys::kDialog);
help::list(out, view_ == View::Main ? "open" : view_ == View::Categories ? "choose what to clean" : "choose how old"); keys::add(out, keys::kMaintenance);
} }
bool MaintenancePage::onKey(const KeyEvent& e) { bool MaintenancePage::onKey(const KeyEvent& e) {
+3 -9
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "note_editor.h" #include "note_editor.h"
#include <Arduino.h> #include <Arduino.h>
@@ -183,15 +184,8 @@ bool NoteEditor::save() {
} }
void NoteEditor::help(std::vector<KeyHelp>& out) const { void NoteEditor::help(std::vector<KeyHelp>& out) const {
if (dialog_) return help::dialog(out); if (dialog_) return keys::add(out, keys::kDialog);
out.push_back({"Enter", "a new line"}); keys::add(out, keys::kNotesEditor);
out.push_back({"Del", "delete backwards"});
out.push_back({"Tab", "two spaces"});
out.push_back({"Fn ; . , /", "move the cursor"});
out.push_back({"Alt Fn ; .", "a page up, down"});
out.push_back({"Ctrl a e", "start, end of the line"});
out.push_back({"opt ' e", "an accent: \xC3\xA9"});
out.push_back({"`", "done: it saves by itself"});
} }
bool NoteEditor::onKey(const KeyEvent& e) { bool NoteEditor::onKey(const KeyEvent& e) {
+4 -8
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "notes_app.h" #include "notes_app.h"
#include <Arduino.h> #include <Arduino.h>
@@ -175,15 +176,10 @@ std::string NotesApp::titleOf(int row) {
void NotesApp::help(std::vector<KeyHelp>& out) const { void NotesApp::help(std::vector<KeyHelp>& out) const {
if (view_ == View::Edit) return editor_.help(out); if (view_ == View::Edit) return editor_.help(out);
if (dialog_) return help::dialog(out); if (dialog_) return keys::add(out, keys::kDialog);
if (view_ == View::Name) return help::textEntry(out, "rename the file"); if (view_ == View::Name) return keys::add(out, keys::kNotesName);
if (view_ == View::NoMemory) return; if (view_ == View::NoMemory) return;
help::list(out, "open the note"); keys::add(out, keys::kNotes);
out.push_back({", /", "a page up, down"});
out.push_back({"n", "a new note"});
out.push_back({"r", "rename its file"});
out.push_back({"d Del", "delete it"});
out.push_back({"s", "sort: newest, or by name"});
} }
const char* NotesApp::helpTitle() const { const char* NotesApp::helpTitle() const {
+4 -6
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "settings_app.h" #include "settings_app.h"
#include <Arduino.h> #include <Arduino.h>
@@ -131,12 +132,9 @@ bool SettingsApp::onAboutKey(const KeyEvent& e) {
void SettingsApp::help(std::vector<KeyHelp>& out) const { void SettingsApp::help(std::vector<KeyHelp>& out) const {
switch (page_) { switch (page_) {
case Page::Menu: case Page::Menu: keys::add(out, keys::kSettings); break;
help::list(out, "edit, or open the page"); case Page::Text: keys::add(out, keys::kText); break;
out.push_back({", /", "change a switch or a slider"}); case Page::Choice: keys::add(out, keys::kSettingsChoice); break;
break;
case Page::Text: help::textEntry(out); break;
case Page::Choice: help::list(out, "choose it"); break;
case Page::About: break; case Page::About: break;
case Page::Wifi: wifiPage_.help(out); break; case Page::Wifi: wifiPage_.help(out); break;
case Page::Firmware: firmwarePage_.help(out); break; case Page::Firmware: firmwarePage_.help(out); break;
+4 -9
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "setup_app.h" #include "setup_app.h"
#include "platform/identity.h" #include "platform/identity.h"
@@ -47,16 +48,10 @@ bool SetupApp::onKey(const KeyEvent& e) {
void SetupApp::help(std::vector<KeyHelp>& out) const { void SetupApp::help(std::vector<KeyHelp>& out) const {
switch (wizard_.step()) { switch (wizard_.step()) {
case Step::LongName: case Step::LongName:
case Step::ShortName: help::textEntry(out, "next step"); break; case Step::ShortName: keys::add(out, keys::kSetupText); break;
case Step::Region: case Step::Region:
case Step::Timezone: case Step::Timezone: keys::add(out, keys::kSetupChoice); break;
help::list(out, "choose it, next step"); default: keys::add(out, keys::kSetup); break;
out.push_back({"`", "the step before"});
break;
default:
out.push_back({"Enter", "continue"});
out.push_back({"`", "the step before"});
break;
} }
} }
+204
View File
@@ -0,0 +1,204 @@
#include "apps/shell_app.h"
#include <Arduino.h>
#include <algorithm>
#include "app_keys.h"
#include "file_names.h"
#include "platform/console.h"
#include "ui/fonts.h"
#include "ui/theme.h"
#include "ui/widgets.h"
namespace roro {
namespace {
bool startsWith(const std::string& s, const char* prefix) { return s.rfind(prefix, 0) == 0; }
} // namespace
void ShellApp::onEnter() {
open_ = console.openShellRing();
console.shellShowsAll(false); // its own replies only, each time it's opened
ringPos_ = 0;
scroll_ = 0;
confirm_.reset();
// What Tab completes besides the firmware's own commands, written as `help` writes them.
ownHelp_ = "help | clear | quit | exit\nkey up|down|left|right|select|back|home|del|tab|space|help\n";
log_.clear();
log_.add(open_ ? "The console's commands. `help` lists them." : "No memory for the Shell: leave an App, or stop IRC.");
}
// Nothing is kept once it's left: the ring, the lines and the list of commands all go (Q207).
void ShellApp::onExit() {
console.closeShellRing();
console.shellShowsAll(false);
open_ = false;
log_.clear();
std::string().swap(ownHelp_);
confirm_.reset();
}
void ShellApp::update(uint32_t) {
uint8_t buf[256];
uint32_t skipped = 0;
size_t n;
while ((n = console.readShellSince(ringPos_, buf, sizeof buf, skipped)) > 0) {
if (skipped) log_.add("[... " + std::to_string(skipped) + " bytes lost: more was printed than fits]");
log_.feed(reinterpret_cast<const char*>(buf), n);
}
if (log_.revision() != seenRevision_) {
seenRevision_ = log_.revision();
requestRedraw();
}
}
void ShellApp::runNow(const std::string& line) {
// The runner echoes the line into the console, where the Shell reads it back with the reply,
// except a token being set, which goes nowhere (Q210): that one is shown here, masked.
if (startsWith(line, "debug token ") && line != "debug token new") log_.add("> debug token ...");
run_(line);
}
void ShellApp::enter(const std::string& line) {
history_.add(line);
scroll_ = 0;
if (line == "quit" || line == "exit") return apps_.home();
if (line == "clear") return log_.clear();
// Q209: `rm` as Unix has it, with a question where Unix has none, since a slip of the finger is
// a key away here. A file, or a folder with something in it, is asked about unless -f says not
// to. An empty folder goes without a word; anything `rm` would refuse anyway, it refuses itself.
if (startsWith(line, "rm ")) {
files::RmArgs args = files::parseRm(line.substr(3));
bool ask = false;
if (args.force || args.path.empty()) {
} else if (files::hasGlob(args.path)) { // a pattern: one question for all it matches
bool more = false;
int n = count_(args.path, more);
ask = n > 0 && !more; // none, or too many: `rm` says so itself
question_ = "The " + std::to_string(n) + " that match " + args.path + (args.recursive ? ", folders and what's in them too" : "") + ". It can't be undone.";
} else {
Target target = probe_(args.path);
ask = target == Target::File || (target == Target::FullFolder && args.recursive);
question_ = target == Target::File ? args.path + ". It can't be undone." : args.path + " and everything in it. It can't be undone.";
}
if (ask) {
pending_ = line;
confirm_.reset(new DialogModel({"Cancel", "Delete"}));
return;
}
}
runNow(line);
}
bool ShellApp::onKey(const KeyEvent& e) {
requestRedraw();
if (confirm_) {
confirm_->onKey(e);
if (confirm_->result() == DialogModel::kPending) return true;
if (confirm_->result() == 1) runNow(pending_);
else log_.add("Not deleted.");
confirm_.reset();
return true;
}
// Alt + ; / Alt + . scroll back and forward; Up / Down (Fn + ; / Fn + .) recall earlier lines.
if (e.key == Key::Char && e.alt && (e.ch == ';' || e.ch == '.')) {
if (e.ch == ';') scroll_++;
else if (scroll_ > 0) scroll_--;
return true;
}
if (e.key == Key::Char && e.ctrl && (e.ch == 'b' || e.ch == 'B')) { // Q206
console.shellShowsAll(!console.shellShowsAll());
log_.add(console.shellShowsAll() ? "Showing everything the console prints." : "Showing only the replies to your commands.");
return true;
}
std::string recalled;
switch (e.key) {
case Key::Char: input_.insert(e.ch); break;
case Key::Delete: input_.backspace(); break;
case Key::Left: input_.left(); break;
case Key::Right: input_.right(); break;
case Key::Up:
if (history_.up(input_.text(), recalled)) input_.setText(recalled);
break;
case Key::Down:
if (history_.down(recalled)) input_.setText(recalled);
break;
case Key::Tab: { // the command, every word of it; where its words end, a path on the card
std::vector<std::string> matches;
PathToComplete path;
bool more = false;
const std::string typed = input_.text();
std::string done = completeWords(typed, (std::string(helpText_) + ownHelp_).c_str(), matches);
if (done == typed && matches.empty() && splitForPath(typed, path)) done = completePath(path, list_(path.folder, path.prefix, more), matches);
input_.setText(done);
if (matches.size() > 1) {
std::string all;
for (auto& m : matches) all += (all.empty() ? "" : " ") + m;
log_.add(all + (more ? " ..." : ""));
}
break;
}
case Key::Select: {
std::string line = input_.text();
input_.setText("");
if (!line.empty()) enter(line);
break;
}
default: return false; // Back leaves the Shell
}
return true;
}
void ShellApp::help(std::vector<KeyHelp>& out) const {
if (confirm_) return keys::add(out, keys::kDialog);
keys::add(out, keys::kShell);
}
void ShellApp::draw(Canvas& c) {
const auto& area = theme::kContent;
const int inputH = theme::kLineHeight + 4;
const theme::Rect output{area.x, area.y, area.w, area.h - inputH - 1};
const int rows = output.h / theme::kLineHeight;
// Wrapped from the newest line backwards, only as far as the screen and the scroll need.
std::vector<std::pair<std::string, uint16_t>> shown; // newest first
auto measure = widgets::bodyMeasure(c);
c.setFont(&fonts::body);
const auto& lines = log_.lines();
int needed = rows + scroll_;
for (auto it = lines.rbegin(); it != lines.rend() && static_cast<int>(shown.size()) < needed; ++it) {
uint16_t color = it->rfind("> ", 0) == 0 ? theme::kAccent : theme::kText;
auto wrapped = wrapText(it->empty() ? std::string(" ") : *it, output.w - 8, measure);
for (auto w = wrapped.rbegin(); w != wrapped.rend(); ++w) shown.push_back({*w, color});
}
int total = static_cast<int>(shown.size());
if (scroll_ > total - rows) scroll_ = total > rows ? total - rows : 0;
c.setClipRect(output.x, output.y, output.w, output.h);
for (int r = 0; r < rows; r++) {
int i = scroll_ + (rows - 1 - r); // the row at the bottom is the newest
if (i >= total) continue;
c.setTextColor(shown[i].second);
c.drawString(shown[i].first.c_str(), 4, output.y + r * theme::kLineHeight + 1);
}
c.clearClipRect();
// State, not keys: how far back it's scrolled, and whether background lines are hidden.
c.setFont(&fonts::small);
c.setTextDatum(top_right);
if (scroll_ > 0) {
c.setTextColor(theme::kWarning);
c.drawString(("^ " + std::to_string(scroll_)).c_str(), area.w - 3, output.y + 1);
} else if (console.shellShowsAll()) {
c.setTextColor(theme::kMuted);
c.drawString("all", area.w - 3, output.y + 1);
}
c.setTextDatum(top_left);
widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0});
if (confirm_) widgets::dialog(c, "Delete?", question_, *confirm_);
}
} // namespace roro
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "app.h"
#include "app_manager.h"
#include "dialog_model.h"
#include "input_history.h"
#include "line_editor.h"
#include "shell_log.h"
namespace roro {
// The Shell (issue #67): the console's commands on the device's own screen and keyboard. A third
// place to type them, after USB serial and the Debug Console, and trusted like the first: whoever
// holds the device can do all of it in Settings anyway (Q205).
//
// It shows the replies to its own commands, and only those unless asked otherwise (Q206): the
// console knows who each line was printed for (Console::Origin) and fills a ring that exists only
// while the App is open. Nothing is kept once it is left.
class ShellApp : public App {
public:
using Run = std::function<void(const std::string& line)>;
// What `rm` is pointed at: it decides whether the Shell asks first.
enum class Target { Missing, File, EmptyFolder, FullFolder };
using Probe = std::function<Target(const std::string& path)>;
// A folder's entries that start with `prefix`, whatever their case, a folder's with a slash at its
// end: what Tab completes a path from. `more` when there were too many to give them all.
using List = std::function<std::vector<std::string>(const std::string& folder, const std::string& prefix, bool& more)>;
// How many names a pattern matches (/notes/*.txt), for the question `rm` asks; `more` past the limit.
using Count = std::function<int(const std::string& pattern, bool& more)>;
ShellApp(Run run, Probe probe, List list, Count count, const char* helpText, AppManager& apps)
: run_(std::move(run)), probe_(std::move(probe)), list_(std::move(list)), count_(std::move(count)), helpText_(helpText), apps_(apps) {}
void onEnter() override;
void onExit() override;
bool onKey(const KeyEvent& e) override;
bool textEntryActive() const override { return !confirm_; }
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
private:
void enter(const std::string& line);
void runNow(const std::string& line);
Run run_;
Probe probe_;
List list_;
Count count_;
const char* helpText_;
AppManager& apps_;
ShellLog log_;
std::string ownHelp_; // the Shell's own commands and the keys `key` takes, in the form of `help`'s text
LineEditor input_{240};
InputHistory history_{16};
std::unique_ptr<DialogModel> confirm_;
std::string pending_, question_; // the `rm` being asked about, and what's asked
uint32_t ringPos_ = 0, seenRevision_ = 0;
int scroll_ = 0; // wrapped lines scrolled back from the bottom
bool open_ = false;
};
} // namespace roro
+6 -18
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "storage_app.h" #include "storage_app.h"
#include <Arduino.h> #include <Arduino.h>
@@ -279,26 +280,13 @@ void StorageApp::help(std::vector<KeyHelp>& out) const {
case View::Maintenance: return maintenance_.help(out); case View::Maintenance: return maintenance_.help(out);
case View::Editor: return noteEditor_.help(out); case View::Editor: return noteEditor_.help(out);
case View::Viewer: return viewer_.help(out); case View::Viewer: return viewer_.help(out);
case View::Details: case View::Details: return keys::add(out, keys::kStorageDetails);
out.push_back({"; .", "scroll"});
out.push_back({"Enter", "back to the folder"});
return;
default: break; default: break;
} }
if (dialog_) return help::dialog(out); if (dialog_) return keys::add(out, keys::kDialog);
if (wantList_ || wait_ != Wait::None) return (void)out.push_back({"`", "stop the copy or the delete"}); if (wantList_ || wait_ != Wait::None) return keys::add(out, keys::kStorageBusy);
if (view_ == View::Name) return help::textEntry(out, renaming_ ? "rename it" : "make the folder"); if (view_ == View::Name) return keys::add(out, keys::kStorageName);
help::list(out, "open the folder or the file"); keys::add(out, keys::kStorage);
out.push_back({", /", "a page up, down"});
out.push_back({"c x", "copy, cut"});
out.push_back({"v", "paste here"});
out.push_back({"r", "rename"});
out.push_back({"d Del", "delete, after asking"});
out.push_back({"n", "a new folder"});
out.push_back({"i", "details: size, date, type"});
out.push_back({"s", "sort: name, date, size"});
out.push_back({"m", "Maintenance: clean-up, erase"});
out.push_back({"`", "the folder above"});
} }
const char* StorageApp::helpTitle() const { const char* StorageApp::helpTitle() const {
+4 -7
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "system_app.h" #include "system_app.h"
#include <Arduino.h> #include <Arduino.h>
@@ -74,13 +75,9 @@ void SystemApp::onExit() { // nothing is kept while it's closed (Q120)
} }
void SystemApp::help(std::vector<KeyHelp>& out) const { void SystemApp::help(std::vector<KeyHelp>& out) const {
out.push_back({"Tab", "the next view"}); if (view_ == View::Tasks) keys::add(out, keys::kSystemTasks);
out.push_back({"Aa Tab", "the view before"}); else if (view_ == View::System) keys::add(out, keys::kSystemSystem);
if (view_ == View::Tasks) { else keys::add(out, keys::kSystem);
out.push_back({"; .", "scroll"});
out.push_back({"s", "sort: cpu, stack, name"});
}
if (view_ == View::System) out.push_back({"; .", "scroll"});
} }
const char* SystemApp::helpTitle() const { const char* SystemApp::helpTitle() const {
+9 -17
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "wifi_settings_page.h" #include "wifi_settings_page.h"
#include "ipv4.h" #include "ipv4.h"
@@ -449,25 +450,16 @@ void WifiSettingsPage::draw(Canvas& c) {
} }
void WifiSettingsPage::help(std::vector<KeyHelp>& out) const { void WifiSettingsPage::help(std::vector<KeyHelp>& out) const {
if (forgetDialog_) return help::dialog(out); if (forgetDialog_) return keys::add(out, keys::kDialog);
switch (view_) { switch (view_) {
case View::Main: case View::Main: keys::add(out, keys::kWifi); break;
help::list(out, "open, or change"); case View::Servers: keys::add(out, keys::kWifiServers); break;
out.push_back({", /", "switch Wi-Fi on or off"}); case View::Network: keys::add(out, keys::kWifiNetwork); break;
break; case View::Details: keys::add(out, keys::kWifiStatus); break;
case View::Servers: case View::Scan: keys::add(out, keys::kWifiScan); break;
help::list(out, "edit"); case View::Ssid: keys::add(out, keys::kWifiName); break;
out.push_back({", /", "Always use my DNS: on, off"});
break;
case View::Network:
help::list(out, "edit, or forget");
out.push_back({", /", "Automatic or Fixed"});
break;
case View::Details: out.push_back({"Enter", "back"}); break;
case View::Scan: help::list(out, "choose this network"); break;
case View::Ssid: help::textEntry(out, "next: the password"); break;
case View::Password: case View::Password:
case View::Edit: help::textEntry(out); break; case View::Edit: keys::add(out, keys::kText); break;
} }
} }
+5 -11
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "wifi_tools_app.h" #include "wifi_tools_app.h"
#include <M5Cardputer.h> #include <M5Cardputer.h>
@@ -155,17 +156,10 @@ void WifiToolsApp::draw(Canvas& c) {
void WifiToolsApp::help(std::vector<KeyHelp>& out) const { void WifiToolsApp::help(std::vector<KeyHelp>& out) const {
switch (view_) { switch (view_) {
case View::Menu: help::list(out); break; case View::Menu: keys::add(out, keys::kWifiTools); break;
case View::Networks: case View::Networks: keys::add(out, keys::kWifiNetworks); break;
help::list(out, "track its signal"); case View::Channels: break; // nothing but the keys that work everywhere
out.push_back({"s", "sort: signal, channel, name"}); case View::Tracker: keys::add(out, keys::kWifiTracker); break;
out.push_back({"o", "open networks only"});
out.push_back({"h", "hide the hidden ones"});
out.push_back({"w", "strong ones only"});
out.push_back({"l", "log the scans to the card"});
break;
case View::Channels: break;
case View::Tracker: out.push_back({"m", "clicks on or off"}); break;
} }
} }
+274 -14
View File
@@ -3,11 +3,14 @@
#include <SD.h> #include <SD.h>
#include <deque>
#include <atomic> #include <atomic>
#include <memory> #include <memory>
#include "app_manager.h" #include "app_manager.h"
#include "png_rgb332.h"
#include "apps/demo_app.h" #include "apps/demo_app.h"
#include "apps/shell_app.h"
#include "apps/gemini_app.h" #include "apps/gemini_app.h"
#include "apps/gnss_app.h" #include "apps/gnss_app.h"
#include "apps/irc_app.h" #include "apps/irc_app.h"
@@ -151,6 +154,11 @@ extern "C" bool verifyRollbackLater() { return true; } // C linkage, or the wea
size_t getArduinoLoopTaskStackSize() { return 6144; } size_t getArduinoLoopTaskStackSize() { return 6144; }
static void setupSafeMode(int crashes); static void setupSafeMode(int crashes);
static const char* helpText();
static void shellRun(const std::string& line);
static ShellApp::Target shellProbe(const std::string& path);
static std::vector<std::string> shellList(const std::string& folder, const std::string& prefix, bool& more);
static int shellCount(const std::string& pattern, bool& more);
void setup() { void setup() {
nvs.begin(); nvs.begin();
@@ -209,6 +217,7 @@ void setup() {
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)}); apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus)}); apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus)});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)}); apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)});
// Leaving the foreground App makes it save: a note being typed, when the device is powered off. // Leaving the foreground App makes it save: a note being typed, when the device is powered off.
power->beforePowerOff = []() { apps->home(); }; power->beforePowerOff = []() { apps->home(); };
apps->registerApp({"system", "System", false, apps->registerApp({"system", "System", false,
@@ -270,10 +279,13 @@ static void setupSafeMode(int crashes) {
// Dev aid: commands to drive the UI without the keyboard, from the serial port or the Debug Console. // Dev aid: commands to drive the UI without the keyboard, from the serial port or the Debug Console.
static bool listingWanted = false; static bool listingWanted = false;
// Who asked for each reply that is printed later (Console::Origin): the Shell sees its own, only.
static Console::Origin listingFrom, fileOpFrom, updateFrom, tasksFrom;
static void printListingWhenReady() { static void printListingWhenReady() {
if (!listingWanted || !storageService->listingReady()) return; if (!listingWanted || !storageService->listingReady()) return;
listingWanted = false; listingWanted = false;
Console::As as(listingFrom);
auto listing = storageService->listing(); auto listing = storageService->listing();
for (size_t i = 0; i < listing.size(); i++) { for (size_t i = 0; i < listing.size(); i++) {
uint64_t bytes = 0; uint64_t bytes = 0;
@@ -422,10 +434,53 @@ static std::vector<std::string> filesInUse(const std::string& path, bool folder)
// `cp`, `mv`, `rm`, `mkdir`, `du` run as the Storage App's operations do; the result prints here. // `cp`, `mv`, `rm`, `mkdir`, `du` run as the Storage App's operations do; the result prints here.
static bool consoleFileOp = false; static bool consoleFileOp = false;
// A pattern in a path (issue #67): `rm /notes/*.txt` is one `rm` for each name it matches, run one
// after the other, each when the one before is done. 64 at most: more is refused, not started.
constexpr size_t kMaxGlob = 64;
struct QueuedFileCommand {
std::string line;
Console::Origin from;
};
static std::deque<QueuedFileCommand> fileQueue;
static void fileCommand(const String& line);
// The paths a pattern matches, sorted. `more` when there were over kMaxGlob (or over 2000 entries to
// look through); empty when the pattern isn't in the last part of the path alone.
static std::vector<std::string> expandGlob(const std::string& path, bool& more) {
std::vector<std::string> out;
std::string folder, pattern;
more = false;
if (!files::splitGlob(path, folder, pattern) || !storageService->state().present) return out;
storageService->runAndWait([&]() {
File dir = SD.open(folder.c_str());
if (!dir || !dir.isDirectory()) return;
int seen = 0;
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
if (++seen > 2000 || out.size() > kMaxGlob) {
more = true;
break;
}
if (files::globMatch(pattern, f.name())) out.push_back(files::joinPath(folder, f.name()));
}
});
if (out.size() > kMaxGlob) more = true;
std::sort(out.begin(), out.end());
return out;
}
static void fileQueueStep() {
if (fileQueue.empty() || consoleFileOp) return; // the one before is still running
QueuedFileCommand next = std::move(fileQueue.front());
fileQueue.pop_front();
Console::As as(next.from);
fileCommand(next.line.c_str());
}
static void fileOpsStep() { static void fileOpsStep() {
FileOps::Status s; FileOps::Status s;
if (!consoleFileOp || !fileOps->finished(s)) return; if (!consoleFileOp || !fileOps->finished(s)) return;
consoleFileOp = false; consoleFileOp = false;
Console::As as(fileOpFrom);
static const char* const kNames[] = {"", "ls", "du", "cp", "mv", "rm", "mkdir"}; static const char* const kNames[] = {"", "ls", "du", "cp", "mv", "rm", "mkdir"};
const char* name = kNames[static_cast<int>(s.op)]; const char* name = kNames[static_cast<int>(s.op)];
if (!s.error.empty()) console.printf("%s: error %s\n", name, s.error.c_str()); if (!s.error.empty()) console.printf("%s: error %s\n", name, s.error.c_str());
@@ -459,6 +514,7 @@ static void printReleases(bool list) {
static void updateStep() { static void updateStep() {
if (!updateWatch || update->giteaBusy()) return; if (!updateWatch || update->giteaBusy()) return;
Console::As as(updateFrom);
if (updateWatch == 3) { if (updateWatch == 3) {
console.printf("update: probe: %s\n", update->probeResult().c_str()); console.printf("update: probe: %s\n", update->probeResult().c_str());
updateWatch = 0; updateWatch = 0;
@@ -473,6 +529,7 @@ static void updateCommand(const String& args) {
if (update->giteaBusy()) return (void)console.println("update: busy"); if (update->giteaBusy()) return (void)console.println("update: busy");
args == "check" ? update->requestCheck() : update->requestList(); args == "check" ? update->requestCheck() : update->requestList();
updateWatch = args == "check" ? 1 : 2; updateWatch = args == "check" ? 1 : 2;
updateFrom = console.origin();
} else if (args == "status") { } else if (args == "status") {
GiteaReleases& g = update->gitea(); GiteaReleases& g = update->gitea();
console.printf("update: running %s, failed here before: %s, daily check %s, heap %u\n", update->runningVersion().c_str(), console.printf("update: running %s, failed here before: %s, daily check %s, heap %u\n", update->runningVersion().c_str(),
@@ -489,6 +546,7 @@ static void updateCommand(const String& args) {
int space = rest.indexOf(' '); int space = rest.indexOf(' ');
update->requestProbe((space < 0 ? rest : rest.substring(0, space)).c_str(), space < 0 ? "/" : rest.substring(space + 1).c_str()); update->requestProbe((space < 0 ? rest : rest.substring(0, space)).c_str(), space < 0 ? "/" : rest.substring(space + 1).c_str());
updateWatch = 3; updateWatch = 3;
updateFrom = console.origin();
} else if (args.startsWith("damage ")) { // update damage cut <bytes> | flip <offset>: for the next download } else if (args.startsWith("damage ")) { // update damage cut <bytes> | flip <offset>: for the next download
long n = args.substring(args.lastIndexOf(' ') + 1).toInt(); long n = args.substring(args.lastIndexOf(' ') + 1).toInt();
args.startsWith("damage cut") ? update->damageNextDownload(n, -1) : update->damageNextDownload(-1, n); args.startsWith("damage cut") ? update->damageNextDownload(n, -1) : update->damageNextDownload(-1, n);
@@ -508,6 +566,7 @@ static void updateCommand(const String& args) {
static void fileCommand(const String& line) { static void fileCommand(const String& line) {
int space = line.indexOf(' '); int space = line.indexOf(' ');
std::string command = line.substring(0, space).c_str(), rest = line.substring(space + 1).c_str(); std::string command = line.substring(0, space).c_str(), rest = line.substring(space + 1).c_str();
const std::string everything = rest; // rm reads its own switches
bool force = rest.rfind("-f ", 0) == 0; // replace what's there bool force = rest.rfind("-f ", 0) == 0; // replace what's there
if (force) rest = rest.substr(3); if (force) rest = rest.substr(3);
size_t split = rest.find('\t'); // two paths: a tab between them if either has a space size_t split = rest.find('\t'); // two paths: a tab between them if either has a space
@@ -515,13 +574,42 @@ static void fileCommand(const String& line) {
std::string a = rest.substr(0, split), b = split == std::string::npos ? "" : rest.substr(split + 1); std::string a = rest.substr(0, split), b = split == std::string::npos ? "" : rest.substr(split + 1);
std::string why; std::string why;
bool exists = false, folder = false; bool exists = false, folder = false;
if (command == "cancel") return fileOps->cancel(); if (command == "cancel") {
fileQueue.clear(); // and what a pattern had lined up
return fileOps->cancel();
}
// A pattern: the same command for each name it matches.
files::RmArgs rmArgs = command == "rm" ? files::parseRm(everything) : files::RmArgs();
const std::string globbed = command == "rm" ? rmArgs.path : command == "du" ? rest : command == "cp" || command == "mv" ? a : "";
if (files::hasGlob(globbed) && storageService->state().present) {
bool more = false, intoExists = false;
std::string folder, pattern;
std::vector<std::string> paths = expandGlob(globbed, more);
if (!files::splitGlob(globbed, folder, pattern)) why = "a pattern goes in the last part of a path: /notes/*.txt";
else if (more) why = "more than 64 match: a narrower pattern, please";
else if (paths.empty()) why = "nothing matches";
else if ((command == "cp" || command == "mv") && (b.empty() || !fileOps->isFolder(b, intoExists) || !intoExists)) why = "several files go into a folder that exists";
if (!why.empty()) return (void)console.printf("%s: error %s\n", command.c_str(), why.c_str());
console.printf("%s: %u match %s\n", command.c_str(), (unsigned)paths.size(), globbed.c_str());
Console::Origin from = console.origin();
for (auto& p : paths) {
std::string one = command == "rm" ? std::string("rm ") + (rmArgs.recursive ? "-r " : "") + p
: command == "du" ? "du " + p
: command + (force ? " -f " : " ") + p + "\t" + b; // a tab between two paths: either may hold a space
fileQueue.push_back({one, from});
}
return;
}
if (!storageService->state().present) why = "no SD card"; if (!storageService->state().present) why = "no SD card";
else if (command == "du") why = fileOps->count(rest); else if (command == "du") why = fileOps->count(rest);
else if (command == "mkdir") why = fileOps->makeFolder(rest); else if (command == "mkdir") why = fileOps->makeFolder(rest);
else if (command == "rm") { else if (command == "rm") { // as Unix has it: a folder needs -r. The questions are the Shell's (-f is its)
folder = fileOps->isFolder(rest, exists); files::RmArgs args = files::parseRm(everything);
why = exists ? fileOps->remove(rest, folder) : "It isn't there"; folder = fileOps->isFolder(args.path, exists);
if (args.path.empty()) why = "rm [-r] [-f] <path>";
else if (!exists) why = "It isn't there";
else if (folder && !args.recursive) why = "It's a folder: rm -r removes it and what's in it";
else why = fileOps->remove(args.path, folder);
} else if (b.empty()) why = "two paths, please"; } else if (b.empty()) why = "two paths, please";
else { else {
folder = fileOps->isFolder(a, exists); folder = fileOps->isFolder(a, exists);
@@ -533,6 +621,7 @@ static void fileCommand(const String& line) {
} }
if (!why.empty()) return (void)console.printf("%s: error %s\n", command.c_str(), why.c_str()); if (!why.empty()) return (void)console.printf("%s: error %s\n", command.c_str(), why.c_str());
consoleFileOp = true; consoleFileOp = true;
fileOpFrom = console.origin();
} }
static uint32_t loopPasses = 0; // counted in loop(), for `tasks` (issue #40) static uint32_t loopPasses = 0; // counted in loop(), for `tasks` (issue #40)
@@ -542,6 +631,7 @@ static uint32_t tasksPasses = 0;
static void tasksStep() { static void tasksStep() {
if (!tasksPending || static_cast<int32_t>(millis() - tasksDueMs) < 0) return; if (!tasksPending || static_cast<int32_t>(millis() - tasksDueMs) < 0) return;
tasksPending = false; tasksPending = false;
Console::As as(tasksFrom);
system_info::printTasks(console, tasksBefore, tasksTotal); system_info::printTasks(console, tasksBefore, tasksTotal);
console.printf("loop: %lu passes in the last second, chip %.1f C\n", (unsigned long)(loopPasses - tasksPasses), temperatureRead()); console.printf("loop: %lu passes in the last second, chip %.1f C\n", (unsigned long)(loopPasses - tasksPasses), temperatureRead());
std::vector<TaskSample>().swap(tasksBefore); std::vector<TaskSample>().swap(tasksBefore);
@@ -553,10 +643,11 @@ static const char* const kHelp =
"reboot restart\n" "reboot restart\n"
"boot other restart into the other app slot (manual Rollback)\n" "boot other restart into the other app slot (manual Rollback)\n"
"log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n" "log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n"
"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\n" "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)\n"
"screenshot [seconds] the screen as a PNG in /screenshots on the card, now or after a pause\n"
"lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only\n" "lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only\n"
"lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)\n" "lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)\n"
"lora sweep on [from MHz] [to MHz] [step kHz] | off | dump RSSI across a band (863 870 100)\n" "lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100)\n"
"lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)\n" "lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)\n"
"gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)\n" "gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)\n"
"gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n" "gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n"
@@ -569,8 +660,9 @@ static const char* const kHelp =
"gemini get <url> fetch a Gemini page and report header, size, certificate, heap\n" "gemini get <url> fetch a Gemini page and report header, size, certificate, heap\n"
"irc start | irc stop | irc dump | irc say <buffer> <text>\n" "irc start | irc stop | irc dump | irc say <buffer> <text>\n"
"install <path.ota> Update from SD\n" "install <path.ota> Update from SD\n"
"update check | list | status | install <tag> the project's releases on Gitea\n" "update check | update list | update status | update install <tag> the project's releases on Gitea\n"
"sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n" "sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
"Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command\n"
"debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\n" "debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\n"
"debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token\n" "debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token\n"
"crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n" "crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n"
@@ -581,10 +673,64 @@ static const char* const kHelp =
"sd fill <folder> <count> makes that many small files there, to test a crowded folder\n" "sd fill <folder> <count> makes that many small files there, to test a crowded folder\n"
"coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump\n" "coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump\n"
"reset (Debug Console only) restart at once, even if the main loop is stuck\n" "reset (Debug Console only) restart at once, even if the main loop is stuck\n"
"get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py\n" "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\n"
"quit close the Debug Console connection\n" "quit close the Debug Console connection\n"
; ;
static const char* helpText() { return kHelp; }
// `screenshot [seconds]` (issue #67, Q209): the frame as it is composed, as a PNG on the card. The
// pause is for getting to the screen you want: from the Shell, "now" is always the Shell.
static bool shotPending = false;
static Console::Origin shotFrom;
static uint32_t shotDueMs = 0;
static std::atomic<bool> shotSaved{false};
static void saveScreenshot() {
if (!storageService || !storageService->state().present) return (void)console.println("screenshot: error no SD card");
char name[40];
int64_t now = clockService ? clockService->utcNow() : -1;
if (now >= 0) {
time_t t = static_cast<time_t>(now);
struct tm local;
localtime_r(&t, &local);
snprintf(name, sizeof name, "/screenshots/%04d%02d%02d-%02d%02d%02d.png", local.tm_year + 1900, local.tm_mon + 1, local.tm_mday,
local.tm_hour, local.tm_min, local.tm_sec);
} else snprintf(name, sizeof name, "/screenshots/shot-%lu.png", (unsigned long)(millis() / 1000)); // no clock yet
std::string path = name;
Console::Origin from = shotFrom;
storageService->runJob([path, from]() {
Console::As as(from);
Canvas& frame = screen.canvas();
const uint8_t* pixels = static_cast<const uint8_t*>(frame.getBuffer());
int w = frame.width(), h = frame.height();
if (!pixels) return (void)console.println("screenshot: error no frame");
if (!SD.exists("/screenshots")) SD.mkdir("/screenshots");
File f = SD.open(path.c_str(), FILE_WRITE);
if (!f) return (void)console.printf("screenshot: error the card refused to make %s\n", path.c_str());
png::Rgb332Writer writer(w, h, [&f](const uint8_t* data, size_t len) { return f.write(data, len) == len; });
bool ok = writer.begin();
for (int y = 0; ok && y < h; y++) ok = writer.row(pixels + y * w); // read as it stands: it may tear
ok = ok && writer.end();
f.close();
if (!ok) {
SD.remove(path.c_str());
return (void)console.println("screenshot: error the card refused a write");
}
console.printf("screenshot: saved %s (%u bytes)\n", path.c_str(), (unsigned)png::Rgb332Writer::fileSize(w, h));
shotSaved = true; // the main loop says so on the screen, after the picture is taken
});
}
static void screenshotStep() {
if (shotPending && static_cast<int32_t>(millis() - shotDueMs) >= 0) {
shotPending = false;
saveScreenshot();
}
if (shotSaved.exchange(false))
bus.publish(Event::withText(EventType::Notification, "Screenshot saved in /screenshots", static_cast<int32_t>(NotificationLevel::Info)));
}
// Commands that only touch what Safe Mode starts. // Commands that only touch what Safe Mode starts.
static bool safeModeCommand(const String& line) { static bool safeModeCommand(const String& line) {
return line == "help" || line == "info" || line == "tasks" || line == "net" || line == "reboot" || line == "boot other" || return line == "help" || line == "info" || line == "tasks" || line == "net" || line == "reboot" || line == "boot other" ||
@@ -639,11 +785,34 @@ static void runCommand(String line, bool fromSerial = false) {
if (line.isEmpty()) return; if (line.isEmpty()) return;
if (safeMode && !safeModeCommand(line)) return (void)console.println("not available in Safe Mode"); if (safeMode && !safeModeCommand(line)) return (void)console.println("not available in Safe Mode");
if (line == "help") console.print(kHelp); if (line == "help") console.print(kHelp);
if (line[0] >= 'A' && line[0] <= 'Z') { // an App, by its name with a capital
const AppInfo* app = apps ? apps->byCommand(line.c_str()) : nullptr;
if (!app) {
console.printf("No App called %s:", line.c_str());
if (apps)
for (auto* a : apps->visibleApps()) console.printf(" %s", AppManager::commandFor(a->id).c_str());
console.println();
} else if (apps->open(app->id)) console.printf("%s\n", app->title);
else console.println("Not now: Setup is running");
return;
}
if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial); if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial);
if (line == "screenshot" || line.startsWith("screenshot ")) {
uint32_t seconds = constrain(line.substring(10).toInt(), 0, 60);
shotPending = true;
shotFrom = console.origin();
shotDueMs = millis() + seconds * 1000;
if (seconds) console.printf("screenshot: in %lu s\n", (unsigned long)seconds);
return;
}
// Over the Debug Console these never get here: its own task answers them.
if (line == "coredump get" || line == "reset" || line.startsWith("get ") || line.startsWith("put "))
return (void)console.println("Debug Console only: scripts/rdbg.py speaks it");
if (line == "info") { if (line == "info") {
system_info::printSystem(console); system_info::printSystem(console);
console.printf("wifi: %s, ip %s, rssi %d | sd: %s, %u write faults\n", wifi->ssid().c_str(), wifi->ip().c_str(), console.printf("wifi: %s, ip %s, rssi %d | sd: %s, %u write faults\n", wifi->ssid().c_str(), wifi->ip().c_str(),
wifi->rssi(), storageService->state().present ? "present" : "none", (unsigned)sdLastFault().count); wifi->rssi(), storageService->state().present ? "present" : "none", (unsigned)sdLastFault().count);
if (apps) console.printf("app: %s%s\n", apps->foregroundTitle() ? apps->foregroundTitle() : "Launcher", apps->help().isOpen() ? " (the help panel is open)" : "");
console.printf("update: %s | debug console: %s\n", update->onProbation() ? "on probation" : "confirmed", console.printf("update: %s | debug console: %s\n", update->onProbation() ? "on probation" : "confirmed",
!debugConsole->on() ? "off" : debugConsole->clientConnected() ? "on, a client connected" : "on"); !debugConsole->on() ? "off" : debugConsole->clientConnected() ? "on, a client connected" : "on");
system_info::printSlots(console, nvs); system_info::printSlots(console, nvs);
@@ -657,6 +826,7 @@ static void runCommand(String line, bool fromSerial = false) {
tasksDueMs = millis() + 1000; tasksDueMs = millis() + 1000;
tasksPasses = loopPasses; tasksPasses = loopPasses;
tasksPending = true; tasksPending = true;
tasksFrom = console.origin();
} }
if (line == "net") { // bytes each service has read and written since boot (S1, Q121) if (line == "net") { // bytes each service has read and written since boot (S1, Q121)
for (int i = 0; i < static_cast<int>(net::User::Count); i++) { for (int i = 0; i < static_cast<int>(net::User::Count); i++) {
@@ -682,14 +852,21 @@ static void runCommand(String line, bool fromSerial = false) {
fileCommand(line); fileCommand(line);
if (line == "ls" || line.startsWith("ls ")) { if (line == "ls" || line.startsWith("ls ")) {
if (!storageService->state().present) return (void)console.println("sd: no card"); if (!storageService->state().present) return (void)console.println("sd: no card");
std::string path = line.length() > 3 ? line.substring(3).c_str() : "/"; std::string path = line.length() > 3 ? line.substring(3).c_str() : "/", pattern;
storageService->runJob([path]() { // card access stays on the storage task const std::string asked = path;
if (files::hasGlob(path) && !files::splitGlob(asked, path, pattern)) // /notes/*.txt: the folder, and what to show of it
return (void)console.println("ls: error a pattern goes in the last part of a path: /notes/*.txt");
Console::Origin from = console.origin();
storageService->runJob([path, pattern, asked, from]() { // card access stays on the storage task
Console::As as(from);
File dir = SD.open(path.c_str()); File dir = SD.open(path.c_str());
if (!dir || !dir.isDirectory()) return (void)console.printf("ls: %s is not a folder\n", path.c_str()); if (!dir || !dir.isDirectory()) return (void)console.printf("ls: %s is not a folder\n", path.c_str());
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
if (!pattern.empty() && !files::globMatch(pattern, f.name())) continue;
console.printf("%10u %-16s %s%s\n", f.isDirectory() ? 0u : (unsigned)f.size(), console.printf("%10u %-16s %s%s\n", f.isDirectory() ? 0u : (unsigned)f.size(),
files::formatStamp(static_cast<uint32_t>(f.getLastWrite())).c_str(), f.name(), f.isDirectory() ? "/" : ""); files::formatStamp(static_cast<uint32_t>(f.getLastWrite())).c_str(), f.name(), f.isDirectory() ? "/" : "");
console.printf("ls: end of %s\n", path.c_str()); }
console.printf("ls: end of %s\n", asked.c_str());
}); });
} }
if (line.startsWith("install ")) update->installFromSd(line.substring(8).c_str()); // Update from SD if (line.startsWith("install ")) update->installFromSd(line.substring(8).c_str()); // Update from SD
@@ -697,7 +874,9 @@ static void runCommand(String line, bool fromSerial = false) {
int space = line.lastIndexOf(' '); int space = line.lastIndexOf(' ');
std::string folder = line.substring(8, space).c_str(); std::string folder = line.substring(8, space).c_str();
int count = constrain(line.substring(space + 1).toInt(), 0, 1000); int count = constrain(line.substring(space + 1).toInt(), 0, 1000);
storageService->runJob([folder, count]() { Console::Origin from = console.origin();
storageService->runJob([folder, count, from]() {
Console::As as(from);
int made = 0; int made = 0;
for (int i = 1; i <= count; i++) { for (int i = 1; i <= count; i++) {
char name[24]; char name[24];
@@ -883,7 +1062,9 @@ static void runCommand(String line, bool fromSerial = false) {
if (line.startsWith("sd put ")) startUpload(line.substring(7)); // then raw bytes: see uploadStep() if (line.startsWith("sd put ")) startUpload(line.substring(7)); // then raw bytes: see uploadStep()
if (line == "sd card") { // what the card says it is, from its CID register if (line == "sd card") { // what the card says it is, from its CID register
if (!storageService->state().present) return (void)console.println("sd card: no card"); if (!storageService->state().present) return (void)console.println("sd card: no card");
storageService->runJob([]() { Console::Origin from = console.origin();
storageService->runJob([from]() {
Console::As as(from);
uint8_t cid[16]; uint8_t cid[16];
const char* type = SD.cardType() == CARD_SDHC ? "SDHC/SDXC" : SD.cardType() == CARD_SD ? "SDSC" : "MMC or unknown"; const char* type = SD.cardType() == CARD_SDHC ? "SDHC/SDXC" : SD.cardType() == CARD_SD ? "SDSC" : "MMC or unknown";
if (!sdReadCid(cid)) return (void)console.println("sd card: the card didn't answer"); if (!sdReadCid(cid)) return (void)console.println("sd card: the card didn't answer");
@@ -896,6 +1077,7 @@ static void runCommand(String line, bool fromSerial = false) {
if (line == "sd list") { if (line == "sd list") {
storageService->requestListing(); storageService->requestListing();
listingWanted = true; listingWanted = true;
listingFrom = console.origin();
} }
if (line.startsWith("gemini trust ")) { // gemini trust <host> <port> <sha256>: accept a changed certificate if (line.startsWith("gemini trust ")) { // gemini trust <host> <port> <sha256>: accept a changed certificate
char host[96] = "", fp[80] = ""; char host[96] = "", fp[80] = "";
@@ -1009,6 +1191,81 @@ static void runCommand(String line, bool fromSerial = false) {
} }
} }
// What the Shell App runs (issue #67): trusted like USB serial (Q205), and echoed into the console so
// that a session reads the same from afar, except a token being set (Q210).
//
// The App only hands the line over: it is run from the main loop, like the consoles' commands, not
// from inside the key handler. Run from there, `rm` on a folder overflowed the main loop's stack
// (a key from the Debug Console, the App manager, the Shell, then runCommand a second time and
// printf under all of it): the loop has under 2 KB of stack to spare.
static std::deque<std::string> shellLines;
static void shellRun(const std::string& line) {
if (shellLines.size() < 8) shellLines.push_back(line);
}
static void shellCommands() {
while (!shellLines.empty()) {
std::string line = std::move(shellLines.front());
shellLines.pop_front();
Console::As as(Console::Origin::Shell); // what it prints, and what answers it later, is the Shell's
String l = line.c_str();
if (!l.startsWith("debug token ") || l == "debug token new") console.printf("> %s\n", line.c_str());
runCommand(l, true);
}
}
// What a path is, for the Shell to know whether `rm` deserves a question (Q209).
static ShellApp::Target shellProbe(const std::string& path) {
bool exists = false;
bool folder = fileOps->isFolder(path, exists);
if (!exists) return ShellApp::Target::Missing;
if (!folder) return ShellApp::Target::File;
bool empty = true;
storageService->runAndWait([&]() {
File dir = SD.open(path.c_str());
if (dir) {
File first = dir.openNextFile();
empty = !first;
}
});
return empty ? ShellApp::Target::EmptyFolder : ShellApp::Target::FullFolder;
}
// The entries of a folder that Tab could complete to, for the Shell (issue #67). Read on the storage
// task while the main loop waits, so it is held to what a crowded folder allows: 400 entries looked
// at, 24 given back.
static std::vector<std::string> shellList(const std::string& folder, const std::string& prefix, bool& more) {
std::vector<std::string> names;
more = false;
if (!storageService->state().present) return names;
storageService->runAndWait([&]() {
std::string path = folder.size() > 1 ? folder.substr(0, folder.size() - 1) : folder; // without the slash at its end
File dir = SD.open(path.c_str());
if (!dir || !dir.isDirectory()) return;
int seen = 0;
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
if (++seen > 400) {
more = true;
break;
}
std::string name = f.name();
bool fits = name.size() >= prefix.size();
for (size_t i = 0; fits && i < prefix.size(); i++) fits = tolower(static_cast<unsigned char>(name[i])) == tolower(static_cast<unsigned char>(prefix[i]));
if (!fits) continue;
if (names.size() >= 24) {
more = true;
break;
}
names.push_back(name + (f.isDirectory() ? "/" : ""));
}
});
std::sort(names.begin(), names.end());
return names;
}
static int shellCount(const std::string& pattern, bool& more) { return static_cast<int>(expandGlob(pattern, more).size()); }
static void serialCommands() { static void serialCommands() {
if (upload.active()) return readUploadBytes(); // raw file bytes, not commands if (upload.active()) return readUploadBytes(); // raw file bytes, not commands
static String line; static String line;
@@ -1097,12 +1354,15 @@ static void loopPass() {
serialCommands(); serialCommands();
remoteCommands(); remoteCommands();
shellCommands();
tasksStep(); tasksStep();
screenshotStep();
ipTrialStep(); ipTrialStep();
if (noiseTest) noiseTest->step(now); if (noiseTest) noiseTest->step(now);
noteStableOnce(now); noteStableOnce(now);
updateStep(); updateStep();
fileOpsStep(); fileOpsStep();
fileQueueStep();
uploadStep(); uploadStep();
printListingWhenReady(); printListingWhenReady();
M5Cardputer.update(); M5Cardputer.update();
+103 -30
View File
@@ -4,6 +4,7 @@
#include <esp_log.h> #include <esp_log.h>
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <algorithm> #include <algorithm>
#include <cstdio> #include <cstdio>
@@ -35,41 +36,124 @@ void Console::captureEspLogs() {
if (!espLogNext) espLogNext = esp_log_set_vprintf(teeEspLog); // once: it stays, and costs nothing with the ring closed if (!espLogNext) espLogNext = esp_log_set_vprintf(teeEspLog); // once: it stays, and costs nothing with the ring closed
} }
bool Console::openRing() { namespace {
if (ring_) return true;
auto* fresh = static_cast<uint8_t*>(malloc(kRingBytes)); // The tasks printing As the Shell right now, and how deep each is in it. Under ringLock. Two at
// once is the most there is (the main loop, and the storage task finishing a listing).
struct ShellTask {
TaskHandle_t task;
uint8_t depth;
};
ShellTask shellTasks[4] = {};
bool fromShellLocked(TaskHandle_t task) {
for (auto& t : shellTasks)
if (t.depth && t.task == task) return true;
return false;
}
// One ring: the last kRingBytes written, and how many were written in all. Under ringLock.
bool openOne(uint8_t*& ring, uint32_t& head) {
if (ring) return true;
auto* fresh = static_cast<uint8_t*>(malloc(Console::kRingBytes));
if (!fresh) return false; if (!fresh) return false;
portENTER_CRITICAL(&ringLock); portENTER_CRITICAL(&ringLock);
head_ = 0; head = 0;
ring_ = fresh; ring = fresh;
portEXIT_CRITICAL(&ringLock); portEXIT_CRITICAL(&ringLock);
return true; return true;
} }
void Console::closeRing() { void closeOne(uint8_t*& ring) {
portENTER_CRITICAL(&ringLock); portENTER_CRITICAL(&ringLock);
uint8_t* old = ring_; uint8_t* old = ring;
ring_ = nullptr; ring = nullptr;
portEXIT_CRITICAL(&ringLock); portEXIT_CRITICAL(&ringLock);
free(old); free(old);
} }
void writeOne(uint8_t* ring, uint32_t& head, const uint8_t* data, size_t len) { // inside the lock
if (!ring) return;
size_t at = head % Console::kRingBytes;
size_t first = std::min(len, Console::kRingBytes - at);
memcpy(ring + at, data, first);
memcpy(ring, data + first, len - first);
head += len;
}
size_t readOne(const uint8_t* ring, uint32_t head, uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped) {
portENTER_CRITICAL(&ringLock);
size_t n = 0;
skipped = 0;
if (ring) {
uint32_t from = head > Console::kRingBytes ? head - Console::kRingBytes : 0;
skipped = pos < from ? from - pos : 0;
if (pos < from) pos = from;
n = std::min<size_t>(max, head - pos);
size_t at = pos % Console::kRingBytes;
size_t first = std::min(n, Console::kRingBytes - at);
memcpy(out, ring + at, first);
memcpy(out + first, ring, n - first);
pos += n;
}
portEXIT_CRITICAL(&ringLock);
return n;
}
} // namespace
bool Console::openRing() { return openOne(ring_, head_); }
void Console::closeRing() { closeOne(ring_); }
bool Console::openShellRing() { return openOne(shellRing_, shellHead_); }
void Console::closeShellRing() { closeOne(shellRing_); }
void Console::toRing(const uint8_t* data, size_t len) { void Console::toRing(const uint8_t* data, size_t len) {
if (len > kRingBytes) { if (len > kRingBytes) {
data += len - kRingBytes; // only the tail can fit data += len - kRingBytes; // only the tail can fit
len = kRingBytes; len = kRingBytes;
} }
TaskHandle_t task = xTaskGetCurrentTaskHandle();
portENTER_CRITICAL(&ringLock); portENTER_CRITICAL(&ringLock);
if (ring_) { writeOne(ring_, head_, data, len);
size_t at = head_ % kRingBytes; if (shellRing_ && (shellAll_ || fromShellLocked(task))) writeOne(shellRing_, shellHead_, data, len);
size_t first = std::min(len, kRingBytes - at); portEXIT_CRITICAL(&ringLock);
memcpy(ring_ + at, data, first); }
memcpy(ring_, data + first, len - first);
head_ += len; Console::Origin Console::origin() const {
TaskHandle_t task = xTaskGetCurrentTaskHandle();
portENTER_CRITICAL(&ringLock);
bool shell = fromShellLocked(task);
portEXIT_CRITICAL(&ringLock);
return shell ? Origin::Shell : Origin::System;
}
Console::As::As(Origin origin) : entered_(false) {
if (origin != Origin::Shell) return;
TaskHandle_t task = xTaskGetCurrentTaskHandle();
portENTER_CRITICAL(&ringLock);
ShellTask* slot = nullptr;
for (auto& t : shellTasks)
if (t.depth && t.task == task) slot = &t;
if (!slot)
for (auto& t : shellTasks)
if (!t.depth && !slot) slot = &t;
if (slot) {
slot->task = task;
slot->depth++;
entered_ = true;
} }
portEXIT_CRITICAL(&ringLock); portEXIT_CRITICAL(&ringLock);
} }
Console::As::~As() {
if (!entered_) return;
TaskHandle_t task = xTaskGetCurrentTaskHandle();
portENTER_CRITICAL(&ringLock);
for (auto& t : shellTasks)
if (t.depth && t.task == task) t.depth--;
portEXIT_CRITICAL(&ringLock);
}
uint32_t Console::oldest() const { uint32_t Console::oldest() const {
portENTER_CRITICAL(&ringLock); portENTER_CRITICAL(&ringLock);
uint32_t pos = head_ > kRingBytes ? head_ - kRingBytes : 0; uint32_t pos = head_ > kRingBytes ? head_ - kRingBytes : 0;
@@ -78,22 +162,11 @@ uint32_t Console::oldest() const {
} }
size_t Console::readSince(uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped) { size_t Console::readSince(uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped) {
portENTER_CRITICAL(&ringLock); return readOne(ring_, head_, pos, out, max, skipped);
size_t n = 0; }
skipped = 0;
if (ring_) { size_t Console::readShellSince(uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped) {
uint32_t from = head_ > kRingBytes ? head_ - kRingBytes : 0; return readOne(shellRing_, shellHead_, pos, out, max, skipped);
skipped = pos < from ? from - pos : 0;
if (pos < from) pos = from;
n = std::min<size_t>(max, head_ - pos);
size_t at = pos % kRingBytes;
size_t first = std::min(n, kRingBytes - at);
memcpy(out, ring_ + at, first);
memcpy(out + first, ring_, n - first);
pos += n;
}
portEXIT_CRITICAL(&ringLock);
return n;
} }
size_t Console::write(const uint8_t* data, size_t len) { size_t Console::write(const uint8_t* data, size_t len) {
+29
View File
@@ -34,9 +34,38 @@ class Console : public Print {
// The ring only, for output that already reaches the serial port another way. // The ring only, for output that already reaches the serial port another way.
void toRing(const uint8_t* data, size_t len); void toRing(const uint8_t* data, size_t len);
// Who asked for what is being printed. A command typed in the Shell App prints its reply `As`
// the Shell, and so does whatever answers it later from another task (a listing read by the
// storage task, `tasks` a second on): the one that starts the work notes origin() and the one that
// prints takes it back. Everything else is the System's: logs, and the consoles' own commands.
enum class Origin : uint8_t { System, Shell };
Origin origin() const; // of the task that asks, now
class As {
public:
explicit As(Origin origin);
~As();
As(const As&) = delete;
As& operator=(const As&) = delete;
private:
bool entered_;
};
// A second ring of the same kind, for the Shell App (issue #67): open while the App is. It gets
// what is printed As the Shell, and nothing else, so that the Shell shows the replies to its own
// commands and not the rest (Q206); or everything, when asked.
bool openShellRing();
void closeShellRing();
void shellShowsAll(bool all) { shellAll_ = all; }
bool shellShowsAll() const { return shellAll_; }
size_t readShellSince(uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped);
private: private:
uint8_t* ring_ = nullptr; uint8_t* ring_ = nullptr;
uint32_t head_ = 0; // total bytes written since the ring opened; it holds the last kRingBytes of them uint32_t head_ = 0; // total bytes written since the ring opened; it holds the last kRingBytes of them
uint8_t* shellRing_ = nullptr;
uint32_t shellHead_ = 0;
volatile bool shellAll_ = false;
}; };
extern Console console; extern Console console;
+5 -1
View File
@@ -140,6 +140,7 @@ bool GeminiService::fetch(const std::string& url, bool preferSaved) {
bool GeminiService::fetchToConsole(const std::string& url) { bool GeminiService::fetchToConsole(const std::string& url) {
if (busy_) return false; if (busy_) return false;
toConsole_ = true; toConsole_ = true;
consoleFrom_ = console.origin();
if (!fetch(url)) { if (!fetch(url)) {
toConsole_ = false; toConsole_ = false;
return false; return false;
@@ -380,7 +381,10 @@ void GeminiService::runFetch(GeminiPage& page) {
} }
} }
page.ms = millis() - started; page.ms = millis() - started;
if (toConsole_) report(page, before, lowest_); if (toConsole_) {
Console::As as(consoleFrom_);
report(page, before, lowest_);
}
} }
// One request, following up to kMaxRedirects redirects. // One request, following up to kMaxRedirects redirects.
+2
View File
@@ -6,6 +6,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "platform/console.h"
#include "event_bus.h" #include "event_bus.h"
#include "gemini_response.h" #include "gemini_response.h"
#include "key_value_store.h" #include "key_value_store.h"
@@ -95,6 +96,7 @@ class GeminiService {
// `gemini get <url>`: the same fetch, reported on the console instead of to the App. // `gemini get <url>`: the same fetch, reported on the console instead of to the App.
bool fetchToConsole(const std::string& url); bool fetchToConsole(const std::string& url);
Console::Origin consoleFrom_ = Console::Origin::System; // who asked for that fetch: its report is theirs
private: private:
enum class Job { Fetch, Save, SaveWithLinks, Refresh, Delete, Bookmark, Download, Window }; enum class Job { Fetch, Save, SaveWithLinks, Refresh, Delete, Bookmark, Download, Window };
@@ -260,6 +260,19 @@ void test_the_help_panel_scrolls_within_its_rows() {
TEST_ASSERT_EQUAL(0, m.rows().size()); TEST_ASSERT_EQUAL(0, m.rows().size());
} }
// Issue #67: an App is opened by its name with a capital letter.
void test_an_app_command_is_its_id_with_a_capital() {
TEST_ASSERT_EQUAL_STRING("Notes", AppManager::commandFor("notes").c_str());
TEST_ASSERT_EQUAL_STRING("Wifi", AppManager::commandFor("wifi-tools").c_str());
TEST_ASSERT_EQUAL_STRING("Irc", AppManager::commandFor("irc").c_str());
Fixture f;
TEST_ASSERT_NOT_NULL(f.manager.byCommand("Notes"));
TEST_ASSERT_EQUAL_STRING("notes", f.manager.byCommand("Notes")->id);
TEST_ASSERT_NULL(f.manager.byCommand("notes")); // small letters are commands, not Apps
TEST_ASSERT_NULL(f.manager.byCommand("Demo")); // hidden: not in the Launcher, not here
TEST_ASSERT_NULL(f.manager.byCommand("Nope"));
}
int main() { int main() {
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_launcher_is_in_foreground_after_begin); RUN_TEST(test_launcher_is_in_foreground_after_begin);
@@ -283,5 +296,6 @@ int main() {
RUN_TEST(test_open_help_takes_every_key_and_any_but_the_arrows_closes_it); RUN_TEST(test_open_help_takes_every_key_and_any_but_the_arrows_closes_it);
RUN_TEST(test_help_works_in_a_modal_app_and_closes_when_the_app_changes); RUN_TEST(test_help_works_in_a_modal_app_and_closes_when_the_app_changes);
RUN_TEST(test_the_help_panel_scrolls_within_its_rows); RUN_TEST(test_the_help_panel_scrolls_within_its_rows);
RUN_TEST(test_an_app_command_is_its_id_with_a_capital);
return UNITY_END(); return UNITY_END();
} }
+64
View File
@@ -104,6 +104,67 @@ void test_looks_like_text() {
TEST_ASSERT_TRUE(looksLikeText(nullptr, 0)); // an empty file reads as text TEST_ASSERT_TRUE(looksLikeText(nullptr, 0)); // an empty file reads as text
} }
void test_rm_takes_its_switches_like_unix() {
auto a = parseRm("/notes/a.txt");
TEST_ASSERT_FALSE(a.recursive);
TEST_ASSERT_FALSE(a.force);
TEST_ASSERT_EQUAL_STRING("/notes/a.txt", a.path.c_str());
a = parseRm("-r /gemini/saved");
TEST_ASSERT_TRUE(a.recursive);
TEST_ASSERT_FALSE(a.force);
TEST_ASSERT_EQUAL_STRING("/gemini/saved", a.path.c_str());
for (const char* both : {"-rf /x", "-fr /x", "-r -f /x", "-f -r /x", "-R -f /x"}) {
a = parseRm(both);
TEST_ASSERT_TRUE(a.recursive);
TEST_ASSERT_TRUE(a.force);
TEST_ASSERT_EQUAL_STRING("/x", a.path.c_str());
}
a = parseRm("-f /a file with spaces.txt ");
TEST_ASSERT_TRUE(a.force);
TEST_ASSERT_EQUAL_STRING("/a file with spaces.txt", a.path.c_str());
a = parseRm("-x /odd"); // not a switch of rm: taken as the name
TEST_ASSERT_FALSE(a.force);
TEST_ASSERT_EQUAL_STRING("-x /odd", a.path.c_str());
TEST_ASSERT_EQUAL_STRING("", parseRm("-rf").path.c_str());
}
void test_a_pattern_matches_names() {
TEST_ASSERT_TRUE(globMatch("*.txt", "notes.txt"));
TEST_ASSERT_TRUE(globMatch("*.TXT", "notes.txt")); // the card doesn't tell cases apart
TEST_ASSERT_FALSE(globMatch("*.txt", "notes.txt.bak"));
TEST_ASSERT_TRUE(globMatch("*", "anything"));
TEST_ASSERT_TRUE(globMatch("*", ""));
TEST_ASSERT_TRUE(globMatch("a?c", "abc"));
TEST_ASSERT_FALSE(globMatch("a?c", "ac"));
TEST_ASSERT_FALSE(globMatch("a?c", "abbc"));
TEST_ASSERT_TRUE(globMatch("2026-10-0?.log", "2026-10-07.log"));
TEST_ASSERT_TRUE(globMatch("a*b*c", "a-then-b-then-c"));
TEST_ASSERT_TRUE(globMatch("a*b*c", "abbbc"));
TEST_ASSERT_FALSE(globMatch("a*b*c", "a-then-c"));
TEST_ASSERT_TRUE(globMatch("*a", "banana")); // the star has to give back and try again
TEST_ASSERT_FALSE(globMatch("*ab", "banana"));
TEST_ASSERT_TRUE(globMatch("file-000?.txt", "file-0001.txt"));
TEST_ASSERT_TRUE(globMatch("exact", "EXACT"));
TEST_ASSERT_FALSE(globMatch("", "x"));
TEST_ASSERT_TRUE(globMatch("**", "x"));
}
void test_a_path_with_a_pattern_is_taken_apart() {
std::string folder, pattern;
TEST_ASSERT_TRUE(splitGlob("/notes/*.txt", folder, pattern));
TEST_ASSERT_EQUAL_STRING("/notes", folder.c_str());
TEST_ASSERT_EQUAL_STRING("*.txt", pattern.c_str());
TEST_ASSERT_TRUE(splitGlob("/*", folder, pattern));
TEST_ASSERT_EQUAL_STRING("/", folder.c_str());
TEST_ASSERT_EQUAL_STRING("*", pattern.c_str());
TEST_ASSERT_FALSE(splitGlob("/notes/a.txt", folder, pattern)); // no pattern
TEST_ASSERT_FALSE(splitGlob("/*/a.txt", folder, pattern)); // not in a folder's name
TEST_ASSERT_FALSE(splitGlob("/n*/a?.txt", folder, pattern));
TEST_ASSERT_FALSE(splitGlob("*.txt", folder, pattern)); // a path starts at the top
TEST_ASSERT_TRUE(hasGlob("a?"));
TEST_ASSERT_FALSE(hasGlob("/plain/path"));
}
int main() { int main() {
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_path_parts); RUN_TEST(test_path_parts);
@@ -114,5 +175,8 @@ int main() {
RUN_TEST(test_copy_names); RUN_TEST(test_copy_names);
RUN_TEST(test_kinds); RUN_TEST(test_kinds);
RUN_TEST(test_looks_like_text); RUN_TEST(test_looks_like_text);
RUN_TEST(test_rm_takes_its_switches_like_unix);
RUN_TEST(test_a_pattern_matches_names);
RUN_TEST(test_a_path_with_a_pattern_is_taken_apart);
return UNITY_END(); return UNITY_END();
} }
+115
View File
@@ -0,0 +1,115 @@
#include <unity.h>
#include <cstring>
#include <string>
#include <vector>
#include "png_rgb332.h"
using namespace roro::png;
void setUp() {}
void tearDown() {}
static uint32_t be(const std::vector<uint8_t>& d, size_t at) {
return (uint32_t(d[at]) << 24) | (uint32_t(d[at + 1]) << 16) | (uint32_t(d[at + 2]) << 8) | d[at + 3];
}
void test_the_checksums_match_their_reference_values() {
const char* nine = "123456789";
TEST_ASSERT_EQUAL_HEX32(0xCBF43926, crc32(0, reinterpret_cast<const uint8_t*>(nine), 9));
TEST_ASSERT_EQUAL_HEX32(0x11E60398, adler32(1, reinterpret_cast<const uint8_t*>("Wikipedia"), 9));
// Running: in two pieces, the same.
uint32_t c = crc32(0, reinterpret_cast<const uint8_t*>(nine), 4);
TEST_ASSERT_EQUAL_HEX32(0xCBF43926, crc32(c, reinterpret_cast<const uint8_t*>(nine + 4), 5));
}
// Walks the chunks of what was written: every length adds up and every CRC is right.
void test_a_small_image_is_a_well_formed_png() {
std::vector<uint8_t> out;
const int w = 4, h = 3;
Rgb332Writer writer(w, h, [&](const uint8_t* d, size_t n) {
out.insert(out.end(), d, d + n);
return true;
});
TEST_ASSERT_TRUE(writer.begin());
const uint8_t rows[3][4] = {{0x00, 0xE0, 0x1C, 0x03}, {0xFF, 0x80, 0x10, 0x02}, {1, 2, 3, 4}};
for (auto& r : rows) TEST_ASSERT_TRUE(writer.row(r));
TEST_ASSERT_FALSE(writer.row(rows[0])); // no more rows than the height
TEST_ASSERT_TRUE(writer.end());
TEST_ASSERT_EQUAL(Rgb332Writer::fileSize(w, h), out.size());
const uint8_t signature[] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
TEST_ASSERT_EQUAL_MEMORY(signature, out.data(), 8);
std::vector<std::string> kinds;
std::vector<uint8_t> idat;
for (size_t at = 8; at < out.size();) {
uint32_t len = be(out, at);
std::string kind(out.begin() + at + 4, out.begin() + at + 8);
kinds.push_back(kind);
TEST_ASSERT_EQUAL_HEX32(crc32(0, out.data() + at + 4, len + 4), be(out, at + 8 + len));
if (kind == "IDAT") idat.assign(out.begin() + at + 8, out.begin() + at + 8 + len);
if (kind == "IHDR") {
TEST_ASSERT_EQUAL(w, be(out, at + 8));
TEST_ASSERT_EQUAL(h, be(out, at + 12));
TEST_ASSERT_EQUAL(8, out[at + 16]);
TEST_ASSERT_EQUAL(3, out[at + 17]); // indexed
}
if (kind == "PLTE") {
TEST_ASSERT_EQUAL(768, len);
const uint8_t* white = out.data() + at + 8 + 255 * 3;
TEST_ASSERT_EQUAL(255, white[0] + 0);
TEST_ASSERT_EQUAL(255, white[1] + 0);
TEST_ASSERT_EQUAL(255, white[2] + 0);
const uint8_t* red = out.data() + at + 8 + 0xE0 * 3;
TEST_ASSERT_EQUAL(255, red[0] + 0);
TEST_ASSERT_EQUAL(0, red[1] + 0);
}
at += 12 + len;
}
TEST_ASSERT_EQUAL(4, kinds.size());
TEST_ASSERT_EQUAL_STRING("IHDR", kinds[0].c_str());
TEST_ASSERT_EQUAL_STRING("PLTE", kinds[1].c_str());
TEST_ASSERT_EQUAL_STRING("IDAT", kinds[2].c_str());
TEST_ASSERT_EQUAL_STRING("IEND", kinds[3].c_str());
// The zlib stream: one stored, final block of (w + 1) * h bytes, then their Adler-32.
const size_t raw = (w + 1) * h;
TEST_ASSERT_EQUAL(2 + 5 + raw + 4, idat.size());
TEST_ASSERT_EQUAL_HEX8(0x78, idat[0]);
TEST_ASSERT_EQUAL(0, ((idat[0] << 8) | idat[1]) % 31); // the header's own check
TEST_ASSERT_EQUAL_HEX8(0x01, idat[2]); // final, stored
TEST_ASSERT_EQUAL(raw, idat[3] | (idat[4] << 8));
TEST_ASSERT_EQUAL(static_cast<uint16_t>(~raw), idat[5] | (idat[6] << 8));
TEST_ASSERT_EQUAL(0, idat[7]); // the first row's filter byte: none
TEST_ASSERT_EQUAL_HEX8(0xE0, idat[9]); // its second pixel
TEST_ASSERT_EQUAL_HEX32(adler32(1, idat.data() + 7, raw), be(idat, 7 + raw));
}
void test_the_screen_fits_and_a_bigger_image_is_refused() {
TEST_ASSERT_EQUAL(8 + 25 + 780 + 12 + (2 + 5 + 241 * 135 + 4) + 12, Rgb332Writer::fileSize(240, 135));
int calls = 0;
Rgb332Writer screen(240, 135, [&](const uint8_t*, size_t) { return ++calls > 0; });
TEST_ASSERT_TRUE(screen.begin());
Rgb332Writer big(320, 240, [](const uint8_t*, size_t) { return true; }); // 77,040 bytes: more than one block holds
TEST_ASSERT_FALSE(big.begin());
}
void test_a_sink_that_fails_stops_it_and_too_few_rows_dont_end() {
Rgb332Writer failing(4, 3, [](const uint8_t*, size_t) { return false; });
TEST_ASSERT_FALSE(failing.begin());
Rgb332Writer shortOne(4, 3, [](const uint8_t*, size_t) { return true; });
TEST_ASSERT_TRUE(shortOne.begin());
const uint8_t row[4] = {};
TEST_ASSERT_TRUE(shortOne.row(row));
TEST_ASSERT_FALSE(shortOne.end());
}
int main(int, char**) {
UNITY_BEGIN();
RUN_TEST(test_the_checksums_match_their_reference_values);
RUN_TEST(test_a_small_image_is_a_well_formed_png);
RUN_TEST(test_the_screen_fits_and_a_bigger_image_is_refused);
RUN_TEST(test_a_sink_that_fails_stops_it_and_too_few_rows_dont_end);
return UNITY_END();
}
+183
View File
@@ -0,0 +1,183 @@
#include <unity.h>
#include <string>
#include <vector>
#include "shell_log.h"
using namespace roro;
void setUp() {}
void tearDown() {}
static void feed(ShellLog& log, const std::string& text) { log.feed(text.data(), text.size()); }
void test_lines_arrive_in_pieces() {
ShellLog log;
feed(log, "firmware: roro");
TEST_ASSERT_EQUAL(0, log.lines().size());
feed(log, "9stack\r\nuptime: 1s\n");
TEST_ASSERT_EQUAL(2, log.lines().size());
TEST_ASSERT_EQUAL_STRING("firmware: roro9stack", log.lines()[0].c_str());
TEST_ASSERT_EQUAL_STRING("uptime: 1s", log.lines()[1].c_str());
}
void test_the_heap_line_is_never_kept() {
ShellLog log;
feed(log, "status: heap 105000 min 90000\nirc: connected\n");
TEST_ASSERT_EQUAL(1, log.lines().size());
TEST_ASSERT_EQUAL_STRING("irc: connected", log.lines()[0].c_str());
}
void test_the_oldest_lines_go_past_4_kb() {
ShellLog log;
for (int i = 0; i < 200; i++) feed(log, std::string(49, 'x') + "\n"); // 10 KB in all
size_t bytes = 0;
for (auto& l : log.lines()) bytes += l.size() + 1;
TEST_ASSERT_TRUE(bytes <= ShellLog::kMaxBytes);
TEST_ASSERT_TRUE(log.lines().size() >= 80);
uint32_t before = log.revision();
log.add("> info");
TEST_ASSERT_EQUAL_STRING("> info", log.lines().back().c_str());
TEST_ASSERT_TRUE(log.revision() != before);
log.clear();
TEST_ASSERT_EQUAL(0, log.lines().size());
}
static const char* kHelp =
"info firmware, uptime, memory\n"
"ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> the SD card\n"
"lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio\n"
"lora sweep on [from MHz] [to MHz] | lora sweep off | lora sweep dump RSSI across a band\n"
"gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n"
"log level <0-5> ESP-IDF log level\n"
"wifi ip <ssid> dhcp | wifi ip ... try <seconds> | wifi ip keep a Saved Network's IP setting\n"
"sd card | sd list | cat <path> | log <text> | burst\n"
"debug on | debug token <16 to 64 characters> | debug token new (USB serial only)\n"
"get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary\n"
"Irc | Notes | Storage open that App\n";
static std::string tab(const std::string& typed, std::vector<std::string>* candidates = nullptr) {
std::vector<std::string> matches;
std::string out = completeWords(typed, kHelp, matches);
if (candidates) *candidates = matches;
return out;
}
void test_tab_completes_the_first_word() {
std::vector<std::string> m;
TEST_ASSERT_EQUAL_STRING("info ", tab("in", &m).c_str()); // the only one: with its space
TEST_ASSERT_EQUAL(0, m.size());
TEST_ASSERT_EQUAL_STRING("l", tab("l", &m).c_str()); // ls, lora, log: they agree on no more
TEST_ASSERT_EQUAL(3, m.size());
TEST_ASSERT_EQUAL_STRING("lo", tab("lo", &m).c_str()); // lora, log
TEST_ASSERT_EQUAL(2, m.size());
TEST_ASSERT_EQUAL_STRING("mkdir ", tab("m").c_str());
TEST_ASSERT_EQUAL_STRING("zz", tab("zz", &m).c_str()); // nothing: as it was
TEST_ASSERT_EQUAL(0, m.size());
TEST_ASSERT_EQUAL_STRING("", tab("").c_str());
TEST_ASSERT_EQUAL_STRING("Notes ", tab("N").c_str()); // an App: a capital
TEST_ASSERT_EQUAL_STRING("n", tab("n").c_str()); // and no command starts with n here
TEST_ASSERT_EQUAL_STRING("screenshot ", tab("sc").c_str());
}
// Every word of a command, not only the first.
void test_tab_completes_the_words_after_the_first() {
std::vector<std::string> m;
TEST_ASSERT_EQUAL_STRING("lora status ", tab("lora st").c_str());
TEST_ASSERT_EQUAL_STRING("lora s", tab("lora s", &m).c_str()); // status, sweep
TEST_ASSERT_EQUAL(2, m.size());
TEST_ASSERT_EQUAL_STRING("lora ", tab("lora ", &m).c_str()); // everything lora can be followed by
const char* afterLora[] = {"probe", "status", "rx", "preset", "sweep"};
TEST_ASSERT_EQUAL(5, m.size());
for (size_t i = 0; i < m.size(); i++) TEST_ASSERT_EQUAL_STRING(afterLora[i], m[i].c_str());
TEST_ASSERT_EQUAL_STRING("lora rx o", tab("lora rx o", &m).c_str()); // on|off: either
TEST_ASSERT_EQUAL(2, m.size());
TEST_ASSERT_EQUAL_STRING("lora rx off ", tab("lora rx of").c_str());
TEST_ASSERT_EQUAL_STRING("lora sweep dump ", tab("lora sweep d").c_str());
TEST_ASSERT_EQUAL_STRING("gnss track st", tab("gnss track st", &m).c_str()); // start, stop
TEST_ASSERT_EQUAL(2, m.size());
TEST_ASSERT_EQUAL_STRING("gnss track stop ", tab("gnss track sto").c_str());
TEST_ASSERT_EQUAL_STRING("log level ", tab("log l").c_str());
TEST_ASSERT_EQUAL_STRING("wifi ip keep ", tab("wifi ip k").c_str()); // past the <ssid> of its neighbours
TEST_ASSERT_EQUAL_STRING("debug token new ", tab("debug token n").c_str());
TEST_ASSERT_EQUAL_STRING("sd ", tab("sd ", &m).c_str());
TEST_ASSERT_EQUAL(2, m.size()); // card, list
}
// Where a command's words end, Tab has nothing to say: the Shell then tries a path.
void test_tab_stops_where_the_arguments_start() {
std::vector<std::string> m;
TEST_ASSERT_EQUAL_STRING("ls /no", tab("ls /no", &m).c_str());
TEST_ASSERT_EQUAL(0, m.size());
TEST_ASSERT_EQUAL_STRING("rm ", tab("rm ", &m).c_str());
TEST_ASSERT_EQUAL(0, m.size());
TEST_ASSERT_EQUAL_STRING("lora preset Lo", tab("lora preset Lo").c_str()); // a <name>: not a word of the command
TEST_ASSERT_EQUAL_STRING("lora status x", tab("lora status x").c_str());
TEST_ASSERT_EQUAL_STRING("nope st", tab("nope st").c_str());
TEST_ASSERT_EQUAL_STRING("lora st", tab("lora st").c_str()); // two spaces
}
void test_a_later_word_is_taken_apart_as_a_path() {
PathToComplete p;
TEST_ASSERT_FALSE(splitForPath("ls", p)); // still the command
TEST_ASSERT_FALSE(splitForPath("rm -r", p)); // a switch
TEST_ASSERT_FALSE(splitForPath("irc say he", p)); // not a path, and irc doesn't take one
TEST_ASSERT_TRUE(splitForPath("rm -r /notes/sh", p));
TEST_ASSERT_EQUAL_STRING("rm -r ", p.head.c_str());
TEST_ASSERT_EQUAL_STRING("/notes/", p.folder.c_str());
TEST_ASSERT_EQUAL_STRING("sh", p.prefix.c_str());
TEST_ASSERT_TRUE(splitForPath("ls /", p));
TEST_ASSERT_EQUAL_STRING("/", p.folder.c_str());
TEST_ASSERT_EQUAL_STRING("", p.prefix.c_str());
TEST_ASSERT_TRUE(splitForPath("cat no", p)); // a file command: the slash is understood
TEST_ASSERT_EQUAL_STRING("cat ", p.head.c_str());
TEST_ASSERT_EQUAL_STRING("/", p.folder.c_str());
TEST_ASSERT_EQUAL_STRING("no", p.prefix.c_str());
TEST_ASSERT_TRUE(splitForPath("ls ", p));
TEST_ASSERT_EQUAL_STRING("/", p.folder.c_str());
TEST_ASSERT_TRUE(splitForPath("gemini get /x", p)); // any word that starts with a slash
TEST_ASSERT_TRUE(splitForPath("cp /notes/a.txt /gem", p)); // the second path of two
TEST_ASSERT_EQUAL_STRING("cp /notes/a.txt ", p.head.c_str());
TEST_ASSERT_EQUAL_STRING("gem", p.prefix.c_str());
}
void test_tab_completes_a_path() {
std::vector<std::string> root = {"captures/", "gemini/", "gnss/", "irc/", "notes/", "updates/", "wifi/", "readme.txt"};
std::vector<std::string> matches;
PathToComplete p;
splitForPath("ls /no", p);
TEST_ASSERT_EQUAL_STRING("ls /notes/", completePath(p, root, matches).c_str()); // a folder: its slash, to go on
TEST_ASSERT_EQUAL(0, matches.size());
splitForPath("cat /re", p);
TEST_ASSERT_EQUAL_STRING("cat /readme.txt ", completePath(p, root, matches).c_str()); // a file: done, a space
splitForPath("ls /g", p);
TEST_ASSERT_EQUAL_STRING("ls /g", completePath(p, root, matches).c_str()); // gemini, gnss: they agree on no more
TEST_ASSERT_EQUAL(2, matches.size());
TEST_ASSERT_EQUAL_STRING("gemini/", matches[0].c_str());
splitForPath("ls /zz", p);
TEST_ASSERT_EQUAL_STRING("ls /zz", completePath(p, root, matches).c_str()); // nothing: as it was
TEST_ASSERT_EQUAL(0, matches.size());
splitForPath("rm -r /NO", p); // the card doesn't tell cases apart: the name's own case is taken
TEST_ASSERT_EQUAL_STRING("rm -r /notes/", completePath(p, root, matches).c_str());
std::vector<std::string> notes = {"shopping-list.txt", "Shed.txt", "todo.txt"};
splitForPath("cat /notes/sh", p);
TEST_ASSERT_EQUAL_STRING("cat /notes/sh", completePath(p, notes, matches).c_str()); // "sh" and "Sh": as far as they agree
TEST_ASSERT_EQUAL(2, matches.size());
splitForPath("cat /notes/", p);
TEST_ASSERT_EQUAL_STRING("cat /notes/", completePath(p, notes, matches).c_str()); // everything fits: the list
TEST_ASSERT_EQUAL(3, matches.size());
}
int main(int, char**) {
UNITY_BEGIN();
RUN_TEST(test_lines_arrive_in_pieces);
RUN_TEST(test_the_heap_line_is_never_kept);
RUN_TEST(test_the_oldest_lines_go_past_4_kb);
RUN_TEST(test_tab_completes_the_first_word);
RUN_TEST(test_tab_completes_the_words_after_the_first);
RUN_TEST(test_tab_stops_where_the_arguments_start);
RUN_TEST(test_a_later_word_is_taken_apart_as_a_path);
RUN_TEST(test_tab_completes_a_path);
return UNITY_END();
}