Set up a docs site (mkdocs) for in-depth documentation - #93
Conversation
The depth was there but scattered: five package READMEs, ten files under docs/fleet, the inference-server doc, the design notes and the tuning runs, each reachable only by knowing it existed. The README refresh deliberately kept the README to sell-and-orient, which left nothing that could absorb it. `pixi run docs` serves a mkdocs Material site and `pixi run docs-build` builds it under --strict; a docs workflow gates every PR on that build and deploys main to GitHub Pages. Both live in their own pixi environment with no default feature, so the robot env is untouched and a contributor building the site solves mkdocs rather than ROS -- the lock diff adds the docs environment and removes nothing. The site does not move any documentation. A package README is read in a checkout and on GitHub at least as often as on a site, so it stays with its package and docs/hooks/repo_links.py mounts it at build time -- 24 files, from the package READMEs to design/BOM.md to the hardware research notes -- and the edit pencil on a mounted page opens the file it came from. Every tracked markdown file except the README and CLAUDE.md is now reachable from the nav. Mounting is what makes the links the interesting part. A copied file's relative links point into the repo, so each one is resolved against the file's real home and rewritten: to another site page where the target is one (a mounted file or anything under docs/), and to a GitHub blob URL where it is source the site does not serve. A figure from outside docs/ is copied in rather than linked, so the site never reaches back to GitHub to render its own pages. Anything the rewriter cannot resolve is left exactly as written, which makes it a mkdocs warning and fails --strict rather than shipping as a 404. Two things fell out of turning that on. Headings are slugged GitHub's way (pymdownx.slugs, set in the hook because spelling a Python object in YAML needs a tag check-yaml refuses to load): every heading link in the repo was written against a file rendered on GitHub, and the two sluggers disagree wherever a heading contains an em dash, which is most of them here. And mote_perception/README.md's two links to the inference-server doc pointed at ../../docs/, one level above the repo -- broken on GitHub too, and found by the first strict build. Three pages are new, because no single file covered what the nav needed to point at: a landing page, getting-started, and simulation and sites/maps/zones guides that tie the existing material together.
|
|
||
| concurrency: | ||
| group: pages-${{ github.ref }} | ||
| cancel-in-progress: true |
There was a problem hiding this comment.
cancel-in-progress: true applies to the whole workflow-level concurrency group (pages-${{ github.ref }}), which also covers the deploy job below it (.github/workflows/docs.yml#L44-L56). Since every push to main lands in the same group, a second push to main while a previous run's deploy (the actual actions/deploy-pages publish) is still in flight will cancel it mid-deploy. GitHub's own Pages "starter workflow" deliberately keeps cancel-in-progress: false for exactly this reason — cancellation is only safe on the PR-only build path, not a path that includes a live production deploy.
Suggest scoping cancellation to non-main runs, so PR builds still cancel superseded runs but a live deploy on main is never killed:
| return config | ||
|
|
||
|
|
||
| def on_files(files, config): |
There was a problem hiding this comment.
_figures is a module-level dict (docs/hooks/repo_links.py#L75) populated by insertion inside _resolve (docs/hooks/repo_links.py#L103), which runs from on_files on every rebuild. MkDocs caches hook modules for the life of the mkdocs serve process, so this dict persists across live-reload rebuilds rather than resetting. If a mounted file's figure reference is edited out, or a figure is renamed/deleted, while pixi run docs is running, the stale entry survives and figure.read_bytes() (docs/hooks/repo_links.py#L174-L175) raises FileNotFoundError out of the hook, crashing the dev server.
Reset it at the top of on_files so each rebuild starts clean:
|
|
||
| concurrency: | ||
| group: pages-${{ github.ref }} | ||
| cancel-in-progress: true |
There was a problem hiding this comment.
(Correcting my comment above — the suggestion block was dropped. Here's the concrete fix, scoping cancellation to non-main runs so PR builds still cancel superseded runs but a live deploy on main is never killed:)
| cancel-in-progress: true | |
| cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} |
| return config | ||
|
|
||
|
|
||
| def on_files(files, config): |
There was a problem hiding this comment.
(Correcting my comment above — the suggestion block was dropped. Here's the concrete fix, resetting _figures at the top of on_files so each rebuild starts clean:)
| def on_files(files, config): | |
| def on_files(files, config): | |
| _figures.clear() |
…ilds Two defects from review, both in the paths that only run outside a single `mkdocs build`. The workflow-level concurrency group covers the deploy job as well as the build, so `cancel-in-progress: true` meant a second push to main could cancel `actions/deploy-pages` half-way through publishing. Cancellation is now scoped to non-main refs, so a superseded PR build still dies and a deploy never does -- which is why GitHub's own Pages workflow declines to cancel at all. `_figures` is module state, and mkdocs loads a hook module once per process (`Hooks._load_hook` is lru_cached), so under `mkdocs serve` it accumulated across every rebuild instead of describing the current one. Rename or delete a figure mid-session and the stale entry outlives it, so the copy-in pass reads a path that is gone and takes the dev server down with FileNotFoundError. Reproduced by building twice in one process with the figure deleted between: build 2 raises before the clear, and succeeds after it.
Teleoperation with no leader arm, and the recordings that make it worth doing. Design and workflow in mote_arm/TELEOP.md. The leader is a pose held in software, moved by the keyboard (virtual_leader), published on leader/joint_states, and turned into arm_controller trajectories by arm_mirror — through mote_arm.control, like every other command client, so the arm keeps exactly one command path. Of the three candidate shapes this is the second: LeRobot's own keyboard teleop would have been cheapest, but it means running LeRobot's robot class and bus driver on the Pi, which is the thing the bring-up decision exists to avoid. LeRobot is where the dataset goes, not where the arm is driven from — and that split is the design. IK jog was out for v1 and stays out. The frontend is deliberately the replaceable part: the mirror's whole contract is leader/joint_states plus a latched teleop/estop, so a slider GUI or a gamepad is a drop-in with no code here. Every safety rule is decided in teleop.py and nowhere else, so all of it is unit-tested without a bus: soft-limit clamping, a 0.5 rad/s rate limit (a leader that jumps becomes a ramp, never a lunge), the deadman, the panic latch, and re-seeding from the measured pose on every resume so a pause cannot bank up motion and pay it out later. The deadman is the leader's *liveness*: a frontend publishes only while it is being driven, so a released key, a closed window and a dropped SSH session all arrive as the same thing. On that transition the mirror issues one goal at the arm's present position — stopping it there rather than letting it coast to the setpoint it was still travelling towards — and then sends nothing, because an absent goal is a hold. Panic is controller deactivation, since torque *is* controller activation now. One structural consequence: the mirror ticks on its own thread rather than on a ROS timer. Taking hold of the arm is a switch_controller call, and a service call made from inside an executor callback can never complete — the future is resolved by the executor the callback is blocking. arm-jog avoids this by driving from its REPL thread; the mirror does the same with a plain loop while cli.spin_background spins the node. Episodes: episode_record samples joint_states (observation), arm_controller/joint_trajectory (action) and /image_raw/compressed at 20 Hz. The action is the mirror's output, not the leader's pose, because a policy replaces whatever produces goals — and it is read off the trajectory topic rather than from the mirror, so an arm-jog session records just as well. It writes a *capture* under $MOTE_HOME/episodes — JSON lines plus the compressed frames stored byte-for-byte, standard library only, since the Pi carries no parquet or ffmpeg and should not have to. tools/lerobot_export.py converts a capture to a real LeRobotDataset off-board, in its own linux-64 pixi env, through LeRobot's own API rather than emitting the files: the format already moved once (v2.1 -> v3.0) and a hand-rolled writer would be wrong the next time it moved. It resamples onto the exact 1/fps grid first — LeRobot derives timestamps from the frame index, so a slipped capture would otherwise export as if its timing had been perfect — and loads the result back to verify. episode_replay reads the capture, not the dataset, so replay needs nothing off-board; it approaches the first pose, replays at a quarter speed, and stops on sustained lag. mock_arm presents the control stack's exact surface — the trajectory topic and switch_controller — with nothing behind it, plus a synthetic camera encoded with zlib and struct, and starts limp as the real stack does. So the whole loop runs on a workstation: pixi run arm-teleop-test drives leader -> mirror -> arm_controller -> arm -> record -> replay -> export plan headless and is the gate before the bench. pixi run arm-bench-teleop is the guided hardware session (BENCH.md step 8); it prompts for the three observations no script can make and writes a report. Verified against the mock control stack: 220 frames over 10.9s with no dropped ticks, replay finishing within 0.0000 rad of the last action at 0.010 rad of steady lag, and an exported v3.0 dataset (aggregated parquet + MP4 shards) that loads back through LeRobot with the right shapes and reads in LeRobot's own lerobot-dataset-viz. The state-only path forced by the camera/arm clash (GitHub #2) exports and verifies the same way. Not verified: anything on the arm itself — that is BENCH.md step 8, and it needs a human. Two things found by running it. Two publishers on one arm fight, which the stall guard caught before the loop script learned to stop the leader first. And mote_arm keeps its own reading of MOTE_HOME rather than importing mote_bringup's: the dependency runs mote_bringup -> mote_arm (the base launch resolves this arm's calibration into the URDF) and colcon cannot order the packages if it runs back — episodes_root therefore goes through mote_arm.poses.mote_home, as arm_gains and calibrate already do. Incidental: the lag-supervision rule moves to motion.py, shared with arm-pose go rather than reimplemented. TELEOP.md is mounted into the docs site (#93) beside the arm's README and bench runbook, so the three cross-link as pages rather than as repo paths. 1005 tests pass, lint clean, `mkdocs build --strict` clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
Set up a mkdocs (Material) documentation site so the in-depth docs have a
single navigable home, without moving them away from the code.
Branch: worktree-docs-site (committed, not pushed), one commit ed9af75.
What landed
mkdocs.yml+ adocspixi feature/environment (no-default-feature, likelint):pixi run docslive-serves,pixi run docs-buildbuilds with--strict. The lock diff is purely additive -- 1 removed line, the diffheader -- so the robot/default env is untouched.
.github/workflows/docs.yml:--strictbuild on every PR, deploy to GitHubPages on
main(upload-pages-artifact + deploy-pages).docs/hooks/repo_links.py: mounts 24 markdown files that live with the code(the five package READMEs, mote_arm/BENCH.md, the sub-package READMEs,
design/{README,BOM,ASSEMBLY,WIRING}.md and the five design research notes)
as site pages at build time, and rewrites their relative links -- to another
site page where the target is one, to a GitHub blob/tree URL where it is
source the site does not serve. Figures from outside
docs/are copied inunder
figures/so the site renders its own pages without reaching back toGitHub. Anything unresolvable is left as written, so it becomes a mkdocs
warning and fails
--strict. A mounted page's edit pencil opens the file itwas copied from.
zones, missions, map cleanup, Foxglove, chaos), perception + inference
server, the SO-101 arm, fleet (runbook, architecture, both contracts, server
pipelines, all six verification ledgers), simulation + its three harnesses,
and the tuning notes.
docs/index.md,docs/getting-started.md,docs/simulation.md, plusdocs/robot/sites.mdfor sites/maps/zones.fleet and contributing sections.
Nothing was deleted or moved. Every tracked
.mdexcept the root README andCLAUDE.md is reachable from the nav (verified programmatically against
git ls-files '*.md').Two real bugs the strict build found
mote_perception/README.mdlinked the inference-server doc twice as../../docs/inference-server.md, one level above the repo root -- broken onGitHub too. Fixed.
pymdownx.slugs, set inon_configrather than YAML because the tag
check-yamlrefuses to load). Every headinglink in the repo was written against GitHub's renderer, and the two sluggers
disagree wherever a heading has an em dash -- which is most of them here.
Without this, ~10 cross-references landed at the top of the page instead of
the section.
validation.anchors: warnnow keeps that true.Verified
pixi run docs-build: 49 pages, zero warnings under--strict(withomitted_files,absolute_links,unrecognized_linksandanchorsall setto warn).
pixi run docs: serves on http://127.0.0.1:8000/Mote/ (thesite_urlpath);home,
robot/sites/,fleet/andarm/bench/all 200.copied-in figures, tables, code blocks and rewritten source links.
pixi run lint: 12/12 hooks pass.Needs a human
GitHub Pages has to be enabled in repo settings with Source: GitHub Actions
before the deploy job can publish; until then the PR build check still runs.
The site URL assumed throughout is https://clachdev.github.io/Mote/.