One firmware: the Debug Console in every build, off until switched on, with the device's own token
CI / build (pull_request) Successful in 7m20s
Site / build (pull_request) Successful in 9s

There is no Debug Build any more (ADR 0010, issue #68, Q188 to Q195). The
console and the test commands are compiled into every firmware. It listens
only while Settings > Debug Console is on, which isn't the default; off,
neither its task nor its 4 KB ring exists. The token is made by the device
and shown on that page; a client proves it knows it by answering a challenge
with an HMAC, so it never crosses the network, and five wrong answers close
the console for a minute. DBG in the Status Bar while it listens.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-06 22:59:35 +02:00
co-authored by Claude Opus 5.5
parent 1353e6a5f9
commit c68741cc46
65 changed files with 1245 additions and 374 deletions
+3 -1
View File
@@ -6,7 +6,9 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
[ -n "${RORO_NO_DOCKER:-}" ] || docker build -q -t "$IMAGE" "$ROOT/docker" >/dev/null
# 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
View File
@@ -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"
-12
View File
@@ -1,12 +0,0 @@
# Debug Builds only: compiles in the Debug Console token from $RORO_DEBUG_TOKEN (set by _docker.sh
# from ~/.config/roro9stack/debug-token). Refuses to build without one rather than use a default.
import os
import re
import sys
Import("env") # noqa: F821 (provided by PlatformIO)
token = os.environ.get("RORO_DEBUG_TOKEN", "").strip()
if not re.fullmatch(r"[0-9a-f]{32}", token):
sys.exit("debug build: RORO_DEBUG_TOKEN is missing; build through scripts/ci.sh or scripts/flash.sh --debug")
env.Append(CPPDEFINES=[("RORO_DEBUG_TOKEN", '\\"%s\\"' % token)]) # noqa: F821
+16 -7
View File
@@ -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.
# Usage: scripts/flash.sh [--debug] [port] USB (default: the first Espressif device found)
# 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
View File
@@ -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"))
-3
View File
@@ -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