Debug Console: files, screenshots and Update from SD over Wi-Fi

New commands everywhere: ls, rm and install <path> (Update from SD
without the Firmware page), all as Storage Service jobs. In Debug
Builds the console task answers get and put (one storage job per
transfer, file kept open, TCP flow control: about 300 KB/s, against
55 KB/s over serial) and screenshot (the RGB332 frame the UI composes
into). rdbg.py turns those into files and PNGs.

A failed put closes the connection: the rest of the file had been
parsed as commands. Card writes are retried 3 times after closing,
truncating to the last good byte and reopening, since FATFS keeps a
file in error after one failed write (seen once at 1.3 MB on this card).
onStorage() decides with one compare-and-swap whether the job or the
timeout wins, so an abandoned job can't touch a returned stack frame.

Verified on the device: screenshot; a get round trip byte-identical;
4 puts in a row; a tampered .ota refused by install; a good one put,
installed from SD, confirmed on Probation. The retry path itself has
not fired since it was added.

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 03:18:23 +02:00
co-authored by Claude Opus 5.5
parent 14ff13f634
commit 388e847cd4
6 changed files with 275 additions and 14 deletions
+86 -2
View File
@@ -8,14 +8,21 @@ Usage: scripts/rdbg.py [-H host] [-b] [command ...]
-b also print the backlog the device sends on connecting (boot messages and so on)
Commands handled here as well as on the device:
crash the last crash, with its backtrace decoded (scripts/decode_backtrace.sh)
coredump [file] fetch the core dump (default core-<date>.bin) and decode it with esp-coredump
crash the last crash, with its backtrace decoded (scripts/decode_backtrace.sh)
coredump [file] fetch the core dump (default core-<date>.bin) and decode it with esp-coredump
get <card path> [file] copy a file from the SD card
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).
"""
import hashlib
import os
import re
import select
import struct
import subprocess
import zlib
import socket
import sys
import time
@@ -134,6 +141,77 @@ def coredump(sock, path):
subprocess.call([os.path.join(SCRIPTS, "decode_coredump.sh"), path, key])
def binary(sock, command, tag):
"""Sends a binary command; returns (header words, payload) or exits with the device's error."""
sock.sendall((command + "\n").encode())
buf = b""
sock.settimeout(30)
pattern = re.compile(tag.encode() + rb": (data|ready|rgb332|error)([^\n]*)\n")
while not (m := pattern.search(buf)):
chunk = sock.recv(65536)
if not chunk:
sys.exit("device: closed the connection")
buf += chunk
if m.group(1) == b"error":
sys.exit(f"device: {tag}: error{m.group(2).decode()}")
return m.group(1).decode(), m.group(2).decode().split(), buf[m.end():]
def receive(sock, have, size):
while len(have) < size:
chunk = sock.recv(65536)
if not chunk:
sys.exit(f"device: the connection closed after {len(have)} of {size} bytes")
have += chunk
return have[:size]
def get(sock, remote, local):
_, words, rest = binary(sock, f"get {remote}", "get")
start, size = time.time(), int(words[0])
data = receive(sock, rest, size)
local = local or os.path.basename(remote)
open(local, "wb").write(data)
print(f"saved {local} ({size} bytes, {size / 1024 / max(time.time() - start, 0.001):.0f} KB/s)")
def put(sock, local, remote):
data = open(local, "rb").read()
remote = remote or "/updates/" + os.path.basename(local)
digest = hashlib.sha256(data).hexdigest()
binary(sock, f"put {remote} {len(data)} {digest}", "put")
start = time.time()
sock.sendall(data)
answer = read_until(sock, b"\n", 30) or b"put: error no answer"
answer = answer.decode(errors="replace").strip().splitlines()[-1]
print(f"device: {answer} ({len(data) / 1024 / max(time.time() - start, 0.001):.0f} KB/s)")
if " error " in answer:
sys.exit(1)
def png(path, width, height, rgb, scale):
rows = b""
for y in range(height):
row = b"".join(rgb[(y * width + x) * 3:(y * width + x) * 3 + 3] * scale for x in range(width))
rows += (b"\x00" + row) * scale
def chunk(kind, body):
return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body))
header = struct.pack(">IIBBBBB", width * scale, height * scale, 8, 2, 0, 0, 0)
with open(path, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b""))
def screenshot(sock, path):
_, words, rest = binary(sock, "screenshot", "screenshot")
width, height = int(words[0]), int(words[1])
pixels = receive(sock, rest, width * height)
# RGB332, as M5GFX stores an 8-bit sprite: RRRGGGBB.
rgb = b"".join(bytes(((v >> 5) * 255 // 7, ((v >> 2) & 7) * 255 // 7, (v & 3) * 255 // 3)) for v in pixels)
path = path or time.strftime("screen-%Y%m%d-%H%M%S.png")
png(path, width, height, rgb, 2)
print(f"saved {path} ({width * 2}x{height * 2})")
def interactive(sock):
sock.setblocking(False)
while True:
@@ -181,6 +259,12 @@ def main():
return interactive(sock)
if args == ["crash"]:
return crash(sock)
if args[0] == "get" and len(args) >= 2:
return get(sock, args[1], args[2] if len(args) > 2 else None)
if args[0] == "put" and len(args) >= 2:
return put(sock, args[1], args[2] if len(args) > 2 else None)
if args[0] == "screenshot":
return screenshot(sock, args[1] if len(args) > 1 else None)
if args == ["reset"]: # answered by the console's own task, not the main loop
sock.sendall(b"reset\n")
print((read_until(sock, b"restarting now\n", 10) or b"device: no answer").decode().strip().splitlines()[-1])