Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55c9ad2eb4 | ||
|
|
0fdbb5b1ed | ||
|
|
d17d10948d | ||
|
|
7b5df713ad | ||
|
|
3863d28593 | ||
|
|
c278a06ca1 | ||
|
|
828f7ce821 | ||
|
|
ca5874fe70 | ||
|
|
07ac7ba457 | ||
|
|
942725a047 | ||
|
|
34e6714785 | ||
|
|
82023d36b3 | ||
|
|
7fe8b3d22a | ||
|
|
1c6ae0e04f | ||
|
|
74b7713553 | ||
|
|
6be05b782d | ||
|
|
1874a1b586 | ||
|
|
c68741cc46 | ||
|
|
1353e6a5f9 |
+50
-9
@@ -1,14 +1,31 @@
|
||||
# CI and releases (docs/milestones/R1.md).
|
||||
# A push to main: the host tests, with their coverage of lib/, and the README's badges
|
||||
# published to the branch `badges`.
|
||||
# A pull request: the same tests and coverage, then the release firmware and the Debug Build.
|
||||
# A pull request: the same tests and coverage, then the firmware (from the build cache).
|
||||
# A branch's pushes run nothing by themselves: its pull request runs, once.
|
||||
# A tag v*: all of it, then a Gitea release with the signed Update File.
|
||||
# A tag v*: the tests, then the firmware built once, clean, signed and published as a
|
||||
# Gitea release.
|
||||
# Run by hand: the release of a tag that exists already (the ones from before CI).
|
||||
#
|
||||
# The job runs in a plain Python image, as scripts/ci.sh does on a developer's machine, with the
|
||||
# toolchains in a Docker volume the runner allows (container.valid_volumes: roro9stack-pio): that
|
||||
# volume is the cache. No JavaScript actions, so the image needs no Node: the checkout is git.
|
||||
#
|
||||
# What the volume keeps between runs, and what makes each go stale (issue #74, R1.md):
|
||||
# /pio/packages, /pio/platforms toolchains and the framework: by their versions in platformio.ini
|
||||
# .../framework-arduinoespressif32-libs/.roro-sdkconfig.defaults
|
||||
# the mark that the framework is already rebuilt with our SDK settings: the
|
||||
# project's sdkconfig.defaults, which isn't in git, so that every fresh
|
||||
# checkout rebuilt the framework (260 s). The platform checks its hash
|
||||
# against platformio.ini's settings, and rebuilds if they differ. It is kept
|
||||
# inside the libraries it describes, so it goes when they are reinstalled.
|
||||
# /pio/ci/build-cache PlatformIO's build cache (SCons): objects by the signature of their
|
||||
# sources and command line. For pull requests only: a release compiles
|
||||
# its own sources from nothing.
|
||||
# /pio/ci/ccache the host tests' objects (they're built for coverage, which the build
|
||||
# cache can't keep: it would lose the .gcno files)
|
||||
# /pio/ci/venv PlatformIO and gcovr: delete the folder to upgrade them
|
||||
# To start from nothing (a slow run, about 7 minutes): delete /pio/ci and that .roro-sdkconfig.defaults file.
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
@@ -37,13 +54,23 @@ jobs:
|
||||
env:
|
||||
PLATFORMIO_CORE_DIR: /pio
|
||||
RORO_NO_DOCKER: 1
|
||||
SDK_MARK: /pio/packages/framework-arduinoespressif32-libs/.roro-sdkconfig.defaults
|
||||
CCACHE_DIR: /pio/ci/ccache
|
||||
CCACHE_MAXSIZE: 1G
|
||||
steps:
|
||||
- name: Tools
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends git build-essential openssl >/dev/null
|
||||
pip install -q --no-cache-dir --root-user-action=ignore platformio gcovr
|
||||
pio --version; df -h /pio | tail -1; ls /pio | head
|
||||
apt-get install -y -qq --no-install-recommends git build-essential openssl ccache >/dev/null
|
||||
mkdir -p /pio/ci
|
||||
if [ ! -x /pio/ci/venv/bin/pio ]; then
|
||||
python -m venv /pio/ci/venv
|
||||
/pio/ci/venv/bin/pip install -q --no-cache-dir platformio gcovr
|
||||
fi
|
||||
ln -sf /pio/ci/venv/bin/pio /pio/ci/venv/bin/gcovr /usr/local/bin/
|
||||
pio --version; df -h /pio | tail -1; du -sh /pio/ci/* 2>/dev/null || true
|
||||
# The build cache only grows: start it again past 3 GB (a full set of objects is 160 MB, and each run adds about 40).
|
||||
if [ "$(du -sm /pio/ci/build-cache 2>/dev/null | cut -f1)" -gt 3072 ] 2>/dev/null; then rm -rf /pio/ci/build-cache; fi
|
||||
|
||||
- name: Check out
|
||||
run: |
|
||||
@@ -57,11 +84,20 @@ jobs:
|
||||
|
||||
- name: Host tests, and their coverage of lib/
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
run: scripts/coverage.sh
|
||||
run: |
|
||||
export PATH="/usr/lib/ccache:$PATH" # gcc and g++ through ccache
|
||||
scripts/coverage.sh
|
||||
ccache -s | grep -E 'Hits|Misses' | head -2
|
||||
|
||||
- name: The release firmware and the Debug Build
|
||||
if: github.event_name == 'pull_request' || github.ref_type == 'tag'
|
||||
run: scripts/ci.sh builds
|
||||
# A pull request only: a tag's firmware is built once, by the release step below.
|
||||
- name: The firmware
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
PLATFORMIO_BUILD_CACHE_DIR: /pio/ci/build-cache
|
||||
run: |
|
||||
[ ! -f "$SDK_MARK" ] || cp "$SDK_MARK" sdkconfig.defaults
|
||||
scripts/ci.sh builds
|
||||
cp sdkconfig.defaults "$SDK_MARK" # what the framework in the volume is rebuilt with, now
|
||||
|
||||
# The README's badges are files on a branch of their own, replaced at each push to main and
|
||||
# at each tag (the release badge says which tag is the latest)
|
||||
@@ -103,7 +139,12 @@ jobs:
|
||||
trap 'rm -f "$RORO_OTA_KEY"' EXIT
|
||||
printf '%s\n' "$OTA_SIGNING_KEY" > "$RORO_OTA_KEY"
|
||||
umask 022
|
||||
# The framework rebuilt with our settings is reused if it matches (the platform checks);
|
||||
# the release's own sources are compiled from nothing, with no build cache.
|
||||
[ ! -f "$SDK_MARK" ] || cp "$SDK_MARK" /tmp/release-src/sdkconfig.defaults
|
||||
scripts/release_build.sh /tmp/release-src dist
|
||||
# An old tag has no SDK settings of its own, and no sdkconfig.defaults afterwards: nothing to mark.
|
||||
[ ! -f /tmp/release-src/sdkconfig.defaults ] || [ ! -d "$(dirname "$SDK_MARK")" ] || cp /tmp/release-src/sdkconfig.defaults "$SDK_MARK"
|
||||
|
||||
- name: Publish the release
|
||||
if: steps.release.outputs.tag != ''
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
#
|
||||
# It runs when the site, or a document the site is built from, changes (a pull request, or a push to
|
||||
# main); the firmware workflow (ci.yml) skips a change that touches only these files. A change that
|
||||
# touches both runs both. src/main.cpp is here too: the site's command reference is generated from the
|
||||
# firmware's own `help` text, and this job checks that it is still current.
|
||||
# touches both runs both. src/main.cpp and lib/core/src/app_keys.h are here too: the site's command
|
||||
# reference and its key tables are generated from them, and this job checks that they are still current.
|
||||
name: Site
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', '.gitea/workflows/site.yml']
|
||||
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', 'lib/core/src/app_keys.h', '.gitea/workflows/site.yml']
|
||||
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']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -12,3 +12,6 @@ sdkconfig.*
|
||||
|
||||
# The built site (site/config.toml sends it here)
|
||||
/public/
|
||||
|
||||
# The version, written by scripts/version.py before each build
|
||||
lib/version/src/version_generated.h
|
||||
|
||||
+12
-8
@@ -106,6 +106,14 @@ The regulatory band plan the device transmits under (here EU868). It sets the al
|
||||
**Duty Cycle Budget**:
|
||||
The share of airtime the Region allows this device to transmit. When it's used up, outgoing traffic waits.
|
||||
|
||||
**Shell**:
|
||||
The App that runs the console's commands on the device itself, and shows what the console prints. Trusted like the USB port, not like the network.
|
||||
_Avoid_: terminal, command line, REPL
|
||||
|
||||
**Help panel**:
|
||||
The list of the keys that work on the screen you are on, opened with Fn+h anywhere (or `?` outside Text Entry). Each App answers for its current state; no screen names keys any other way, except the first-start Setup.
|
||||
_Avoid_: hints, cheat sheet, shortcuts bar
|
||||
|
||||
**Text Entry**:
|
||||
When an App is editing text. During Text Entry, `;` `.` `,` `/` type their characters and Fn makes them arrows. Otherwise they are arrows on their own.
|
||||
_Avoid_: edit mode, insert mode
|
||||
@@ -144,7 +152,7 @@ _Avoid_: Settings > Storage
|
||||
Installing a new firmware image without a USB cable: pushed over Wi-Fi from the developer's PC, read from the SD card, or downloaded from the project's Gitea **Release**.
|
||||
|
||||
**Release**:
|
||||
A tag `v*` on the project's Gitea with a signed **Update File**, a factory image for USB, the ELF to decode crashes and checksums, built and published by CI. The device reads them to look for updates. Debug Builds aren't published.
|
||||
A tag `v*` on the project's Gitea with a signed **Update File**, a factory image for USB, the ELF to decode crashes and checksums, built and published by CI. The device reads them to look for updates.
|
||||
_Avoid_: flash, upgrade (alone)
|
||||
|
||||
**Update File**:
|
||||
@@ -160,16 +168,12 @@ Returning automatically to the previous firmware when new firmware resets or cra
|
||||
_Avoid_: revert, downgrade (a downgrade is installing an older version on purpose)
|
||||
|
||||
**Safe Mode**:
|
||||
What the firmware starts instead of everything else after 3 crash restarts in a row: Wi-Fi and Firmware Updates (and the Debug Console in a Debug Build), so it can be fixed without a cable. A normal restart leaves it.
|
||||
What the firmware starts instead of everything else after 3 crash restarts in a row: Wi-Fi and Firmware Updates (and the Debug Console if it's switched on), so it can be fixed without a cable. A normal restart leaves it.
|
||||
_Avoid_: recovery mode, failsafe
|
||||
|
||||
**Debug Build**:
|
||||
A firmware built with the remote debugging aids compiled in (`+debug` in its version). Release builds have none of them.
|
||||
_Avoid_: dev build, test build (a test build is one made to fail on purpose, such as a crashing update)
|
||||
|
||||
**Debug Console**:
|
||||
The console of a Debug Build over Wi-Fi: live log lines and the serial commands, behind a token.
|
||||
_Avoid_: telnet, remote shell
|
||||
The console over Wi-Fi, in every firmware but off until switched on in Settings: live log lines and the serial commands, for whoever holds the device's token.
|
||||
_Avoid_: telnet, remote shell, Debug Build (there is one firmware)
|
||||
|
||||
## Relationships
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262*
|
||||
- Decisions: [docs/adr/](docs/adr/)
|
||||
- Milestones: [docs/milestones/](docs/milestones/)
|
||||
|
||||
## 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). Every screen's keys are one table in `lib/core/src/app_keys.h`: the help panel shows the table of the state an App is in, and the website's key tables are generated from the same file.
|
||||
|
||||
## Requirements
|
||||
|
||||
Only **Docker** is needed. PlatformIO and the ESP32 toolchain run inside a container, and are cached in the `roro9stack-pio` Docker volume. The first build downloads about 1 GB and takes a few minutes.
|
||||
@@ -22,18 +26,18 @@ This runs the host-side unit tests (`test/`, `native` environment), then builds
|
||||
|
||||
`scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure.
|
||||
|
||||
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 4 minutes; later builds take under a minute.
|
||||
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 6 minutes; later builds take about 15 seconds when little has changed. The platform knows the framework is already rebuilt by `sdkconfig.defaults` in the project folder, which it writes and git ignores: delete it and the next build rebuilds the framework. CI keeps that file, a build cache and ccache in its volume (docs/milestones/R1.md).
|
||||
|
||||
## CI and releases
|
||||
|
||||
Gitea Actions (`.gitea/workflows/ci.yml`, docs/milestones/R1.md) runs the host tests on every push to `main`, and on a pull request also builds the release firmware and the Debug Build: changes reach `main` through pull requests. Pushing a tag `v*` runs all of it and publishes a release on Gitea with:
|
||||
Gitea Actions (`.gitea/workflows/ci.yml`, docs/milestones/R1.md) runs the host tests on every push to `main`, and on a pull request also builds the firmware: changes reach `main` through pull requests. Pushing a tag `v*` runs all of it and publishes a release on Gitea with:
|
||||
|
||||
- `roro9stack-<version>.ota`, the signed Update File;
|
||||
- `roro9stack-<version>-factory.bin`, the whole flash image for a first install over USB;
|
||||
- `roro9stack-<version>.elf.gz`, to decode crash reports from that build;
|
||||
- `SHA256SUMS`.
|
||||
|
||||
CI signs with the project's key, held as a repository secret (ADR 0008). Debug Builds are built but never published: each carries its builder's Debug Console token.
|
||||
CI signs with the project's key, held as a repository secret (ADR 0008). There is one firmware: the Debug Console is in every build, switched off until its owner switches it on (ADR 0010).
|
||||
|
||||
`scripts/ota_verify.py <file.ota>` checks an Update File on a PC the way a device does. `scripts/release_build.sh` and `scripts/release_publish.py` are what the workflow runs; they work the same by hand.
|
||||
|
||||
@@ -87,7 +91,7 @@ The connection is checked against the two ISRG roots Let's Encrypt chains end in
|
||||
|
||||
**IRC steps aside.** A secure connection takes about 52 KB of memory at its peak, and IRC's own takes 40 KB of the 107 KB there is. A check or an install you ask for makes IRC disconnect for the few seconds it takes and reconnect afterwards. The daily check never does that: with IRC connected it waits for a moment when IRC isn't, so while IRC stays connected for days it doesn't run, and **Latest release** is the way to check.
|
||||
|
||||
**A Debug Build** shows the latest release but doesn't install it: releases have no Debug Console (they aren't built with one, so that nobody's console token is published), and installing one would take it away. A Debug Build is updated from the PC with `scripts/flash.sh --debug --ota`.
|
||||
**There is no separate Debug Build** any more (ADR 0010): every firmware installs releases, and the Debug Console is a setting, which an update leaves as it was.
|
||||
|
||||
## Networks without DHCP
|
||||
|
||||
@@ -152,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.
|
||||
|
||||
## 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
|
||||
|
||||
`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.
|
||||
@@ -159,16 +167,16 @@ A note holds up to 16 KB while it's edited. A bigger text file opens read-only i
|
||||
| Command | Effect |
|
||||
|---|---|
|
||||
| `burst` | Publishes 5 Notifications at once |
|
||||
| `key up\|down\|left\|right\|select\|back\|home`, or `key <char>` | Injects a key press |
|
||||
| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing) |
|
||||
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
|
||||
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
|
||||
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
|
||||
| `wifi ip <ssid> dhcp` / `wifi ip <ssid> <address>/<prefix> [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Debug Builds: add `try <seconds>` to go back to the previous setting unless `wifi ip keep` follows |
|
||||
| `wifi ip <ssid> dhcp` / `wifi ip <ssid> <address>/<prefix> [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Add `try <seconds>` to go back to the previous setting unless `wifi ip keep` follows |
|
||||
| `wifi dns <a> [b]` / `wifi dns always on\|off` / `wifi ntp <a> [b]` | DNS servers (used on Fixed networks, or always), and NTP servers |
|
||||
| `log <text>` | Appends a line to a test IRC Log (`/irc/dev/#test/<date>.log`) |
|
||||
| `sd card` | What the SD card says it is: type, size, and its identity register (maker, name, revision, serial, date) |
|
||||
| `sd list` | Lists the files of each Storage Clean-up category |
|
||||
| `sd fill <folder> <count>` | Debug Builds: makes that many small files in a folder, to test a crowded one |
|
||||
| `sd fill <folder> <count>` | Makes that many small files in a folder, to test a crowded one |
|
||||
| `cat <path>` | Prints the first ~1.2 KB of a file on the SD card |
|
||||
| `irc start` | Starts the IRC Service (normally done by opening the IRC App) |
|
||||
| `irc stop` | Stops it, as `/quit` does: QUIT if connected, no more retries, and the App stays disconnected until you type |
|
||||
@@ -177,16 +185,18 @@ A note holds up to 16 KB while it's edited. A bigger text file opens read-only i
|
||||
| `irc say <buffer> <text>` | Types into a Buffer, commands included (`irc say 0 /join #test`) |
|
||||
| `irc dump` | Prints IRC status, memory, and the last lines of each Buffer |
|
||||
| `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap, then the address, gateway, DNS and NTP servers in use and where each came from |
|
||||
| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, and both app slots with their versions and OTA states |
|
||||
| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, **which App is in front**, and both app slots with their versions and OTA states |
|
||||
| `Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Shell`, `System`, `Settings` | Opens that App: a capital letter is an App, not a command |
|
||||
| `tasks` | FreeRTOS tasks over the next second: state, priority, lowest free stack, share of a core, each core's load, and how many passes the main loop made |
|
||||
| `net` | Bytes each network service has read and written since boot |
|
||||
| `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) |
|
||||
| `log level <0-5>` | ESP-IDF log level |
|
||||
| `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path |
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (a folder with what's in it), new folder. `-f` replaces a file that's in the way; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
|
||||
| `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead |
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
|
||||
| `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does |
|
||||
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one (not on a Debug Build) |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Debug Builds: 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 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 |
|
||||
| `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor |
|
||||
| `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack |
|
||||
| `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
|
||||
@@ -198,26 +208,31 @@ A note holds up to 16 KB while it's edited. A bigger text file opens read-only i
|
||||
| `gnss track start` / `gnss track stop` | A Track, as `r` in the GNSS App (the reason is printed if it can't start) |
|
||||
| `gnss nmea on` / `gnss nmea off` | Prints each NMEA sentence the receiver sends, as `nmea: …` |
|
||||
| `gnss send <sentence>` | Sends a sentence to the receiver, without the `$` and the checksum (it adds them) |
|
||||
| `lora noise test [gnss\|quiet]` / `lora noise report` | Debug Builds: Sweep under one changed condition at a time to find what raises the noise floor (Wi-Fi goes off for a few seconds); then the result |
|
||||
| `lora inject <hex> [rssi] [snr]` | Debug Builds: a packet into the Scanner as if received (nothing is sent) |
|
||||
| `lora noise test [gnss\|quiet]` / `lora noise report` | Sweep under one changed condition at a time to find what raises the noise floor (Wi-Fi goes off for a few seconds); then the result |
|
||||
| `lora inject <hex> [rssi] [snr]` | A packet into the Scanner as if received (nothing is sent) |
|
||||
| `crash` | The last crash: which firmware, why, task, PC and backtrace (from the core dump in flash) |
|
||||
| `coredump erase` | Forgets the core dump |
|
||||
| `loop spin on` / `loop spin off` | Debug Builds: make the main loop spin without resting, to compare load and radio noise |
|
||||
| `crash abort` / `crash wdt` | Debug Builds: crash on purpose, or hang the main loop until the watchdog fires |
|
||||
| `loop spin on` / `loop spin off` | Make the main loop spin without resting, to compare load and radio noise |
|
||||
| `crash abort` / `crash wdt` | Crash on purpose, or hang the main loop until the watchdog fires |
|
||||
| `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long |
|
||||
| `debug on` / `debug token <value>` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed |
|
||||
| `help` | Lists the commands |
|
||||
|
||||
`scripts/flash.sh` stops a running serial log first, since it would hold the port.
|
||||
|
||||
### Debug Builds and the Debug Console
|
||||
### The Debug Console
|
||||
|
||||
`scripts/flash.sh --debug` (USB) or `scripts/flash.sh --debug --ota <ip>` (Wi-Fi) installs a Debug Build: the same firmware plus the Debug Console on TCP 2323 (ADR 0004). Then, with `RORO_OTA_HOST` set to the device's IP:
|
||||
Every firmware has the Debug Console (ADR 0010): the serial console over Wi-Fi, on TCP 2323. It is **off** until you switch it on in **Settings → Debug Console**, which also makes its token and shows it. With the device on USB, `scripts/flash.sh --debug` flashes it, switches the console on and gives it the token in `~/.config/roro9stack/debug-token`, so nothing has to be typed. Then, with `RORO_OTA_HOST` set to the device's IP:
|
||||
|
||||
```sh
|
||||
scripts/rdbg.py # interactive: the console backlog, live lines, and commands
|
||||
scripts/rdbg.py info # one command and its reply
|
||||
scripts/rdbg.py -b tasks # the same, after the backlog (boot messages and so on)
|
||||
scripts/rdbg.py -t K7QF-3M2X-9WBD-HT4P-6RNC info # another device's token; or $RORO_DEBUG_TOKEN
|
||||
```
|
||||
|
||||
The token never crosses the network: the device sends a challenge and `rdbg.py` answers with its HMAC. Five wrong answers in a row close the console for a minute. `debug status` and `debug off` work from anywhere; `debug on`, `debug token <value>` and `debug token new` work over USB serial only.
|
||||
|
||||
Every command above works there too, plus a few handled by the PC side or the console's own task:
|
||||
|
||||
```sh
|
||||
@@ -231,6 +246,6 @@ scripts/rdbg.py get <card path> [file] # from the SD card
|
||||
|
||||
So a Firmware Update can also go `rdbg.py put roro9stack-….ota` then `rdbg.py install /updates/roro9stack-….ota`: the Update from SD path, without touching the device.
|
||||
|
||||
Every build keeps its ELF in `.pio/elves/` (version and digest in the name) for that; `scripts/decode_backtrace.sh <version|digest> <addresses>` decodes any backtrace by hand.
|
||||
Every build keeps its ELF in `.pio/elves/` (version and digest in the name) for that, and `rdbg.py crash` fetches a release's ELF from Gitea when it isn't there; `scripts/decode_backtrace.sh <version|digest> <addresses>` decodes any backtrace by hand.
|
||||
|
||||
After 3 crash restarts in a row the firmware starts in **Safe Mode** (ADR 0005): only Wi-Fi, Firmware Updates and the Debug Console, so a fix can be pushed as usual. `reboot` leaves it. The token is in `~/.config/roro9stack/debug-token`, made by the first build; keep developing on Debug Builds, so the firmware a Rollback returns to always has the console.
|
||||
After 3 crash restarts in a row the firmware starts in **Safe Mode** (ADR 0005): only Wi-Fi, Firmware Updates and, if it's switched on, the Debug Console, so a fix can be pushed as usual. `reboot` leaves it.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# A Debug Console over Wi-Fi, in Debug Builds only
|
||||
|
||||
**Superseded in part by [ADR 0010](0010-debug-console-in-every-build.md) (2026-10-06):** there is no Debug Build any more. The console is in every firmware, off until switched on, and its token belongs to the device, not to the build. What this record says about how the console works (the ring, commands on the main loop, binary commands on its own task, one client) still holds.
|
||||
|
||||
The goal of Firmware Updates is to manage the device without a cable, and that includes finding out what went wrong. So a **Debug Build** (`cardputer-adv-debug`, `-DRORO_DEBUG`, version suffix `+debug`) adds a **Debug Console** on TCP 2323: the serial console, over Wi-Fi. A client sends a token as its first line, then gets the last 4 KB of console output (boot messages included), every new line live, and runs the same commands as the serial port, plus a few that only make sense remotely. ESP-IDF's own log lines are teed into it.
|
||||
|
||||
It's compiled out of release builds entirely, rather than switched off by a setting. A console that runs commands is a remote control: in a release build, nothing listens.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Safe Mode, crash reports and a watched main loop, in every build
|
||||
|
||||
Rollback protects against new firmware that fails Probation. It does nothing for firmware that was confirmed and crashes later: a corrupt setting, a server that sends something unexpected, a bug that takes an hour to show. Without a cable, such a device would restart forever. Three measures, in release and Debug Builds alike, keep it reachable:
|
||||
Rollback protects against new firmware that fails Probation. It does nothing for firmware that was confirmed and crashes later: a corrupt setting, a server that sends something unexpected, a bug that takes an hour to show. Without a cable, such a device would restart forever. Three measures, in every build, keep it reachable:
|
||||
|
||||
- **Safe Mode.** The firmware counts starts that follow a crash (panic or watchdog) in NVS, first thing at boot. After 3 in a row, it starts only the clock, Wi-Fi, the Update Service and, in a Debug Build, the Debug Console: no Apps, no IRC, no SD card, and a screen that says so with the address to push an update to. Any normal restart (a `reboot`, an update), or a minute of uptime, resets the count.
|
||||
- **Crash reports.** The same boot record keeps which version was running, so after a crash the firmware knows which one crashed, even when a Rollback has switched slots since. ESP-IDF already writes a core dump to its flash partition on a panic; after the restart the firmware prints its summary (task, PC, reason, backtrace) and raises a Notification. The `crash` command shows it again later. In a Debug Build, `scripts/rdbg.py crash` decodes the backtrace and `scripts/rdbg.py coredump` fetches the whole dump for `esp-coredump`, against the ELF of that exact build (`.pio/elves/`, named by version and ELF digest).
|
||||
- **Safe Mode.** The firmware counts starts that follow a crash (panic or watchdog) in NVS, first thing at boot. After 3 in a row, it starts only the clock, Wi-Fi, the Update Service and, if it's switched on, the Debug Console: no Apps, no IRC, no SD card, and a screen that says so with the address to push an update to. Any normal restart (a `reboot`, an update), or a minute of uptime, resets the count.
|
||||
- **Crash reports.** The same boot record keeps which version was running, so after a crash the firmware knows which one crashed, even when a Rollback has switched slots since. ESP-IDF already writes a core dump to its flash partition on a panic; after the restart the firmware prints its summary (task, PC, reason, backtrace) and raises a Notification. The `crash` command shows it again later. With the Debug Console, `scripts/rdbg.py crash` decodes the backtrace and `scripts/rdbg.py coredump` fetches the whole dump for `esp-coredump`, against the ELF of that exact build (`.pio/elves/`, named by version and ELF digest).
|
||||
- **The main loop is watched.** Arduino-ESP32 subscribes only core 0's idle task to the task watchdog, and the main loop runs on core 1: a stuck loop used to hang the device for good, with the screen frozen and the Debug Console unable to run commands. `enableLoopWDT()` makes a loop stuck for 5 s a panic, with a core dump, counted towards Safe Mode. And an installed update no longer depends on the main loop: the Update Service restarts into it by itself after 90 s.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# The Debug Console is in every build, off until its owner switches it on
|
||||
|
||||
There is **one firmware**. The Debug Console of ADR 0004 is compiled into it, and so are the commands that used to exist only in Debug Builds. It listens only while **Settings → Debug Console** is on, which is not the default, and it lets in whoever proves they hold **the device's own token**. The `cardputer-adv-debug` environment, `RORO_DEBUG`, the `+debug` version and the token compiled in from the builder's machine are gone.
|
||||
|
||||
ADR 0004 kept the console out of release builds with one argument: a console that runs commands is a remote control, so in a release nothing should listen. That argument was never whole: the Update Service has listened on TCP 3232 in every build since Firmware Updates exist, guarded by a signature. A listener that is off by default and guarded by a secret the device made itself is the same kind of trade, and what the split cost had grown:
|
||||
|
||||
- **What was tested wasn't what shipped.** Development ran on Debug Builds; releases were a different binary, built to be published and never run by the person who wrote them.
|
||||
- **Debug Builds couldn't be published**, since each carried its builder's token. So the console, `rdbg.py`, screenshots and crash decoding were for whoever built the firmware, and nobody else.
|
||||
- **Rules that existed only to protect the console:** a Debug Build never installed a release (it would have lost the console), `update install … force` to do it anyway, "keep a Debug Build in the fallback slot", versions with `+debug` that had to compare equal to their release.
|
||||
- **CI built two firmwares** on every pull request and every tag.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Off means nothing is there.** With the setting off, no socket listens, the console's task doesn't exist and neither does its 4 KB ring: a device that never uses the console pays for it in flash (30 KB more than the release build was) and 88 bytes of static RAM, measured. A missing or invalid stored setting counts as off.
|
||||
- **The token is the device's.** The first time the console is switched on, the device makes one from its hardware random generator: 100 bits, as 20 characters of Crockford's base32 (`K7QF-3M2X-9WBD-HT4P-6RNC`), shown on the Debug Console page and nowhere else. It can be replaced by one typed by hand, of 16 to 64 characters. It is stored with the other settings and is never printed on a console.
|
||||
- **The token never crosses the network.** The device sends 16 random bytes; the client answers with their HMAC-SHA256 keyed by the token. Someone on the same Wi-Fi who records a login has nothing they can use for the next one.
|
||||
- **Five wrong answers in a row** close the console to everyone for a minute, with a Notification naming the address they came from. A wrong answer still costs a second.
|
||||
- **`DBG` in the Status Bar** while the console listens, bright while a client is connected.
|
||||
- **USB serial can set it up:** `debug on`, `debug token <value>`, `debug token new`. `scripts/flash.sh --debug` uses them to give a freshly flashed device the developer's token. Whoever holds the cable can flash anything anyway (ADR 0003); the console itself can only switch itself off.
|
||||
- **Safe Mode** starts the console if it's switched on: the settings are read before Safe Mode is decided.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **"Nothing listens" now rests on one stored setting**, not on absent code. That setting is typed, validated and host-tested, and its default is off; a bug that flipped it would have to come from code that already runs on the device.
|
||||
- **Whoever has the token and the network has the device:** the console, its keys, the SD card's files, a restart. Not its firmware: an update still has to be signed (ADR 0003).
|
||||
- **The stream is still plain text.** The token is safe from an eavesdropper; what the console prints, and the files it carries, are not. TLS would cost about 52 KB of the 107 KB there is, next to IRC's own connection: not for a console.
|
||||
- **An old `rdbg.py` can't talk to a new firmware**, and the reverse: the first line of the protocol changed. Accepted, with one user.
|
||||
- **A device whose settings are damaged has no console in Safe Mode**, only the signed update push. Before, a Debug Build always had it.
|
||||
- **The commands that crash, damage a download or fill a folder on purpose** are in every build. They need the cable or the token, like everything else.
|
||||
- **Releases already publish their ELF**, so `rdbg.py crash` fetches it when it isn't in `.pio/elves/`: crash reports can be decoded without having built the firmware.
|
||||
+110
-2
@@ -14,7 +14,7 @@ Until now the tests, the builds, the signing and the flashing all happened on on
|
||||
|---|---|
|
||||
| Q151 | A push to `main`: the host tests (with their coverage). A push to another branch: nothing, its pull request is what runs (a branch with an open pull request ran twice, once for each). A pull request: the same and both builds; changes reach `main` through pull requests. A tag `v*`: all of it, then a release. (First: everything on every push, which rebuilt the firmware far more often than anyone looked at it.) |
|
||||
| Q152 | **CI signs.** The signing key is the repository secret `OTA_SIGNING_KEY`; a tag push makes a complete, signed release with no manual step (ADR 0008). |
|
||||
| Q153 | The Debug Build is built in CI with a token of the runner's own, to prove it compiles, and **isn't published**: it would hand everyone its Debug Console token. |
|
||||
| Q153 | *Revised by Q188: there is one firmware.* The Debug Build is built in CI with a token of the runner's own, to prove it compiles, and **isn't published**: it would hand everyone its Debug Console token. |
|
||||
| Q154 | Pull requests from forks don't start a run. |
|
||||
| Q155 | A release carries `roro9stack-<version>.ota` (signed), `-factory.bin` for USB, `.elf.gz` to decode crashes, and `SHA256SUMS`. |
|
||||
| Q156 | Its text is the tag's message, what the files are, and the commits since the tag before. |
|
||||
@@ -64,7 +64,7 @@ The device looks at the project's Gitea for a newer release, says so, and instal
|
||||
| Q168 | A version that failed (rolled back) isn't announced again by the background check until a newer one exists; it can still be installed by hand. |
|
||||
| Q169 | **Older releases:** a list of the last ten, newest first, the running one marked. Installing an older one asks with a stronger warning. |
|
||||
| Q170 | Enter on a release shows its version, date, size and the tag message, with Install. |
|
||||
| Q171 | **Debug Builds** check and show the latest release, but don't install it: it would replace the Debug Build and its console (Q153: Debug Builds aren't published). Their updates come from the PC. |
|
||||
| Q171 | *Revised by Q188: there is no Debug Build, every firmware installs releases.* **Debug Builds** check and show the latest release, but don't install it: it would replace the Debug Build and its console (Q153: Debug Builds aren't published). Their updates come from the PC. |
|
||||
| Q172 | **Memory, as measured:** a TLS connection peaks at about 52 KB of heap, with or without checking the certificate, so it starts with 80 KB free (Q86's 20 KB spare on top), not 55 KB. **A check, list or install someone asked for makes IRC step aside** and come back after; the daily check never does, and with IRC connected it waits. (First: 55 KB and nothing else. With IRC connected a check left 3 KB and a download 836 bytes.) |
|
||||
| Q173 | Left out, each with its issue: installing automatically (#52), a release channel (#53), resuming a download (#54). |
|
||||
| Q174 | Ships as **v0.11.0**. Tested on the device with the real signed releases; a Debug Build command pretends the device runs an older version, so v0.10.0 counts as an update. |
|
||||
@@ -123,3 +123,111 @@ The device looks at the project's Gitea for a newer release, says so, and instal
|
||||
- **A key press during the hold:** Back on the Firmware page while a check is going doesn't cancel it.
|
||||
|
||||
**Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure.
|
||||
|
||||
## One firmware: the Debug Console in every build (issue #68)
|
||||
|
||||
The issue asked for a token that could be set, so that Debug Builds could be published. The design round ended somewhere simpler: no Debug Builds. The console is in every firmware, off until switched on, with a token that belongs to the device (ADR 0010, which supersedes part of ADR 0004).
|
||||
|
||||
### Decisions (design round 2026-10-06)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q188 | **One firmware,** with the Debug Console and the test commands compiled in. The `cardputer-adv-debug` environment, `RORO_DEBUG`, the `+debug` version and `scripts/debug_flags.py` go; CI builds one firmware. Revises Q153 and Q171. |
|
||||
| Q189 | **Off by default,** and off when the stored setting is missing or invalid. Off, nothing listens and no memory is used: the task and the 4 KB ring exist only while it's on. |
|
||||
| Q190 | **Settings → Debug Console:** the switch, where to connect, the token, "New token", "Type a token". It stays on across restarts and in Safe Mode. **`DBG` in the Status Bar** while it listens, bright with a client connected. |
|
||||
| Q191 | **The device makes the token** the first time the console is switched on: 100 bits as 20 characters of Crockford's base32, shown in groups of four. It can be replaced by one typed by hand, of at least 16 characters. Case and dashes don't count. |
|
||||
| Q192 | **USB serial can set it up:** `debug on`, `debug token <value>`, `debug token new`, over USB only; `debug status` and `debug off` from anywhere. `scripts/flash.sh --debug` gives a device the developer's token. The token is never printed. |
|
||||
| Q193 | **Challenge and answer:** the device sends 16 random bytes, the client their HMAC-SHA256 keyed by the token. Five wrong answers in a row close the console for a minute, with a Notification. **Old clients and old firmwares don't talk to each other:** accepted, this far from v1 and with one user. |
|
||||
| Q194 | `rdbg.py` takes the token from **`-t`/`--token`**, then **`$RORO_DEBUG_TOKEN`**, then `~/.config/roro9stack/debug-token`. |
|
||||
| Q195 | **The test commands are in every build** (`crash abort`, `crash wdt`, `update pretend`, `update damage`, `lora inject`, `sd fill`, `loop spin`, `wifi ip … try`). `update install … force` goes: there's nothing left to protect. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/debug`** (host-tested, 9 tests): the token maker, tidying what a person types, HMAC-SHA256 on the project's own SHA-256 (checked against RFC 4231), the answer to a challenge (the same vector as `rdbg.py`'s), a comparison that takes the same time whatever it's given, and the pause after five wrong answers, right across the clock's wrap.
|
||||
- **Two settings,** `DebugConsole` and `DebugToken`, with the others: validated, and invalid stored values count as missing.
|
||||
- **The console's task and ring come and go with the setting.** `Console::openRing` allocates the ring; switching off closes the socket, frees it and ends the task. `tick` follows the settings once a second, so a switch off and on again takes a second or two.
|
||||
- **Measured against the two builds it replaces:** 1,881,799 bytes of flash and 54,700 of static RAM. The release build was 1,851,387 and 54,612 (so 30 KB more flash and 88 bytes more RAM); the Debug Build was 1,874,887 and 58,780 (4 KB of RAM back: the ring is no longer a static array).
|
||||
- **The listener is asked whether it listens.** The framework's `begin()` returns nothing and fails without a word; the task now checks, says so on the console, and tries again every two seconds.
|
||||
- **`debug off <seconds>`** closes the console and brings it back by itself: the only way to try its closing and reopening from afar, since switching it on is for the device and the cable only.
|
||||
- **`scripts/rdbg.py`** answers the challenge, and fetches a release's ELF from Gitea when a crash names a version that isn't in `.pio/elves/`.
|
||||
|
||||
### Checks
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Host tests | 468 pass (456 before) |
|
||||
| The firmware builds | One environment, 1,881,799 bytes of flash used |
|
||||
| Pushed over Wi-Fi to a device running a Debug Build | Installed, restarted; the update port answers and **port 2323 refuses connections**: off by default |
|
||||
| Switched on at the device (Settings → Debug Console) | A token is made and shown. Its first drawing, in bold at normal size, was misread once: it is now at twice the size, in two lines, and `O`, `I` and `L` are taken for `0` and `1` |
|
||||
| A login with `scripts/rdbg.py` | The challenge is answered; `info`, `screenshot` and the backlog work |
|
||||
| `DBG` in the Status Bar | There while the console is on, bright while a client is connected (screenshots) |
|
||||
| `debug on` and `debug token` over the console | Refused: `over USB serial only` |
|
||||
| Six logins with a wrong token | Five are refused, a second apart; the sixth, and the right token after it, get `locked`. After a minute the right token works again. The console's own log and a Notification say so |
|
||||
| Three `crash abort` in a row | **Safe Mode**, with the console reachable: `info` works, `ls /` says `not available in Safe Mode`. `rdbg.py crash` decodes the backtrace to `runCommand` at the `abort()` line. `reboot` leaves Safe Mode |
|
||||
| An update over Wi-Fi with the console on | The setting and the token survive: the console is back by itself after the restart |
|
||||
| `debug off` over the console | The client is dropped and port 2323 refuses connections; the update port still answers |
|
||||
| `debug off 2`, 10 times in a row, then 15 | It came back each time, a second after the pause. Free heap dips by about 270 bytes for each connection the device closes and **is all back two minutes later** (107.2 KB before, 103.2 right after 15, 107.0 at two minutes): TCP holds a closed connection that long. Not a leak, though it looked like one for an hour |
|
||||
| Memory, console on with a client | 108 KB free (the Debug Build it replaces: 104 KB). In Safe Mode: 178 KB free |
|
||||
|
||||
**One false alarm:** after the tests the console "wouldn't come back on". It was off: the setting had been left off by `debug off`, and the page's "Switch it on?" dialog opens on **Cancel**, so Enter twice leaves it off. Nothing was lost.
|
||||
|
||||
**Not checked:** `scripts/flash.sh --debug` over USB (no device on USB here); "New token" and "Type a token" from the page (the code paths are the ones `debug token` uses, which need the cable); the Toast itself on screen (the console is closed while it shows; its Notification is in the log); free memory with the console off, which only the serial port could say (the static figures above are the evidence); fetching a release's ELF, which needs a crash on a released version.
|
||||
|
||||
## CI that doesn't rebuild the world (issue #74)
|
||||
|
||||
A pull request's run took over seven minutes, a tag's thirteen and a half. The goal: a firmware build under a minute.
|
||||
|
||||
### Where the time went (run 81, a pull request, 2026-10-06)
|
||||
|
||||
| Step | Time |
|
||||
|---|---|
|
||||
| Tools (apt, pip) | 15 s |
|
||||
| Check out | 2 s |
|
||||
| Host tests and coverage | 55 s |
|
||||
| **The firmware** | **358 s** |
|
||||
| of which: CMake configuring ESP-IDF | 87 s |
|
||||
| of which: compiling ESP-IDF's libraries | 171 s |
|
||||
| of which: our own build (the Arduino core, the libraries, `src/`) | 91 s |
|
||||
|
||||
A tag's run did the firmware step, then built the same commit again for the release: twice 356 s.
|
||||
|
||||
### Why the framework was rebuilt every time
|
||||
|
||||
The framework is rebuilt with our SDK settings (ADR 0006), and the rebuilt libraries stay in the toolchain volume. But the platform decides whether they match by reading **`sdkconfig.defaults` in the project folder**, whose first line carries a hash of the settings. That file is generated, and not in git. A fresh checkout has none, so the platform concluded "different settings", **reinstalled the framework and rebuilt it**: 260 seconds, at every run, to arrive at the libraries that were already there. On a developer's machine the file is simply still there from the last build, which is why nobody saw it.
|
||||
|
||||
### What changed
|
||||
|
||||
| Change | Effect |
|
||||
|---|---|
|
||||
| **The file is kept in the volume, inside the libraries it describes** (`framework-arduinoespressif32-libs/.roro-sdkconfig.defaults`), copied into the checkout before a build and back after one that passed. The platform still checks its hash against `platformio.ini`: changed settings rebuild, as they must. Kept there and not beside them, it disappears when the libraries are reinstalled, so it can't describe libraries that are gone | 358 s to 92 s |
|
||||
| **The version is no longer a `-D` on every compiler command line.** `scripts/version.py` writes `lib/version/src/version_generated.h` (not in git, written only when it changes), read by one file. Before, every commit recompiled everything, on a developer's machine too, and no cache could have helped | A rebuild with nothing changed: 77 s to 13 s, locally |
|
||||
| **PlatformIO's build cache** (`PLATFORMIO_BUILD_CACHE_DIR`, SCons's CacheDir) in the volume, for the firmware of pull requests: objects by the signature of their sources and command line | 92 s to 26 s, with a new version and one changed file |
|
||||
| **ccache for the host tests.** They are built with coverage counters, and the build cache would return objects without their `.gcno` files; ccache keeps both | 49 s to 33 s. What's left is PlatformIO starting 51 test programs |
|
||||
| **A tag builds its firmware once**, in the release step | minus 6 minutes |
|
||||
| PlatformIO and gcovr in a virtual environment in the volume | a few seconds |
|
||||
|
||||
**A release compiles its own sources from nothing:** it reuses the rebuilt framework (the platform checks the hash) but not the build cache, so no published file contains an object that came from another commit's build.
|
||||
|
||||
### Measured (a development machine, fresh copies of the tree, the same volume)
|
||||
|
||||
| | Before | After |
|
||||
|---|---|---|
|
||||
| The firmware, fresh checkout, nothing cached for it | 358 s | 82 s (it fills the cache) |
|
||||
| The firmware, fresh checkout, a new version and one file changed | 358 s | **27 s** |
|
||||
| Host tests and coverage | 49 s | 33 s |
|
||||
| Rebuilding locally with nothing changed | 77 s | 13 s |
|
||||
|
||||
### Measured on the runner (pull request #76, 2026-10-07)
|
||||
|
||||
| Run | Tools | Tests and coverage | The firmware | The whole job |
|
||||
|---|---|---|---|---|
|
||||
| Before (run 81) | 15 s | 55 s | 358 s | 434 s |
|
||||
| The first with the new workflow: no mark yet, the framework is rebuilt once more and the caches fill | 16 s | 59 s | 354 s | 431 s |
|
||||
| The next commit (only the workflow changed) | 10 s | 36 s | **51 s** | **100 s** |
|
||||
| The same commit again | 10 s | 36 s | **18 s** | **66 s** |
|
||||
|
||||
In the 51-second run, 245 objects came from the cache and 43 were compiled: `version.cpp`, as expected, and all 42 files of `src/`, which had not changed. In the run after it, all 290 came from the cache. So the objects of `src/` made by the run that rebuilt the framework were not reusable by a normal run, and those of a normal run are: the two-pass build that rebuilds the framework compiles `src/` with something different on its command line. It costs one 51-second run after each framework rebuild, which is rare; I did not look for what differs.
|
||||
|
||||
The firmware step with everything cached is 18 seconds: the libraries are downloaded and unpacked (4 s), the dependency scan (5 s), fetching 290 objects, the link and the image (the last 11 s). A pull request that changes a few files should land between that and 51 seconds.
|
||||
|
||||
**What it costs:** the build cache grows by about 40 MB a run (each linked firmware is kept) and is started again past 3 GB; ccache is held to 1 GB.
|
||||
|
||||
@@ -136,3 +136,69 @@ M3 found the LoRa radio's noise floor about 15 dB above what the chip hears alon
|
||||
**Settings > Pause GNSS for LoRa**, off by default: while the LoRa radio listens or sweeps, the GNSS receiver waits in standby, and wakes when the radio goes back to sleep (a Fix again after about 7 s here). Never during a Track. The GNSS App says "GNSS is paused" meanwhile. `gnss quiet on|off` on the console.
|
||||
|
||||
It's off by default because GNSS on by default was decided in M2 (Q58), and from M4 the radio listens all the time: then "pause while listening" means GNSS mostly off, which is a decision about position, the clock and Tracks, for M4's design round (issue #23).
|
||||
|
||||
## The Shell (issue #67)
|
||||
|
||||
The console's commands could only be typed on a PC: over USB, or over Wi-Fi with the Debug Console. A device in a bag, or on a network that is down, could not be asked anything. The Shell is an App that runs the same commands on the device's own screen and keyboard.
|
||||
|
||||
### Decisions (design round 2026-10-07)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q204 | **An App, "Shell", in the Launcher**, in every firmware: its commands already work over USB for anyone holding the device. |
|
||||
| Q205 | **Trusted like USB serial**, not like the network: `debug on` and `debug token` work from it, as they do in Settings. The token is never shown. |
|
||||
| Q206 | *Revised the same day, after trying it.* **It shows the replies to its own commands, and only those.** The console knows who each line is printed for (`Console::Origin`): a command run from the Shell prints as the Shell's, and so does what answers it later from another task, which notes who asked and takes it back when it prints (`ls`, `tasks`, `du`, `cp`, `update check`, `sd list`, `screenshot`, `gemini get`). What USB or the Debug Console asked for, and the system's own lines, are not the Shell's. **Ctrl+b shows everything** instead. The first version kept whatever was printed in the ten seconds after a command, which was a guess, and a noisy one. |
|
||||
| Q207 | **Nothing while it's closed.** Open, a 4 KB ring of the console's and up to 4 KB of lines; both go when the App is left, with the list of commands for Tab. |
|
||||
| Q208 | Enter runs the line; Fn with up and down recalls the last 16; Alt with up and down scrolls back. **Tab completes** the command, **every word of it** *(the first only, at first)*: `lora st` gives `lora status`, `gnss track ` lists `start stop`, `key ` its eleven names. The words come from the firmware's `help` text, read as it is written, so a new command completes without a table to keep. *(Added the same day)* **past the command, a path on the SD card**: a folder keeps its slash to go on from, a file completed whole gets a space, several candidates are listed. Whatever the case typed, the name's own is taken, since the card doesn't tell them apart. After a file command the first slash is understood (`cat no` is `/no`). A name with a space in it isn't completed. |
|
||||
| Q209 | *Revised the same day.* **`rm` is Unix's, with a question.** A folder needs `-r`, here and over the consoles (where `rm <folder>` used to remove it with what was in it). In the Shell, a file, or a folder with something in it, is asked about unless `-f` (`-rf`, `-r -f`); an empty folder with `-r` goes without a word; what `rm` would refuse anyway, it refuses itself. Over the consoles nothing is asked: scripts delete as before. **`screenshot [seconds]`** saves the screen as a PNG in `/screenshots`, now or after a pause, since from the Shell "now" is the Shell. `get`, `put`, `coredump get` and `reset` answer `Debug Console only`. *(Added the same day)* **`*` and `?` in a name**, for `ls`, `du`, `rm`, `cp` and `mv`, here and over the consoles: `rm /notes/*.txt`, `cp /gnss/2026-10-0?.gpx /backup`. In the last part of the path only, any case, as the card has it. It is the same command once for each name matched, in the name's order; `cp` and `mv` then need a folder that exists to put them in. Over 64 matches is refused whole, as is none. In the Shell `rm` with a pattern asks **once**, with the count. |
|
||||
| Q210 | Commands are echoed as `> command` into the console, so a session reads the same from afar. **A token being set is not echoed.** |
|
||||
| Q211 | **Not in Safe Mode**, which starts no Apps: issue #77. |
|
||||
| Q212 | Its keys are a table in `app_keys.h`, so the help panel and the website have them; a page in the user guide. |
|
||||
| Q213 | *Added the same day.* **An App's name with a capital opens it:** `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Notes`, `Shell`, `System`, `Settings` (the App's id, up to its first dash). From the Shell, without going back to the Launcher, and from the consoles too. The capital says "an App": every command of the firmware is in small letters. Tab completes them. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`ShellApp`** (`src/apps/shell_app.cpp`), with its model host-tested in `lib/apps_model/src/shell_log.h`: lines arriving in pieces, the 4 KB limit, the words of every command out of the `help` text (Apps' names included), and Tab.
|
||||
- **Who a line is for:** `Console::As` marks the calling task as printing for the Shell while it lives, and `Console::origin()` lets a command that answers later carry that to wherever it prints. The console has a second ring for the Shell, which gets the lines marked so, or everything.
|
||||
- **The Shell hands its lines to the main loop**, which runs them like the consoles' commands. See below for why.
|
||||
- **`info` says which App is in front** (`app: Shell`): so that a hand driving the device from afar can look before it types.
|
||||
- **`screenshot`** writes the PNG a row at a time with no buffer: 8-bit indexed colour, the 256 colours of RGB332 as the palette, the pixels in one stored deflate block (`lib/files/src/png_rgb332.h`, 4 tests). 33,383 bytes for the 240 x 135 screen. A Toast says so once it is on the card, so the Toast is never in the picture.
|
||||
- **The `help` text lost its shorthand** (`update check | list | status` is written out), since Tab reads its words from there: `|` between two commands, `on|off` between two words, three spaces before the description. The Shell's own words (`clear`, `quit`, `key`'s names) are added in the same notation.
|
||||
- **Tab on a path** reads the folder on the storage task while the main loop waits, so it is bounded: 400 entries looked at, 24 candidates given back, and `...` after the list when there were more.
|
||||
- **A pattern** is matched in `lib/files/src/file_names.h` (`globMatch`, host-tested), and the names it matches are lined up as so many commands, which the main loop runs one after the other as each finishes. `cancel` empties the line-up. 2000 entries looked at, 64 matches at most.
|
||||
- **Cost:** 21 KB of flash, 96 bytes of static RAM. Open: 7 KB of heap (107.6 KB free before, 100.7 with it open, 106.1 after leaving).
|
||||
|
||||
### What went wrong while building it
|
||||
|
||||
**The device crashed, and the test sent a message to an IRC channel.** The first version ran a command from inside the key handler. Driven from the Debug Console, that is: the main loop, a remote command, `key select`, the App manager, the Shell, `runCommand` a second time, the file command, and `printf` under all of it. The main loop has under 2 KB of stack to spare; `rm` on a folder went past it. The crash report decoded to exactly that chain.
|
||||
|
||||
The device restarted into the Launcher, and the test script, which did not look, went on typing. Its next Enter opened IRC, which connected, and a few lines later it typed "No" into a channel and pressed Enter. One word, sent to real people, that can't be taken back.
|
||||
|
||||
Two changes came of it. The Shell now **queues** its line and the main loop runs it, at the same stack depth as a console's command. And `info` reports the App in front, which the test script now checks before every line it types.
|
||||
|
||||
### Checks on the device (2026-10-07, driven over the Debug Console with `key`)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Open it from the Launcher, type `ls /`, Enter | The folders are listed |
|
||||
| Tab on `in` | `info install` is shown and the line stays; on `u` it becomes `update `; on `l`, `log ls lora loop` |
|
||||
| Up | The line before comes back |
|
||||
| `screenshot` | `/screenshots/20261007-104357.png`, 33,383 bytes. Fetched and decoded on the PC: 240 x 135, indexed, every chunk's CRC right, the pixels the Shell's screen |
|
||||
| Output | With a Debug Console client connecting and disconnecting for every key, the Shell shows the commands and their replies and nothing else: `info`, `ls /` (answered by the storage task), `tasks` (answered a second later) |
|
||||
| `rm` on a folder, without `-r` | Refused, the folder stays |
|
||||
| `rm -r` on an empty folder | Removed, no question |
|
||||
| `rm -r` on a folder with files | Asks; Cancel leaves it. `rm -rf` removes it without asking |
|
||||
| `rm` on a file | Asks; Delete removes it |
|
||||
| Tab on a path | `ls /no` becomes `ls /notes/`, and again, with one note in it, the note's whole name and a space. `ls /g` lists `gemini/ gnss/`. `cat no` becomes `cat /notes/`. `rm -r /CAP` becomes `rm -r /captures/`. `ls /zz` stays as it is |
|
||||
| Tab past the first word | `lora st` becomes `lora status `; `gnss tr` becomes `gnss track ` and Tab again lists `start stop`; `key ` lists its eleven names; `upd` becomes `update ` and lists `check list status install` |
|
||||
| `ls` with a pattern | `ls /gt/*.txt` the five files, `ls /gt/*2*` the one, `ls /gt3/*6?.txt` ten of seventy. `ls /g*/x`: refused, a pattern goes in the last part |
|
||||
| `cp /gt/file-000?.txt /gt2`, `du /gt2/*1*` | Five copies; one size |
|
||||
| `rm /gt2/*` in the Shell | "The 5 that match /gt2/\*": Cancel leaves the five. `rm /gt3/*6?.txt`, Delete: ten gone, sixty left. `rm -f /gt2/*`: gone without a question |
|
||||
| `rm /gt/*` where one match is a folder | The files go, the folder stays (no `-r`) |
|
||||
| No match, and seventy | `rm: error nothing matches`; `rm: error more than 64 match: a narrower pattern, please`, and all seventy still there |
|
||||
| `No`, Tab, Enter | Completes to `Notes ` and opens Notes. `Shell` from the Debug Console opens the Shell |
|
||||
| `screenshot 4`, then Home | The picture, taken four seconds later, is of the Launcher: the pause works, and the Toast isn't in it |
|
||||
| `quit` | Back to the Launcher, and the memory comes back |
|
||||
| The help panel in the Shell | Its keys, then the ones that work everywhere |
|
||||
|
||||
**Not checked:** Ctrl+b and the Alt scroll, which `key` can't press (no modifiers): the filter itself is host-tested, the key that flips it is not; the real keyboard altogether; the Toast after a screenshot, which was published but not looked at; and `mv` with a pattern and `cancel` in the middle of a line-up, which share their code with `cp` and `rm` but were not run. The main loop's lowest free stack after all of it: 1.8 KB, where it was.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# U1 — Look and feel
|
||||
|
||||
**Status:** in progress. The help key (issue #69) is merged; the website's key tables generated from the same lists (issue #72) are in a pull request. Screen recording (#17) and the rest of the milestone are not started.
|
||||
|
||||
**Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content.
|
||||
|
||||
## The help key (issue #69)
|
||||
|
||||
Every screen used to say something about its keys, differently: a footer of abbreviations in one place (`c x v:paste r:name d:del n:new i:info s:sort`), a line under a text field in another (`Enter: save \`: cancel`), `Tab: sky` in a corner, and nothing at all in several. About 30 such strings, each costing a line of a small screen, and none of them complete.
|
||||
|
||||
### Decisions (design round 2026-10-07)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q196 | **Fn+h, on every screen,** text fields included (Fn is held, so nothing is typed). **`?` too, outside Text Entry.** |
|
||||
| Q197 | It opens **a panel over the content area**, titled with where you are: the screen's own keys, then an "Everywhere" group (Back, Home, the arrows, the help key). The arrows scroll it; any other key closes it and is not passed on. |
|
||||
| Q198 | **Each App answers "what are your keys right now?"** for the state it is in; pages, viewers, dialogs and text fields answer for themselves, with shared lists for dialogs, lists and text entry. The lists are constants; the panel's rows exist only while it is open. |
|
||||
| Q199 | **Every hint that names a key goes,** text fields included. What stays is state: `REC 12 points`, `LOG 42`, `sort:signal`, `typing`/`saved`, what is waiting to be pasted, the Sweep's floor. Messages were reworded where they named a key ("v pastes a copy of…" is "Copied …: paste it where you like"). |
|
||||
| Q200 | **The first-start Setup keeps its hints,** and is the one place that does: someone in their first minute doesn't know the help key yet. It tells them about it on its first and last screens. |
|
||||
| Q201 | **Loud everywhere else:** the user guide opens with it, the FAQ has it first, and a device set up before this firmware gets one Toast, once: "Fn+h: the keys of any screen". |
|
||||
| Q202 | `key help` over the consoles. Generating the website's key tables from the same lists is a follow-up, not this issue. |
|
||||
| Q203 | The key and the panel first, host-tested; then one App at a time, declaring its keys and losing its hints in the same step; then every screen looked at on the device. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`Key::Help`** from the key mapper: Fn+h in both modes, `?` only outside Text Entry (`lib/input`, 3 tests).
|
||||
- **`App::help()` and `App::helpTitle()`** (`lib/core/src/app.h`), `KeyHelp` rows and `HelpModel` (`key_help.h`). The **App manager** opens the panel, appends the "Everywhere" group, and while it is open takes every key: nothing reaches the App, Home included. It closes when the App changes (5 tests).
|
||||
- **Every App declares its keys by state:** the Launcher, IRC (chat, settings, a field), Wi-Fi Tools (4 views), GNSS, Gemini (page, saved page, address, answer, dialogs), the LoRa Scanner (4 views), Storage (browse, details, a name, the viewer's 6 modes, the editor, Maintenance, busy), Notes (list, editor, a file name), System (5 views), Settings (menu, text, choice, and the Wi-Fi, Firmware and Debug Console pages with their own states), Setup and the widget demo.
|
||||
- **The hints are gone** from all of them. The footers that remain say state only.
|
||||
- **It costs** 8.5 KB of flash and 40 bytes of static RAM.
|
||||
|
||||
### Checks
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Host tests | 476 pass (468 before) |
|
||||
| On the device, `key help` and a screenshot | The Launcher and all nine Apps, and these states: Storage scrolled, System's tasks, a Settings text field, Wi-Fi Tools' networks. The panel is titled with the scope, lists the right keys, scrolls, and closes on Tab |
|
||||
| The one-time Toast | `notification: Fn+h: the keys of any screen` on the first start after the update |
|
||||
|
||||
### One source for the device and the website (issue #72)
|
||||
|
||||
The lists first lived in each App's `help()`, as code. They are now **data, in one file**: `lib/core/src/app_keys.h`, 52 constant tables, each under a comment `// id: Title`. An App's `help()` picks the table of the state it is in. `site/tools/gen_dev_docs.py` reads the same file and writes `site/data/keys.toml`; the `keys` shortcode shows a screen's tables on its guide page, and `/guide/keys/` shows all of them. The Site job fails when the data file is out of date, or when a page asks for a table that doesn't exist, and it now runs when `app_keys.h` changes. A key added to an App shows up on the website without anyone editing a page.
|
||||
|
||||
Three rows lost their second wording on the way, since a table is constant: GNSS's `Tab` and `r`, and the Scanner's `c`, now say both things they do ("record a Track, or stop it") instead of the one that applies. The guide pages keep their written tables too, where they say more than a key list can; those can still drift, and the generated ones under them are the reference.
|
||||
|
||||
**Not checked:** the real Fn+h and `?` on the keyboard (the mapper is host-tested; the device was driven with `key help`); the Setup screens, which only a device that was never set up shows, so their new text has not been seen on a screen; and the states that need something to happen first (a dialog, a copy in progress, a Gemini prompt, a packet's details): their lists were read against the key handling, not looked at.
|
||||
@@ -24,7 +24,7 @@ const RowDef kRows[] = {
|
||||
{Row::Coordinates, Kind::Toggle, "Coordinates"},
|
||||
{Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"},
|
||||
{Row::CheckUpdates, Kind::Toggle, "Check for updates"}, {Row::Firmware, Kind::Page, "Firmware"},
|
||||
{Row::About, Kind::Page, "About"},
|
||||
{Row::DebugConsole, Kind::Page, "Debug Console"}, {Row::About, Kind::Page, "About"},
|
||||
};
|
||||
|
||||
const int kDimSeconds[] = {10, 15, 30, 60, 120, 300};
|
||||
@@ -86,6 +86,7 @@ std::string SettingsMenu::value(int i) const {
|
||||
case Row::Coordinates: return settings_.getBool(Setting::CoordinatesDms) ? "Deg min sec" : "Decimal";
|
||||
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
|
||||
case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
|
||||
case Row::DebugConsole: return settings_.getBool(Setting::DebugConsole) ? "On" : "Off";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace roro {
|
||||
// values, choice lists and validation messages. Rendering and navigation live in the App.
|
||||
class SettingsMenu {
|
||||
public:
|
||||
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, About };
|
||||
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, DebugConsole, About };
|
||||
enum class Kind { Text, Choice, Toggle, Slider, Page };
|
||||
|
||||
explicit SettingsMenu(Settings& settings) : settings_(settings) {}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "key_event.h"
|
||||
#include "key_help.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
@@ -25,6 +28,12 @@ class App {
|
||||
// True while the App is editing text: the arrow keys then type ; . , / and need Fn to move.
|
||||
virtual bool textEntryActive() const { return false; }
|
||||
|
||||
// The keys that work right now, for the help panel (Fn+h): the App's own, in the state it's
|
||||
// in. Back, Home and the arrows are added for it. No screen names keys any other way.
|
||||
virtual void help(std::vector<KeyHelp>& out) const { (void)out; }
|
||||
// What the panel is titled with, when the App's name isn't enough ("Notes: editor").
|
||||
virtual const char* helpTitle() const { return nullptr; }
|
||||
|
||||
// Called every main-loop pass while in the foreground (e.g. to refresh live values).
|
||||
virtual void update(uint32_t nowMs) { (void)nowMs; }
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "app_manager.h"
|
||||
|
||||
#include "app_keys.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace roro {
|
||||
@@ -52,6 +54,22 @@ void AppManager::endModal() {
|
||||
}
|
||||
|
||||
void AppManager::handleKey(const KeyEvent& event) {
|
||||
if (help_.isOpen()) { // scrolls or closes; nothing reaches the App, Home included
|
||||
help_.onKey(event);
|
||||
redraw_ = true;
|
||||
return;
|
||||
}
|
||||
if (event.key == Key::Help) {
|
||||
std::vector<KeyHelp> rows;
|
||||
foreground_->help(rows);
|
||||
rows.push_back({"Everywhere", nullptr});
|
||||
keys::add(rows, keys::kEverywhere);
|
||||
const char* scope = foreground_->helpTitle();
|
||||
const char* app = foregroundTitle();
|
||||
help_.open(scope ? scope : app ? app : "Launcher", std::move(rows));
|
||||
redraw_ = true;
|
||||
return;
|
||||
}
|
||||
if (modal_) {
|
||||
foreground_->onKey(event);
|
||||
return;
|
||||
@@ -64,6 +82,19 @@ void AppManager::handleKey(const KeyEvent& event) {
|
||||
if (!consumed && event.key == Key::Back) home();
|
||||
}
|
||||
|
||||
std::string AppManager::commandFor(const char* id) {
|
||||
std::string command;
|
||||
for (const char* p = id; *p && *p != '-'; p++) command += *p;
|
||||
if (!command.empty() && command[0] >= 'a' && command[0] <= 'z') command[0] = static_cast<char>(command[0] - 'a' + 'A');
|
||||
return command;
|
||||
}
|
||||
|
||||
const AppInfo* AppManager::byCommand(const std::string& command) const {
|
||||
for (auto& info : apps_)
|
||||
if (!info.hidden && commandFor(info.id) == command) return &info;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const char* AppManager::foregroundTitle() const {
|
||||
for (auto& info : apps_)
|
||||
if (info.app == foreground_) return info.title;
|
||||
@@ -77,6 +108,7 @@ bool AppManager::takeRedraw() {
|
||||
}
|
||||
|
||||
void AppManager::switchTo(App& app) {
|
||||
help_.close(); // an App opened from elsewhere (a Notification, a command): its keys, not the last one's
|
||||
if (&app == foreground_) return;
|
||||
foreground_->onExit();
|
||||
foreground_ = &app;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app.h"
|
||||
@@ -34,9 +35,19 @@ class AppManager {
|
||||
void handleKey(const KeyEvent& event);
|
||||
void update(uint32_t nowMs) { foreground_->update(nowMs); }
|
||||
|
||||
// The command that opens an App from the consoles and the Shell (issue #67): its id with a
|
||||
// capital letter, up to the first dash. "notes" is Notes, "wifi-tools" is Wifi. The capital says
|
||||
// "an App", where every command of the firmware is in small letters.
|
||||
static std::string commandFor(const char* id);
|
||||
// The App that command names, among the ones the Launcher lists; nullptr if there's none.
|
||||
const AppInfo* byCommand(const std::string& command) const;
|
||||
|
||||
App& foreground() const { return *foreground_; }
|
||||
const char* foregroundTitle() const; // nullptr for the Launcher
|
||||
|
||||
// The help panel (Fn+h): open, it takes every key, and the App sees none of them.
|
||||
const HelpModel& help() const { return help_; }
|
||||
|
||||
// True once after the screen needs redrawing (App switch, or the App asked for it).
|
||||
bool takeRedraw();
|
||||
|
||||
@@ -47,6 +58,7 @@ class AppManager {
|
||||
App& launcher_;
|
||||
App* foreground_;
|
||||
std::vector<AppInfo> apps_;
|
||||
HelpModel help_;
|
||||
bool redraw_ = true;
|
||||
bool modal_ = false;
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ enum class Key : uint8_t {
|
||||
Home,
|
||||
Tab,
|
||||
Delete,
|
||||
Help, // Fn+h anywhere, or ? outside Text Entry: the keys of this screen (issue #69)
|
||||
};
|
||||
|
||||
struct KeyEvent {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "key_event.h"
|
||||
|
||||
// The help panel (issue #69, docs/milestones/U1.md): Fn+h on any screen lists the keys that work
|
||||
// there. Every App says what its keys are in the state it's in; nothing on a screen names keys.
|
||||
namespace roro {
|
||||
|
||||
// One line of the panel: a key (or keys) and what it does. A line with no action is a heading.
|
||||
struct KeyHelp {
|
||||
const char* keys;
|
||||
const char* action;
|
||||
};
|
||||
|
||||
|
||||
// The panel itself: what it lists, and how far it's scrolled. The keys it takes while open are the
|
||||
// arrows; any other key closes it, and none reaches the App.
|
||||
class HelpModel {
|
||||
public:
|
||||
explicit HelpModel(int visibleRows = 8) : visible_(visibleRows) {}
|
||||
|
||||
void open(const std::string& title, std::vector<KeyHelp> rows) {
|
||||
title_ = title;
|
||||
rows_ = std::move(rows);
|
||||
top_ = 0;
|
||||
open_ = true;
|
||||
}
|
||||
void close() {
|
||||
open_ = false;
|
||||
rows_.clear();
|
||||
rows_.shrink_to_fit(); // nothing is kept while it's closed
|
||||
}
|
||||
bool isOpen() const { return open_; }
|
||||
|
||||
void onKey(const KeyEvent& e) {
|
||||
int last = std::max(0, static_cast<int>(rows_.size()) - visible_);
|
||||
switch (e.key) {
|
||||
case Key::Up: top_ = std::max(0, top_ - 1); break;
|
||||
case Key::Down: top_ = std::min(last, top_ + 1); break;
|
||||
case Key::Left: top_ = std::max(0, top_ - visible_); break;
|
||||
case Key::Right: top_ = std::min(last, top_ + visible_); break;
|
||||
default: close(); break;
|
||||
}
|
||||
}
|
||||
|
||||
const std::string& title() const { return title_; }
|
||||
const std::vector<KeyHelp>& rows() const { return rows_; }
|
||||
int top() const { return top_; }
|
||||
int visibleRows() const { return visible_; }
|
||||
|
||||
private:
|
||||
std::string title_;
|
||||
std::vector<KeyHelp> rows_;
|
||||
int top_ = 0;
|
||||
int visible_;
|
||||
bool open_ = false;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,117 @@
|
||||
#include "debug_auth.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "sha256.h"
|
||||
|
||||
namespace roro::debug {
|
||||
|
||||
namespace {
|
||||
const char kAlphabet[] = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
}
|
||||
|
||||
std::string makeToken(const uint8_t random[kTokenRandom]) {
|
||||
std::string token;
|
||||
uint32_t bits = 0;
|
||||
int have = 0;
|
||||
size_t next = 0;
|
||||
while (token.size() < kTokenChars) {
|
||||
if (have < 5) {
|
||||
bits = (bits << 8) | random[next++];
|
||||
have += 8;
|
||||
}
|
||||
token += kAlphabet[(bits >> (have - 5)) & 31];
|
||||
have -= 5;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
std::string tidyToken(const std::string& typed) {
|
||||
std::string out;
|
||||
for (char c : typed) {
|
||||
if (c == '-' || c == ' ' || c == '\t' || c == '\r' || c == '\n') continue;
|
||||
if (c >= 'a' && c <= 'z') c = static_cast<char>(c - 'a' + 'A');
|
||||
// Crockford's rule for the letters his alphabet leaves out: read as the digit they look like.
|
||||
if (c == 'O') c = '0';
|
||||
if (c == 'I' || c == 'L') c = '1';
|
||||
out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool validToken(const std::string& tidied) {
|
||||
if (tidied.size() < kMinTokenChars || tidied.size() > kMaxTokenChars) return false;
|
||||
for (char c : tidied)
|
||||
if (c <= ' ' || c > '~' || c == '-' || (c >= 'a' && c <= 'z') || c == 'O' || c == 'I' || c == 'L') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string groupToken(const std::string& token) {
|
||||
std::string out;
|
||||
for (size_t i = 0; i < token.size(); i++) {
|
||||
if (i && i % 4 == 0) out += '-';
|
||||
out += token[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void hmacSha256(const uint8_t* key, size_t keyLen, const uint8_t* message, size_t messageLen, uint8_t out[32]) {
|
||||
uint8_t block[64] = {};
|
||||
if (keyLen > sizeof block) Sha256::hash(key, keyLen, block); // a long key is hashed first
|
||||
else memcpy(block, key, keyLen);
|
||||
|
||||
uint8_t pad[64];
|
||||
for (size_t i = 0; i < sizeof pad; i++) pad[i] = block[i] ^ 0x36;
|
||||
uint8_t inner[32];
|
||||
Sha256 in;
|
||||
in.update(pad, sizeof pad);
|
||||
in.update(message, messageLen);
|
||||
in.finish(inner);
|
||||
|
||||
for (size_t i = 0; i < sizeof pad; i++) pad[i] = block[i] ^ 0x5c;
|
||||
Sha256 outer;
|
||||
outer.update(pad, sizeof pad);
|
||||
outer.update(inner, sizeof inner);
|
||||
outer.finish(out);
|
||||
}
|
||||
|
||||
std::string toHex(const uint8_t* data, size_t len) {
|
||||
static const char digits[] = "0123456789abcdef";
|
||||
std::string out;
|
||||
out.reserve(len * 2);
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
out += digits[data[i] >> 4];
|
||||
out += digits[data[i] & 15];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string answerFor(const std::string& token, const uint8_t nonce[kNonceBytes]) {
|
||||
uint8_t mac[32];
|
||||
hmacSha256(reinterpret_cast<const uint8_t*>(token.data()), token.size(), nonce, kNonceBytes, mac);
|
||||
return toHex(mac, sizeof mac);
|
||||
}
|
||||
|
||||
bool sameText(const std::string& a, const std::string& b) {
|
||||
uint8_t diff = a.size() != b.size();
|
||||
for (size_t i = 0; i < b.size(); i++) diff |= static_cast<uint8_t>((i < a.size() ? a[i] : 0) ^ b[i]);
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
bool AuthGate::locked(uint32_t nowMs) {
|
||||
if (locked_ && nowMs - lockedAtMs_ >= kLockMs) { // unsigned: right across the 49-day wrap too
|
||||
locked_ = false;
|
||||
failures_ = 0;
|
||||
}
|
||||
return locked_;
|
||||
}
|
||||
|
||||
bool AuthGate::failed(uint32_t nowMs) {
|
||||
if (locked(nowMs)) return false;
|
||||
if (++failures_ < kMaxFailures) return false;
|
||||
locked_ = true;
|
||||
lockedAtMs_ = nowMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace roro::debug
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
// Who may use the Debug Console (ADR 0010): a token that only the device and its owner know, proved
|
||||
// with a challenge and an answer so that it never crosses the network, and a pause after wrong answers.
|
||||
namespace roro::debug {
|
||||
|
||||
constexpr size_t kTokenChars = 20; // a token the device makes: 100 bits
|
||||
constexpr size_t kTokenRandom = 13; // the random bytes it takes
|
||||
constexpr size_t kMinTokenChars = 16; // a token typed by hand, once tidied
|
||||
constexpr size_t kMaxTokenChars = 64;
|
||||
constexpr size_t kNonceBytes = 16;
|
||||
|
||||
// A token from random bytes: 20 characters of Crockford's base32 (no I, L, O or U to misread).
|
||||
std::string makeToken(const uint8_t random[kTokenRandom]);
|
||||
|
||||
// What a person typed, as it's stored and compared: no dashes or spaces, in capitals, and with O
|
||||
// read as 0, I and L as 1. So a token can be read off the screen in groups, typed in any case,
|
||||
// and the usual misreadings don't matter.
|
||||
std::string tidyToken(const std::string& typed);
|
||||
|
||||
// A tidied token that may be stored: 16 to 64 printable ASCII characters, as tidyToken leaves them.
|
||||
bool validToken(const std::string& tidied);
|
||||
|
||||
// For the screen: K7QF-3M2X-9WBD-HT4P-6RNC.
|
||||
std::string groupToken(const std::string& token);
|
||||
|
||||
void hmacSha256(const uint8_t* key, size_t keyLen, const uint8_t* message, size_t messageLen, uint8_t out[32]);
|
||||
|
||||
std::string toHex(const uint8_t* data, size_t len);
|
||||
|
||||
// What a client must send back for a challenge: HMAC-SHA256 of the nonce, keyed by the token, in hex.
|
||||
std::string answerFor(const std::string& token, const uint8_t nonce[kNonceBytes]);
|
||||
|
||||
// Compares without stopping at the first difference, so timing says nothing about the answer.
|
||||
bool sameText(const std::string& a, const std::string& b);
|
||||
|
||||
// Five wrong answers in a row, from anyone, and nobody is listened to for a minute.
|
||||
class AuthGate {
|
||||
public:
|
||||
static constexpr int kMaxFailures = 5;
|
||||
static constexpr uint32_t kLockMs = 60000;
|
||||
|
||||
bool locked(uint32_t nowMs);
|
||||
// A wrong answer. True if it's the one that starts the pause.
|
||||
bool failed(uint32_t nowMs);
|
||||
void succeeded() { failures_ = 0; }
|
||||
int failures() const { return failures_; }
|
||||
|
||||
private:
|
||||
int failures_ = 0;
|
||||
bool locked_ = false;
|
||||
uint32_t lockedAtMs_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro::debug
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
namespace roro::files {
|
||||
|
||||
const char* const kFirmwareFolders[] = {"/irc", "/wifi", "/updates", "/gnss", "/gemini", "/captures", "/notes"};
|
||||
const char* const kFirmwareFolders[] = {"/irc", "/wifi", "/updates", "/gnss", "/gemini", "/captures", "/notes", "/screenshots"};
|
||||
const size_t kFirmwareFolderCount = sizeof kFirmwareFolders / sizeof kFirmwareFolders[0];
|
||||
|
||||
std::string parentOf(const std::string& path) {
|
||||
@@ -96,4 +96,52 @@ bool looksLikeText(const uint8_t* data, size_t len) {
|
||||
return odd * 20 <= len; // a stray control character or two is still text
|
||||
}
|
||||
|
||||
RmArgs parseRm(const std::string& args) {
|
||||
RmArgs out;
|
||||
size_t at = 0;
|
||||
while (at < args.size()) {
|
||||
while (at < args.size() && args[at] == ' ') at++;
|
||||
if (at >= args.size() || args[at] != '-') break;
|
||||
size_t end = args.find(' ', at);
|
||||
std::string flags = args.substr(at + 1, end == std::string::npos ? std::string::npos : end - at - 1);
|
||||
bool known = !flags.empty();
|
||||
for (char c : flags) known = known && (c == 'r' || c == 'R' || c == 'f');
|
||||
if (!known) break; // a name that starts with a dash
|
||||
for (char c : flags) (c == 'f' ? out.force : out.recursive) = true;
|
||||
at = end == std::string::npos ? args.size() : end;
|
||||
}
|
||||
out.path = at < args.size() ? args.substr(at) : "";
|
||||
while (!out.path.empty() && out.path.back() == ' ') out.path.pop_back();
|
||||
return out;
|
||||
}
|
||||
|
||||
bool hasGlob(const std::string& text) { return text.find_first_of("*?") != std::string::npos; }
|
||||
|
||||
bool globMatch(const std::string& pattern, const std::string& name) {
|
||||
auto lower = [](char c) { return c >= 'A' && c <= 'Z' ? static_cast<char>(c - 'A' + 'a') : c; };
|
||||
size_t p = 0, n = 0, star = std::string::npos, mark = 0;
|
||||
while (n < name.size()) {
|
||||
if (p < pattern.size() && (pattern[p] == '?' || lower(pattern[p]) == lower(name[n]))) {
|
||||
p++;
|
||||
n++;
|
||||
} else if (p < pattern.size() && pattern[p] == '*') {
|
||||
star = p++; // try it as nothing first; come back here to let it take one more
|
||||
mark = n;
|
||||
} else if (star != std::string::npos) {
|
||||
p = star + 1;
|
||||
n = ++mark;
|
||||
} else return false;
|
||||
}
|
||||
while (p < pattern.size() && pattern[p] == '*') p++;
|
||||
return p == pattern.size();
|
||||
}
|
||||
|
||||
bool splitGlob(const std::string& path, std::string& folder, std::string& pattern) {
|
||||
size_t slash = path.rfind('/');
|
||||
if (slash == std::string::npos) return false;
|
||||
pattern = path.substr(slash + 1);
|
||||
folder = slash == 0 ? "/" : path.substr(0, slash);
|
||||
return hasGlob(pattern) && !hasGlob(folder);
|
||||
}
|
||||
|
||||
} // namespace roro::files
|
||||
|
||||
@@ -39,4 +39,20 @@ FileKind kindOf(const std::string& name);
|
||||
bool opensAtEnd(const std::string& name); // logs
|
||||
bool looksLikeText(const uint8_t* data, size_t len);
|
||||
|
||||
// `rm`'s arguments, as Unix has them (issue #67): -r for a folder and what's in it, -f for no
|
||||
// question, alone or together (-rf, -fr, -r -f), then the path, which may hold spaces.
|
||||
struct RmArgs {
|
||||
bool recursive = false, force = false;
|
||||
std::string path;
|
||||
};
|
||||
RmArgs parseRm(const std::string& args);
|
||||
|
||||
// Patterns in a path (issue #67): * for any run of characters, ? for one, in the last part of the
|
||||
// path only (/notes/*.txt, not /*/a.txt). Cases aren't told apart, as on the card.
|
||||
bool hasGlob(const std::string& text);
|
||||
bool globMatch(const std::string& pattern, const std::string& name);
|
||||
// "/notes/*.txt" taken apart: the folder ("/notes", or "/" at the top) and the pattern ("*.txt").
|
||||
// False if there's no pattern in it, or if the folder has one too.
|
||||
bool splitGlob(const std::string& path, std::string& folder, std::string& pattern);
|
||||
|
||||
} // namespace roro::files
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -99,6 +99,20 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case 'h':
|
||||
case 'H':
|
||||
if (keys.fn) { // Fn+h: help, while typing too
|
||||
out.push_back(KeyEvent::of(Key::Help));
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case '?':
|
||||
if (!textEntry_ && !keys.fn) { // ? alone, when it wouldn't be typed
|
||||
out.push_back(KeyEvent::of(Key::Help));
|
||||
return;
|
||||
}
|
||||
if (keys.fn) return;
|
||||
break;
|
||||
default:
|
||||
if (keys.fn) return; // other Fn combos are unassigned
|
||||
break;
|
||||
|
||||
@@ -23,7 +23,7 @@ struct RawKeys {
|
||||
|
||||
// Turns keyboard state changes into logical KeyEvents: only newly pressed keys produce events;
|
||||
// Fn + ; . , / are arrows, and so are ; . , / alone when no text is being entered; ` is Back and
|
||||
// Fn + ` is Home; the Compose Key (opt) followed by an accent and a letter types the accented
|
||||
// Fn + ` is Home; Fn + h is Help anywhere, and so is ? when no text is being entered; the Compose Key (opt) followed by an accent and a letter types the accented
|
||||
// letter (opt ' e -> é).
|
||||
class KeyMapper {
|
||||
public:
|
||||
|
||||
@@ -13,12 +13,6 @@ constexpr const char* kGiteaRepo = "twisla/roro9stack";
|
||||
|
||||
inline bool versionNewer(const std::string& a, const std::string& b) { return versionOlder(b, a); }
|
||||
|
||||
// "v0.10.0+debug": the Debug Build says so wherever the version shows (scripts/version.py).
|
||||
inline bool isDebugBuild(const std::string& version) {
|
||||
static const std::string tail = "+debug";
|
||||
return version.size() >= tail.size() && version.compare(version.size() - tail.size(), tail.size(), tail) == 0;
|
||||
}
|
||||
|
||||
// Is `release` a newer one than what runs?
|
||||
inline bool isNewer(const Release& release, const std::string& running) {
|
||||
return release.usable() && versionNewer(release.tag, running);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "settings.h"
|
||||
|
||||
#include "debug_auth.h"
|
||||
#include "ipv4.h"
|
||||
|
||||
namespace roro {
|
||||
@@ -41,6 +42,9 @@ const Definition kDefinitions[] = {
|
||||
{"ntp2", Kind::String, 0, "time.cloudflare.com", 0, 63},
|
||||
{"gnss_quiet", Kind::Bool, 0, nullptr, 0, 1}, // off: GNSS stays on, as decided in M2 (Q58)
|
||||
{"check_updates", Kind::Bool, 1, nullptr, 0, 1}, // on: it only looks, and says so (R1, Q165)
|
||||
{"debug_on", Kind::Bool, 0, nullptr, 0, 1}, // off: nothing listens until the owner says so (Q189)
|
||||
{"debug_token", Kind::String, 0, "", 0, 64}, // empty, or a valid token
|
||||
{"help_told", Kind::Bool, 0, nullptr, 0, 1},
|
||||
};
|
||||
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
|
||||
"every Setting needs a definition");
|
||||
@@ -98,6 +102,7 @@ bool Settings::validString(Setting s, const std::string& value) const {
|
||||
if (s == Setting::Dns2) return value.empty() || net::parseIpv4(value, address);
|
||||
if (s == Setting::Ntp1) return net::validHost(value);
|
||||
if (s == Setting::Ntp2) return value.empty() || net::validHost(value);
|
||||
if (s == Setting::DebugToken) return value.empty() || debug::validToken(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ enum class Setting : uint8_t {
|
||||
Ntp2, // string: the second, or empty
|
||||
GnssQuietForLora, // bool: put the GNSS receiver in standby while the LoRa radio listens (issue #20)
|
||||
CheckUpdates, // bool: look for a newer release on Gitea once a day (R1, Q165)
|
||||
DebugConsole, // bool: the Debug Console listens on Wi-Fi (ADR 0010, Q189: off unless switched on)
|
||||
DebugToken, // string: its token, tidied (debug_auth.h); empty until the console is first switched on
|
||||
HelpTold, // bool: this device has been told about the help key once (issue #69, Q201)
|
||||
Count
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,14 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#ifndef RORO_VERSION
|
||||
#define RORO_VERSION "unknown"
|
||||
#endif
|
||||
|
||||
namespace roro {
|
||||
|
||||
constexpr const char* kProductName = "roro9stack";
|
||||
|
||||
// "roro9stack v0.1.0" — used on the boot screen and in About.
|
||||
inline const char* versionString() { return RORO_VERSION; }
|
||||
// "v0.1.0", from `git describe` (scripts/version.py): used on the boot screen and in About.
|
||||
// A function in one file, not a macro on every compiler command line: a new commit then recompiles
|
||||
// that one file, and everything else comes from the build cache (issue #74).
|
||||
const char* versionString();
|
||||
|
||||
} // namespace roro
|
||||
|
||||
@@ -32,15 +32,6 @@ custom_sdkconfig =
|
||||
CONFIG_MBEDTLS_DYNAMIC_FREE_CONFIG_DATA=y
|
||||
CONFIG_MBEDTLS_DYNAMIC_FREE_CA_CERT=y
|
||||
|
||||
; Debug Build: the same firmware plus the Debug Console on TCP 2323 (see ADR 0004). The token comes
|
||||
; from ~/.config/roro9stack/debug-token, passed in by scripts/_docker.sh; it's never committed.
|
||||
[env:cardputer-adv-debug]
|
||||
extends = env:cardputer-adv
|
||||
extra_scripts = pre:scripts/version.py, pre:scripts/debug_flags.py
|
||||
build_flags =
|
||||
${env:cardputer-adv.build_flags}
|
||||
-DRORO_DEBUG
|
||||
|
||||
; Host-side unit tests for pure logic (no hardware).
|
||||
[env:native]
|
||||
platform = native
|
||||
|
||||
+3
-1
@@ -6,7 +6,9 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
[ -n "${RORO_NO_DOCKER:-}" ] || docker build -q -t "$IMAGE" "$ROOT/docker" >/dev/null
|
||||
|
||||
# The Debug Console token (ADR 0004): made once, kept with the OTA key, never committed.
|
||||
# The Debug Console token of the developer's device (ADR 0010): made once, kept with the OTA key, never
|
||||
# committed and never compiled in. scripts/flash.sh --debug gives it to a device over USB, and
|
||||
# scripts/rdbg.py answers the device's challenge with it.
|
||||
DEBUG_TOKEN_FILE="$HOME/.config/roro9stack/debug-token"
|
||||
if [ ! -s "$DEBUG_TOKEN_FILE" ]; then
|
||||
mkdir -p "$(dirname "$DEBUG_TOKEN_FILE")"
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@ DOCKER_EXTRA=()
|
||||
|
||||
case "${1:-all}" in
|
||||
tests) STEPS='pio test -e native' ;;
|
||||
builds) STEPS='pio run -e cardputer-adv -e cardputer-adv-debug' ;;
|
||||
all) STEPS='pio test -e native && pio run -e cardputer-adv -e cardputer-adv-debug' ;;
|
||||
builds) STEPS='pio run -e cardputer-adv' ;;
|
||||
all) STEPS='pio test -e native && pio run -e cardputer-adv' ;;
|
||||
*) echo "Usage: scripts/ci.sh [tests|builds]" >&2; exit 1 ;;
|
||||
esac
|
||||
run_in_container bash -c 'git config --global --add safe.directory "$PWD" && '"$STEPS"
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# Debug Builds only: compiles in the Debug Console token from $RORO_DEBUG_TOKEN (set by _docker.sh
|
||||
# from ~/.config/roro9stack/debug-token). Refuses to build without one rather than use a default.
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
Import("env") # noqa: F821 (provided by PlatformIO)
|
||||
|
||||
token = os.environ.get("RORO_DEBUG_TOKEN", "").strip()
|
||||
if not re.fullmatch(r"[0-9a-f]{32}", token):
|
||||
sys.exit("debug build: RORO_DEBUG_TOKEN is missing; build through scripts/ci.sh or scripts/flash.sh --debug")
|
||||
env.Append(CPPDEFINES=[("RORO_DEBUG_TOKEN", '\\"%s\\"' % token)]) # noqa: F821
|
||||
+15
-6
@@ -1,17 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Flash the firmware over USB, then open the serial monitor.
|
||||
# Usage: scripts/flash.sh [--debug] [port] USB (default: the first Espressif device found)
|
||||
# scripts/flash.sh [--debug] --ota [host] Wi-Fi: build, sign and push a Firmware Update
|
||||
# (host: the device's IP from Settings > About, or $RORO_OTA_HOST)
|
||||
# --debug builds the Debug Build (cardputer-adv-debug): the Debug Console on TCP 2323, see ADR 0004.
|
||||
# scripts/flash.sh --ota [host] Wi-Fi: build, sign and push a Firmware Update
|
||||
# (host: the device's IP from Settings > Firmware, or $RORO_OTA_HOST)
|
||||
# --debug (USB only): once flashed, switches the Debug Console on and gives it the token in
|
||||
# ~/.config/roro9stack/debug-token, over the serial port (ADR 0010, Q192), so scripts/rdbg.py works
|
||||
# at once. The settings survive later updates: it is needed once for a device, not at each flash.
|
||||
set -euo pipefail
|
||||
ENV=cardputer-adv
|
||||
PROVISION=
|
||||
if [ "${1:-}" = "--debug" ]; then
|
||||
ENV=cardputer-adv-debug
|
||||
PROVISION=1
|
||||
shift
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "--ota" ]; then
|
||||
[ -z "$PROVISION" ] || echo "flash.sh: --debug does nothing over Wi-Fi: the console's setting stays as it is on the device" >&2
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
HOST="${2:-${RORO_OTA_HOST:-}}"
|
||||
[ -n "$HOST" ] || { echo "Usage: scripts/flash.sh --ota <device IP> (or set RORO_OTA_HOST)" >&2; exit 1; }
|
||||
@@ -19,7 +23,6 @@ if [ "${1:-}" = "--ota" ]; then
|
||||
DOCKER_EXTRA=()
|
||||
run_in_container bash -c "git config --global --add safe.directory /work && pio run -e $ENV" | tail -3
|
||||
VERSION="$(git -C "$ROOT" describe --tags --always --dirty)"
|
||||
[ "$ENV" = cardputer-adv-debug ] && VERSION="$VERSION+debug" # as scripts/version.py names it
|
||||
OUT="$ROOT/.pio/build/$ENV/roro9stack-$VERSION.ota"
|
||||
"$ROOT/scripts/make_ota.py" "$ROOT/.pio/build/$ENV/firmware.bin" "$VERSION" "$OUT"
|
||||
exec "$ROOT/scripts/ota_push.py" "$OUT" "$HOST"
|
||||
@@ -30,4 +33,10 @@ source "$(dirname "$0")/_docker.sh"
|
||||
docker rm -f roro9stack-serial >/dev/null 2>&1 || true # a serial log would hold the port
|
||||
DOCKER_EXTRA=(--device "$PORT" --group-add "$(stat -c %g "$PORT")" -it)
|
||||
|
||||
run_in_container bash -c "git config --global --add safe.directory /work && pio run -e $ENV -t upload --upload-port $PORT && pio device monitor -p $PORT -b 115200"
|
||||
# The token is read from the environment inside the container (_docker.sh passes it), so it is on no
|
||||
# command line of the host; serial_log.py prints what the device says, not what it sends.
|
||||
SETUP=""
|
||||
# serial_log.py finds the port by its name, and follows it when the device re-enumerates after the upload.
|
||||
[ -z "$PROVISION" ] || DOCKER_EXTRA=(--group-add "$(stat -c %g "$PORT")" --privileged -v /dev:/dev -it)
|
||||
[ -z "$PROVISION" ] || SETUP='&& /pio/penv/bin/python scripts/serial_log.py 8 sleep:4 "debug token $RORO_DEBUG_TOKEN" "debug on"'
|
||||
run_in_container bash -c "git config --global --add safe.directory /work && pio run -e $ENV -t upload --upload-port $PORT $SETUP && pio device monitor -p $PORT -b 115200"
|
||||
|
||||
+85
-12
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The Debug Console of a Debug Build, over Wi-Fi (TCP 2323, see ADR 0004).
|
||||
"""The Debug Console, over Wi-Fi (TCP 2323, see ADR 0010). Switch it on first: Settings > Debug Console.
|
||||
|
||||
Usage: scripts/rdbg.py [-H host] [-b] [command ...]
|
||||
Usage: scripts/rdbg.py [-H host] [-t token] [-b] [command ...]
|
||||
no command interactive: type commands, see every console line live; Ctrl-D or `quit` leaves
|
||||
command runs it and prints what follows, until the console has been quiet for a moment
|
||||
-H host the device's IP (Settings > Firmware), default $RORO_OTA_HOST
|
||||
-H host the device's IP (Settings > Debug Console), default $RORO_OTA_HOST
|
||||
-t token the device's token (also --token), as Settings > Debug Console shows it: dashes and
|
||||
case don't matter. Otherwise $RORO_DEBUG_TOKEN, otherwise ~/.config/roro9stack/debug-token
|
||||
-b also print the backlog the device sends on connecting (boot messages and so on)
|
||||
|
||||
Commands handled here as well as on the device:
|
||||
@@ -14,9 +16,11 @@ Commands handled here as well as on the device:
|
||||
put <file> [card path] copy a file to the SD card (default /updates/<name>), checked with SHA-256
|
||||
screenshot [file.png] what the screen shows (default screen-<date>.png), at 2x
|
||||
reset restart at once, even if the main loop is stuck
|
||||
The token is read from ~/.config/roro9stack/debug-token (made by the first build).
|
||||
The token never crosses the network: the device sends a challenge, and this answers with its HMAC.
|
||||
"""
|
||||
import gzip
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
@@ -26,9 +30,53 @@ import zlib
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
PORT = 2323
|
||||
TOKEN_FILE = os.path.expanduser("~/.config/roro9stack/debug-token")
|
||||
RELEASES = "https://git.twis.la/twisla/roro9stack/releases/download"
|
||||
|
||||
|
||||
def tidy_token(typed):
|
||||
"""As the device stores it (lib/debug/src/debug_auth.cpp): no dashes or spaces, in capitals."""
|
||||
tidy = "".join(c for c in typed if c not in "- \t\r\n").upper()
|
||||
return tidy.replace("O", "0").replace("I", "1").replace("L", "1") # read as the digits they look like
|
||||
|
||||
|
||||
def answer_for(token, nonce):
|
||||
"""What the device expects back for a challenge: HMAC-SHA256 of the nonce, keyed by the token."""
|
||||
return hmac.new(token.encode(), nonce, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def find_token(given):
|
||||
token = given or os.environ.get("RORO_DEBUG_TOKEN")
|
||||
if not token and os.path.exists(TOKEN_FILE):
|
||||
token = open(TOKEN_FILE).read()
|
||||
token = tidy_token(token or "")
|
||||
if not token:
|
||||
sys.exit("No token: pass -t <token>, set RORO_DEBUG_TOKEN, or put it in " + TOKEN_FILE +
|
||||
"\n(the device shows it in Settings > Debug Console)")
|
||||
return token
|
||||
|
||||
|
||||
def log_in(sock, token):
|
||||
"""Answers the device's challenge; returns the banner line, or exits saying what went wrong."""
|
||||
first = read_until(sock, b"\n", 10)
|
||||
if first is None:
|
||||
sys.exit("device: nothing came back. Is the console switched on (Settings > Debug Console)?")
|
||||
line = first.decode(errors="replace").strip()
|
||||
if line.startswith("locked"):
|
||||
sys.exit("device: closed for a minute after too many wrong tokens")
|
||||
challenge = re.search(r"challenge ([0-9a-f]{32})$", line)
|
||||
if not challenge:
|
||||
sys.exit("device: no challenge (an older firmware?): " + line[:60])
|
||||
sock.sendall((answer_for(token, bytes.fromhex(challenge.group(1))) + "\n").encode())
|
||||
banner = read_until(sock, b"\n", 15)
|
||||
if banner is None:
|
||||
sys.exit("device: no answer")
|
||||
if banner.startswith(b"denied"):
|
||||
sys.exit("device: wrong token (Settings > Debug Console shows the right one)")
|
||||
return banner
|
||||
|
||||
|
||||
def read_until(sock, marker, timeout):
|
||||
@@ -102,17 +150,40 @@ def crash_firmware(reply):
|
||||
return version.group(1) if version else None
|
||||
|
||||
|
||||
def have_elf(reply):
|
||||
"""Makes sure .pio/elves/ holds the ELF of the firmware that crashed: a release's is on Gitea."""
|
||||
folder = os.path.join(os.path.dirname(SCRIPTS), ".pio", "elves")
|
||||
key = crash_firmware(reply)
|
||||
version = re.search(r"last one in (\S+)", reply)
|
||||
version = version.group(1) if version else None
|
||||
names = os.listdir(folder) if os.path.isdir(folder) else []
|
||||
if not key or any(key in n for n in names) or not version or not re.fullmatch(r"v\d+\.\d+\.\d+", version):
|
||||
return # there already, or not a release: only tags are published
|
||||
url = f"{RELEASES}/{version}/roro9stack-{version}.elf.gz"
|
||||
try:
|
||||
elf = gzip.decompress(urllib.request.urlopen(url, timeout=60).read())
|
||||
except Exception as e:
|
||||
return print(f"(no ELF for {version} here, and none fetched from {url}: {e})")
|
||||
digest = hashlib.sha256(elf).hexdigest()[:16]
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
path = os.path.join(folder, f"{version}.{digest}.elf") # as scripts/version.py names them
|
||||
open(path, "wb").write(elf)
|
||||
print(f"(fetched the ELF of {version} from its release: {os.path.relpath(path)})")
|
||||
|
||||
|
||||
def crash(sock):
|
||||
reply = run(sock, "crash")
|
||||
trace = re.search(r"backtrace((?: 0x[0-9a-f]+)+)", reply)
|
||||
key = crash_firmware(reply)
|
||||
if trace and key:
|
||||
have_elf(reply)
|
||||
print()
|
||||
subprocess.call([os.path.join(SCRIPTS, "decode_backtrace.sh"), key] + trace.group(1).split())
|
||||
|
||||
|
||||
def coredump(sock, path):
|
||||
info = run(sock, "crash", out=None)
|
||||
have_elf(info)
|
||||
sock.sendall(b"coredump get\n")
|
||||
# One buffer throughout: the header, the size and the first bytes often share a packet.
|
||||
buf = b""
|
||||
@@ -237,25 +308,27 @@ def interactive(sock):
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
host, backlog = os.environ.get("RORO_OTA_HOST"), False
|
||||
host, backlog, token = os.environ.get("RORO_OTA_HOST"), False, None
|
||||
while args and args[0].startswith("-"):
|
||||
flag = args.pop(0)
|
||||
if flag == "-H" and args:
|
||||
host = args.pop(0)
|
||||
elif flag in ("-t", "--token") and args:
|
||||
token = args.pop(0)
|
||||
elif flag == "-b":
|
||||
backlog = True
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
if not host:
|
||||
sys.exit("No device: pass -H <ip> or set RORO_OTA_HOST\n\n" + __doc__)
|
||||
token = open(TOKEN_FILE).read().strip()
|
||||
token = find_token(token)
|
||||
|
||||
with socket.create_connection((host, PORT), timeout=10) as sock:
|
||||
sock.sendall((token + "\n").encode())
|
||||
# The device may still be finishing a previous client: wait for this connection's banner.
|
||||
banner = read_until(sock, b"Backlog follows.\n", 15)
|
||||
if banner is None:
|
||||
sys.exit("device: no banner (wrong token, or another client is connected)")
|
||||
try:
|
||||
sock = socket.create_connection((host, PORT), timeout=10)
|
||||
except OSError as e:
|
||||
sys.exit(f"device: can't connect to {host}:{PORT} ({e}). Is the console switched on (Settings > Debug Console)?")
|
||||
with sock:
|
||||
banner = log_in(sock, token)
|
||||
show = sys.stdout if backlog or not args else None
|
||||
if show:
|
||||
show.write(banner.decode(errors="replace"))
|
||||
|
||||
+8
-3
@@ -10,10 +10,15 @@ try:
|
||||
except Exception:
|
||||
version = "unknown"
|
||||
|
||||
if env["PIOENV"].endswith("-debug"): # noqa: F821
|
||||
version += "+debug" # a Debug Build says so wherever the version shows
|
||||
# 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
|
||||
|
||||
env.Append(CPPDEFINES=[("RORO_VERSION", '\\"%s\\"' % version)]) # noqa: F821
|
||||
_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
|
||||
|
||||
@@ -8,6 +8,6 @@ sort_by = "weight"
|
||||
eyebrow = "Developer docs"
|
||||
+++
|
||||
|
||||
The firmware is open source (GPL-3.0) and lives on [Gitea](https://git.twis.la/twisla/roro9stack). It is built for one device, the M5Stack Cardputer ADV with the Cap LoRa-1262, and it is built to be **worked on without touching the device**: install a build over Wi-Fi, read its console, press its keys, take screenshots of it, copy files to and from its SD card, and fetch its crash dumps, all from a PC on the same network. The first section is about exactly that.
|
||||
The firmware is open source (GPL-3.0) and lives on [Gitea](https://git.twis.la/twisla/roro9stack). It is built for one device, the M5Stack Cardputer ADV with the Cap LoRa-1262, and it is built to be **worked on without touching the device**: install a build over Wi-Fi, read its console, press its keys, take screenshots of it, copy files to and from its SD card, and fetch its crash dumps, all from a PC on the same network. The first section is about exactly that. The same commands also run on the device itself, in the [Shell](/guide/shell/).
|
||||
|
||||
Some of these pages are written by hand. The rest are **generated from the repository's own documents** (the decisions, the milestone plans, the README and the firmware's own `help` text), so they are never out of date: each says which file it comes from.
|
||||
|
||||
@@ -10,4 +10,4 @@ weight = 2
|
||||
eyebrow = "Developer docs"
|
||||
+++
|
||||
|
||||
The firmware is built, tested and released **in Docker**, so that a clean machine, your machine and the CI runner all get the same result. These pages say how. For the Debug Console, which is how you work on a device once it is flashed, see [Debug Builds and the Debug Console](/dev/debug/).
|
||||
The firmware is built, tested and released **in Docker**, so that a clean machine, your machine and the CI runner all get the same result. These pages say how. For the Debug Console, which is how you work on a device once it is flashed, see [The Debug Console](/dev/debug/).
|
||||
|
||||
@@ -22,17 +22,17 @@ This runs the host-side unit tests (`test/`, `native` environment), then builds
|
||||
|
||||
`scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure.
|
||||
|
||||
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 4 minutes; later builds take under a minute.
|
||||
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 6 minutes; later builds take about 15 seconds when little has changed. The platform knows the framework is already rebuilt by `sdkconfig.defaults` in the project folder, which it writes and git ignores: delete it and the next build rebuilds the framework. CI keeps that file, a build cache and ccache in its volume (docs/milestones/R1.md).
|
||||
|
||||
## CI and releases
|
||||
|
||||
Gitea Actions (`.gitea/workflows/ci.yml`, docs/milestones/R1.md) runs the host tests on every push to `main`, and on a pull request also builds the release firmware and the Debug Build: changes reach `main` through pull requests. Pushing a tag `v*` runs all of it and publishes a release on Gitea with:
|
||||
Gitea Actions (`.gitea/workflows/ci.yml`, docs/milestones/R1.md) runs the host tests on every push to `main`, and on a pull request also builds the firmware: changes reach `main` through pull requests. Pushing a tag `v*` runs all of it and publishes a release on Gitea with:
|
||||
|
||||
- `roro9stack-<version>.ota`, the signed Update File;
|
||||
- `roro9stack-<version>-factory.bin`, the whole flash image for a first install over USB;
|
||||
- `roro9stack-<version>.elf.gz`, to decode crash reports from that build;
|
||||
- `SHA256SUMS`.
|
||||
|
||||
CI signs with the project's key, held as a repository secret (ADR 0008). Debug Builds are built but never published: each carries its builder's Debug Console token.
|
||||
CI signs with the project's key, held as a repository secret (ADR 0008). There is one firmware: the Debug Console is in every build, switched off until its owner switches it on (ADR 0010).
|
||||
|
||||
`scripts/ota_verify.py <file.ota>` checks an Update File on a PC the way a device does. `scripts/release_build.sh` and `scripts/release_publish.py` are what the workflow runs; they work the same by hand.
|
||||
|
||||
@@ -54,4 +54,4 @@ The connection is checked against the two ISRG roots Let's Encrypt chains end in
|
||||
|
||||
**IRC steps aside.** A secure connection takes about 52 KB of memory at its peak, and IRC's own takes 40 KB of the 107 KB there is. A check or an install you ask for makes IRC disconnect for the few seconds it takes and reconnect afterwards. The daily check never does that: with IRC connected it waits for a moment when IRC isn't, so while IRC stays connected for days it doesn't run, and **Latest release** is the way to check.
|
||||
|
||||
**A Debug Build** shows the latest release but doesn't install it: releases have no Debug Console (they aren't built with one, so that nobody's console token is published), and installing one would take it away. A Debug Build is updated from the PC with `scripts/flash.sh --debug --ota`.
|
||||
**There is no separate Debug Build** any more (ADR 0010): every firmware installs releases, and the Debug Console is a setting, which an update leaves as it was.
|
||||
|
||||
@@ -24,7 +24,7 @@ scripts/ota_keygen.sh # once: creates the key p
|
||||
scripts/make_ota.py firmware.bin v0.12.0 out.ota # wraps and signs an image (the key: $RORO_OTA_KEY or the default path)
|
||||
scripts/ota_verify.py out.ota # checks a file the way a device does, on the PC
|
||||
scripts/ota_push.py out.ota 10.39.39.12 # pushes it to the Update Service, TCP 3232
|
||||
scripts/flash.sh --ota 10.39.39.12 # builds, signs and pushes in one go (add --debug for a Debug Build)
|
||||
scripts/flash.sh --ota 10.39.39.12 # builds, signs and pushes in one go
|
||||
```
|
||||
|
||||
`ota_push.py` prints the device's answer (`OK …`), or says the device **refused the update and hung up**, with the reason on the device's screen: the header is checked first, so a refused file stops mid-transfer.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<text x="30" y="75" font-size="11" class="wi-dim">builds, signs, pushes</text>
|
||||
<rect class="wi-box" x="16" y="112" width="220" height="56" rx="6"/>
|
||||
<text x="30" y="135" font-size="12" font-weight="600">rdbg.py put</text>
|
||||
<text x="30" y="155" font-size="11" class="wi-dim">Debug Build, Wi-Fi 2323</text>
|
||||
<text x="30" y="155" font-size="11" class="wi-dim">Debug Console, Wi-Fi 2323</text>
|
||||
<rect class="wi-box" x="16" y="192" width="220" height="56" rx="6"/>
|
||||
<text x="30" y="215" font-size="12" font-weight="600">sd_put.sh</text>
|
||||
<text x="30" y="235" font-size="11" class="wi-dim">USB serial, card stays in</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
@@ -1,5 +1,5 @@
|
||||
+++
|
||||
title = "Debug Builds and the Debug Console"
|
||||
title = "The Debug Console"
|
||||
description = "A firmware you can drive from your desk: its console over Wi-Fi, its screen as a PNG, its SD card, its crash dumps, and a way back when an update goes wrong."
|
||||
template = "guide-index.html"
|
||||
page_template = "guide-page.html"
|
||||
@@ -10,13 +10,13 @@ weight = 1
|
||||
eyebrow = "Developer docs"
|
||||
+++
|
||||
|
||||
A **Debug Build** is the same firmware plus a **Debug Console**: the serial console, over Wi-Fi, behind a token. It is the most useful thing in the project. With it you can:
|
||||
The **Debug Console** is the serial console, over Wi-Fi, for whoever holds the device's token. It is in **every firmware**, switched off until you switch it on, and it is the most useful thing in the project. With it you can:
|
||||
|
||||
- **see everything the device prints**, boot messages included, without a cable;
|
||||
- **run every serial command** from your desk;
|
||||
- **press keys** and **take screenshots**, so a UI change can be tested and looked at remotely;
|
||||
- **copy files** to and from the SD card;
|
||||
- **read crash reports and fetch core dumps**, decoded against the exact build that crashed;
|
||||
- **update the firmware** over the same Wi-Fi, and know that a bad update rolls back to a build that still has the console.
|
||||
- **update the firmware** over the same Wi-Fi, and know that a bad update rolls back by itself.
|
||||
|
||||
Start with [Debug Builds](/dev/debug/debug-builds/) to put one on a device, then [the Debug Console](/dev/debug/console/). The rest are what you can do with it.
|
||||
Start with [Switch the console on](/dev/debug/switch-it-on/), then [the Debug Console](/dev/debug/console/). The rest are what you can do with it.
|
||||
|
||||
@@ -10,7 +10,7 @@ tag = "Reference"
|
||||
+++
|
||||
## What `help` prints
|
||||
|
||||
The firmware's own list, read from `src/main.cpp`. These work in every build, over USB serial:
|
||||
The firmware's own list, read from `src/main.cpp`. Every firmware has all of them, over USB serial and over the Debug Console. The ones marked *Debug Console only* exist only over Wi-Fi, where [`rdbg.py`](/dev/debug/files-and-screens/) speaks them, and the ones marked *USB serial only* only over the cable:
|
||||
|
||||
```
|
||||
info firmware, uptime, memory, Wi-Fi, app slots
|
||||
@@ -19,29 +19,28 @@ net bytes each network service has read and written since boot
|
||||
reboot restart
|
||||
boot other restart into the other app slot (manual Rollback)
|
||||
log level <0-5> ESP-IDF log level (0 none ... 5 verbose)
|
||||
ls [folder] | du <path> | mkdir <path> | rm <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules
|
||||
ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask; * and ? in a name: /notes/*.txt)
|
||||
screenshot [seconds] the screen as a PNG in /screenshots on the card, now or after a pause
|
||||
lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only
|
||||
lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)
|
||||
lora sweep on [from MHz] [to MHz] [step kHz] | off | dump RSSI across a band (863 870 100)
|
||||
lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100)
|
||||
lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)
|
||||
gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)
|
||||
gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>
|
||||
crash the last crash: firmware, reason, task, backtrace
|
||||
coredump erase forget the core dump in flash
|
||||
key <name|char> press a key: up down left right select back home del tab space, or one character
|
||||
key <name|char> press a key: up down left right select back home del tab space help, or one character
|
||||
wifi status | wifi add <ssid><TAB><password>
|
||||
wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting
|
||||
wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers
|
||||
gemini get <url> fetch a Gemini page and report header, size, certificate, heap
|
||||
irc start | irc stop | irc dump | irc say <buffer> <text>
|
||||
install <path.ota> Update from SD
|
||||
update check | list | status | install <tag> the project's releases on Gitea
|
||||
update check | update list | update status | update install <tag> the project's releases on Gitea
|
||||
sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal
|
||||
```
|
||||
|
||||
A **Debug Build** adds these (the last ones, marked *Debug Console only*, exist only over Wi-Fi, where [`rdbg.py`](/dev/debug/files-and-screens/) speaks them):
|
||||
|
||||
```
|
||||
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 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)
|
||||
wifi ip ... try <seconds> | wifi ip keep a trial IP setting: back to the previous one unless kept
|
||||
loop spin on|off make the main loop spin without resting, to compare load and radio noise
|
||||
@@ -50,11 +49,11 @@ lora inject <hex> [rssi] [snr] a packet into the LoRa Scanner as if received (
|
||||
sd fill <folder> <count> makes that many small files there, to test a crowded folder
|
||||
coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump
|
||||
reset (Debug Console only) restart at once, even if the main loop is stuck
|
||||
get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py
|
||||
get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py: there, a bare `screenshot` sends the screen instead of saving it
|
||||
quit close the Debug Console connection
|
||||
```
|
||||
|
||||
In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few run: `help`, `info`, `tasks`, `net`, `reboot`, `boot other`, `wifi status`, and anything starting with `log level`, `crash`, `coredump`, `wifi add`. Anything else answers `not available in Safe Mode`.
|
||||
In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few run: `help`, `info`, `tasks`, `net`, `reboot`, `boot other`, `wifi status`, and anything starting with `log level`, `crash`, `coredump`, `wifi add`, `debug`. Anything else answers `not available in Safe Mode`.
|
||||
|
||||
## What they do
|
||||
|
||||
@@ -63,16 +62,16 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
|
||||
| Command | Effect |
|
||||
|---|---|
|
||||
| `burst` | Publishes 5 Notifications at once |
|
||||
| `key up\|down\|left\|right\|select\|back\|home`, or `key <char>` | Injects a key press |
|
||||
| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing) |
|
||||
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
|
||||
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
|
||||
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
|
||||
| `wifi ip <ssid> dhcp` / `wifi ip <ssid> <address>/<prefix> [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Debug Builds: add `try <seconds>` to go back to the previous setting unless `wifi ip keep` follows |
|
||||
| `wifi ip <ssid> dhcp` / `wifi ip <ssid> <address>/<prefix> [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Add `try <seconds>` to go back to the previous setting unless `wifi ip keep` follows |
|
||||
| `wifi dns <a> [b]` / `wifi dns always on\|off` / `wifi ntp <a> [b]` | DNS servers (used on Fixed networks, or always), and NTP servers |
|
||||
| `log <text>` | Appends a line to a test IRC Log (`/irc/dev/#test/<date>.log`) |
|
||||
| `sd card` | What the SD card says it is: type, size, and its identity register (maker, name, revision, serial, date) |
|
||||
| `sd list` | Lists the files of each Storage Clean-up category |
|
||||
| `sd fill <folder> <count>` | Debug Builds: makes that many small files in a folder, to test a crowded one |
|
||||
| `sd fill <folder> <count>` | Makes that many small files in a folder, to test a crowded one |
|
||||
| `cat <path>` | Prints the first ~1.2 KB of a file on the SD card |
|
||||
| `irc start` | Starts the IRC Service (normally done by opening the IRC App) |
|
||||
| `irc stop` | Stops it, as `/quit` does: QUIT if connected, no more retries, and the App stays disconnected until you type |
|
||||
@@ -81,16 +80,18 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
|
||||
| `irc say <buffer> <text>` | Types into a Buffer, commands included (`irc say 0 /join #test`) |
|
||||
| `irc dump` | Prints IRC status, memory, and the last lines of each Buffer |
|
||||
| `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap, then the address, gateway, DNS and NTP servers in use and where each came from |
|
||||
| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, and both app slots with their versions and OTA states |
|
||||
| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, **which App is in front**, and both app slots with their versions and OTA states |
|
||||
| `Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Shell`, `System`, `Settings` | Opens that App: a capital letter is an App, not a command |
|
||||
| `tasks` | FreeRTOS tasks over the next second: state, priority, lowest free stack, share of a core, each core's load, and how many passes the main loop made |
|
||||
| `net` | Bytes each network service has read and written since boot |
|
||||
| `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) |
|
||||
| `log level <0-5>` | ESP-IDF log level |
|
||||
| `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path |
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (a folder with what's in it), new folder. `-f` replaces a file that's in the way; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
|
||||
| `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead |
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
|
||||
| `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does |
|
||||
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one (not on a Debug Build) |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Debug Builds: 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 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 |
|
||||
| `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor |
|
||||
| `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack |
|
||||
| `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
|
||||
@@ -102,12 +103,14 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
|
||||
| `gnss track start` / `gnss track stop` | A Track, as `r` in the GNSS App (the reason is printed if it can't start) |
|
||||
| `gnss nmea on` / `gnss nmea off` | Prints each NMEA sentence the receiver sends, as `nmea: …` |
|
||||
| `gnss send <sentence>` | Sends a sentence to the receiver, without the `$` and the checksum (it adds them) |
|
||||
| `lora noise test [gnss\|quiet]` / `lora noise report` | Debug Builds: Sweep under one changed condition at a time to find what raises the noise floor (Wi-Fi goes off for a few seconds); then the result |
|
||||
| `lora inject <hex> [rssi] [snr]` | Debug Builds: a packet into the Scanner as if received (nothing is sent) |
|
||||
| `lora noise test [gnss\|quiet]` / `lora noise report` | Sweep under one changed condition at a time to find what raises the noise floor (Wi-Fi goes off for a few seconds); then the result |
|
||||
| `lora inject <hex> [rssi] [snr]` | A packet into the Scanner as if received (nothing is sent) |
|
||||
| `crash` | The last crash: which firmware, why, task, PC and backtrace (from the core dump in flash) |
|
||||
| `coredump erase` | Forgets the core dump |
|
||||
| `loop spin on` / `loop spin off` | Debug Builds: make the main loop spin without resting, to compare load and radio noise |
|
||||
| `crash abort` / `crash wdt` | Debug Builds: crash on purpose, or hang the main loop until the watchdog fires |
|
||||
| `loop spin on` / `loop spin off` | Make the main loop spin without resting, to compare load and radio noise |
|
||||
| `crash abort` / `crash wdt` | Crash on purpose, or hang the main loop until the watchdog fires |
|
||||
| `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long |
|
||||
| `debug on` / `debug token <value>` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed |
|
||||
| `help` | Lists the commands |
|
||||
|
||||
`scripts/flash.sh` stops a running serial log first, since it would hold the port.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
+++
|
||||
title = "The Debug Console"
|
||||
description = "Connect to a Debug Build over Wi-Fi: the protocol, what you get when you connect, how commands run, and what the console can and cannot do."
|
||||
description = "Connect to the console over Wi-Fi: the protocol, what you get when you connect, how commands run, and what the console can and cannot do."
|
||||
weight = 2
|
||||
[extra]
|
||||
tag = "Console"
|
||||
@@ -17,16 +17,16 @@ scripts/rdbg.py info # one command, and its reply
|
||||
scripts/rdbg.py -b tasks # the same, with the backlog shown first
|
||||
```
|
||||
|
||||
`scripts/rdbg.py` is a Python script with no dependencies. A one-shot command prints the reply and what follows, until the console has been quiet for a moment (1.5 seconds). It reads the token from `~/.config/roro9stack/debug-token` and talks to **TCP 2323** on the device's address. In interactive mode, <kbd>Ctrl</kbd>+<kbd>D</kbd> or `quit` leaves. Piped input works too, but `rdbg.py` leaves **as soon as its input ends**, before the replies arrive: keep the input open for a few seconds, as in `(printf 'info\ntasks\n'; sleep 3) | scripts/rdbg.py`. For one command, the one-shot form above is simpler.
|
||||
`scripts/rdbg.py` is a Python script with no dependencies. A one-shot command prints the reply and what follows, until the console has been quiet for a moment (1.5 seconds). It takes the token from `-t`, `$RORO_DEBUG_TOKEN` or `~/.config/roro9stack/debug-token` ([Switch the console on](/dev/debug/switch-it-on/)) and talks to **TCP 2323** on the device's address. In interactive mode, <kbd>Ctrl</kbd>+<kbd>D</kbd> or `quit` leaves. Piped input works too, but `rdbg.py` leaves **as soon as its input ends**, before the replies arrive: keep the input open for a few seconds, as in `(printf 'info\ntasks\n'; sleep 3) | scripts/rdbg.py`. For one command, the one-shot form above is simpler.
|
||||
|
||||
The device listens **only while Wi-Fi is connected**, and the console follows Wi-Fi: it stops listening when Wi-Fi drops and starts again when it is back.
|
||||
The device listens **only while the console is switched on and Wi-Fi is connected**, and the console follows Wi-Fi: it stops listening when Wi-Fi drops and starts again when it is back.
|
||||
|
||||
## What you get
|
||||
|
||||
On connecting, in order:
|
||||
|
||||
1. a **banner**: `roro9stack v0.11.0-3-g12006bd+debug debug console. 'help' lists the commands. Backlog follows.`
|
||||
2. the **backlog**: the last **4 KB** of console output, oldest first, **boot messages included** (a ring buffer in RAM);
|
||||
1. a **banner**: `roro9stack v0.12.0 debug console. 'help' lists the commands. Backlog follows.`
|
||||
2. the **backlog**: the last **4 KB** of console output, oldest first (a ring buffer in RAM, kept while the console is switched on: boot messages included, if it was on at boot);
|
||||
3. then **every new line, live**: everything the firmware prints, and ESP-IDF's own log lines, which are copied into the same stream (they still reach the USB port too);
|
||||
4. the **replies to your commands**, in the same stream, each preceded by the `> command` line when it runs.
|
||||
|
||||
@@ -49,12 +49,17 @@ It is a plain line protocol, easy to speak from anything. This is what `rdbg.py`
|
||||
| Step | Detail |
|
||||
|---|---|
|
||||
| Connect | TCP 2323. **One client at a time**: a second one waits until the first leaves |
|
||||
| Authenticate | Send the token and `\n` within **10 seconds**. The comparison takes the same time whatever you send |
|
||||
| Refused | After about a second the device sends `denied\n` and hangs up, and prints `debug: refused a client from <ip>` on its own console. No quick retries |
|
||||
| Challenge | The device sends one line: `roro9stack debug console, challenge <32 hex digits>`. Those are 16 random bytes, new each time |
|
||||
| Answer | Within **10 seconds**, send the **HMAC-SHA256 of those 16 bytes, keyed by the token**, as 64 hex digits and `\n`. The token is the tidied one: capitals, no dashes |
|
||||
| Accepted | The banner line, then the backlog |
|
||||
| Refused | After about a second the device sends `denied\n` and hangs up, and prints `debug: refused a client from <ip>` on its own console |
|
||||
| Locked | **Five wrong answers in a row** close the console to everyone for 60 seconds: it answers `locked\n` and hangs up, and a Toast on the device names the address they came from |
|
||||
| Commands | One line each, up to 240 bytes. The device **queues at most 8**; past that, `debug: busy, command dropped` |
|
||||
| Leave | `quit` or `exit` closes the connection |
|
||||
| Binary commands | `get`, `put`, `screenshot`, `coredump get`: a text header line, then raw bytes (see [files and screens](/dev/debug/files-and-screens/)) |
|
||||
|
||||
**The token never crosses the network.** Someone on the same Wi-Fi who records a login gets a challenge and its answer, which are no use for the next challenge. The comparison on the device takes the same time whatever it is given.
|
||||
|
||||
A command that never runs has not been dropped by the network: the main loop is busy or stuck. That is what the next section is about.
|
||||
|
||||
## How commands run
|
||||
@@ -69,22 +74,26 @@ A command that never runs has not been dropped by the network: the main loop is
|
||||
|
||||
## Security
|
||||
|
||||
- The token is checked **before anything else**, and a wrong one costs a second.
|
||||
- Anyone on the same network **with the token** can read the console, press keys and restart the device. The console never prints stored secrets (Wi-Fi and IRC passwords), but the IRC traffic it shows is readable.
|
||||
- The stream is **plain text**: fine on a home network, not across the internet. Do not forward port 2323.
|
||||
- **Release builds have no console at all.** Nothing listens.
|
||||
- **Off by default.** Nothing listens until the console is switched on at the device, or over USB.
|
||||
- The login is a **challenge and an answer**: the token itself is never sent, and five wrong answers close the console for a minute.
|
||||
- Anyone on the same network **with the token** can read the console, press keys, copy the SD card's files and restart the device. The console never prints stored secrets (the token, Wi-Fi and IRC passwords), but the IRC traffic it shows is readable.
|
||||
- They cannot change the firmware: an update still has to be **signed**.
|
||||
- The stream after the login is **plain text**: fine on a home network, not across the internet. Do not forward port 2323.
|
||||
|
||||
The decision is [ADR 0004](/dev/decisions/0004-debug-console-in-debug-builds/).
|
||||
The decisions are [ADR 0010](/dev/decisions/0010-debug-console-in-every-build/) and, for how the console works inside, [ADR 0004](/dev/decisions/0004-debug-console-in-debug-builds/).
|
||||
|
||||
## Without `rdbg.py`
|
||||
|
||||
Anything that can open a TCP connection works. The token and each command are just lines:
|
||||
Anything that can open a TCP connection and compute an HMAC works:
|
||||
|
||||
```python
|
||||
import socket
|
||||
import hashlib, hmac, socket
|
||||
token = "K7QF3M2X9WBDHT4P6RNC" # tidied: capitals, no dashes
|
||||
s = socket.create_connection(("10.39.39.12", 2323))
|
||||
s.sendall(b"<token>\n") # then read the banner line
|
||||
s.sendall(b"info\n") # read until the stream goes quiet
|
||||
challenge = s.makefile().readline().split()[-1] # "... challenge 0f1e2d..."
|
||||
answer = hmac.new(token.encode(), bytes.fromhex(challenge), hashlib.sha256).hexdigest()
|
||||
s.sendall((answer + "\n").encode()) # then read the banner line
|
||||
s.sendall(b"info\n") # and what follows, until the stream goes quiet
|
||||
```
|
||||
|
||||
`scripts/rdbg.py` adds the parts that need work on the PC side: decoding crash backtraces, saving files and screenshots, and checking checksums.
|
||||
|
||||
@@ -6,7 +6,7 @@ weight = 5
|
||||
tag = "Console"
|
||||
+++
|
||||
|
||||
Rollback (see [How an update works](/dev/build/how-an-update-works/)) protects against **new** firmware that fails Probation. These measures cover firmware that was **confirmed and then crashes**: a corrupt setting, a server that sends something unexpected, a bug that takes an hour to show. They are in **every build**, release and Debug alike; the Debug Console is what makes them convenient. The decision is [ADR 0005](/dev/decisions/0005-safe-mode-crash-reports-watchdog/).
|
||||
Rollback (see [How an update works](/dev/build/how-an-update-works/)) protects against **new** firmware that fails Probation. These measures cover firmware that was **confirmed and then crashes**: a corrupt setting, a server that sends something unexpected, a bug that takes an hour to show. They are in every firmware; the Debug Console is what makes them convenient. The decision is [ADR 0005](/dev/decisions/0005-safe-mode-crash-reports-watchdog/).
|
||||
|
||||
## What a crash leaves behind
|
||||
|
||||
@@ -18,7 +18,7 @@ The `crash` command prints it again whenever you like:
|
||||
|
||||
```
|
||||
> crash
|
||||
crash: last one in v0.9.0-1-g4ab873e-dirty+debug (panic)
|
||||
crash: last one in v0.9.0-1-g4ab873e-dirty (panic)
|
||||
crash: task loopTask, pc 0x4037e179, cause 0, address 0x00000000
|
||||
crash: reason: abort() was called at PC 0x421209b3 on core 1
|
||||
crash: backtrace 0x4037e179 0x4037e141 0x4038582d 0x421209b3 ...
|
||||
@@ -40,7 +40,8 @@ scripts/rdbg.py coredump my.bin # ...to a file you name
|
||||
- **`crash`** runs the device's `crash`, finds the ELF by digest (or by version), and passes the backtrace to `scripts/decode_backtrace.sh`, which runs `addr2line` from the build container: one line for each frame, with function and file:line.
|
||||
- **`coredump`** fetches the raw dump over the console (it works when the main loop is stuck) and runs `scripts/decode_coredump.sh`: `esp-coredump` and GDB, giving **every task's backtrace, the registers, and the crashed task's stack**.
|
||||
- By hand: `scripts/decode_backtrace.sh <version|digest> <address>...`, and `scripts/decode_coredump.sh <core.bin> <version|digest>`. Both say which ELF they used.
|
||||
- If no archived ELF matches, they say so: the build was made on another machine, or `.pio/` was cleaned.
|
||||
- **A release's ELF is fetched for you.** When the crash names a released version (`v0.12.0`) and no local ELF matches, `rdbg.py` downloads `roro9stack-<version>.elf.gz` from the release on Gitea into `.pio/elves/`, so a crash on a firmware you did not build can be decoded. Decoding itself still runs in the build container.
|
||||
- If nothing matches, the scripts say so: an unreleased build made on another machine, or `.pio/` was cleaned.
|
||||
|
||||
## The main loop is watched
|
||||
|
||||
@@ -52,21 +53,19 @@ An installed update no longer depends on the main loop either: the Update Servic
|
||||
|
||||
The count of starts that follow a crash (a panic or the watchdog) is kept in NVS. After **three in a row**, the firmware starts **Safe Mode** instead of everything else:
|
||||
|
||||
- only the **clock, Wi-Fi, the Update Service and, in a Debug Build, the Debug Console** start: no Apps, no IRC, no SD card;
|
||||
- only the **clock, Wi-Fi, the Update Service and, if it is switched on, the Debug Console** start: no Apps, no IRC, no SD card;
|
||||
- the screen says so, with the **address to push an update to**;
|
||||
- only a few commands run (the [command reference](/dev/debug/commands/) lists them); anything else answers `not available in Safe Mode`.
|
||||
|
||||
Any **normal restart** (`reboot`, an update) or **a minute of uptime** resets the count. So Safe Mode is reached by crashing three times *quickly*, and a crash loop costs about half a minute before the device becomes reachable. To leave it: push a fix (`scripts/flash.sh --debug --ota`), or `reboot`.
|
||||
Any **normal restart** (`reboot`, an update) or **a minute of uptime** resets the count. So Safe Mode is reached by crashing three times *quickly*, and a crash loop costs about half a minute before the device becomes reachable. To leave it: push a fix (`scripts/flash.sh --ota`), or `reboot`. Over USB, `debug on` works in Safe Mode too, if the console was off.
|
||||
|
||||
**Its limit:** if Wi-Fi or the Update Service is what crashes, Safe Mode cannot help, and it takes USB.
|
||||
|
||||
## Crash on purpose
|
||||
|
||||
On a Debug Build:
|
||||
|
||||
```
|
||||
crash abort # abort(): a panic with a core dump
|
||||
crash wdt # hang the main loop until the task watchdog fires
|
||||
```
|
||||
|
||||
Use them to see a real report and decode it, to check that **the counter reaches Safe Mode**, and to prove that a Debug Build you are about to rely on will survive its own crashes. The device restarts by itself after either, and the report is there to read when the console comes back.
|
||||
Use them to see a real report and decode it, to check that **the counter reaches Safe Mode**, and to prove that a build you are about to rely on will survive its own crashes. The device restarts by itself after either, and the report is there to read when the console comes back.
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
+++
|
||||
title = "Debug Builds"
|
||||
description = "What a Debug Build is, how to build and flash one, how its token works, and why you should keep one in the fallback slot."
|
||||
weight = 1
|
||||
[extra]
|
||||
tag = "Start here"
|
||||
+++
|
||||
|
||||
## What it is
|
||||
|
||||
A Debug Build is built from the same source as a release, with the `cardputer-adv-debug` environment (it `extends` `cardputer-adv` in `platformio.ini`) and `-DRORO_DEBUG`. Differences:
|
||||
|
||||
- the **Debug Console** on TCP **2323** (next page);
|
||||
- extra commands, only meant for testing: crash on purpose, fake an installed version, damage a download, inject a LoRa packet, fill a folder with files (see the [command reference](/dev/debug/commands/));
|
||||
- the version ends in **`+debug`** (`scripts/version.py`) wherever the version shows: Settings, `info`, the Update Service. A `+debug` version compares **equal** to its release counterpart, so going between the two is never refused as a downgrade.
|
||||
|
||||
**It is compiled out of release builds, not switched off by a setting.** A console that runs commands, presses keys and reboots the device is a remote control; in a release build nothing listens and the code is not there.
|
||||
|
||||
## Build and flash one
|
||||
|
||||
Everything runs in Docker (see [Build, test and release](/dev/build/build-and-test/)). Over USB:
|
||||
|
||||
```sh
|
||||
scripts/flash.sh --debug # builds cardputer-adv-debug, uploads, opens the serial monitor
|
||||
```
|
||||
|
||||
Once a Debug Build is on the device, every later one can go over Wi-Fi, with no cable:
|
||||
|
||||
```sh
|
||||
export RORO_OTA_HOST=10.39.39.12 # the address Settings > Firmware shows
|
||||
scripts/flash.sh --debug --ota # builds, signs and pushes; the device installs and restarts
|
||||
```
|
||||
|
||||
The device's address is on **Settings → Firmware** ("Push to", which also gives port 3232, the update port). The Debug Console is on the same address, port 2323. See [Flash and update](/dev/build/flash/) for the update side.
|
||||
|
||||
## The token
|
||||
|
||||
The console asks for a secret first. It is **128 random bits**, made the first time any build runs (`scripts/_docker.sh`), kept in `~/.config/roro9stack/debug-token` next to the update-signing key, and passed into the build container as `RORO_DEBUG_TOKEN`. `scripts/debug_flags.py` compiles it into the firmware, and **refuses to build a Debug Build without one**, rather than fall back on a default.
|
||||
|
||||
- It is **never committed.** Releases have no console, so nothing of it is published: CI builds a Debug Build on a pull request to prove it still compiles, but never publishes it, because each one carries its builder's token.
|
||||
- It guards against **the network**, not against someone who holds the device: anyone with USB access can flash anything anyway ([ADR 0003](/dev/decisions/0003-own-signature-check-not-secure-boot/)).
|
||||
- To change it, delete the file and build again; the new token goes into the next Debug Build you flash.
|
||||
|
||||
## Keep a Debug Build in the other slot
|
||||
|
||||
The device has two app slots, so an update never overwrites the running firmware. If a new firmware crashes during [Probation](/dev/build/how-an-update-works/), the device goes **back to the previous one**, whatever that is. As long as you develop on Debug Builds, **the firmware a crash falls back to has the console too**, so a bad update never costs you remote access. Pushing a *release* build over a Debug Build leaves the Debug Build in the other slot until the next update overwrites it.
|
||||
|
||||
So the habit is: develop on Debug Builds, release by tag, and think twice before pushing a release over the only Debug Build you have.
|
||||
|
||||
## Releases and Debug Builds
|
||||
|
||||
- A Debug Build shows the latest release in Settings → Firmware but **never installs it**: that would replace the console with a release that has none. Update a Debug Build from the PC with `scripts/flash.sh --debug --ota`.
|
||||
- A Debug Build still checks and lists releases, which is useful for testing the update path: `update pretend` makes a released version count as newer (see [Drive the UI](/dev/debug/drive-the-ui/)).
|
||||
- **Safe Mode** (after 3 crashes in a row) keeps the Debug Console running, so a crash loop is something you fix remotely: see [Crashes and Safe Mode](/dev/debug/crashes/).
|
||||
|
||||
The reasoning is in [ADR 0004](/dev/decisions/0004-debug-console-in-debug-builds/).
|
||||
@@ -11,7 +11,7 @@ Everything the keyboard can do, a command can do, and everything on the screen c
|
||||
## Keys
|
||||
|
||||
```
|
||||
key up|down|left|right|select|back|home|del|tab|space
|
||||
key up|down|left|right|select|back|home|del|tab|space|help
|
||||
key a # any single character: it is typed
|
||||
```
|
||||
|
||||
@@ -22,6 +22,18 @@ Two things to know before you use them:
|
||||
|
||||
`Fn` combinations, modifiers and the compose key have no command: the arrows are `key up|down|left|right`, and `key back` is the back key (`` ` `` on the device). Text is typed one character at a time.
|
||||
|
||||
**`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
|
||||
|
||||
**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.
|
||||
@@ -34,6 +46,7 @@ scripts/rdbg.py key select
|
||||
scripts/rdbg.py screenshot b.png # look again before the next destructive step
|
||||
```
|
||||
|
||||
- **Check the App in front before typing anything.** `info` prints `app: <name>`. A crash restarts the device into the Launcher, and a script that goes on typing is typing somewhere else: one of this project's own test scripts sent a word to an IRC channel that way.
|
||||
- Prefer **reading a state** to assuming it: `info`, `ls <folder>`, `cat <file>`, `irc dump`, `gnss status`, `lora status`, `wifi status`, `update status`.
|
||||
- Test on a **scratch folder** on the card, not on your real files.
|
||||
- For anything that deletes (`rm`, a delete dialog), `ls` first and `ls` after.
|
||||
@@ -59,12 +72,12 @@ Each of these puts something in, **without** the outside world:
|
||||
| `gnss send <sentence>` | Sends an NMEA sentence **to** the GNSS receiver (the checksum is added), to configure it |
|
||||
| `gemini get <url>` | Fetches a page and reports header, size, certificate and heap use, without the App |
|
||||
| `gemini trust <host> <port> <sha256>` | Pins a certificate by hand |
|
||||
| `sd fill <folder> <count>` | **Debug Build.** Makes that many small files in a folder, to test a crowded one (the Storage App shows the first 256) |
|
||||
| `sd fill <folder> <count>` | Makes that many small files in a folder, to test a crowded one (the Storage App shows the first 256) |
|
||||
| `wifi add <ssid><TAB><password>` | Adds a Saved Network, so credentials stay out of the repository |
|
||||
|
||||
## Test the update path
|
||||
|
||||
An update that goes wrong is the case you most want to rehearse, and a Debug Build can make it go wrong **on purpose**:
|
||||
An update that goes wrong is the case you most want to rehearse, and the firmware can make it go wrong **on purpose**:
|
||||
|
||||
```
|
||||
update status # what the device runs, what failed here before, the daily check, heap
|
||||
@@ -72,15 +85,14 @@ update check | update list # look at the server: the latest release, or
|
||||
update pretend v0.9.0 # take the running version to be v0.9.0: the latest release now counts as "new"
|
||||
update damage cut 50000 # the next download is cut after 50000 bytes
|
||||
update damage flip 100000 # ...or has the byte at offset 100000 damaged
|
||||
update install v0.11.0 force # try the install
|
||||
update install v0.11.0 # try the install
|
||||
update probe git.twis.la # is that server's certificate accepted? (the two ISRG roots only)
|
||||
update daily # forget today's daily check: it runs again at the next tick
|
||||
update pretend off # back to the real version
|
||||
```
|
||||
|
||||
- **`force` is needed on a Debug Build.** A plain `update install <tag>` answers `Debug Build: update from the PC`, because installing a release would replace the console with a build that has none. `force` is accepted only on a Debug Build, and only from the console.
|
||||
- **A damaged download must be refused cleanly:** the Update Service checks the signature after the first 160 bytes and the image hash at the end, so a cut or a flipped byte must end in a refusal with **nothing switched**. Check `info` afterwards: both slots, and `update: confirmed`.
|
||||
- **An undamaged `force` install really installs** the release, into the other slot. The Debug Build stays where it was until the next update overwrites it, and a Rollback returns to it, but think before you do it.
|
||||
- **An undamaged install really installs** the release, into the other slot, and the device restarts into it. Your build stays in the slot it was in until the next update overwrites it, and the console's setting and token are untouched: the release has the console too.
|
||||
- The server is read by the device itself, with Wi-Fi up; the download is one TLS connection, about 52 KB of heap at its peak, so IRC steps aside (see [the memory limit](/howto/not-enough-memory/)). `status: heap` in the stream shows it happen.
|
||||
|
||||
For crashes during Probation, see [Crashes and Safe Mode](/dev/debug/crashes/).
|
||||
@@ -94,7 +106,7 @@ wifi ip MyNet 10.39.39.50/24 10.39.39.1 try 60 # use this address for 60 s..
|
||||
wifi ip keep # ...and keep it, if you could still reach the device
|
||||
```
|
||||
|
||||
If you do not send `wifi ip keep` in time, the device goes back to the **previous** setting by itself, and the console comes back with it. (A Debug Build command: `try` is not in release builds.)
|
||||
If you do not send `wifi ip keep` in time, the device goes back to the **previous** setting by itself, and the console comes back with it.
|
||||
|
||||
## Measure
|
||||
|
||||
@@ -104,7 +116,7 @@ tasks # each task over the next second: state, priority, least free stack,
|
||||
net # bytes each network service has read and written since start
|
||||
```
|
||||
|
||||
`tasks` takes a second to answer. A low number in the `stack` column is a risk (the System App shows it in the warning colour under 512 bytes). `loop spin on|off` (Debug Build) makes the main loop spin without resting, to compare load and radio noise. And the `status: heap` line every 10 seconds in the stream is the cheapest memory trace there is: watch it while you do the thing you suspect.
|
||||
`tasks` takes a second to answer. A low number in the `stack` column is a risk (the System App shows it in the warning colour under 512 bytes). `loop spin on|off` makes the main loop spin without resting, to compare load and radio noise. And the `status: heap` line every 10 seconds in the stream is the cheapest memory trace there is: watch it while you do the thing you suspect.
|
||||
|
||||
```
|
||||
task st pri stack cpu% core
|
||||
@@ -120,4 +132,4 @@ The [System App](/guide/system/) shows the same, live, on the device.
|
||||
|
||||
## Radio experiments
|
||||
|
||||
`lora preset <name>` and `lora custom <MHz> <BW kHz> <SF> <CR> <sync hex> [preamble]` change what the receiver listens to (receive only: the radio never transmits), `lora sweep on [from] [to] [step]` takes a survey, and `lora noise test [gnss|quiet]` (Debug Build) runs a Sweep under one changed condition at a time, with Wi-Fi off for a moment, to find what raises the noise floor. `lora probe` finds the radio and reports its chip, oscillator, antenna switch, interrupt line and noise floor. Details in the [command reference](/dev/debug/commands/).
|
||||
`lora preset <name>` and `lora custom <MHz> <BW kHz> <SF> <CR> <sync hex> [preamble]` change what the receiver listens to (receive only: the radio never transmits), `lora sweep on [from] [to] [step]` takes a survey, and `lora noise test [gnss|quiet]` runs a Sweep under one changed condition at a time, with Wi-Fi off for a moment, to find what raises the noise floor. `lora probe` finds the radio and reports its chip, oscillator, antenna switch, interrupt line and noise floor. Details in the [command reference](/dev/debug/commands/).
|
||||
|
||||
@@ -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 the real thing: the screenshots on this site, in the [user guide](/guide/) and the [devlog](/devlog/), were taken this way.
|
||||
- **With a number, it saves to the card instead:** `screenshot 5` (or `screenshot 0`) writes a PNG to `/screenshots` on the SD card after that many seconds, as the [Shell](/guide/shell/) does. A bare `screenshot` over the console is the binary one above.
|
||||
- Its main use is in a loop: send a key, wait a moment, take a screenshot, look. See [Drive the UI](/dev/debug/drive-the-ui/).
|
||||
|
||||
## `coredump get`: the crash dump
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
+++
|
||||
title = "Switch the console on"
|
||||
description = "The Debug Console is in every firmware, and off. How to switch it on, where its token comes from, and how to set a device up without typing anything."
|
||||
weight = 1
|
||||
[extra]
|
||||
tag = "Start here"
|
||||
+++
|
||||
|
||||
## One firmware
|
||||
|
||||
There is no special build. **Every roro9stack firmware has the Debug Console**, the same one, and the commands made for testing (crash on purpose, fake an installed version, damage a download, inject a LoRa packet). It is **off** until its owner switches it on.
|
||||
|
||||
**Off means nothing is there:** no socket listens, the console's task does not exist, and neither does its 4 KB buffer. A device that never uses it pays 30 KB of flash and 88 bytes of memory.
|
||||
|
||||
Before version 0.12 this was a separate *Debug Build* with a token compiled in from the builder's machine, which is why it could not be published. [ADR 0010](/dev/decisions/0010-debug-console-in-every-build/) says why that changed.
|
||||
|
||||
## On the device
|
||||
|
||||
**Settings → Debug Console:**
|
||||
|
||||
| Row | Does |
|
||||
|---|---|
|
||||
| **Debug Console** | The switch. Switching it **on** asks first (the question opens on *Cancel*: move to *Switch on*), and makes a token if there is none |
|
||||
| **Connect to** | The address and port: `10.39.39.12:2323` |
|
||||
| **New token** | Makes another one. The old one stops working, and whoever is connected is cut off |
|
||||
| **Type a token** | One of your own, of 16 to 64 characters |
|
||||
|
||||
Under the rows, the **token**, in large type on two lines, in groups of four: `K7QF-3M2X-9WBD-HT4P-6RNC`. This page is the only place it is ever shown. The Status Bar shows **`DBG`** while the console listens, and brighter while someone is connected.
|
||||
|
||||
The setting **stays** across restarts and updates, and in Safe Mode.
|
||||
|
||||
## The token
|
||||
|
||||
- **The device makes it,** from its hardware random generator, the first time the console is switched on: 100 bits, written as 20 characters without the letters that get misread (no I, L, O or U).
|
||||
- **Dashes and case do not count,** and an `O`, `I` or `L` is taken for the `0` or `1` it was. Type it as you read it.
|
||||
- **One you type** must have at least 16 characters. A short one would be the weakest part of the whole thing.
|
||||
- It is **never printed** on a console, and it **never crosses the network** ([the Debug Console](/dev/debug/console/) says how).
|
||||
- It guards against **the network**, not against someone who holds the device: anyone with USB access can flash anything anyway ([ADR 0003](/dev/decisions/0003-own-signature-check-not-secure-boot/)).
|
||||
|
||||
## Give `rdbg.py` the token
|
||||
|
||||
`scripts/rdbg.py` looks for it in this order:
|
||||
|
||||
```sh
|
||||
scripts/rdbg.py -t K7QF-3M2X-9WBD-HT4P-6RNC info # 1. on the command line (also --token)
|
||||
RORO_DEBUG_TOKEN=K7QF-3M2X-9WBD-HT4P-6RNC scripts/rdbg.py info # 2. in the environment
|
||||
echo K7QF-3M2X-9WBD-HT4P-6RNC > ~/.config/roro9stack/debug-token # 3. in a file, for the device you use every day
|
||||
```
|
||||
|
||||
## Without typing: over USB
|
||||
|
||||
With the device on a cable, the console can be set up from the PC:
|
||||
|
||||
```sh
|
||||
scripts/flash.sh --debug # flashes over USB, then switches the console on and gives it your token
|
||||
```
|
||||
|
||||
It sends two commands over the serial port, which you can also type there yourself:
|
||||
|
||||
```
|
||||
debug on # switch it on (a token is made if there is none)
|
||||
debug token <value> # give it this token: 16 to 64 characters
|
||||
debug token new # make a new one
|
||||
debug status # on or off, token set or not, a client or not (the token itself is never shown)
|
||||
debug off # switch it off
|
||||
debug off 30 # ...for 30 seconds: it comes back by itself
|
||||
```
|
||||
|
||||
`debug on` and `debug token` work **over USB serial only**: the console cannot be used to open itself wider. `debug status` and `debug off` work from anywhere, and a screenshot taken over the console while this page is open shows the token, to someone who already had it. Your token file is made by the first build (`scripts/_docker.sh`), 32 hex digits, and is never committed.
|
||||
|
||||
Since the setting survives updates, this is needed **once for a device**, not at each flash. Later builds go over Wi-Fi with `scripts/flash.sh --ota`.
|
||||
|
||||
## Should it be on?
|
||||
|
||||
On your own network, on a device you are working on: yes, that is what it is for. Remember what it gives to whoever has the token **and** is on the same network: the console, the keys, the files on the SD card, a restart. It does not give them the firmware: an update still has to be signed.
|
||||
|
||||
On a network you share with strangers, switch it off, or at least know that what the console prints is not encrypted. The token is safe there; the conversation is not.
|
||||
@@ -1,6 +1,6 @@
|
||||
+++
|
||||
title = "A Debug Console over Wi-Fi, in Debug Builds only"
|
||||
description = "The goal of Firmware Updates is to manage the device without a cable, and that includes finding out what went wrong. So a Debug Build (cardputer-adv-debug, -DRORO_DEBUG, version suffix +debug) adds a Debug Console on TCP 2323…"
|
||||
description = "Superseded in part by ADR 0010 (2026-10-06): there is no Debug Build any more. The console is in every firmware, off until switched on, and its token belongs to the device, not to the build. What this record says about how the…"
|
||||
weight = 4
|
||||
|
||||
[extra]
|
||||
@@ -8,6 +8,8 @@ docs = true
|
||||
source = "docs/adr/0004-debug-console-in-debug-builds.md"
|
||||
tag = "ADR 0004"
|
||||
+++
|
||||
**Superseded in part by [ADR 0010](/dev/decisions/0010-debug-console-in-every-build/) (2026-10-06):** there is no Debug Build any more. The console is in every firmware, off until switched on, and its token belongs to the device, not to the build. What this record says about how the console works (the ring, commands on the main loop, binary commands on its own task, one client) still holds.
|
||||
|
||||
The goal of Firmware Updates is to manage the device without a cable, and that includes finding out what went wrong. So a **Debug Build** (`cardputer-adv-debug`, `-DRORO_DEBUG`, version suffix `+debug`) adds a **Debug Console** on TCP 2323: the serial console, over Wi-Fi. A client sends a token as its first line, then gets the last 4 KB of console output (boot messages included), every new line live, and runs the same commands as the serial port, plus a few that only make sense remotely. ESP-IDF's own log lines are teed into it.
|
||||
|
||||
It's compiled out of release builds entirely, rather than switched off by a setting. A console that runs commands is a remote control: in a release build, nothing listens.
|
||||
|
||||
@@ -8,10 +8,10 @@ docs = true
|
||||
source = "docs/adr/0005-safe-mode-crash-reports-watchdog.md"
|
||||
tag = "ADR 0005"
|
||||
+++
|
||||
Rollback protects against new firmware that fails Probation. It does nothing for firmware that was confirmed and crashes later: a corrupt setting, a server that sends something unexpected, a bug that takes an hour to show. Without a cable, such a device would restart forever. Three measures, in release and Debug Builds alike, keep it reachable:
|
||||
Rollback protects against new firmware that fails Probation. It does nothing for firmware that was confirmed and crashes later: a corrupt setting, a server that sends something unexpected, a bug that takes an hour to show. Without a cable, such a device would restart forever. Three measures, in every build, keep it reachable:
|
||||
|
||||
- **Safe Mode.** The firmware counts starts that follow a crash (panic or watchdog) in NVS, first thing at boot. After 3 in a row, it starts only the clock, Wi-Fi, the Update Service and, in a Debug Build, the Debug Console: no Apps, no IRC, no SD card, and a screen that says so with the address to push an update to. Any normal restart (a `reboot`, an update), or a minute of uptime, resets the count.
|
||||
- **Crash reports.** The same boot record keeps which version was running, so after a crash the firmware knows which one crashed, even when a Rollback has switched slots since. ESP-IDF already writes a core dump to its flash partition on a panic; after the restart the firmware prints its summary (task, PC, reason, backtrace) and raises a Notification. The `crash` command shows it again later. In a Debug Build, `scripts/rdbg.py crash` decodes the backtrace and `scripts/rdbg.py coredump` fetches the whole dump for `esp-coredump`, against the ELF of that exact build (`.pio/elves/`, named by version and ELF digest).
|
||||
- **Safe Mode.** The firmware counts starts that follow a crash (panic or watchdog) in NVS, first thing at boot. After 3 in a row, it starts only the clock, Wi-Fi, the Update Service and, if it's switched on, the Debug Console: no Apps, no IRC, no SD card, and a screen that says so with the address to push an update to. Any normal restart (a `reboot`, an update), or a minute of uptime, resets the count.
|
||||
- **Crash reports.** The same boot record keeps which version was running, so after a crash the firmware knows which one crashed, even when a Rollback has switched slots since. ESP-IDF already writes a core dump to its flash partition on a panic; after the restart the firmware prints its summary (task, PC, reason, backtrace) and raises a Notification. The `crash` command shows it again later. With the Debug Console, `scripts/rdbg.py crash` decodes the backtrace and `scripts/rdbg.py coredump` fetches the whole dump for `esp-coredump`, against the ELF of that exact build (`.pio/elves/`, named by version and ELF digest).
|
||||
- **The main loop is watched.** Arduino-ESP32 subscribes only core 0's idle task to the task watchdog, and the main loop runs on core 1: a stuck loop used to hang the device for good, with the screen frozen and the Debug Console unable to run commands. `enableLoopWDT()` makes a loop stuck for 5 s a panic, with a core dump, counted towards Safe Mode. And an installed update no longer depends on the main loop: the Update Service restarts into it by itself after 90 s.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
+++
|
||||
title = "The Debug Console is in every build, off until its owner switches it on"
|
||||
description = "There is one firmware. The Debug Console of ADR 0004 is compiled into it, and so are the commands that used to exist only in Debug Builds. It listens only while Settings → Debug Console is on, which is not the default, and it…"
|
||||
weight = 10
|
||||
|
||||
[extra]
|
||||
docs = true
|
||||
source = "docs/adr/0010-debug-console-in-every-build.md"
|
||||
tag = "ADR 0010"
|
||||
+++
|
||||
There is **one firmware**. The Debug Console of ADR 0004 is compiled into it, and so are the commands that used to exist only in Debug Builds. It listens only while **Settings → Debug Console** is on, which is not the default, and it lets in whoever proves they hold **the device's own token**. The `cardputer-adv-debug` environment, `RORO_DEBUG`, the `+debug` version and the token compiled in from the builder's machine are gone.
|
||||
|
||||
ADR 0004 kept the console out of release builds with one argument: a console that runs commands is a remote control, so in a release nothing should listen. That argument was never whole: the Update Service has listened on TCP 3232 in every build since Firmware Updates exist, guarded by a signature. A listener that is off by default and guarded by a secret the device made itself is the same kind of trade, and what the split cost had grown:
|
||||
|
||||
- **What was tested wasn't what shipped.** Development ran on Debug Builds; releases were a different binary, built to be published and never run by the person who wrote them.
|
||||
- **Debug Builds couldn't be published**, since each carried its builder's token. So the console, `rdbg.py`, screenshots and crash decoding were for whoever built the firmware, and nobody else.
|
||||
- **Rules that existed only to protect the console:** a Debug Build never installed a release (it would have lost the console), `update install … force` to do it anyway, "keep a Debug Build in the fallback slot", versions with `+debug` that had to compare equal to their release.
|
||||
- **CI built two firmwares** on every pull request and every tag.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Off means nothing is there.** With the setting off, no socket listens, the console's task doesn't exist and neither does its 4 KB ring: a device that never uses the console pays for it in flash (30 KB more than the release build was) and 88 bytes of static RAM, measured. A missing or invalid stored setting counts as off.
|
||||
- **The token is the device's.** The first time the console is switched on, the device makes one from its hardware random generator: 100 bits, as 20 characters of Crockford's base32 (`K7QF-3M2X-9WBD-HT4P-6RNC`), shown on the Debug Console page and nowhere else. It can be replaced by one typed by hand, of 16 to 64 characters. It is stored with the other settings and is never printed on a console.
|
||||
- **The token never crosses the network.** The device sends 16 random bytes; the client answers with their HMAC-SHA256 keyed by the token. Someone on the same Wi-Fi who records a login has nothing they can use for the next one.
|
||||
- **Five wrong answers in a row** close the console to everyone for a minute, with a Notification naming the address they came from. A wrong answer still costs a second.
|
||||
- **`DBG` in the Status Bar** while the console listens, bright while a client is connected.
|
||||
- **USB serial can set it up:** `debug on`, `debug token <value>`, `debug token new`. `scripts/flash.sh --debug` uses them to give a freshly flashed device the developer's token. Whoever holds the cable can flash anything anyway (ADR 0003); the console itself can only switch itself off.
|
||||
- **Safe Mode** starts the console if it's switched on: the settings are read before Safe Mode is decided.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **"Nothing listens" now rests on one stored setting**, not on absent code. That setting is typed, validated and host-tested, and its default is off; a bug that flipped it would have to come from code that already runs on the device.
|
||||
- **Whoever has the token and the network has the device:** the console, its keys, the SD card's files, a restart. Not its firmware: an update still has to be signed (ADR 0003).
|
||||
- **The stream is still plain text.** The token is safe from an eavesdropper; what the console prints, and the files it carries, are not. TLS would cost about 52 KB of the 107 KB there is, next to IRC's own connection: not for a console.
|
||||
- **An old `rdbg.py` can't talk to a new firmware**, and the reverse: the first line of the protocol changed. Accepted, with one user.
|
||||
- **A device whose settings are damaged has no console in Safe Mode**, only the signed update push. Before, a Debug Build always had it.
|
||||
- **The commands that crash, damage a download or fill a folder on purpose** are in every build. They need the cable or the token, like everything else.
|
||||
- **Releases already publish their ELF**, so `rdbg.py crash` fetches it when it isn't in `.pio/elves/`: crash reports can be decoded without having built the firmware.
|
||||
@@ -22,7 +22,7 @@ Until now the tests, the builds, the signing and the flashing all happened on on
|
||||
|---|---|
|
||||
| Q151 | A push to `main`: the host tests (with their coverage). A push to another branch: nothing, its pull request is what runs (a branch with an open pull request ran twice, once for each). A pull request: the same and both builds; changes reach `main` through pull requests. A tag `v*`: all of it, then a release. (First: everything on every push, which rebuilt the firmware far more often than anyone looked at it.) |
|
||||
| Q152 | **CI signs.** The signing key is the repository secret `OTA_SIGNING_KEY`; a tag push makes a complete, signed release with no manual step (ADR 0008). |
|
||||
| Q153 | The Debug Build is built in CI with a token of the runner's own, to prove it compiles, and **isn't published**: it would hand everyone its Debug Console token. |
|
||||
| Q153 | *Revised by Q188: there is one firmware.* The Debug Build is built in CI with a token of the runner's own, to prove it compiles, and **isn't published**: it would hand everyone its Debug Console token. |
|
||||
| Q154 | Pull requests from forks don't start a run. |
|
||||
| Q155 | A release carries `roro9stack-<version>.ota` (signed), `-factory.bin` for USB, `.elf.gz` to decode crashes, and `SHA256SUMS`. |
|
||||
| Q156 | Its text is the tag's message, what the files are, and the commits since the tag before. |
|
||||
@@ -72,7 +72,7 @@ The device looks at the project's Gitea for a newer release, says so, and instal
|
||||
| Q168 | A version that failed (rolled back) isn't announced again by the background check until a newer one exists; it can still be installed by hand. |
|
||||
| Q169 | **Older releases:** a list of the last ten, newest first, the running one marked. Installing an older one asks with a stronger warning. |
|
||||
| Q170 | Enter on a release shows its version, date, size and the tag message, with Install. |
|
||||
| Q171 | **Debug Builds** check and show the latest release, but don't install it: it would replace the Debug Build and its console (Q153: Debug Builds aren't published). Their updates come from the PC. |
|
||||
| Q171 | *Revised by Q188: there is no Debug Build, every firmware installs releases.* **Debug Builds** check and show the latest release, but don't install it: it would replace the Debug Build and its console (Q153: Debug Builds aren't published). Their updates come from the PC. |
|
||||
| Q172 | **Memory, as measured:** a TLS connection peaks at about 52 KB of heap, with or without checking the certificate, so it starts with 80 KB free (Q86's 20 KB spare on top), not 55 KB. **A check, list or install someone asked for makes IRC step aside** and come back after; the daily check never does, and with IRC connected it waits. (First: 55 KB and nothing else. With IRC connected a check left 3 KB and a download 836 bytes.) |
|
||||
| Q173 | Left out, each with its issue: installing automatically (#52), a release channel (#53), resuming a download (#54). |
|
||||
| Q174 | Ships as **v0.11.0**. Tested on the device with the real signed releases; a Debug Build command pretends the device runs an older version, so v0.10.0 counts as an update. |
|
||||
@@ -131,3 +131,111 @@ The device looks at the project's Gitea for a newer release, says so, and instal
|
||||
- **A key press during the hold:** Back on the Firmware page while a check is going doesn't cancel it.
|
||||
|
||||
**Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure.
|
||||
|
||||
## One firmware: the Debug Console in every build (issue #68)
|
||||
|
||||
The issue asked for a token that could be set, so that Debug Builds could be published. The design round ended somewhere simpler: no Debug Builds. The console is in every firmware, off until switched on, with a token that belongs to the device (ADR 0010, which supersedes part of ADR 0004).
|
||||
|
||||
### Decisions (design round 2026-10-06)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q188 | **One firmware,** with the Debug Console and the test commands compiled in. The `cardputer-adv-debug` environment, `RORO_DEBUG`, the `+debug` version and `scripts/debug_flags.py` go; CI builds one firmware. Revises Q153 and Q171. |
|
||||
| Q189 | **Off by default,** and off when the stored setting is missing or invalid. Off, nothing listens and no memory is used: the task and the 4 KB ring exist only while it's on. |
|
||||
| Q190 | **Settings → Debug Console:** the switch, where to connect, the token, "New token", "Type a token". It stays on across restarts and in Safe Mode. **`DBG` in the Status Bar** while it listens, bright with a client connected. |
|
||||
| Q191 | **The device makes the token** the first time the console is switched on: 100 bits as 20 characters of Crockford's base32, shown in groups of four. It can be replaced by one typed by hand, of at least 16 characters. Case and dashes don't count. |
|
||||
| Q192 | **USB serial can set it up:** `debug on`, `debug token <value>`, `debug token new`, over USB only; `debug status` and `debug off` from anywhere. `scripts/flash.sh --debug` gives a device the developer's token. The token is never printed. |
|
||||
| Q193 | **Challenge and answer:** the device sends 16 random bytes, the client their HMAC-SHA256 keyed by the token. Five wrong answers in a row close the console for a minute, with a Notification. **Old clients and old firmwares don't talk to each other:** accepted, this far from v1 and with one user. |
|
||||
| Q194 | `rdbg.py` takes the token from **`-t`/`--token`**, then **`$RORO_DEBUG_TOKEN`**, then `~/.config/roro9stack/debug-token`. |
|
||||
| Q195 | **The test commands are in every build** (`crash abort`, `crash wdt`, `update pretend`, `update damage`, `lora inject`, `sd fill`, `loop spin`, `wifi ip … try`). `update install … force` goes: there's nothing left to protect. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/debug`** (host-tested, 9 tests): the token maker, tidying what a person types, HMAC-SHA256 on the project's own SHA-256 (checked against RFC 4231), the answer to a challenge (the same vector as `rdbg.py`'s), a comparison that takes the same time whatever it's given, and the pause after five wrong answers, right across the clock's wrap.
|
||||
- **Two settings,** `DebugConsole` and `DebugToken`, with the others: validated, and invalid stored values count as missing.
|
||||
- **The console's task and ring come and go with the setting.** `Console::openRing` allocates the ring; switching off closes the socket, frees it and ends the task. `tick` follows the settings once a second, so a switch off and on again takes a second or two.
|
||||
- **Measured against the two builds it replaces:** 1,881,799 bytes of flash and 54,700 of static RAM. The release build was 1,851,387 and 54,612 (so 30 KB more flash and 88 bytes more RAM); the Debug Build was 1,874,887 and 58,780 (4 KB of RAM back: the ring is no longer a static array).
|
||||
- **The listener is asked whether it listens.** The framework's `begin()` returns nothing and fails without a word; the task now checks, says so on the console, and tries again every two seconds.
|
||||
- **`debug off <seconds>`** closes the console and brings it back by itself: the only way to try its closing and reopening from afar, since switching it on is for the device and the cable only.
|
||||
- **`scripts/rdbg.py`** answers the challenge, and fetches a release's ELF from Gitea when a crash names a version that isn't in `.pio/elves/`.
|
||||
|
||||
### Checks
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Host tests | 468 pass (456 before) |
|
||||
| The firmware builds | One environment, 1,881,799 bytes of flash used |
|
||||
| Pushed over Wi-Fi to a device running a Debug Build | Installed, restarted; the update port answers and **port 2323 refuses connections**: off by default |
|
||||
| Switched on at the device (Settings → Debug Console) | A token is made and shown. Its first drawing, in bold at normal size, was misread once: it is now at twice the size, in two lines, and `O`, `I` and `L` are taken for `0` and `1` |
|
||||
| A login with `scripts/rdbg.py` | The challenge is answered; `info`, `screenshot` and the backlog work |
|
||||
| `DBG` in the Status Bar | There while the console is on, bright while a client is connected (screenshots) |
|
||||
| `debug on` and `debug token` over the console | Refused: `over USB serial only` |
|
||||
| Six logins with a wrong token | Five are refused, a second apart; the sixth, and the right token after it, get `locked`. After a minute the right token works again. The console's own log and a Notification say so |
|
||||
| Three `crash abort` in a row | **Safe Mode**, with the console reachable: `info` works, `ls /` says `not available in Safe Mode`. `rdbg.py crash` decodes the backtrace to `runCommand` at the `abort()` line. `reboot` leaves Safe Mode |
|
||||
| An update over Wi-Fi with the console on | The setting and the token survive: the console is back by itself after the restart |
|
||||
| `debug off` over the console | The client is dropped and port 2323 refuses connections; the update port still answers |
|
||||
| `debug off 2`, 10 times in a row, then 15 | It came back each time, a second after the pause. Free heap dips by about 270 bytes for each connection the device closes and **is all back two minutes later** (107.2 KB before, 103.2 right after 15, 107.0 at two minutes): TCP holds a closed connection that long. Not a leak, though it looked like one for an hour |
|
||||
| Memory, console on with a client | 108 KB free (the Debug Build it replaces: 104 KB). In Safe Mode: 178 KB free |
|
||||
|
||||
**One false alarm:** after the tests the console "wouldn't come back on". It was off: the setting had been left off by `debug off`, and the page's "Switch it on?" dialog opens on **Cancel**, so Enter twice leaves it off. Nothing was lost.
|
||||
|
||||
**Not checked:** `scripts/flash.sh --debug` over USB (no device on USB here); "New token" and "Type a token" from the page (the code paths are the ones `debug token` uses, which need the cable); the Toast itself on screen (the console is closed while it shows; its Notification is in the log); free memory with the console off, which only the serial port could say (the static figures above are the evidence); fetching a release's ELF, which needs a crash on a released version.
|
||||
|
||||
## CI that doesn't rebuild the world (issue #74)
|
||||
|
||||
A pull request's run took over seven minutes, a tag's thirteen and a half. The goal: a firmware build under a minute.
|
||||
|
||||
### Where the time went (run 81, a pull request, 2026-10-06)
|
||||
|
||||
| Step | Time |
|
||||
|---|---|
|
||||
| Tools (apt, pip) | 15 s |
|
||||
| Check out | 2 s |
|
||||
| Host tests and coverage | 55 s |
|
||||
| **The firmware** | **358 s** |
|
||||
| of which: CMake configuring ESP-IDF | 87 s |
|
||||
| of which: compiling ESP-IDF's libraries | 171 s |
|
||||
| of which: our own build (the Arduino core, the libraries, `src/`) | 91 s |
|
||||
|
||||
A tag's run did the firmware step, then built the same commit again for the release: twice 356 s.
|
||||
|
||||
### Why the framework was rebuilt every time
|
||||
|
||||
The framework is rebuilt with our SDK settings (ADR 0006), and the rebuilt libraries stay in the toolchain volume. But the platform decides whether they match by reading **`sdkconfig.defaults` in the project folder**, whose first line carries a hash of the settings. That file is generated, and not in git. A fresh checkout has none, so the platform concluded "different settings", **reinstalled the framework and rebuilt it**: 260 seconds, at every run, to arrive at the libraries that were already there. On a developer's machine the file is simply still there from the last build, which is why nobody saw it.
|
||||
|
||||
### What changed
|
||||
|
||||
| Change | Effect |
|
||||
|---|---|
|
||||
| **The file is kept in the volume, inside the libraries it describes** (`framework-arduinoespressif32-libs/.roro-sdkconfig.defaults`), copied into the checkout before a build and back after one that passed. The platform still checks its hash against `platformio.ini`: changed settings rebuild, as they must. Kept there and not beside them, it disappears when the libraries are reinstalled, so it can't describe libraries that are gone | 358 s to 92 s |
|
||||
| **The version is no longer a `-D` on every compiler command line.** `scripts/version.py` writes `lib/version/src/version_generated.h` (not in git, written only when it changes), read by one file. Before, every commit recompiled everything, on a developer's machine too, and no cache could have helped | A rebuild with nothing changed: 77 s to 13 s, locally |
|
||||
| **PlatformIO's build cache** (`PLATFORMIO_BUILD_CACHE_DIR`, SCons's CacheDir) in the volume, for the firmware of pull requests: objects by the signature of their sources and command line | 92 s to 26 s, with a new version and one changed file |
|
||||
| **ccache for the host tests.** They are built with coverage counters, and the build cache would return objects without their `.gcno` files; ccache keeps both | 49 s to 33 s. What's left is PlatformIO starting 51 test programs |
|
||||
| **A tag builds its firmware once**, in the release step | minus 6 minutes |
|
||||
| PlatformIO and gcovr in a virtual environment in the volume | a few seconds |
|
||||
|
||||
**A release compiles its own sources from nothing:** it reuses the rebuilt framework (the platform checks the hash) but not the build cache, so no published file contains an object that came from another commit's build.
|
||||
|
||||
### Measured (a development machine, fresh copies of the tree, the same volume)
|
||||
|
||||
| | Before | After |
|
||||
|---|---|---|
|
||||
| The firmware, fresh checkout, nothing cached for it | 358 s | 82 s (it fills the cache) |
|
||||
| The firmware, fresh checkout, a new version and one file changed | 358 s | **27 s** |
|
||||
| Host tests and coverage | 49 s | 33 s |
|
||||
| Rebuilding locally with nothing changed | 77 s | 13 s |
|
||||
|
||||
### Measured on the runner (pull request #76, 2026-10-07)
|
||||
|
||||
| Run | Tools | Tests and coverage | The firmware | The whole job |
|
||||
|---|---|---|---|---|
|
||||
| Before (run 81) | 15 s | 55 s | 358 s | 434 s |
|
||||
| The first with the new workflow: no mark yet, the framework is rebuilt once more and the caches fill | 16 s | 59 s | 354 s | 431 s |
|
||||
| The next commit (only the workflow changed) | 10 s | 36 s | **51 s** | **100 s** |
|
||||
| The same commit again | 10 s | 36 s | **18 s** | **66 s** |
|
||||
|
||||
In the 51-second run, 245 objects came from the cache and 43 were compiled: `version.cpp`, as expected, and all 42 files of `src/`, which had not changed. In the run after it, all 290 came from the cache. So the objects of `src/` made by the run that rebuilt the framework were not reusable by a normal run, and those of a normal run are: the two-pass build that rebuilds the framework compiles `src/` with something different on its command line. It costs one 51-second run after each framework rebuild, which is rare; I did not look for what differs.
|
||||
|
||||
The firmware step with everything cached is 18 seconds: the libraries are downloaded and unpacked (4 s), the dependency scan (5 s), fetching 290 objects, the link and the image (the last 11 s). A pull request that changes a few files should land between that and 51 seconds.
|
||||
|
||||
**What it costs:** the build cache grows by about 40 MB a run (each linked firmware is kept) and is started again past 3 GB; ccache is held to 1 GB.
|
||||
|
||||
@@ -144,3 +144,69 @@ M3 found the LoRa radio's noise floor about 15 dB above what the chip hears alon
|
||||
**Settings > Pause GNSS for LoRa**, off by default: while the LoRa radio listens or sweeps, the GNSS receiver waits in standby, and wakes when the radio goes back to sleep (a Fix again after about 7 s here). Never during a Track. The GNSS App says "GNSS is paused" meanwhile. `gnss quiet on|off` on the console.
|
||||
|
||||
It's off by default because GNSS on by default was decided in M2 (Q58), and from M4 the radio listens all the time: then "pause while listening" means GNSS mostly off, which is a decision about position, the clock and Tracks, for M4's design round (issue #23).
|
||||
|
||||
## The Shell (issue #67)
|
||||
|
||||
The console's commands could only be typed on a PC: over USB, or over Wi-Fi with the Debug Console. A device in a bag, or on a network that is down, could not be asked anything. The Shell is an App that runs the same commands on the device's own screen and keyboard.
|
||||
|
||||
### Decisions (design round 2026-10-07)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q204 | **An App, "Shell", in the Launcher**, in every firmware: its commands already work over USB for anyone holding the device. |
|
||||
| Q205 | **Trusted like USB serial**, not like the network: `debug on` and `debug token` work from it, as they do in Settings. The token is never shown. |
|
||||
| Q206 | *Revised the same day, after trying it.* **It shows the replies to its own commands, and only those.** The console knows who each line is printed for (`Console::Origin`): a command run from the Shell prints as the Shell's, and so does what answers it later from another task, which notes who asked and takes it back when it prints (`ls`, `tasks`, `du`, `cp`, `update check`, `sd list`, `screenshot`, `gemini get`). What USB or the Debug Console asked for, and the system's own lines, are not the Shell's. **Ctrl+b shows everything** instead. The first version kept whatever was printed in the ten seconds after a command, which was a guess, and a noisy one. |
|
||||
| Q207 | **Nothing while it's closed.** Open, a 4 KB ring of the console's and up to 4 KB of lines; both go when the App is left, with the list of commands for Tab. |
|
||||
| Q208 | Enter runs the line; Fn with up and down recalls the last 16; Alt with up and down scrolls back. **Tab completes** the command, **every word of it** *(the first only, at first)*: `lora st` gives `lora status`, `gnss track ` lists `start stop`, `key ` its eleven names. The words come from the firmware's `help` text, read as it is written, so a new command completes without a table to keep. *(Added the same day)* **past the command, a path on the SD card**: a folder keeps its slash to go on from, a file completed whole gets a space, several candidates are listed. Whatever the case typed, the name's own is taken, since the card doesn't tell them apart. After a file command the first slash is understood (`cat no` is `/no`). A name with a space in it isn't completed. |
|
||||
| Q209 | *Revised the same day.* **`rm` is Unix's, with a question.** A folder needs `-r`, here and over the consoles (where `rm <folder>` used to remove it with what was in it). In the Shell, a file, or a folder with something in it, is asked about unless `-f` (`-rf`, `-r -f`); an empty folder with `-r` goes without a word; what `rm` would refuse anyway, it refuses itself. Over the consoles nothing is asked: scripts delete as before. **`screenshot [seconds]`** saves the screen as a PNG in `/screenshots`, now or after a pause, since from the Shell "now" is the Shell. `get`, `put`, `coredump get` and `reset` answer `Debug Console only`. *(Added the same day)* **`*` and `?` in a name**, for `ls`, `du`, `rm`, `cp` and `mv`, here and over the consoles: `rm /notes/*.txt`, `cp /gnss/2026-10-0?.gpx /backup`. In the last part of the path only, any case, as the card has it. It is the same command once for each name matched, in the name's order; `cp` and `mv` then need a folder that exists to put them in. Over 64 matches is refused whole, as is none. In the Shell `rm` with a pattern asks **once**, with the count. |
|
||||
| Q210 | Commands are echoed as `> command` into the console, so a session reads the same from afar. **A token being set is not echoed.** |
|
||||
| Q211 | **Not in Safe Mode**, which starts no Apps: issue #77. |
|
||||
| Q212 | Its keys are a table in `app_keys.h`, so the help panel and the website have them; a page in the user guide. |
|
||||
| Q213 | *Added the same day.* **An App's name with a capital opens it:** `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Notes`, `Shell`, `System`, `Settings` (the App's id, up to its first dash). From the Shell, without going back to the Launcher, and from the consoles too. The capital says "an App": every command of the firmware is in small letters. Tab completes them. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`ShellApp`** (`src/apps/shell_app.cpp`), with its model host-tested in `lib/apps_model/src/shell_log.h`: lines arriving in pieces, the 4 KB limit, the words of every command out of the `help` text (Apps' names included), and Tab.
|
||||
- **Who a line is for:** `Console::As` marks the calling task as printing for the Shell while it lives, and `Console::origin()` lets a command that answers later carry that to wherever it prints. The console has a second ring for the Shell, which gets the lines marked so, or everything.
|
||||
- **The Shell hands its lines to the main loop**, which runs them like the consoles' commands. See below for why.
|
||||
- **`info` says which App is in front** (`app: Shell`): so that a hand driving the device from afar can look before it types.
|
||||
- **`screenshot`** writes the PNG a row at a time with no buffer: 8-bit indexed colour, the 256 colours of RGB332 as the palette, the pixels in one stored deflate block (`lib/files/src/png_rgb332.h`, 4 tests). 33,383 bytes for the 240 x 135 screen. A Toast says so once it is on the card, so the Toast is never in the picture.
|
||||
- **The `help` text lost its shorthand** (`update check | list | status` is written out), since Tab reads its words from there: `|` between two commands, `on|off` between two words, three spaces before the description. The Shell's own words (`clear`, `quit`, `key`'s names) are added in the same notation.
|
||||
- **Tab on a path** reads the folder on the storage task while the main loop waits, so it is bounded: 400 entries looked at, 24 candidates given back, and `...` after the list when there were more.
|
||||
- **A pattern** is matched in `lib/files/src/file_names.h` (`globMatch`, host-tested), and the names it matches are lined up as so many commands, which the main loop runs one after the other as each finishes. `cancel` empties the line-up. 2000 entries looked at, 64 matches at most.
|
||||
- **Cost:** 21 KB of flash, 96 bytes of static RAM. Open: 7 KB of heap (107.6 KB free before, 100.7 with it open, 106.1 after leaving).
|
||||
|
||||
### What went wrong while building it
|
||||
|
||||
**The device crashed, and the test sent a message to an IRC channel.** The first version ran a command from inside the key handler. Driven from the Debug Console, that is: the main loop, a remote command, `key select`, the App manager, the Shell, `runCommand` a second time, the file command, and `printf` under all of it. The main loop has under 2 KB of stack to spare; `rm` on a folder went past it. The crash report decoded to exactly that chain.
|
||||
|
||||
The device restarted into the Launcher, and the test script, which did not look, went on typing. Its next Enter opened IRC, which connected, and a few lines later it typed "No" into a channel and pressed Enter. One word, sent to real people, that can't be taken back.
|
||||
|
||||
Two changes came of it. The Shell now **queues** its line and the main loop runs it, at the same stack depth as a console's command. And `info` reports the App in front, which the test script now checks before every line it types.
|
||||
|
||||
### Checks on the device (2026-10-07, driven over the Debug Console with `key`)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Open it from the Launcher, type `ls /`, Enter | The folders are listed |
|
||||
| Tab on `in` | `info install` is shown and the line stays; on `u` it becomes `update `; on `l`, `log ls lora loop` |
|
||||
| Up | The line before comes back |
|
||||
| `screenshot` | `/screenshots/20261007-104357.png`, 33,383 bytes. Fetched and decoded on the PC: 240 x 135, indexed, every chunk's CRC right, the pixels the Shell's screen |
|
||||
| Output | With a Debug Console client connecting and disconnecting for every key, the Shell shows the commands and their replies and nothing else: `info`, `ls /` (answered by the storage task), `tasks` (answered a second later) |
|
||||
| `rm` on a folder, without `-r` | Refused, the folder stays |
|
||||
| `rm -r` on an empty folder | Removed, no question |
|
||||
| `rm -r` on a folder with files | Asks; Cancel leaves it. `rm -rf` removes it without asking |
|
||||
| `rm` on a file | Asks; Delete removes it |
|
||||
| Tab on a path | `ls /no` becomes `ls /notes/`, and again, with one note in it, the note's whole name and a space. `ls /g` lists `gemini/ gnss/`. `cat no` becomes `cat /notes/`. `rm -r /CAP` becomes `rm -r /captures/`. `ls /zz` stays as it is |
|
||||
| Tab past the first word | `lora st` becomes `lora status `; `gnss tr` becomes `gnss track ` and Tab again lists `start stop`; `key ` lists its eleven names; `upd` becomes `update ` and lists `check list status install` |
|
||||
| `ls` with a pattern | `ls /gt/*.txt` the five files, `ls /gt/*2*` the one, `ls /gt3/*6?.txt` ten of seventy. `ls /g*/x`: refused, a pattern goes in the last part |
|
||||
| `cp /gt/file-000?.txt /gt2`, `du /gt2/*1*` | Five copies; one size |
|
||||
| `rm /gt2/*` in the Shell | "The 5 that match /gt2/\*": Cancel leaves the five. `rm /gt3/*6?.txt`, Delete: ten gone, sixty left. `rm -f /gt2/*`: gone without a question |
|
||||
| `rm /gt/*` where one match is a folder | The files go, the folder stays (no `-r`) |
|
||||
| No match, and seventy | `rm: error nothing matches`; `rm: error more than 64 match: a narrower pattern, please`, and all seventy still there |
|
||||
| `No`, Tab, Enter | Completes to `Notes ` and opens Notes. `Shell` from the Debug Console opens the Shell |
|
||||
| `screenshot 4`, then Home | The picture, taken four seconds later, is of the Launcher: the pause works, and the Toast isn't in it |
|
||||
| `quit` | Back to the Launcher, and the memory comes back |
|
||||
| The help panel in the Shell | Its keys, then the ones that work everywhere |
|
||||
|
||||
**Not checked:** Ctrl+b and the Alt scroll, which `key` can't press (no modifiers): the filter itself is host-tested, the key that flips it is not; the real keyboard altogether; the Toast after a screenshot, which was published but not looked at; and `mv` with a pattern and `cancel` in the middle of a line-up, which share their code with `cp` and `rm` but were not run. The main loop's lowest free stack after all of it: 1.8 KB, where it was.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
+++
|
||||
title = "Look and feel"
|
||||
description = "The interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content."
|
||||
weight = 90
|
||||
|
||||
[extra]
|
||||
docs = true
|
||||
source = "docs/milestones/U1.md"
|
||||
tag = "U1"
|
||||
+++
|
||||
**Status:** in progress. 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.
|
||||
|
||||
## The help key (issue #69)
|
||||
|
||||
Every screen used to say something about its keys, differently: a footer of abbreviations in one place (`c x v:paste r:name d:del n:new i:info s:sort`), a line under a text field in another (`Enter: save \`: cancel`), `Tab: sky` in a corner, and nothing at all in several. About 30 such strings, each costing a line of a small screen, and none of them complete.
|
||||
|
||||
### Decisions (design round 2026-10-07)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q196 | **Fn+h, on every screen,** text fields included (Fn is held, so nothing is typed). **`?` too, outside Text Entry.** |
|
||||
| Q197 | It opens **a panel over the content area**, titled with where you are: the screen's own keys, then an "Everywhere" group (Back, Home, the arrows, the help key). The arrows scroll it; any other key closes it and is not passed on. |
|
||||
| Q198 | **Each App answers "what are your keys right now?"** for the state it is in; pages, viewers, dialogs and text fields answer for themselves, with shared lists for dialogs, lists and text entry. The lists are constants; the panel's rows exist only while it is open. |
|
||||
| Q199 | **Every hint that names a key goes,** text fields included. What stays is state: `REC 12 points`, `LOG 42`, `sort:signal`, `typing`/`saved`, what is waiting to be pasted, the Sweep's floor. Messages were reworded where they named a key ("v pastes a copy of…" is "Copied …: paste it where you like"). |
|
||||
| Q200 | **The first-start Setup keeps its hints,** and is the one place that does: someone in their first minute doesn't know the help key yet. It tells them about it on its first and last screens. |
|
||||
| Q201 | **Loud everywhere else:** the user guide opens with it, the FAQ has it first, and a device set up before this firmware gets one Toast, once: "Fn+h: the keys of any screen". |
|
||||
| Q202 | `key help` over the consoles. Generating the website's key tables from the same lists is a follow-up, not this issue. |
|
||||
| Q203 | The key and the panel first, host-tested; then one App at a time, declaring its keys and losing its hints in the same step; then every screen looked at on the device. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`Key::Help`** from the key mapper: Fn+h in both modes, `?` only outside Text Entry (`lib/input`, 3 tests).
|
||||
- **`App::help()` and `App::helpTitle()`** (`lib/core/src/app.h`), `KeyHelp` rows and `HelpModel` (`key_help.h`). The **App manager** opens the panel, appends the "Everywhere" group, and while it is open takes every key: nothing reaches the App, Home included. It closes when the App changes (5 tests).
|
||||
- **Every App declares its keys by state:** the Launcher, IRC (chat, settings, a field), Wi-Fi Tools (4 views), GNSS, Gemini (page, saved page, address, answer, dialogs), the LoRa Scanner (4 views), Storage (browse, details, a name, the viewer's 6 modes, the editor, Maintenance, busy), Notes (list, editor, a file name), System (5 views), Settings (menu, text, choice, and the Wi-Fi, Firmware and Debug Console pages with their own states), Setup and the widget demo.
|
||||
- **The hints are gone** from all of them. The footers that remain say state only.
|
||||
- **It costs** 8.5 KB of flash and 40 bytes of static RAM.
|
||||
|
||||
### Checks
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Host tests | 476 pass (468 before) |
|
||||
| On the device, `key help` and a screenshot | The Launcher and all nine Apps, and these states: Storage scrolled, System's tasks, a Settings text field, Wi-Fi Tools' networks. The panel is titled with the scope, lists the right keys, scrolls, and closes on Tab |
|
||||
| The one-time Toast | `notification: Fn+h: the keys of any screen` on the first start after the update |
|
||||
|
||||
### One source for the device and the website (issue #72)
|
||||
|
||||
The lists first lived in each App's `help()`, as code. They are now **data, in one file**: `lib/core/src/app_keys.h`, 52 constant tables, each under a comment `// id: Title`. An App's `help()` picks the table of the state it is in. `site/tools/gen_dev_docs.py` reads the same file and writes `site/data/keys.toml`; the `keys` shortcode shows a screen's tables on its guide page, and `/guide/keys/` shows all of them. The Site job fails when the data file is out of date, or when a page asks for a table that doesn't exist, and it now runs when `app_keys.h` changes. A key added to an App shows up on the website without anyone editing a page.
|
||||
|
||||
Three rows lost their second wording on the way, since a table is constant: GNSS's `Tab` and `r`, and the Scanner's `c`, now say both things they do ("record a Track, or stop it") instead of the one that applies. The guide pages keep their written tables too, where they say more than a key list can; those can still drift, and the generated ones under them are the reference.
|
||||
|
||||
**Not checked:** the real Fn+h and `?` on the keyboard (the mapper is host-tested; the device was driven with `key help`); the Setup screens, which only a device that was never set up shows, so their new text has not been seen on a screen; and the states that need something to happen first (a dialog, a copy in progress, a Gemini prompt, a packet's details): their lists were read against the key handling, not looked at.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,266 @@
|
||||
+++
|
||||
title = '''It was off'''
|
||||
description = '''roro9stack gets a website, and writing down how its Debug Console works shows that only one person could ever use it. So the Debug Build is retired: one firmware, with the console in it, off until its owner switches it on. Then an evening of chasing a console that wouldn't come back and a memory leak, neither of which existed.'''
|
||||
date = 2026-10-07T00:30:00+02:00
|
||||
|
||||
[extra]
|
||||
topics = '''ESP32-S3 · Debugging · Security'''
|
||||
read_label = '''Read what was off →'''
|
||||
uid = '''<b>debug:</b> on, token set, client connected'''
|
||||
dek = "A day of writing for [roro9stack](/devlog/roro9stack/), my firmware for the M5Stack Cardputer: a website, a user guide, and developer docs about the thing I like best in it, a console over Wi-Fi. Documenting it properly made one fact hard to miss: nobody else could have it. Fixing that deleted more than it added, and then cost me an evening looking for two bugs that turned out to be a switch in the Off position and TCP minding its own business."
|
||||
byline = '''designed by interrogation, round twelve: fourteen questions thrown away, eight kept'''
|
||||
|
||||
[extra.sign]
|
||||
label = "Tokens leaked by the feature built to protect them"
|
||||
note = "In a screenshot, taken over the console, of the one page that shows the token."
|
||||
count = "1"
|
||||
tone = "red"
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The Debug Build"
|
||||
role = "retired, v0.3.0 to v0.11.0"
|
||||
text = "The same firmware plus a console over Wi-Fi, with its builder's token compiled in. Which is why it could never be published, and why the firmware I tested was never the one I released."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "Port 3232"
|
||||
role = "the Update Service, in every build since v0.3.0"
|
||||
text = "Listens on the network in release builds, guarded by a signature. The counter-example to \"in a release, nothing listens\" that had been sitting there all along."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The token"
|
||||
role = "100 bits, 20 characters"
|
||||
text = "Made by the device, shown on one page of Settings and nowhere else. Read off a 240-pixel screen by a human, which is where the trouble started."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The dialog"
|
||||
role = "\"Switch it on?\""
|
||||
text = "Opens with Cancel selected, as a question about a remote control should. Enter, Enter: still off. Works exactly as designed."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "TIME_WAIT"
|
||||
role = "two minutes, per closed connection"
|
||||
text = "What TCP does with a connection it has closed, in case a late packet turns up. About 270 bytes each. Looks exactly like a leak if you only watch for one minute."
|
||||
+++
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **roro9stack has a website:** [roro9stack.net](/), with an Install page that flashes a Cardputer from the browser, a [user guide](/guide/), [how-tos](/howto/), an [FAQ](/faq/), [developer docs](/dev/) generated from the repository, and this devlog, which moved here.
|
||||
- **The Debug Build is gone.** There is one firmware (**v0.12.0**), and the Debug Console is in it: **off** until you switch it on in Settings, with a token the device makes itself.
|
||||
- **The token never crosses the network.** The device sends a challenge, the client answers with an HMAC. Five wrong answers close the console for a minute.
|
||||
- **It costs** 30 KB of flash and 88 bytes of RAM over the old release build. Off, nothing listens and nothing is allocated.
|
||||
- Then I spent an evening on **a console that wouldn't come back on** (it was off) and **a memory leak of 230 bytes per reopening** (it was TCP). Both produced a real fix on the way, and one design change I had to undo.
|
||||
- 468 host tests, 12 more than last time. The decision is [ADR 0010](/dev/decisions/0010-debug-console-in-every-build/).
|
||||
|
||||
## The cast
|
||||
|
||||
{{ cast() }}
|
||||
|
||||
## A site, in four phases
|
||||
|
||||
The [last post](/devlog/roro9stack-f1-r1/) ended with a device that installs its own releases. That makes it something another person could use, and another person needs somewhere to start that isn't a Gitea README. So the first half of the day was a website: a home page, an Install page, then a guide to each App, how-tos, an FAQ, and developer docs.
|
||||
|
||||
Three things from that half are worth keeping.
|
||||
|
||||
**Flashing from the browser, without a copy of the firmware.** The Install page uses ESP Web Tools and Web Serial. The obvious way is to copy the firmware image next to the page; then every release needs a rebuild of the site. Instead the page asks Gitea's API for the latest release, in the browser, and hands the flasher a manifest it builds on the spot. That only needed one header: Caddy, in front of Gitea, adds `Access-Control-Allow-Origin: https://roro9stack.net` to the release downloads and to the releases API, which are public anyway. A new release shows up on the page the moment it exists.
|
||||
|
||||
**"Generated from the repository" met Zola.** The developer docs were to be built from `docs/`, the README and the firmware's own `help` text, not copied by hand. Zola refuses to read a file outside its own folder, and it resolves symlinks before deciding, so that door is closed too. So a small script writes those pages, they are committed, and CI fails when one is out of date. The command reference is the part I like: it is parsed from the `kHelp` string in `main.cpp`, so the site can't describe a command the firmware doesn't have.
|
||||
|
||||
**The posts you're reading had `<style>` in them.** This devlog came over from my blog, seventeen diagrams included, each an inline SVG with its own `<style>` block. The site's Content-Security-Policy is `style-src 'self'`, which refuses exactly that. The rules moved into a stylesheet and the `style="…"` attributes became classes. Tested in a real browser with the production policy on every response: no violations.
|
||||
|
||||
The site says only what the firmware does today, which meant writing the same sentence several times: the mesh messenger isn't built. The radio listens. It doesn't talk yet.
|
||||
|
||||
## Documenting a feature only I could use
|
||||
|
||||
The part of the developer docs I cared most about is the Debug Console: the serial console over Wi-Fi, with key presses, screenshots, file transfer and crash dumps. I wrote six pages on it, checked the protocol against the live device, and by the end had described, in detail, a feature with this property:
|
||||
|
||||
> A Debug Build carries its builder's token, so Debug Builds are never published.
|
||||
|
||||
Everything in those pages was for whoever builds the firmware. That's me.
|
||||
|
||||
The issue I filed was the obvious patch: make the token configurable, so that Debug Builds can be published. The design round for it had fourteen questions. Where does a published Debug Build go, so that devices in the field don't install it by accident (v0.11.0 takes the last `.ota` it finds in a release)? What is its tag called, so that CI doesn't start itself again? How does a device that runs one get the next?
|
||||
|
||||
Then one question from the other side of the table replaced all fourteen: *why have Debug Builds at all?*
|
||||
|
||||
## The argument that was never whole
|
||||
|
||||
[ADR 0004](/dev/decisions/0004-debug-console-in-debug-builds/) kept the console out of release builds with one sentence, which I was rather proud of:
|
||||
|
||||
> A console that runs commands is a remote control: in a release build, nothing listens.
|
||||
|
||||
Except something does. The Update Service has listened on TCP 3232 in every build since v0.3.0, guarded by a signature. "Nothing listens" was never true; "nothing listens without a lock on it" was. A console that is off by default, behind a secret the device made itself, is the same trade.
|
||||
|
||||
And the split had been charging rent the whole time:
|
||||
|
||||
- **What I tested wasn't what I shipped.** I lived on Debug Builds. Releases were a different binary that nobody ran before it was published.
|
||||
- **Rules that only protected the console.** A Debug Build refused to install a release (it would have lost its console), so there was `update install … force` to do it anyway, and advice about which firmware to keep in the other slot.
|
||||
- **Versions ending in `+debug`** that had to compare equal to their release.
|
||||
- **Two firmwares built by CI** on every pull request and every tag.
|
||||
|
||||
One firmware deletes all four.
|
||||
|
||||
## What off means
|
||||
|
||||
The new argument only holds if "off" is as good as "not compiled in". So:
|
||||
|
||||
- **Off is the default,** and what a missing or damaged setting falls back to.
|
||||
- **Off, nothing exists:** no socket, no task, and no 4 KB buffer, which used to be a static array and is now allocated when the console is switched on.
|
||||
- **On needs someone at the device,** or on the USB cable. Over the console itself, `debug on` and `debug token` answer `over USB serial only`: it can switch itself off, not open itself wider.
|
||||
|
||||
{% table() %}
|
||||
| Build | Flash | Static RAM |
|
||||
|---|---|---|
|
||||
| The release build, before | 1,851,387 | 54,612 |
|
||||
| The Debug Build, before | 1,874,887 | 58,780 |
|
||||
| **The one firmware** | **1,881,799** | **54,700** |
|
||||
{% end %}
|
||||
|
||||
Thirty kilobytes of flash, and 88 bytes of RAM, for a console in every device. The Debug Build's extra 4 KB of RAM was the buffer, sitting there whether anyone connected or not.
|
||||
|
||||
## A token you can read, and one you can't steal
|
||||
|
||||
The token used to be 32 hex digits in a file on my laptop, compiled in. Now the device makes it, the first time the console is switched on: 100 bits from the hardware generator, as twenty characters of Crockford's base32, the alphabet that leaves out I, L, O and U because people misread them.
|
||||
|
||||
The old login sent the token as the first line of a plain TCP connection. That was fine for a secret that lived on one laptop and one device on one network. It's not fine for a secret in every device, on whatever Wi-Fi its owner uses: mine is called `knbg-guests`, and anyone with the Wi-Fi password can watch it.
|
||||
|
||||
So the token no longer travels:
|
||||
|
||||
{% code(caption="The whole login. A recorded answer is no use for the next challenge.") %}
|
||||
```
|
||||
device: roro9stack debug console, challenge 3f9a…c1 (16 random bytes)
|
||||
client: HMAC-SHA256(key = token, message = those bytes), in hex
|
||||
device: roro9stack v0.12.0 debug console. 'help' lists the commands. Backlog follows.
|
||||
```
|
||||
{% end %}
|
||||
|
||||
HMAC is twenty lines on top of the SHA-256 the firmware already had for update files, checked against the RFC's vectors and against the same vector as the Python client. Five wrong answers in a row and the console answers `locked` to everyone for a minute:
|
||||
|
||||
{% code(caption="Six tries with a wrong token, then the right one.") %}
|
||||
```
|
||||
try 1 at 1s: wrong token
|
||||
try 2 at 3s: wrong token
|
||||
try 3 at 4s: wrong token
|
||||
try 4 at 5s: wrong token
|
||||
try 5 at 6s: wrong token
|
||||
try 6 at 6s: closed for a minute after too many wrong tokens
|
||||
right token: closed for a minute after too many wrong tokens
|
||||
```
|
||||
{% end %}
|
||||
|
||||
This breaks every old client. I'm the only user, and v1 is a long way off; this is exactly when to break things.
|
||||
|
||||
## Three ways it went wrong
|
||||
|
||||
### I couldn't read my own token
|
||||
|
||||
First login, on the real device: `wrong token`. The firmware was right. I had typed one character wrong, reading a 20-character token drawn in bold at the normal size, where 8 and B, 5 and S, 2 and Z are a matter of opinion.
|
||||
|
||||
The token is now drawn at twice the size, in regular weight, on two lines. And what I should have done from the start: Crockford's rule says that if someone types an O, an I or an L, they meant 0 or 1, because the alphabet doesn't have those letters. Both ends now apply it.
|
||||
|
||||
### The screenshot
|
||||
|
||||
To check the new layout I did what I always do: took a screenshot over the console.
|
||||
|
||||
{{ figure(src="token.png", alt="The Cardputer's Settings, Debug Console page at 2x: Debug Console On, Connect to 10.39.39.12:2323, New token, Type a token, and below them a token in large type on two lines, T49R-HQXB-NDJX then BNSB-XSWD. The Status Bar shows DBG in blue.", width=480, height=270, caption="The page that shows the token \"and nowhere else\", in a file on my laptop. This token was replaced within the hour.") }}
|
||||
|
||||
The documentation I had written an hour earlier says the token is shown on one page and never printed anywhere. And there it is, in a PNG. No escalation: whoever can take a screenshot already has the token. But a secret now sits in a file because of a habit, and the sign at the top of this post is for that. The docs now say it in so many words: a screenshot of that page is a copy of the token.
|
||||
|
||||
### The console that wouldn't come back
|
||||
|
||||
The last test was `debug off`, sent over the console: the client is dropped, the port refuses connections. Good. I switched it back on at the device, made a new token, and tried to log in.
|
||||
|
||||
Connection refused.
|
||||
|
||||
The device was up. Its update port answered. And it stayed refused across another firmware push and a restart. So, I reasoned, the console fails to come back once it has been closed, and I went looking:
|
||||
|
||||
- **The framework's `begin()` returns nothing.** `NetworkServer::begin()` can fail at `socket()`, `bind()` or `listen()` and says nothing either way; my code set `listening = true` and never asked. A real hole. Fixed: it asks, complains on the console, and tries again every two seconds.
|
||||
- **I couldn't reproduce it from my desk.** Switching the console on is for the device and the cable only, by design, so the one path I wanted to test was the one I had made unreachable. Hence `debug off <seconds>`: the console closes and comes back by itself after the pause. It can't open anything wider (same state, same token), and it makes closing and reopening something a script can do twenty times.
|
||||
|
||||
Then I went and looked at the device's screen.
|
||||
|
||||
It said **Off**.
|
||||
|
||||
The "Switch it on?" dialog opens with **Cancel** selected. Enter on the row, Enter on the dialog: nothing changes, and the page says so in plain letters that I hadn't read. I had filed a bug against a switch for being in the position I left it in.
|
||||
|
||||
### The leak that was TCP
|
||||
|
||||
With `debug off <seconds>` I could finally hammer the path. It came back every time. And free memory fell by about 230 bytes per cycle.
|
||||
|
||||
A leak in code I had just written, in a firmware where 836 bytes was [once all that was left](/devlog/roro9stack-f1-r1/). I had a suspect: the console's task is deleted when the console goes off and made again when it comes back. Twenty plain logins leaked nothing, so it wasn't connections. I changed the design: the task stays, asleep, while the console is off. That breaks the promise that off means nothing is there, but a leak is worse.
|
||||
|
||||
It leaked exactly as much as before.
|
||||
|
||||
So I did what I should have done first, and measured for longer:
|
||||
|
||||
{% table() %}
|
||||
| | Free heap |
|
||||
|---|---|
|
||||
| Before | 107,248 |
|
||||
| Right after 15 closings and reopenings | 103,180 |
|
||||
| 60 s later | 105,008 |
|
||||
| 120 s later | 107,004 |
|
||||
| 240 s later | 106,924 |
|
||||
{% end %}
|
||||
|
||||
All of it comes back. When the device closes a connection, TCP keeps it for two minutes in case a late packet arrives, and each one holds about 270 bytes. Fifteen cycles in a minute and a half look like a leak; fifteen cycles and a cup of tea look like nothing at all.
|
||||
|
||||
The design change went back out. Off means the task is gone too, as decided.
|
||||
|
||||
## What held
|
||||
|
||||
{{ figure(src="dbg.png", alt="The Cardputer's Launcher at 2x, listing IRC, Wi-Fi Tools, GNSS, Gemini, LoRa Scanner, Storage, Notes, System and Settings. The Status Bar shows DBG in blue between the GNSS and Wi-Fi indicators.", width=480, height=270, caption="`DBG` in the Status Bar: there while the console listens, bright while someone is connected. Taken over the console, so: bright.") }}
|
||||
|
||||
Checked on the device, not just in tests:
|
||||
|
||||
- **Off by default.** Pushed over a Debug Build, the new firmware came up with port 2323 refusing connections.
|
||||
- **The setting survives updates.** Four pushes later the console came back by itself each time.
|
||||
- **Safe Mode has the console.** Three `crash abort` in a row, and the device started in Safe Mode with 178 KB free and the console reachable. `ls /` answered `not available in Safe Mode`, and the crash decoded to the line of `main.cpp` with `abort()` on it.
|
||||
- **25 closings and reopenings,** each back a second after its pause.
|
||||
- **More memory than before.** 108 KB free with the console on and a client connected, against 104 KB on the Debug Build.
|
||||
|
||||
And what I didn't check: `scripts/flash.sh --debug`, which now sets a device up over USB, with no cable attached to the VM that night; typing a token of my own on the device; and the notification on the screen during a lockout, which nobody connected can see, the console being closed.
|
||||
|
||||
## By the numbers
|
||||
|
||||
{% table() %}
|
||||
| | |
|
||||
|---|---|
|
||||
| Design questions written, then thrown away | 14 |
|
||||
| Design questions kept | 8 |
|
||||
| Host tests | 468 |
|
||||
| Bits in a token | 100 |
|
||||
| Wrong answers before the console closes | 5 |
|
||||
| Flash it costs every device | 30,412 bytes |
|
||||
| RAM it costs every device, off | 88 bytes |
|
||||
| Bytes TCP holds for a closed connection, for two minutes | about 270 |
|
||||
| Pages on the website | 66 |
|
||||
| Bugs found in the console that evening | 1 (the silent `begin()`) |
|
||||
| Bugs I thought I'd found | 3 |
|
||||
{% end %}
|
||||
|
||||
## Where it stands
|
||||
|
||||
{% steps() %}
|
||||
1. ~~M0 and M1: the skeleton, Wi-Fi, IRC, Wi-Fi Tools.~~ v0.1.0 to v0.2.1, [the first post](/devlog/roro9stack/).
|
||||
|
||||
2. ~~Updates and debugging over the air.~~ v0.3.0, [Look, no cables](/devlog/roro9stack-ota/).
|
||||
|
||||
3. ~~M2: GNSS.~~ v0.4.0, [Seventeen satellites](/devlog/roro9stack-gnss/).
|
||||
|
||||
4. ~~G1: Gemini.~~ v0.5.0, [A browser in the RAM IRC left over](/devlog/roro9stack-gemini/).
|
||||
|
||||
5. ~~M3: the LoRa radio, listening.~~ v0.6.0, [The loudest thing it hears is itself](/devlog/roro9stack-lora/).
|
||||
|
||||
6. ~~S1: the card, fixed addresses, the System App.~~ v0.6.1 to v0.8.1, [One byte too early](/devlog/roro9stack-s1/).
|
||||
|
||||
7. ~~F1 and the start of R1: files, notes, signed releases, updates from Gitea.~~ v0.9.0 to v0.11.0, [836 bytes](/devlog/roro9stack-f1-r1/).
|
||||
|
||||
8. ~~W1: the website.~~ You're on it.
|
||||
|
||||
9. ~~One firmware, with the Debug Console in it.~~ v0.12.0, this post.
|
||||
|
||||
10. Next: notes of any size, a shell on the device itself for the same commands, and one help key everywhere instead of a hint line on every screen. And M4, the mesh, which still wants a second node.
|
||||
{% end %}
|
||||
|
||||
{% signoff() %}
|
||||
I wrote a feature whose whole point is that it's off until you switch it on, and then spent an evening debugging it for being off. The switch works. So does TCP. The only thing that leaked was the token, and I did that myself.
|
||||
{% end %}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -6,6 +6,10 @@ template = "guide-page.html"
|
||||
toc = true
|
||||
+++
|
||||
|
||||
## Which keys work on this screen?
|
||||
|
||||
Press **<kbd>Fn</kbd> + <kbd>h</kbd>**, on any screen: it lists the keys that work there, then the ones that work everywhere. <kbd>?</kbd> does the same when you are not typing text. The screens themselves never name their keys, so this is the one key to remember. See [The basics](/guide/basics/).
|
||||
|
||||
## Can I send messages over the mesh?
|
||||
|
||||
**Not yet.** The LoRa Scanner **listens** to Meshtastic traffic and shows what it hears, but nothing is transmitted. A mesh messenger is the goal and is planned in two milestones, one for receiving and one for transmitting; it waits for a second node to test against.
|
||||
|
||||
@@ -6,7 +6,7 @@ sort_by = "weight"
|
||||
page_template = "guide-page.html"
|
||||
+++
|
||||
|
||||
This guide says what the firmware does **today** and nothing else. Start with the basics (the keys, the Launcher, the first start), then read the page of any App. It describes the latest release; the numbers and key names come from the firmware's own source.
|
||||
This guide says what the firmware does **today** and nothing else. Start with the basics (the keys, the Launcher, the first start), then read the page of any App. **On the device itself, <kbd>Fn</kbd> + <kbd>h</kbd> lists the keys of whatever screen you are on:** nothing else on a screen names them. It describes the latest release; the numbers and key names come from the firmware's own source.
|
||||
|
||||
**The mesh messenger is planned, not built.** The LoRa Scanner listens to Meshtastic traffic and shows it, but the device sends nothing yet: that is the next milestone and waits for a second node to test with.
|
||||
|
||||
|
||||
@@ -6,6 +6,14 @@ weight = 1
|
||||
tag = "Start here"
|
||||
+++
|
||||
|
||||
## One key to remember
|
||||
|
||||
**<kbd>Fn</kbd> + <kbd>h</kbd>, on any screen, lists the keys that work there.** No screen names its keys: that key does. It works everywhere, in a text field too, and <kbd>?</kbd> does the same whenever you are not typing. The arrows scroll the list; any other key closes it.
|
||||
|
||||
The list is for *the screen you are on*: in Storage it is the file keys, in a dialog it is the dialog's, in a text field it is the editing keys. Each list ends with the keys that work everywhere.
|
||||
|
||||
If you only read one paragraph of this guide, this was it.
|
||||
|
||||
## The keys
|
||||
|
||||
The Cardputer's keyboard has no arrow keys and no Escape, so the firmware gives a few keys a second job:
|
||||
@@ -17,11 +25,12 @@ The Cardputer's keyboard has no arrow keys and no Escape, so the firmware gives
|
||||
| <kbd>Fn</kbd> + <kbd>`</kbd> | **Home**: back to the Launcher |
|
||||
| <kbd>Fn</kbd> + <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> | The arrows: up, down, left, right |
|
||||
| <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> alone | The same arrows, as long as you are **not** typing text |
|
||||
| <kbd>Fn</kbd> + <kbd>h</kbd>, or <kbd>?</kbd> when not typing | **Help:** the keys of the screen you are on |
|
||||
| <kbd>Tab</kbd> | Switches view in an App that has more than one |
|
||||
| <kbd>Del</kbd> | Deletes backwards when you type |
|
||||
| <kbd>opt</kbd> then an accent, then a letter | Types an accented letter: <kbd>opt</kbd> <kbd>'</kbd> <kbd>e</kbd> gives é |
|
||||
|
||||
While you type text (a note, an IRC line, a setting), `;` `.` `,` `/` type their own characters and you need <kbd>Fn</kbd> for the arrows. The bottom of the screen shows `opt` while a compose is waiting for its letter.
|
||||
While you type text (a note, an IRC line, a setting), `;` `.` `,` `/` type their own characters and you need <kbd>Fn</kbd> for the arrows. The Status Bar shows `opt` while a compose is waiting for its letter.
|
||||
|
||||
## The Launcher
|
||||
|
||||
@@ -38,6 +47,7 @@ A strip at the top of every screen: the name of the App on the left, and on the
|
||||
| `97%` | The battery (in the warning colour at 15% and under) |
|
||||
| `SD` | A card is in; the warning colour at 80% full |
|
||||
| bars and `W` | Wi-Fi connected, with its signal; `W?` is searching; `MON` is the Wi-Fi radio in its monitoring mode, which pauses IRC |
|
||||
| `DBG` | The Debug Console is switched on (Settings → Debug Console); brighter while a PC is connected to it |
|
||||
| `REC` | A GNSS Track is being recorded |
|
||||
| `CAP` | A LoRa capture is being recorded |
|
||||
| `L` | The radio is listening; it lights up for a moment on each packet. `SW` while a Sweep runs |
|
||||
@@ -49,7 +59,7 @@ News from a background service (an IRC mention, a Storage warning, an update tha
|
||||
|
||||
## The first start
|
||||
|
||||
On a new device a short Setup asks four things, then never appears again:
|
||||
On a new device a short Setup asks four things, then never appears again. It also tells you about <kbd>Fn</kbd> + <kbd>h</kbd>, twice, and it is the only part of the firmware that names keys on the screen:
|
||||
|
||||
1. **Long name**, up to 39 bytes.
|
||||
2. **Short name**, up to 4 characters.
|
||||
@@ -58,6 +68,12 @@ On a new device a short Setup asks four things, then never appears again:
|
||||
|
||||
## The SD card
|
||||
|
||||
Put a microSD card in the Cardputer. Without one the radio, GNSS, Wi-Fi and IRC still work, but nothing can be saved: no notes, IRC logs, Wi-Fi scan logs, GPX tracks, LoRa captures or saved Gemini pages. The [Storage App](/guide/storage/) shows what is on the card.
|
||||
Put a microSD card in the Cardputer. Without one the radio, GNSS, Wi-Fi and IRC still work, but nothing can be saved: no notes, IRC logs, Wi-Fi scan logs, GPX tracks, LoRa captures, screenshots or saved Gemini pages. The [Storage App](/guide/storage/) shows what is on the card.
|
||||
|
||||
The firmware keeps its own folders at the top of the card (`captures`, `gemini`, `gnss`, `irc`, `updates`, `wifi`, plus `notes`). You can use the card in a computer too, but those names are the firmware's.
|
||||
The firmware keeps its own folders at the top of the card (`captures`, `gemini`, `gnss`, `irc`, `notes`, `screenshots`, `updates`, `wifi`). You can use the card in a computer too, but those names are the firmware's.
|
||||
|
||||
## The keys, as the device lists them
|
||||
|
||||
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
|
||||
|
||||
{{ keys(scopes=["launcher", "everywhere", "dialog", "text"]) }}
|
||||
|
||||
@@ -40,3 +40,9 @@ Most capsules sign their own certificate. The first certificate seen for a host
|
||||
## Big pages and memory
|
||||
|
||||
With a card, every page streams to the card first, so a page larger than the device's memory still arrives whole and is read from the card as you scroll. Without a card, a page is limited to what fits in memory. If there is not enough free memory to open a secure connection, the fetch says so instead of failing silently; stopping IRC frees the most.
|
||||
|
||||
## The keys, as the device lists them
|
||||
|
||||
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
|
||||
|
||||
{{ keys(scopes=["gemini", "gemini-saved", "gemini-address", "gemini-answer"]) }}
|
||||
|
||||
@@ -28,3 +28,9 @@ Once there is a fix, the device's clock follows it.
|
||||
## Tracks
|
||||
|
||||
<kbd>r</kbd> starts recording a **Track**: the route is saved as a **GPX** file on the SD card, in `/gnss/tracks` (named by date and time), and the Status Bar shows `REC`. Press <kbd>r</kbd> again to stop. A Track keeps recording with the App closed. It needs a card and a clock (a fix or Wi-Fi sets it); if it cannot start, the App says why: `GNSS is off`, `No SD card` or `Waiting for the time`. The [Storage App](/guide/storage/) opens a `.gpx` file and shows its points, start, duration and distance.
|
||||
|
||||
## The keys, as the device lists them
|
||||
|
||||
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
|
||||
|
||||
{{ keys(scopes=["gnss"]) }}
|
||||
|
||||
@@ -49,3 +49,9 @@ Every buffer is logged to the SD card, one file per day, under `/irc`. Logs stop
|
||||
|
||||
- IRC **pauses** while Wi-Fi is monitoring (the Status Bar shows `MON`) and while the device **installs an update**. It reconnects and rejoins its channels afterwards; the App says `paused` in the meantime.
|
||||
- A secure connection costs memory: IRC's takes about 40 KB of the 107 KB the device has, and an update's download needs about 52 KB more. That is why IRC steps aside for an update, and why the daily update check waits for IRC to be disconnected (see [Updates](/guide/updates/)).
|
||||
|
||||
## The keys, as the device lists them
|
||||
|
||||
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
|
||||
|
||||
{{ keys(scopes=["irc", "irc-settings", "irc-field"]) }}
|
||||
|
||||
@@ -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) }}
|
||||
@@ -33,3 +33,9 @@ A capture records packets into a **pcap** file with LoRaTap headers in `/capture
|
||||
## The GNSS receiver raises the noise
|
||||
|
||||
The GNSS receiver on the same Cap makes the radio's noise floor about **8 dB** worse while it runs. **Settings → Pause GNSS for LoRa** (off by default) puts the receiver on standby while the radio listens, except while a Track is being recorded.
|
||||
|
||||
## The keys, as the device lists them
|
||||
|
||||
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
|
||||
|
||||
{{ keys(scopes=["lora", "lora-packet", "lora-presets", "lora-sweep"]) }}
|
||||
|
||||
@@ -35,3 +35,9 @@ A new note has no file until you type something. The file is then named after it
|
||||
## 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.
|
||||
|
||||
## 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"]) }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
+++
|
||||
title = "Settings"
|
||||
description = "The device's names, region, screen, sound, GNSS and Wi-Fi, and where firmware updates are found."
|
||||
weight = 10
|
||||
weight = 11
|
||||
[extra]
|
||||
tag = "Settings"
|
||||
+++
|
||||
@@ -22,6 +22,7 @@ Move with the arrows. On a toggle, a choice or a slider, left and right change t
|
||||
| **Wi-Fi** | The page below |
|
||||
| **Check for updates** | Once a day, see [Updates](/guide/updates/) |
|
||||
| **Firmware** | The page described in [Updates](/guide/updates/) |
|
||||
| **Debug Console** | Off unless you switch it on. It lets a PC on the same network read the device's console and drive it, with a token shown on this page: see [the developer docs](/dev/debug/switch-it-on/). Leave it off if that means nothing to you |
|
||||
| **About** | The firmware version, the node number, battery, memory, uptime, clock and licence |
|
||||
|
||||
## Wi-Fi
|
||||
@@ -40,3 +41,9 @@ A network normally gives the device its address by itself (DHCP, **Automatic**).
|
||||
### DNS and NTP
|
||||
|
||||
Two DNS servers (9.9.9.9 and 1.1.1.1 by default), used on Fixed networks, or on every network if **Always use my DNS** is on; and two NTP servers (pool.ntp.org and time.cloudflare.com), used after any that the network's DHCP offers. <kbd>Enter</kbd> on **Status** shows what is in use and where each value came from.
|
||||
|
||||
## The keys, as the device lists them
|
||||
|
||||
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
|
||||
|
||||
{{ keys(scopes=["settings", "settings-choice", "wifi", "wifi-servers", "wifi-network", "wifi-status", "wifi-scan", "wifi-name", "debug-console"]) }}
|
||||
|
||||
@@ -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"]) }}
|
||||
@@ -13,7 +13,7 @@ Storage shows what is on the SD card: each folder's entries with their size and
|
||||
|
||||
| Key | Does |
|
||||
|---|---|
|
||||
| <kbd>c</kbd> / <kbd>x</kbd> | Copies or cuts the selected file or folder; the footer shows what <kbd>v</kbd> would paste |
|
||||
| <kbd>c</kbd> / <kbd>x</kbd> | Copies or cuts the selected file or folder; the footer shows what is waiting to be pasted |
|
||||
| <kbd>v</kbd> | Pastes it into the folder shown. A copy next to its original is named `name (2).txt`; anything in the way is asked about first |
|
||||
| <kbd>r</kbd> | Renames |
|
||||
| <kbd>d</kbd> | Deletes, after saying what is inside: "Delete saved and its 42 files (1.2 MB)?" |
|
||||
@@ -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.
|
||||
|
||||
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"]) }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
+++
|
||||
title = "System"
|
||||
description = "What the device is doing right now: load, tasks, memory, network traffic, battery and temperature. Live, and read-only."
|
||||
weight = 9
|
||||
weight = 10
|
||||
[extra]
|
||||
tag = "System"
|
||||
screens = ["system.png"]
|
||||
@@ -18,3 +18,9 @@ System changes nothing: it shows. It samples once a second and keeps its history
|
||||
## Why it exists
|
||||
|
||||
The device has about 107 KB of free memory and no PSRAM, so memory is the resource that decides what can run together: a secure connection takes about 52 KB at its peak. System makes that visible, and is how the project measures its own changes.
|
||||
|
||||
## The keys, as the device lists them
|
||||
|
||||
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
|
||||
|
||||
{{ keys(scopes=["system", "system-tasks", "system-system"]) }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
+++
|
||||
title = "Updates"
|
||||
description = "How the device updates itself from the project's releases, from the SD card or from a PC, and how it protects itself when an update goes wrong."
|
||||
weight = 11
|
||||
weight = 12
|
||||
[extra]
|
||||
tag = "Firmware"
|
||||
screens = ["update.png"]
|
||||
@@ -41,4 +41,10 @@ The connection to the project's server is checked against the two root certifica
|
||||
|
||||
## For developers
|
||||
|
||||
Updates can also be pushed from a PC over Wi-Fi, with `scripts/flash.sh --ota <ip>`, or put on the card with `scripts/sd_put.sh`: see the [README](https://git.twis.la/twisla/roro9stack#firmware-updates-over-wi-fi-ota). **Debug Builds** show the latest release but do not install it, because a release has no debug console: update a Debug Build from the PC.
|
||||
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"]) }}
|
||||
|
||||
@@ -34,3 +34,9 @@ One bar for each of the 13 Wi-Fi channels shows how busy it is: how many network
|
||||
## Signal tracker
|
||||
|
||||
Follows one network: its name, address and channel, then the signal in dBm in large type, a strength bar, and a graph of the recent readings, so you can walk towards the strongest signal. <kbd>m</kbd> turns audible clicks on and off. If the network is no longer heard the screen says `lost`. <kbd>`</kbd> (Back) returns to the list.
|
||||
|
||||
## The keys, as the device lists them
|
||||
|
||||
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
|
||||
|
||||
{{ keys(scopes=["wifi-tools", "wifi-networks", "wifi-tracker"]) }}
|
||||
|
||||
@@ -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 |
|
||||
| Files saved from Gemini that are not text | `/gemini/downloads` | whatever they were |
|
||||
| Update files | `/updates` | `.ota` |
|
||||
| Screenshots (the Shell's `screenshot`) | `/screenshots` | `.png`, named by date and time |
|
||||
|
||||
## Rules worth knowing
|
||||
|
||||
|
||||
@@ -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"],
|
||||
]
|
||||
@@ -254,3 +254,12 @@ footer small { display: block; max-width: 760px; }
|
||||
.source { max-width: 820px; font-size: 14px; line-height: 20px; }
|
||||
.source code { background: var(--s2); padding: 0 6px; }
|
||||
.card.featured { flex-basis: 100%; }
|
||||
|
||||
/* The keys of a screen, as the device's help panel lists them (the `keys` shortcode) */
|
||||
.keys { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px 32px; }
|
||||
.keys-scope { background: var(--s2); padding: 16px 20px; }
|
||||
.keys-scope h4 { font: 500 12px/16px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--cyantext); margin: 0 0 8px; }
|
||||
.prose .keys table { width: 100%; }
|
||||
.prose .keys td { padding: 4px 8px 4px 0; border-bottom: 0; font-size: 15px; }
|
||||
.prose .keys td:first-child { white-space: nowrap; width: 1%; padding-right: 16px; }
|
||||
.prose .keys kbd { white-space: pre; }
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</header>
|
||||
|
||||
<div class="prose">
|
||||
<p>Each release has a <strong>signed update file</strong> (<code>.ota</code>: copy it to <code>/updates</code> on the SD card and install it from Settings → Firmware or the Storage App, or push it over Wi-Fi), a <strong>factory image</strong> (<code>-factory.bin</code>, the whole flash at offset 0 for a first install over USB), the <strong>symbols</strong> (<code>.elf.gz</code>, to decode a crash report) and <code>SHA256SUMS</code>. Debug Builds aren't published.</p>
|
||||
<p>Each release has a <strong>signed update file</strong> (<code>.ota</code>: copy it to <code>/updates</code> on the SD card and install it from Settings → Firmware or the Storage App, or push it over Wi-Fi), a <strong>factory image</strong> (<code>-factory.bin</code>, the whole flash at offset 0 for a first install over USB), the <strong>symbols</strong> (<code>.elf.gz</code>, to decode a crash report) and <code>SHA256SUMS</code>.</p>
|
||||
</div>
|
||||
|
||||
{% if releases %}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
</article>
|
||||
{% endfor %}
|
||||
</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 class="wrap" id="screens" aria-labelledby="screens-title">
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
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
|
||||
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.
|
||||
"""
|
||||
import json
|
||||
@@ -23,7 +25,7 @@ REPO = SITE.parent
|
||||
OUT = SITE / "content" / "dev"
|
||||
REPO_URL = re.search(r'repo\s*=\s*"([^"]+)"', (SITE / "config.toml").read_text()).group(1)
|
||||
|
||||
MILESTONES = ["OTA", "M2", "G1", "M3", "S1", "F1", "R1", "W1"] # in the order they were done
|
||||
MILESTONES = ["OTA", "M2", "G1", "M3", "S1", "F1", "R1", "W1", "U1"] # in the order they were done
|
||||
# Left out on purpose: docs/milestones/M0.md, M1.md and CONTEXT.md (the glossary) describe Wi-Fi monitoring, which this site does not publish.
|
||||
# They stay in the repository.
|
||||
|
||||
@@ -139,8 +141,38 @@ def safe_mode_commands():
|
||||
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():
|
||||
pages = {}
|
||||
pages = {"../../data/keys.toml": keys_toml()}
|
||||
|
||||
for path in sorted((REPO / "docs" / "adr").glob("*.md")):
|
||||
title, body = title_and_body(path.read_text())
|
||||
@@ -164,23 +196,40 @@ def build():
|
||||
+ relink("\n".join(readme[k] for k in ("Flash", "Firmware Updates over Wi-Fi (OTA)")), "README.md"))
|
||||
|
||||
common, debug = help_text()
|
||||
if debug:
|
||||
sys.exit("gen_dev_docs: kHelp has a RORO_DEBUG part again: there is one firmware (ADR 0010)")
|
||||
exact, prefix = safe_mode_commands()
|
||||
safe = ", ".join(f"`{c}`" for c in exact) + ", and anything starting with " + ", ".join(f"`{p.strip()}`" for p in prefix)
|
||||
dev = readme["Development aids"].split("### Debug Builds and the Debug Console")[0]
|
||||
dev = readme["Development aids"].split("### The Debug Console")[0]
|
||||
dev = re.sub(r"^## Development aids\n", "", dev).strip() + "\n"
|
||||
pages["debug/commands.md"] = (
|
||||
front("Command reference", "Every command the firmware understands, over USB serial or the Debug Console: what `help` prints, then what each one does.", 30, "src/main.cpp and README.md", tag="Reference")
|
||||
+ "## What `help` prints\n\n"
|
||||
+ "The firmware's own list, read from `src/main.cpp`. These work in every build, over USB serial:\n\n```\n" + common + "\n```\n\n"
|
||||
+ "A **Debug Build** adds these (the last ones, marked *Debug Console only*, exist only over Wi-Fi, where [`rdbg.py`](/dev/debug/files-and-screens/) speaks them):\n\n```\n" + debug + "\n```\n\n"
|
||||
+ "The firmware's own list, read from `src/main.cpp`. Every firmware has all of them, over USB serial and over the Debug Console. The ones marked *Debug Console only* exist only over Wi-Fi, where [`rdbg.py`](/dev/debug/files-and-screens/) speaks them, and the ones marked *USB serial only* only over the cable:\n\n```\n" + common + "\n```\n\n"
|
||||
+ f"In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few run: {safe}. Anything else answers `not available in Safe Mode`.\n\n"
|
||||
+ "## What they do\n\n" + dev)
|
||||
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():
|
||||
check = "--check" in sys.argv
|
||||
pages = build()
|
||||
for problem in unknown_scopes():
|
||||
print("gen_dev_docs:", problem)
|
||||
if unknown_scopes():
|
||||
sys.exit(1)
|
||||
stale = []
|
||||
for rel, text in sorted(pages.items()):
|
||||
path = OUT / rel
|
||||
@@ -192,10 +241,10 @@ def main():
|
||||
path.write_text(text)
|
||||
if check:
|
||||
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: {len(pages)} pages, {len(stale)} out of date")
|
||||
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)} files, {len(stale)} out of date")
|
||||
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__":
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
#include "app_keys.h"
|
||||
#include "apps/debug_console_page.h"
|
||||
|
||||
#include "debug_auth.h"
|
||||
#include "services/debug_console.h"
|
||||
#include "ui/fonts.h"
|
||||
#include "ui/widgets.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
void DebugConsolePage::enter() {
|
||||
list_.setCount(kRows);
|
||||
confirm_.reset();
|
||||
typing_ = false;
|
||||
refusal_.clear();
|
||||
}
|
||||
|
||||
bool DebugConsolePage::onKey(const KeyEvent& e) {
|
||||
if (confirm_) {
|
||||
confirm_->onKey(e);
|
||||
if (confirm_->result() == 1) {
|
||||
if (ask_ == Ask::SwitchOn) DebugConsole::switchOn(settings_);
|
||||
else settings_.setString(Setting::DebugToken, DebugConsole::freshToken());
|
||||
}
|
||||
if (confirm_->result() != DialogModel::kPending) confirm_.reset();
|
||||
return true;
|
||||
}
|
||||
if (typing_) {
|
||||
switch (e.key) {
|
||||
case Key::Char: editor_.insert(e.ch); break;
|
||||
case Key::Delete: editor_.backspace(); break;
|
||||
case Key::Left: editor_.left(); break;
|
||||
case Key::Right: editor_.right(); break;
|
||||
case Key::Back: typing_ = false; break;
|
||||
case Key::Select: {
|
||||
std::string token = debug::tidyToken(editor_.text());
|
||||
if (debug::validToken(token) && settings_.setString(Setting::DebugToken, token)) typing_ = false;
|
||||
else refusal_ = "16 to 64 characters, please";
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
switch (e.key) {
|
||||
case Key::Up: list_.up(); break;
|
||||
case Key::Down: list_.down(); break;
|
||||
case Key::Back: return false;
|
||||
case Key::Left:
|
||||
case Key::Right:
|
||||
case Key::Select:
|
||||
if (e.key != Key::Select && list_.selected() != kSwitch) break;
|
||||
switch (list_.selected()) {
|
||||
case kSwitch:
|
||||
if (settings_.getBool(Setting::DebugConsole)) settings_.setBool(Setting::DebugConsole, false);
|
||||
else { // on is the one to think about
|
||||
ask_ = Ask::SwitchOn;
|
||||
confirm_.reset(new DialogModel({"Cancel", "Switch on"}));
|
||||
}
|
||||
break;
|
||||
case kNewToken:
|
||||
ask_ = Ask::NewToken;
|
||||
confirm_.reset(new DialogModel({"Cancel", "New token"}));
|
||||
break;
|
||||
case kTypeToken:
|
||||
editor_ = LineEditor(64);
|
||||
refusal_.clear();
|
||||
typing_ = true;
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void DebugConsolePage::help(std::vector<KeyHelp>& out) const {
|
||||
if (confirm_) return keys::add(out, keys::kDialog);
|
||||
if (typing_) return keys::add(out, keys::kText);
|
||||
keys::add(out, keys::kDebugConsole);
|
||||
}
|
||||
|
||||
void DebugConsolePage::draw(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
c.setTextDatum(top_left);
|
||||
if (typing_) {
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("A token of your own", 4, area.y + 4);
|
||||
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
|
||||
c.setTextColor(refusal_.empty() ? theme::kMuted : theme::kWarning);
|
||||
c.drawString(refusal_.empty() ? "16 to 64 characters. Capitals or not, it's the same." : refusal_.c_str(), 4, area.y + 44);
|
||||
return;
|
||||
}
|
||||
|
||||
bool on = settings_.getBool(Setting::DebugConsole);
|
||||
std::string ip = wifi_.ip();
|
||||
widgets::list(
|
||||
c, list_, {area.x, area.y, area.w, kRows * theme::kLineHeight},
|
||||
[](int i) -> std::string {
|
||||
switch (i) {
|
||||
case kSwitch: return "Debug Console";
|
||||
case kAddress: return "Connect to";
|
||||
case kNewToken: return "New token";
|
||||
default: return "Type a token";
|
||||
}
|
||||
},
|
||||
[&](int i) -> std::string {
|
||||
switch (i) {
|
||||
case kSwitch: return on ? "On" : "Off";
|
||||
case kAddress: return !on ? "-" : ip.empty() ? "Wi-Fi not connected" : ip + ":" + std::to_string(DebugConsole::kPort);
|
||||
default: return ">";
|
||||
}
|
||||
});
|
||||
|
||||
// The token: here, in full, and nowhere else (it's never printed on a console). One the device
|
||||
// made is drawn at twice the size, three groups then two: it has to be read and typed.
|
||||
int y = area.y + kRows * theme::kLineHeight + 4;
|
||||
c.drawFastHLine(4, y - 2, area.w - 8, theme::kMuted);
|
||||
const std::string& token = settings_.getString(Setting::DebugToken);
|
||||
std::string shown = debug::groupToken(token);
|
||||
c.setFont(&fonts::body);
|
||||
if (token.empty()) {
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("No token yet: one is made when", 4, y + 4);
|
||||
c.drawString("you switch the console on.", 4, y + 4 + theme::kLineHeight);
|
||||
} else if (token.size() <= debug::kTokenChars) {
|
||||
c.setTextColor(theme::kText);
|
||||
c.setTextSize(2);
|
||||
c.drawString(shown.substr(0, 14).c_str(), 4, y + 2);
|
||||
if (shown.size() > 15) c.drawString(shown.substr(15).c_str(), 4, y + 2 + 2 * theme::kLineHeight - 3);
|
||||
c.setTextSize(1);
|
||||
} else {
|
||||
c.setTextColor(theme::kText);
|
||||
for (size_t at = 0; at < shown.size(); at += 35, y += theme::kLineHeight) c.drawString(shown.substr(at, 35).c_str(), 4, y + 2);
|
||||
}
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(on ? theme::kWarning : theme::kMuted);
|
||||
c.drawString(on ? "On this Wi-Fi, the token is full control." : "Off: nothing listens.", 4, area.y + area.h - 9);
|
||||
|
||||
if (confirm_) {
|
||||
if (ask_ == Ask::SwitchOn)
|
||||
widgets::dialog(c, "Switch it on?", "With the token, anyone on this Wi-Fi can read the console, press keys and copy files.", *confirm_);
|
||||
else widgets::dialog(c, "A new token?", "The one in use stops working, and whoever is connected is cut off.", *confirm_);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "dialog_model.h"
|
||||
#include "key_event.h"
|
||||
#include "key_help.h"
|
||||
#include "line_editor.h"
|
||||
#include "list_model.h"
|
||||
#include "services/wifi_service.h"
|
||||
#include "settings.h"
|
||||
#include "ui/canvas.h"
|
||||
#include "ui/theme.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// Settings → Debug Console (ADR 0010): the switch, where to connect, and the token, which is shown
|
||||
// here and nowhere else. Switching on makes a token if there's none; a new one, or one typed by
|
||||
// hand, ends the connection of whoever holds the old one.
|
||||
class DebugConsolePage {
|
||||
public:
|
||||
DebugConsolePage(Settings& settings, WifiService& wifi) : settings_(settings), wifi_(wifi) {}
|
||||
|
||||
void enter();
|
||||
bool onKey(const KeyEvent& e); // false: leave the page
|
||||
bool textEntryActive() const { return typing_; }
|
||||
void draw(Canvas& c);
|
||||
void help(std::vector<KeyHelp>& out) const;
|
||||
const char* helpTitle() const { return typing_ ? "A token of your own" : "Debug Console"; }
|
||||
|
||||
private:
|
||||
enum Row { kSwitch, kAddress, kNewToken, kTypeToken, kRows };
|
||||
enum class Ask { None, SwitchOn, NewToken };
|
||||
|
||||
Settings& settings_;
|
||||
WifiService& wifi_;
|
||||
ListModel list_{kRows};
|
||||
std::unique_ptr<DialogModel> confirm_;
|
||||
Ask ask_ = Ask::None;
|
||||
bool typing_ = false;
|
||||
LineEditor editor_{64};
|
||||
std::string refusal_;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "app_keys.h"
|
||||
#include "demo_app.h"
|
||||
|
||||
#include "ui/widgets.h"
|
||||
@@ -25,6 +26,12 @@ void DemoApp::notify(const char* text, NotificationLevel level) {
|
||||
bus_.publish(Event::withText(EventType::Notification, text, static_cast<int32_t>(level)));
|
||||
}
|
||||
|
||||
void DemoApp::help(std::vector<KeyHelp>& out) const {
|
||||
if (dialog_) return keys::add(out, keys::kDialog);
|
||||
if (page_ == Page::Editor) return keys::add(out, keys::kText);
|
||||
if (page_ == Page::Menu) keys::add(out, keys::kDemo);
|
||||
}
|
||||
|
||||
bool DemoApp::onKey(const KeyEvent& e) {
|
||||
requestRedraw();
|
||||
if (dialog_) {
|
||||
@@ -97,7 +104,7 @@ void DemoApp::draw(Canvas& c) {
|
||||
}
|
||||
case Page::Editor:
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("Type (opt ' e = \xC3\xA9). Enter: toast", 4, area.y + 4);
|
||||
c.drawString("A line editor", 4, area.y + 4);
|
||||
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString((std::to_string(editor_.text().size()) + "/39 bytes").c_str(), 4, area.y + 44);
|
||||
|
||||
@@ -21,6 +21,7 @@ class DemoApp : public App {
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
bool textEntryActive() const override { return page_ == Page::Editor && !dialog_; }
|
||||
void draw(Canvas& c) override;
|
||||
void help(std::vector<KeyHelp>& out) const override;
|
||||
|
||||
private:
|
||||
enum class Page { Menu, Text, Editor };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "app_keys.h"
|
||||
#include "file_viewer.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
@@ -265,6 +266,18 @@ void FileViewer::openPacket(int index) {
|
||||
mode_ = Mode::Packet;
|
||||
}
|
||||
|
||||
void FileViewer::help(std::vector<KeyHelp>& out) const {
|
||||
if (confirm_) return keys::add(out, keys::kDialog);
|
||||
switch (mode_) {
|
||||
case Mode::Text: keys::add(out, keys::kViewerText); break;
|
||||
case Mode::Hex: keys::add(out, keys::kViewerHex); break;
|
||||
case Mode::Pcap: keys::add(out, keys::kViewerPcap); break;
|
||||
case Mode::Packet: keys::add(out, keys::kViewerPacket); break;
|
||||
case Mode::Gpx: keys::add(out, keys::kViewerGpx); break;
|
||||
case Mode::Ota: keys::add(out, keys::kViewerOta); break;
|
||||
}
|
||||
}
|
||||
|
||||
bool FileViewer::onKey(const KeyEvent& e) {
|
||||
if (confirm_) {
|
||||
confirm_->onKey(e);
|
||||
@@ -380,7 +393,7 @@ void FileViewer::draw(Canvas& c) {
|
||||
refresh();
|
||||
c.setTextDatum(top_left);
|
||||
theme::Rect body{area.x, area.y + 10, area.w, kRows * theme::kLineHeight};
|
||||
std::string right, keys = "Tab: hex";
|
||||
std::string right;
|
||||
bool scanning = scan_ && !scan_->done;
|
||||
|
||||
switch (mode_) {
|
||||
@@ -396,8 +409,6 @@ void FileViewer::draw(Canvas& c) {
|
||||
c.fillRect(area.w - 2, barY, 2, barH, theme::kMuted);
|
||||
}
|
||||
right = formatBytes(size_);
|
||||
keys = std::string("Tab: ") + (mode_ != base_ ? "back" : mode_ == Mode::Text ? "hex" : "text") + " t: top b: end";
|
||||
if (mode_ == Mode::Text) keys += " e: edit";
|
||||
break;
|
||||
}
|
||||
case Mode::Gpx: {
|
||||
@@ -407,7 +418,6 @@ void FileViewer::draw(Canvas& c) {
|
||||
else if (scan_) lines = scan_->gpx.lines();
|
||||
widgets::textLines(c, lines, 0, body);
|
||||
right = formatBytes(size_);
|
||||
keys = "Tab: the file as text";
|
||||
break;
|
||||
}
|
||||
case Mode::Pcap: {
|
||||
@@ -423,7 +433,6 @@ void FileViewer::draw(Canvas& c) {
|
||||
return row < shown_.size() ? shown_[row] : std::string();
|
||||
});
|
||||
right = std::to_string(scan_->packets.size()) + (scan_->morePackets ? "+ packets" : " packets");
|
||||
keys = "Enter: the packet Tab: hex";
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -447,7 +456,6 @@ void FileViewer::draw(Canvas& c) {
|
||||
}
|
||||
widgets::textLines(c, lines, 0, body);
|
||||
right = formatBytes(size_);
|
||||
keys = ok ? "Enter: install Tab: hex" : "Tab: hex";
|
||||
if (confirm_) {
|
||||
widgets::dialog(c, "Install update?", "The device restarts into " + scan_->version + " once it's written.", *confirm_);
|
||||
return;
|
||||
@@ -464,7 +472,7 @@ void FileViewer::draw(Canvas& c) {
|
||||
c.drawString(right.c_str(), area.w - 3, area.y + 1);
|
||||
c.setTextDatum(top_left);
|
||||
if (!message_.empty()) c.setTextColor(theme::kWarning);
|
||||
c.drawString(message_.empty() ? keys.c_str() : message_.c_str(), 4, area.y + area.h - 9);
|
||||
if (!message_.empty()) c.drawString(message_.c_str(), 4, area.y + area.h - 9);
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "dialog_model.h"
|
||||
#include "key_help.h"
|
||||
#include "key_event.h"
|
||||
#include "list_model.h"
|
||||
#include "services/storage_service.h"
|
||||
@@ -27,6 +28,7 @@ class FileViewer {
|
||||
void open(const std::string& path, uint32_t size);
|
||||
void close();
|
||||
bool onKey(const KeyEvent& e); // false: leave the viewer
|
||||
void help(std::vector<KeyHelp>& out) const;
|
||||
bool update(uint32_t nowMs); // true: draw again
|
||||
void draw(Canvas& c);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "app_keys.h"
|
||||
#include "firmware_page.h"
|
||||
|
||||
#include <SD.h>
|
||||
@@ -63,9 +64,9 @@ std::string FirmwarePage::latestText(bool& warn) const {
|
||||
release::Release r;
|
||||
if (g.status() == GiteaReleases::Status::Failed && !g.latest(r)) {
|
||||
warn = true;
|
||||
return "failed: Enter retries";
|
||||
return "the check failed";
|
||||
}
|
||||
if (!g.latest(r)) return "Enter: check";
|
||||
if (!g.latest(r)) return "not checked yet";
|
||||
return r.tag + (release::isNewer(r, update_.runningVersion()) ? " (new)" : " (current)");
|
||||
}
|
||||
|
||||
@@ -80,7 +81,6 @@ bool FirmwarePage::installable(std::string& why) const {
|
||||
std::string running = update_.runningVersion();
|
||||
if (!shown_.usable()) why = "Nothing to install in it";
|
||||
else if (!release::versionNewer(shown_.tag, running) && !versionOlder(shown_.tag, running)) why = "That's the version running";
|
||||
else if (release::isDebugBuild(running)) why = "Debug Build: update from the PC"; // it keeps its console (Q171)
|
||||
else if (wifi_.state() != WifiController::State::Connected) why = "Not connected to Wi-Fi";
|
||||
else return true;
|
||||
return false;
|
||||
@@ -203,7 +203,7 @@ void FirmwarePage::drawRelease(Canvas& c) {
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextDatum(top_left);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString(ok ? "Enter: install c: check again" : (why + " c: check").c_str(), 4, area.y + area.h - 9);
|
||||
if (!ok) c.drawString(why.c_str(), 4, area.y + area.h - 9); // why it can't be installed from here
|
||||
}
|
||||
|
||||
void FirmwarePage::drawOlder(Canvas& c) {
|
||||
@@ -233,9 +233,19 @@ void FirmwarePage::drawOlder(Canvas& c) {
|
||||
return r.tag + (r.tag == running ? " (running)" : release::versionNewer(r.tag, running) ? " (new)" : "");
|
||||
},
|
||||
[&](int i) { return olderReleases_[i].date(); });
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("Enter: details c: look again", 4, area.y + area.h - 9);
|
||||
}
|
||||
|
||||
void FirmwarePage::help(std::vector<KeyHelp>& out) const {
|
||||
if (confirm_) return keys::add(out, keys::kDialog);
|
||||
switch (view_) {
|
||||
case View::Main: keys::add(out, keys::kFirmware); break;
|
||||
case View::Release: keys::add(out, keys::kFirmwareRelease); break;
|
||||
case View::Older: keys::add(out, keys::kFirmwareOlder); break;
|
||||
}
|
||||
}
|
||||
|
||||
const char* FirmwarePage::helpTitle() const {
|
||||
return view_ == View::Release ? "A release" : view_ == View::Older ? "Older releases" : "Firmware";
|
||||
}
|
||||
|
||||
void FirmwarePage::drawMain(Canvas& c) {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#include "dialog_model.h"
|
||||
#include "key_event.h"
|
||||
#include "key_help.h"
|
||||
#include "list_model.h"
|
||||
#include "release_info.h"
|
||||
#include "services/storage_service.h"
|
||||
@@ -28,6 +29,8 @@ class FirmwarePage {
|
||||
void enter();
|
||||
bool onKey(const KeyEvent& e); // false: leave the page
|
||||
void draw(Canvas& c);
|
||||
void help(std::vector<KeyHelp>& out) const;
|
||||
const char* helpTitle() const;
|
||||
|
||||
private:
|
||||
enum Row { kVersion, kStatus, kAddress, kLatest, kOlder, kSdHeader, kFixed };
|
||||
|
||||
+17
-4
@@ -1,3 +1,4 @@
|
||||
#include "app_keys.h"
|
||||
#include "gemini_app.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
@@ -171,7 +172,7 @@ void GeminiApp::update(uint32_t nowMs) {
|
||||
else {
|
||||
std::string mime = result.header.mimeType();
|
||||
showMessage(url, "# Not a text page\nThis is " + mime +
|
||||
", which the Cardputer can't show. s saves it to the card, in "
|
||||
", which the Cardputer can't show. Saving it puts it on the card, in "
|
||||
"/gemini/downloads.\n");
|
||||
page_.header.meta = mime; // so s knows to download it
|
||||
}
|
||||
@@ -302,6 +303,18 @@ void GeminiApp::selectLink(int direction) {
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
void GeminiApp::help(std::vector<KeyHelp>& out) const {
|
||||
if (certDialog_ || deleteDialog_) return keys::add(out, keys::kDialog);
|
||||
if (inputOpen_) return keys::add(out, keys::kGeminiAnswer);
|
||||
if (addressOpen_) return keys::add(out, keys::kGeminiAddress);
|
||||
if (isSaved()) keys::add(out, keys::kGeminiSaved);
|
||||
else keys::add(out, keys::kGemini);
|
||||
}
|
||||
|
||||
const char* GeminiApp::helpTitle() const {
|
||||
return inputOpen_ ? "Gemini: an answer" : addressOpen_ ? "Gemini: an address" : nullptr;
|
||||
}
|
||||
|
||||
bool GeminiApp::onKey(const KeyEvent& e) {
|
||||
requestRedraw();
|
||||
if (certDialog_) {
|
||||
@@ -388,7 +401,7 @@ bool GeminiApp::onKey(const KeyEvent& e) {
|
||||
if (isGemini(page_.base())) gemini_.addBookmark(page_.base(), titleOf());
|
||||
break;
|
||||
case 's': // Q82, Q73
|
||||
if (isSaved()) status_ = "Already saved; r refreshes it";
|
||||
if (isSaved()) status_ = "Already saved: it can be refreshed";
|
||||
else if (!isGemini(page_.url)) status_ = "Only Gemini pages can be saved";
|
||||
else if (page_.header.mimeType().rfind("text/", 0) == 0) gemini_.save(page_.url);
|
||||
else gemini_.download(page_.url);
|
||||
@@ -485,7 +498,7 @@ void GeminiApp::draw(Canvas& c) {
|
||||
c.fillRect(box.x - 2, box.y - 12, box.w + 4, box.h + 14, theme::kBackground);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("Go to (Enter, or ` to cancel)", box.x, box.y - 10);
|
||||
c.drawString("Go to", box.x, box.y - 10);
|
||||
widgets::lineEditor(c, address_, box);
|
||||
}
|
||||
if (inputOpen_) {
|
||||
@@ -496,7 +509,7 @@ void GeminiApp::draw(Canvas& c) {
|
||||
c.drawString(displayText(inputPrompt_).substr(0, 39).c_str(), box.x, box.y - 22);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString(inputSensitive_ ? "(hidden) Enter sends, ` cancels" : "Enter sends, ` cancels", box.x, box.y - 9);
|
||||
if (inputSensitive_) c.drawString("(hidden)", box.x, box.y - 9);
|
||||
if (inputSensitive_) {
|
||||
LineEditor masked(1024);
|
||||
masked.setText(std::string(input_.text().size(), '*'));
|
||||
|
||||
@@ -24,6 +24,8 @@ class GeminiApp : public App {
|
||||
bool textEntryActive() const override { return addressOpen_ || inputOpen_; }
|
||||
void update(uint32_t nowMs) override;
|
||||
void draw(Canvas& c) override;
|
||||
void help(std::vector<KeyHelp>& out) const override;
|
||||
const char* helpTitle() const override;
|
||||
|
||||
private:
|
||||
struct Visit {
|
||||
|
||||
+7
-10
@@ -1,3 +1,4 @@
|
||||
#include "app_keys.h"
|
||||
#include "gnss_app.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
@@ -80,11 +81,10 @@ void GnssApp::draw(Canvas& c) {
|
||||
return;
|
||||
}
|
||||
sky_ ? drawSky(c) : drawPosition(c);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.setTextDatum(top_right);
|
||||
c.drawString(sky_ ? "Tab: position" : "Tab: sky", area.w - 3, area.y + area.h - 9);
|
||||
c.setTextDatum(top_left);
|
||||
}
|
||||
|
||||
void GnssApp::help(std::vector<KeyHelp>& out) const {
|
||||
keys::add(out, keys::kGnss);
|
||||
}
|
||||
|
||||
void GnssApp::drawPosition(Canvas& c) {
|
||||
@@ -139,15 +139,12 @@ void GnssApp::drawPosition(Canvas& c) {
|
||||
c.setFont(&fonts::small);
|
||||
if (gnss_.tracking()) {
|
||||
c.setTextColor(theme::kWarning);
|
||||
std::snprintf(line, sizeof line, "REC %d point%s, %s r: stop", gnss_.trackPoints(),
|
||||
std::snprintf(line, sizeof line, "REC %d point%s, %s", gnss_.trackPoints(),
|
||||
gnss_.trackPoints() == 1 ? "" : "s", duration(now - gnss_.trackStartMs()).c_str());
|
||||
} else if (!refusal_.empty() && now - refusalMs_ < 4000) {
|
||||
c.setTextColor(theme::kWarning);
|
||||
std::snprintf(line, sizeof line, "%s", refusal_.c_str());
|
||||
} else {
|
||||
c.setTextColor(theme::kMuted);
|
||||
std::snprintf(line, sizeof line, "r: record a Track");
|
||||
}
|
||||
} else line[0] = 0;
|
||||
c.drawString(line, 4, area.y + area.h - 9);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ class GnssApp : public App {
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
void update(uint32_t nowMs) override;
|
||||
void draw(Canvas& c) override;
|
||||
void help(std::vector<KeyHelp>& out) const override;
|
||||
|
||||
private:
|
||||
void drawPosition(Canvas& c);
|
||||
|
||||
+12
-2
@@ -1,3 +1,4 @@
|
||||
#include "app_keys.h"
|
||||
#include "irc_app.h"
|
||||
|
||||
#include "ui/fonts.h"
|
||||
@@ -299,6 +300,16 @@ void IrcApp::drawChat(Canvas& c) {
|
||||
widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0});
|
||||
}
|
||||
|
||||
void IrcApp::help(std::vector<KeyHelp>& out) const {
|
||||
if (page_ == Page::Chat) return keys::add(out, keys::kIrc);
|
||||
if (editing_) return keys::add(out, keys::kIrcField);
|
||||
keys::add(out, keys::kIrcSettings);
|
||||
}
|
||||
|
||||
const char* IrcApp::helpTitle() const {
|
||||
return page_ == Page::Settings ? (editing_ ? "IRC: a setting" : "IRC settings") : nullptr;
|
||||
}
|
||||
|
||||
void IrcApp::drawSettings(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
if (editing_) {
|
||||
@@ -306,8 +317,7 @@ void IrcApp::drawSettings(Canvas& c) {
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString(fieldLabel(fields_.selected()).c_str(), 4, area.y + 4);
|
||||
widgets::lineEditor(c, fieldEditor_, {4, area.y + 22, area.w - 8, 0});
|
||||
c.drawString(fields_.selected() == kAutojoin ? "e.g. #roro, #private key" : "Enter: OK `: cancel", 4,
|
||||
area.y + 44);
|
||||
if (fields_.selected() == kAutojoin) c.drawString("e.g. #roro, #private key", 4, area.y + 44);
|
||||
return;
|
||||
}
|
||||
widgets::list(
|
||||
|
||||
@@ -24,6 +24,8 @@ class IrcApp : public App {
|
||||
void update(uint32_t nowMs) override;
|
||||
bool textEntryActive() const override { return page_ == Page::Chat || editing_; }
|
||||
void draw(Canvas& c) override;
|
||||
void help(std::vector<KeyHelp>& out) const override;
|
||||
const char* helpTitle() const override;
|
||||
|
||||
private:
|
||||
enum class Page { Chat, Settings };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user