Public Access
One firmware: the Debug Console in every build, off until switched on (#68) #70
@@ -1,7 +1,7 @@
|
||||
# CI and releases (docs/milestones/R1.md).
|
||||
# A push to main: the host tests, with their coverage of lib/, and the README's badges
|
||||
# published to the branch `badges`.
|
||||
# A pull request: the same tests and coverage, then the release firmware and the Debug Build.
|
||||
# A pull request: the same tests and coverage, then the firmware.
|
||||
# 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.
|
||||
# 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'
|
||||
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'
|
||||
run: scripts/ci.sh builds
|
||||
|
||||
|
||||
+4
-8
@@ -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**.
|
||||
|
||||
**Release**:
|
||||
A tag `v*` on the project's Gitea with a signed **Update File**, a factory image for USB, the ELF to decode crashes and checksums, built and published by CI. The device reads them to look for updates. Debug Builds aren't published.
|
||||
A tag `v*` on the project's Gitea with a signed **Update File**, a factory image for USB, the ELF to decode crashes and checksums, built and published by CI. The device reads them to look for updates.
|
||||
_Avoid_: flash, upgrade (alone)
|
||||
|
||||
**Update File**:
|
||||
@@ -160,16 +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)
|
||||
|
||||
**Safe Mode**:
|
||||
What the firmware starts instead of everything else after 3 crash restarts in a row: Wi-Fi and Firmware Updates (and the Debug Console in a Debug Build), so it can be fixed without a cable. A normal restart leaves it.
|
||||
What the firmware starts instead of everything else after 3 crash restarts in a row: Wi-Fi and Firmware Updates (and the Debug Console if it's switched on), so it can be fixed without a cable. A normal restart leaves it.
|
||||
_Avoid_: recovery mode, failsafe
|
||||
|
||||
**Debug Build**:
|
||||
A firmware built with the remote debugging aids compiled in (`+debug` in its version). Release builds have none of them.
|
||||
_Avoid_: dev build, test build (a test build is one made to fail on purpose, such as a crashing update)
|
||||
|
||||
**Debug Console**:
|
||||
The console of a Debug Build over Wi-Fi: live log lines and the serial commands, behind a token.
|
||||
_Avoid_: telnet, remote shell
|
||||
The console over Wi-Fi, in every firmware but off until switched on in Settings: live log lines and the serial commands, for whoever holds the device's token.
|
||||
_Avoid_: telnet, remote shell, Debug Build (there is one firmware)
|
||||
|
||||
## Relationships
|
||||
|
||||
|
||||
@@ -26,14 +26,14 @@ The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkc
|
||||
|
||||
## CI and releases
|
||||
|
||||
Gitea Actions (`.gitea/workflows/ci.yml`, docs/milestones/R1.md) runs the host tests on every push to `main`, and on a pull request also builds the release firmware and the Debug Build: changes reach `main` through pull requests. Pushing a tag `v*` runs all of it and publishes a release on Gitea with:
|
||||
Gitea Actions (`.gitea/workflows/ci.yml`, docs/milestones/R1.md) runs the host tests on every push to `main`, and on a pull request also builds the firmware: changes reach `main` through pull requests. Pushing a tag `v*` runs all of it and publishes a release on Gitea with:
|
||||
|
||||
- `roro9stack-<version>.ota`, the signed Update File;
|
||||
- `roro9stack-<version>-factory.bin`, the whole flash image for a first install over USB;
|
||||
- `roro9stack-<version>.elf.gz`, to decode crash reports from that build;
|
||||
- `SHA256SUMS`.
|
||||
|
||||
CI signs with the project's key, held as a repository secret (ADR 0008). Debug Builds are built but never published: each carries its builder's Debug Console token.
|
||||
CI signs with the project's key, held as a repository secret (ADR 0008). There is one firmware: the Debug Console is in every build, switched off until its owner switches it on (ADR 0010).
|
||||
|
||||
`scripts/ota_verify.py <file.ota>` checks an Update File on a PC the way a device does. `scripts/release_build.sh` and `scripts/release_publish.py` are what the workflow runs; they work the same by hand.
|
||||
|
||||
@@ -87,7 +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.
|
||||
|
||||
**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
|
||||
|
||||
@@ -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) |
|
||||
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
|
||||
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
|
||||
| `wifi ip <ssid> dhcp` / `wifi ip <ssid> <address>/<prefix> [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Debug Builds: add `try <seconds>` to go back to the previous setting unless `wifi ip keep` follows |
|
||||
| `wifi ip <ssid> dhcp` / `wifi ip <ssid> <address>/<prefix> [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Add `try <seconds>` to go back to the previous setting unless `wifi ip keep` follows |
|
||||
| `wifi dns <a> [b]` / `wifi dns always on\|off` / `wifi ntp <a> [b]` | DNS servers (used on Fixed networks, or always), and NTP servers |
|
||||
| `log <text>` | Appends a line to a test IRC Log (`/irc/dev/#test/<date>.log`) |
|
||||
| `sd card` | What the SD card says it is: type, size, and its identity register (maker, name, revision, serial, date) |
|
||||
| `sd list` | Lists the files of each Storage Clean-up category |
|
||||
| `sd fill <folder> <count>` | Debug Builds: makes that many small files in a folder, to test a crowded one |
|
||||
| `sd fill <folder> <count>` | Makes that many small files in a folder, to test a crowded one |
|
||||
| `cat <path>` | Prints the first ~1.2 KB of a file on the SD card |
|
||||
| `irc start` | Starts the IRC Service (normally done by opening the IRC App) |
|
||||
| `irc stop` | Stops it, as `/quit` does: QUIT if connected, no more retries, and the App stays disconnected until you type |
|
||||
@@ -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 |
|
||||
| `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 |
|
||||
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one (not on a Debug Build) |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Debug Builds: pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
|
||||
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
|
||||
| `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor |
|
||||
| `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack |
|
||||
| `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
|
||||
@@ -198,26 +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 nmea on` / `gnss nmea off` | Prints each NMEA sentence the receiver sends, as `nmea: …` |
|
||||
| `gnss send <sentence>` | Sends a sentence to the receiver, without the `$` and the checksum (it adds them) |
|
||||
| `lora noise test [gnss\|quiet]` / `lora noise report` | Debug Builds: Sweep under one changed condition at a time to find what raises the noise floor (Wi-Fi goes off for a few seconds); then the result |
|
||||
| `lora inject <hex> [rssi] [snr]` | Debug Builds: a packet into the Scanner as if received (nothing is sent) |
|
||||
| `lora noise test [gnss\|quiet]` / `lora noise report` | Sweep under one changed condition at a time to find what raises the noise floor (Wi-Fi goes off for a few seconds); then the result |
|
||||
| `lora inject <hex> [rssi] [snr]` | A packet into the Scanner as if received (nothing is sent) |
|
||||
| `crash` | The last crash: which firmware, why, task, PC and backtrace (from the core dump in flash) |
|
||||
| `coredump erase` | Forgets the core dump |
|
||||
| `loop spin on` / `loop spin off` | Debug Builds: make the main loop spin without resting, to compare load and radio noise |
|
||||
| `crash abort` / `crash wdt` | Debug Builds: crash on purpose, or hang the main loop until the watchdog fires |
|
||||
| `loop spin on` / `loop spin off` | Make the main loop spin without resting, to compare load and radio noise |
|
||||
| `crash abort` / `crash wdt` | Crash on purpose, or hang the main loop until the watchdog fires |
|
||||
| `debug status` / `debug off` | The Debug Console: whether it's on, has a token and a client; switch it off |
|
||||
| `debug on` / `debug token <value>` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed |
|
||||
| `help` | Lists the commands |
|
||||
|
||||
`scripts/flash.sh` stops a running serial log first, since it would hold the port.
|
||||
|
||||
### Debug Builds and the Debug Console
|
||||
### The Debug Console
|
||||
|
||||
`scripts/flash.sh --debug` (USB) or `scripts/flash.sh --debug --ota <ip>` (Wi-Fi) installs a Debug Build: the same firmware plus the Debug Console on TCP 2323 (ADR 0004). Then, with `RORO_OTA_HOST` set to the device's IP:
|
||||
Every firmware has the Debug Console (ADR 0010): the serial console over Wi-Fi, on TCP 2323. It is **off** until you switch it on in **Settings → Debug Console**, which also makes its token and shows it. With the device on USB, `scripts/flash.sh --debug` flashes it, switches the console on and gives it the token in `~/.config/roro9stack/debug-token`, so nothing has to be typed. Then, with `RORO_OTA_HOST` set to the device's IP:
|
||||
|
||||
```sh
|
||||
scripts/rdbg.py # interactive: the console backlog, live lines, and commands
|
||||
scripts/rdbg.py info # one command and its reply
|
||||
scripts/rdbg.py -b tasks # the same, after the backlog (boot messages and so on)
|
||||
scripts/rdbg.py -t K7QF-3M2X-9WBD-HT4P-6RNC info # another device's token; or $RORO_DEBUG_TOKEN
|
||||
```
|
||||
|
||||
The token never crosses the network: the device sends a challenge and `rdbg.py` answers with its HMAC. Five wrong answers in a row close the console for a minute. `debug status` and `debug off` work from anywhere; `debug on`, `debug token <value>` and `debug token new` work over USB serial only.
|
||||
|
||||
Every command above works there too, plus a few handled by the PC side or the console's own task:
|
||||
|
||||
```sh
|
||||
@@ -231,6 +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.
|
||||
|
||||
Every build keeps its ELF in `.pio/elves/` (version and digest in the name) for that; `scripts/decode_backtrace.sh <version|digest> <addresses>` decodes any backtrace by hand.
|
||||
Every build keeps its ELF in `.pio/elves/` (version and digest in the name) for that, and `rdbg.py crash` fetches a release's ELF from Gitea when it isn't there; `scripts/decode_backtrace.sh <version|digest> <addresses>` decodes any backtrace by hand.
|
||||
|
||||
After 3 crash restarts in a row the firmware starts in **Safe Mode** (ADR 0005): only Wi-Fi, Firmware Updates and the Debug Console, so a fix can be pushed as usual. `reboot` leaves it. The token is in `~/.config/roro9stack/debug-token`, made by the first build; keep developing on Debug Builds, so the firmware a Rollback returns to always has the console.
|
||||
After 3 crash restarts in a row the firmware starts in **Safe Mode** (ADR 0005): only Wi-Fi, Firmware Updates and, if it's switched on, the Debug Console, so a fix can be pushed as usual. `reboot` leaves it.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# A Debug Console over Wi-Fi, in Debug Builds only
|
||||
|
||||
**Superseded in part by [ADR 0010](0010-debug-console-in-every-build.md) (2026-10-06):** there is no Debug Build any more. The console is in every firmware, off until switched on, and its token belongs to the device, not to the build. What this record says about how the console works (the ring, commands on the main loop, binary commands on its own task, one client) still holds.
|
||||
|
||||
The goal of Firmware Updates is to manage the device without a cable, and that includes finding out what went wrong. So a **Debug Build** (`cardputer-adv-debug`, `-DRORO_DEBUG`, version suffix `+debug`) adds a **Debug Console** on TCP 2323: the serial console, over Wi-Fi. A client sends a token as its first line, then gets the last 4 KB of console output (boot messages included), every new line live, and runs the same commands as the serial port, plus a few that only make sense remotely. ESP-IDF's own log lines are teed into it.
|
||||
|
||||
It's compiled out of release builds entirely, rather than switched off by a setting. A console that runs commands is a remote control: in a release build, nothing listens.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Safe Mode, crash reports and a watched main loop, in every build
|
||||
|
||||
Rollback protects against new firmware that fails Probation. It does nothing for firmware that was confirmed and crashes later: a corrupt setting, a server that sends something unexpected, a bug that takes an hour to show. Without a cable, such a device would restart forever. Three measures, in release and Debug Builds alike, keep it reachable:
|
||||
Rollback protects against new firmware that fails Probation. It does nothing for firmware that was confirmed and crashes later: a corrupt setting, a server that sends something unexpected, a bug that takes an hour to show. Without a cable, such a device would restart forever. Three measures, in every build, keep it reachable:
|
||||
|
||||
- **Safe Mode.** The firmware counts starts that follow a crash (panic or watchdog) in NVS, first thing at boot. After 3 in a row, it starts only the clock, Wi-Fi, the Update Service and, in a Debug Build, the Debug Console: no Apps, no IRC, no SD card, and a screen that says so with the address to push an update to. Any normal restart (a `reboot`, an update), or a minute of uptime, resets the count.
|
||||
- **Crash reports.** The same boot record keeps which version was running, so after a crash the firmware knows which one crashed, even when a Rollback has switched slots since. ESP-IDF already writes a core dump to its flash partition on a panic; after the restart the firmware prints its summary (task, PC, reason, backtrace) and raises a Notification. The `crash` command shows it again later. In a Debug Build, `scripts/rdbg.py crash` decodes the backtrace and `scripts/rdbg.py coredump` fetches the whole dump for `esp-coredump`, against the ELF of that exact build (`.pio/elves/`, named by version and ELF digest).
|
||||
- **Safe Mode.** The firmware counts starts that follow a crash (panic or watchdog) in NVS, first thing at boot. After 3 in a row, it starts only the clock, Wi-Fi, the Update Service and, if it's switched on, the Debug Console: no Apps, no IRC, no SD card, and a screen that says so with the address to push an update to. Any normal restart (a `reboot`, an update), or a minute of uptime, resets the count.
|
||||
- **Crash reports.** The same boot record keeps which version was running, so after a crash the firmware knows which one crashed, even when a Rollback has switched slots since. ESP-IDF already writes a core dump to its flash partition on a panic; after the restart the firmware prints its summary (task, PC, reason, backtrace) and raises a Notification. The `crash` command shows it again later. With the Debug Console, `scripts/rdbg.py crash` decodes the backtrace and `scripts/rdbg.py coredump` fetches the whole dump for `esp-coredump`, against the ELF of that exact build (`.pio/elves/`, named by version and ELF digest).
|
||||
- **The main loop is watched.** Arduino-ESP32 subscribes only core 0's idle task to the task watchdog, and the main loop runs on core 1: a stuck loop used to hang the device for good, with the screen frozen and the Debug Console unable to run commands. `enableLoopWDT()` makes a loop stuck for 5 s a panic, with a core dump, counted towards Safe Mode. And an installed update no longer depends on the main loop: the Update Service restarts into it by itself after 90 s.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# The Debug Console is in every build, off until its owner switches it on
|
||||
|
||||
There is **one firmware**. The Debug Console of ADR 0004 is compiled into it, and so are the commands that used to exist only in Debug Builds. It listens only while **Settings → Debug Console** is on, which is not the default, and it lets in whoever proves they hold **the device's own token**. The `cardputer-adv-debug` environment, `RORO_DEBUG`, the `+debug` version and the token compiled in from the builder's machine are gone.
|
||||
|
||||
ADR 0004 kept the console out of release builds with one argument: a console that runs commands is a remote control, so in a release nothing should listen. That argument was never whole: the Update Service has listened on TCP 3232 in every build since Firmware Updates exist, guarded by a signature. A listener that is off by default and guarded by a secret the device made itself is the same kind of trade, and what the split cost had grown:
|
||||
|
||||
- **What was tested wasn't what shipped.** Development ran on Debug Builds; releases were a different binary, built to be published and never run by the person who wrote them.
|
||||
- **Debug Builds couldn't be published**, since each carried its builder's token. So the console, `rdbg.py`, screenshots and crash decoding were for whoever built the firmware, and nobody else.
|
||||
- **Rules that existed only to protect the console:** a Debug Build never installed a release (it would have lost the console), `update install … force` to do it anyway, "keep a Debug Build in the fallback slot", versions with `+debug` that had to compare equal to their release.
|
||||
- **CI built two firmwares** on every pull request and every tag.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Off means nothing is there.** With the setting off, no socket listens, the console's task doesn't exist and neither does its 4 KB ring: a device that never uses the console pays for it in flash (30 KB more than the release build was) and 88 bytes of static RAM, measured. A missing or invalid stored setting counts as off.
|
||||
- **The token is the device's.** The first time the console is switched on, the device makes one from its hardware random generator: 100 bits, as 20 characters of Crockford's base32 (`K7QF-3M2X-9WBD-HT4P-6RNC`), shown on the Debug Console page and nowhere else. It can be replaced by one typed by hand, of 16 to 64 characters. It is stored with the other settings and is never printed on a console.
|
||||
- **The token never crosses the network.** The device sends 16 random bytes; the client answers with their HMAC-SHA256 keyed by the token. Someone on the same Wi-Fi who records a login has nothing they can use for the next one.
|
||||
- **Five wrong answers in a row** close the console to everyone for a minute, with a Notification naming the address they came from. A wrong answer still costs a second.
|
||||
- **`DBG` in the Status Bar** while the console listens, bright while a client is connected.
|
||||
- **USB serial can set it up:** `debug on`, `debug token <value>`, `debug token new`. `scripts/flash.sh --debug` uses them to give a freshly flashed device the developer's token. Whoever holds the cable can flash anything anyway (ADR 0003); the console itself can only switch itself off.
|
||||
- **Safe Mode** starts the console if it's switched on: the settings are read before Safe Mode is decided.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **"Nothing listens" now rests on one stored setting**, not on absent code. That setting is typed, validated and host-tested, and its default is off; a bug that flipped it would have to come from code that already runs on the device.
|
||||
- **Whoever has the token and the network has the device:** the console, its keys, the SD card's files, a restart. Not its firmware: an update still has to be signed (ADR 0003).
|
||||
- **The stream is still plain text.** The token is safe from an eavesdropper; what the console prints, and the files it carries, are not. TLS would cost about 52 KB of the 107 KB there is, next to IRC's own connection: not for a console.
|
||||
- **An old `rdbg.py` can't talk to a new firmware**, and the reverse: the first line of the protocol changed. Accepted, with one user.
|
||||
- **A device whose settings are damaged has no console in Safe Mode**, only the signed update push. Before, a Debug Build always had it.
|
||||
- **The commands that crash, damage a download or fill a folder on purpose** are in every build. They need the cable or the token, like everything else.
|
||||
- **Releases already publish their ELF**, so `rdbg.py crash` fetches it when it isn't in `.pio/elves/`: crash reports can be decoded without having built the firmware.
|
||||
+46
-2
@@ -14,7 +14,7 @@ Until now the tests, the builds, the signing and the flashing all happened on on
|
||||
|---|---|
|
||||
| Q151 | A push to `main`: the host tests (with their coverage). A push to another branch: nothing, its pull request is what runs (a branch with an open pull request ran twice, once for each). A pull request: the same and both builds; changes reach `main` through pull requests. A tag `v*`: all of it, then a release. (First: everything on every push, which rebuilt the firmware far more often than anyone looked at it.) |
|
||||
| Q152 | **CI signs.** The signing key is the repository secret `OTA_SIGNING_KEY`; a tag push makes a complete, signed release with no manual step (ADR 0008). |
|
||||
| Q153 | The Debug Build is built in CI with a token of the runner's own, to prove it compiles, and **isn't published**: it would hand everyone its Debug Console token. |
|
||||
| Q153 | *Revised by Q188: there is one firmware.* The Debug Build is built in CI with a token of the runner's own, to prove it compiles, and **isn't published**: it would hand everyone its Debug Console token. |
|
||||
| Q154 | Pull requests from forks don't start a run. |
|
||||
| Q155 | A release carries `roro9stack-<version>.ota` (signed), `-factory.bin` for USB, `.elf.gz` to decode crashes, and `SHA256SUMS`. |
|
||||
| Q156 | Its text is the tag's message, what the files are, and the commits since the tag before. |
|
||||
@@ -64,7 +64,7 @@ The device looks at the project's Gitea for a newer release, says so, and instal
|
||||
| Q168 | A version that failed (rolled back) isn't announced again by the background check until a newer one exists; it can still be installed by hand. |
|
||||
| Q169 | **Older releases:** a list of the last ten, newest first, the running one marked. Installing an older one asks with a stronger warning. |
|
||||
| Q170 | Enter on a release shows its version, date, size and the tag message, with Install. |
|
||||
| Q171 | **Debug Builds** check and show the latest release, but don't install it: it would replace the Debug Build and its console (Q153: Debug Builds aren't published). Their updates come from the PC. |
|
||||
| Q171 | *Revised by Q188: there is no Debug Build, every firmware installs releases.* **Debug Builds** check and show the latest release, but don't install it: it would replace the Debug Build and its console (Q153: Debug Builds aren't published). Their updates come from the PC. |
|
||||
| Q172 | **Memory, as measured:** a TLS connection peaks at about 52 KB of heap, with or without checking the certificate, so it starts with 80 KB free (Q86's 20 KB spare on top), not 55 KB. **A check, list or install someone asked for makes IRC step aside** and come back after; the daily check never does, and with IRC connected it waits. (First: 55 KB and nothing else. With IRC connected a check left 3 KB and a download 836 bytes.) |
|
||||
| Q173 | Left out, each with its issue: installing automatically (#52), a release channel (#53), resuming a download (#54). |
|
||||
| Q174 | Ships as **v0.11.0**. Tested on the device with the real signed releases; a Debug Build command pretends the device runs an older version, so v0.10.0 counts as an update. |
|
||||
@@ -123,3 +123,47 @@ The device looks at the project's Gitea for a newer release, says so, and instal
|
||||
- **A key press during the hold:** Back on the Firmware page while a check is going doesn't cancel it.
|
||||
|
||||
**Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure.
|
||||
|
||||
## One firmware: the Debug Console in every build (issue #68)
|
||||
|
||||
The issue asked for a token that could be set, so that Debug Builds could be published. The design round ended somewhere simpler: no Debug Builds. The console is in every firmware, off until switched on, with a token that belongs to the device (ADR 0010, which supersedes part of ADR 0004).
|
||||
|
||||
### Decisions (design round 2026-10-06)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q188 | **One firmware,** with the Debug Console and the test commands compiled in. The `cardputer-adv-debug` environment, `RORO_DEBUG`, the `+debug` version and `scripts/debug_flags.py` go; CI builds one firmware. Revises Q153 and Q171. |
|
||||
| Q189 | **Off by default,** and off when the stored setting is missing or invalid. Off, nothing listens and no memory is used: the task and the 4 KB ring exist only while it's on. |
|
||||
| Q190 | **Settings → Debug Console:** the switch, where to connect, the token, "New token", "Type a token". It stays on across restarts and in Safe Mode. **`DBG` in the Status Bar** while it listens, bright with a client connected. |
|
||||
| Q191 | **The device makes the token** the first time the console is switched on: 100 bits as 20 characters of Crockford's base32, shown in groups of four. It can be replaced by one typed by hand, of at least 16 characters. Case and dashes don't count. |
|
||||
| Q192 | **USB serial can set it up:** `debug on`, `debug token <value>`, `debug token new`, over USB only; `debug status` and `debug off` from anywhere. `scripts/flash.sh --debug` gives a device the developer's token. The token is never printed. |
|
||||
| Q193 | **Challenge and answer:** the device sends 16 random bytes, the client their HMAC-SHA256 keyed by the token. Five wrong answers in a row close the console for a minute, with a Notification. **Old clients and old firmwares don't talk to each other:** accepted, this far from v1 and with one user. |
|
||||
| Q194 | `rdbg.py` takes the token from **`-t`/`--token`**, then **`$RORO_DEBUG_TOKEN`**, then `~/.config/roro9stack/debug-token`. |
|
||||
| Q195 | **The test commands are in every build** (`crash abort`, `crash wdt`, `update pretend`, `update damage`, `lora inject`, `sd fill`, `loop spin`, `wifi ip … try`). `update install … force` goes: there's nothing left to protect. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/debug`** (host-tested, 9 tests): the token maker, tidying what a person types, HMAC-SHA256 on the project's own SHA-256 (checked against RFC 4231), the answer to a challenge (the same vector as `rdbg.py`'s), a comparison that takes the same time whatever it's given, and the pause after five wrong answers, right across the clock's wrap.
|
||||
- **Two settings,** `DebugConsole` and `DebugToken`, with the others: validated, and invalid stored values count as missing.
|
||||
- **The console's task and ring come and go with the setting.** `Console::openRing` allocates the ring; switching off closes the socket, frees it and ends the task. `tick` follows the settings once a second, so a switch off and on again takes a second or two.
|
||||
- **Measured against the two builds it replaces:** 1,881,799 bytes of flash and 54,700 of static RAM. The release build was 1,851,387 and 54,612 (so 30 KB more flash and 88 bytes more RAM); the Debug Build was 1,874,887 and 58,780 (4 KB of RAM back: the ring is no longer a static array).
|
||||
- **`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 |
|
||||
| Memory, console on with a client | 108 KB free (the Debug Build it replaces: 104 KB). In Safe Mode: 178 KB free |
|
||||
|
||||
**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.
|
||||
|
||||
@@ -24,7 +24,7 @@ const RowDef kRows[] = {
|
||||
{Row::Coordinates, Kind::Toggle, "Coordinates"},
|
||||
{Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"},
|
||||
{Row::CheckUpdates, Kind::Toggle, "Check for updates"}, {Row::Firmware, Kind::Page, "Firmware"},
|
||||
{Row::About, Kind::Page, "About"},
|
||||
{Row::DebugConsole, Kind::Page, "Debug Console"}, {Row::About, Kind::Page, "About"},
|
||||
};
|
||||
|
||||
const int kDimSeconds[] = {10, 15, 30, 60, 120, 300};
|
||||
@@ -86,6 +86,7 @@ std::string SettingsMenu::value(int i) const {
|
||||
case Row::Coordinates: return settings_.getBool(Setting::CoordinatesDms) ? "Deg min sec" : "Decimal";
|
||||
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
|
||||
case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
|
||||
case Row::DebugConsole: return settings_.getBool(Setting::DebugConsole) ? "On" : "Off";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace roro {
|
||||
// values, choice lists and validation messages. Rendering and navigation live in the App.
|
||||
class SettingsMenu {
|
||||
public:
|
||||
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, About };
|
||||
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, DebugConsole, About };
|
||||
enum class Kind { Text, Choice, Toggle, Slider, Page };
|
||||
|
||||
explicit SettingsMenu(Settings& settings) : settings_(settings) {}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#include "debug_auth.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "sha256.h"
|
||||
|
||||
namespace roro::debug {
|
||||
|
||||
namespace {
|
||||
const char kAlphabet[] = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
}
|
||||
|
||||
std::string makeToken(const uint8_t random[kTokenRandom]) {
|
||||
std::string token;
|
||||
uint32_t bits = 0;
|
||||
int have = 0;
|
||||
size_t next = 0;
|
||||
while (token.size() < kTokenChars) {
|
||||
if (have < 5) {
|
||||
bits = (bits << 8) | random[next++];
|
||||
have += 8;
|
||||
}
|
||||
token += kAlphabet[(bits >> (have - 5)) & 31];
|
||||
have -= 5;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
std::string tidyToken(const std::string& typed) {
|
||||
std::string out;
|
||||
for (char c : typed) {
|
||||
if (c == '-' || c == ' ' || c == '\t' || c == '\r' || c == '\n') continue;
|
||||
if (c >= 'a' && c <= 'z') c = static_cast<char>(c - 'a' + 'A');
|
||||
// Crockford's rule for the letters his alphabet leaves out: read as the digit they look like.
|
||||
if (c == 'O') c = '0';
|
||||
if (c == 'I' || c == 'L') c = '1';
|
||||
out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool validToken(const std::string& tidied) {
|
||||
if (tidied.size() < kMinTokenChars || tidied.size() > kMaxTokenChars) return false;
|
||||
for (char c : tidied)
|
||||
if (c <= ' ' || c > '~' || c == '-' || (c >= 'a' && c <= 'z') || c == 'O' || c == 'I' || c == 'L') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string groupToken(const std::string& token) {
|
||||
std::string out;
|
||||
for (size_t i = 0; i < token.size(); i++) {
|
||||
if (i && i % 4 == 0) out += '-';
|
||||
out += token[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void hmacSha256(const uint8_t* key, size_t keyLen, const uint8_t* message, size_t messageLen, uint8_t out[32]) {
|
||||
uint8_t block[64] = {};
|
||||
if (keyLen > sizeof block) Sha256::hash(key, keyLen, block); // a long key is hashed first
|
||||
else memcpy(block, key, keyLen);
|
||||
|
||||
uint8_t pad[64];
|
||||
for (size_t i = 0; i < sizeof pad; i++) pad[i] = block[i] ^ 0x36;
|
||||
uint8_t inner[32];
|
||||
Sha256 in;
|
||||
in.update(pad, sizeof pad);
|
||||
in.update(message, messageLen);
|
||||
in.finish(inner);
|
||||
|
||||
for (size_t i = 0; i < sizeof pad; i++) pad[i] = block[i] ^ 0x5c;
|
||||
Sha256 outer;
|
||||
outer.update(pad, sizeof pad);
|
||||
outer.update(inner, sizeof inner);
|
||||
outer.finish(out);
|
||||
}
|
||||
|
||||
std::string toHex(const uint8_t* data, size_t len) {
|
||||
static const char digits[] = "0123456789abcdef";
|
||||
std::string out;
|
||||
out.reserve(len * 2);
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
out += digits[data[i] >> 4];
|
||||
out += digits[data[i] & 15];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string answerFor(const std::string& token, const uint8_t nonce[kNonceBytes]) {
|
||||
uint8_t mac[32];
|
||||
hmacSha256(reinterpret_cast<const uint8_t*>(token.data()), token.size(), nonce, kNonceBytes, mac);
|
||||
return toHex(mac, sizeof mac);
|
||||
}
|
||||
|
||||
bool sameText(const std::string& a, const std::string& b) {
|
||||
uint8_t diff = a.size() != b.size();
|
||||
for (size_t i = 0; i < b.size(); i++) diff |= static_cast<uint8_t>((i < a.size() ? a[i] : 0) ^ b[i]);
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
bool AuthGate::locked(uint32_t nowMs) {
|
||||
if (locked_ && nowMs - lockedAtMs_ >= kLockMs) { // unsigned: right across the 49-day wrap too
|
||||
locked_ = false;
|
||||
failures_ = 0;
|
||||
}
|
||||
return locked_;
|
||||
}
|
||||
|
||||
bool AuthGate::failed(uint32_t nowMs) {
|
||||
if (locked(nowMs)) return false;
|
||||
if (++failures_ < kMaxFailures) return false;
|
||||
locked_ = true;
|
||||
lockedAtMs_ = nowMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace roro::debug
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
// Who may use the Debug Console (ADR 0010): a token that only the device and its owner know, proved
|
||||
// with a challenge and an answer so that it never crosses the network, and a pause after wrong answers.
|
||||
namespace roro::debug {
|
||||
|
||||
constexpr size_t kTokenChars = 20; // a token the device makes: 100 bits
|
||||
constexpr size_t kTokenRandom = 13; // the random bytes it takes
|
||||
constexpr size_t kMinTokenChars = 16; // a token typed by hand, once tidied
|
||||
constexpr size_t kMaxTokenChars = 64;
|
||||
constexpr size_t kNonceBytes = 16;
|
||||
|
||||
// A token from random bytes: 20 characters of Crockford's base32 (no I, L, O or U to misread).
|
||||
std::string makeToken(const uint8_t random[kTokenRandom]);
|
||||
|
||||
// What a person typed, as it's stored and compared: no dashes or spaces, in capitals, and with O
|
||||
// read as 0, I and L as 1. So a token can be read off the screen in groups, typed in any case,
|
||||
// and the usual misreadings don't matter.
|
||||
std::string tidyToken(const std::string& typed);
|
||||
|
||||
// A tidied token that may be stored: 16 to 64 printable ASCII characters, as tidyToken leaves them.
|
||||
bool validToken(const std::string& tidied);
|
||||
|
||||
// For the screen: K7QF-3M2X-9WBD-HT4P-6RNC.
|
||||
std::string groupToken(const std::string& token);
|
||||
|
||||
void hmacSha256(const uint8_t* key, size_t keyLen, const uint8_t* message, size_t messageLen, uint8_t out[32]);
|
||||
|
||||
std::string toHex(const uint8_t* data, size_t len);
|
||||
|
||||
// What a client must send back for a challenge: HMAC-SHA256 of the nonce, keyed by the token, in hex.
|
||||
std::string answerFor(const std::string& token, const uint8_t nonce[kNonceBytes]);
|
||||
|
||||
// Compares without stopping at the first difference, so timing says nothing about the answer.
|
||||
bool sameText(const std::string& a, const std::string& b);
|
||||
|
||||
// Five wrong answers in a row, from anyone, and nobody is listened to for a minute.
|
||||
class AuthGate {
|
||||
public:
|
||||
static constexpr int kMaxFailures = 5;
|
||||
static constexpr uint32_t kLockMs = 60000;
|
||||
|
||||
bool locked(uint32_t nowMs);
|
||||
// A wrong answer. True if it's the one that starts the pause.
|
||||
bool failed(uint32_t nowMs);
|
||||
void succeeded() { failures_ = 0; }
|
||||
int failures() const { return failures_; }
|
||||
|
||||
private:
|
||||
int failures_ = 0;
|
||||
bool locked_ = false;
|
||||
uint32_t lockedAtMs_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro::debug
|
||||
@@ -13,12 +13,6 @@ constexpr const char* kGiteaRepo = "twisla/roro9stack";
|
||||
|
||||
inline bool versionNewer(const std::string& a, const std::string& b) { return versionOlder(b, a); }
|
||||
|
||||
// "v0.10.0+debug": the Debug Build says so wherever the version shows (scripts/version.py).
|
||||
inline bool isDebugBuild(const std::string& version) {
|
||||
static const std::string tail = "+debug";
|
||||
return version.size() >= tail.size() && version.compare(version.size() - tail.size(), tail.size(), tail) == 0;
|
||||
}
|
||||
|
||||
// Is `release` a newer one than what runs?
|
||||
inline bool isNewer(const Release& release, const std::string& running) {
|
||||
return release.usable() && versionNewer(release.tag, running);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "settings.h"
|
||||
|
||||
#include "debug_auth.h"
|
||||
#include "ipv4.h"
|
||||
|
||||
namespace roro {
|
||||
@@ -41,6 +42,8 @@ const Definition kDefinitions[] = {
|
||||
{"ntp2", Kind::String, 0, "time.cloudflare.com", 0, 63},
|
||||
{"gnss_quiet", Kind::Bool, 0, nullptr, 0, 1}, // off: GNSS stays on, as decided in M2 (Q58)
|
||||
{"check_updates", Kind::Bool, 1, nullptr, 0, 1}, // on: it only looks, and says so (R1, Q165)
|
||||
{"debug_on", Kind::Bool, 0, nullptr, 0, 1}, // off: nothing listens until the owner says so (Q189)
|
||||
{"debug_token", Kind::String, 0, "", 0, 64}, // empty, or a valid token
|
||||
};
|
||||
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
|
||||
"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::Ntp1) return net::validHost(value);
|
||||
if (s == Setting::Ntp2) return value.empty() || net::validHost(value);
|
||||
if (s == Setting::DebugToken) return value.empty() || debug::validToken(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ enum class Setting : uint8_t {
|
||||
Ntp2, // string: the second, or empty
|
||||
GnssQuietForLora, // bool: put the GNSS receiver in standby while the LoRa radio listens (issue #20)
|
||||
CheckUpdates, // bool: look for a newer release on Gitea once a day (R1, Q165)
|
||||
DebugConsole, // bool: the Debug Console listens on Wi-Fi (ADR 0010, Q189: off unless switched on)
|
||||
DebugToken, // string: its token, tidied (debug_auth.h); empty until the console is first switched on
|
||||
Count
|
||||
};
|
||||
|
||||
|
||||
@@ -32,15 +32,6 @@ custom_sdkconfig =
|
||||
CONFIG_MBEDTLS_DYNAMIC_FREE_CONFIG_DATA=y
|
||||
CONFIG_MBEDTLS_DYNAMIC_FREE_CA_CERT=y
|
||||
|
||||
; Debug Build: the same firmware plus the Debug Console on TCP 2323 (see ADR 0004). The token comes
|
||||
; from ~/.config/roro9stack/debug-token, passed in by scripts/_docker.sh; it's never committed.
|
||||
[env:cardputer-adv-debug]
|
||||
extends = env:cardputer-adv
|
||||
extra_scripts = pre:scripts/version.py, pre:scripts/debug_flags.py
|
||||
build_flags =
|
||||
${env:cardputer-adv.build_flags}
|
||||
-DRORO_DEBUG
|
||||
|
||||
; Host-side unit tests for pure logic (no hardware).
|
||||
[env:native]
|
||||
platform = native
|
||||
|
||||
+3
-1
@@ -6,7 +6,9 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
[ -n "${RORO_NO_DOCKER:-}" ] || docker build -q -t "$IMAGE" "$ROOT/docker" >/dev/null
|
||||
|
||||
# The Debug Console token (ADR 0004): made once, kept with the OTA key, never committed.
|
||||
# The Debug Console token of the developer's device (ADR 0010): made once, kept with the OTA key, never
|
||||
# committed and never compiled in. scripts/flash.sh --debug gives it to a device over USB, and
|
||||
# scripts/rdbg.py answers the device's challenge with it.
|
||||
DEBUG_TOKEN_FILE="$HOME/.config/roro9stack/debug-token"
|
||||
if [ ! -s "$DEBUG_TOKEN_FILE" ]; then
|
||||
mkdir -p "$(dirname "$DEBUG_TOKEN_FILE")"
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@ DOCKER_EXTRA=()
|
||||
|
||||
case "${1:-all}" in
|
||||
tests) STEPS='pio test -e native' ;;
|
||||
builds) STEPS='pio run -e cardputer-adv -e cardputer-adv-debug' ;;
|
||||
all) STEPS='pio test -e native && pio run -e cardputer-adv -e cardputer-adv-debug' ;;
|
||||
builds) STEPS='pio run -e cardputer-adv' ;;
|
||||
all) STEPS='pio test -e native && pio run -e cardputer-adv' ;;
|
||||
*) echo "Usage: scripts/ci.sh [tests|builds]" >&2; exit 1 ;;
|
||||
esac
|
||||
run_in_container bash -c 'git config --global --add safe.directory "$PWD" && '"$STEPS"
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# Debug Builds only: compiles in the Debug Console token from $RORO_DEBUG_TOKEN (set by _docker.sh
|
||||
# from ~/.config/roro9stack/debug-token). Refuses to build without one rather than use a default.
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
Import("env") # noqa: F821 (provided by PlatformIO)
|
||||
|
||||
token = os.environ.get("RORO_DEBUG_TOKEN", "").strip()
|
||||
if not re.fullmatch(r"[0-9a-f]{32}", token):
|
||||
sys.exit("debug build: RORO_DEBUG_TOKEN is missing; build through scripts/ci.sh or scripts/flash.sh --debug")
|
||||
env.Append(CPPDEFINES=[("RORO_DEBUG_TOKEN", '\\"%s\\"' % token)]) # noqa: F821
|
||||
+15
-6
@@ -1,17 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Flash the firmware over USB, then open the serial monitor.
|
||||
# Usage: scripts/flash.sh [--debug] [port] USB (default: the first Espressif device found)
|
||||
# scripts/flash.sh [--debug] --ota [host] Wi-Fi: build, sign and push a Firmware Update
|
||||
# (host: the device's IP from Settings > About, or $RORO_OTA_HOST)
|
||||
# --debug builds the Debug Build (cardputer-adv-debug): the Debug Console on TCP 2323, see ADR 0004.
|
||||
# scripts/flash.sh --ota [host] Wi-Fi: build, sign and push a Firmware Update
|
||||
# (host: the device's IP from Settings > Firmware, or $RORO_OTA_HOST)
|
||||
# --debug (USB only): once flashed, switches the Debug Console on and gives it the token in
|
||||
# ~/.config/roro9stack/debug-token, over the serial port (ADR 0010, Q192), so scripts/rdbg.py works
|
||||
# at once. The settings survive later updates: it is needed once for a device, not at each flash.
|
||||
set -euo pipefail
|
||||
ENV=cardputer-adv
|
||||
PROVISION=
|
||||
if [ "${1:-}" = "--debug" ]; then
|
||||
ENV=cardputer-adv-debug
|
||||
PROVISION=1
|
||||
shift
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "--ota" ]; then
|
||||
[ -z "$PROVISION" ] || echo "flash.sh: --debug does nothing over Wi-Fi: the console's setting stays as it is on the device" >&2
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
HOST="${2:-${RORO_OTA_HOST:-}}"
|
||||
[ -n "$HOST" ] || { echo "Usage: scripts/flash.sh --ota <device IP> (or set RORO_OTA_HOST)" >&2; exit 1; }
|
||||
@@ -19,7 +23,6 @@ if [ "${1:-}" = "--ota" ]; then
|
||||
DOCKER_EXTRA=()
|
||||
run_in_container bash -c "git config --global --add safe.directory /work && pio run -e $ENV" | tail -3
|
||||
VERSION="$(git -C "$ROOT" describe --tags --always --dirty)"
|
||||
[ "$ENV" = cardputer-adv-debug ] && VERSION="$VERSION+debug" # as scripts/version.py names it
|
||||
OUT="$ROOT/.pio/build/$ENV/roro9stack-$VERSION.ota"
|
||||
"$ROOT/scripts/make_ota.py" "$ROOT/.pio/build/$ENV/firmware.bin" "$VERSION" "$OUT"
|
||||
exec "$ROOT/scripts/ota_push.py" "$OUT" "$HOST"
|
||||
@@ -30,4 +33,10 @@ source "$(dirname "$0")/_docker.sh"
|
||||
docker rm -f roro9stack-serial >/dev/null 2>&1 || true # a serial log would hold the port
|
||||
DOCKER_EXTRA=(--device "$PORT" --group-add "$(stat -c %g "$PORT")" -it)
|
||||
|
||||
run_in_container bash -c "git config --global --add safe.directory /work && pio run -e $ENV -t upload --upload-port $PORT && pio device monitor -p $PORT -b 115200"
|
||||
# The token is read from the environment inside the container (_docker.sh passes it), so it is on no
|
||||
# command line of the host; serial_log.py prints what the device says, not what it sends.
|
||||
SETUP=""
|
||||
# serial_log.py finds the port by its name, and follows it when the device re-enumerates after the upload.
|
||||
[ -z "$PROVISION" ] || DOCKER_EXTRA=(--group-add "$(stat -c %g "$PORT")" --privileged -v /dev:/dev -it)
|
||||
[ -z "$PROVISION" ] || SETUP='&& /pio/penv/bin/python scripts/serial_log.py 8 sleep:4 "debug token $RORO_DEBUG_TOKEN" "debug on"'
|
||||
run_in_container bash -c "git config --global --add safe.directory /work && pio run -e $ENV -t upload --upload-port $PORT $SETUP && pio device monitor -p $PORT -b 115200"
|
||||
|
||||
+85
-12
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The Debug Console of a Debug Build, over Wi-Fi (TCP 2323, see ADR 0004).
|
||||
"""The Debug Console, over Wi-Fi (TCP 2323, see ADR 0010). Switch it on first: Settings > Debug Console.
|
||||
|
||||
Usage: scripts/rdbg.py [-H host] [-b] [command ...]
|
||||
Usage: scripts/rdbg.py [-H host] [-t token] [-b] [command ...]
|
||||
no command interactive: type commands, see every console line live; Ctrl-D or `quit` leaves
|
||||
command runs it and prints what follows, until the console has been quiet for a moment
|
||||
-H host the device's IP (Settings > Firmware), default $RORO_OTA_HOST
|
||||
-H host the device's IP (Settings > Debug Console), default $RORO_OTA_HOST
|
||||
-t token the device's token (also --token), as Settings > Debug Console shows it: dashes and
|
||||
case don't matter. Otherwise $RORO_DEBUG_TOKEN, otherwise ~/.config/roro9stack/debug-token
|
||||
-b also print the backlog the device sends on connecting (boot messages and so on)
|
||||
|
||||
Commands handled here as well as on the device:
|
||||
@@ -14,9 +16,11 @@ Commands handled here as well as on the device:
|
||||
put <file> [card path] copy a file to the SD card (default /updates/<name>), checked with SHA-256
|
||||
screenshot [file.png] what the screen shows (default screen-<date>.png), at 2x
|
||||
reset restart at once, even if the main loop is stuck
|
||||
The token is read from ~/.config/roro9stack/debug-token (made by the first build).
|
||||
The token never crosses the network: the device sends a challenge, and this answers with its HMAC.
|
||||
"""
|
||||
import gzip
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
@@ -26,9 +30,53 @@ import zlib
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
PORT = 2323
|
||||
TOKEN_FILE = os.path.expanduser("~/.config/roro9stack/debug-token")
|
||||
RELEASES = "https://git.twis.la/twisla/roro9stack/releases/download"
|
||||
|
||||
|
||||
def tidy_token(typed):
|
||||
"""As the device stores it (lib/debug/src/debug_auth.cpp): no dashes or spaces, in capitals."""
|
||||
tidy = "".join(c for c in typed if c not in "- \t\r\n").upper()
|
||||
return tidy.replace("O", "0").replace("I", "1").replace("L", "1") # read as the digits they look like
|
||||
|
||||
|
||||
def answer_for(token, nonce):
|
||||
"""What the device expects back for a challenge: HMAC-SHA256 of the nonce, keyed by the token."""
|
||||
return hmac.new(token.encode(), nonce, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def find_token(given):
|
||||
token = given or os.environ.get("RORO_DEBUG_TOKEN")
|
||||
if not token and os.path.exists(TOKEN_FILE):
|
||||
token = open(TOKEN_FILE).read()
|
||||
token = tidy_token(token or "")
|
||||
if not token:
|
||||
sys.exit("No token: pass -t <token>, set RORO_DEBUG_TOKEN, or put it in " + TOKEN_FILE +
|
||||
"\n(the device shows it in Settings > Debug Console)")
|
||||
return token
|
||||
|
||||
|
||||
def log_in(sock, token):
|
||||
"""Answers the device's challenge; returns the banner line, or exits saying what went wrong."""
|
||||
first = read_until(sock, b"\n", 10)
|
||||
if first is None:
|
||||
sys.exit("device: nothing came back. Is the console switched on (Settings > Debug Console)?")
|
||||
line = first.decode(errors="replace").strip()
|
||||
if line.startswith("locked"):
|
||||
sys.exit("device: closed for a minute after too many wrong tokens")
|
||||
challenge = re.search(r"challenge ([0-9a-f]{32})$", line)
|
||||
if not challenge:
|
||||
sys.exit("device: no challenge (an older firmware?): " + line[:60])
|
||||
sock.sendall((answer_for(token, bytes.fromhex(challenge.group(1))) + "\n").encode())
|
||||
banner = read_until(sock, b"\n", 15)
|
||||
if banner is None:
|
||||
sys.exit("device: no answer")
|
||||
if banner.startswith(b"denied"):
|
||||
sys.exit("device: wrong token (Settings > Debug Console shows the right one)")
|
||||
return banner
|
||||
|
||||
|
||||
def read_until(sock, marker, timeout):
|
||||
@@ -102,17 +150,40 @@ def crash_firmware(reply):
|
||||
return version.group(1) if version else None
|
||||
|
||||
|
||||
def have_elf(reply):
|
||||
"""Makes sure .pio/elves/ holds the ELF of the firmware that crashed: a release's is on Gitea."""
|
||||
folder = os.path.join(os.path.dirname(SCRIPTS), ".pio", "elves")
|
||||
key = crash_firmware(reply)
|
||||
version = re.search(r"last one in (\S+)", reply)
|
||||
version = version.group(1) if version else None
|
||||
names = os.listdir(folder) if os.path.isdir(folder) else []
|
||||
if not key or any(key in n for n in names) or not version or not re.fullmatch(r"v\d+\.\d+\.\d+", version):
|
||||
return # there already, or not a release: only tags are published
|
||||
url = f"{RELEASES}/{version}/roro9stack-{version}.elf.gz"
|
||||
try:
|
||||
elf = gzip.decompress(urllib.request.urlopen(url, timeout=60).read())
|
||||
except Exception as e:
|
||||
return print(f"(no ELF for {version} here, and none fetched from {url}: {e})")
|
||||
digest = hashlib.sha256(elf).hexdigest()[:16]
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
path = os.path.join(folder, f"{version}.{digest}.elf") # as scripts/version.py names them
|
||||
open(path, "wb").write(elf)
|
||||
print(f"(fetched the ELF of {version} from its release: {os.path.relpath(path)})")
|
||||
|
||||
|
||||
def crash(sock):
|
||||
reply = run(sock, "crash")
|
||||
trace = re.search(r"backtrace((?: 0x[0-9a-f]+)+)", reply)
|
||||
key = crash_firmware(reply)
|
||||
if trace and key:
|
||||
have_elf(reply)
|
||||
print()
|
||||
subprocess.call([os.path.join(SCRIPTS, "decode_backtrace.sh"), key] + trace.group(1).split())
|
||||
|
||||
|
||||
def coredump(sock, path):
|
||||
info = run(sock, "crash", out=None)
|
||||
have_elf(info)
|
||||
sock.sendall(b"coredump get\n")
|
||||
# One buffer throughout: the header, the size and the first bytes often share a packet.
|
||||
buf = b""
|
||||
@@ -237,25 +308,27 @@ def interactive(sock):
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
host, backlog = os.environ.get("RORO_OTA_HOST"), False
|
||||
host, backlog, token = os.environ.get("RORO_OTA_HOST"), False, None
|
||||
while args and args[0].startswith("-"):
|
||||
flag = args.pop(0)
|
||||
if flag == "-H" and args:
|
||||
host = args.pop(0)
|
||||
elif flag in ("-t", "--token") and args:
|
||||
token = args.pop(0)
|
||||
elif flag == "-b":
|
||||
backlog = True
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
if not host:
|
||||
sys.exit("No device: pass -H <ip> or set RORO_OTA_HOST\n\n" + __doc__)
|
||||
token = open(TOKEN_FILE).read().strip()
|
||||
token = find_token(token)
|
||||
|
||||
with socket.create_connection((host, PORT), timeout=10) as sock:
|
||||
sock.sendall((token + "\n").encode())
|
||||
# The device may still be finishing a previous client: wait for this connection's banner.
|
||||
banner = read_until(sock, b"Backlog follows.\n", 15)
|
||||
if banner is None:
|
||||
sys.exit("device: no banner (wrong token, or another client is connected)")
|
||||
try:
|
||||
sock = socket.create_connection((host, PORT), timeout=10)
|
||||
except OSError as e:
|
||||
sys.exit(f"device: can't connect to {host}:{PORT} ({e}). Is the console switched on (Settings > Debug Console)?")
|
||||
with sock:
|
||||
banner = log_in(sock, token)
|
||||
show = sys.stdout if backlog or not args else None
|
||||
if show:
|
||||
show.write(banner.decode(errors="replace"))
|
||||
|
||||
@@ -10,9 +10,6 @@ try:
|
||||
except Exception:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -10,4 +10,4 @@ weight = 2
|
||||
eyebrow = "Developer docs"
|
||||
+++
|
||||
|
||||
The firmware is built, tested and released **in Docker**, so that a clean machine, your machine and the CI runner all get the same result. These pages say how. For the Debug Console, which is how you work on a device once it is flashed, see [Debug Builds and the Debug Console](/dev/debug/).
|
||||
The firmware is built, tested and released **in Docker**, so that a clean machine, your machine and the CI runner all get the same result. These pages say how. For the Debug Console, which is how you work on a device once it is flashed, see [The Debug Console](/dev/debug/).
|
||||
|
||||
@@ -26,13 +26,13 @@ The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkc
|
||||
|
||||
## CI and releases
|
||||
|
||||
Gitea Actions (`.gitea/workflows/ci.yml`, docs/milestones/R1.md) runs the host tests on every push to `main`, and on a pull request also builds the release firmware and the Debug Build: changes reach `main` through pull requests. Pushing a tag `v*` runs all of it and publishes a release on Gitea with:
|
||||
Gitea Actions (`.gitea/workflows/ci.yml`, docs/milestones/R1.md) runs the host tests on every push to `main`, and on a pull request also builds the firmware: changes reach `main` through pull requests. Pushing a tag `v*` runs all of it and publishes a release on Gitea with:
|
||||
|
||||
- `roro9stack-<version>.ota`, the signed Update File;
|
||||
- `roro9stack-<version>-factory.bin`, the whole flash image for a first install over USB;
|
||||
- `roro9stack-<version>.elf.gz`, to decode crash reports from that build;
|
||||
- `SHA256SUMS`.
|
||||
|
||||
CI signs with the project's key, held as a repository secret (ADR 0008). Debug Builds are built but never published: each carries its builder's Debug Console token.
|
||||
CI signs with the project's key, held as a repository secret (ADR 0008). There is one firmware: the Debug Console is in every build, switched off until its owner switches it on (ADR 0010).
|
||||
|
||||
`scripts/ota_verify.py <file.ota>` checks an Update File on a PC the way a device does. `scripts/release_build.sh` and `scripts/release_publish.py` are what the workflow runs; they work the same by hand.
|
||||
|
||||
@@ -54,4 +54,4 @@ The connection is checked against the two ISRG roots Let's Encrypt chains end in
|
||||
|
||||
**IRC steps aside.** A secure connection takes about 52 KB of memory at its peak, and IRC's own takes 40 KB of the 107 KB there is. A check or an install you ask for makes IRC disconnect for the few seconds it takes and reconnect afterwards. The daily check never does that: with IRC connected it waits for a moment when IRC isn't, so while IRC stays connected for days it doesn't run, and **Latest release** is the way to check.
|
||||
|
||||
**A Debug Build** shows the latest release but doesn't install it: releases have no Debug Console (they aren't built with one, so that nobody's console token is published), and installing one would take it away. A Debug Build is updated from the PC with `scripts/flash.sh --debug --ota`.
|
||||
**There is no separate Debug Build** any more (ADR 0010): every firmware installs releases, and the Debug Console is a setting, which an update leaves as it was.
|
||||
|
||||
@@ -24,7 +24,7 @@ scripts/ota_keygen.sh # once: creates the key p
|
||||
scripts/make_ota.py firmware.bin v0.12.0 out.ota # wraps and signs an image (the key: $RORO_OTA_KEY or the default path)
|
||||
scripts/ota_verify.py out.ota # checks a file the way a device does, on the PC
|
||||
scripts/ota_push.py out.ota 10.39.39.12 # pushes it to the Update Service, TCP 3232
|
||||
scripts/flash.sh --ota 10.39.39.12 # builds, signs and pushes in one go (add --debug for a Debug Build)
|
||||
scripts/flash.sh --ota 10.39.39.12 # builds, signs and pushes in one go
|
||||
```
|
||||
|
||||
`ota_push.py` prints the device's answer (`OK …`), or says the device **refused the update and hung up**, with the reason on the device's screen: the header is checked first, so a refused file stops mid-transfer.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<text x="30" y="75" font-size="11" class="wi-dim">builds, signs, pushes</text>
|
||||
<rect class="wi-box" x="16" y="112" width="220" height="56" rx="6"/>
|
||||
<text x="30" y="135" font-size="12" font-weight="600">rdbg.py put</text>
|
||||
<text x="30" y="155" font-size="11" class="wi-dim">Debug Build, Wi-Fi 2323</text>
|
||||
<text x="30" y="155" font-size="11" class="wi-dim">Debug Console, Wi-Fi 2323</text>
|
||||
<rect class="wi-box" x="16" y="192" width="220" height="56" rx="6"/>
|
||||
<text x="30" y="215" font-size="12" font-weight="600">sd_put.sh</text>
|
||||
<text x="30" y="235" font-size="11" class="wi-dim">USB serial, card stays in</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
@@ -1,5 +1,5 @@
|
||||
+++
|
||||
title = "Debug Builds and the Debug Console"
|
||||
title = "The Debug Console"
|
||||
description = "A firmware you can drive from your desk: its console over Wi-Fi, its screen as a PNG, its SD card, its crash dumps, and a way back when an update goes wrong."
|
||||
template = "guide-index.html"
|
||||
page_template = "guide-page.html"
|
||||
@@ -10,13 +10,13 @@ weight = 1
|
||||
eyebrow = "Developer docs"
|
||||
+++
|
||||
|
||||
A **Debug Build** is the same firmware plus a **Debug Console**: the serial console, over Wi-Fi, behind a token. It is the most useful thing in the project. With it you can:
|
||||
The **Debug Console** is the serial console, over Wi-Fi, for whoever holds the device's token. It is in **every firmware**, switched off until you switch it on, and it is the most useful thing in the project. With it you can:
|
||||
|
||||
- **see everything the device prints**, boot messages included, without a cable;
|
||||
- **run every serial command** from your desk;
|
||||
- **press keys** and **take screenshots**, so a UI change can be tested and looked at remotely;
|
||||
- **copy files** to and from the SD card;
|
||||
- **read crash reports and fetch core dumps**, decoded against the exact build that crashed;
|
||||
- **update the firmware** over the same Wi-Fi, and know that a bad update rolls back to a build that still has the console.
|
||||
- **update the firmware** over the same Wi-Fi, and know that a bad update rolls back by itself.
|
||||
|
||||
Start with [Debug Builds](/dev/debug/debug-builds/) to put one on a device, then [the Debug Console](/dev/debug/console/). The rest are what you can do with it.
|
||||
Start with [Switch the console on](/dev/debug/switch-it-on/), then [the Debug Console](/dev/debug/console/). The rest are what you can do with it.
|
||||
|
||||
@@ -10,7 +10,7 @@ tag = "Reference"
|
||||
+++
|
||||
## What `help` prints
|
||||
|
||||
The firmware's own list, read from `src/main.cpp`. These work in every build, over USB serial:
|
||||
The firmware's own list, read from `src/main.cpp`. Every firmware has all of them, over USB serial and over the Debug Console. The ones marked *Debug Console only* exist only over Wi-Fi, where [`rdbg.py`](/dev/debug/files-and-screens/) speaks them, and the ones marked *USB serial only* only over the cable:
|
||||
|
||||
```
|
||||
info firmware, uptime, memory, Wi-Fi, app slots
|
||||
@@ -37,11 +37,8 @@ irc start | irc stop | irc dump | irc say <buffer> <text>
|
||||
install <path.ota> Update from SD
|
||||
update check | list | status | install <tag> the project's releases on Gitea
|
||||
sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal
|
||||
```
|
||||
|
||||
A **Debug Build** adds these (the last ones, marked *Debug Console only*, exist only over Wi-Fi, where [`rdbg.py`](/dev/debug/files-and-screens/) speaks them):
|
||||
|
||||
```
|
||||
debug status | debug off the Debug Console over Wi-Fi (Settings > Debug Console)
|
||||
debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token
|
||||
crash abort|wdt crash on purpose (to test crash reports and Safe Mode)
|
||||
wifi ip ... try <seconds> | wifi ip keep a trial IP setting: back to the previous one unless kept
|
||||
loop spin on|off make the main loop spin without resting, to compare load and radio noise
|
||||
@@ -54,7 +51,7 @@ get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) bina
|
||||
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
|
||||
|
||||
@@ -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) |
|
||||
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
|
||||
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
|
||||
| `wifi ip <ssid> dhcp` / `wifi ip <ssid> <address>/<prefix> [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Debug Builds: add `try <seconds>` to go back to the previous setting unless `wifi ip keep` follows |
|
||||
| `wifi ip <ssid> dhcp` / `wifi ip <ssid> <address>/<prefix> [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Add `try <seconds>` to go back to the previous setting unless `wifi ip keep` follows |
|
||||
| `wifi dns <a> [b]` / `wifi dns always on\|off` / `wifi ntp <a> [b]` | DNS servers (used on Fixed networks, or always), and NTP servers |
|
||||
| `log <text>` | Appends a line to a test IRC Log (`/irc/dev/#test/<date>.log`) |
|
||||
| `sd card` | What the SD card says it is: type, size, and its identity register (maker, name, revision, serial, date) |
|
||||
| `sd list` | Lists the files of each Storage Clean-up category |
|
||||
| `sd fill <folder> <count>` | Debug Builds: makes that many small files in a folder, to test a crowded one |
|
||||
| `sd fill <folder> <count>` | Makes that many small files in a folder, to test a crowded one |
|
||||
| `cat <path>` | Prints the first ~1.2 KB of a file on the SD card |
|
||||
| `irc start` | Starts the IRC Service (normally done by opening the IRC App) |
|
||||
| `irc stop` | Stops it, as `/quit` does: QUIT if connected, no more retries, and the App stays disconnected until you type |
|
||||
@@ -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 |
|
||||
| `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 |
|
||||
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one (not on a Debug Build) |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Debug Builds: pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
|
||||
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
|
||||
| `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor |
|
||||
| `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack |
|
||||
| `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
|
||||
@@ -102,12 +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 nmea on` / `gnss nmea off` | Prints each NMEA sentence the receiver sends, as `nmea: …` |
|
||||
| `gnss send <sentence>` | Sends a sentence to the receiver, without the `$` and the checksum (it adds them) |
|
||||
| `lora noise test [gnss\|quiet]` / `lora noise report` | Debug Builds: Sweep under one changed condition at a time to find what raises the noise floor (Wi-Fi goes off for a few seconds); then the result |
|
||||
| `lora inject <hex> [rssi] [snr]` | Debug Builds: a packet into the Scanner as if received (nothing is sent) |
|
||||
| `lora noise test [gnss\|quiet]` / `lora noise report` | Sweep under one changed condition at a time to find what raises the noise floor (Wi-Fi goes off for a few seconds); then the result |
|
||||
| `lora inject <hex> [rssi] [snr]` | A packet into the Scanner as if received (nothing is sent) |
|
||||
| `crash` | The last crash: which firmware, why, task, PC and backtrace (from the core dump in flash) |
|
||||
| `coredump erase` | Forgets the core dump |
|
||||
| `loop spin on` / `loop spin off` | Debug Builds: make the main loop spin without resting, to compare load and radio noise |
|
||||
| `crash abort` / `crash wdt` | Debug Builds: crash on purpose, or hang the main loop until the watchdog fires |
|
||||
| `loop spin on` / `loop spin off` | Make the main loop spin without resting, to compare load and radio noise |
|
||||
| `crash abort` / `crash wdt` | Crash on purpose, or hang the main loop until the watchdog fires |
|
||||
| `debug status` / `debug off` | The Debug Console: whether it's on, has a token and a client; switch it off |
|
||||
| `debug on` / `debug token <value>` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed |
|
||||
| `help` | Lists the commands |
|
||||
|
||||
`scripts/flash.sh` stops a running serial log first, since it would hold the port.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
+++
|
||||
title = "The Debug Console"
|
||||
description = "Connect to a Debug Build over Wi-Fi: the protocol, what you get when you connect, how commands run, and what the console can and cannot do."
|
||||
description = "Connect to the console over Wi-Fi: the protocol, what you get when you connect, how commands run, and what the console can and cannot do."
|
||||
weight = 2
|
||||
[extra]
|
||||
tag = "Console"
|
||||
@@ -17,16 +17,16 @@ scripts/rdbg.py info # one command, and its reply
|
||||
scripts/rdbg.py -b tasks # the same, with the backlog shown first
|
||||
```
|
||||
|
||||
`scripts/rdbg.py` is a Python script with no dependencies. A one-shot command prints the reply and what follows, until the console has been quiet for a moment (1.5 seconds). It reads the token from `~/.config/roro9stack/debug-token` and talks to **TCP 2323** on the device's address. In interactive mode, <kbd>Ctrl</kbd>+<kbd>D</kbd> or `quit` leaves. Piped input works too, but `rdbg.py` leaves **as soon as its input ends**, before the replies arrive: keep the input open for a few seconds, as in `(printf 'info\ntasks\n'; sleep 3) | scripts/rdbg.py`. For one command, the one-shot form above is simpler.
|
||||
`scripts/rdbg.py` is a Python script with no dependencies. A one-shot command prints the reply and what follows, until the console has been quiet for a moment (1.5 seconds). It takes the token from `-t`, `$RORO_DEBUG_TOKEN` or `~/.config/roro9stack/debug-token` ([Switch the console on](/dev/debug/switch-it-on/)) and talks to **TCP 2323** on the device's address. In interactive mode, <kbd>Ctrl</kbd>+<kbd>D</kbd> or `quit` leaves. Piped input works too, but `rdbg.py` leaves **as soon as its input ends**, before the replies arrive: keep the input open for a few seconds, as in `(printf 'info\ntasks\n'; sleep 3) | scripts/rdbg.py`. For one command, the one-shot form above is simpler.
|
||||
|
||||
The device listens **only while Wi-Fi is connected**, and the console follows Wi-Fi: it stops listening when Wi-Fi drops and starts again when it is back.
|
||||
The device listens **only while the console is switched on and Wi-Fi is connected**, and the console follows Wi-Fi: it stops listening when Wi-Fi drops and starts again when it is back.
|
||||
|
||||
## What you get
|
||||
|
||||
On connecting, in order:
|
||||
|
||||
1. a **banner**: `roro9stack v0.11.0-3-g12006bd+debug debug console. 'help' lists the commands. Backlog follows.`
|
||||
2. the **backlog**: the last **4 KB** of console output, oldest first, **boot messages included** (a ring buffer in RAM);
|
||||
1. a **banner**: `roro9stack v0.12.0 debug console. 'help' lists the commands. Backlog follows.`
|
||||
2. the **backlog**: the last **4 KB** of console output, oldest first (a ring buffer in RAM, kept while the console is switched on: boot messages included, if it was on at boot);
|
||||
3. then **every new line, live**: everything the firmware prints, and ESP-IDF's own log lines, which are copied into the same stream (they still reach the USB port too);
|
||||
4. the **replies to your commands**, in the same stream, each preceded by the `> command` line when it runs.
|
||||
|
||||
@@ -49,12 +49,17 @@ It is a plain line protocol, easy to speak from anything. This is what `rdbg.py`
|
||||
| Step | Detail |
|
||||
|---|---|
|
||||
| Connect | TCP 2323. **One client at a time**: a second one waits until the first leaves |
|
||||
| Authenticate | Send the token and `\n` within **10 seconds**. The comparison takes the same time whatever you send |
|
||||
| Refused | After about a second the device sends `denied\n` and hangs up, and prints `debug: refused a client from <ip>` on its own console. No quick retries |
|
||||
| Challenge | The device sends one line: `roro9stack debug console, challenge <32 hex digits>`. Those are 16 random bytes, new each time |
|
||||
| Answer | Within **10 seconds**, send the **HMAC-SHA256 of those 16 bytes, keyed by the token**, as 64 hex digits and `\n`. The token is the tidied one: capitals, no dashes |
|
||||
| Accepted | The banner line, then the backlog |
|
||||
| Refused | After about a second the device sends `denied\n` and hangs up, and prints `debug: refused a client from <ip>` on its own console |
|
||||
| Locked | **Five wrong answers in a row** close the console to everyone for 60 seconds: it answers `locked\n` and hangs up, and a Toast on the device names the address they came from |
|
||||
| Commands | One line each, up to 240 bytes. The device **queues at most 8**; past that, `debug: busy, command dropped` |
|
||||
| Leave | `quit` or `exit` closes the connection |
|
||||
| Binary commands | `get`, `put`, `screenshot`, `coredump get`: a text header line, then raw bytes (see [files and screens](/dev/debug/files-and-screens/)) |
|
||||
|
||||
**The token never crosses the network.** Someone on the same Wi-Fi who records a login gets a challenge and its answer, which are no use for the next challenge. The comparison on the device takes the same time whatever it is given.
|
||||
|
||||
A command that never runs has not been dropped by the network: the main loop is busy or stuck. That is what the next section is about.
|
||||
|
||||
## How commands run
|
||||
@@ -69,22 +74,26 @@ A command that never runs has not been dropped by the network: the main loop is
|
||||
|
||||
## Security
|
||||
|
||||
- The token is checked **before anything else**, and a wrong one costs a second.
|
||||
- Anyone on the same network **with the token** can read the console, press keys and restart the device. The console never prints stored secrets (Wi-Fi and IRC passwords), but the IRC traffic it shows is readable.
|
||||
- The stream is **plain text**: fine on a home network, not across the internet. Do not forward port 2323.
|
||||
- **Release builds have no console at all.** Nothing listens.
|
||||
- **Off by default.** Nothing listens until the console is switched on at the device, or over USB.
|
||||
- The login is a **challenge and an answer**: the token itself is never sent, and five wrong answers close the console for a minute.
|
||||
- Anyone on the same network **with the token** can read the console, press keys, copy the SD card's files and restart the device. The console never prints stored secrets (the token, Wi-Fi and IRC passwords), but the IRC traffic it shows is readable.
|
||||
- They cannot change the firmware: an update still has to be **signed**.
|
||||
- The stream after the login is **plain text**: fine on a home network, not across the internet. Do not forward port 2323.
|
||||
|
||||
The decision is [ADR 0004](/dev/decisions/0004-debug-console-in-debug-builds/).
|
||||
The decisions are [ADR 0010](/dev/decisions/0010-debug-console-in-every-build/) and, for how the console works inside, [ADR 0004](/dev/decisions/0004-debug-console-in-debug-builds/).
|
||||
|
||||
## Without `rdbg.py`
|
||||
|
||||
Anything that can open a TCP connection works. The token and each command are just lines:
|
||||
Anything that can open a TCP connection and compute an HMAC works:
|
||||
|
||||
```python
|
||||
import socket
|
||||
import hashlib, hmac, socket
|
||||
token = "K7QF3M2X9WBDHT4P6RNC" # tidied: capitals, no dashes
|
||||
s = socket.create_connection(("10.39.39.12", 2323))
|
||||
s.sendall(b"<token>\n") # then read the banner line
|
||||
s.sendall(b"info\n") # read until the stream goes quiet
|
||||
challenge = s.makefile().readline().split()[-1] # "... challenge 0f1e2d..."
|
||||
answer = hmac.new(token.encode(), bytes.fromhex(challenge), hashlib.sha256).hexdigest()
|
||||
s.sendall((answer + "\n").encode()) # then read the banner line
|
||||
s.sendall(b"info\n") # and what follows, until the stream goes quiet
|
||||
```
|
||||
|
||||
`scripts/rdbg.py` adds the parts that need work on the PC side: decoding crash backtraces, saving files and screenshots, and checking checksums.
|
||||
|
||||
@@ -6,7 +6,7 @@ weight = 5
|
||||
tag = "Console"
|
||||
+++
|
||||
|
||||
Rollback (see [How an update works](/dev/build/how-an-update-works/)) protects against **new** firmware that fails Probation. These measures cover firmware that was **confirmed and then crashes**: a corrupt setting, a server that sends something unexpected, a bug that takes an hour to show. They are in **every build**, release and Debug alike; the Debug Console is what makes them convenient. The decision is [ADR 0005](/dev/decisions/0005-safe-mode-crash-reports-watchdog/).
|
||||
Rollback (see [How an update works](/dev/build/how-an-update-works/)) protects against **new** firmware that fails Probation. These measures cover firmware that was **confirmed and then crashes**: a corrupt setting, a server that sends something unexpected, a bug that takes an hour to show. They are in every firmware; the Debug Console is what makes them convenient. The decision is [ADR 0005](/dev/decisions/0005-safe-mode-crash-reports-watchdog/).
|
||||
|
||||
## What a crash leaves behind
|
||||
|
||||
@@ -18,7 +18,7 @@ The `crash` command prints it again whenever you like:
|
||||
|
||||
```
|
||||
> crash
|
||||
crash: last one in v0.9.0-1-g4ab873e-dirty+debug (panic)
|
||||
crash: last one in v0.9.0-1-g4ab873e-dirty (panic)
|
||||
crash: task loopTask, pc 0x4037e179, cause 0, address 0x00000000
|
||||
crash: reason: abort() was called at PC 0x421209b3 on core 1
|
||||
crash: backtrace 0x4037e179 0x4037e141 0x4038582d 0x421209b3 ...
|
||||
@@ -40,7 +40,8 @@ scripts/rdbg.py coredump my.bin # ...to a file you name
|
||||
- **`crash`** runs the device's `crash`, finds the ELF by digest (or by version), and passes the backtrace to `scripts/decode_backtrace.sh`, which runs `addr2line` from the build container: one line for each frame, with function and file:line.
|
||||
- **`coredump`** fetches the raw dump over the console (it works when the main loop is stuck) and runs `scripts/decode_coredump.sh`: `esp-coredump` and GDB, giving **every task's backtrace, the registers, and the crashed task's stack**.
|
||||
- By hand: `scripts/decode_backtrace.sh <version|digest> <address>...`, and `scripts/decode_coredump.sh <core.bin> <version|digest>`. Both say which ELF they used.
|
||||
- If no archived ELF matches, they say so: the build was made on another machine, or `.pio/` was cleaned.
|
||||
- **A release's ELF is fetched for you.** When the crash names a released version (`v0.12.0`) and no local ELF matches, `rdbg.py` downloads `roro9stack-<version>.elf.gz` from the release on Gitea into `.pio/elves/`, so a crash on a firmware you did not build can be decoded. Decoding itself still runs in the build container.
|
||||
- If nothing matches, the scripts say so: an unreleased build made on another machine, or `.pio/` was cleaned.
|
||||
|
||||
## The main loop is watched
|
||||
|
||||
@@ -52,21 +53,19 @@ An installed update no longer depends on the main loop either: the Update Servic
|
||||
|
||||
The count of starts that follow a crash (a panic or the watchdog) is kept in NVS. After **three in a row**, the firmware starts **Safe Mode** instead of everything else:
|
||||
|
||||
- only the **clock, Wi-Fi, the Update Service and, in a Debug Build, the Debug Console** start: no Apps, no IRC, no SD card;
|
||||
- only the **clock, Wi-Fi, the Update Service and, if it is switched on, the Debug Console** start: no Apps, no IRC, no SD card;
|
||||
- the screen says so, with the **address to push an update to**;
|
||||
- only a few commands run (the [command reference](/dev/debug/commands/) lists them); anything else answers `not available in Safe Mode`.
|
||||
|
||||
Any **normal restart** (`reboot`, an update) or **a minute of uptime** resets the count. So Safe Mode is reached by crashing three times *quickly*, and a crash loop costs about half a minute before the device becomes reachable. To leave it: push a fix (`scripts/flash.sh --debug --ota`), or `reboot`.
|
||||
Any **normal restart** (`reboot`, an update) or **a minute of uptime** resets the count. So Safe Mode is reached by crashing three times *quickly*, and a crash loop costs about half a minute before the device becomes reachable. To leave it: push a fix (`scripts/flash.sh --ota`), or `reboot`. Over USB, `debug on` works in Safe Mode too, if the console was off.
|
||||
|
||||
**Its limit:** if Wi-Fi or the Update Service is what crashes, Safe Mode cannot help, and it takes USB.
|
||||
|
||||
## Crash on purpose
|
||||
|
||||
On a Debug Build:
|
||||
|
||||
```
|
||||
crash abort # abort(): a panic with a core dump
|
||||
crash wdt # hang the main loop until the task watchdog fires
|
||||
```
|
||||
|
||||
Use them to see a real report and decode it, to check that **the counter reaches Safe Mode**, and to prove that a Debug Build you are about to rely on will survive its own crashes. The device restarts by itself after either, and the report is there to read when the console comes back.
|
||||
Use them to see a real report and decode it, to check that **the counter reaches Safe Mode**, and to prove that a build you are about to rely on will survive its own crashes. The device restarts by itself after either, and the report is there to read when the console comes back.
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
+++
|
||||
title = "Debug Builds"
|
||||
description = "What a Debug Build is, how to build and flash one, how its token works, and why you should keep one in the fallback slot."
|
||||
weight = 1
|
||||
[extra]
|
||||
tag = "Start here"
|
||||
+++
|
||||
|
||||
## What it is
|
||||
|
||||
A Debug Build is built from the same source as a release, with the `cardputer-adv-debug` environment (it `extends` `cardputer-adv` in `platformio.ini`) and `-DRORO_DEBUG`. Differences:
|
||||
|
||||
- the **Debug Console** on TCP **2323** (next page);
|
||||
- extra commands, only meant for testing: crash on purpose, fake an installed version, damage a download, inject a LoRa packet, fill a folder with files (see the [command reference](/dev/debug/commands/));
|
||||
- the version ends in **`+debug`** (`scripts/version.py`) wherever the version shows: Settings, `info`, the Update Service. A `+debug` version compares **equal** to its release counterpart, so going between the two is never refused as a downgrade.
|
||||
|
||||
**It is compiled out of release builds, not switched off by a setting.** A console that runs commands, presses keys and reboots the device is a remote control; in a release build nothing listens and the code is not there.
|
||||
|
||||
## Build and flash one
|
||||
|
||||
Everything runs in Docker (see [Build, test and release](/dev/build/build-and-test/)). Over USB:
|
||||
|
||||
```sh
|
||||
scripts/flash.sh --debug # builds cardputer-adv-debug, uploads, opens the serial monitor
|
||||
```
|
||||
|
||||
Once a Debug Build is on the device, every later one can go over Wi-Fi, with no cable:
|
||||
|
||||
```sh
|
||||
export RORO_OTA_HOST=10.39.39.12 # the address Settings > Firmware shows
|
||||
scripts/flash.sh --debug --ota # builds, signs and pushes; the device installs and restarts
|
||||
```
|
||||
|
||||
The device's address is on **Settings → Firmware** ("Push to", which also gives port 3232, the update port). The Debug Console is on the same address, port 2323. See [Flash and update](/dev/build/flash/) for the update side.
|
||||
|
||||
## The token
|
||||
|
||||
The console asks for a secret first. It is **128 random bits**, made the first time any build runs (`scripts/_docker.sh`), kept in `~/.config/roro9stack/debug-token` next to the update-signing key, and passed into the build container as `RORO_DEBUG_TOKEN`. `scripts/debug_flags.py` compiles it into the firmware, and **refuses to build a Debug Build without one**, rather than fall back on a default.
|
||||
|
||||
- It is **never committed.** Releases have no console, so nothing of it is published: CI builds a Debug Build on a pull request to prove it still compiles, but never publishes it, because each one carries its builder's token.
|
||||
- It guards against **the network**, not against someone who holds the device: anyone with USB access can flash anything anyway ([ADR 0003](/dev/decisions/0003-own-signature-check-not-secure-boot/)).
|
||||
- To change it, delete the file and build again; the new token goes into the next Debug Build you flash.
|
||||
|
||||
## Keep a Debug Build in the other slot
|
||||
|
||||
The device has two app slots, so an update never overwrites the running firmware. If a new firmware crashes during [Probation](/dev/build/how-an-update-works/), the device goes **back to the previous one**, whatever that is. As long as you develop on Debug Builds, **the firmware a crash falls back to has the console too**, so a bad update never costs you remote access. Pushing a *release* build over a Debug Build leaves the Debug Build in the other slot until the next update overwrites it.
|
||||
|
||||
So the habit is: develop on Debug Builds, release by tag, and think twice before pushing a release over the only Debug Build you have.
|
||||
|
||||
## Releases and Debug Builds
|
||||
|
||||
- A Debug Build shows the latest release in Settings → Firmware but **never installs it**: that would replace the console with a release that has none. Update a Debug Build from the PC with `scripts/flash.sh --debug --ota`.
|
||||
- A Debug Build still checks and lists releases, which is useful for testing the update path: `update pretend` makes a released version count as newer (see [Drive the UI](/dev/debug/drive-the-ui/)).
|
||||
- **Safe Mode** (after 3 crashes in a row) keeps the Debug Console running, so a crash loop is something you fix remotely: see [Crashes and Safe Mode](/dev/debug/crashes/).
|
||||
|
||||
The reasoning is in [ADR 0004](/dev/decisions/0004-debug-console-in-debug-builds/).
|
||||
@@ -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 |
|
||||
| `gemini get <url>` | Fetches a page and reports header, size, certificate and heap use, without the App |
|
||||
| `gemini trust <host> <port> <sha256>` | Pins a certificate by hand |
|
||||
| `sd fill <folder> <count>` | **Debug Build.** Makes that many small files in a folder, to test a crowded one (the Storage App shows the first 256) |
|
||||
| `sd fill <folder> <count>` | Makes that many small files in a folder, to test a crowded one (the Storage App shows the first 256) |
|
||||
| `wifi add <ssid><TAB><password>` | Adds a Saved Network, so credentials stay out of the repository |
|
||||
|
||||
## Test the update path
|
||||
|
||||
An update that goes wrong is the case you most want to rehearse, and a Debug Build can make it go wrong **on purpose**:
|
||||
An update that goes wrong is the case you most want to rehearse, and the firmware can make it go wrong **on purpose**:
|
||||
|
||||
```
|
||||
update status # what the device runs, what failed here before, the daily check, heap
|
||||
@@ -72,15 +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 damage cut 50000 # the next download is cut after 50000 bytes
|
||||
update damage flip 100000 # ...or has the byte at offset 100000 damaged
|
||||
update install v0.11.0 force # try the install
|
||||
update install v0.11.0 # try the install
|
||||
update probe git.twis.la # is that server's certificate accepted? (the two ISRG roots only)
|
||||
update daily # forget today's daily check: it runs again at the next tick
|
||||
update pretend off # back to the real version
|
||||
```
|
||||
|
||||
- **`force` is needed on a Debug Build.** A plain `update install <tag>` answers `Debug Build: update from the PC`, because installing a release would replace the console with a build that has none. `force` is accepted only on a Debug Build, and only from the console.
|
||||
- **A damaged download must be refused cleanly:** the Update Service checks the signature after the first 160 bytes and the image hash at the end, so a cut or a flipped byte must end in a refusal with **nothing switched**. Check `info` afterwards: both slots, and `update: confirmed`.
|
||||
- **An undamaged `force` install really installs** the release, into the other slot. The Debug Build stays where it was until the next update overwrites it, and a Rollback returns to it, but think before you do it.
|
||||
- **An undamaged install really installs** the release, into the other slot, and the device restarts into it. Your build stays in the slot it was in until the next update overwrites it, and the console's setting and token are untouched: the release has the console too.
|
||||
- The server is read by the device itself, with Wi-Fi up; the download is one TLS connection, about 52 KB of heap at its peak, so IRC steps aside (see [the memory limit](/howto/not-enough-memory/)). `status: heap` in the stream shows it happen.
|
||||
|
||||
For crashes during Probation, see [Crashes and Safe Mode](/dev/debug/crashes/).
|
||||
@@ -94,7 +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
|
||||
```
|
||||
|
||||
If you do not send `wifi ip keep` in time, the device goes back to the **previous** setting by itself, and the console comes back with it. (A Debug Build command: `try` is not in release builds.)
|
||||
If you do not send `wifi ip keep` in time, the device goes back to the **previous** setting by itself, and the console comes back with it.
|
||||
|
||||
## Measure
|
||||
|
||||
@@ -104,7 +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
|
||||
```
|
||||
|
||||
`tasks` takes a second to answer. A low number in the `stack` column is a risk (the System App shows it in the warning colour under 512 bytes). `loop spin on|off` (Debug Build) makes the main loop spin without resting, to compare load and radio noise. And the `status: heap` line every 10 seconds in the stream is the cheapest memory trace there is: watch it while you do the thing you suspect.
|
||||
`tasks` takes a second to answer. A low number in the `stack` column is a risk (the System App shows it in the warning colour under 512 bytes). `loop spin on|off` makes the main loop spin without resting, to compare load and radio noise. And the `status: heap` line every 10 seconds in the stream is the cheapest memory trace there is: watch it while you do the thing you suspect.
|
||||
|
||||
```
|
||||
task st pri stack cpu% core
|
||||
@@ -120,4 +119,4 @@ The [System App](/guide/system/) shows the same, live, on the device.
|
||||
|
||||
## Radio experiments
|
||||
|
||||
`lora preset <name>` and `lora custom <MHz> <BW kHz> <SF> <CR> <sync hex> [preamble]` change what the receiver listens to (receive only: the radio never transmits), `lora sweep on [from] [to] [step]` takes a survey, and `lora noise test [gnss|quiet]` (Debug Build) runs a Sweep under one changed condition at a time, with Wi-Fi off for a moment, to find what raises the noise floor. `lora probe` finds the radio and reports its chip, oscillator, antenna switch, interrupt line and noise floor. Details in the [command reference](/dev/debug/commands/).
|
||||
`lora preset <name>` and `lora custom <MHz> <BW kHz> <SF> <CR> <sync hex> [preamble]` change what the receiver listens to (receive only: the radio never transmits), `lora sweep on [from] [to] [step]` takes a survey, and `lora noise test [gnss|quiet]` runs a Sweep under one changed condition at a time, with Wi-Fi off for a moment, to find what raises the noise floor. `lora probe` finds the radio and reports its chip, oscillator, antenna switch, interrupt line and noise floor. Details in the [command reference](/dev/debug/commands/).
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
+++
|
||||
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, 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 on` and `debug token` work **over USB serial only**: the console cannot be used to open itself wider. `debug status` and `debug off` work from anywhere, and a screenshot taken over the console while this page is open shows the token, to someone who already had it. Your token file is made by the first build (`scripts/_docker.sh`), 32 hex digits, and is never committed.
|
||||
|
||||
Since the setting survives updates, this is needed **once for a device**, not at each flash. Later builds go over Wi-Fi with `scripts/flash.sh --ota`.
|
||||
|
||||
## Should it be on?
|
||||
|
||||
On your own network, on a device you are working on: yes, that is what it is for. Remember what it gives to whoever has the token **and** is on the same network: the console, the keys, the files on the SD card, a restart. It does not give them the firmware: an update still has to be signed.
|
||||
|
||||
On a network you share with strangers, switch it off, or at least know that what the console prints is not encrypted. The token is safe there; the conversation is not.
|
||||
@@ -1,6 +1,6 @@
|
||||
+++
|
||||
title = "A Debug Console over Wi-Fi, in Debug Builds only"
|
||||
description = "The goal of Firmware Updates is to manage the device without a cable, and that includes finding out what went wrong. So a Debug Build (cardputer-adv-debug, -DRORO_DEBUG, version suffix +debug) adds a Debug Console on TCP 2323…"
|
||||
description = "Superseded in part by ADR 0010 (2026-10-06): there is no Debug Build any more. The console is in every firmware, off until switched on, and its token belongs to the device, not to the build. What this record says about how the…"
|
||||
weight = 4
|
||||
|
||||
[extra]
|
||||
@@ -8,6 +8,8 @@ docs = true
|
||||
source = "docs/adr/0004-debug-console-in-debug-builds.md"
|
||||
tag = "ADR 0004"
|
||||
+++
|
||||
**Superseded in part by [ADR 0010](/dev/decisions/0010-debug-console-in-every-build/) (2026-10-06):** there is no Debug Build any more. The console is in every firmware, off until switched on, and its token belongs to the device, not to the build. What this record says about how the console works (the ring, commands on the main loop, binary commands on its own task, one client) still holds.
|
||||
|
||||
The goal of Firmware Updates is to manage the device without a cable, and that includes finding out what went wrong. So a **Debug Build** (`cardputer-adv-debug`, `-DRORO_DEBUG`, version suffix `+debug`) adds a **Debug Console** on TCP 2323: the serial console, over Wi-Fi. A client sends a token as its first line, then gets the last 4 KB of console output (boot messages included), every new line live, and runs the same commands as the serial port, plus a few that only make sense remotely. ESP-IDF's own log lines are teed into it.
|
||||
|
||||
It's compiled out of release builds entirely, rather than switched off by a setting. A console that runs commands is a remote control: in a release build, nothing listens.
|
||||
|
||||
@@ -8,10 +8,10 @@ docs = true
|
||||
source = "docs/adr/0005-safe-mode-crash-reports-watchdog.md"
|
||||
tag = "ADR 0005"
|
||||
+++
|
||||
Rollback protects against new firmware that fails Probation. It does nothing for firmware that was confirmed and crashes later: a corrupt setting, a server that sends something unexpected, a bug that takes an hour to show. Without a cable, such a device would restart forever. Three measures, in release and Debug Builds alike, keep it reachable:
|
||||
Rollback protects against new firmware that fails Probation. It does nothing for firmware that was confirmed and crashes later: a corrupt setting, a server that sends something unexpected, a bug that takes an hour to show. Without a cable, such a device would restart forever. Three measures, in every build, keep it reachable:
|
||||
|
||||
- **Safe Mode.** The firmware counts starts that follow a crash (panic or watchdog) in NVS, first thing at boot. After 3 in a row, it starts only the clock, Wi-Fi, the Update Service and, in a Debug Build, the Debug Console: no Apps, no IRC, no SD card, and a screen that says so with the address to push an update to. Any normal restart (a `reboot`, an update), or a minute of uptime, resets the count.
|
||||
- **Crash reports.** The same boot record keeps which version was running, so after a crash the firmware knows which one crashed, even when a Rollback has switched slots since. ESP-IDF already writes a core dump to its flash partition on a panic; after the restart the firmware prints its summary (task, PC, reason, backtrace) and raises a Notification. The `crash` command shows it again later. In a Debug Build, `scripts/rdbg.py crash` decodes the backtrace and `scripts/rdbg.py coredump` fetches the whole dump for `esp-coredump`, against the ELF of that exact build (`.pio/elves/`, named by version and ELF digest).
|
||||
- **Safe Mode.** The firmware counts starts that follow a crash (panic or watchdog) in NVS, first thing at boot. After 3 in a row, it starts only the clock, Wi-Fi, the Update Service and, if it's switched on, the Debug Console: no Apps, no IRC, no SD card, and a screen that says so with the address to push an update to. Any normal restart (a `reboot`, an update), or a minute of uptime, resets the count.
|
||||
- **Crash reports.** The same boot record keeps which version was running, so after a crash the firmware knows which one crashed, even when a Rollback has switched slots since. ESP-IDF already writes a core dump to its flash partition on a panic; after the restart the firmware prints its summary (task, PC, reason, backtrace) and raises a Notification. The `crash` command shows it again later. With the Debug Console, `scripts/rdbg.py crash` decodes the backtrace and `scripts/rdbg.py coredump` fetches the whole dump for `esp-coredump`, against the ELF of that exact build (`.pio/elves/`, named by version and ELF digest).
|
||||
- **The main loop is watched.** Arduino-ESP32 subscribes only core 0's idle task to the task watchdog, and the main loop runs on core 1: a stuck loop used to hang the device for good, with the screen frozen and the Debug Console unable to run commands. `enableLoopWDT()` makes a loop stuck for 5 s a panic, with a core dump, counted towards Safe Mode. And an installed update no longer depends on the main loop: the Update Service restarts into it by itself after 90 s.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
+++
|
||||
title = "The Debug Console is in every build, off until its owner switches it on"
|
||||
description = "There is one firmware. The Debug Console of ADR 0004 is compiled into it, and so are the commands that used to exist only in Debug Builds. It listens only while Settings → Debug Console is on, which is not the default, and it…"
|
||||
weight = 10
|
||||
|
||||
[extra]
|
||||
docs = true
|
||||
source = "docs/adr/0010-debug-console-in-every-build.md"
|
||||
tag = "ADR 0010"
|
||||
+++
|
||||
There is **one firmware**. The Debug Console of ADR 0004 is compiled into it, and so are the commands that used to exist only in Debug Builds. It listens only while **Settings → Debug Console** is on, which is not the default, and it lets in whoever proves they hold **the device's own token**. The `cardputer-adv-debug` environment, `RORO_DEBUG`, the `+debug` version and the token compiled in from the builder's machine are gone.
|
||||
|
||||
ADR 0004 kept the console out of release builds with one argument: a console that runs commands is a remote control, so in a release nothing should listen. That argument was never whole: the Update Service has listened on TCP 3232 in every build since Firmware Updates exist, guarded by a signature. A listener that is off by default and guarded by a secret the device made itself is the same kind of trade, and what the split cost had grown:
|
||||
|
||||
- **What was tested wasn't what shipped.** Development ran on Debug Builds; releases were a different binary, built to be published and never run by the person who wrote them.
|
||||
- **Debug Builds couldn't be published**, since each carried its builder's token. So the console, `rdbg.py`, screenshots and crash decoding were for whoever built the firmware, and nobody else.
|
||||
- **Rules that existed only to protect the console:** a Debug Build never installed a release (it would have lost the console), `update install … force` to do it anyway, "keep a Debug Build in the fallback slot", versions with `+debug` that had to compare equal to their release.
|
||||
- **CI built two firmwares** on every pull request and every tag.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Off means nothing is there.** With the setting off, no socket listens, the console's task doesn't exist and neither does its 4 KB ring: a device that never uses the console pays for it in flash (30 KB more than the release build was) and 88 bytes of static RAM, measured. A missing or invalid stored setting counts as off.
|
||||
- **The token is the device's.** The first time the console is switched on, the device makes one from its hardware random generator: 100 bits, as 20 characters of Crockford's base32 (`K7QF-3M2X-9WBD-HT4P-6RNC`), shown on the Debug Console page and nowhere else. It can be replaced by one typed by hand, of 16 to 64 characters. It is stored with the other settings and is never printed on a console.
|
||||
- **The token never crosses the network.** The device sends 16 random bytes; the client answers with their HMAC-SHA256 keyed by the token. Someone on the same Wi-Fi who records a login has nothing they can use for the next one.
|
||||
- **Five wrong answers in a row** close the console to everyone for a minute, with a Notification naming the address they came from. A wrong answer still costs a second.
|
||||
- **`DBG` in the Status Bar** while the console listens, bright while a client is connected.
|
||||
- **USB serial can set it up:** `debug on`, `debug token <value>`, `debug token new`. `scripts/flash.sh --debug` uses them to give a freshly flashed device the developer's token. Whoever holds the cable can flash anything anyway (ADR 0003); the console itself can only switch itself off.
|
||||
- **Safe Mode** starts the console if it's switched on: the settings are read before Safe Mode is decided.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **"Nothing listens" now rests on one stored setting**, not on absent code. That setting is typed, validated and host-tested, and its default is off; a bug that flipped it would have to come from code that already runs on the device.
|
||||
- **Whoever has the token and the network has the device:** the console, its keys, the SD card's files, a restart. Not its firmware: an update still has to be signed (ADR 0003).
|
||||
- **The stream is still plain text.** The token is safe from an eavesdropper; what the console prints, and the files it carries, are not. TLS would cost about 52 KB of the 107 KB there is, next to IRC's own connection: not for a console.
|
||||
- **An old `rdbg.py` can't talk to a new firmware**, and the reverse: the first line of the protocol changed. Accepted, with one user.
|
||||
- **A device whose settings are damaged has no console in Safe Mode**, only the signed update push. Before, a Debug Build always had it.
|
||||
- **The commands that crash, damage a download or fill a folder on purpose** are in every build. They need the cable or the token, like everything else.
|
||||
- **Releases already publish their ELF**, so `rdbg.py crash` fetches it when it isn't in `.pio/elves/`: crash reports can be decoded without having built the firmware.
|
||||
@@ -22,7 +22,7 @@ Until now the tests, the builds, the signing and the flashing all happened on on
|
||||
|---|---|
|
||||
| Q151 | A push to `main`: the host tests (with their coverage). A push to another branch: nothing, its pull request is what runs (a branch with an open pull request ran twice, once for each). A pull request: the same and both builds; changes reach `main` through pull requests. A tag `v*`: all of it, then a release. (First: everything on every push, which rebuilt the firmware far more often than anyone looked at it.) |
|
||||
| Q152 | **CI signs.** The signing key is the repository secret `OTA_SIGNING_KEY`; a tag push makes a complete, signed release with no manual step (ADR 0008). |
|
||||
| Q153 | The Debug Build is built in CI with a token of the runner's own, to prove it compiles, and **isn't published**: it would hand everyone its Debug Console token. |
|
||||
| Q153 | *Revised by Q188: there is one firmware.* The Debug Build is built in CI with a token of the runner's own, to prove it compiles, and **isn't published**: it would hand everyone its Debug Console token. |
|
||||
| Q154 | Pull requests from forks don't start a run. |
|
||||
| Q155 | A release carries `roro9stack-<version>.ota` (signed), `-factory.bin` for USB, `.elf.gz` to decode crashes, and `SHA256SUMS`. |
|
||||
| Q156 | Its text is the tag's message, what the files are, and the commits since the tag before. |
|
||||
@@ -72,7 +72,7 @@ The device looks at the project's Gitea for a newer release, says so, and instal
|
||||
| Q168 | A version that failed (rolled back) isn't announced again by the background check until a newer one exists; it can still be installed by hand. |
|
||||
| Q169 | **Older releases:** a list of the last ten, newest first, the running one marked. Installing an older one asks with a stronger warning. |
|
||||
| Q170 | Enter on a release shows its version, date, size and the tag message, with Install. |
|
||||
| Q171 | **Debug Builds** check and show the latest release, but don't install it: it would replace the Debug Build and its console (Q153: Debug Builds aren't published). Their updates come from the PC. |
|
||||
| Q171 | *Revised by Q188: there is no Debug Build, every firmware installs releases.* **Debug Builds** check and show the latest release, but don't install it: it would replace the Debug Build and its console (Q153: Debug Builds aren't published). Their updates come from the PC. |
|
||||
| Q172 | **Memory, as measured:** a TLS connection peaks at about 52 KB of heap, with or without checking the certificate, so it starts with 80 KB free (Q86's 20 KB spare on top), not 55 KB. **A check, list or install someone asked for makes IRC step aside** and come back after; the daily check never does, and with IRC connected it waits. (First: 55 KB and nothing else. With IRC connected a check left 3 KB and a download 836 bytes.) |
|
||||
| Q173 | Left out, each with its issue: installing automatically (#52), a release channel (#53), resuming a download (#54). |
|
||||
| Q174 | Ships as **v0.11.0**. Tested on the device with the real signed releases; a Debug Build command pretends the device runs an older version, so v0.10.0 counts as an update. |
|
||||
@@ -131,3 +131,47 @@ The device looks at the project's Gitea for a newer release, says so, and instal
|
||||
- **A key press during the hold:** Back on the Firmware page while a check is going doesn't cancel it.
|
||||
|
||||
**Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure.
|
||||
|
||||
## One firmware: the Debug Console in every build (issue #68)
|
||||
|
||||
The issue asked for a token that could be set, so that Debug Builds could be published. The design round ended somewhere simpler: no Debug Builds. The console is in every firmware, off until switched on, with a token that belongs to the device (ADR 0010, which supersedes part of ADR 0004).
|
||||
|
||||
### Decisions (design round 2026-10-06)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q188 | **One firmware,** with the Debug Console and the test commands compiled in. The `cardputer-adv-debug` environment, `RORO_DEBUG`, the `+debug` version and `scripts/debug_flags.py` go; CI builds one firmware. Revises Q153 and Q171. |
|
||||
| Q189 | **Off by default,** and off when the stored setting is missing or invalid. Off, nothing listens and no memory is used: the task and the 4 KB ring exist only while it's on. |
|
||||
| Q190 | **Settings → Debug Console:** the switch, where to connect, the token, "New token", "Type a token". It stays on across restarts and in Safe Mode. **`DBG` in the Status Bar** while it listens, bright with a client connected. |
|
||||
| Q191 | **The device makes the token** the first time the console is switched on: 100 bits as 20 characters of Crockford's base32, shown in groups of four. It can be replaced by one typed by hand, of at least 16 characters. Case and dashes don't count. |
|
||||
| Q192 | **USB serial can set it up:** `debug on`, `debug token <value>`, `debug token new`, over USB only; `debug status` and `debug off` from anywhere. `scripts/flash.sh --debug` gives a device the developer's token. The token is never printed. |
|
||||
| Q193 | **Challenge and answer:** the device sends 16 random bytes, the client their HMAC-SHA256 keyed by the token. Five wrong answers in a row close the console for a minute, with a Notification. **Old clients and old firmwares don't talk to each other:** accepted, this far from v1 and with one user. |
|
||||
| Q194 | `rdbg.py` takes the token from **`-t`/`--token`**, then **`$RORO_DEBUG_TOKEN`**, then `~/.config/roro9stack/debug-token`. |
|
||||
| Q195 | **The test commands are in every build** (`crash abort`, `crash wdt`, `update pretend`, `update damage`, `lora inject`, `sd fill`, `loop spin`, `wifi ip … try`). `update install … force` goes: there's nothing left to protect. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/debug`** (host-tested, 9 tests): the token maker, tidying what a person types, HMAC-SHA256 on the project's own SHA-256 (checked against RFC 4231), the answer to a challenge (the same vector as `rdbg.py`'s), a comparison that takes the same time whatever it's given, and the pause after five wrong answers, right across the clock's wrap.
|
||||
- **Two settings,** `DebugConsole` and `DebugToken`, with the others: validated, and invalid stored values count as missing.
|
||||
- **The console's task and ring come and go with the setting.** `Console::openRing` allocates the ring; switching off closes the socket, frees it and ends the task. `tick` follows the settings once a second, so a switch off and on again takes a second or two.
|
||||
- **Measured against the two builds it replaces:** 1,881,799 bytes of flash and 54,700 of static RAM. The release build was 1,851,387 and 54,612 (so 30 KB more flash and 88 bytes more RAM); the Debug Build was 1,874,887 and 58,780 (4 KB of RAM back: the ring is no longer a static array).
|
||||
- **`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 |
|
||||
| Memory, console on with a client | 108 KB free (the Debug Build it replaces: 104 KB). In Safe Mode: 178 KB free |
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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) |
|
||||
| `SD` | A card is in; the warning colour at 80% full |
|
||||
| bars and `W` | Wi-Fi connected, with its signal; `W?` is searching; `MON` is the Wi-Fi radio in its monitoring mode, which pauses IRC |
|
||||
| `DBG` | The Debug Console is switched on (Settings → Debug Console); brighter while a PC is connected to it |
|
||||
| `REC` | A GNSS Track is being recorded |
|
||||
| `CAP` | A LoRa capture is being recorded |
|
||||
| `L` | The radio is listening; it lights up for a moment on each packet. `SW` while a Sweep runs |
|
||||
|
||||
@@ -22,6 +22,7 @@ Move with the arrows. On a toggle, a choice or a slider, left and right change t
|
||||
| **Wi-Fi** | The page below |
|
||||
| **Check for updates** | Once a day, see [Updates](/guide/updates/) |
|
||||
| **Firmware** | The page described in [Updates](/guide/updates/) |
|
||||
| **Debug Console** | Off unless you switch it on. It lets a PC on the same network read the device's console and drive it, with a token shown on this page: see [the developer docs](/dev/debug/switch-it-on/). Leave it off if that means nothing to you |
|
||||
| **About** | The firmware version, the node number, battery, memory, uptime, clock and licence |
|
||||
|
||||
## Wi-Fi
|
||||
|
||||
@@ -41,4 +41,4 @@ The connection to the project's server is checked against the two root certifica
|
||||
|
||||
## For developers
|
||||
|
||||
Updates can also be pushed from a PC over Wi-Fi, with `scripts/flash.sh --ota <ip>`, or put on the card with `scripts/sd_put.sh`: see the [README](https://git.twis.la/twisla/roro9stack#firmware-updates-over-wi-fi-ota). **Debug Builds** show the latest release but do not install it, because a release has no debug console: update a Debug Build from the PC.
|
||||
Updates can also be pushed from a PC over Wi-Fi, with `scripts/flash.sh --ota <ip>`, or put on the card with `scripts/sd_put.sh`: see the [README](https://git.twis.la/twisla/roro9stack#firmware-updates-over-wi-fi-ota). The firmware's console can be reached over Wi-Fi too: see [The Debug Console](/dev/debug/).
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</header>
|
||||
|
||||
<div class="prose">
|
||||
<p>Each release has a <strong>signed update file</strong> (<code>.ota</code>: copy it to <code>/updates</code> on the SD card and install it from Settings → Firmware or the Storage App, or push it over Wi-Fi), a <strong>factory image</strong> (<code>-factory.bin</code>, the whole flash at offset 0 for a first install over USB), the <strong>symbols</strong> (<code>.elf.gz</code>, to decode a crash report) and <code>SHA256SUMS</code>. Debug Builds aren't published.</p>
|
||||
<p>Each release has a <strong>signed update file</strong> (<code>.ota</code>: copy it to <code>/updates</code> on the SD card and install it from Settings → Firmware or the Storage App, or push it over Wi-Fi), a <strong>factory image</strong> (<code>-factory.bin</code>, the whole flash at offset 0 for a first install over USB), the <strong>symbols</strong> (<code>.elf.gz</code>, to decode a crash report) and <code>SHA256SUMS</code>.</p>
|
||||
</div>
|
||||
|
||||
{% if releases %}
|
||||
|
||||
@@ -164,15 +164,16 @@ def build():
|
||||
+ relink("\n".join(readme[k] for k in ("Flash", "Firmware Updates over Wi-Fi (OTA)")), "README.md"))
|
||||
|
||||
common, debug = help_text()
|
||||
if debug:
|
||||
sys.exit("gen_dev_docs: kHelp has a RORO_DEBUG part again: there is one firmware (ADR 0010)")
|
||||
exact, prefix = safe_mode_commands()
|
||||
safe = ", ".join(f"`{c}`" for c in exact) + ", and anything starting with " + ", ".join(f"`{p.strip()}`" for p in prefix)
|
||||
dev = readme["Development aids"].split("### Debug Builds and the Debug Console")[0]
|
||||
dev = readme["Development aids"].split("### The Debug Console")[0]
|
||||
dev = re.sub(r"^## Development aids\n", "", dev).strip() + "\n"
|
||||
pages["debug/commands.md"] = (
|
||||
front("Command reference", "Every command the firmware understands, over USB serial or the Debug Console: what `help` prints, then what each one does.", 30, "src/main.cpp and README.md", tag="Reference")
|
||||
+ "## What `help` prints\n\n"
|
||||
+ "The firmware's own list, read from `src/main.cpp`. These work in every build, over USB serial:\n\n```\n" + common + "\n```\n\n"
|
||||
+ "A **Debug Build** adds these (the last ones, marked *Debug Console only*, exist only over Wi-Fi, where [`rdbg.py`](/dev/debug/files-and-screens/) speaks them):\n\n```\n" + debug + "\n```\n\n"
|
||||
+ "The firmware's own list, read from `src/main.cpp`. Every firmware has all of them, over USB serial and over the Debug Console. The ones marked *Debug Console only* exist only over Wi-Fi, where [`rdbg.py`](/dev/debug/files-and-screens/) speaks them, and the ones marked *USB serial only* only over the cable:\n\n```\n" + common + "\n```\n\n"
|
||||
+ f"In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few run: {safe}. Anything else answers `not available in Safe Mode`.\n\n"
|
||||
+ "## What they do\n\n" + dev)
|
||||
return pages
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -80,7 +80,6 @@ bool FirmwarePage::installable(std::string& why) const {
|
||||
std::string running = update_.runningVersion();
|
||||
if (!shown_.usable()) why = "Nothing to install in it";
|
||||
else if (!release::versionNewer(shown_.tag, running) && !versionOlder(shown_.tag, running)) why = "That's the version running";
|
||||
else if (release::isDebugBuild(running)) why = "Debug Build: update from the PC"; // it keeps its console (Q171)
|
||||
else if (wifi_.state() != WifiController::State::Connected) why = "Not connected to Wi-Fi";
|
||||
else return true;
|
||||
return false;
|
||||
|
||||
@@ -34,6 +34,9 @@ bool SettingsApp::onKey(const KeyEvent& e) {
|
||||
case Page::Firmware:
|
||||
if (!firmwarePage_.onKey(e)) page_ = Page::Menu;
|
||||
return true;
|
||||
case Page::Debug:
|
||||
if (!debugPage_.onKey(e)) page_ = Page::Menu;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -75,6 +78,10 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) {
|
||||
page_ = Page::Firmware;
|
||||
firmwarePage_.enter();
|
||||
break;
|
||||
case Row::DebugConsole:
|
||||
page_ = Page::Debug;
|
||||
debugPage_.enter();
|
||||
break;
|
||||
default: page_ = Page::About; break;
|
||||
}
|
||||
break;
|
||||
@@ -124,7 +131,7 @@ bool SettingsApp::onAboutKey(const KeyEvent& e) {
|
||||
|
||||
void SettingsApp::update(uint32_t nowMs) {
|
||||
// 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());
|
||||
if (live && nowMs - lastRefreshMs_ >= 500) {
|
||||
lastRefreshMs_ = nowMs;
|
||||
@@ -184,6 +191,7 @@ void SettingsApp::draw(Canvas& c) {
|
||||
case Page::About: widgets::textLines(c, aboutLines(), 0, area); break;
|
||||
case Page::Wifi: wifiPage_.draw(c); break;
|
||||
case Page::Firmware: firmwarePage_.draw(c); break;
|
||||
case Page::Debug: debugPage_.draw(c); break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "services/battery_service.h"
|
||||
#include "services/clock_service.h"
|
||||
#include "services/storage_service.h"
|
||||
#include "apps/debug_console_page.h"
|
||||
#include "apps/firmware_page.h"
|
||||
#include "apps/wifi_settings_page.h"
|
||||
#include "settings_menu.h"
|
||||
@@ -32,24 +33,26 @@ struct SettingsAppDeps {
|
||||
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 {
|
||||
public:
|
||||
explicit SettingsApp(const SettingsAppDeps& deps)
|
||||
: d_(deps),
|
||||
menu_(deps.settings),
|
||||
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;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
void update(uint32_t nowMs) 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;
|
||||
|
||||
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 onTextKey(const KeyEvent& e);
|
||||
@@ -62,6 +65,7 @@ class SettingsApp : public App {
|
||||
SettingsMenu menu_;
|
||||
WifiSettingsPage wifiPage_;
|
||||
FirmwarePage firmwarePage_;
|
||||
DebugConsolePage debugPage_;
|
||||
Page page_ = Page::Menu;
|
||||
ListModel list_{theme::kContent.h / theme::kLineHeight};
|
||||
ListModel choices_{theme::kContent.h / theme::kLineHeight};
|
||||
|
||||
+40
-57
@@ -74,17 +74,13 @@ static ClockService* clockService;
|
||||
static GnssService* gnssService;
|
||||
static RadioService* radioService;
|
||||
static LoraCaptureService* loraCapture;
|
||||
#ifdef RORO_DEBUG
|
||||
static NoiseTest* noiseTest;
|
||||
#endif
|
||||
static GeminiService* geminiService;
|
||||
static SavedNetworks* savedNetworks;
|
||||
static WifiService* wifi;
|
||||
static IrcService* irc;
|
||||
static UpdateService* update;
|
||||
#ifdef RORO_DEBUG
|
||||
static DebugConsole* debugConsole;
|
||||
#endif
|
||||
static Notifier* notifier;
|
||||
static LauncherApp launcher;
|
||||
static AppManager* apps;
|
||||
@@ -131,6 +127,7 @@ static StatusInfo currentStatus() {
|
||||
s.radio = last && millis() - last < 400 ? StatusInfo::Radio::Packet : StatusInfo::Radio::Listening;
|
||||
}
|
||||
s.capturing = loraCapture && loraCapture->capturing();
|
||||
s.debug = !debugConsole->on() ? StatusInfo::Debug::Off : debugConsole->clientConnected() ? StatusInfo::Debug::Client : StatusInfo::Debug::On;
|
||||
using WifiState = WifiController::State;
|
||||
switch (wifi->state()) {
|
||||
case WifiState::Connected: {
|
||||
@@ -194,18 +191,14 @@ void setup() {
|
||||
services.add(*gnssService);
|
||||
services.add(*radioService);
|
||||
loraCapture = new LoraCaptureService(*radioService, *storageService, *clockService, bus);
|
||||
#ifdef RORO_DEBUG
|
||||
noiseTest = new NoiseTest(*radioService);
|
||||
#endif
|
||||
services.add(*loraCapture);
|
||||
services.add(*storageService);
|
||||
services.add(*wifi);
|
||||
services.add(*irc);
|
||||
services.add(*update);
|
||||
#ifdef RORO_DEBUG
|
||||
debugConsole = new DebugConsole(*wifi, *storageService);
|
||||
debugConsole = new DebugConsole(*wifi, *storageService, settings);
|
||||
services.add(*debugConsole);
|
||||
#endif
|
||||
|
||||
apps = new AppManager(launcher);
|
||||
launcher.setManager(*apps);
|
||||
@@ -228,9 +221,7 @@ void setup() {
|
||||
bus.subscribe(EventType::Notification, [](const Event& e) { console.printf("notification: %s\n", e.text); });
|
||||
|
||||
if (!screen.begin()) console.println("frame buffer allocation failed");
|
||||
#ifdef RORO_DEBUG
|
||||
debugConsole->setFrame(screen.canvas()); // for `screenshot`
|
||||
#endif
|
||||
services.startAll(millis());
|
||||
apps->begin();
|
||||
if (!settings.getBool(Setting::SetupDone)) apps->openModal("setup");
|
||||
@@ -246,7 +237,7 @@ void setup() {
|
||||
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.
|
||||
static void setupSafeMode(int crashes) {
|
||||
clockService = new ClockService(settings, bus);
|
||||
@@ -258,15 +249,11 @@ static void setupSafeMode(int crashes) {
|
||||
services.add(*clockService);
|
||||
services.add(*wifi);
|
||||
services.add(*update);
|
||||
#ifdef RORO_DEBUG
|
||||
debugConsole = new DebugConsole(*wifi, *storageService);
|
||||
debugConsole = new DebugConsole(*wifi, *storageService, settings);
|
||||
services.add(*debugConsole);
|
||||
#endif
|
||||
bus.subscribe(EventType::Notification, [](const Event& e) { console.printf("notification: %s\n", e.text); });
|
||||
screen.begin();
|
||||
#ifdef RORO_DEBUG
|
||||
debugConsole->setFrame(screen.canvas());
|
||||
#endif
|
||||
services.startAll(millis());
|
||||
console.printf("%s %s in SAFE MODE: %d crash restarts in a row. Only Wi-Fi and Firmware Updates run.\n",
|
||||
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
|
||||
// wrong address tried over Wi-Fi doesn't cut the device off for good.
|
||||
static struct {
|
||||
@@ -404,7 +390,6 @@ static void ipTrialStep() {
|
||||
ipTrial.wasFixed ? net::formatFixed(ipTrial.was).c_str() : "Automatic (DHCP)");
|
||||
wifi->ipSettingChanged(ipTrial.ssid);
|
||||
}
|
||||
#endif
|
||||
|
||||
// `tasks`: the first sample, and when to take the second and print.
|
||||
static std::vector<TaskSample> tasksBefore;
|
||||
@@ -467,13 +452,11 @@ static void printReleases(bool list) {
|
||||
|
||||
static void updateStep() {
|
||||
if (!updateWatch || update->giteaBusy()) return;
|
||||
#ifdef RORO_DEBUG
|
||||
if (updateWatch == 3) {
|
||||
console.printf("update: probe: %s\n", update->probeResult().c_str());
|
||||
updateWatch = 0;
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
printReleases(updateWatch == 2);
|
||||
updateWatch = 0;
|
||||
}
|
||||
@@ -492,14 +475,8 @@ static void updateCommand(const String& args) {
|
||||
printReleases(false);
|
||||
} else if (args.startsWith("install ")) {
|
||||
String rest = args.substring(8);
|
||||
bool force = rest.endsWith(" force");
|
||||
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);
|
||||
std::string why = update->requestInstall(rest.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?
|
||||
String rest = args.substring(6);
|
||||
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
|
||||
update->pretendVersion(args.substring(8) == "off" ? "" : args.substring(8).c_str());
|
||||
console.printf("update: running %s\n", update->runningVersion().c_str());
|
||||
#endif
|
||||
} else {
|
||||
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)
|
||||
#ifdef RORO_DEBUG
|
||||
static bool loopSpin = false; // `loop spin on`: no rest between passes, to compare load and radio noise
|
||||
#endif
|
||||
static uint32_t tasksPasses = 0;
|
||||
|
||||
static void tasksStep() {
|
||||
@@ -590,7 +564,8 @@ static const char* const kHelp =
|
||||
"install <path.ota> Update from SD\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"
|
||||
#ifdef RORO_DEBUG
|
||||
"debug status | debug off the Debug Console over Wi-Fi (Settings > Debug Console)\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"
|
||||
"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"
|
||||
@@ -601,34 +576,58 @@ static const char* const kHelp =
|
||||
"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"
|
||||
"quit close the Debug Console connection\n"
|
||||
#endif
|
||||
;
|
||||
|
||||
// Commands that only touch what Safe Mode starts.
|
||||
static bool safeModeCommand(const String& line) {
|
||||
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 == "wifi status" || line.startsWith("wifi add ");
|
||||
line == "wifi status" || line.startsWith("wifi add ") || line.startsWith("debug ");
|
||||
}
|
||||
|
||||
static void runCommand(String line) {
|
||||
// `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 (!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 | on | token <16 to 64 characters> | token new");
|
||||
}
|
||||
|
||||
static void runCommand(String line, bool fromSerial = false) {
|
||||
line.trim();
|
||||
if (line.isEmpty()) return;
|
||||
if (safeMode && !safeModeCommand(line)) return (void)console.println("not available in Safe Mode");
|
||||
if (line == "help") console.print(kHelp);
|
||||
if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial);
|
||||
if (line == "info") {
|
||||
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(),
|
||||
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);
|
||||
}
|
||||
#ifdef RORO_DEBUG
|
||||
if (line == "loop spin on" || line == "loop spin off") {
|
||||
loopSpin = line.endsWith("on");
|
||||
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
|
||||
tasksTotal = system_info::sampleTasks(tasksBefore);
|
||||
tasksDueMs = millis() + 1000;
|
||||
@@ -670,7 +669,6 @@ static void runCommand(String line) {
|
||||
});
|
||||
}
|
||||
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
|
||||
int space = line.lastIndexOf(' ');
|
||||
std::string folder = line.substring(8, space).c_str();
|
||||
@@ -689,11 +687,9 @@ static void runCommand(String line) {
|
||||
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 status" && radioService) radioService->printStatus(console);
|
||||
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 test" && noiseTest && !noiseTest->running()) {
|
||||
// One thing changed at a time, each put back before the next (issue #20). Wi-Fi off cuts the
|
||||
@@ -758,7 +754,6 @@ static void runCommand(String line) {
|
||||
};
|
||||
noiseTest->start(std::move(conditions));
|
||||
}
|
||||
#endif
|
||||
if (line.startsWith("lora sweep") && radioService) { // on [from MHz] [to MHz] [step kHz] | off | dump
|
||||
if (line == "lora sweep dump") radioService->printSweep(console);
|
||||
else if (line == "lora sweep off") {
|
||||
@@ -776,7 +771,6 @@ static void runCommand(String line) {
|
||||
console.printf("lora capture: %s\n", why.empty() ? loraCapture->path().c_str() : why.c_str());
|
||||
}
|
||||
if (line == "lora capture stop" && loraCapture) loraCapture->stop();
|
||||
#ifdef RORO_DEBUG
|
||||
if (line.startsWith("lora inject ") && radioService) { // <hex> [rssi] [snr]: as if received
|
||||
String hex = line.substring(12);
|
||||
int space = hex.indexOf(' ');
|
||||
@@ -787,7 +781,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);
|
||||
radioService->inject(data, n, rssi, snr);
|
||||
}
|
||||
#endif
|
||||
if (line.startsWith("lora custom ") && radioService) {
|
||||
double mhz = 0, bw = 0;
|
||||
unsigned sf = 0, cr = 0, sync = 0, preamble = 8;
|
||||
@@ -822,11 +815,9 @@ static void runCommand(String line) {
|
||||
console.println(gnssService->send(line.substring(10).c_str()) ? "gnss: sent" : "gnss: off");
|
||||
if (line == "crash") crash_report::print(console, nvs);
|
||||
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 wdt")
|
||||
for (;;) {} // the main loop never yields: the task watchdog fires
|
||||
#endif
|
||||
if (line == "burst")
|
||||
for (int i = 1; i <= 5; i++)
|
||||
bus.publish(Event::withText(EventType::Notification, ("Burst " + String(i)).c_str(), 0));
|
||||
@@ -933,7 +924,6 @@ static void runCommand(String line) {
|
||||
}
|
||||
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();
|
||||
#ifdef RORO_DEBUG
|
||||
if (rest == "keep") { // the trial setting stays
|
||||
console.println(ipTrial.active ? "wifi ip: kept" : "wifi ip: no trial running");
|
||||
ipTrial.active = false;
|
||||
@@ -945,7 +935,6 @@ static void runCommand(String line) {
|
||||
trialS = strtoul(rest.c_str() + tryAt + 5, nullptr, 10);
|
||||
rest = rest.substr(0, tryAt);
|
||||
}
|
||||
#endif
|
||||
std::string ssid, why;
|
||||
net::FixedIp fixed;
|
||||
bool dhcp = rest.size() > 5 && rest.compare(rest.size() - 5, 5, " dhcp") == 0;
|
||||
@@ -959,9 +948,7 @@ static void runCommand(String line) {
|
||||
}
|
||||
}
|
||||
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};
|
||||
#endif
|
||||
if (why.empty()) why = savedNetworks->setIp(ssid, dhcp ? nullptr : &fixed);
|
||||
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());
|
||||
@@ -1007,18 +994,18 @@ static void serialCommands() {
|
||||
line += c;
|
||||
continue;
|
||||
}
|
||||
runCommand(line);
|
||||
runCommand(line, true);
|
||||
line = "";
|
||||
}
|
||||
}
|
||||
|
||||
static void remoteCommands() {
|
||||
#ifdef RORO_DEBUG
|
||||
for (std::string remote; debugConsole->takeCommand(remote);) {
|
||||
console.printf("> %s\n", remote.c_str()); // so the transcript reads the same on both ends
|
||||
runCommand(remote.c_str());
|
||||
}
|
||||
#endif
|
||||
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).
|
||||
@@ -1069,9 +1056,7 @@ void loop() {
|
||||
return;
|
||||
}
|
||||
loopPass();
|
||||
#ifdef RORO_DEBUG
|
||||
if (loopSpin) return;
|
||||
#endif
|
||||
if (upload.active()) return; // a serial file transfer: every byte is read promptly
|
||||
delay(power->screen() == ScreenState::Off ? kLoopRestScreenOffMs : kLoopRestScreenOnMs);
|
||||
}
|
||||
@@ -1088,10 +1073,8 @@ static void loopPass() {
|
||||
serialCommands();
|
||||
remoteCommands();
|
||||
tasksStep();
|
||||
#ifdef RORO_DEBUG
|
||||
ipTrialStep();
|
||||
if (noiseTest) noiseTest->step(now);
|
||||
#endif
|
||||
noteStableOnce(now);
|
||||
updateStep();
|
||||
fileOpsStep();
|
||||
|
||||
@@ -2,19 +2,16 @@
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#ifdef RORO_DEBUG
|
||||
#include <esp_log.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#endif
|
||||
|
||||
namespace roro {
|
||||
|
||||
Console console;
|
||||
|
||||
#ifdef RORO_DEBUG
|
||||
namespace {
|
||||
|
||||
// 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
|
||||
|
||||
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) {
|
||||
if (len > kRingBytes) {
|
||||
@@ -42,11 +60,13 @@ void Console::toRing(const uint8_t* data, size_t len) {
|
||||
len = kRingBytes;
|
||||
}
|
||||
portENTER_CRITICAL(&ringLock);
|
||||
if (ring_) {
|
||||
size_t at = head_ % kRingBytes;
|
||||
size_t first = std::min(len, kRingBytes - at);
|
||||
memcpy(ring_ + at, data, first);
|
||||
memcpy(ring_, data + first, len - first);
|
||||
head_ += len;
|
||||
}
|
||||
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) {
|
||||
portENTER_CRITICAL(&ringLock);
|
||||
size_t n = 0;
|
||||
skipped = 0;
|
||||
if (ring_) {
|
||||
uint32_t from = head_ > kRingBytes ? head_ - kRingBytes : 0;
|
||||
skipped = pos < from ? from - pos : 0;
|
||||
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 first = std::min(n, kRingBytes - at);
|
||||
memcpy(out, ring_ + at, first);
|
||||
memcpy(out + first, ring_, n - first);
|
||||
pos += n;
|
||||
}
|
||||
portEXIT_CRITICAL(&ringLock);
|
||||
return n;
|
||||
}
|
||||
#endif
|
||||
|
||||
size_t Console::write(const uint8_t* data, size_t len) {
|
||||
#ifdef RORO_DEBUG
|
||||
toRing(data, len);
|
||||
#endif
|
||||
// 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.
|
||||
if (Serial.availableForWrite() >= static_cast<int>(len)) Serial.write(data, len);
|
||||
|
||||
+11
-7
@@ -7,18 +7,23 @@
|
||||
|
||||
namespace roro {
|
||||
|
||||
// Where the firmware's console output goes: the USB serial port, and in a Debug Build also a ring
|
||||
// buffer the Debug Console sends over Wi-Fi (with what came before the connection, so boot messages
|
||||
// aren't lost). Use `console` instead of Serial for anything a human should be able to read remotely.
|
||||
// Where the firmware's console output goes: the USB serial port, and while the Debug Console is
|
||||
// switched on (ADR 0010) also a ring buffer it sends over Wi-Fi, with what came before the connection.
|
||||
// Use `console` instead of Serial for anything a human should be able to read remotely.
|
||||
class Console : public Print {
|
||||
public:
|
||||
size_t write(uint8_t c) override { return write(&c, 1); }
|
||||
size_t write(const uint8_t* data, size_t len) override;
|
||||
using Print::write;
|
||||
|
||||
#ifdef RORO_DEBUG
|
||||
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).
|
||||
void captureEspLogs();
|
||||
// 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);
|
||||
|
||||
private:
|
||||
uint8_t ring_[kRingBytes];
|
||||
uint32_t head_ = 0; // total bytes ever written; the ring holds the last kRingBytes of them
|
||||
#endif
|
||||
uint8_t* ring_ = nullptr;
|
||||
uint32_t head_ = 0; // total bytes written since the ring opened; it holds the last kRingBytes of them
|
||||
};
|
||||
|
||||
extern Console console;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#ifdef RORO_DEBUG
|
||||
|
||||
#include "services/debug_console.h"
|
||||
|
||||
@@ -7,6 +6,7 @@
|
||||
#include <algorithm>
|
||||
#include <esp_core_dump.h>
|
||||
#include <esp_flash.h>
|
||||
#include <esp_random.h>
|
||||
|
||||
#include <SD.h>
|
||||
#include <unistd.h>
|
||||
@@ -29,14 +29,6 @@ constexpr uint32_t kAuthTimeoutMs = 10000;
|
||||
constexpr size_t kMaxLine = 240;
|
||||
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.
|
||||
bool readLine(NetworkClient& c, std::string& line, uint32_t timeoutMs) {
|
||||
line.clear();
|
||||
@@ -77,12 +69,40 @@ void sendCoreDump(NetworkClient& client) {
|
||||
|
||||
} // namespace
|
||||
|
||||
DebugConsole::DebugConsole(WifiService& wifi, StorageService& storage)
|
||||
: wifi_(wifi), storage_(storage), lock_(xSemaphoreCreateMutex()) {}
|
||||
DebugConsole::DebugConsole(WifiService& wifi, StorageService& storage, Settings& settings)
|
||||
: 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();
|
||||
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) {
|
||||
@@ -96,12 +116,22 @@ bool DebugConsole::takeCommand(std::string& line) {
|
||||
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::listen() {
|
||||
{
|
||||
NetworkServer server(kPort);
|
||||
bool listening = false;
|
||||
for (;;) {
|
||||
while (wanted_) {
|
||||
bool up = wifi_.state() == WifiController::State::Connected;
|
||||
if (up && !listening) {
|
||||
server.begin();
|
||||
@@ -120,14 +150,48 @@ void DebugConsole::listen() {
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(200));
|
||||
}
|
||||
if (listening) server.end();
|
||||
}
|
||||
// Switched off: nothing is left behind (Q189). The commands a client queued die with it.
|
||||
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) {
|
||||
std::string token;
|
||||
if (readLine(client, token, kAuthTimeoutMs) && sameToken(token, RORO_DEBUG_TOKEN)) return true;
|
||||
console.printf("debug: refused a client from %s\n", client.remoteIP().toString().c_str());
|
||||
if (gate_.locked(millis())) {
|
||||
client.print("locked\n");
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -135,10 +199,11 @@ void DebugConsole::serve(NetworkClient& client) {
|
||||
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());
|
||||
connected_ = true;
|
||||
uint32_t tokenSeq = tokenSeq_;
|
||||
uint32_t pos = console.oldest();
|
||||
uint8_t buf[512];
|
||||
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.
|
||||
uint32_t skipped = 0;
|
||||
size_t n;
|
||||
@@ -313,4 +378,3 @@ void DebugConsole::screenshot(NetworkClient& client) {
|
||||
|
||||
} // namespace roro
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef RORO_DEBUG
|
||||
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
|
||||
@@ -9,18 +7,22 @@
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
#include "debug_auth.h"
|
||||
#include "service.h"
|
||||
#include "services/storage_service.h"
|
||||
#include "services/wifi_service.h"
|
||||
#include "settings.h"
|
||||
#include "ui/canvas.h"
|
||||
|
||||
class NetworkClient;
|
||||
|
||||
namespace roro {
|
||||
|
||||
// Debug Builds only (ADR 0004): the console over Wi-Fi, on TCP 2323 while Wi-Fi is Connected. A
|
||||
// client sends the debug token as its first line, 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 console over Wi-Fi, on TCP 2323 while Wi-Fi is Connected, in every build but only while it's
|
||||
// switched on in Settings (ADR 0010): off, nothing listens, and neither its task nor the console's
|
||||
// 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),
|
||||
// which runs them where touching Apps and Services is safe. Their output reaches the client
|
||||
@@ -29,18 +31,29 @@ class DebugConsole : public Service {
|
||||
public:
|
||||
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.
|
||||
void setFrame(Canvas& frame) { frame_ = &frame; }
|
||||
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.
|
||||
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_; }
|
||||
|
||||
private:
|
||||
static void taskEntry(void* self);
|
||||
void apply();
|
||||
void listen();
|
||||
void serve(::NetworkClient& client);
|
||||
bool authenticate(::NetworkClient& client);
|
||||
@@ -55,13 +68,16 @@ class DebugConsole : public Service {
|
||||
|
||||
WifiService& wifi_;
|
||||
StorageService& storage_;
|
||||
Settings& settings_;
|
||||
Canvas* frame_ = nullptr;
|
||||
TaskHandle_t task_ = nullptr;
|
||||
SemaphoreHandle_t lock_;
|
||||
volatile TaskHandle_t task_ = nullptr; // null once the task has freed everything and gone
|
||||
SemaphoreHandle_t lock_; // commands_, token_, alert_
|
||||
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;
|
||||
debug::AuthGate gate_; // the task's own
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#ifdef RORO_DEBUG
|
||||
|
||||
#include "services/noise_test.h"
|
||||
|
||||
@@ -107,4 +106,3 @@ void NoiseTest::printReport(Print& out) const {
|
||||
|
||||
} // namespace roro
|
||||
|
||||
#endif // RORO_DEBUG
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef RORO_DEBUG
|
||||
|
||||
#include <Print.h>
|
||||
|
||||
@@ -56,4 +55,3 @@ class NoiseTest {
|
||||
|
||||
} // namespace roro
|
||||
|
||||
#endif // RORO_DEBUG
|
||||
|
||||
@@ -190,7 +190,6 @@ void RadioService::store(RadioPacket& p) {
|
||||
lastPacketMs_ = p.ms;
|
||||
}
|
||||
|
||||
#ifdef RORO_DEBUG
|
||||
void RadioService::debugAntenna(bool on) {
|
||||
auto& i2c = M5.In_I2C;
|
||||
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);
|
||||
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
|
||||
// IRQ status, which sampleNoise() reads.
|
||||
|
||||
@@ -99,7 +99,6 @@ class RadioService : public Service {
|
||||
void printStatus(Print& out) const;
|
||||
void setEcho(bool echo);
|
||||
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
|
||||
// converter, and receive gain boosted or not. Used the next time the radio is set up.
|
||||
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
|
||||
// transmitter in range. Nothing goes on air.
|
||||
void inject(const uint8_t* data, size_t len, float rssi, float snr);
|
||||
#endif
|
||||
|
||||
private:
|
||||
enum Notify : uint32_t { kIrq = 1, kRequest = 2 };
|
||||
|
||||
@@ -215,9 +215,8 @@ std::string UpdateService::failedVersion() const {
|
||||
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 (!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";
|
||||
release::Release r;
|
||||
if (!gitea_.find(tag, r)) return "Look for releases first";
|
||||
@@ -295,14 +294,12 @@ void UpdateService::serve() {
|
||||
if (gitea_.find(installTag_, release)) installFromGitea(release);
|
||||
break;
|
||||
}
|
||||
#ifdef RORO_DEBUG
|
||||
case Request::Probe: {
|
||||
HttpsGet get(net::User::Updates);
|
||||
std::string why = get.open(probeHost_, probePath_, "*/*");
|
||||
probeResult_ = why.empty() ? "accepted, the server answered 200" : why;
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
case Request::None: break;
|
||||
}
|
||||
// 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);
|
||||
return;
|
||||
}
|
||||
#ifdef RORO_DEBUG
|
||||
GiteaSource source(get, damageCut_, damageFlip_);
|
||||
damageCut_ = damageFlip_ = -1;
|
||||
#else
|
||||
GiteaSource source(get, -1, -1);
|
||||
#endif
|
||||
install(source, "Gitea");
|
||||
}
|
||||
|
||||
|
||||
@@ -50,9 +50,8 @@ class UpdateService : public Service {
|
||||
void requestCheck() { request_ = Request::Check; }
|
||||
void requestList() { request_ = Request::List; }
|
||||
// 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,
|
||||
// it would take its Debug Console away) unless `force`, which only the Debug Console passes.
|
||||
std::string requestInstall(const std::string& tag, bool force = false);
|
||||
// installs it. "" when it started, or why not.
|
||||
std::string requestInstall(const std::string& tag);
|
||||
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
|
||||
// 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_; }
|
||||
void pretendVersion(const std::string& v) { fakeVersion_ = v; }
|
||||
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
|
||||
// (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; }
|
||||
std::string probeResult() const { return probeResult_; }
|
||||
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
|
||||
#endif
|
||||
|
||||
// The main loop calls this once it has drawn a frame (part of Probation).
|
||||
void firstFrameDrawn() { firstFrame_ = true; }
|
||||
@@ -109,11 +106,9 @@ class UpdateService : public Service {
|
||||
std::string installTag_, fakeVersion_, announced_;
|
||||
bool dailyCheck_ = false;
|
||||
uint32_t nextCheckMs_ = 90000; // not before the device has settled
|
||||
#ifdef RORO_DEBUG
|
||||
std::string probeHost_, probePath_;
|
||||
volatile long damageCut_ = -1, damageFlip_ = -1;
|
||||
std::string probeResult_;
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
|
||||
@@ -56,11 +56,9 @@ class WifiService : public Service {
|
||||
void ipSettingChanged(const std::string& ssid);
|
||||
// The DNS or NTP settings changed: use them now.
|
||||
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
|
||||
// during the test brings Wi-Fi back, which a changed setting wouldn't.
|
||||
void debugPause(bool paused) { paused_ = paused; }
|
||||
#endif
|
||||
|
||||
// A Saved Network was added: try it now rather than after the retry delay.
|
||||
void savedNetworksChanged() { controller_.retryNow(millis()); }
|
||||
|
||||
@@ -48,6 +48,11 @@ void statusBar(Canvas& c, const StatusInfo& info) {
|
||||
case StatusInfo::Wifi::Monitoring: right("MON", kAccent); 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.capturing) right("CAP", kWarning);
|
||||
switch (info.radio) { // Q101: muted while listening, bright for a moment on each packet
|
||||
|
||||
+2
-1
@@ -29,12 +29,13 @@ struct StatusInfo {
|
||||
bool tracking = false; // a Track is recording (Q63)
|
||||
enum class Radio { None, Listening, Packet, Sweep } radio = Radio::None; // M3, Q101: Packet flashes
|
||||
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 {
|
||||
return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock &&
|
||||
sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose && wifi == o.wifi &&
|
||||
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); }
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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")); // already said so
|
||||
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() {
|
||||
|
||||
@@ -185,6 +185,41 @@ void test_dns_and_ntp_are_checked() {
|
||||
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() {
|
||||
UNITY_BEGIN();
|
||||
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_dns_and_ntp_defaults);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -97,6 +97,17 @@ void test_wifi_is_a_page() {
|
||||
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() {
|
||||
Fixture f;
|
||||
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_check_for_updates_is_on_by_default);
|
||||
RUN_TEST(test_gnss_and_coordinate_rows_toggle);
|
||||
RUN_TEST(test_the_debug_console_is_a_page_that_says_off);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user