Compare commits

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 01:29:51 +02:00
twisla 1c6ae0e04f Merge pull request 'Devlog: "It was off" (the website, and one firmware with the Debug Console in it)' (#71) from devlog-console into main
Site / build (push) Successful in 9s
Reviewed-on: #71
2026-10-06 22:20:56 +00:00
twislaandClaude Opus 5.5 74b7713553 Devlog: "It was off", the website and the one firmware with the Debug Console in it (v0.12.0)
Site / build (pull_request) Successful in 9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 00:06:47 +02:00
twisla 6be05b782d Merge pull request 'One firmware: the Debug Console in every build, off until switched on (#68)' (#70) from console-setting into main
Site / build (push) Successful in 12s
CI / build (push) Successful in 13m11s
Reviewed-on: #70
2026-10-06 21:58:23 +00:00
twislaandClaude Opus 5.5 1874a1b586 Debug Console: the listener is checked and retried, and debug off <seconds> comes back by itself
CI / build (pull_request) Successful in 7m10s
Site / build (pull_request) Successful in 14s
The framework's server begin() fails without a word: the console's task now
asks whether it listens, says so, and tries again. `debug off <seconds>`
closes the console and reopens it after the pause, which is the only way to
test its closing and reopening from afar.

Checked on the device: 25 closings and reopenings, each back a second after
the pause. Free heap dips about 270 bytes for each connection the device
closes and is all back two minutes later (TCP keeps a closed connection that
long): not a leak.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 23:50:41 +02:00
twislaandClaude Opus 5.5 c68741cc46 One firmware: the Debug Console in every build, off until switched on, with the device's own token
CI / build (pull_request) Successful in 7m20s
Site / build (pull_request) Successful in 9s
There is no Debug Build any more (ADR 0010, issue #68, Q188 to Q195). The
console and the test commands are compiled into every firmware. It listens
only while Settings > Debug Console is on, which isn't the default; off,
neither its task nor its 4 KB ring exists. The token is made by the device
and shown on that page; a client proves it knows it by answering a challenge
with an HMAC, so it never crosses the network, and five wrong answers close
the console for a minute. DBG in the Status Bar while it listens.

Over USB serial only: debug on, debug token <value>, debug token new.
scripts/flash.sh --debug uses them to set a device up with the developer's
token. scripts/rdbg.py takes the token from -t, $RORO_DEBUG_TOKEN or the
file, answers the challenge, and fetches a release's ELF to decode a crash.

Gone: the cardputer-adv-debug environment, RORO_DEBUG, the +debug version,
scripts/debug_flags.py, update install ... force, and the rule that a Debug
Build doesn't install releases. Old clients and old firmwares don't talk to
each other.

Against the builds it replaces: 30 KB more flash and 88 bytes more static
RAM than the release, 4 KB less RAM than the Debug Build. 468 host tests.
Checked on the device: off by default, login, the pause after wrong tokens,
Safe Mode with the console, the setting surviving an update, debug off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 22:59:35 +02:00
twisla 1353e6a5f9 Merge pull request 'Site: the developer docs (phase 4), Debug Builds and the Debug Console first' (#66) from site-dev into main
CI / build (push) Successful in 1m9s
Site / build (push) Successful in 10s
Reviewed-on: #66
2026-10-06 19:34:40 +00:00
130 changed files with 3597 additions and 476 deletions
+50 -9
View File
@@ -1,14 +1,31 @@
# CI and releases (docs/milestones/R1.md). # CI and releases (docs/milestones/R1.md).
# A push to main: the host tests, with their coverage of lib/, and the README's badges # A push to main: the host tests, with their coverage of lib/, and the README's badges
# published to the branch `badges`. # published to the branch `badges`.
# A pull request: the same tests and coverage, then the 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 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). # Run by hand: the release of a tag that exists already (the ones from before CI).
# #
# The job runs in a plain Python image, as scripts/ci.sh does on a developer's machine, with the # The job runs in a plain Python image, as scripts/ci.sh does on a developer's machine, with the
# toolchains in a Docker volume the runner allows (container.valid_volumes: roro9stack-pio): that # toolchains in a Docker volume the runner allows (container.valid_volumes: roro9stack-pio): that
# volume is the cache. No JavaScript actions, so the image needs no Node: the checkout is git. # volume is the cache. No JavaScript actions, so the image needs no Node: the checkout is git.
#
# What the volume keeps between runs, and what makes each go stale (issue #74, R1.md):
# /pio/packages, /pio/platforms toolchains and the framework: by their versions in platformio.ini
# .../framework-arduinoespressif32-libs/.roro-sdkconfig.defaults
# the mark that the framework is already rebuilt with our SDK settings: the
# project's sdkconfig.defaults, which isn't in git, so that every fresh
# checkout rebuilt the framework (260 s). The platform checks its hash
# against platformio.ini's settings, and rebuilds if they differ. It is kept
# inside the libraries it describes, so it goes when they are reinstalled.
# /pio/ci/build-cache PlatformIO's build cache (SCons): objects by the signature of their
# sources and command line. For pull requests only: a release compiles
# its own sources from nothing.
# /pio/ci/ccache the host tests' objects (they're built for coverage, which the build
# cache can't keep: it would lose the .gcno files)
# /pio/ci/venv PlatformIO and gcovr: delete the folder to upgrade them
# To start from nothing (a slow run, about 7 minutes): delete /pio/ci and that .roro-sdkconfig.defaults file.
name: CI name: CI
on: on:
push: push:
@@ -37,13 +54,23 @@ jobs:
env: env:
PLATFORMIO_CORE_DIR: /pio PLATFORMIO_CORE_DIR: /pio
RORO_NO_DOCKER: 1 RORO_NO_DOCKER: 1
SDK_MARK: /pio/packages/framework-arduinoespressif32-libs/.roro-sdkconfig.defaults
CCACHE_DIR: /pio/ci/ccache
CCACHE_MAXSIZE: 1G
steps: steps:
- name: Tools - name: Tools
run: | run: |
apt-get update -qq apt-get update -qq
apt-get install -y -qq --no-install-recommends git build-essential openssl >/dev/null apt-get install -y -qq --no-install-recommends git build-essential openssl ccache >/dev/null
pip install -q --no-cache-dir --root-user-action=ignore platformio gcovr mkdir -p /pio/ci
pio --version; df -h /pio | tail -1; ls /pio | head if [ ! -x /pio/ci/venv/bin/pio ]; then
python -m venv /pio/ci/venv
/pio/ci/venv/bin/pip install -q --no-cache-dir platformio gcovr
fi
ln -sf /pio/ci/venv/bin/pio /pio/ci/venv/bin/gcovr /usr/local/bin/
pio --version; df -h /pio | tail -1; du -sh /pio/ci/* 2>/dev/null || true
# The build cache only grows: start it again past 3 GB (a full set of objects is 160 MB, and each run adds about 40).
if [ "$(du -sm /pio/ci/build-cache 2>/dev/null | cut -f1)" -gt 3072 ] 2>/dev/null; then rm -rf /pio/ci/build-cache; fi
- name: Check out - name: Check out
run: | run: |
@@ -57,11 +84,20 @@ jobs:
- name: Host tests, and their coverage of lib/ - name: Host tests, and their coverage of lib/
if: github.event_name != 'workflow_dispatch' if: github.event_name != 'workflow_dispatch'
run: scripts/coverage.sh run: |
export PATH="/usr/lib/ccache:$PATH" # gcc and g++ through ccache
scripts/coverage.sh
ccache -s | grep -E 'Hits|Misses' | head -2
- name: The release firmware and the Debug Build # A pull request only: a tag's firmware is built once, by the release step below.
if: github.event_name == 'pull_request' || github.ref_type == 'tag' - name: The firmware
run: scripts/ci.sh builds if: github.event_name == 'pull_request'
env:
PLATFORMIO_BUILD_CACHE_DIR: /pio/ci/build-cache
run: |
[ ! -f "$SDK_MARK" ] || cp "$SDK_MARK" sdkconfig.defaults
scripts/ci.sh builds
cp sdkconfig.defaults "$SDK_MARK" # what the framework in the volume is rebuilt with, now
# The README's badges are files on a branch of their own, replaced at each push to main and # The README's badges are files on a branch of their own, replaced at each push to main and
# at each tag (the release badge says which tag is the latest) # at each tag (the release badge says which tag is the latest)
@@ -103,7 +139,12 @@ jobs:
trap 'rm -f "$RORO_OTA_KEY"' EXIT trap 'rm -f "$RORO_OTA_KEY"' EXIT
printf '%s\n' "$OTA_SIGNING_KEY" > "$RORO_OTA_KEY" printf '%s\n' "$OTA_SIGNING_KEY" > "$RORO_OTA_KEY"
umask 022 umask 022
# The framework rebuilt with our settings is reused if it matches (the platform checks);
# the release's own sources are compiled from nothing, with no build cache.
[ ! -f "$SDK_MARK" ] || cp "$SDK_MARK" /tmp/release-src/sdkconfig.defaults
scripts/release_build.sh /tmp/release-src dist scripts/release_build.sh /tmp/release-src dist
# An old tag has no SDK settings of its own, and no sdkconfig.defaults afterwards: nothing to mark.
[ ! -f /tmp/release-src/sdkconfig.defaults ] || [ ! -d "$(dirname "$SDK_MARK")" ] || cp /tmp/release-src/sdkconfig.defaults "$SDK_MARK"
- name: Publish the release - name: Publish the release
if: steps.release.outputs.tag != '' if: steps.release.outputs.tag != ''
+4 -4
View File
@@ -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 # It runs when the site, or a document the site is built from, changes (a pull request, or a push to
# main); the firmware workflow (ci.yml) skips a change that touches only these files. A change that # main); the firmware workflow (ci.yml) skips a change that touches only these files. A change that
# touches both runs both. src/main.cpp is here too: the site's command reference is generated from the # touches both runs both. src/main.cpp and lib/core/src/app_keys.h are here too: the site's command
# firmware's own `help` text, and this job checks that it is still current. # reference and its key tables are generated from them, and this job checks that they are still current.
name: Site name: Site
on: on:
push: push:
branches: [main] branches: [main]
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', '.gitea/workflows/site.yml'] paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', 'lib/core/src/app_keys.h', '.gitea/workflows/site.yml']
pull_request: pull_request:
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', '.gitea/workflows/site.yml'] paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', 'lib/core/src/app_keys.h', '.gitea/workflows/site.yml']
jobs: jobs:
build: build:
+3
View File
@@ -12,3 +12,6 @@ sdkconfig.*
# The built site (site/config.toml sends it here) # The built site (site/config.toml sends it here)
/public/ /public/
# The version, written by scripts/version.py before each build
lib/version/src/version_generated.h
+8 -8
View File
@@ -106,6 +106,10 @@ The regulatory band plan the device transmits under (here EU868). It sets the al
**Duty Cycle Budget**: **Duty Cycle Budget**:
The share of airtime the Region allows this device to transmit. When it's used up, outgoing traffic waits. The share of airtime the Region allows this device to transmit. When it's used up, outgoing traffic waits.
**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**: **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. 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 _Avoid_: edit mode, insert mode
@@ -144,7 +148,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**. 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**: **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) _Avoid_: flash, upgrade (alone)
**Update File**: **Update File**:
@@ -160,16 +164,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) _Avoid_: revert, downgrade (a downgrade is installing an older version on purpose)
**Safe Mode**: **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 _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**: **Debug Console**:
The console of a Debug Build over Wi-Fi: live log lines and the serial commands, behind a token. 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 _Avoid_: telnet, remote shell, Debug Build (there is one firmware)
## Relationships ## Relationships
+26 -17
View File
@@ -8,6 +8,10 @@ A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262*
- Decisions: [docs/adr/](docs/adr/) - Decisions: [docs/adr/](docs/adr/)
- Milestones: [docs/milestones/](docs/milestones/) - 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 ## 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. 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. `scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure.
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 4 minutes; later builds take under a minute. The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 6 minutes; later builds take about 15 seconds when little has changed. The platform knows the framework is already rebuilt by `sdkconfig.defaults` in the project folder, which it writes and git ignores: delete it and the next build rebuilds the framework. CI keeps that file, a build cache and ccache in its volume (docs/milestones/R1.md).
## CI and releases ## CI and releases
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>.ota`, the signed Update File;
- `roro9stack-<version>-factory.bin`, the whole flash image for a first install over USB; - `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; - `roro9stack-<version>.elf.gz`, to decode crash reports from that build;
- `SHA256SUMS`. - `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. `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. **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 ## Networks without DHCP
@@ -159,16 +163,16 @@ A note holds up to 16 KB while it's edited. A bigger text file opens read-only i
| Command | Effect | | Command | Effect |
|---|---| |---|---|
| `burst` | Publishes 5 Notifications at once | | `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) | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `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 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 | | `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`) | | `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 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 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 | | `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 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 | | `irc stop` | Stops it, as `/quit` does: QUIT if connected, no more retries, and the App stays disconnected until you type |
@@ -185,8 +189,8 @@ A note holds up to 16 KB while it's edited. A bigger text file opens read-only i
| `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path | | `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path |
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (a folder with what's in it), new folder. `-f` replaces a file that's in the way; a tab separates paths that hold spaces; `cancel` stops a copy or a delete | | `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 |
| `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does | | `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does |
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one (not on a Debug Build) | | `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` | 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 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 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 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 | | `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
@@ -198,26 +202,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 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 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) | | `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 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]` | Debug Builds: a packet into the Scanner as if received (nothing is sent) | | `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) | | `crash` | The last crash: which firmware, why, task, PC and backtrace (from the core dump in flash) |
| `coredump erase` | Forgets the core dump | | `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 | | `loop spin on` / `loop spin off` | 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 | | `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 | | `help` | Lists the commands |
`scripts/flash.sh` stops a running serial log first, since it would hold the port. `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 ```sh
scripts/rdbg.py # interactive: the console backlog, live lines, and commands scripts/rdbg.py # interactive: the console backlog, live lines, and commands
scripts/rdbg.py info # one command and its reply 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 -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: Every command above works there too, plus a few handled by the PC side or the console's own task:
```sh ```sh
@@ -231,6 +240,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. 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 # 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. 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. 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 # 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. - **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. 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). - **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. - **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 ## 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
View File
@@ -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.) | | 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). | | 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. | | 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`. | | 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. | | 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. | | 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. | | 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. | | 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.) | | 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). | | 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. | | 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. - **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. **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.
+46
View File
@@ -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.
+2 -1
View File
@@ -24,7 +24,7 @@ const RowDef kRows[] = {
{Row::Coordinates, Kind::Toggle, "Coordinates"}, {Row::Coordinates, Kind::Toggle, "Coordinates"},
{Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"}, {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::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}; 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::Coordinates: return settings_.getBool(Setting::CoordinatesDms) ? "Deg min sec" : "Decimal";
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised"; case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off"; case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
case Row::DebugConsole: return settings_.getBool(Setting::DebugConsole) ? "On" : "Off";
default: return ""; default: return "";
} }
} }
+1 -1
View File
@@ -11,7 +11,7 @@ namespace roro {
// values, choice lists and validation messages. Rendering and navigation live in the App. // values, choice lists and validation messages. Rendering and navigation live in the App.
class SettingsMenu { class SettingsMenu {
public: 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 }; enum class Kind { Text, Choice, Toggle, Slider, Page };
explicit SettingsMenu(Settings& settings) : settings_(settings) {} explicit SettingsMenu(Settings& settings) : settings_(settings) {}
+9
View File
@@ -2,7 +2,10 @@
#include <cstdint> #include <cstdint>
#include <vector>
#include "key_event.h" #include "key_event.h"
#include "key_help.h"
namespace roro { 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. // True while the App is editing text: the arrow keys then type ; . , / and need Fn to move.
virtual bool textEntryActive() const { return false; } 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). // Called every main-loop pass while in the foreground (e.g. to refresh live values).
virtual void update(uint32_t nowMs) { (void)nowMs; } virtual void update(uint32_t nowMs) { (void)nowMs; }
+434
View File
@@ -0,0 +1,434 @@
#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"},
};
// 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
+19
View File
@@ -1,5 +1,7 @@
#include "app_manager.h" #include "app_manager.h"
#include "app_keys.h"
#include <cstring> #include <cstring>
namespace roro { namespace roro {
@@ -52,6 +54,22 @@ void AppManager::endModal() {
} }
void AppManager::handleKey(const KeyEvent& event) { 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_) { if (modal_) {
foreground_->onKey(event); foreground_->onKey(event);
return; return;
@@ -77,6 +95,7 @@ bool AppManager::takeRedraw() {
} }
void AppManager::switchTo(App& app) { 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; if (&app == foreground_) return;
foreground_->onExit(); foreground_->onExit();
foreground_ = &app; foreground_ = &app;
+4
View File
@@ -37,6 +37,9 @@ class AppManager {
App& foreground() const { return *foreground_; } App& foreground() const { return *foreground_; }
const char* foregroundTitle() const; // nullptr for the Launcher const char* foregroundTitle() const; // nullptr for the Launcher
// 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). // True once after the screen needs redrawing (App switch, or the App asked for it).
bool takeRedraw(); bool takeRedraw();
@@ -47,6 +50,7 @@ class AppManager {
App& launcher_; App& launcher_;
App* foreground_; App* foreground_;
std::vector<AppInfo> apps_; std::vector<AppInfo> apps_;
HelpModel help_;
bool redraw_ = true; bool redraw_ = true;
bool modal_ = false; bool modal_ = false;
}; };
+1
View File
@@ -16,6 +16,7 @@ enum class Key : uint8_t {
Home, Home,
Tab, Tab,
Delete, Delete,
Help, // Fn+h anywhere, or ? outside Text Entry: the keys of this screen (issue #69)
}; };
struct KeyEvent { struct KeyEvent {
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include <algorithm>
#include <string>
#include <vector>
#include "key_event.h"
// The help panel (issue #69, docs/milestones/U1.md): Fn+h on any screen lists the keys that work
// there. Every App says what its keys are in the state it's in; nothing on a screen names keys.
namespace roro {
// One line of the panel: a key (or keys) and what it does. A line with no action is a heading.
struct KeyHelp {
const char* keys;
const char* action;
};
// The panel itself: what it lists, and how far it's scrolled. The keys it takes while open are the
// arrows; any other key closes it, and none reaches the App.
class HelpModel {
public:
explicit HelpModel(int visibleRows = 8) : visible_(visibleRows) {}
void open(const std::string& title, std::vector<KeyHelp> rows) {
title_ = title;
rows_ = std::move(rows);
top_ = 0;
open_ = true;
}
void close() {
open_ = false;
rows_.clear();
rows_.shrink_to_fit(); // nothing is kept while it's closed
}
bool isOpen() const { return open_; }
void onKey(const KeyEvent& e) {
int last = std::max(0, static_cast<int>(rows_.size()) - visible_);
switch (e.key) {
case Key::Up: top_ = std::max(0, top_ - 1); break;
case Key::Down: top_ = std::min(last, top_ + 1); break;
case Key::Left: top_ = std::max(0, top_ - visible_); break;
case Key::Right: top_ = std::min(last, top_ + visible_); break;
default: close(); break;
}
}
const std::string& title() const { return title_; }
const std::vector<KeyHelp>& rows() const { return rows_; }
int top() const { return top_; }
int visibleRows() const { return visible_; }
private:
std::string title_;
std::vector<KeyHelp> rows_;
int top_ = 0;
int visible_;
bool open_ = false;
};
} // namespace roro
+117
View File
@@ -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
+59
View File
@@ -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
+14
View File
@@ -99,6 +99,20 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
return; return;
} }
break; 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: default:
if (keys.fn) return; // other Fn combos are unassigned if (keys.fn) return; // other Fn combos are unassigned
break; break;
+1 -1
View File
@@ -23,7 +23,7 @@ struct RawKeys {
// Turns keyboard state changes into logical KeyEvents: only newly pressed keys produce events; // 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 + ; . , / 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 -> é). // letter (opt ' e -> é).
class KeyMapper { class KeyMapper {
public: public:
-6
View File
@@ -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); } 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? // Is `release` a newer one than what runs?
inline bool isNewer(const Release& release, const std::string& running) { inline bool isNewer(const Release& release, const std::string& running) {
return release.usable() && versionNewer(release.tag, running); return release.usable() && versionNewer(release.tag, running);
+5
View File
@@ -1,5 +1,6 @@
#include "settings.h" #include "settings.h"
#include "debug_auth.h"
#include "ipv4.h" #include "ipv4.h"
namespace roro { namespace roro {
@@ -41,6 +42,9 @@ const Definition kDefinitions[] = {
{"ntp2", Kind::String, 0, "time.cloudflare.com", 0, 63}, {"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) {"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) {"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), static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
"every Setting needs a definition"); "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::Dns2) return value.empty() || net::parseIpv4(value, address);
if (s == Setting::Ntp1) return net::validHost(value); if (s == Setting::Ntp1) return net::validHost(value);
if (s == Setting::Ntp2) return value.empty() || 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; return true;
} }
+3
View File
@@ -31,6 +31,9 @@ enum class Setting : uint8_t {
Ntp2, // string: the second, or empty Ntp2, // string: the second, or empty
GnssQuietForLora, // bool: put the GNSS receiver in standby while the LoRa radio listens (issue #20) 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) 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 Count
}; };
+15
View File
@@ -0,0 +1,15 @@
#include "version.h"
// Written by scripts/version.py before each build; not in git.
#if __has_include("version_generated.h")
#include "version_generated.h"
#endif
#ifndef RORO_VERSION
#define RORO_VERSION "unknown"
#endif
namespace roro {
const char* versionString() { return RORO_VERSION; }
} // namespace roro
+4 -6
View File
@@ -1,14 +1,12 @@
#pragma once #pragma once
#ifndef RORO_VERSION
#define RORO_VERSION "unknown"
#endif
namespace roro { namespace roro {
constexpr const char* kProductName = "roro9stack"; constexpr const char* kProductName = "roro9stack";
// "roro9stack v0.1.0" — used on the boot screen and in About. // "v0.1.0", from `git describe` (scripts/version.py): used on the boot screen and in About.
inline const char* versionString() { return RORO_VERSION; } // A function in one file, not a macro on every compiler command line: a new commit then recompiles
// that one file, and everything else comes from the build cache (issue #74).
const char* versionString();
} // namespace roro } // namespace roro
-9
View File
@@ -32,15 +32,6 @@ custom_sdkconfig =
CONFIG_MBEDTLS_DYNAMIC_FREE_CONFIG_DATA=y CONFIG_MBEDTLS_DYNAMIC_FREE_CONFIG_DATA=y
CONFIG_MBEDTLS_DYNAMIC_FREE_CA_CERT=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). ; Host-side unit tests for pure logic (no hardware).
[env:native] [env:native]
platform = native platform = native
+3 -1
View File
@@ -6,7 +6,9 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
[ -n "${RORO_NO_DOCKER:-}" ] || docker build -q -t "$IMAGE" "$ROOT/docker" >/dev/null [ -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" DEBUG_TOKEN_FILE="$HOME/.config/roro9stack/debug-token"
if [ ! -s "$DEBUG_TOKEN_FILE" ]; then if [ ! -s "$DEBUG_TOKEN_FILE" ]; then
mkdir -p "$(dirname "$DEBUG_TOKEN_FILE")" mkdir -p "$(dirname "$DEBUG_TOKEN_FILE")"
+2 -2
View File
@@ -7,8 +7,8 @@ DOCKER_EXTRA=()
case "${1:-all}" in case "${1:-all}" in
tests) STEPS='pio test -e native' ;; tests) STEPS='pio test -e native' ;;
builds) STEPS='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 -e cardputer-adv-debug' ;; all) STEPS='pio test -e native && pio run -e cardputer-adv' ;;
*) echo "Usage: scripts/ci.sh [tests|builds]" >&2; exit 1 ;; *) echo "Usage: scripts/ci.sh [tests|builds]" >&2; exit 1 ;;
esac esac
run_in_container bash -c 'git config --global --add safe.directory "$PWD" && '"$STEPS" run_in_container bash -c 'git config --global --add safe.directory "$PWD" && '"$STEPS"
-12
View File
@@ -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
View File
@@ -1,17 +1,21 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Flash the firmware over USB, then open the serial monitor. # Flash the firmware over USB, then open the serial monitor.
# Usage: scripts/flash.sh [--debug] [port] USB (default: the first Espressif device found) # 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 # scripts/flash.sh --ota [host] Wi-Fi: build, sign and push a Firmware Update
# (host: the device's IP from Settings > About, or $RORO_OTA_HOST) # (host: the device's IP from Settings > Firmware, or $RORO_OTA_HOST)
# --debug builds the Debug Build (cardputer-adv-debug): the Debug Console on TCP 2323, see ADR 0004. # --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 set -euo pipefail
ENV=cardputer-adv ENV=cardputer-adv
PROVISION=
if [ "${1:-}" = "--debug" ]; then if [ "${1:-}" = "--debug" ]; then
ENV=cardputer-adv-debug PROVISION=1
shift shift
fi fi
if [ "${1:-}" = "--ota" ]; then 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)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${2:-${RORO_OTA_HOST:-}}" HOST="${2:-${RORO_OTA_HOST:-}}"
[ -n "$HOST" ] || { echo "Usage: scripts/flash.sh --ota <device IP> (or set RORO_OTA_HOST)" >&2; exit 1; } [ -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=() DOCKER_EXTRA=()
run_in_container bash -c "git config --global --add safe.directory /work && pio run -e $ENV" | tail -3 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)" 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" OUT="$ROOT/.pio/build/$ENV/roro9stack-$VERSION.ota"
"$ROOT/scripts/make_ota.py" "$ROOT/.pio/build/$ENV/firmware.bin" "$VERSION" "$OUT" "$ROOT/scripts/make_ota.py" "$ROOT/.pio/build/$ENV/firmware.bin" "$VERSION" "$OUT"
exec "$ROOT/scripts/ota_push.py" "$OUT" "$HOST" 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 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) 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
View File
@@ -1,10 +1,12 @@
#!/usr/bin/env python3 #!/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 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 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) -b also print the backlog the device sends on connecting (boot messages and so on)
Commands handled here as well as on the device: 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 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 screenshot [file.png] what the screen shows (default screen-<date>.png), at 2x
reset restart at once, even if the main loop is stuck 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 hashlib
import hmac
import os import os
import re import re
import select import select
@@ -26,9 +30,53 @@ import zlib
import socket import socket
import sys import sys
import time import time
import urllib.request
PORT = 2323 PORT = 2323
TOKEN_FILE = os.path.expanduser("~/.config/roro9stack/debug-token") 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): def read_until(sock, marker, timeout):
@@ -102,17 +150,40 @@ def crash_firmware(reply):
return version.group(1) if version else None 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): def crash(sock):
reply = run(sock, "crash") reply = run(sock, "crash")
trace = re.search(r"backtrace((?: 0x[0-9a-f]+)+)", reply) trace = re.search(r"backtrace((?: 0x[0-9a-f]+)+)", reply)
key = crash_firmware(reply) key = crash_firmware(reply)
if trace and key: if trace and key:
have_elf(reply)
print() print()
subprocess.call([os.path.join(SCRIPTS, "decode_backtrace.sh"), key] + trace.group(1).split()) subprocess.call([os.path.join(SCRIPTS, "decode_backtrace.sh"), key] + trace.group(1).split())
def coredump(sock, path): def coredump(sock, path):
info = run(sock, "crash", out=None) info = run(sock, "crash", out=None)
have_elf(info)
sock.sendall(b"coredump get\n") sock.sendall(b"coredump get\n")
# One buffer throughout: the header, the size and the first bytes often share a packet. # One buffer throughout: the header, the size and the first bytes often share a packet.
buf = b"" buf = b""
@@ -237,25 +308,27 @@ def interactive(sock):
def main(): def main():
args = sys.argv[1:] 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("-"): while args and args[0].startswith("-"):
flag = args.pop(0) flag = args.pop(0)
if flag == "-H" and args: if flag == "-H" and args:
host = args.pop(0) host = args.pop(0)
elif flag in ("-t", "--token") and args:
token = args.pop(0)
elif flag == "-b": elif flag == "-b":
backlog = True backlog = True
else: else:
sys.exit(__doc__) sys.exit(__doc__)
if not host: if not host:
sys.exit("No device: pass -H <ip> or set RORO_OTA_HOST\n\n" + __doc__) 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: try:
sock.sendall((token + "\n").encode()) sock = socket.create_connection((host, PORT), timeout=10)
# The device may still be finishing a previous client: wait for this connection's banner. except OSError as e:
banner = read_until(sock, b"Backlog follows.\n", 15) sys.exit(f"device: can't connect to {host}:{PORT} ({e}). Is the console switched on (Settings > Debug Console)?")
if banner is None: with sock:
sys.exit("device: no banner (wrong token, or another client is connected)") banner = log_in(sock, token)
show = sys.stdout if backlog or not args else None show = sys.stdout if backlog or not args else None
if show: if show:
show.write(banner.decode(errors="replace")) show.write(banner.decode(errors="replace"))
+8 -3
View File
@@ -10,10 +10,15 @@ try:
except Exception: except Exception:
version = "unknown" version = "unknown"
if env["PIOENV"].endswith("-debug"): # noqa: F821 # The version goes into one generated header, read by one file (lib/version/src/version.cpp). As a -D
version += "+debug" # a Debug Build says so wherever the version shows # 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 # Keep every build's ELF, named by version and the first 16 hex digits of its SHA-256 (the core dump
+1 -1
View File
@@ -10,4 +10,4 @@ weight = 2
eyebrow = "Developer docs" 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/).
+3 -3
View File
@@ -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. `scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure.
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 4 minutes; later builds take under a minute. The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 6 minutes; later builds take about 15 seconds when little has changed. The platform knows the framework is already rebuilt by `sdkconfig.defaults` in the project folder, which it writes and git ignores: delete it and the next build rebuilds the framework. CI keeps that file, a build cache and ccache in its volume (docs/milestones/R1.md).
## CI and releases ## CI and releases
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>.ota`, the signed Update File;
- `roro9stack-<version>-factory.bin`, the whole flash image for a first install over USB; - `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; - `roro9stack-<version>.elf.gz`, to decode crash reports from that build;
- `SHA256SUMS`. - `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. `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.
+1 -1
View File
@@ -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. **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/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_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/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. `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> <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"/> <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="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"/> <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="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> <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

+4 -4
View File
@@ -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." 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" template = "guide-index.html"
page_template = "guide-page.html" page_template = "guide-page.html"
@@ -10,13 +10,13 @@ weight = 1
eyebrow = "Developer docs" 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; - **see everything the device prints**, boot messages included, without a cable;
- **run every serial command** from your desk; - **run every serial command** from your desk;
- **press keys** and **take screenshots**, so a UI change can be tested and looked at remotely; - **press keys** and **take screenshots**, so a UI change can be tested and looked at remotely;
- **copy files** to and from the SD card; - **copy files** to and from the SD card;
- **read crash reports and fetch core dumps**, decoded against the exact build that crashed; - **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.
+16 -17
View File
@@ -10,7 +10,7 @@ tag = "Reference"
+++ +++
## What `help` prints ## 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 info firmware, uptime, memory, Wi-Fi, app slots
@@ -28,7 +28,7 @@ gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it cos
gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum> 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 crash the last crash: firmware, reason, task, backtrace
coredump erase forget the core dump in flash 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 status | wifi add <ssid><TAB><password>
wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting 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 wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers
@@ -37,11 +37,8 @@ irc start | irc stop | irc dump | irc say <buffer> <text>
install <path.ota> Update from SD install <path.ota> Update from SD
update check | list | status | install <tag> the project's releases on Gitea update check | list | status | install <tag> the project's releases on Gitea
sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal
``` 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
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):
```
crash abort|wdt crash on purpose (to test crash reports and Safe Mode) crash abort|wdt crash on purpose (to test crash reports and Safe Mode)
wifi ip ... try <seconds> | wifi ip keep a trial IP setting: back to the previous one unless kept 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 loop spin on|off make the main loop spin without resting, to compare load and radio noise
@@ -54,7 +51,7 @@ get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) bina
quit close the Debug Console connection 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 ## What they do
@@ -63,16 +60,16 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| Command | Effect | | Command | Effect |
|---|---| |---|---|
| `burst` | Publishes 5 Notifications at once | | `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) | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `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 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 | | `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`) | | `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 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 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 | | `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 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 | | `irc stop` | Stops it, as `/quit` does: QUIT if connected, no more retries, and the App stays disconnected until you type |
@@ -89,8 +86,8 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path | | `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path |
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (a folder with what's in it), new folder. `-f` replaces a file that's in the way; a tab separates paths that hold spaces; `cancel` stops a copy or a delete | | `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 |
| `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does | | `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does |
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one (not on a Debug Build) | | `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` | 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 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 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 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 | | `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
@@ -102,12 +99,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 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 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) | | `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 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]` | Debug Builds: a packet into the Scanner as if received (nothing is sent) | | `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) | | `crash` | The last crash: which firmware, why, task, PC and backtrace (from the core dump in flash) |
| `coredump erase` | Forgets the core dump | | `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 | | `loop spin on` / `loop spin off` | 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 | | `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 | | `help` | Lists the commands |
`scripts/flash.sh` stops a running serial log first, since it would hold the port. `scripts/flash.sh` stops a running serial log first, since it would hold the port.
+25 -16
View File
@@ -1,6 +1,6 @@
+++ +++
title = "The Debug Console" 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 weight = 2
[extra] [extra]
tag = "Console" 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 -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 ## What you get
On connecting, in order: On connecting, in order:
1. a **banner**: `roro9stack v0.11.0-3-g12006bd+debug debug console. 'help' lists the commands. Backlog follows.` 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, **boot messages included** (a ring buffer in RAM); 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); 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. 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 | | Step | Detail |
|---|---| |---|---|
| Connect | TCP 2323. **One client at a time**: a second one waits until the first leaves | | 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 | | Challenge | The device sends one line: `roro9stack debug console, challenge <32 hex digits>`. Those are 16 random bytes, new each time |
| 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 | | 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` | | 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 | | 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/)) | | 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. 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 ## How commands run
@@ -69,22 +74,26 @@ A command that never runs has not been dropped by the network: the main loop is
## Security ## Security
- The token is checked **before anything else**, and a wrong one costs a second. - **Off by default.** Nothing listens until the console is switched on at the device, or over USB.
- 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 login is a **challenge and an answer**: the token itself is never sent, and five wrong answers close the console for a minute.
- The stream is **plain text**: fine on a home network, not across the internet. Do not forward port 2323. - 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.
- **Release builds have no console at all.** Nothing listens. - 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` ## 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 ```python
import socket import hashlib, hmac, socket
token = "K7QF3M2X9WBDHT4P6RNC" # tidied: capitals, no dashes
s = socket.create_connection(("10.39.39.12", 2323)) s = socket.create_connection(("10.39.39.12", 2323))
s.sendall(b"<token>\n") # then read the banner line challenge = s.makefile().readline().split()[-1] # "... challenge 0f1e2d..."
s.sendall(b"info\n") # read until the stream goes quiet 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. `scripts/rdbg.py` adds the parts that need work on the PC side: decoding crash backtraces, saving files and screenshots, and checking checksums.
+7 -8
View File
@@ -6,7 +6,7 @@ weight = 5
tag = "Console" 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 ## What a crash leaves behind
@@ -18,7 +18,7 @@ The `crash` command prints it again whenever you like:
``` ```
> crash > 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: task loopTask, pc 0x4037e179, cause 0, address 0x00000000
crash: reason: abort() was called at PC 0x421209b3 on core 1 crash: reason: abort() was called at PC 0x421209b3 on core 1
crash: backtrace 0x4037e179 0x4037e141 0x4038582d 0x421209b3 ... 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. - **`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**. - **`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. - 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 ## 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: 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**; - 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`. - 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. **Its limit:** if Wi-Fi or the Update Service is what crashes, Safe Mode cannot help, and it takes USB.
## Crash on purpose ## Crash on purpose
On a Debug Build:
``` ```
crash abort # abort(): a panic with a core dump crash abort # abort(): a panic with a core dump
crash wdt # hang the main loop until the task watchdog fires 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.
-56
View File
@@ -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/).
+10 -9
View File
@@ -11,7 +11,7 @@ Everything the keyboard can do, a command can do, and everything on the screen c
## Keys ## 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 key a # any single character: it is typed
``` ```
@@ -22,6 +22,8 @@ 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. `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.
## Look before you press ## Look before you press
**Take a screenshot before any key that deletes, renames or installs.** A blind sequence of `key` commands goes wrong the moment the screen is not where you think it is, and the screen is often not where you think it is: a Toast, a dialog that has not closed, a different App. A sequence that was meant to open a note once renamed real data instead. **Take a screenshot before any key that deletes, renames or installs.** A blind sequence of `key` commands goes wrong the moment the screen is not where you think it is, and the screen is often not where you think it is: a Toast, a dialog that has not closed, a different App. A sequence that was meant to open a note once renamed real data instead.
@@ -59,12 +61,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 | | `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 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 | | `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 | | `wifi add <ssid><TAB><password>` | Adds a Saved Network, so credentials stay out of the repository |
## Test the update path ## 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 update status # what the device runs, what failed here before, the daily check, heap
@@ -72,15 +74,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 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 cut 50000 # the next download is cut after 50000 bytes
update damage flip 100000 # ...or has the byte at offset 100000 damaged 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 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 daily # forget today's daily check: it runs again at the next tick
update pretend off # back to the real version 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`. - **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. - 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/). For crashes during Probation, see [Crashes and Safe Mode](/dev/debug/crashes/).
@@ -94,7 +95,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 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 ## Measure
@@ -104,7 +105,7 @@ tasks # each task over the next second: state, priority, least free stack,
net # bytes each network service has read and written since start 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 task st pri stack cpu% core
@@ -120,4 +121,4 @@ The [System App](/guide/system/) shows the same, live, on the device.
## Radio experiments ## 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/).
+77
View File
@@ -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" 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 weight = 4
[extra] [extra]
@@ -8,6 +8,8 @@ docs = true
source = "docs/adr/0004-debug-console-in-debug-builds.md" source = "docs/adr/0004-debug-console-in-debug-builds.md"
tag = "ADR 0004" 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. 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. 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" source = "docs/adr/0005-safe-mode-crash-reports-watchdog.md"
tag = "ADR 0005" 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. - **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. 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). - **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. - **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 ## 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.
+110 -2
View File
@@ -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.) | | 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). | | 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. | | 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`. | | 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. | | 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. | | 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. | | 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. | | 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.) | | 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). | | 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. | | 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. - **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. **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.
+54
View File
@@ -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

+4
View File
@@ -6,6 +6,10 @@ template = "guide-page.html"
toc = true 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? ## 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. **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.
+1 -1
View File
@@ -6,7 +6,7 @@ sort_by = "weight"
page_template = "guide-page.html" 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. **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.
+18 -2
View File
@@ -6,6 +6,14 @@ weight = 1
tag = "Start here" 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 keys
The Cardputer's keyboard has no arrow keys and no Escape, so the firmware gives a few keys a second job: 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> | **Home**: back to the Launcher |
| <kbd>Fn</kbd> + <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> | The arrows: up, down, left, right | | <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>;</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>Tab</kbd> | Switches view in an App that has more than one |
| <kbd>Del</kbd> | Deletes backwards when you type | | <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 é | | <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 ## 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) | | `97%` | The battery (in the warning colour at 15% and under) |
| `SD` | A card is in; the warning colour at 80% full | | `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 | | 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 | | `REC` | A GNSS Track is being recorded |
| `CAP` | A LoRa capture 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 | | `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 ## 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. 1. **Long name**, up to 39 bytes.
2. **Short name**, up to 4 characters. 2. **Short name**, up to 4 characters.
@@ -61,3 +71,9 @@ On a new device a short Setup asks four things, then never appears again:
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 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`, `updates`, `wifi`, plus `notes`). You can use the card in a computer too, but those names are the firmware's.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["launcher", "everywhere", "dialog", "text"]) }}
+6
View File
@@ -40,3 +40,9 @@ Most capsules sign their own certificate. The first certificate seen for a host
## Big pages and memory ## Big pages and memory
With a card, every page streams to the card first, so a page larger than the device's memory still arrives whole and is read from the card as you scroll. Without a card, a page is limited to what fits in memory. If there is not enough free memory to open a secure connection, the fetch says so instead of failing silently; stopping IRC frees the most. With a card, every page streams to the card first, so a page larger than the device's memory still arrives whole and is read from the card as you scroll. Without a card, a page is limited to what fits in memory. If there is not enough free memory to open a secure connection, the fetch says so instead of failing silently; stopping IRC frees the most.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["gemini", "gemini-saved", "gemini-address", "gemini-answer"]) }}
+6
View File
@@ -28,3 +28,9 @@ Once there is a fix, the device's clock follows it.
## Tracks ## Tracks
<kbd>r</kbd> starts recording a **Track**: the route is saved as a **GPX** file on the SD card, in `/gnss/tracks` (named by date and time), and the Status Bar shows `REC`. Press <kbd>r</kbd> again to stop. A Track keeps recording with the App closed. It needs a card and a clock (a fix or Wi-Fi sets it); if it cannot start, the App says why: `GNSS is off`, `No SD card` or `Waiting for the time`. The [Storage App](/guide/storage/) opens a `.gpx` file and shows its points, start, duration and distance. <kbd>r</kbd> starts recording a **Track**: the route is saved as a **GPX** file on the SD card, in `/gnss/tracks` (named by date and time), and the Status Bar shows `REC`. Press <kbd>r</kbd> again to stop. A Track keeps recording with the App closed. It needs a card and a clock (a fix or Wi-Fi sets it); if it cannot start, the App says why: `GNSS is off`, `No SD card` or `Waiting for the time`. The [Storage App](/guide/storage/) opens a `.gpx` file and shows its points, start, duration and distance.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["gnss"]) }}
+6
View File
@@ -49,3 +49,9 @@ Every buffer is logged to the SD card, one file per day, under `/irc`. Logs stop
- IRC **pauses** while Wi-Fi is monitoring (the Status Bar shows `MON`) and while the device **installs an update**. It reconnects and rejoins its channels afterwards; the App says `paused` in the meantime. - IRC **pauses** while Wi-Fi is monitoring (the Status Bar shows `MON`) and while the device **installs an update**. It reconnects and rejoins its channels afterwards; the App says `paused` in the meantime.
- A secure connection costs memory: IRC's takes about 40 KB of the 107 KB the device has, and an update's download needs about 52 KB more. That is why IRC steps aside for an update, and why the daily update check waits for IRC to be disconnected (see [Updates](/guide/updates/)). - A secure connection costs memory: IRC's takes about 40 KB of the 107 KB the device has, and an update's download needs about 52 KB more. That is why IRC steps aside for an update, and why the daily update check waits for IRC to be disconnected (see [Updates](/guide/updates/)).
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["irc", "irc-settings", "irc-field"]) }}
+13
View File
@@ -0,0 +1,13 @@
+++
title = "Every key"
description = "The keys of every screen of the firmware, as the help panel lists them on the device: one table for each screen and state."
weight = 12
[extra]
tag = "Reference"
+++
On the device, <kbd>Fn</kbd> + <kbd>h</kbd> lists the keys of the screen you are on (and <kbd>?</kbd> does, when you are not typing). This page is all of those lists at once, generated from the same source the firmware reads: `lib/core/src/app_keys.h`.
In the tables, `; . , /` are the arrow keys (up, down, left, right): alone when you are not typing, with <kbd>Fn</kbd> when you are. `` ` `` is Back, `Aa` is Shift, and two keys separated by spaces are two keys that do the two things listed.
{{ keys(all=true) }}
+6
View File
@@ -33,3 +33,9 @@ A capture records packets into a **pcap** file with LoRaTap headers in `/capture
## The GNSS receiver raises the noise ## The GNSS receiver raises the noise
The GNSS receiver on the same Cap makes the radio's noise floor about **8 dB** worse while it runs. **Settings → Pause GNSS for LoRa** (off by default) puts the receiver on standby while the radio listens, except while a Track is being recorded. The GNSS receiver on the same Cap makes the radio's noise floor about **8 dB** worse while it runs. **Settings → Pause GNSS for LoRa** (off by default) puts the receiver on standby while the radio listens, except while a Track is being recorded.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["lora", "lora-packet", "lora-presets", "lora-sweep"]) }}
+6
View File
@@ -35,3 +35,9 @@ A new note has no file until you type something. The file is then named after it
## Limits ## Limits
A note holds up to **16 KB** while it is edited. A bigger text file opens read-only in the [Storage App](/guide/storage/); editing a file of any size is planned. In Storage, <kbd>e</kbd> on a text file opens it in the same editor, anywhere on the card, unless the file is read-only. Notes are never offered for deletion by the clean-up. A note holds up to **16 KB** while it is edited. A bigger text file opens read-only in the [Storage App](/guide/storage/); editing a file of any size is planned. In Storage, <kbd>e</kbd> on a text file opens it in the same editor, anywhere on the card, unless the file is read-only. Notes are never offered for deletion by the clean-up.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["notes", "notes-editor", "notes-name"]) }}
+7
View File
@@ -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 | | **Wi-Fi** | The page below |
| **Check for updates** | Once a day, see [Updates](/guide/updates/) | | **Check for updates** | Once a day, see [Updates](/guide/updates/) |
| **Firmware** | The page described in [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 | | **About** | The firmware version, the node number, battery, memory, uptime, clock and licence |
## Wi-Fi ## Wi-Fi
@@ -40,3 +41,9 @@ A network normally gives the device its address by itself (DHCP, **Automatic**).
### DNS and NTP ### DNS and NTP
Two DNS servers (9.9.9.9 and 1.1.1.1 by default), used on Fixed networks, or on every network if **Always use my DNS** is on; and two NTP servers (pool.ntp.org and time.cloudflare.com), used after any that the network's DHCP offers. <kbd>Enter</kbd> on **Status** shows what is in use and where each value came from. Two DNS servers (9.9.9.9 and 1.1.1.1 by default), used on Fixed networks, or on every network if **Always use my DNS** is on; and two NTP servers (pool.ntp.org and time.cloudflare.com), used after any that the network's DHCP offers. <kbd>Enter</kbd> on **Status** shows what is in use and where each value came from.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["settings", "settings-choice", "wifi", "wifi-servers", "wifi-network", "wifi-status", "wifi-scan", "wifi-name", "debug-console"]) }}
+7 -1
View File
@@ -13,7 +13,7 @@ Storage shows what is on the SD card: each folder's entries with their size and
| Key | Does | | 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>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>r</kbd> | Renames |
| <kbd>d</kbd> | Deletes, after saying what is inside: "Delete saved and its 42 files (1.2 MB)?" | | <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. At the top of the card, the last row, **Maintenance** (or <kbd>m</kbd>), shows the card's usage and holds **Storage clean-up** and **Erase SD card**. They delete for good, so they sit behind a warning. Clean-up deletes old logs and captures by category and age, showing the space it would free first. Notes and Saved Pages are never offered.
The firmware warns once per start when the card passes **80%** full; past **90%**, logs stop being written so that the rest is kept for captures. The firmware warns once per start when the card passes **80%** full; past **90%**, logs stop being written so that the rest is kept for captures.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["storage", "storage-details", "storage-name", "storage-busy", "maintenance", "viewer-text", "viewer-hex", "viewer-pcap", "viewer-packet", "viewer-gpx", "viewer-ota"]) }}
+6
View File
@@ -18,3 +18,9 @@ System changes nothing: it shows. It samples once a second and keeps its history
## Why it exists ## Why it exists
The device has about 107 KB of free memory and no PSRAM, so memory is the resource that decides what can run together: a secure connection takes about 52 KB at its peak. System makes that visible, and is how the project measures its own changes. The device has about 107 KB of free memory and no PSRAM, so memory is the resource that decides what can run together: a secure connection takes about 52 KB at its peak. System makes that visible, and is how the project measures its own changes.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["system", "system-tasks", "system-system"]) }}
+7 -1
View File
@@ -41,4 +41,10 @@ The connection to the project's server is checked against the two root certifica
## For developers ## For developers
Updates can also be pushed from a PC over Wi-Fi, with `scripts/flash.sh --ota <ip>`, or put on the card with `scripts/sd_put.sh`: see the [README](https://git.twis.la/twisla/roro9stack#firmware-updates-over-wi-fi-ota). **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"]) }}
+6
View File
@@ -34,3 +34,9 @@ One bar for each of the 13 Wi-Fi channels shows how busy it is: how many network
## Signal tracker ## Signal tracker
Follows one network: its name, address and channel, then the signal in dBm in large type, a strength bar, and a graph of the recent readings, so you can walk towards the strongest signal. <kbd>m</kbd> turns audible clicks on and off. If the network is no longer heard the screen says `lost`. <kbd>`</kbd> (Back) returns to the list. Follows one network: its name, address and channel, then the signal in dBm in large type, a strength bar, and a graph of the recent readings, so you can walk towards the strongest signal. <kbd>m</kbd> turns audible clicks on and off. If the network is no longer heard the screen says `lost`. <kbd>`</kbd> (Back) returns to the list.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["wifi-tools", "wifi-networks", "wifi-tracker"]) }}
+516
View File
@@ -0,0 +1,516 @@
# 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 = "system"
title = "System, any view"
rows = [
["Tab", "the next view"],
["Aa Tab", "the view before"],
]
[[scope]]
id = "system-tasks"
title = "System, the tasks"
rows = [
["Tab", "the next view"],
["Aa Tab", "the view before"],
["; .", "scroll"],
["s", "sort: cpu, stack, name"],
]
[[scope]]
id = "system-system"
title = "System, the system view"
rows = [
["Tab", "the next view"],
["Aa Tab", "the view before"],
["; .", "scroll"],
]
[[scope]]
id = "settings"
title = "Settings"
rows = [
["; .", "up, down"],
["Enter", "edit, or open the page"],
[", /", "change a switch or a slider"],
]
[[scope]]
id = "settings-choice"
title = "Settings, a choice"
rows = [
["; .", "up, down"],
["Enter", "choose it"],
]
[[scope]]
id = "wifi"
title = "Settings, Wi-Fi"
rows = [
["; .", "up, down"],
["Enter", "open, or change"],
[", /", "switch Wi-Fi on or off"],
]
[[scope]]
id = "wifi-servers"
title = "Settings, DNS and NTP"
rows = [
["; .", "up, down"],
["Enter", "edit"],
[", /", "Always use my DNS: on, off"],
]
[[scope]]
id = "wifi-network"
title = "Settings, a saved network"
rows = [
["; .", "up, down"],
["Enter", "edit, or forget"],
[", /", "Automatic or Fixed"],
]
[[scope]]
id = "wifi-status"
title = "Settings, the Wi-Fi status"
rows = [
["Enter", "back"],
]
[[scope]]
id = "wifi-scan"
title = "Settings, adding a network"
rows = [
["; .", "up, down"],
["Enter", "choose this network"],
]
[[scope]]
id = "wifi-name"
title = "Settings, a hidden network's name"
rows = [
["Enter", "next: the password"],
["`", "cancel"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
]
[[scope]]
id = "firmware"
title = "Settings, Firmware"
rows = [
["; .", "up, down"],
["Enter", "check, open, or install"],
["c", "look for a newer release"],
]
[[scope]]
id = "firmware-release"
title = "Settings, a release"
rows = [
["; .", "scroll"],
["Enter", "install it"],
["c", "check again"],
]
[[scope]]
id = "firmware-older"
title = "Settings, older releases"
rows = [
["; .", "up, down"],
["Enter", "its details"],
["c", "read the list again"],
]
[[scope]]
id = "debug-console"
title = "Settings, Debug Console"
rows = [
["; .", "up, down"],
["Enter", "switch, or open"],
[", /", "switch the console on or off"],
]
[[scope]]
id = "demo"
title = "The widget demo"
rows = [
["; .", "up, down"],
["Enter", "try the widget"],
]
+9
View File
@@ -254,3 +254,12 @@ footer small { display: block; max-width: 760px; }
.source { max-width: 820px; font-size: 14px; line-height: 20px; } .source { max-width: 820px; font-size: 14px; line-height: 20px; }
.source code { background: var(--s2); padding: 0 6px; } .source code { background: var(--s2); padding: 0 6px; }
.card.featured { flex-basis: 100%; } .card.featured { flex-basis: 100%; }
/* The keys of a screen, as the device's help panel lists them (the `keys` shortcode) */
.keys { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px 32px; }
.keys-scope { background: var(--s2); padding: 16px 20px; }
.keys-scope h4 { font: 500 12px/16px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--cyantext); margin: 0 0 8px; }
.prose .keys table { width: 100%; }
.prose .keys td { padding: 4px 8px 4px 0; border-bottom: 0; font-size: 15px; }
.prose .keys td:first-child { white-space: nowrap; width: 1%; padding-right: 16px; }
.prose .keys kbd { white-space: pre; }
+1 -1
View File
@@ -11,7 +11,7 @@
</header> </header>
<div class="prose"> <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> </div>
{% if releases %} {% if releases %}
+18
View File
@@ -0,0 +1,18 @@
{#- The keys of one or more screens, as the help panel (Fn+h) lists them on the device:
{{ keys(scopes=["storage", "storage-details"]) }}, or {{ keys(all=true) }} for every screen.
The tables come from site/data/keys.toml, generated from lib/core/src/app_keys.h (issue #72):
a key added to an App shows up here without anyone editing a page. -#}
{%- set data = load_data(path="data/keys.toml", format="toml") -%}
{%- set everything = all is defined and all -%}
<div class="keys">
{%- for s in data.scope %}{% if everything or (scopes is defined and s.id in scopes) %}
<div class="keys-scope">
<h4 id="keys-{{ s.id }}">{{ s.title }}</h4>
<table>
{%- for r in s.rows %}
<tr><td><kbd>{{ r.0 }}</kbd></td><td>{{ r.1 }}</td></tr>
{%- endfor %}
</table>
</div>
{%- endif %}{% endfor %}
</div>
+57 -8
View File
@@ -9,6 +9,8 @@ Zola cannot read a file outside its own folder, so the pages are generated and c
milestones/ one page for each docs/milestones/*.md milestones/ one page for each docs/milestones/*.md
build/build-and-test, build/flash sections of README.md build/build-and-test, build/flash sections of README.md
debug/commands the commands the firmware's `help` prints (parsed from src/main.cpp), then README's table of them debug/commands the commands the firmware's `help` prints (parsed from src/main.cpp), then README's table of them
site/data/keys.toml every screen's keys, from lib/core/src/app_keys.h: what the help panel (Fn+h) shows on the
device, for the `keys` shortcode of the user guide (issue #72)
Every generated page says where it comes from; edit that file, not the page. Hand-written pages sit next to them. Every generated page says where it comes from; edit that file, not the page. Hand-written pages sit next to them.
""" """
import json import json
@@ -23,7 +25,7 @@ REPO = SITE.parent
OUT = SITE / "content" / "dev" OUT = SITE / "content" / "dev"
REPO_URL = re.search(r'repo\s*=\s*"([^"]+)"', (SITE / "config.toml").read_text()).group(1) 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. # 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. # They stay in the repository.
@@ -139,8 +141,38 @@ def safe_mode_commands():
return exact, prefix return exact, prefix
def key_tables():
"""Every table of lib/core/src/app_keys.h: [(id, title, [(keys, action), ...])], in the file's order."""
src = (REPO / "lib" / "core" / "src" / "app_keys.h").read_text()
def text(literal): # a C string literal's contents: \xHH bytes are UTF-8
raw = re.sub(r"\\x([0-9A-Fa-f]{2})", lambda m: chr(int(m.group(1), 16)), literal).replace('\\"', '"')
return raw.encode("latin-1").decode("utf-8")
tables = []
for m in re.finditer(r"// ([a-z0-9-]+): ([^\n]+)\ninline constexpr KeyHelp k\w+\[\] = \{\n(.*?)\n\};", src, re.S):
rows = [(text(k), text(a)) for k, a in re.findall(r'\{"((?:[^"\\]|\\.)*)", "((?:[^"\\]|\\.)*)"\},', m.group(3))]
if len(rows) != len([l for l in m.group(3).splitlines() if l.strip()]):
sys.exit(f"gen_dev_docs: a row of `{m.group(1)}` in app_keys.h isn't in the form {{\"keys\", \"action\"}},")
tables.append((m.group(1), m.group(2).strip(), rows))
declared = len(re.findall(r"^inline constexpr KeyHelp k\w+\[\]", src, re.M))
if len(tables) != declared or len({t[0] for t in tables}) != len(tables):
sys.exit(f"gen_dev_docs: app_keys.h has {declared} tables, {len(tables)} with an `// id: Title` comment and a unique id")
return tables
def keys_toml():
out = ["# Generated by site/tools/gen_dev_docs.py from lib/core/src/app_keys.h: the keys of every screen, as the",
"# help panel (Fn+h) lists them on the device. Edit that header, not this file.", ""]
for ident, title, rows in key_tables():
out += ["[[scope]]", f"id = {json.dumps(ident)}", f"title = {json.dumps(title, ensure_ascii=False)}", "rows = ["]
out += [f" [{json.dumps(k, ensure_ascii=False)}, {json.dumps(a, ensure_ascii=False)}]," for k, a in rows]
out += ["]", ""]
return "\n".join(out)
def build(): def build():
pages = {} pages = {"../../data/keys.toml": keys_toml()}
for path in sorted((REPO / "docs" / "adr").glob("*.md")): for path in sorted((REPO / "docs" / "adr").glob("*.md")):
title, body = title_and_body(path.read_text()) title, body = title_and_body(path.read_text())
@@ -164,23 +196,40 @@ def build():
+ relink("\n".join(readme[k] for k in ("Flash", "Firmware Updates over Wi-Fi (OTA)")), "README.md")) + relink("\n".join(readme[k] for k in ("Flash", "Firmware Updates over Wi-Fi (OTA)")), "README.md"))
common, debug = help_text() 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() 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) 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" dev = re.sub(r"^## Development aids\n", "", dev).strip() + "\n"
pages["debug/commands.md"] = ( 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") 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" + "## 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" + "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"
+ "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"
+ 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" + 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) + "## What they do\n\n" + dev)
return pages return pages
def unknown_scopes():
"""Scopes a page asks the `keys` shortcode for that app_keys.h doesn't have."""
known = {t[0] for t in key_tables()}
bad = []
for path in sorted((SITE / "content").rglob("*.md")):
for call in re.findall(r"keys\(scopes=\[([^\]]*)\]", path.read_text()):
for ident in re.findall(r'"([^"]+)"', call):
if ident not in known:
bad.append(f"{path.relative_to(SITE)}: no key table `{ident}` in lib/core/src/app_keys.h")
return bad
def main(): def main():
check = "--check" in sys.argv check = "--check" in sys.argv
pages = build() pages = build()
for problem in unknown_scopes():
print("gen_dev_docs:", problem)
if unknown_scopes():
sys.exit(1)
stale = [] stale = []
for rel, text in sorted(pages.items()): for rel, text in sorted(pages.items()):
path = OUT / rel path = OUT / rel
@@ -192,10 +241,10 @@ def main():
path.write_text(text) path.write_text(text)
if check: if check:
for rel in stale: for rel in stale:
print(f"gen_dev_docs: content/dev/{rel} is out of date: run site/tools/gen_dev_docs.py and commit the result") print(f"gen_dev_docs: {os.path.normpath(os.path.join('site/content/dev', rel))} is out of date: run site/tools/gen_dev_docs.py and commit the result")
print(f"gen_dev_docs: {len(pages)} pages, {len(stale)} out of date") print(f"gen_dev_docs: {len(pages)} files, {len(stale)} out of date")
sys.exit(1 if stale else 0) sys.exit(1 if stale else 0)
print(f"gen_dev_docs: {len(pages)} pages, {len(stale)} written") print(f"gen_dev_docs: {len(pages)} files, {len(stale)} written")
if __name__ == "__main__": if __name__ == "__main__":
+149
View File
@@ -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
+46
View File
@@ -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
+8 -1
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "demo_app.h" #include "demo_app.h"
#include "ui/widgets.h" #include "ui/widgets.h"
@@ -25,6 +26,12 @@ void DemoApp::notify(const char* text, NotificationLevel level) {
bus_.publish(Event::withText(EventType::Notification, text, static_cast<int32_t>(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) { bool DemoApp::onKey(const KeyEvent& e) {
requestRedraw(); requestRedraw();
if (dialog_) { if (dialog_) {
@@ -97,7 +104,7 @@ void DemoApp::draw(Canvas& c) {
} }
case Page::Editor: case Page::Editor:
c.setTextColor(theme::kMuted); 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}); widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
c.setTextColor(theme::kMuted); c.setTextColor(theme::kMuted);
c.drawString((std::to_string(editor_.text().size()) + "/39 bytes").c_str(), 4, area.y + 44); c.drawString((std::to_string(editor_.text().size()) + "/39 bytes").c_str(), 4, area.y + 44);
+1
View File
@@ -21,6 +21,7 @@ class DemoApp : public App {
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
bool textEntryActive() const override { return page_ == Page::Editor && !dialog_; } bool textEntryActive() const override { return page_ == Page::Editor && !dialog_; }
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
private: private:
enum class Page { Menu, Text, Editor }; enum class Page { Menu, Text, Editor };
+15 -7
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "file_viewer.h" #include "file_viewer.h"
#include <Arduino.h> #include <Arduino.h>
@@ -265,6 +266,18 @@ void FileViewer::openPacket(int index) {
mode_ = Mode::Packet; 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) { bool FileViewer::onKey(const KeyEvent& e) {
if (confirm_) { if (confirm_) {
confirm_->onKey(e); confirm_->onKey(e);
@@ -380,7 +393,7 @@ void FileViewer::draw(Canvas& c) {
refresh(); refresh();
c.setTextDatum(top_left); c.setTextDatum(top_left);
theme::Rect body{area.x, area.y + 10, area.w, kRows * theme::kLineHeight}; 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; bool scanning = scan_ && !scan_->done;
switch (mode_) { switch (mode_) {
@@ -396,8 +409,6 @@ void FileViewer::draw(Canvas& c) {
c.fillRect(area.w - 2, barY, 2, barH, theme::kMuted); c.fillRect(area.w - 2, barY, 2, barH, theme::kMuted);
} }
right = formatBytes(size_); 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; break;
} }
case Mode::Gpx: { case Mode::Gpx: {
@@ -407,7 +418,6 @@ void FileViewer::draw(Canvas& c) {
else if (scan_) lines = scan_->gpx.lines(); else if (scan_) lines = scan_->gpx.lines();
widgets::textLines(c, lines, 0, body); widgets::textLines(c, lines, 0, body);
right = formatBytes(size_); right = formatBytes(size_);
keys = "Tab: the file as text";
break; break;
} }
case Mode::Pcap: { case Mode::Pcap: {
@@ -423,7 +433,6 @@ void FileViewer::draw(Canvas& c) {
return row < shown_.size() ? shown_[row] : std::string(); return row < shown_.size() ? shown_[row] : std::string();
}); });
right = std::to_string(scan_->packets.size()) + (scan_->morePackets ? "+ packets" : " packets"); right = std::to_string(scan_->packets.size()) + (scan_->morePackets ? "+ packets" : " packets");
keys = "Enter: the packet Tab: hex";
} }
break; break;
} }
@@ -447,7 +456,6 @@ void FileViewer::draw(Canvas& c) {
} }
widgets::textLines(c, lines, 0, body); widgets::textLines(c, lines, 0, body);
right = formatBytes(size_); right = formatBytes(size_);
keys = ok ? "Enter: install Tab: hex" : "Tab: hex";
if (confirm_) { if (confirm_) {
widgets::dialog(c, "Install update?", "The device restarts into " + scan_->version + " once it's written.", *confirm_); widgets::dialog(c, "Install update?", "The device restarts into " + scan_->version + " once it's written.", *confirm_);
return; return;
@@ -464,7 +472,7 @@ void FileViewer::draw(Canvas& c) {
c.drawString(right.c_str(), area.w - 3, area.y + 1); c.drawString(right.c_str(), area.w - 3, area.y + 1);
c.setTextDatum(top_left); c.setTextDatum(top_left);
if (!message_.empty()) c.setTextColor(theme::kWarning); 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 } // namespace roro
+2
View File
@@ -7,6 +7,7 @@
#include <vector> #include <vector>
#include "dialog_model.h" #include "dialog_model.h"
#include "key_help.h"
#include "key_event.h" #include "key_event.h"
#include "list_model.h" #include "list_model.h"
#include "services/storage_service.h" #include "services/storage_service.h"
@@ -27,6 +28,7 @@ class FileViewer {
void open(const std::string& path, uint32_t size); void open(const std::string& path, uint32_t size);
void close(); void close();
bool onKey(const KeyEvent& e); // false: leave the viewer bool onKey(const KeyEvent& e); // false: leave the viewer
void help(std::vector<KeyHelp>& out) const;
bool update(uint32_t nowMs); // true: draw again bool update(uint32_t nowMs); // true: draw again
void draw(Canvas& c); void draw(Canvas& c);
+17 -7
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "firmware_page.h" #include "firmware_page.h"
#include <SD.h> #include <SD.h>
@@ -63,9 +64,9 @@ std::string FirmwarePage::latestText(bool& warn) const {
release::Release r; release::Release r;
if (g.status() == GiteaReleases::Status::Failed && !g.latest(r)) { if (g.status() == GiteaReleases::Status::Failed && !g.latest(r)) {
warn = true; 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)"); 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(); std::string running = update_.runningVersion();
if (!shown_.usable()) why = "Nothing to install in it"; 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::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 if (wifi_.state() != WifiController::State::Connected) why = "Not connected to Wi-Fi";
else return true; else return true;
return false; return false;
@@ -203,7 +203,7 @@ void FirmwarePage::drawRelease(Canvas& c) {
c.setFont(&fonts::small); c.setFont(&fonts::small);
c.setTextDatum(top_left); c.setTextDatum(top_left);
c.setTextColor(theme::kMuted); 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) { 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)" : ""); return r.tag + (r.tag == running ? " (running)" : release::versionNewer(r.tag, running) ? " (new)" : "");
}, },
[&](int i) { return olderReleases_[i].date(); }); [&](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) { void FirmwarePage::drawMain(Canvas& c) {
+3
View File
@@ -7,6 +7,7 @@
#include "dialog_model.h" #include "dialog_model.h"
#include "key_event.h" #include "key_event.h"
#include "key_help.h"
#include "list_model.h" #include "list_model.h"
#include "release_info.h" #include "release_info.h"
#include "services/storage_service.h" #include "services/storage_service.h"
@@ -28,6 +29,8 @@ class FirmwarePage {
void enter(); void enter();
bool onKey(const KeyEvent& e); // false: leave the page bool onKey(const KeyEvent& e); // false: leave the page
void draw(Canvas& c); void draw(Canvas& c);
void help(std::vector<KeyHelp>& out) const;
const char* helpTitle() const;
private: private:
enum Row { kVersion, kStatus, kAddress, kLatest, kOlder, kSdHeader, kFixed }; enum Row { kVersion, kStatus, kAddress, kLatest, kOlder, kSdHeader, kFixed };
+17 -4
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "gemini_app.h" #include "gemini_app.h"
#include <Arduino.h> #include <Arduino.h>
@@ -171,7 +172,7 @@ void GeminiApp::update(uint32_t nowMs) {
else { else {
std::string mime = result.header.mimeType(); std::string mime = result.header.mimeType();
showMessage(url, "# Not a text page\nThis is " + mime + 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"); "/gemini/downloads.\n");
page_.header.meta = mime; // so s knows to download it page_.header.meta = mime; // so s knows to download it
} }
@@ -302,6 +303,18 @@ void GeminiApp::selectLink(int direction) {
requestRedraw(); 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) { bool GeminiApp::onKey(const KeyEvent& e) {
requestRedraw(); requestRedraw();
if (certDialog_) { if (certDialog_) {
@@ -388,7 +401,7 @@ bool GeminiApp::onKey(const KeyEvent& e) {
if (isGemini(page_.base())) gemini_.addBookmark(page_.base(), titleOf()); if (isGemini(page_.base())) gemini_.addBookmark(page_.base(), titleOf());
break; break;
case 's': // Q82, Q73 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 (!isGemini(page_.url)) status_ = "Only Gemini pages can be saved";
else if (page_.header.mimeType().rfind("text/", 0) == 0) gemini_.save(page_.url); else if (page_.header.mimeType().rfind("text/", 0) == 0) gemini_.save(page_.url);
else gemini_.download(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.fillRect(box.x - 2, box.y - 12, box.w + 4, box.h + 14, theme::kBackground);
c.setFont(&fonts::small); c.setFont(&fonts::small);
c.setTextColor(theme::kMuted); 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); widgets::lineEditor(c, address_, box);
} }
if (inputOpen_) { 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.drawString(displayText(inputPrompt_).substr(0, 39).c_str(), box.x, box.y - 22);
c.setFont(&fonts::small); c.setFont(&fonts::small);
c.setTextColor(theme::kMuted); 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_) { if (inputSensitive_) {
LineEditor masked(1024); LineEditor masked(1024);
masked.setText(std::string(input_.text().size(), '*')); masked.setText(std::string(input_.text().size(), '*'));
+2
View File
@@ -24,6 +24,8 @@ class GeminiApp : public App {
bool textEntryActive() const override { return addressOpen_ || inputOpen_; } bool textEntryActive() const override { return addressOpen_ || inputOpen_; }
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private: private:
struct Visit { struct Visit {
+7 -10
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "gnss_app.h" #include "gnss_app.h"
#include <Arduino.h> #include <Arduino.h>
@@ -80,11 +81,10 @@ void GnssApp::draw(Canvas& c) {
return; return;
} }
sky_ ? drawSky(c) : drawPosition(c); sky_ ? drawSky(c) : drawPosition(c);
c.setFont(&fonts::small); }
c.setTextColor(theme::kMuted);
c.setTextDatum(top_right); void GnssApp::help(std::vector<KeyHelp>& out) const {
c.drawString(sky_ ? "Tab: position" : "Tab: sky", area.w - 3, area.y + area.h - 9); keys::add(out, keys::kGnss);
c.setTextDatum(top_left);
} }
void GnssApp::drawPosition(Canvas& c) { void GnssApp::drawPosition(Canvas& c) {
@@ -139,15 +139,12 @@ void GnssApp::drawPosition(Canvas& c) {
c.setFont(&fonts::small); c.setFont(&fonts::small);
if (gnss_.tracking()) { if (gnss_.tracking()) {
c.setTextColor(theme::kWarning); 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()); gnss_.trackPoints() == 1 ? "" : "s", duration(now - gnss_.trackStartMs()).c_str());
} else if (!refusal_.empty() && now - refusalMs_ < 4000) { } else if (!refusal_.empty() && now - refusalMs_ < 4000) {
c.setTextColor(theme::kWarning); c.setTextColor(theme::kWarning);
std::snprintf(line, sizeof line, "%s", refusal_.c_str()); std::snprintf(line, sizeof line, "%s", refusal_.c_str());
} else { } else line[0] = 0;
c.setTextColor(theme::kMuted);
std::snprintf(line, sizeof line, "r: record a Track");
}
c.drawString(line, 4, area.y + area.h - 9); c.drawString(line, 4, area.y + area.h - 9);
} }
+1
View File
@@ -17,6 +17,7 @@ class GnssApp : public App {
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
private: private:
void drawPosition(Canvas& c); void drawPosition(Canvas& c);
+12 -2
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "irc_app.h" #include "irc_app.h"
#include "ui/fonts.h" #include "ui/fonts.h"
@@ -299,6 +300,16 @@ void IrcApp::drawChat(Canvas& c) {
widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0}); 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) { void IrcApp::drawSettings(Canvas& c) {
const auto& area = theme::kContent; const auto& area = theme::kContent;
if (editing_) { if (editing_) {
@@ -306,8 +317,7 @@ void IrcApp::drawSettings(Canvas& c) {
c.setTextColor(theme::kMuted); c.setTextColor(theme::kMuted);
c.drawString(fieldLabel(fields_.selected()).c_str(), 4, area.y + 4); c.drawString(fieldLabel(fields_.selected()).c_str(), 4, area.y + 4);
widgets::lineEditor(c, fieldEditor_, {4, area.y + 22, area.w - 8, 0}); 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, if (fields_.selected() == kAutojoin) c.drawString("e.g. #roro, #private key", 4, area.y + 44);
area.y + 44);
return; return;
} }
widgets::list( widgets::list(
+2
View File
@@ -24,6 +24,8 @@ class IrcApp : public App {
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
bool textEntryActive() const override { return page_ == Page::Chat || editing_; } bool textEntryActive() const override { return page_ == Page::Chat || editing_; }
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private: private:
enum class Page { Chat, Settings }; enum class Page { Chat, Settings };
+2
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "app.h" #include "app.h"
#include "app_keys.h"
#include "app_manager.h" #include "app_manager.h"
#include "list_model.h" #include "list_model.h"
#include "ui/theme.h" #include "ui/theme.h"
@@ -14,6 +15,7 @@ class LauncherApp : public App {
void onEnter() override; void onEnter() override;
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override { keys::add(out, keys::kLauncher); }
private: private:
AppManager* manager_ = nullptr; AppManager* manager_ = nullptr;
+22 -4
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "lora_scanner_app.h" #include "lora_scanner_app.h"
#include <Arduino.h> #include <Arduino.h>
@@ -165,6 +166,24 @@ void LoraScannerApp::update(uint32_t nowMs) {
requestRedraw(); requestRedraw();
} }
void LoraScannerApp::help(std::vector<KeyHelp>& out) const {
switch (view_) {
case View::Packets: keys::add(out, keys::kLora); break;
case View::Details: keys::add(out, keys::kLoraPacket); break;
case View::Presets: keys::add(out, keys::kLoraPresets); break;
case View::Sweep: keys::add(out, keys::kLoraSweep); break;
}
}
const char* LoraScannerApp::helpTitle() const {
switch (view_) {
case View::Details: return "A LoRa packet";
case View::Presets: return "LoRa presets";
case View::Sweep: return "LoRa Sweep";
default: return nullptr;
}
}
void LoraScannerApp::draw(Canvas& c) { void LoraScannerApp::draw(Canvas& c) {
lastDrawMs_ = millis(); lastDrawMs_ = millis();
c.setTextDatum(top_left); c.setTextDatum(top_left);
@@ -234,8 +253,7 @@ void LoraScannerApp::drawPackets(Canvas& c) {
c.setFont(&fonts::small); c.setFont(&fonts::small);
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs; bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted); c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
std::string keys = std::string("Enter: details p: preset Tab: sweep c: ") + (capture_.capturing() ? "stop" : "capture"); if (showMessage) c.drawString(message_.c_str(), 4, area.y + area.h - 9);
c.drawString(showMessage ? message_.c_str() : keys.c_str(), 4, area.y + area.h - 9);
} }
void LoraScannerApp::drawDetails(Canvas& c) { void LoraScannerApp::drawDetails(Canvas& c) {
@@ -329,9 +347,9 @@ void LoraScannerApp::drawSweep(Canvas& c) {
c.drawString(line, x0 + f.steps * cell, wfTop + kWaterfallRows + 1); c.drawString(line, x0 + f.steps * cell, wfTop + kWaterfallRows + 1);
c.setTextDatum(top_left); c.setTextDatum(top_left);
if (st.peaks.empty()) if (st.peaks.empty())
std::snprintf(line, sizeof line, "floor %d dBm, nothing above it Tab: sniffer", st.floor); std::snprintf(line, sizeof line, "floor %d dBm, nothing above it", st.floor);
else else
std::snprintf(line, sizeof line, "floor %d, %.1f MHz at %d dBm Tab: sniffer", st.floor, st.peaks[0].hz / 1e6, std::snprintf(line, sizeof line, "floor %d, %.1f MHz at %d dBm", st.floor, st.peaks[0].hz / 1e6,
st.peaks[0].dbm); st.peaks[0].dbm);
c.setTextColor(theme::kMuted); c.setTextColor(theme::kMuted);
c.drawString(line, 4, y); c.drawString(line, 4, y);
+2
View File
@@ -26,6 +26,8 @@ class LoraScannerApp : public App {
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private: private:
enum class View { Packets, Details, Presets, Sweep }; enum class View { Packets, Details, Presets, Sweep };
+6
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "maintenance_page.h" #include "maintenance_page.h"
#include "ui/widgets.h" #include "ui/widgets.h"
@@ -35,6 +36,11 @@ CleanupPlan MaintenancePage::planFor(int age) const {
return CleanupPlan::make(listing_[categories_.selected()], cutoff, today); return CleanupPlan::make(listing_[categories_.selected()], cutoff, today);
} }
void MaintenancePage::help(std::vector<KeyHelp>& out) const {
if (dialog_) return keys::add(out, keys::kDialog);
keys::add(out, keys::kMaintenance);
}
bool MaintenancePage::onKey(const KeyEvent& e) { bool MaintenancePage::onKey(const KeyEvent& e) {
if (dialog_) { if (dialog_) {
dialog_->onKey(e); dialog_->onKey(e);
+2
View File
@@ -5,6 +5,7 @@
#include "cleanup_plan.h" #include "cleanup_plan.h"
#include "dialog_model.h" #include "dialog_model.h"
#include "key_help.h"
#include "event_bus.h" #include "event_bus.h"
#include "key_event.h" #include "key_event.h"
#include "list_model.h" #include "list_model.h"
@@ -24,6 +25,7 @@ class MaintenancePage {
void enter(); void enter();
bool onKey(const KeyEvent& e); // false: leave the page bool onKey(const KeyEvent& e); // false: leave the page
void draw(Canvas& c); void draw(Canvas& c);
void help(std::vector<KeyHelp>& out) const;
private: private:
enum class View { Main, Categories, Ages }; enum class View { Main, Categories, Ages };
+7 -1
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "note_editor.h" #include "note_editor.h"
#include <Arduino.h> #include <Arduino.h>
@@ -182,6 +183,11 @@ bool NoteEditor::save() {
return true; return true;
} }
void NoteEditor::help(std::vector<KeyHelp>& out) const {
if (dialog_) return keys::add(out, keys::kDialog);
keys::add(out, keys::kNotesEditor);
}
bool NoteEditor::onKey(const KeyEvent& e) { bool NoteEditor::onKey(const KeyEvent& e) {
if (!text_) return false; if (!text_) return false;
redraw_ = true; redraw_ = true;
@@ -290,7 +296,7 @@ void NoteEditor::draw(Canvas& c) {
c.setFont(&fonts::small); c.setFont(&fonts::small);
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs; bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted); c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
c.drawString(showMessage ? message_.c_str() : "Fn+arrows: move Ctrl+A/E: line Back: done", 4, area.y + area.h - 9); if (showMessage) c.drawString(message_.c_str(), 4, area.y + area.h - 9);
if (dialog_ && ask_ == Ask::Recover) if (dialog_ && ask_ == Ask::Recover)
widgets::dialog(c, "Unsaved copy", "A save of this note was cut short. Its copy has " + formatBytes(recovered_.size()) + ", the note " + widgets::dialog(c, "Unsaved copy", "A save of this note was cut short. Its copy has " + formatBytes(recovered_.size()) + ", the note " +
+2
View File
@@ -5,6 +5,7 @@
#include <vector> #include <vector>
#include "dialog_model.h" #include "dialog_model.h"
#include "key_help.h"
#include "key_event.h" #include "key_event.h"
#include "note_text.h" #include "note_text.h"
#include "services/clock_service.h" #include "services/clock_service.h"
@@ -33,6 +34,7 @@ class NoteEditor {
const std::string& path() const { return path_; } // "" for a new note nothing was typed in const std::string& path() const { return path_; } // "" for a new note nothing was typed in
bool onKey(const KeyEvent& e); // false: done, and saved bool onKey(const KeyEvent& e); // false: done, and saved
void help(std::vector<KeyHelp>& out) const;
bool update(uint32_t nowMs); // true: draw again bool update(uint32_t nowMs); // true: draw again
void draw(Canvas& c); void draw(Canvas& c);
+14 -2
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "notes_app.h" #include "notes_app.h"
#include <Arduino.h> #include <Arduino.h>
@@ -173,6 +174,18 @@ std::string NotesApp::titleOf(int row) {
return files::fitName(list_.name(notes_[row]), kTitleChars); // an empty note, or not a text return files::fitName(list_.name(notes_[row]), kTitleChars); // an empty note, or not a text
} }
void NotesApp::help(std::vector<KeyHelp>& out) const {
if (view_ == View::Edit) return editor_.help(out);
if (dialog_) return keys::add(out, keys::kDialog);
if (view_ == View::Name) return keys::add(out, keys::kNotesName);
if (view_ == View::NoMemory) return;
keys::add(out, keys::kNotes);
}
const char* NotesApp::helpTitle() const {
return view_ == View::Edit ? "Notes: the editor" : view_ == View::Name ? "Notes: a file name" : nullptr;
}
bool NotesApp::onKey(const KeyEvent& e) { bool NotesApp::onKey(const KeyEvent& e) {
requestRedraw(); requestRedraw();
if (view_ == View::NoMemory) return false; if (view_ == View::NoMemory) return false;
@@ -315,7 +328,6 @@ void NotesApp::draw(Canvas& c) {
} else if (notes_.empty()) { } else if (notes_.empty()) {
c.setTextColor(theme::kMuted); c.setTextColor(theme::kMuted);
c.drawString("No notes yet.", 4, rows.y + 4); c.drawString("No notes yet.", 4, rows.y + 4);
c.drawString("n starts one.", 4, rows.y + 4 + theme::kLineHeight);
} else { } else {
readTitles(); readTitles();
widgets::list( widgets::list(
@@ -329,7 +341,7 @@ void NotesApp::draw(Canvas& c) {
c.setFont(&fonts::small); c.setFont(&fonts::small);
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs; bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted); c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
c.drawString(showMessage ? message_.c_str() : "n: new Enter: open r: name d: del s: sort", 4, area.y + area.h - 9); if (showMessage) c.drawString(message_.c_str(), 4, area.y + area.h - 9);
if (view_ == View::Name) { if (view_ == View::Name) {
theme::Rect box{16, 44, theme::kWidth - 32, 48}; theme::Rect box{16, 44, theme::kWidth - 32, 48};
+2
View File
@@ -28,6 +28,8 @@ class NotesApp : public App {
bool textEntryActive() const override { return view_ == View::Edit || view_ == View::Name; } bool textEntryActive() const override { return view_ == View::Edit || view_ == View::Name; }
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private: private:
enum class View { List, Edit, Name, NoMemory }; enum class View { List, Edit, Name, NoMemory };
+34 -5
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "settings_app.h" #include "settings_app.h"
#include <Arduino.h> #include <Arduino.h>
@@ -34,6 +35,9 @@ bool SettingsApp::onKey(const KeyEvent& e) {
case Page::Firmware: case Page::Firmware:
if (!firmwarePage_.onKey(e)) page_ = Page::Menu; if (!firmwarePage_.onKey(e)) page_ = Page::Menu;
return true; return true;
case Page::Debug:
if (!debugPage_.onKey(e)) page_ = Page::Menu;
return true;
} }
return false; return false;
} }
@@ -75,6 +79,10 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) {
page_ = Page::Firmware; page_ = Page::Firmware;
firmwarePage_.enter(); firmwarePage_.enter();
break; break;
case Row::DebugConsole:
page_ = Page::Debug;
debugPage_.enter();
break;
default: page_ = Page::About; break; default: page_ = Page::About; break;
} }
break; break;
@@ -122,9 +130,31 @@ bool SettingsApp::onAboutKey(const KeyEvent& e) {
return true; return true;
} }
void SettingsApp::help(std::vector<KeyHelp>& out) const {
switch (page_) {
case Page::Menu: keys::add(out, keys::kSettings); break;
case Page::Text: keys::add(out, keys::kText); break;
case Page::Choice: keys::add(out, keys::kSettingsChoice); break;
case Page::About: break;
case Page::Wifi: wifiPage_.help(out); break;
case Page::Firmware: firmwarePage_.help(out); break;
case Page::Debug: debugPage_.help(out); break;
}
}
const char* SettingsApp::helpTitle() const {
switch (page_) {
case Page::Wifi: return wifiPage_.helpTitle();
case Page::Firmware: return firmwarePage_.helpTitle();
case Page::Debug: return debugPage_.helpTitle();
case Page::About: return "About";
default: return nullptr;
}
}
void SettingsApp::update(uint32_t nowMs) { void SettingsApp::update(uint32_t nowMs) {
// Live values on About and Firmware. // Live values on About and Firmware.
bool live = page_ == Page::About || page_ == Page::Firmware || bool live = page_ == Page::About || page_ == Page::Firmware || page_ == Page::Debug ||
(page_ == Page::Wifi && wifiPage_.live()); (page_ == Page::Wifi && wifiPage_.live());
if (live && nowMs - lastRefreshMs_ >= 500) { if (live && nowMs - lastRefreshMs_ >= 500) {
lastRefreshMs_ = nowMs; lastRefreshMs_ = nowMs;
@@ -170,10 +200,8 @@ void SettingsApp::draw(Canvas& c) {
c.setTextColor(theme::kMuted); c.setTextColor(theme::kMuted);
c.drawString(menu_.label(editingRow_).c_str(), 4, area.y + 4); c.drawString(menu_.label(editingRow_).c_str(), 4, area.y + 4);
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0}); widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
c.drawString(("Enter: save `: cancel " + std::to_string(editor_.text().size()) + "/" + c.drawString((std::to_string(editor_.text().size()) + " of " + std::to_string(editor_.maxBytes()) + " bytes").c_str(), 4,
std::to_string(editor_.maxBytes()) + " bytes") area.y + 44);
.c_str(),
4, area.y + 44);
break; break;
case Page::Choice: { case Page::Choice: {
auto options = menu_.choices(editingRow_); auto options = menu_.choices(editingRow_);
@@ -184,6 +212,7 @@ void SettingsApp::draw(Canvas& c) {
case Page::About: widgets::textLines(c, aboutLines(), 0, area); break; case Page::About: widgets::textLines(c, aboutLines(), 0, area); break;
case Page::Wifi: wifiPage_.draw(c); break; case Page::Wifi: wifiPage_.draw(c); break;
case Page::Firmware: firmwarePage_.draw(c); break; case Page::Firmware: firmwarePage_.draw(c); break;
case Page::Debug: debugPage_.draw(c); break;
} }
} }
+10 -4
View File
@@ -13,6 +13,7 @@
#include "services/battery_service.h" #include "services/battery_service.h"
#include "services/clock_service.h" #include "services/clock_service.h"
#include "services/storage_service.h" #include "services/storage_service.h"
#include "apps/debug_console_page.h"
#include "apps/firmware_page.h" #include "apps/firmware_page.h"
#include "apps/wifi_settings_page.h" #include "apps/wifi_settings_page.h"
#include "settings_menu.h" #include "settings_menu.h"
@@ -32,24 +33,28 @@ struct SettingsAppDeps {
UpdateService& update; UpdateService& update;
}; };
// Settings: every user-facing setting, plus the Wi-Fi, Firmware and About pages. // Settings: every user-facing setting, plus the Wi-Fi, Firmware, Debug Console and About pages.
class SettingsApp : public App { class SettingsApp : public App {
public: public:
explicit SettingsApp(const SettingsAppDeps& deps) explicit SettingsApp(const SettingsAppDeps& deps)
: d_(deps), : d_(deps),
menu_(deps.settings), menu_(deps.settings),
wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus), wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus),
firmwarePage_(deps.update, deps.wifi, deps.storage) {} firmwarePage_(deps.update, deps.wifi, deps.storage),
debugPage_(deps.settings, deps.wifi) {}
void onEnter() override; void onEnter() override;
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
bool textEntryActive() const override { bool textEntryActive() const override {
return page_ == Page::Text || (page_ == Page::Wifi && wifiPage_.textEntryActive()); return page_ == Page::Text || (page_ == Page::Wifi && wifiPage_.textEntryActive()) ||
(page_ == Page::Debug && debugPage_.textEntryActive());
} }
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private: private:
enum class Page { Menu, Text, Choice, About, Wifi, Firmware }; enum class Page { Menu, Text, Choice, About, Wifi, Firmware, Debug };
bool onMenuKey(const KeyEvent& e); bool onMenuKey(const KeyEvent& e);
bool onTextKey(const KeyEvent& e); bool onTextKey(const KeyEvent& e);
@@ -62,6 +67,7 @@ class SettingsApp : public App {
SettingsMenu menu_; SettingsMenu menu_;
WifiSettingsPage wifiPage_; WifiSettingsPage wifiPage_;
FirmwarePage firmwarePage_; FirmwarePage firmwarePage_;
DebugConsolePage debugPage_;
Page page_ = Page::Menu; Page page_ = Page::Menu;
ListModel list_{theme::kContent.h / theme::kLineHeight}; ListModel list_{theme::kContent.h / theme::kLineHeight};
ListModel choices_{theme::kContent.h / theme::kLineHeight}; ListModel choices_{theme::kContent.h / theme::kLineHeight};
+18 -2
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "setup_app.h" #include "setup_app.h"
#include "platform/identity.h" #include "platform/identity.h"
@@ -44,6 +45,18 @@ bool SetupApp::onKey(const KeyEvent& e) {
return true; return true;
} }
void SetupApp::help(std::vector<KeyHelp>& out) const {
switch (wizard_.step()) {
case Step::LongName:
case Step::ShortName: keys::add(out, keys::kSetupText); break;
case Step::Region:
case Step::Timezone: keys::add(out, keys::kSetupChoice); break;
default: keys::add(out, keys::kSetup); break;
}
}
// The one place that names keys on the screen (Q200): someone in their first minute doesn't know
// the help key yet, and this is where they learn it.
void SetupApp::draw(Canvas& c) { void SetupApp::draw(Canvas& c) {
const auto& area = theme::kContent; const auto& area = theme::kContent;
auto heading = [&](const char* text) { auto heading = [&](const char* text) {
@@ -63,6 +76,7 @@ void SetupApp::draw(Canvas& c) {
heading("Welcome to roro9stack"); heading("Welcome to roro9stack");
note("Let's set up this device: your names on the mesh, your radio region and your timezone.", note("Let's set up this device: your names on the mesh, your radio region and your timezone.",
area.y + 22, theme::kText); area.y + 22, theme::kText);
note("One key to know: Fn + h lists the keys of the screen you're on.", area.y + 22 + 4 * theme::kLineHeight, theme::kAccent);
note("Enter: continue", area.y + area.h - 14); note("Enter: continue", area.y + area.h - 14);
break; break;
case Step::LongName: case Step::LongName:
@@ -88,8 +102,10 @@ void SetupApp::draw(Canvas& c) {
[this](int i) { return wizard_.choiceLabel(i); }); [this](int i) { return wizard_.choiceLabel(i); });
break; break;
case Step::Done: case Step::Done:
heading("All set"); heading("All set. One key to remember:");
note("You can change all of this later in Settings.", area.y + 22, theme::kText); note("Fn + h, on any screen, lists the keys that work there. No screen names its keys: that key does. Try it now.",
area.y + 22, theme::kAccent);
note("The rest can be changed later in Settings.", area.y + 22 + 4 * theme::kLineHeight, theme::kText);
note("Enter: start `: go back", area.y + area.h - 14); note("Enter: start `: go back", area.y + area.h - 14);
break; break;
} }
+2
View File
@@ -17,6 +17,8 @@ class SetupApp : public App {
return wizard_.step() == SetupWizard::Step::LongName || wizard_.step() == SetupWizard::Step::ShortName; return wizard_.step() == SetupWizard::Step::LongName || wizard_.step() == SetupWizard::Step::ShortName;
} }
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override { return "Setup"; }
private: private:
AppManager& apps_; AppManager& apps_;

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