Debug Builds: the console over Wi-Fi (Debug Console, TCP 2323)

cardputer-adv-debug (-DRORO_DEBUG, version +debug) adds a Debug Console:
after a token line, a client gets the last 6 KB of console output, live
lines (ESP-IDF logs included) and the serial commands. The socket task
only queues lines; the main loop runs them. Release builds compile none
of it. The token lives in ~/.config/roro9stack/debug-token, created by
_docker.sh and passed into the container.

All output now goes through `console`, which never waits for USB: a host
that was attached but not reading stalled the main loop up to 2 s per
line. New commands everywhere: info (slots with their versions from NVS,
since the framework stamps its own into each image), tasks, reboot,
boot other, log level, help. scripts/rdbg.py is the client; flash.sh
--debug builds it; CI builds both variants. ADR 0004.

Verified on the device: USB-flashed, then updated over Wi-Fi to a Debug
Build that confirmed on Probation; both slots hold Debug Builds.

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-04 02:44:14 +02:00
co-authored by Claude Opus 5.5
parent 5b199c2436
commit 0fb7f4e9d5
18 changed files with 828 additions and 97 deletions
+8
View File
@@ -4,9 +4,17 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
docker build -q -t "$IMAGE" "$ROOT/docker" >/dev/null
# The Debug Console token (ADR 0004): made once, kept with the OTA key, never committed.
DEBUG_TOKEN_FILE="$HOME/.config/roro9stack/debug-token"
if [ ! -s "$DEBUG_TOKEN_FILE" ]; then
mkdir -p "$(dirname "$DEBUG_TOKEN_FILE")"
(umask 077 && od -An -tx1 -N16 /dev/urandom | tr -d ' \n' > "$DEBUG_TOKEN_FILE")
fi
run_in_container() {
docker run --rm \
-u "$(id -u):$(id -g)" -e HOME=/tmp \
-e RORO_DEBUG_TOKEN="$(cat "$DEBUG_TOKEN_FILE")" \
-v "$ROOT:/work" \
-v roro9stack-pio:/pio \
"${DOCKER_EXTRA[@]}" \
+1 -1
View File
@@ -4,4 +4,4 @@ set -euo pipefail
source "$(dirname "$0")/_docker.sh"
DOCKER_EXTRA=()
run_in_container bash -c 'git config --global --add safe.directory /work && pio test -e native && pio run -e cardputer-adv'
run_in_container bash -c 'git config --global --add safe.directory /work && pio test -e native && pio run -e cardputer-adv -e cardputer-adv-debug'
+12
View File
@@ -0,0 +1,12 @@
# 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
+14 -7
View File
@@ -1,9 +1,15 @@
#!/usr/bin/env bash
# Flash the firmware over USB, then open the serial monitor.
# Usage: scripts/flash.sh [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 > About, or $RORO_OTA_HOST)
# 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.
set -euo pipefail
ENV=cardputer-adv
if [ "${1:-}" = "--debug" ]; then
ENV=cardputer-adv-debug
shift
fi
if [ "${1:-}" = "--ota" ]; then
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@@ -11,10 +17,11 @@ if [ "${1:-}" = "--ota" ]; then
[ -n "$HOST" ] || { echo "Usage: scripts/flash.sh --ota <device IP> (or set RORO_OTA_HOST)" >&2; exit 1; }
source "$(dirname "$0")/_docker.sh"
DOCKER_EXTRA=()
run_in_container bash -c 'git config --global --add safe.directory /work && pio run -e cardputer-adv' | tail -3
run_in_container bash -c "git config --global --add safe.directory /work && pio run -e $ENV" | tail -3
VERSION="$(git -C "$ROOT" describe --tags --always --dirty)"
OUT="$ROOT/.pio/build/cardputer-adv/roro9stack-$VERSION.ota"
"$ROOT/scripts/make_ota.py" "$ROOT/.pio/build/cardputer-adv/firmware.bin" "$VERSION" "$OUT"
[ "$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"
fi
PORT="${1:-$(readlink -f /dev/serial/by-id/*Espressif* 2>/dev/null | head -1)}"
@@ -23,4 +30,4 @@ 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 cardputer-adv -t upload --upload-port $PORT && pio device monitor -p $PORT -b 115200"
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"
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""The Debug Console of a Debug Build, over Wi-Fi (TCP 2323, see ADR 0004).
Usage: scripts/rdbg.py [-H host] [-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
-b also print the backlog the device sends on connecting (boot messages and so on)
The token is read from ~/.config/roro9stack/debug-token (made by the first build).
"""
import os
import select
import socket
import sys
import time
PORT = 2323
TOKEN_FILE = os.path.expanduser("~/.config/roro9stack/debug-token")
def read_until(sock, marker, timeout):
"""Everything up to and including `marker`, or None on a timeout or a closed connection."""
sock.settimeout(timeout)
data = b""
while marker not in data:
try:
chunk = sock.recv(4096)
except socket.timeout:
return None
if not chunk:
return None
data += chunk
return data
def read_until_quiet(sock, quiet, out):
"""Copies everything the device sends to `out` until nothing has arrived for `quiet` seconds."""
sock.settimeout(quiet)
while True:
try:
data = sock.recv(4096)
except socket.timeout:
return True
if not data:
return False
if out:
out.write(data.decode(errors="replace"))
out.flush()
def interactive(sock):
sock.setblocking(False)
while True:
ready, _, _ = select.select([sock, sys.stdin], [], [])
if sock in ready:
data = sock.recv(4096)
if not data:
print("\n(connection closed)")
return
sys.stdout.write(data.decode(errors="replace"))
sys.stdout.flush()
if sys.stdin in ready:
line = sys.stdin.readline()
if not line:
return
sock.sendall(line.encode())
def main():
args = sys.argv[1:]
host, backlog = os.environ.get("RORO_OTA_HOST"), False
while args and args[0].startswith("-"):
flag = args.pop(0)
if flag == "-H" and args:
host = 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()
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)")
show = sys.stdout if backlog or not args else None
if show:
show.write(banner.decode(errors="replace"))
read_until_quiet(sock, 0.5, show) # the backlog
if not args:
return interactive(sock)
command = " ".join(args)
sock.sendall((command + "\n").encode())
# The main loop echoes "> command" when it runs it; the reply follows.
echoed = read_until(sock, f"> {command}\n".encode(), 15)
if echoed is None:
sys.exit("device: the command never ran")
sys.stdout.write(echoed.decode(errors="replace").split(f"> {command}\n", 1)[1])
try:
read_until_quiet(sock, 1.5, sys.stdout)
except BrokenPipeError: # e.g. piped into head
pass
if __name__ == "__main__":
main()
+3
View File
@@ -10,4 +10,7 @@ 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