Public Access
Fn+h on any screen, text fields included, and ? outside Text Entry, open a panel over the content area: the screen's own keys, then the ones that work everywhere. Every App declares its keys for the state it is in (pages, viewers, dialogs and text fields answer for themselves); the App manager opens the panel and takes every key while it is open. About 30 hint lines are gone, from every App. What stays on a screen is state. The first-start Setup keeps its hints and teaches the key; a device set up before gets one Toast, once. The guide and the FAQ open with it. `key help` over the consoles. 476 host tests (8 new). Checked on the device with key help and screenshots: the Launcher, all nine Apps and several of their states. 8.5 KB of flash and 40 bytes of static RAM. Decisions Q196 to Q203 in docs/milestones/U1.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
204 lines
9.1 KiB
Python
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", "U1"] # 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()
|