Compare commits

...
Author SHA1 Message Date
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
65 changed files with 1287 additions and 374 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
# 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.
# 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*: all of it, then a Gitea release with the signed Update File.
# 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).
@@ -59,7 +59,7 @@ jobs:
if: github.event_name != 'workflow_dispatch' if: github.event_name != 'workflow_dispatch'
run: scripts/coverage.sh run: scripts/coverage.sh
- name: The release firmware and the Debug Build - name: The firmware
if: github.event_name == 'pull_request' || github.ref_type == 'tag' if: github.event_name == 'pull_request' || github.ref_type == 'tag'
run: scripts/ci.sh builds run: scripts/ci.sh builds
+4 -8
View File
@@ -144,7 +144,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 +160,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
+20 -15
View File
@@ -26,14 +26,14 @@ The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkc
## 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 +87,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
@@ -163,12 +163,12 @@ A note holds up to 16 KB while it's edited. A bigger text file opens read-only i
| `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 +185,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 +198,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 +236,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.
+51 -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,52 @@ 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.
+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) {}
+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
-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);
+4
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,8 @@ 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
}; };
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 +101,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;
} }
+2
View File
@@ -31,6 +31,8 @@ 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
Count Count
}; };
-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"))
-3
View File
@@ -10,9 +10,6 @@ try:
except Exception: except Exception:
version = "unknown" version = "unknown"
if env["PIOENV"].endswith("-debug"): # noqa: F821
version += "+debug" # a Debug Build says so wherever the version shows
env.Append(CPPDEFINES=[("RORO_VERSION", '\\"%s\\"' % version)]) # noqa: F821 env.Append(CPPDEFINES=[("RORO_VERSION", '\\"%s\\"' % version)]) # noqa: F821
+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/).
+2 -2
View File
@@ -26,13 +26,13 @@ The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkc
## 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.
+14 -15
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
@@ -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
@@ -67,12 +64,12 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| `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/).
+7 -8
View File
@@ -59,12 +59,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 +72,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 +93,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 +103,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 +119,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.
+51 -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,52 @@ 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.
+1
View File
@@ -38,6 +38,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 |
+1
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
+1 -1
View File
@@ -41,4 +41,4 @@ 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/).
+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 %}
+4 -3
View File
@@ -164,15 +164,16 @@ 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
+144
View File
@@ -0,0 +1,144 @@
#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::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);
c.setTextColor(theme::kMuted);
c.drawString("Enter: save `: cancel", 4, area.y + 44 + theme::kLineHeight);
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
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <memory>
#include <string>
#include "dialog_model.h"
#include "key_event.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);
private:
enum Row { kSwitch, kAddress, kNewToken, kTypeToken, kRows };
enum class Ask { None, SwitchOn, NewToken };
Settings& settings_;
WifiService& wifi_;
ListModel list_{kRows};
std::unique_ptr<DialogModel> confirm_;
Ask ask_ = Ask::None;
bool typing_ = false;
LineEditor editor_{64};
std::string refusal_;
};
} // namespace roro
-1
View File
@@ -80,7 +80,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;
+9 -1
View File
@@ -34,6 +34,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 +78,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;
@@ -124,7 +131,7 @@ bool SettingsApp::onAboutKey(const KeyEvent& e) {
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;
@@ -184,6 +191,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;
} }
} }
+8 -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,26 @@ 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;
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 +65,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};
+58 -57
View File
@@ -74,17 +74,13 @@ static ClockService* clockService;
static GnssService* gnssService; static GnssService* gnssService;
static RadioService* radioService; static RadioService* radioService;
static LoraCaptureService* loraCapture; static LoraCaptureService* loraCapture;
#ifdef RORO_DEBUG
static NoiseTest* noiseTest; static NoiseTest* noiseTest;
#endif
static GeminiService* geminiService; static GeminiService* geminiService;
static SavedNetworks* savedNetworks; static SavedNetworks* savedNetworks;
static WifiService* wifi; static WifiService* wifi;
static IrcService* irc; static IrcService* irc;
static UpdateService* update; static UpdateService* update;
#ifdef RORO_DEBUG
static DebugConsole* debugConsole; static DebugConsole* debugConsole;
#endif
static Notifier* notifier; static Notifier* notifier;
static LauncherApp launcher; static LauncherApp launcher;
static AppManager* apps; static AppManager* apps;
@@ -131,6 +127,7 @@ static StatusInfo currentStatus() {
s.radio = last && millis() - last < 400 ? StatusInfo::Radio::Packet : StatusInfo::Radio::Listening; s.radio = last && millis() - last < 400 ? StatusInfo::Radio::Packet : StatusInfo::Radio::Listening;
} }
s.capturing = loraCapture && loraCapture->capturing(); s.capturing = loraCapture && loraCapture->capturing();
s.debug = !debugConsole->on() ? StatusInfo::Debug::Off : debugConsole->clientConnected() ? StatusInfo::Debug::Client : StatusInfo::Debug::On;
using WifiState = WifiController::State; using WifiState = WifiController::State;
switch (wifi->state()) { switch (wifi->state()) {
case WifiState::Connected: { case WifiState::Connected: {
@@ -194,18 +191,14 @@ void setup() {
services.add(*gnssService); services.add(*gnssService);
services.add(*radioService); services.add(*radioService);
loraCapture = new LoraCaptureService(*radioService, *storageService, *clockService, bus); loraCapture = new LoraCaptureService(*radioService, *storageService, *clockService, bus);
#ifdef RORO_DEBUG
noiseTest = new NoiseTest(*radioService); noiseTest = new NoiseTest(*radioService);
#endif
services.add(*loraCapture); services.add(*loraCapture);
services.add(*storageService); services.add(*storageService);
services.add(*wifi); services.add(*wifi);
services.add(*irc); services.add(*irc);
services.add(*update); services.add(*update);
#ifdef RORO_DEBUG debugConsole = new DebugConsole(*wifi, *storageService, settings);
debugConsole = new DebugConsole(*wifi, *storageService);
services.add(*debugConsole); services.add(*debugConsole);
#endif
apps = new AppManager(launcher); apps = new AppManager(launcher);
launcher.setManager(*apps); launcher.setManager(*apps);
@@ -228,9 +221,7 @@ void setup() {
bus.subscribe(EventType::Notification, [](const Event& e) { console.printf("notification: %s\n", e.text); }); bus.subscribe(EventType::Notification, [](const Event& e) { console.printf("notification: %s\n", e.text); });
if (!screen.begin()) console.println("frame buffer allocation failed"); if (!screen.begin()) console.println("frame buffer allocation failed");
#ifdef RORO_DEBUG
debugConsole->setFrame(screen.canvas()); // for `screenshot` debugConsole->setFrame(screen.canvas()); // for `screenshot`
#endif
services.startAll(millis()); services.startAll(millis());
apps->begin(); apps->begin();
if (!settings.getBool(Setting::SetupDone)) apps->openModal("setup"); if (!settings.getBool(Setting::SetupDone)) apps->openModal("setup");
@@ -246,7 +237,7 @@ void setup() {
enableLoopWDT(); enableLoopWDT();
} }
// Safe Mode: Wi-Fi, the clock, Firmware Updates and (in a Debug Build) the Debug Console. No Apps, // Safe Mode: Wi-Fi, the clock, Firmware Updates and (if it's switched on) the Debug Console. No Apps,
// no IRC, no SD card: whatever crashed three times in a row is most likely among them. // no IRC, no SD card: whatever crashed three times in a row is most likely among them.
static void setupSafeMode(int crashes) { static void setupSafeMode(int crashes) {
clockService = new ClockService(settings, bus); clockService = new ClockService(settings, bus);
@@ -258,15 +249,11 @@ static void setupSafeMode(int crashes) {
services.add(*clockService); services.add(*clockService);
services.add(*wifi); services.add(*wifi);
services.add(*update); services.add(*update);
#ifdef RORO_DEBUG debugConsole = new DebugConsole(*wifi, *storageService, settings);
debugConsole = new DebugConsole(*wifi, *storageService);
services.add(*debugConsole); services.add(*debugConsole);
#endif
bus.subscribe(EventType::Notification, [](const Event& e) { console.printf("notification: %s\n", e.text); }); bus.subscribe(EventType::Notification, [](const Event& e) { console.printf("notification: %s\n", e.text); });
screen.begin(); screen.begin();
#ifdef RORO_DEBUG
debugConsole->setFrame(screen.canvas()); debugConsole->setFrame(screen.canvas());
#endif
services.startAll(millis()); services.startAll(millis());
console.printf("%s %s in SAFE MODE: %d crash restarts in a row. Only Wi-Fi and Firmware Updates run.\n", console.printf("%s %s in SAFE MODE: %d crash restarts in a row. Only Wi-Fi and Firmware Updates run.\n",
kProductName, versionString(), crashes); kProductName, versionString(), crashes);
@@ -385,7 +372,6 @@ static void uploadStep() {
} }
} }
#ifdef RORO_DEBUG
// `wifi ip ... try <seconds>`: a trial IP setting that reverts unless `wifi ip keep` arrives, so a // `wifi ip ... try <seconds>`: a trial IP setting that reverts unless `wifi ip keep` arrives, so a
// wrong address tried over Wi-Fi doesn't cut the device off for good. // wrong address tried over Wi-Fi doesn't cut the device off for good.
static struct { static struct {
@@ -404,7 +390,6 @@ static void ipTrialStep() {
ipTrial.wasFixed ? net::formatFixed(ipTrial.was).c_str() : "Automatic (DHCP)"); ipTrial.wasFixed ? net::formatFixed(ipTrial.was).c_str() : "Automatic (DHCP)");
wifi->ipSettingChanged(ipTrial.ssid); wifi->ipSettingChanged(ipTrial.ssid);
} }
#endif
// `tasks`: the first sample, and when to take the second and print. // `tasks`: the first sample, and when to take the second and print.
static std::vector<TaskSample> tasksBefore; static std::vector<TaskSample> tasksBefore;
@@ -467,13 +452,11 @@ static void printReleases(bool list) {
static void updateStep() { static void updateStep() {
if (!updateWatch || update->giteaBusy()) return; if (!updateWatch || update->giteaBusy()) return;
#ifdef RORO_DEBUG
if (updateWatch == 3) { if (updateWatch == 3) {
console.printf("update: probe: %s\n", update->probeResult().c_str()); console.printf("update: probe: %s\n", update->probeResult().c_str());
updateWatch = 0; updateWatch = 0;
return; return;
} }
#endif
printReleases(updateWatch == 2); printReleases(updateWatch == 2);
updateWatch = 0; updateWatch = 0;
} }
@@ -492,14 +475,8 @@ static void updateCommand(const String& args) {
printReleases(false); printReleases(false);
} else if (args.startsWith("install ")) { } else if (args.startsWith("install ")) {
String rest = args.substring(8); String rest = args.substring(8);
bool force = rest.endsWith(" force"); std::string why = update->requestInstall(rest.c_str());
if (force) rest.remove(rest.length() - 6);
#ifndef RORO_DEBUG
force = false; // only the Debug Console may install on a Debug Build, which a release isn't
#endif
std::string why = update->requestInstall(rest.c_str(), force);
console.printf("update: %s\n", why.empty() ? "installing" : why.c_str()); console.printf("update: %s\n", why.empty() ? "installing" : why.c_str());
#ifdef RORO_DEBUG
} else if (args.startsWith("probe ")) { // update probe <host> [path]: is that server's certificate accepted? } else if (args.startsWith("probe ")) { // update probe <host> [path]: is that server's certificate accepted?
String rest = args.substring(6); String rest = args.substring(6);
int space = rest.indexOf(' '); int space = rest.indexOf(' ');
@@ -516,7 +493,6 @@ static void updateCommand(const String& args) {
} else if (args.startsWith("pretend ")) { // update pretend v0.9.0 | off: what the comparisons take as running } else if (args.startsWith("pretend ")) { // update pretend v0.9.0 | off: what the comparisons take as running
update->pretendVersion(args.substring(8) == "off" ? "" : args.substring(8).c_str()); update->pretendVersion(args.substring(8) == "off" ? "" : args.substring(8).c_str());
console.printf("update: running %s\n", update->runningVersion().c_str()); console.printf("update: running %s\n", update->runningVersion().c_str());
#endif
} else { } else {
console.println("update: check | list | status | install <tag>"); console.println("update: check | list | status | install <tag>");
} }
@@ -553,9 +529,7 @@ static void fileCommand(const String& line) {
} }
static uint32_t loopPasses = 0; // counted in loop(), for `tasks` (issue #40) static uint32_t loopPasses = 0; // counted in loop(), for `tasks` (issue #40)
#ifdef RORO_DEBUG
static bool loopSpin = false; // `loop spin on`: no rest between passes, to compare load and radio noise static bool loopSpin = false; // `loop spin on`: no rest between passes, to compare load and radio noise
#endif
static uint32_t tasksPasses = 0; static uint32_t tasksPasses = 0;
static void tasksStep() { static void tasksStep() {
@@ -590,7 +564,8 @@ static const char* const kHelp =
"install <path.ota> Update from SD\n" "install <path.ota> Update from SD\n"
"update check | list | status | install <tag> the project's releases on Gitea\n" "update check | list | status | install <tag> the project's releases on Gitea\n"
"sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n" "sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
#ifdef RORO_DEBUG "debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\n"
"debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token\n"
"crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n" "crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n"
"wifi ip ... try <seconds> | wifi ip keep a trial IP setting: back to the previous one unless kept\n" "wifi ip ... try <seconds> | wifi ip keep a trial IP setting: back to the previous one unless kept\n"
"loop spin on|off make the main loop spin without resting, to compare load and radio noise\n" "loop spin on|off make the main loop spin without resting, to compare load and radio noise\n"
@@ -601,34 +576,75 @@ static const char* const kHelp =
"reset (Debug Console only) restart at once, even if the main loop is stuck\n" "reset (Debug Console only) restart at once, even if the main loop is stuck\n"
"get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py\n" "get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py\n"
"quit close the Debug Console connection\n" "quit close the Debug Console connection\n"
#endif
; ;
// Commands that only touch what Safe Mode starts. // Commands that only touch what Safe Mode starts.
static bool safeModeCommand(const String& line) { static bool safeModeCommand(const String& line) {
return line == "help" || line == "info" || line == "tasks" || line == "net" || line == "reboot" || line == "boot other" || return line == "help" || line == "info" || line == "tasks" || line == "net" || line == "reboot" || line == "boot other" ||
line.startsWith("log level ") || line.startsWith("crash") || line.startsWith("coredump") || line.startsWith("log level ") || line.startsWith("crash") || line.startsWith("coredump") ||
line == "wifi status" || line.startsWith("wifi add "); line == "wifi status" || line.startsWith("wifi add ") || line.startsWith("debug ");
} }
static void runCommand(String line) { // `debug off <seconds>`: the console closes and comes back by itself. It can't open itself wider
// that way (it was on, with the same token), and it's how its closing and reopening is tested from afar.
static bool debugResumes = false;
static uint32_t debugResumeMs = 0;
static void debugResumeStep() {
if (!debugResumes || static_cast<int32_t>(millis() - debugResumeMs) < 0) return;
debugResumes = false;
settings.setBool(Setting::DebugConsole, true);
}
// `debug ...`: the Debug Console's switch and token (ADR 0010). Switching it on and setting its token
// are for USB serial only (Q192): whoever holds the cable holds the device anyway, and the console
// can't be used to open itself wider. The token is never printed.
static void debugCommand(const String& args, bool fromSerial) {
if (args == "status") {
console.printf("debug: %s, token %s, client %s\n", settings.getBool(Setting::DebugConsole) ? "on" : "off",
settings.getString(Setting::DebugToken).empty() ? "not set" : "set", debugConsole->clientConnected() ? "connected" : "none");
} else if (args == "off") {
settings.setBool(Setting::DebugConsole, false);
console.println("debug: off");
} else if (args.startsWith("off ")) { // debug off <seconds>: a pause, then on again as it was
uint32_t seconds = constrain(args.substring(4).toInt(), 1, 600);
if (!settings.getBool(Setting::DebugConsole)) return (void)console.println("debug: it is off");
settings.setBool(Setting::DebugConsole, false);
debugResumeMs = millis() + seconds * 1000;
debugResumes = true;
console.printf("debug: off for %lu s\n", (unsigned long)seconds);
} else if (!fromSerial) {
console.println("debug: over USB serial only (or Settings > Debug Console)");
} else if (args == "on") {
DebugConsole::switchOn(settings);
console.println("debug: on (the token is in Settings > Debug Console)");
} else if (args == "token new") {
settings.setString(Setting::DebugToken, DebugConsole::freshToken());
console.println("debug: a new token (it is in Settings > Debug Console)");
} else if (args.startsWith("token ")) {
std::string token = debug::tidyToken(args.substring(6).c_str());
bool ok = debug::validToken(token) && settings.setString(Setting::DebugToken, token);
console.println(ok ? "debug: token set" : "debug: a token is 16 to 64 characters");
} else console.println("debug: status | off [seconds] | on | token <16 to 64 characters> | token new");
}
static void runCommand(String line, bool fromSerial = false) {
line.trim(); line.trim();
if (line.isEmpty()) return; if (line.isEmpty()) return;
if (safeMode && !safeModeCommand(line)) return (void)console.println("not available in Safe Mode"); if (safeMode && !safeModeCommand(line)) return (void)console.println("not available in Safe Mode");
if (line == "help") console.print(kHelp); if (line == "help") console.print(kHelp);
if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial);
if (line == "info") { if (line == "info") {
system_info::printSystem(console); system_info::printSystem(console);
console.printf("wifi: %s, ip %s, rssi %d | sd: %s, %u write faults\n", wifi->ssid().c_str(), wifi->ip().c_str(), console.printf("wifi: %s, ip %s, rssi %d | sd: %s, %u write faults\n", wifi->ssid().c_str(), wifi->ip().c_str(),
wifi->rssi(), storageService->state().present ? "present" : "none", (unsigned)sdLastFault().count); wifi->rssi(), storageService->state().present ? "present" : "none", (unsigned)sdLastFault().count);
console.printf("update: %s\n", update->onProbation() ? "on probation" : "confirmed"); console.printf("update: %s | debug console: %s\n", update->onProbation() ? "on probation" : "confirmed",
!debugConsole->on() ? "off" : debugConsole->clientConnected() ? "on, a client connected" : "on");
system_info::printSlots(console, nvs); system_info::printSlots(console, nvs);
} }
#ifdef RORO_DEBUG
if (line == "loop spin on" || line == "loop spin off") { if (line == "loop spin on" || line == "loop spin off") {
loopSpin = line.endsWith("on"); loopSpin = line.endsWith("on");
console.printf("loop: %s\n", loopSpin ? "spinning, no rest" : "resting between passes"); console.printf("loop: %s\n", loopSpin ? "spinning, no rest" : "resting between passes");
} }
#endif
if (line == "tasks") { // sampled now, printed a second later by tasksStep(): the loop must run in between if (line == "tasks") { // sampled now, printed a second later by tasksStep(): the loop must run in between
tasksTotal = system_info::sampleTasks(tasksBefore); tasksTotal = system_info::sampleTasks(tasksBefore);
tasksDueMs = millis() + 1000; tasksDueMs = millis() + 1000;
@@ -670,7 +686,6 @@ static void runCommand(String line) {
}); });
} }
if (line.startsWith("install ")) update->installFromSd(line.substring(8).c_str()); // Update from SD if (line.startsWith("install ")) update->installFromSd(line.substring(8).c_str()); // Update from SD
#ifdef RORO_DEBUG
if (line.startsWith("sd fill ")) { // sd fill <folder> <count>: small files, to test a crowded folder if (line.startsWith("sd fill ")) { // sd fill <folder> <count>: small files, to test a crowded folder
int space = line.lastIndexOf(' '); int space = line.lastIndexOf(' ');
std::string folder = line.substring(8, space).c_str(); std::string folder = line.substring(8, space).c_str();
@@ -689,11 +704,9 @@ static void runCommand(String line) {
console.printf("sd fill: %d files in %s\n", made, folder.c_str()); console.printf("sd fill: %d files in %s\n", made, folder.c_str());
}); });
} }
#endif
if (line == "lora probe" && radioService) radioService->probe(console); if (line == "lora probe" && radioService) radioService->probe(console);
if (line == "lora status" && radioService) radioService->printStatus(console); if (line == "lora status" && radioService) radioService->printStatus(console);
if ((line == "lora rx on" || line == "lora rx off") && radioService) radioService->setEcho(line.endsWith("on")); if ((line == "lora rx on" || line == "lora rx off") && radioService) radioService->setEcho(line.endsWith("on"));
#ifdef RORO_DEBUG
if (line == "lora noise report" && noiseTest) noiseTest->printReport(console); if (line == "lora noise report" && noiseTest) noiseTest->printReport(console);
if (line == "lora noise test" && noiseTest && !noiseTest->running()) { if (line == "lora noise test" && noiseTest && !noiseTest->running()) {
// One thing changed at a time, each put back before the next (issue #20). Wi-Fi off cuts the // One thing changed at a time, each put back before the next (issue #20). Wi-Fi off cuts the
@@ -758,7 +771,6 @@ static void runCommand(String line) {
}; };
noiseTest->start(std::move(conditions)); noiseTest->start(std::move(conditions));
} }
#endif
if (line.startsWith("lora sweep") && radioService) { // on [from MHz] [to MHz] [step kHz] | off | dump if (line.startsWith("lora sweep") && radioService) { // on [from MHz] [to MHz] [step kHz] | off | dump
if (line == "lora sweep dump") radioService->printSweep(console); if (line == "lora sweep dump") radioService->printSweep(console);
else if (line == "lora sweep off") { else if (line == "lora sweep off") {
@@ -776,7 +788,6 @@ static void runCommand(String line) {
console.printf("lora capture: %s\n", why.empty() ? loraCapture->path().c_str() : why.c_str()); console.printf("lora capture: %s\n", why.empty() ? loraCapture->path().c_str() : why.c_str());
} }
if (line == "lora capture stop" && loraCapture) loraCapture->stop(); if (line == "lora capture stop" && loraCapture) loraCapture->stop();
#ifdef RORO_DEBUG
if (line.startsWith("lora inject ") && radioService) { // <hex> [rssi] [snr]: as if received if (line.startsWith("lora inject ") && radioService) { // <hex> [rssi] [snr]: as if received
String hex = line.substring(12); String hex = line.substring(12);
int space = hex.indexOf(' '); int space = hex.indexOf(' ');
@@ -787,7 +798,6 @@ static void runCommand(String line) {
for (size_t i = 0; i + 1 < hex.length() && n < sizeof data; i += 2) data[n++] = strtoul(hex.substring(i, i + 2).c_str(), nullptr, 16); for (size_t i = 0; i + 1 < hex.length() && n < sizeof data; i += 2) data[n++] = strtoul(hex.substring(i, i + 2).c_str(), nullptr, 16);
radioService->inject(data, n, rssi, snr); radioService->inject(data, n, rssi, snr);
} }
#endif
if (line.startsWith("lora custom ") && radioService) { if (line.startsWith("lora custom ") && radioService) {
double mhz = 0, bw = 0; double mhz = 0, bw = 0;
unsigned sf = 0, cr = 0, sync = 0, preamble = 8; unsigned sf = 0, cr = 0, sync = 0, preamble = 8;
@@ -822,11 +832,9 @@ static void runCommand(String line) {
console.println(gnssService->send(line.substring(10).c_str()) ? "gnss: sent" : "gnss: off"); console.println(gnssService->send(line.substring(10).c_str()) ? "gnss: sent" : "gnss: off");
if (line == "crash") crash_report::print(console, nvs); if (line == "crash") crash_report::print(console, nvs);
if (line == "coredump erase") console.println(crash_report::erase() ? "coredump: erased" : "coredump: nothing to erase"); if (line == "coredump erase") console.println(crash_report::erase() ? "coredump: erased" : "coredump: nothing to erase");
#ifdef RORO_DEBUG
if (line == "crash abort") abort(); if (line == "crash abort") abort();
if (line == "crash wdt") if (line == "crash wdt")
for (;;) {} // the main loop never yields: the task watchdog fires for (;;) {} // the main loop never yields: the task watchdog fires
#endif
if (line == "burst") if (line == "burst")
for (int i = 1; i <= 5; i++) for (int i = 1; i <= 5; i++)
bus.publish(Event::withText(EventType::Notification, ("Burst " + String(i)).c_str(), 0)); bus.publish(Event::withText(EventType::Notification, ("Burst " + String(i)).c_str(), 0));
@@ -933,7 +941,6 @@ static void runCommand(String line) {
} }
if (line.startsWith("wifi ip ")) { // wifi ip <ssid> dhcp | <address>/<prefix> [gateway] (the SSID may hold spaces) if (line.startsWith("wifi ip ")) { // wifi ip <ssid> dhcp | <address>/<prefix> [gateway] (the SSID may hold spaces)
std::string rest = line.substring(8).c_str(); std::string rest = line.substring(8).c_str();
#ifdef RORO_DEBUG
if (rest == "keep") { // the trial setting stays if (rest == "keep") { // the trial setting stays
console.println(ipTrial.active ? "wifi ip: kept" : "wifi ip: no trial running"); console.println(ipTrial.active ? "wifi ip: kept" : "wifi ip: no trial running");
ipTrial.active = false; ipTrial.active = false;
@@ -945,7 +952,6 @@ static void runCommand(String line) {
trialS = strtoul(rest.c_str() + tryAt + 5, nullptr, 10); trialS = strtoul(rest.c_str() + tryAt + 5, nullptr, 10);
rest = rest.substr(0, tryAt); rest = rest.substr(0, tryAt);
} }
#endif
std::string ssid, why; std::string ssid, why;
net::FixedIp fixed; net::FixedIp fixed;
bool dhcp = rest.size() > 5 && rest.compare(rest.size() - 5, 5, " dhcp") == 0; bool dhcp = rest.size() > 5 && rest.compare(rest.size() - 5, 5, " dhcp") == 0;
@@ -959,9 +965,7 @@ static void runCommand(String line) {
} }
} }
const SavedNetwork* before = why.empty() ? savedNetworks->find(ssid) : nullptr; const SavedNetwork* before = why.empty() ? savedNetworks->find(ssid) : nullptr;
#ifdef RORO_DEBUG
if (before && trialS) ipTrial = {true, millis() + trialS * 1000, ssid, before->fixed, before->ip}; if (before && trialS) ipTrial = {true, millis() + trialS * 1000, ssid, before->fixed, before->ip};
#endif
if (why.empty()) why = savedNetworks->setIp(ssid, dhcp ? nullptr : &fixed); if (why.empty()) why = savedNetworks->setIp(ssid, dhcp ? nullptr : &fixed);
if (!why.empty()) return (void)console.printf("wifi ip: %s\n", why.c_str()); if (!why.empty()) return (void)console.printf("wifi ip: %s\n", why.c_str());
console.printf("wifi ip: %s is now %s\n", ssid.c_str(), dhcp ? "Automatic (DHCP)" : net::formatFixed(fixed).c_str()); console.printf("wifi ip: %s is now %s\n", ssid.c_str(), dhcp ? "Automatic (DHCP)" : net::formatFixed(fixed).c_str());
@@ -1007,18 +1011,19 @@ static void serialCommands() {
line += c; line += c;
continue; continue;
} }
runCommand(line); runCommand(line, true);
line = ""; line = "";
} }
} }
static void remoteCommands() { static void remoteCommands() {
#ifdef RORO_DEBUG
for (std::string remote; debugConsole->takeCommand(remote);) { for (std::string remote; debugConsole->takeCommand(remote);) {
console.printf("> %s\n", remote.c_str()); // so the transcript reads the same on both ends console.printf("> %s\n", remote.c_str()); // so the transcript reads the same on both ends
runCommand(remote.c_str()); runCommand(remote.c_str());
} }
#endif debugResumeStep();
for (std::string alert; debugConsole->takeAlert(alert);)
bus.publish(Event::withText(EventType::Notification, alert.c_str(), static_cast<int32_t>(NotificationLevel::Warning)));
} }
// After a minute up, the crash streak is over (SafeMode counts starts that crash in a row). // After a minute up, the crash streak is over (SafeMode counts starts that crash in a row).
@@ -1069,9 +1074,7 @@ void loop() {
return; return;
} }
loopPass(); loopPass();
#ifdef RORO_DEBUG
if (loopSpin) return; if (loopSpin) return;
#endif
if (upload.active()) return; // a serial file transfer: every byte is read promptly if (upload.active()) return; // a serial file transfer: every byte is read promptly
delay(power->screen() == ScreenState::Off ? kLoopRestScreenOffMs : kLoopRestScreenOnMs); delay(power->screen() == ScreenState::Off ? kLoopRestScreenOffMs : kLoopRestScreenOnMs);
} }
@@ -1088,10 +1091,8 @@ static void loopPass() {
serialCommands(); serialCommands();
remoteCommands(); remoteCommands();
tasksStep(); tasksStep();
#ifdef RORO_DEBUG
ipTrialStep(); ipTrialStep();
if (noiseTest) noiseTest->step(now); if (noiseTest) noiseTest->step(now);
#endif
noteStableOnce(now); noteStableOnce(now);
updateStep(); updateStep();
fileOpsStep(); fileOpsStep();
+29 -8
View File
@@ -2,19 +2,16 @@
#include <Arduino.h> #include <Arduino.h>
#ifdef RORO_DEBUG
#include <esp_log.h> #include <esp_log.h>
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <algorithm> #include <algorithm>
#include <cstdio> #include <cstdio>
#endif
namespace roro { namespace roro {
Console console; Console console;
#ifdef RORO_DEBUG
namespace { namespace {
// Any task may write (ESP-IDF logs come from everywhere), so the ring is behind a spinlock, held // Any task may write (ESP-IDF logs come from everywhere), so the ring is behind a spinlock, held
@@ -34,7 +31,28 @@ int teeEspLog(const char* format, va_list args) {
} // namespace } // namespace
void Console::captureEspLogs() { espLogNext = esp_log_set_vprintf(teeEspLog); } void Console::captureEspLogs() {
if (!espLogNext) espLogNext = esp_log_set_vprintf(teeEspLog); // once: it stays, and costs nothing with the ring closed
}
bool Console::openRing() {
if (ring_) return true;
auto* fresh = static_cast<uint8_t*>(malloc(kRingBytes));
if (!fresh) return false;
portENTER_CRITICAL(&ringLock);
head_ = 0;
ring_ = fresh;
portEXIT_CRITICAL(&ringLock);
return true;
}
void Console::closeRing() {
portENTER_CRITICAL(&ringLock);
uint8_t* old = ring_;
ring_ = nullptr;
portEXIT_CRITICAL(&ringLock);
free(old);
}
void Console::toRing(const uint8_t* data, size_t len) { void Console::toRing(const uint8_t* data, size_t len) {
if (len > kRingBytes) { if (len > kRingBytes) {
@@ -42,11 +60,13 @@ void Console::toRing(const uint8_t* data, size_t len) {
len = kRingBytes; len = kRingBytes;
} }
portENTER_CRITICAL(&ringLock); portENTER_CRITICAL(&ringLock);
if (ring_) {
size_t at = head_ % kRingBytes; size_t at = head_ % kRingBytes;
size_t first = std::min(len, kRingBytes - at); size_t first = std::min(len, kRingBytes - at);
memcpy(ring_ + at, data, first); memcpy(ring_ + at, data, first);
memcpy(ring_, data + first, len - first); memcpy(ring_, data + first, len - first);
head_ += len; head_ += len;
}
portEXIT_CRITICAL(&ringLock); portEXIT_CRITICAL(&ringLock);
} }
@@ -59,24 +79,25 @@ uint32_t Console::oldest() const {
size_t Console::readSince(uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped) { size_t Console::readSince(uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped) {
portENTER_CRITICAL(&ringLock); portENTER_CRITICAL(&ringLock);
size_t n = 0;
skipped = 0;
if (ring_) {
uint32_t from = head_ > kRingBytes ? head_ - kRingBytes : 0; uint32_t from = head_ > kRingBytes ? head_ - kRingBytes : 0;
skipped = pos < from ? from - pos : 0; skipped = pos < from ? from - pos : 0;
if (pos < from) pos = from; if (pos < from) pos = from;
size_t n = std::min<size_t>(max, head_ - pos); n = std::min<size_t>(max, head_ - pos);
size_t at = pos % kRingBytes; size_t at = pos % kRingBytes;
size_t first = std::min(n, kRingBytes - at); size_t first = std::min(n, kRingBytes - at);
memcpy(out, ring_ + at, first); memcpy(out, ring_ + at, first);
memcpy(out + first, ring_, n - first); memcpy(out + first, ring_, n - first);
pos += n; pos += n;
}
portEXIT_CRITICAL(&ringLock); portEXIT_CRITICAL(&ringLock);
return n; return n;
} }
#endif
size_t Console::write(const uint8_t* data, size_t len) { size_t Console::write(const uint8_t* data, size_t len) {
#ifdef RORO_DEBUG
toRing(data, len); toRing(data, len);
#endif
// Never wait for the USB host. One that's attached but not reading (a VM, a closed terminal) // Never wait for the USB host. One that's attached but not reading (a VM, a closed terminal)
// would stall the caller for up to 2 s per write while HWCDC retries; the ring keeps it anyway. // would stall the caller for up to 2 s per write while HWCDC retries; the ring keeps it anyway.
if (Serial.availableForWrite() >= static_cast<int>(len)) Serial.write(data, len); if (Serial.availableForWrite() >= static_cast<int>(len)) Serial.write(data, len);
+11 -7
View File
@@ -7,18 +7,23 @@
namespace roro { namespace roro {
// Where the firmware's console output goes: the USB serial port, and in a Debug Build also a ring // Where the firmware's console output goes: the USB serial port, and while the Debug Console is
// buffer the Debug Console sends over Wi-Fi (with what came before the connection, so boot messages // switched on (ADR 0010) also a ring buffer it sends over Wi-Fi, with what came before the connection.
// aren't lost). Use `console` instead of Serial for anything a human should be able to read remotely. // Use `console` instead of Serial for anything a human should be able to read remotely.
class Console : public Print { class Console : public Print {
public: public:
size_t write(uint8_t c) override { return write(&c, 1); } size_t write(uint8_t c) override { return write(&c, 1); }
size_t write(const uint8_t* data, size_t len) override; size_t write(const uint8_t* data, size_t len) override;
using Print::write; using Print::write;
#ifdef RORO_DEBUG
static constexpr size_t kRingBytes = 4096; static constexpr size_t kRingBytes = 4096;
// The ring exists only while the Debug Console is on: 4 KB of heap a device that never uses it
// keeps. False if there's no memory for it.
bool openRing();
void closeRing();
bool ringOpen() const { return ring_ != nullptr; }
// Also copies ESP-IDF's own log lines into the ring (they still reach the serial port). // Also copies ESP-IDF's own log lines into the ring (they still reach the serial port).
void captureEspLogs(); void captureEspLogs();
// Copies bytes written since `pos` into `out`, and advances `pos`. A reader that fell more than // Copies bytes written since `pos` into `out`, and advances `pos`. A reader that fell more than
@@ -30,9 +35,8 @@ class Console : public Print {
void toRing(const uint8_t* data, size_t len); void toRing(const uint8_t* data, size_t len);
private: private:
uint8_t ring_[kRingBytes]; uint8_t* ring_ = nullptr;
uint32_t head_ = 0; // total bytes ever written; the ring holds the last kRingBytes of them uint32_t head_ = 0; // total bytes written since the ring opened; it holds the last kRingBytes of them
#endif
}; };
extern Console console; extern Console console;
+98 -21
View File
@@ -1,4 +1,3 @@
#ifdef RORO_DEBUG
#include "services/debug_console.h" #include "services/debug_console.h"
@@ -7,6 +6,7 @@
#include <algorithm> #include <algorithm>
#include <esp_core_dump.h> #include <esp_core_dump.h>
#include <esp_flash.h> #include <esp_flash.h>
#include <esp_random.h>
#include <SD.h> #include <SD.h>
#include <unistd.h> #include <unistd.h>
@@ -29,14 +29,6 @@ constexpr uint32_t kAuthTimeoutMs = 10000;
constexpr size_t kMaxLine = 240; constexpr size_t kMaxLine = 240;
constexpr size_t kMaxQueued = 8; constexpr size_t kMaxQueued = 8;
// Compares without stopping at the first difference, so timing says nothing about the token.
bool sameToken(const std::string& a, const char* b) {
size_t n = strlen(b);
uint8_t diff = a.size() != n;
for (size_t i = 0; i < n; i++) diff |= (i < a.size() ? a[i] : 0) ^ b[i];
return diff == 0;
}
// Reads one line (without its \r\n) within `timeoutMs`; false on a timeout, a drop or an overlong line. // Reads one line (without its \r\n) within `timeoutMs`; false on a timeout, a drop or an overlong line.
bool readLine(NetworkClient& c, std::string& line, uint32_t timeoutMs) { bool readLine(NetworkClient& c, std::string& line, uint32_t timeoutMs) {
line.clear(); line.clear();
@@ -77,12 +69,40 @@ void sendCoreDump(NetworkClient& client) {
} // namespace } // namespace
DebugConsole::DebugConsole(WifiService& wifi, StorageService& storage) DebugConsole::DebugConsole(WifiService& wifi, StorageService& storage, Settings& settings)
: wifi_(wifi), storage_(storage), lock_(xSemaphoreCreateMutex()) {} : wifi_(wifi), storage_(storage), settings_(settings), lock_(xSemaphoreCreateMutex()) {}
void DebugConsole::start() { std::string DebugConsole::freshToken() {
uint8_t random[debug::kTokenRandom];
esp_fill_random(random, sizeof random); // the hardware generator: true random with the radio on
return debug::makeToken(random);
}
void DebugConsole::switchOn(Settings& settings) {
if (settings.getString(Setting::DebugToken).empty()) settings.setString(Setting::DebugToken, freshToken());
settings.setBool(Setting::DebugConsole, true);
}
void DebugConsole::tick(uint32_t) { apply(); }
// The main loop's side. The task frees what it holds and clears task_ when it sees wanted_ go:
// until then a new one isn't started, so switching off and on again quickly takes a tick or two.
void DebugConsole::apply() {
const std::string& token = settings_.getString(Setting::DebugToken);
bool want = settings_.getBool(Setting::DebugConsole) && !token.empty(); // no token, nobody could get in: stay closed
xSemaphoreTake(lock_, portMAX_DELAY);
if (token != token_) {
token_ = token;
tokenSeq_ = tokenSeq_ + 1;
}
xSemaphoreGive(lock_);
wanted_ = want;
if (!want || task_) return;
if (!console.openRing()) return (void)console.println("debug: no memory for the console");
console.captureEspLogs(); console.captureEspLogs();
if (!task_) xTaskCreate(taskEntry, "debug", 6144, this, 1, &task_); TaskHandle_t made = nullptr;
if (xTaskCreate(taskEntry, "debug", 6144, this, 1, &made) == pdPASS) task_ = made;
else console.closeRing();
} }
bool DebugConsole::takeCommand(std::string& line) { bool DebugConsole::takeCommand(std::string& line) {
@@ -96,16 +116,37 @@ bool DebugConsole::takeCommand(std::string& line) {
return any; return any;
} }
bool DebugConsole::takeAlert(std::string& text) {
xSemaphoreTake(lock_, portMAX_DELAY);
bool any = !alert_.empty();
if (any) text = std::move(alert_);
alert_.clear();
xSemaphoreGive(lock_);
return any;
}
void DebugConsole::taskEntry(void* self) { static_cast<DebugConsole*>(self)->listen(); } void DebugConsole::taskEntry(void* self) { static_cast<DebugConsole*>(self)->listen(); }
void DebugConsole::listen() { void DebugConsole::listen() {
{
NetworkServer server(kPort); NetworkServer server(kPort);
bool listening = false; bool listening = false, complained = false;
for (;;) { while (wanted_) {
bool up = wifi_.state() == WifiController::State::Connected; bool up = wifi_.state() == WifiController::State::Connected;
if (up && !listening) { if (up && !listening) {
// begin() gives up without a word (no socket, the port taken): ask whether it
// listens, and try again rather than believe it.
server.begin(); server.begin();
listening = true; listening = static_cast<bool>(server);
if (!listening) {
if (!complained) console.printf("debug: can't listen on port %u (errno %d), trying again\n", kPort, errno);
complained = true;
server.end(); // closes the socket a failed begin() leaves open
vTaskDelay(pdMS_TO_TICKS(2000));
} else if (complained) {
console.printf("debug: listening on port %u\n", kPort);
complained = false;
}
} else if (!up && listening) { } else if (!up && listening) {
server.end(); server.end();
listening = false; listening = false;
@@ -120,14 +161,50 @@ void DebugConsole::listen() {
} }
vTaskDelay(pdMS_TO_TICKS(200)); vTaskDelay(pdMS_TO_TICKS(200));
} }
if (listening) server.end();
}
// Switched off: nothing is left behind (Q189). The commands a client queued die with it. (Free
// heap dips by about 270 bytes for each connection the device closed, for two minutes: TCP keeps
// them that long. Measured over 15 switches: all of it comes back.)
xSemaphoreTake(lock_, portMAX_DELAY);
commands_.clear();
xSemaphoreGive(lock_);
console.closeRing();
task_ = nullptr;
vTaskDelete(nullptr);
} }
// The token never crosses the network (Q193): the device sends 16 random bytes, the client sends
// back their HMAC-SHA256 keyed by the token. A recorded answer is no use for the next challenge.
bool DebugConsole::authenticate(NetworkClient& client) { bool DebugConsole::authenticate(NetworkClient& client) {
std::string token; if (gate_.locked(millis())) {
if (readLine(client, token, kAuthTimeoutMs) && sameToken(token, RORO_DEBUG_TOKEN)) return true; client.print("locked\n");
console.printf("debug: refused a client from %s\n", client.remoteIP().toString().c_str()); return false;
}
xSemaphoreTake(lock_, portMAX_DELAY);
std::string token = token_;
xSemaphoreGive(lock_);
uint8_t nonce[debug::kNonceBytes];
esp_fill_random(nonce, sizeof nonce);
client.printf("%s debug console, challenge %s\n", kProductName, debug::toHex(nonce, sizeof nonce).c_str());
std::string answer;
bool answered = readLine(client, answer, kAuthTimeoutMs);
if (answered && !token.empty() && debug::sameText(answer, debug::answerFor(token, nonce))) {
gate_.succeeded();
return true;
}
std::string from = client.remoteIP().toString().c_str();
console.printf("debug: refused a client from %s\n", from.c_str());
delay(1000); // no quick retries delay(1000); // no quick retries
client.print("denied\n"); client.print("denied\n");
if (answered && gate_.failed(millis())) { // a connection that says nothing isn't a guess
console.printf("debug: %d wrong tokens in a row, closed for %lu s\n", debug::AuthGate::kMaxFailures,
(unsigned long)(debug::AuthGate::kLockMs / 1000));
xSemaphoreTake(lock_, portMAX_DELAY);
alert_ = "Console closed, wrong tokens: " + from; // an Event's text holds 47 characters
xSemaphoreGive(lock_);
}
return false; return false;
} }
@@ -135,10 +212,11 @@ void DebugConsole::serve(NetworkClient& client) {
console.printf("debug: client %s connected\n", client.remoteIP().toString().c_str()); console.printf("debug: client %s connected\n", client.remoteIP().toString().c_str());
client.printf("%s %s debug console. 'help' lists the commands. Backlog follows.\n", kProductName, versionString()); client.printf("%s %s debug console. 'help' lists the commands. Backlog follows.\n", kProductName, versionString());
connected_ = true; connected_ = true;
uint32_t tokenSeq = tokenSeq_;
uint32_t pos = console.oldest(); uint32_t pos = console.oldest();
uint8_t buf[512]; uint8_t buf[512];
std::string line; std::string line;
while (client.connected()) { while (client.connected() && wanted_ && tokenSeq == tokenSeq_) { // switched off, or a new token: out
// Console output since last time, including the replies to this client's commands. // Console output since last time, including the replies to this client's commands.
uint32_t skipped = 0; uint32_t skipped = 0;
size_t n; size_t n;
@@ -313,4 +391,3 @@ void DebugConsole::screenshot(NetworkClient& client) {
} // namespace roro } // namespace roro
#endif
+27 -11
View File
@@ -1,7 +1,5 @@
#pragma once #pragma once
#ifdef RORO_DEBUG
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/semphr.h> #include <freertos/semphr.h>
@@ -9,18 +7,22 @@
#include <functional> #include <functional>
#include <string> #include <string>
#include "debug_auth.h"
#include "service.h" #include "service.h"
#include "services/storage_service.h" #include "services/storage_service.h"
#include "services/wifi_service.h" #include "services/wifi_service.h"
#include "settings.h"
#include "ui/canvas.h" #include "ui/canvas.h"
class NetworkClient; class NetworkClient;
namespace roro { namespace roro {
// Debug Builds only (ADR 0004): the console over Wi-Fi, on TCP 2323 while Wi-Fi is Connected. A // The console over Wi-Fi, on TCP 2323 while Wi-Fi is Connected, in every build but only while it's
// client sends the debug token as its first line, then gets the recent console backlog, every new // switched on in Settings (ADR 0010): off, nothing listens, and neither its task nor the console's
// console line, and runs the same commands as the serial port. One client at a time. // ring exists. A client answers a challenge with its token (debug_auth.h), then gets the recent
// console backlog, every new console line, and runs the same commands as the serial port. One
// client at a time.
// //
// The socket lives on this Service's own task; commands are handed to the main loop (takeCommand), // The socket lives on this Service's own task; commands are handed to the main loop (takeCommand),
// which runs them where touching Apps and Services is safe. Their output reaches the client // which runs them where touching Apps and Services is safe. Their output reaches the client
@@ -29,18 +31,29 @@ class DebugConsole : public Service {
public: public:
static constexpr uint16_t kPort = 2323; static constexpr uint16_t kPort = 2323;
DebugConsole(WifiService& wifi, StorageService& storage); DebugConsole(WifiService& wifi, StorageService& storage, Settings& settings);
// The off-screen frame the UI composes into: `screenshot` sends it as it stands. // The off-screen frame the UI composes into: `screenshot` sends it as it stands.
void setFrame(Canvas& frame) { frame_ = &frame; } void setFrame(Canvas& frame) { frame_ = &frame; }
const char* name() const override { return "debug"; } const char* name() const override { return "debug"; }
void start() override; void start() override { apply(); }
// Follows the settings: starts or stops listening, and takes a changed token (which drops a client).
void tick(uint32_t nowMs) override;
// Switches the console on, making a token first if there's none (Q191). The settings are what
// counts: this only writes them.
static void switchOn(Settings& settings);
static std::string freshToken();
// The next command line a client sent, for the main loop to run. // The next command line a client sent, for the main loop to run.
bool takeCommand(std::string& line); bool takeCommand(std::string& line);
// News for the user that the task can't publish itself (a pause after wrong tokens).
bool takeAlert(std::string& text);
bool on() const { return wanted_; }
bool clientConnected() const { return connected_; } bool clientConnected() const { return connected_; }
private: private:
static void taskEntry(void* self); static void taskEntry(void* self);
void apply();
void listen(); void listen();
void serve(::NetworkClient& client); void serve(::NetworkClient& client);
bool authenticate(::NetworkClient& client); bool authenticate(::NetworkClient& client);
@@ -55,13 +68,16 @@ class DebugConsole : public Service {
WifiService& wifi_; WifiService& wifi_;
StorageService& storage_; StorageService& storage_;
Settings& settings_;
Canvas* frame_ = nullptr; Canvas* frame_ = nullptr;
TaskHandle_t task_ = nullptr; volatile TaskHandle_t task_ = nullptr; // null once the task has freed everything and gone
SemaphoreHandle_t lock_; SemaphoreHandle_t lock_; // commands_, token_, alert_
std::deque<std::string> commands_; std::deque<std::string> commands_;
std::string token_, alert_;
volatile uint32_t tokenSeq_ = 0; // changes with the token: an open connection ends
volatile bool wanted_ = false;
volatile bool connected_ = false; volatile bool connected_ = false;
debug::AuthGate gate_; // the task's own
}; };
} // namespace roro } // namespace roro
#endif
-2
View File
@@ -1,4 +1,3 @@
#ifdef RORO_DEBUG
#include "services/noise_test.h" #include "services/noise_test.h"
@@ -107,4 +106,3 @@ void NoiseTest::printReport(Print& out) const {
} // namespace roro } // namespace roro
#endif // RORO_DEBUG
-2
View File
@@ -1,6 +1,5 @@
#pragma once #pragma once
#ifdef RORO_DEBUG
#include <Print.h> #include <Print.h>
@@ -56,4 +55,3 @@ class NoiseTest {
} // namespace roro } // namespace roro
#endif // RORO_DEBUG
-2
View File
@@ -190,7 +190,6 @@ void RadioService::store(RadioPacket& p) {
lastPacketMs_ = p.ms; lastPacketMs_ = p.ms;
} }
#ifdef RORO_DEBUG
void RadioService::debugAntenna(bool on) { void RadioService::debugAntenna(bool on) {
auto& i2c = M5.In_I2C; auto& i2c = M5.In_I2C;
uint8_t out = i2c.readRegister8(kExpander, kOutput, kI2cFreq); uint8_t out = i2c.readRegister8(kExpander, kOutput, kI2cFreq);
@@ -208,7 +207,6 @@ void RadioService::inject(const uint8_t* data, size_t len, float rssi, float snr
store(p); store(p);
console.printf("lora inject: #%lu, %u B\n", (unsigned long)p.seq, p.len); console.printf("lora inject: #%lu, %u B\n", (unsigned long)p.seq, p.len);
} }
#endif
// Continuous receive. Only RX done raises DIO1; preambles and headers are only recorded in the // Continuous receive. Only RX done raises DIO1; preambles and headers are only recorded in the
// IRQ status, which sampleNoise() reads. // IRQ status, which sampleNoise() reads.
-2
View File
@@ -99,7 +99,6 @@ class RadioService : public Service {
void printStatus(Print& out) const; void printStatus(Print& out) const;
void setEcho(bool echo); void setEcho(bool echo);
void probe(Print& out); // `lora probe`: runs on the radio task void probe(Print& out); // `lora probe`: runs on the radio task
#ifdef RORO_DEBUG
// For the noise self-test (issue #20): the chip's regulator as an LDO instead of its DC-DC // For the noise self-test (issue #20): the chip's regulator as an LDO instead of its DC-DC
// converter, and receive gain boosted or not. Used the next time the radio is set up. // converter, and receive gain boosted or not. Used the next time the radio is set up.
void debugOptions(bool ldo, bool boostedGain) { void debugOptions(bool ldo, bool boostedGain) {
@@ -112,7 +111,6 @@ class RadioService : public Service {
// `lora inject`: a packet into the ring as if received, to test the App and Captures with no // `lora inject`: a packet into the ring as if received, to test the App and Captures with no
// transmitter in range. Nothing goes on air. // transmitter in range. Nothing goes on air.
void inject(const uint8_t* data, size_t len, float rssi, float snr); void inject(const uint8_t* data, size_t len, float rssi, float snr);
#endif
private: private:
enum Notify : uint32_t { kIrq = 1, kRequest = 2 }; enum Notify : uint32_t { kIrq = 1, kRequest = 2 };
+1 -8
View File
@@ -215,9 +215,8 @@ std::string UpdateService::failedVersion() const {
return failed; return failed;
} }
std::string UpdateService::requestInstall(const std::string& tag, bool force) { std::string UpdateService::requestInstall(const std::string& tag) {
if (phase_ != Phase::Idle || giteaBusy()) return "Busy with something else"; if (phase_ != Phase::Idle || giteaBusy()) return "Busy with something else";
if (!force && release::isDebugBuild(runningVersion())) return "Debug Build: update from the PC"; // short: it is drawn in one line at the screen's foot
if (wifi_.state() != WifiController::State::Connected) return "No Wi-Fi"; if (wifi_.state() != WifiController::State::Connected) return "No Wi-Fi";
release::Release r; release::Release r;
if (!gitea_.find(tag, r)) return "Look for releases first"; if (!gitea_.find(tag, r)) return "Look for releases first";
@@ -295,14 +294,12 @@ void UpdateService::serve() {
if (gitea_.find(installTag_, release)) installFromGitea(release); if (gitea_.find(installTag_, release)) installFromGitea(release);
break; break;
} }
#ifdef RORO_DEBUG
case Request::Probe: { case Request::Probe: {
HttpsGet get(net::User::Updates); HttpsGet get(net::User::Updates);
std::string why = get.open(probeHost_, probePath_, "*/*"); std::string why = get.open(probeHost_, probePath_, "*/*");
probeResult_ = why.empty() ? "accepted, the server answered 200" : why; probeResult_ = why.empty() ? "accepted, the server answered 200" : why;
break; break;
} }
#endif
case Request::None: break; case Request::None: break;
} }
// A check or a list someone asked for that failed says so; the daily one stays quiet. // A check or a list someone asked for that failed says so; the daily one stays quiet.
@@ -328,12 +325,8 @@ void UpdateService::installFromGitea(const release::Release& r) {
notify("Update refused: " + why, NotificationLevel::Warning); notify("Update refused: " + why, NotificationLevel::Warning);
return; return;
} }
#ifdef RORO_DEBUG
GiteaSource source(get, damageCut_, damageFlip_); GiteaSource source(get, damageCut_, damageFlip_);
damageCut_ = damageFlip_ = -1; damageCut_ = damageFlip_ = -1;
#else
GiteaSource source(get, -1, -1);
#endif
install(source, "Gitea"); install(source, "Gitea");
} }
+2 -7
View File
@@ -50,9 +50,8 @@ class UpdateService : public Service {
void requestCheck() { request_ = Request::Check; } void requestCheck() { request_ = Request::Check; }
void requestList() { request_ = Request::List; } void requestList() { request_ = Request::List; }
// Downloads `tag` (a release known from the last check or list) into the inactive slot and // Downloads `tag` (a release known from the last check or list) into the inactive slot and
// installs it. "" when it started, or why not. A Debug Build doesn't install a release (Q171, // installs it. "" when it started, or why not.
// it would take its Debug Console away) unless `force`, which only the Debug Console passes. std::string requestInstall(const std::string& tag);
std::string requestInstall(const std::string& tag, bool force = false);
bool giteaBusy() const { return request_ != Request::None || serving_; } bool giteaBusy() const { return request_ != Request::None || serving_; }
// Asked to make room for a TLS connection (R1, Q172): IRC steps aside while the Update Service // Asked to make room for a TLS connection (R1, Q172): IRC steps aside while the Update Service
// talks to Gitea. Set by the main loop; `true` to step aside, `false` to come back. // talks to Gitea. Set by the main loop; `true` to step aside, `false` to come back.
@@ -63,14 +62,12 @@ class UpdateService : public Service {
std::string runningVersion() const { return fakeVersion_.empty() ? versionString() : fakeVersion_; } std::string runningVersion() const { return fakeVersion_.empty() ? versionString() : fakeVersion_; }
void pretendVersion(const std::string& v) { fakeVersion_ = v; } void pretendVersion(const std::string& v) { fakeVersion_ = v; }
std::string failedVersion() const; // a release that rolled back here, "" if none std::string failedVersion() const; // a release that rolled back here, "" if none
#ifdef RORO_DEBUG
// Debug Builds only, to try the refusals on the real network path: one HTTPS GET to any host // Debug Builds only, to try the refusals on the real network path: one HTTPS GET to any host
// (is its certificate accepted?), and a download cut short or with one byte flipped. // (is its certificate accepted?), and a download cut short or with one byte flipped.
void requestProbe(const std::string& host, const std::string& path) { probeHost_ = host; probePath_ = path; probeResult_ = "..."; request_ = Request::Probe; } void requestProbe(const std::string& host, const std::string& path) { probeHost_ = host; probePath_ = path; probeResult_ = "..."; request_ = Request::Probe; }
std::string probeResult() const { return probeResult_; } std::string probeResult() const { return probeResult_; }
void damageNextDownload(long cutAfter, long flipAt) { damageCut_ = cutAfter; damageFlip_ = flipAt; } void damageNextDownload(long cutAfter, long flipAt) { damageCut_ = cutAfter; damageFlip_ = flipAt; }
void forgetToday() { store_.putInt("rel_day", 0); nextCheckMs_ = 0; announced_.clear(); } // the daily check runs at the next tick void forgetToday() { store_.putInt("rel_day", 0); nextCheckMs_ = 0; announced_.clear(); } // the daily check runs at the next tick
#endif
// The main loop calls this once it has drawn a frame (part of Probation). // The main loop calls this once it has drawn a frame (part of Probation).
void firstFrameDrawn() { firstFrame_ = true; } void firstFrameDrawn() { firstFrame_ = true; }
@@ -109,11 +106,9 @@ class UpdateService : public Service {
std::string installTag_, fakeVersion_, announced_; std::string installTag_, fakeVersion_, announced_;
bool dailyCheck_ = false; bool dailyCheck_ = false;
uint32_t nextCheckMs_ = 90000; // not before the device has settled uint32_t nextCheckMs_ = 90000; // not before the device has settled
#ifdef RORO_DEBUG
std::string probeHost_, probePath_; std::string probeHost_, probePath_;
volatile long damageCut_ = -1, damageFlip_ = -1; volatile long damageCut_ = -1, damageFlip_ = -1;
std::string probeResult_; std::string probeResult_;
#endif
}; };
} // namespace roro } // namespace roro
-2
View File
@@ -56,11 +56,9 @@ class WifiService : public Service {
void ipSettingChanged(const std::string& ssid); void ipSettingChanged(const std::string& ssid);
// The DNS or NTP settings changed: use them now. // The DNS or NTP settings changed: use them now.
void serversChanged() { applyServers(Why::SettingsChanged); } void serversChanged() { applyServers(Why::SettingsChanged); }
#ifdef RORO_DEBUG
// The noise self-test switches the radio off for a few seconds. Not saved anywhere: a restart // The noise self-test switches the radio off for a few seconds. Not saved anywhere: a restart
// during the test brings Wi-Fi back, which a changed setting wouldn't. // during the test brings Wi-Fi back, which a changed setting wouldn't.
void debugPause(bool paused) { paused_ = paused; } void debugPause(bool paused) { paused_ = paused; }
#endif
// A Saved Network was added: try it now rather than after the retry delay. // A Saved Network was added: try it now rather than after the retry delay.
void savedNetworksChanged() { controller_.retryNow(millis()); } void savedNetworksChanged() { controller_.retryNow(millis()); }
+5
View File
@@ -48,6 +48,11 @@ void statusBar(Canvas& c, const StatusInfo& info) {
case StatusInfo::Wifi::Monitoring: right("MON", kAccent); break; case StatusInfo::Wifi::Monitoring: right("MON", kAccent); break;
case StatusInfo::Wifi::None: break; case StatusInfo::Wifi::None: break;
} }
switch (info.debug) { // Q190: there while the console listens, bright with someone connected
case StatusInfo::Debug::On: right("DBG", kMuted); break;
case StatusInfo::Debug::Client: right("DBG", kAccent); break;
case StatusInfo::Debug::Off: break;
}
if (info.tracking) right("REC", kWarning); if (info.tracking) right("REC", kWarning);
if (info.capturing) right("CAP", kWarning); if (info.capturing) right("CAP", kWarning);
switch (info.radio) { // Q101: muted while listening, bright for a moment on each packet switch (info.radio) { // Q101: muted while listening, bright for a moment on each packet
+2 -1
View File
@@ -29,12 +29,13 @@ struct StatusInfo {
bool tracking = false; // a Track is recording (Q63) bool tracking = false; // a Track is recording (Q63)
enum class Radio { None, Listening, Packet, Sweep } radio = Radio::None; // M3, Q101: Packet flashes enum class Radio { None, Listening, Packet, Sweep } radio = Radio::None; // M3, Q101: Packet flashes
bool capturing = false; // a LoRa Capture is recording (Q97) bool capturing = false; // a LoRa Capture is recording (Q97)
enum class Debug { Off, On, Client } debug = Debug::Off; // the Debug Console listens (ADR 0010, Q190)
bool operator==(const StatusInfo& o) const { bool operator==(const StatusInfo& o) const {
return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock && return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock &&
sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose && wifi == o.wifi && sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose && wifi == o.wifi &&
wifiBars == o.wifiBars && unread == o.unread && gnss == o.gnss && gnssSatellites == o.gnssSatellites && wifiBars == o.wifiBars && unread == o.unread && gnss == o.gnss && gnssSatellites == o.gnssSatellites &&
tracking == o.tracking && radio == o.radio && capturing == o.capturing; tracking == o.tracking && radio == o.radio && capturing == o.capturing && debug == o.debug;
} }
bool operator!=(const StatusInfo& o) const { return !(*this == o); } bool operator!=(const StatusInfo& o) const { return !(*this == o); }
}; };
+127
View File
@@ -0,0 +1,127 @@
#include <unity.h>
#include <cstring>
#include <string>
#include "debug_auth.h"
using namespace roro::debug;
void setUp() {}
void tearDown() {}
static std::string hmacHex(const std::string& key, const std::string& message) {
uint8_t mac[32];
hmacSha256(reinterpret_cast<const uint8_t*>(key.data()), key.size(), reinterpret_cast<const uint8_t*>(message.data()),
message.size(), mac);
return toHex(mac, sizeof mac);
}
// RFC 4231, test cases 1, 2 and 6.
void test_hmac_matches_the_rfc_vectors() {
TEST_ASSERT_EQUAL_STRING("b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7",
hmacHex(std::string(20, '\x0b'), "Hi There").c_str());
TEST_ASSERT_EQUAL_STRING("5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843",
hmacHex("Jefe", "what do ya want for nothing?").c_str());
TEST_ASSERT_EQUAL_STRING("60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54",
hmacHex(std::string(131, '\xaa'), "Test Using Larger Than Block-Size Key - Hash Key First").c_str());
}
void test_a_made_token_is_20_characters_without_lookalikes() {
uint8_t zeros[kTokenRandom] = {}, ones[kTokenRandom], counting[kTokenRandom];
memset(ones, 0xff, sizeof ones);
for (size_t i = 0; i < sizeof counting; i++) counting[i] = static_cast<uint8_t>(i);
TEST_ASSERT_EQUAL_STRING("00000000000000000000", makeToken(zeros).c_str());
TEST_ASSERT_EQUAL_STRING("ZZZZZZZZZZZZZZZZZZZZ", makeToken(ones).c_str());
TEST_ASSERT_EQUAL_STRING("000G40R40M30E209185G", makeToken(counting).c_str()); // as Python's reference gives
for (char c : makeToken(counting)) TEST_ASSERT_NULL(strchr("ILOU", c));
TEST_ASSERT_TRUE(validToken(makeToken(counting)));
}
void test_a_typed_token_is_tidied() {
TEST_ASSERT_EQUAL_STRING("K7QF3M2X9WBDHT4P6RNC", tidyToken("k7qf-3m2x 9wbd-HT4P-6rnc\n").c_str());
TEST_ASSERT_EQUAL_STRING("K7QF-3M2X-9WBD-HT4P-6RNC", groupToken("K7QF3M2X9WBDHT4P6RNC").c_str());
TEST_ASSERT_EQUAL_STRING("K7QF3M2X9WBDHT4P6RNC", tidyToken(groupToken("K7QF3M2X9WBDHT4P6RNC")).c_str());
// Misread as letters the alphabet doesn't have: taken for the digits they look like.
TEST_ASSERT_EQUAL_STRING("1010", tidyToken("IOlo").c_str());
}
void test_a_token_needs_16_to_64_printable_characters() {
TEST_ASSERT_FALSE(validToken(""));
TEST_ASSERT_FALSE(validToken("1234"));
TEST_ASSERT_FALSE(validToken(std::string(15, 'A')));
TEST_ASSERT_TRUE(validToken(std::string(16, 'A')));
TEST_ASSERT_TRUE(validToken(std::string(64, 'A')));
TEST_ASSERT_FALSE(validToken(std::string(65, 'A')));
TEST_ASSERT_FALSE(validToken("AAAAAAAAAAAAAAA A")); // untidied: a space
TEST_ASSERT_FALSE(validToken("aaaaaaaaaaaaaaaaaaaa")); // untidied: small letters
TEST_ASSERT_FALSE(validToken("AAAAAAAAAAAAAAAAAAAO")); // untidied: an O nobody could ever match
TEST_ASSERT_TRUE(validToken(tidyToken("hello-world-this-is-long")));
TEST_ASSERT_FALSE(validToken(std::string(16, '\x01')));
// The token of before, 32 hex digits from a file, still fits once tidied.
TEST_ASSERT_TRUE(validToken(tidyToken("0f1e2d3c4b5a69788796a5b4c3d2e1f0")));
}
// The same vector scripts/rdbg.py is checked against: Python's hmac.new(token, nonce, sha256).
void test_the_answer_is_the_hmac_of_the_nonce() {
uint8_t nonce[kNonceBytes];
for (size_t i = 0; i < sizeof nonce; i++) nonce[i] = static_cast<uint8_t>(i);
TEST_ASSERT_EQUAL_STRING("e1246c7e74d711cdb27d8d9346515829a01cf46d79fbbc1137885446f12ed2ba",
answerFor("K7QF3M2X9WBDHT4P6RNC", nonce).c_str());
nonce[0] ^= 1; // another challenge, another answer: a recorded one is no use
TEST_ASSERT_FALSE(answerFor("K7QF3M2X9WBDHT4P6RNC", nonce) == "e1246c7e74d711cdb27d8d9346515829a01cf46d79fbbc1137885446f12ed2ba");
}
void test_same_text() {
TEST_ASSERT_TRUE(sameText("abc", "abc"));
TEST_ASSERT_FALSE(sameText("abc", "abd"));
TEST_ASSERT_FALSE(sameText("ab", "abc"));
TEST_ASSERT_FALSE(sameText("abcd", "abc"));
TEST_ASSERT_FALSE(sameText("", "abc"));
TEST_ASSERT_TRUE(sameText("", ""));
}
void test_five_wrong_answers_lock_for_a_minute() {
AuthGate gate;
for (int i = 0; i < 4; i++) {
TEST_ASSERT_FALSE(gate.failed(1000 + i));
TEST_ASSERT_FALSE(gate.locked(1000 + i));
}
TEST_ASSERT_TRUE(gate.failed(2000)); // the fifth starts the pause
TEST_ASSERT_TRUE(gate.locked(2001));
TEST_ASSERT_FALSE(gate.failed(2002)); // nothing is counted meanwhile
TEST_ASSERT_TRUE(gate.locked(2000 + AuthGate::kLockMs - 1));
TEST_ASSERT_FALSE(gate.locked(2000 + AuthGate::kLockMs));
TEST_ASSERT_EQUAL(0, gate.failures()); // and the count starts again
}
void test_a_right_answer_forgets_the_wrong_ones() {
AuthGate gate;
for (int i = 0; i < 4; i++) gate.failed(i);
gate.succeeded();
for (int i = 0; i < 4; i++) TEST_ASSERT_FALSE(gate.failed(10 + i));
TEST_ASSERT_FALSE(gate.locked(20));
}
void test_the_lock_holds_across_the_clock_wrap() {
AuthGate gate;
uint32_t t = 0xFFFFFFF0u; // 16 ms before millis() wraps
for (int i = 0; i < 5; i++) gate.failed(t);
TEST_ASSERT_TRUE(gate.locked(t + 100)); // after the wrap: still locked
TEST_ASSERT_TRUE(gate.locked(t + AuthGate::kLockMs - 1));
TEST_ASSERT_FALSE(gate.locked(t + AuthGate::kLockMs));
}
int main(int, char**) {
UNITY_BEGIN();
RUN_TEST(test_hmac_matches_the_rfc_vectors);
RUN_TEST(test_a_made_token_is_20_characters_without_lookalikes);
RUN_TEST(test_a_typed_token_is_tidied);
RUN_TEST(test_a_token_needs_16_to_64_printable_characters);
RUN_TEST(test_the_answer_is_the_hmac_of_the_nonce);
RUN_TEST(test_same_text);
RUN_TEST(test_five_wrong_answers_lock_for_a_minute);
RUN_TEST(test_a_right_answer_forgets_the_wrong_ones);
RUN_TEST(test_the_lock_holds_across_the_clock_wrap);
return UNITY_END();
}
-2
View File
@@ -115,8 +115,6 @@ void test_which_releases_are_updates() {
TEST_ASSERT_FALSE(shouldAnnounce(r, "v0.10.0", "v0.11.0", "")); // it rolled back here before TEST_ASSERT_FALSE(shouldAnnounce(r, "v0.10.0", "v0.11.0", "")); // it rolled back here before
TEST_ASSERT_FALSE(shouldAnnounce(r, "v0.10.0", "", "v0.11.0")); // already said so TEST_ASSERT_FALSE(shouldAnnounce(r, "v0.10.0", "", "v0.11.0")); // already said so
TEST_ASSERT_TRUE(shouldAnnounce(r, "v0.10.0", "v0.10.5", "v0.10.9")); TEST_ASSERT_TRUE(shouldAnnounce(r, "v0.10.0", "v0.10.5", "v0.10.9"));
TEST_ASSERT_TRUE(isDebugBuild("v0.10.0-3-gabc+debug"));
TEST_ASSERT_FALSE(isDebugBuild("v0.10.0"));
} }
void test_only_the_projects_downloads_are_fetched() { void test_only_the_projects_downloads_are_fetched() {
+37
View File
@@ -185,6 +185,41 @@ void test_dns_and_ntp_are_checked() {
TEST_ASSERT_TRUE(s.setString(Setting::Ntp2, "")); TEST_ASSERT_TRUE(s.setString(Setting::Ntp2, ""));
} }
// ADR 0010: off, and no token, until the owner switches the console on. A stored value that isn't
// valid counts as missing, so a damaged store can't switch it on or leave a weak token.
void test_the_debug_console_is_off_and_has_no_token_by_default() {
MemoryStore store;
EventBus bus;
Settings s(store, bus);
s.load();
TEST_ASSERT_FALSE(s.getBool(Setting::DebugConsole));
TEST_ASSERT_EQUAL_STRING("", s.getString(Setting::DebugToken).c_str());
MemoryStore damaged;
damaged.ints["debug_on"] = 7; // not a bool
damaged.strings["debug_token"] = "1234"; // too short
Settings d(damaged, bus);
d.load();
TEST_ASSERT_FALSE(d.getBool(Setting::DebugConsole));
TEST_ASSERT_EQUAL_STRING("", d.getString(Setting::DebugToken).c_str());
}
void test_a_debug_token_must_be_valid_or_empty() {
MemoryStore store;
EventBus bus;
Settings s(store, bus);
s.load();
TEST_ASSERT_FALSE(s.setString(Setting::DebugToken, "1234"));
TEST_ASSERT_FALSE(s.setString(Setting::DebugToken, "k7qf-3m2x-9wbd-ht4p-6rnc")); // not tidied
TEST_ASSERT_TRUE(s.setString(Setting::DebugToken, "K7QF3M2X9WBDHT4P6RNC"));
TEST_ASSERT_TRUE(s.setBool(Setting::DebugConsole, true));
Settings again(store, bus);
again.load();
TEST_ASSERT_TRUE(again.getBool(Setting::DebugConsole));
TEST_ASSERT_EQUAL_STRING("K7QF3M2X9WBDHT4P6RNC", again.getString(Setting::DebugToken).c_str());
TEST_ASSERT_TRUE(again.setString(Setting::DebugToken, "")); // forgotten
}
int main() { int main() {
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_defaults_when_store_is_empty); RUN_TEST(test_defaults_when_store_is_empty);
@@ -201,5 +236,7 @@ int main() {
RUN_TEST(test_storage_keys_fit_nvs_limit); RUN_TEST(test_storage_keys_fit_nvs_limit);
RUN_TEST(test_dns_and_ntp_defaults); RUN_TEST(test_dns_and_ntp_defaults);
RUN_TEST(test_dns_and_ntp_are_checked); RUN_TEST(test_dns_and_ntp_are_checked);
RUN_TEST(test_the_debug_console_is_off_and_has_no_token_by_default);
RUN_TEST(test_a_debug_token_must_be_valid_or_empty);
return UNITY_END(); return UNITY_END();
} }
@@ -97,6 +97,17 @@ void test_wifi_is_a_page() {
TEST_ASSERT_EQUAL_STRING("On", f.menu.value(wifi).c_str()); TEST_ASSERT_EQUAL_STRING("On", f.menu.value(wifi).c_str());
} }
void test_the_debug_console_is_a_page_that_says_off() {
Fixture f;
int row = f.row(SettingsMenu::Row::DebugConsole);
TEST_ASSERT_TRUE(row >= 0);
TEST_ASSERT_EQUAL(static_cast<int>(SettingsMenu::Kind::Page), static_cast<int>(f.menu.kind(row)));
TEST_ASSERT_EQUAL_STRING("Debug Console", f.menu.label(row).c_str());
TEST_ASSERT_EQUAL_STRING("Off", f.menu.value(row).c_str()); // Q189
f.settings.setBool(Setting::DebugConsole, true);
TEST_ASSERT_EQUAL_STRING("On", f.menu.value(row).c_str());
}
void test_names_are_text_rows_with_their_byte_limits() { void test_names_are_text_rows_with_their_byte_limits() {
Fixture f; Fixture f;
int shortName = f.row(SettingsMenu::Row::ShortName); int shortName = f.row(SettingsMenu::Row::ShortName);
@@ -159,5 +170,6 @@ int main() {
RUN_TEST(test_pause_gnss_for_lora_is_off_by_default); RUN_TEST(test_pause_gnss_for_lora_is_off_by_default);
RUN_TEST(test_check_for_updates_is_on_by_default); RUN_TEST(test_check_for_updates_is_on_by_default);
RUN_TEST(test_gnss_and_coordinate_rows_toggle); RUN_TEST(test_gnss_and_coordinate_rows_toggle);
RUN_TEST(test_the_debug_console_is_a_page_that_says_off);
return UNITY_END(); return UNITY_END();
} }