Files
roro9stack/site/tools/gen_dev_docs.py
T
twislaandClaude Opus 5.5 c68741cc46
CI / build (pull_request) Successful in 7m20s
Site / build (pull_request) Successful in 9s
One firmware: the Debug Console in every build, off until switched on, with the device's own token
There is no Debug Build any more (ADR 0010, issue #68, Q188 to Q195). The
console and the test commands are compiled into every firmware. It listens
only while Settings > Debug Console is on, which isn't the default; off,
neither its task nor its 4 KB ring exists. The token is made by the device
and shown on that page; a client proves it knows it by answering a challenge
with an HMAC, so it never crosses the network, and five wrong answers close
the console for a minute. DBG in the Status Bar while it listens.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 22:59:35 +02:00

204 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""Writes the developer pages of the site from the repository's own documents (docs/milestones/W1.md, phase 4).
python3 site/tools/gen_dev_docs.py writes site/content/dev/... (committed, so the server only runs `zola build`)
python3 site/tools/gen_dev_docs.py --check changes nothing; exits 1 if a generated page is out of date (CI)
Zola cannot read a file outside its own folder, so the pages are generated and committed. What is generated:
decisions/ one page for each docs/adr/*.md
milestones/ one page for each docs/milestones/*.md
build/build-and-test, build/flash sections of README.md
debug/commands the commands the firmware's `help` prints (parsed from src/main.cpp), then README's table of them
Every generated page says where it comes from; edit that file, not the page. Hand-written pages sit next to them.
"""
import json
import os
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
SITE = HERE.parent
REPO = SITE.parent
OUT = SITE / "content" / "dev"
REPO_URL = re.search(r'repo\s*=\s*"([^"]+)"', (SITE / "config.toml").read_text()).group(1)
MILESTONES = ["OTA", "M2", "G1", "M3", "S1", "F1", "R1", "W1"] # in the order they were done
# Left out on purpose: docs/milestones/M0.md, M1.md and CONTEXT.md (the glossary) describe Wi-Fi monitoring, which this site does not publish.
# They stay in the repository.
def plain(md, limit=230):
"""One line of plain text from the start of some Markdown, for a description."""
text = re.sub(r"`([^`]*)`", r"\1", md)
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = re.sub(r"\*{1,3}", "", text)
text = re.sub(r"\s+", " ", text).strip()
text = text[:1].upper() + text[1:]
if len(text) <= limit:
return text
cut = text[:limit].rsplit(" ", 1)[0].rstrip(",;:")
return cut + "…"
def front(title, description, weight, source, tag=None, template=None):
lines = ["+++", f"title = {json.dumps(title, ensure_ascii=False)}", f"description = {json.dumps(description, ensure_ascii=False)}", f"weight = {weight}"]
if template:
lines.append(f'template = "{template}"')
lines += ["", "[extra]", "docs = true", f"source = {json.dumps(source)}"]
if tag:
lines.append(f"tag = {json.dumps(tag, ensure_ascii=False)}")
lines += ["+++", ""]
return "\n".join(lines)
def relink(md, source):
"""Links between the repository's documents become links between the site's pages, or to Gitea."""
here = Path(source).parent
def fix(m):
label, target = m.group(1), m.group(2)
if re.match(r"[a-z]+:|#|/", target):
return m.group(0)
path, _, frag = target.partition("#")
full = os.path.normpath(here / path)
frag = "#" + frag if frag else ""
m_adr = re.fullmatch(r"docs/adr/(.+)\.md", full)
m_ms = re.fullmatch(r"docs/milestones/(.+)\.md", full)
if m_adr:
return f"[{label}](/dev/decisions/{m_adr.group(1)}/{frag})"
if m_ms:
return f"[{label}](/dev/milestones/{m_ms.group(1).lower()}/{frag})"
if full == "CONTEXT.md":
return f"[{label}](/dev/glossary/{frag})"
return f"[{label}]({REPO_URL}/src/branch/main/{full}{frag})"
return re.sub(r"\[([^\]]*)\]\(([^)\s]+)\)", fix, md)
def title_and_body(text):
m = re.match(r"#\s+(.+)\n", text)
return (m.group(1).strip(), text[m.end():].lstrip("\n")) if m else ("", text)
def first_paragraph(body):
for block in re.split(r"\n\s*\n", body):
b = block.strip()
if b and not b.startswith(("#", "|", "```", "- ", "1.", "![")):
return b
return ""
def goal_or_first(body):
m = re.search(r"\*\*Goal:\*\*\s*(.+?)(?:\n\s*\n|\Z)", body, re.S)
return m.group(1) if m else first_paragraph(re.sub(r"^\*\*Status:\*\*.*\n", "", body, flags=re.M))
def sections(readme):
"""README's `## ` sections by heading, each with its heading line and everything up to the next `## `."""
out, name, buf = {}, None, []
for line in readme.splitlines(keepends=True):
if line.startswith("## "):
if name:
out[name] = "".join(buf).rstrip() + "\n"
name, buf = line[3:].strip(), [line]
elif name:
buf.append(line)
if name:
out[name] = "".join(buf).rstrip() + "\n"
return out
def help_text():
"""The two lists `help` prints: for every build, and for Debug Builds only, from kHelp in src/main.cpp."""
src = (REPO / "src" / "main.cpp").read_text()
m = re.search(r"static const char\* const kHelp =(.*?)\n\s*;", src, re.S)
if not m:
sys.exit("gen_dev_docs: kHelp not found in src/main.cpp")
common, debug, in_debug = [], [], False
for line in m.group(1).splitlines():
stripped = line.strip()
if stripped.startswith("#ifdef RORO_DEBUG"):
in_debug = True
elif stripped.startswith("#endif"):
in_debug = False
else:
for lit in re.findall(r'"((?:[^"\\]|\\.)*)"', line):
(debug if in_debug else common).append(lit.replace("\\n", "\n").replace('\\"', '"'))
return "".join(common).rstrip("\n"), "".join(debug).rstrip("\n")
def safe_mode_commands():
src = (REPO / "src" / "main.cpp").read_text()
m = re.search(r"static bool safeModeCommand\(const String& line\) \{(.*?)\n\}", src, re.S)
if not m:
sys.exit("gen_dev_docs: safeModeCommand not found in src/main.cpp")
body = m.group(1)
exact = re.findall(r'line == "([^"]+)"', body)
prefix = re.findall(r'line\.startsWith\("([^"]+)"\)', body)
return exact, prefix
def build():
pages = {}
for path in sorted((REPO / "docs" / "adr").glob("*.md")):
title, body = title_and_body(path.read_text())
number = path.stem[:4]
source = f"docs/adr/{path.name}"
pages[f"decisions/{path.stem}.md"] = front(title, plain(first_paragraph(body)), int(number), source, tag=f"ADR {number}") + relink(body, source)
for i, code in enumerate(MILESTONES):
path = REPO / "docs" / "milestones" / f"{code}.md"
title, body = title_and_body(path.read_text())
title = re.sub(rf"^{code}\s*[—:-]\s*", "", title)
source = f"docs/milestones/{path.name}"
pages[f"milestones/{code.lower()}.md"] = front(title, plain(goal_or_first(body)), (i + 1) * 10, source, tag=code) + relink(body, source)
readme = sections((REPO / "README.md").read_text())
pages["build/build-and-test.md"] = (
front("Build, test and release", "Docker is the only tool you need. How the firmware is built, how the host tests run, and what CI does on a pull request and on a tag.", 1, "README.md", tag="Build")
+ relink("\n".join(readme[k] for k in ("Requirements", "Build and test (local CI)", "CI and releases")), "README.md"))
pages["build/flash.md"] = (
front("Flash and update", "Put the firmware on a Cardputer over USB, then update it over Wi-Fi or from the SD card, and from the project's releases.", 2, "README.md", tag="Flash")
+ 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("### 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`. 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
def main():
check = "--check" in sys.argv
pages = build()
stale = []
for rel, text in sorted(pages.items()):
path = OUT / rel
have = path.read_text() if path.exists() else None
if have != text:
stale.append(rel)
if not check:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
if check:
for rel in stale:
print(f"gen_dev_docs: content/dev/{rel} is out of date: run site/tools/gen_dev_docs.py and commit the result")
print(f"gen_dev_docs: {len(pages)} pages, {len(stale)} out of date")
sys.exit(1 if stale else 0)
print(f"gen_dev_docs: {len(pages)} pages, {len(stale)} written")
if __name__ == "__main__":
main()