Alpha. APIs, wire protocol, and configs all still move between minor versions, and hardware bring-up is in progress (#31).
PAR6 robot backend for Waldo Commander:
a Rust real-time runtime (par6d) that replaces Source Robotics' RCB-Runtime on the
control box, a Rust client library (par6-client), and a Python package
(python/par6) — a thin binding over the Rust engine — implementing the
waldoctl backend contracts.
par6d --sim runs anywhere — no hardware, no CAN interface, no root — so everything
below works on a laptop and in CI.
- Installation
- Quickstart
- Architecture
- Control loop internals
- Command system
- Adding a new command
- Motion profiles
- Collision world
- Kinematics and tools
- The Pinocchio shim
- Ports and environment variables
- Development setup
- Deploying to the control box
- Known divergences from parol6
- Safety notes
- License
par6 links a Pinocchio C-ABI shim built from cpp/. pixi
provides the C++ closure it needs — Pinocchio, coal, eigen, urdfdom, libmujoco,
cmake, ninja and the compiler — from the committed pixi.lock, and Rust comes
from rustup via rust-toolchain.toml.
pixi run setup # solves the closure and builds the shim
pixi run cargo build -p par6d --release
pixi run install-pythonThere is no bootstrap step to remember and nothing to source. The shim and
toppra are compiled by crates/par6-kin/build.rs into cargo's OUT_DIR, so
any cargo invocation under pixi run builds them if they are missing and
rebuilds them when cpp/ changes — cargo owns their freshness the same way
it owns every other artifact's. par6d and the Python extension carry the
directories they load from as rpaths, so both run from any shell.
Every command CI runs is a pixi task, so a red job is reproduced locally by
running the command in its run: line:
| task | what it does |
|---|---|
pixi run setup |
build the workspace and its C++ dependencies |
pixi run lint |
cargo fmt --check and clippy -D warnings |
pixi run test-rust |
cargo test |
pixi run test-timing |
the shipped 250 Hz soak, release |
pixi run test-collision-cost |
the per-waypoint collision cost, uncaptured |
pixi run install-python |
pip install -e python[dev] |
pixi run lint-python |
pre-commit (ruff, ruff-format, ty, hygiene) |
pixi run test-python |
pytest |
pixi run test-e2e |
the client against a real par6d --sim |
pixi run wheel |
the par6 wheel into dist/ |
pixi run bundle |
the daemon bundle, checksums and manifest |
Compile parallelism is picked from available RAM: one shim compile job peaks
near 4 GB, and a small box that overcommits that livelocks in reclaim rather
than failing. CMAKE_BUILD_PARALLEL_LEVEL overrides it.
That rule covers the C++ only — cargo's own job count is cargo's. A RELEASE
build is the memory-hungry one (lto = "thin", codegen-units = 1, and
debug info on par6d), and on an 8 GB box four parallel rustc jobs will take
it out: CARGO_BUILD_JOBS=1 pixi run bundle is the way to build a deploy
bundle on the control box itself. Hosted CI runners have the headroom and set
neither.
Installing just the client, which is what Waldo Commander's [par6] extra does:
pip install https://github.com/Jepson2k/par6/releases/download/<tag>/par6-<version>-cp310-abi3-manylinux_2_39_aarch64.whlThe release wheel is self-contained: par6/_par6.abi3.so plus the whole
C++ closure — the shim, toppra, Pinocchio, coal, urdfdom and libmujoco —
grafted into par6.libs/ by maturin's repair step, with rpaths rewritten to
$ORIGIN. No Rust toolchain, no conda environment, no LD_LIBRARY_PATH;
validate-bundle.sh proves it imports under env -i in a bare venv. Its
one non-wheel dependency is waldoctl, pinned to a git tag, so the install
needs git and network but never a compiler.
Three things it is not.
It is not on PyPI — the release workflow attaches artifacts to a GitHub
release and publishes nothing, so the URL above is the install, not
pip install par6.
It is not installable on the control box. The manylinux tag is not the
glibc floor manifest.json records: the closure needs glibc 2.28, but
conda-forge's Pinocchio needs GLIBCXX_3.4.32 — libstdc++ from GCC 13 —
and manylinux treats libstdc++ as a system library, so auditwheel will not
graft it into the wheel the way pack-bundle.sh does for the daemon.
Raspberry Pi OS bookworm ships GCC 12 (GLIBCXX_3.4.30). A wheel therefore
targets a host with a modern toolchain; the box installs the bundle,
which carries its own libstdc++.so.6.
And it does not contain par6d:
pip install "par6 @ git+https://github.com/Jepson2k/par6.git@main#subdirectory=python"A git URL never consumes a wheel, so that form compiles the extension from
source and needs the toolchain and the C++ closure — build it from a checkout
under pixi run. Either way you get the client, the offline preview and the
kinematics — but not the par6d binary. Robot().start() spawns $PAR6D_BIN, or par6d on PATH, so a
client-only install has nothing to spawn until either the workspace above is built or
a runtime is already listening — which is the normal case on the control box, where
Waldo Commander, this client and par6d all run on the same machine and the runtime
is a systemd service. Shipping a per-platform runtime wheel is
#33.
Deploying to a control box (Raspberry Pi 5, aarch64, PREEMPT_RT) is covered in Deploying to the control box.
from par6 import Robot
with Robot() as robot: # spawns `par6d --sim`, waits for PAR6D_READY
rbt = robot.create_sync_client()
rbt.reset() # clear errors, enable the drives
rbt.home(wait=True)
rbt.move_j([0, -90, 180, 0, 0, 180], speed=0.5, wait=True)
print(rbt.angles(), rbt.pose())Robot() claims exclusive ownership of its address: a runtime already answering PING
there is a hard failure, not a silent attach. To use one somebody else started, check
Robot.is_available() and construct a client directly.
The command port is 6001 — not Waldo Commander's parol6 default of 5001.
from par6.client import RobotClient
with RobotClient(host="127.0.0.1", port=6001) as rbt: # the box's hostname from another machine
print(rbt.angles())import asyncio
from par6.client import AsyncRobotClient
async def main():
async with AsyncRobotClient(host="127.0.0.1", port=6001) as rbt:
await rbt.reset()
await rbt.move_l([300, 0, 350, 180, 0, 180], speed=0.4, wait=True)
async for status in rbt.stream_status():
print(status.angles)
break
asyncio.run(main())The sync client is a thin façade over the async one running on a background event loop; calling it from inside a running loop raises rather than deadlocking.
from par6 import Robot
preview = Robot().create_dry_run_client()
result = preview.move_j([0, -90, 180, 0, 0, 180], speed=0.5)
print(result.duration, result.tcp_poses.shape)The preview runs the same trajectory code, the same limits and the same collision world as the runtime, and refuses what the runtime refuses — with the same error codes — so an editor shows the failure before the arm does.
Waldo Commander (NiceGUI frontend, unchanged)
└─ python/par6 — waldoctl Robot + AsyncRobotClient + sync facade + dry-run preview
│ (a thin shim over crates/par6-client + the par6d preview, via crates/par6-py)
│ protocol v3: UDP msgpack commands · binary status broadcast
par6d (single Rust binary; `par6d --sim` runs anywhere, including CI)
├─ command plane (tokio): validation/gating, queue, index allocator,
│ push completion, status broadcaster
├─ planner thread: TOPPRA (FFI) planned moves · rsruckig streaming/blending ·
│ trapezoid, plus the plan-time collision gate and the enablement probe
├─ housekeeping thread (soft-RT): cart-jog ramps, enable retries, flashing exits
└─ RT thread (SCHED_FIFO 99, alloc-free): fixed-rate tick (`tick_dt_s`, shipped
250 Hz) — CAN RX → state → gravity comp G(q) → mode dispatch → CAN TX →
state snapshot
bus backends: SocketCAN (Spectral/STEPFOC) | closed-loop dynamics sim (Pinocchio ABA)
There is one numerics stack. Kinematics, dynamics and collision run on Pinocchio and
coal through the repo's C-ABI shim (cpp/, see The Pinocchio shim),
inverse kinematics is the analytic OPW closed form derived from the URDF at load, and
TOPPRA retimes planned paths. The Python package holds none of it: par6._par6 (the
par6-py crate) binds the same Kin, Collision, config loader and dry-run engine the
daemon runs, so a preview cannot disagree with the runtime — it is the runtime's code.
| Path | Contents |
|---|---|
crates/par6-proto |
protocol v3 codec — single source of truth; the Python constants are generated from it |
crates/par6-config |
robot / gripper / homing TOML config |
crates/par6-kin |
Pinocchio FFI: FK, Jacobian, gravity, IK; coal collision world (self-pairs + installation/program keep-out layers) |
crates/par6-motion |
TOPPRA + rsruckig + trapezoid, jog ramps, completion policies |
crates/par6-bus |
DriverBus trait, Spectral CAN codec, SocketCAN + simulator backends |
crates/par6-rt |
RT tick loop, mode dispatch, homing FSM, error latching, e-stop |
crates/par6-server |
UDP command plane, status broadcast, collision-world layers |
crates/par6-client |
the client library: command round-trips, retries/dedup, status subscription |
crates/par6d |
the runtime binary: config load, thread spawn/wiring, planner, RT bridge — plus the offline preview harness |
crates/par6-py |
the par6._par6 Python extension (PyO3 over par6-client + the preview) |
cpp/ |
the Pinocchio/coal/TOPPRA C-ABI shim |
python/ |
the par6 pip package (waldoctl backend) |
python/par6/panel/ |
the control box front panel service (par6-panel) and the preflight check (par6-preflight) |
assets/ |
PAR6 URDF, SRDF and meshes from Source Robotics — see assets/NOTICE |
par6d is one process with four planes, one per distinct deadline. Work is assigned to
a plane by its timing class, never by feature:
| Class | What it means | A miss costs |
|---|---|---|
| hard real-time | bounded, data-independent cost; runs every tick | a LOOP_CRITICAL latch — the controller disables |
| soft real-time | bounded cost, but nothing physical depends on it landing this tick | a late effect nobody can see |
| unbounded | cost depends on the data: path length, solver iterations, a collision walk | nothing — there is no deadline |
The rule: a piece of work belongs in the fastest class whose worst case can meet that class's deadline, and no faster. It is decided per function, from the function's worst case, never from how fast it usually runs.
Two corollaries do most of the work:
- Bounded work never shares a thread with unbounded work. It would inherit the unbounded worst case, and the deadline it was placed for stops holding. That is what a plane is; a thread that mixes classes is not a plane.
- There are exactly as many planes as there are distinct deadlines. A plane costs a queue and an ownership rule, so each one has to earn its place with one sentence naming its deadline and what a miss costs. If the sentence cannot be written, the plane is wrong.
The four sentences:
| Thread | Class | Deadline | A miss costs |
|---|---|---|---|
par6d-rt |
hard | every tick | LOOP_CRITICAL; the arm stops |
par6d-housekeeping |
soft | one tick | a cart-jog ramp or an enable retry lands a tick late |
| command plane (tokio) | soft | the status period | a datagram is acked late; a STATUS frame is skipped |
par6d-planner |
none | — | a queued move starts later |
(par6d-tee is fan-out plumbing for the snapshot slot, not a plane: it has no work of
its own.)
The RT thread runs SCHED_FIFO priority 99 pinned to one core and allocates nothing
after init. The command plane parses datagrams, validates and gates them, allocates
queue indices and broadcasts STATUS — every step bounded and data-independent, which is
what lets it keep a deadline at all. The planner thread owns the Planner: IK seeding,
TOPPRA retiming, the plan-time collision walk and the enablement probe, all
data-dependent, none with a deadline. It takes requests on one channel and answers on
another, publishes a latest-wins report (enablement, collision state, warnings, the
in-flight and queued durations) that STATUS and the queries read, and runs at most one
expensive request per iteration between ring pumps, so a long plan can never starve the
sample ring or the exec heartbeat.
Cancelling is the consumer's act, never the planner's. Every event the planner emits
names the queue index it belongs to, so the command plane attributes a late result to the
command that asked for it and discards one for a command it has already dropped; on a
stop the command plane flushes the ring and returns the RT to IDLE itself, without
waiting on the planner, and only then tells the planner to forget its in-flight state.
This is the shape parol6 already runs, copied deliberately because it is the working
example: its planner is a subprocess (parol6/server/motion_planner.py) whose worker
loop applies CancelAll cheaply and plans one PlanCommand at a time, every segment it
emits carries its command_index, and the segment player in the control loop
(parol6/server/segment_player.py) plays, attributes and discards stale segments on
cancel — the planner's own cancel() only clears its blend buffer. Its enablement
probe likewise lives in a separate IK worker process (parol6/server/ik_worker.py).
par6's earlier single command task that owned the planner by value was the regression,
not the improvement.
Between planes:
- command plane → RT: an mpsc channel of
RtCommands. The RT loop drains at most one command per tick, which is why every multi-step effect (mode dances, the e-stop clear sequence) is an ordered queue rather than a synchronous call. - streamed setpoints: a latest-wins slot instead of the channel — a jog stream faster than the tick rate would otherwise grow a backlog and keep jogging after the operator let go.
- RT → everyone: latest-wins snapshot slots, fanned out by
par6d-tee.
Where the tick rate comes from. The tick period is a property of the bus, not of
the software. Every tick sends one motion frame per joint and reads a reply for each;
classic CAN carries a fixed number of bits per frame at a fixed bitrate, so the wire
time per tick is fixed and the tick has to be longer than it. bus_budget in
crates/par6-bus/src/budget.rs computes that time from the joint count, the gripper,
the poll slot and the configured bitrate; the daemon refuses to boot when the configured
tick_dt_s cannot carry it and logs the result at every hardware boot, so there is
exactly one place the number comes from. Two things follow. A faster host tick needs a
faster bus (CAN FD, EtherCAT), not different software: nothing in the runtime knows the
shipped rate — every duration is configured in seconds and converted with
round(s / dt) at construction, and the integration suites boot the simulator at a
second rate to keep it that way. And the drives close their own control loop at their
own rate, so a host tick faster than the bus allows would command motion the arm cannot
track and duplicate a loop the drive already runs.
One tick, in order:
- CAN RX — drain the bus, decode Spectral frames into per-joint state.
- State — update positions, velocities, currents, temperatures, error latches.
- Gravity compensation —
G(q)from Pinocchio on the arm-only chain, with the active tool's inertials attached from its gripper config so tool mass has exactly one source. - Mode dispatch — one of IDLE / HOMING / JOG / STREAM / EXEC / SAFETY_STOP /
FLASHING produces this tick's setpoints. IDLE on a homed, enabled arm with
gravity comp on is freedrive: torque-only
G(q), no position hold. The opt-in[freedrive] drift_lockre-holds the pose once the arm has been still (the drive's impedance frame plus a clamped integral) and lets go the tick a joint moves, so a slightly wrong gravity model stops sagging the arm without the operator ever fighting a hold. - CAN TX — one motion pack per joint, plus any queued control frame.
- Snapshot — publish state to the command plane's reader slot.
Fault authority sits with the drives: the firmware gates all motion on its aggregate
error latch and forces Controller_mode = 0, and the simulator does the same, so a
green CI run is not overstating what it proves.
The deadline is computed from a monotonic clock before the first sleep, so tick 2 is not a period late and the loop statistics start clean.
Wire tags are banded by class, which is what makes gating table-driven
(crates/par6-proto/src/enums.rs):
| Band | Class | Semantics |
|---|---|---|
| 10+ | SYSTEM | reset, stop, e-stop, gravity comp, tool/profile selection |
| 30+ | QUERY | angles, pose, status, io, queue, error, reachable, loop stats |
| 60+ | FIRE_AND_FORGET | jog, servo, teleport — unacked, latest-wins |
| 80+ | QUEUED | move_j / move_l / move_c / move_s / move_p, tool actions, checkpoints |
A QUEUED command is acked with its queue index, then reports its outcome in a
COMPLETE push — so a refusal that happens at dispatch (a collision gate, an
unreachable path) arrives on the COMPLETE, not on the ack. Clients that need the
verdict pass wait=True.
Fire-and-forget commands are unacked by definition, so a refused one would otherwise be
invisible. par6 latches it as the standing error, sends a real ERROR datagram, warns in
the client log, and withdraws the affected joint_en flags.
The recipe, in the order the codec tests expect:
- Tag + variant — add to
CmdTypeincrates/par6-proto/src/enums.rsinside the right band, then theCommandvariant, its decode arm, its encode arm, and itsvalidaterule incommand.rs. - Gating —
crates/par6-server/src/gating.rsdecides what state the command needs (enabled, homed, simulator). Check the RT side agrees: a command accepted on the wire and refused by the RT mode table is a silent drop. - Dispatch —
crates/par6-server/src/server.rs, then theRtCommandstrait method inruntime.rs, then its implementation incrates/par6d/src/bridge.rs(immediate) orplanner.rs(queued). - Clients —
crates/par6-client/src/api.rs, thecrates/par6-pybinding, and the Python shim (python/par6/client/). The preview needs nothing per-command: it drives the daemon's own planner. - Codec tests —
crates/par6-proto's encode/decode round trip and hostile-input tests cover every tag. Python needs no regeneration step: the extension exposes the constants straight off the crate. - Test — a sim e2e that drives the command through the real client against a real
par6d --sim.
par6-proto is a frozen interface: changing it needs a contracts-labeled issue and a
re-freeze. See CLAUDE.md.
| Profile | Used for |
|---|---|
RUCKIG (default) |
jerk-limited point-to-point and streaming; the profile blends are built on |
TRAPEZOID |
velocity-limited point-to-point |
QUINTIC |
point-to-point with zero velocity and acceleration at both ends; no cruise, does not blend |
TOPPRA |
time-optimal retiming of a cartesian waypoint path |
Every cartesian move rides one pipeline: the geometry produces a pose list, seeded IK
turns each pose into a joint waypoint, and TOPPRA times the chain. Only the shape
differs — a line for move_l, the circle through the via point for move_c, a cubic
spline for move_s, an auto-rounded polyline for move_p.
speed scales the velocity ceiling, accel scales the acceleration ceiling, and
duration acts as a minimum the plan is stretched to meet. The two are mutually
exclusive.
A move with a positive blend radius r is held until the command after it decides
what the corner looks like; consecutive same-family moves fold into one motion that
completes every command it consumed at the same instant.
The runtime enforces an SRDF-exact collision world on planned and streamed motion,
in two layers: installation (from the robot TOML, immutable from the wire) and
program (replaced by set_shapes). Both are checked with a 5 mm default clearance.
The rule, for planned and streamed motion alike: a configuration may keep a pair the start is already in — an arm inside a keep-out has to be able to move its way out — but may not add one. Planned paths are walked at 0.02 rad joint pitch; streams are projected one velocity-scaled lookahead ahead, so a faster jog stops further from contact.
Colliding geometry is reported in waldoctl's vocabulary: bare URDF link names for the
arm and tool, shape:<name> for a program keep-out, install:<name> for an
installation one.
The client side runs the same world. Robot.in_collision / colliding_pairs /
check_trajectory / min_distance / apply_shapes drive the engine's CollisionWorld
(par6_kin::Collision through par6._par6) on the active tool's own URDF tree with its
SRDF and the config's installation keep-outs loaded, so a preview and the arm agree
about which paths are refused and name the offending pairs the same way.
par6 ships one URDF tree per fitted end-effector — flange, MSG gripper, SSG48 gripper —
and the runtime is built around one fitted gripper, refusing SELECT_TOOL for any
other. A tool's TCP is not modelled separately: it is the tcp link of that tool's own
tree, so selecting a tool selects the tree the runtime is fitted with and FK resolves
exactly where par6d does.
set_tcp_offset composes after the tool transform, in the tool-local frame. A variant
change clears it, because an offset measured against the old TCP describes nothing once
the frame moves. It is a queued command: the offset lands at its turn, so moves queued
before it keep the old frame, moves after it are planned against the new one, and a
blend chain never folds across it. SELECT_TOOL and SET_TCP_OFFSET therefore apply in
program order, and the TCP_OFFSET query reports the new value only once the command
has completed — the same lag TOOLS has after select_tool.
The trees are re-based onto the vendor motor convention: URDF q equals the runtime's
theta, so config angle values apply to the model verbatim. See
assets/par6_description/CHANGELOG.md for the derivation and the equivalence check.
cpp/ is one C-ABI shim over the C++ dependencies the Rust crates link:
- Pinocchio (kinematics/dynamics) —
par6_kin_*: create/destroy, fk, jacobian, gravity, aba. Consumed bypar6-kin, whose analytic IK (par6_kin::Opw) is derived from the URDF at load: the fit is checked against this FK at pseudo-random configurations and a model the two disagree on is refused. That catches an FK the OPW form cannot express, not a wrong URDF — a mis-measured link length fits, so the geometry is nominal data the check does not second-guess. - coal / hpp-fcl (collision) —
par6_col_*: a two-layer world (installation keep-outs andSET_SHAPES) over the URDF's<collision>meshes, self pairs minus same-joint and parent/child-adjacent ones, shapes in metres and radians (R = Rx·Ry·Rz). - toppra-cpp (time-optimal path parameterization) —
par6_traj_*. Built from source bycrates/par6-kin/build.rs(conda-forge ships no C++ toppra), pinned to commit142456f3(v0.6.9), with its bundled Seidel LP solver — no qpOASES, no GPL GLPK.
cpp/include/par6_shim.h the frozen C ABI (PAR6_SHIM_ABI_VERSION)
cpp/src/par6_shim.cpp par6_kin_* (pinocchio)
cpp/src/par6_traj.cpp par6_traj_* (toppra-cpp)
cpp/src/par6_col.cpp par6_col_* (pinocchio + coal)
crates/par6-kin/src/sys/ the raw decls (ffi.rs) and the RAII handles over them; Kin/Collision/Trajectory build on those
crates/par6-kin/build.rs compiles toppra and cpp/ into cargo's OUT_DIR
pixi.toml / pixi.lock the C++ closure they link against
crates/par6-kin/build.rs builds toppra and the shim into cargo's OUT_DIR
(target/<profile>/build/par6-kin-*/out/shim/lib), with libtoppra installed beside
the shim so one directory and one rpath cover the pair. Cargo owns their freshness:
an edit under cpp/ reruns the script and rebuilds what it touched, and a
cargo clean removes them along with everything else. The C++ closure they link
against — pinocchio 4.1, coal, eigen, urdfdom, libmujoco 3.12,
plus cmake, ninja and the compiler — comes from pixi.lock; toppra 142456f3
is pinned in the build script.
PAR6_TOPPRA_SRC supplies a toppra checkout instead of fetching one, for an
offline build.
ABI conventions, frozen in par6_shim.h: poses are row-major 4×4; Jacobians 6×nq,
rows [linear; angular], world axes at the frame origin; gravity is RNEA at zero
velocity/acceleration; an optional rigid tool at create shifts fk/jacobian/ik to the tool
frame and adds its inertials to the gravity model; every par6_kin_* call after create
is allocation-free (one handle per thread); par6_traj_sample is allocation-free and
safe from the RT tick; par6_col_check allocates in coal's narrow phase and is
planner-side only. Exceptions never cross the boundary.
What the shim is held to: crates/par6-kin/tests/c_boundary_{collision,traj}.rs cover the C
boundary itself (NULL/out-of-range arguments, geometry-index layout across layer
replacement, buffer truncation, the time-optimality requirement of the retimer);
crates/par6-kin/tests/{kinematics,collision_world}.rs cover the contract above the
boundary — the Jacobian as the derivative of FK, IK landing on every FK pose, and the
collision verdicts a preview and the runtime both depend on, placed from the model's own
TCP on every shipped URDF variant.
Mesh cost: assets/ ships the vendor's full-resolution STLs for both <visual> and
<collision>. Measured per-waypoint check cost on the control box, in release:
| scene | flange | gripper variant |
|---|---|---|
| self-collision only | 14 µs | 19 µs |
| plus a box keep-out | 25 µs | 25 µs |
| plus a keep-out carrying its own margin | 26 µs | 34 µs |
Convex hulls were measured and rejected: the SSG48's jaw hulls overlap when closed and report a permanent false collision at the home pose.
Every keep-out is a bounded primitive. A half-space was measured and rejected too: an unbounded solid has no bounding volume to prune against, so coal scans every triangle and one check costs ~35 ms against 25 µs for a box.
conda-forge ships linux-aarch64 Pinocchio, so the control box builds the shim natively
with the same script; cross-compiling it from x86_64 is not supported.
Only the 6001 command port is fixed by the wire contract; the rest are defaults in
config/PAR6.toml under [protocol].
| Port | Purpose |
|---|---|
| 6001 | command plane (UDP, msgpack) |
| 6002 | status broadcast (binary) |
Precedence throughout is CLI flag > PAR6_* environment variable > robot TOML.
| Variable | Effect |
|---|---|
PAR6_CONFIG |
robot TOML path (--config) |
PAR6_ASSETS |
par6_description tree with the URDFs (--assets) |
PAR6_COMMAND_PORT |
command UDP port; 0 = ephemeral (--port) |
PAR6_BIND |
command-socket bind address (--bind) |
PAR6_STATUS_HOST |
unicast status destination (--status-host) |
PAR6_STATUS_PORT |
status broadcast port (--status-port) |
PAR6_STATUS_TRANSPORT |
auto | multicast | unicast (--status-transport) |
PAR6_STATUS_RATE_HZ |
STATUS broadcast rate; must divide the tick rate (--status-rate) |
PAR6_SIM_DYNAMICS |
with --sim, use the torque-level plant (--sim-dynamics) |
PAR6_LOG_DIR |
also write the rotating activity logs there (--log-dir) — see below |
PAR6_TICK_PROFILE |
per-phase RT tick profiler, logged once a second (--tick-profile) |
PAR6_GPIO_CHIP |
gpiochip device for the e-stop line |
PAR6_SHM_DIR |
where the bus-grant segments go (default /dev/shm) — see below |
stderr carries every log line, as always (RUST_LOG filters it, default info).
With --log-dir the daemon also keeps two size-rotated files there, routed by the
record's module target: rt.log (2 MiB, five copies) holds what the RT thread
itself says — mode transitions, latches, degraded-scheduling notices — and
commands.log (20 MiB, five copies) holds the command plane and the daemon: one
line per accepted, completed, refused or cancelled command keyed by its index, with
the error catalog's cause and remedy on failure, the RT latch on its edges, and a
host-vitals line (load, memory, CPU temperature, disk, uptime) at start and every
minute. The RT tick never writes a file: its only log calls sit on throttled
failure paths, so the sink costs the tick nothing, and a write that fails is
dropped rather than allowed to stall the daemon.
par6-panel (installed with pip install "par6[panel]", run by
scripts/deploy/par6-panel.service) owns the control box's two buttons, two
LEDs and 128x64 OLED and the UART link to the mainboard PCB, entirely from
panel.toml (PAR6_PANEL_CONFIG; every device path, I²C address, pin and
baud lives there). One rule on the buttons: tap = move, hold = select, hold
button 1 = back; destructive actions ask for a hold to confirm and cancel on
any tap. Once a blink period it sends the PCB its heartbeat, toggles the
LEDs anti-phase, publishes the panel state and drives the LEDs from what it
published. A UART that will not open disables PCB comms and nothing else.
Install it on the box with pip install "par6[panel]" into a venv, copy
panel.toml to /etc/par6/panel.toml, point the unit's ExecStart at that
venv's par6-panel, then systemctl enable --now par6-panel. Run
par6-preflight first: it changes nothing and reports what the box is
missing.
par6-preflight is the diagnostic that brings nothing up: RT kernel, CAN,
GPIO and RT-priority permissions, cores, disk, devices and imports, each
required or advisory, re-executed inside the package's virtualenv so it
reflects the runtime environment.
A fresh Spectral drive sits at its factory node id, which the config does not
list. par6 scan rescans the bus (an RTR ping to every id, one per tick) and lists
every node id with whether the config lists it, whether it answered, its freshness
and its device identity; par6 set-can-id OLD NEW --force renames it (cmd 11) and
par6 save-config NEW --force persists that (cmd 13) — without --force both
refuse an id the config does not list. Both are refused while anything could be
moving: only an IDLE or ACTIVE_ERROR arm with nothing executing, queued or
streaming qualifies, so holding the e-stop while you commission is the normal
way. The runtime keeps addressing the ids the config names, so after renaming a
configured drive update the config and restart the daemon. par6 set-pid-gains
pushes one drive's tuning live, par6 tool runs a tool action, and
par6 flashing enter|exit hands the bus to a firmware flasher and takes it back.
par6 flash --node N is that flasher, built in: it fetches the vendor's latest
release (--product stepfoc|spectral-bldc, --tag for a specific one, --file
for a local .bin), verifies it against the release's firmware.json manifest
and its vector table, takes the bus with enter_flashing, drives the drive's CAN
bootloader through the image, holds the bus silent until the drive answers as an
application again, and only then gives the bus back — the drive checks the
whole-image CRC itself and boots it once the bus has been quiet for ~3 s, so
handing the bus back at the commit would leave it in its bootloader. It has
to run on the machine holding the CAN interface (the flash extra brings
python-can). Retries are reported, not hidden: a run that needed forty is a bus
worth looking at. An interrupted write leaves the drive waiting in its
bootloader, which a second par6 flash recovers. What CAN cannot do — read a
drive's parameters back, presets, calibration — is UART-only and stays with the
vendor's tool over a bench connection.
can0 is a system-wide exclusive resource, and the vendor's CAN tools (the
firmware flasher, the motor tuners) decide whether they may transmit by reading
two shared-memory segments the runtime publishes:
| Segment | Contents | Meaning |
|---|---|---|
/dev/shm/loop_tick |
one little-endian f64 |
advancing = a runtime is live and owns the bus |
/dev/shm/robot_mode |
4-byte LE length + UTF-8 | FLASHING = bus granted; anything else = keep off |
par6d publishes both, from the RT core's own tick counter — so a stalled RT thread reads as stalled — and removes them on a clean stop. A box publishing neither reads as having no runtime at all, which is a flasher's cue to transmit; that is the failure this exists to prevent, not a nicety.
Liveness is read before the mode, so a segment left behind by a crash is safe: its tick stops advancing and the box reads as free, which by then it is.
par6d.service sets BindPaths=/dev/shm because a private /dev/shm would put
the segments somewhere only par6d can see. Point a second runtime on the same
box at PAR6_SHM_DIR so it does not overwrite the claim of the one that owns
the arm.
The Python side reads three of its own:
| Variable | Effect |
|---|---|
PAR6D_BIN |
the par6d binary Robot.start() spawns |
PAR6_HOST |
default client host |
PAR6_COMMAND_PORT |
default client port |
pixi run lint # the CI gate: fmt + clippy -D warnings
pixi run test-rust
pixi run build-daemon
pixi run install-python # builds par6._par6
pixi run test-python
pixi run test-e2e # the client against a real par6d --simCI is these tasks and nothing else, on aarch64 first: the arm runs on a Raspberry Pi, so the full Rust, Python, e2e and packaging suites run on ARM64 runners and x86_64 carries a build plus the core tests as the compatibility check a developer's laptop needs.
The Rust tests are the whole test surface for the numerics: the kinematics contract
(par6-kin/tests/kinematics.rs), the collision verdicts (collision_world.rs), and the
preview ↔ runtime parity (par6d/tests/preview.rs) are all requirement-derived — there
are no recorded fixtures and no second implementation to compare against. The Python
tests cover the waldoctl contract of the shim over the engine.
Use python3 -m pytest, not a bare pytest — on some setups the pytest on PATH
resolves to an interpreter that does not have the package.
Without PAR6D_BIN the Python e2e tests skip, which is how a whole integration
layer can vanish from a run unnoticed. CI sets it.
The pytest run writes JUnit XML to python/test-results.xml; read that rather than
re-running to recover console output.
Target: Raspberry Pi 5, aarch64, PREEMPT_RT kernel. The box runs everything —
Waldo Commander, this Python package and one par6d process supervised by systemd,
talking SocketCAN to the arm and protocol v2 (UDP) to Waldo Commander on localhost.
The normal path is to build on the box (Installation: the shim,
par6d and the Python package build there in minutes) and install locally:
pixi run bundle # -> dist/par6d-aarch64.tar.gz + SHA256SUMS + manifest.json
sudo tar -C /tmp -xzf dist/par6d-aarch64.tar.gz
sudo /tmp/bundle/install.sh --local --bundle /tmp/bundlepixi run bundle builds par6d, stages its whole runtime closure — the shim,
toppra, libmujoco, Pinocchio, coal and everything they pull in — into one flat
directory, rewrites every rpath to $ORIGIN (the binary's to the install
directory), and proves the set loadable before packing it: one glibc floor
across the closure, no soname the staged copies do not provide, and no
build-machine path left in anything that ships. The manifest records the commit,
the daemon and client versions, the waldoctl pin and the measured glibc floor.
scripts/deploy/validate-bundle.sh dist/ is what CI runs against that output:
it unpacks and installs the bundle and the wheel with no pixi, no cargo, no
.ffi and no LD_LIBRARY_PATH, then drives forward kinematics, a keep-out
refusal and a live daemon through them. A release publishes the artifacts that
passed it, unchanged.
The glibc floor comes from pixi's compiler, not the build machine's: the conda
toolchain carries its own sysroot, so a native build on a glibc 2.39 host
produces a bundle whose whole closure needs at most 2.28 — under Raspberry Pi
OS bookworm's 2.36 and bullseye's 2.31, and measured into manifest.json on
every build. There is no cross-compilation pipeline any more; native ARM64
builds are the shipped path.
pixi run bundle # -> dist/par6d-<arch>.tar.gz + SHA256SUMS + manifest.jsonEvery par6d carries kinematics. The Pinocchio C-ABI shim — TCP FK,
gravity compensation, move_l/move_j_pose, the cartesian streamables,
TOPPRA, and the coal collision world — is linked unconditionally, because a
runtime without it would broadcast a NaN TCP pose, report zero cartesian
freedom, refuse every cartesian command, and answer set_shapes with success
against a collision world that does not exist, none of which a client can
see. The shim is a build-time prerequisite of the whole workspace, so
par6-kin's build script builds it rather than looking for one.
pixi run bundle builds par6d in release, then pack-bundle.sh:
- copies
par6d,libpar6_shim.so,libtoppra.soandlibmujoco.so.*into one staging directory, and points the binary's rpath at the directoryinstall.shfills on the box; - runs
stage_runtime_libs.py, which walksDT_NEEDEDfrom those roots, copies every dependency out of the pixi prefix into the same flat directory, and refuses a set that is not self-consistent; - rewrites every staged library to search
$ORIGIN, so nothing that ships names a path from the build machine; - packs the bundle and writes
SHA256SUMSandmanifest.json.
glibc floor. The staged closure requires at most GLIBC_2.17 and the
par6d linked against it at most GLIBC_2.28, because pixi's conda compiler
carries its own sysroot and is the Rust linker too. Raspberry Pi OS
bookworm ships 2.36 and bullseye 2.31, so both clear it.
pack-bundle.sh measures the floor across the whole closure and writes it
into manifest.json; validate-bundle.sh refuses a bundle that exceeds it.
Symbol-version check. stage_runtime_libs.py performs the check that
would otherwise only surface on the box: every versioned symbol
(GLIBCXX_*, CXXABI_*, GCC_*, …) demanded of a library that ships must
be provided by the copy that ships. That catches a compiler newer than the
env's C++ runtime, which otherwise appears as version GLIBCXX_3.4.x not found at the first systemctl start.
On the box, use the local sequence at the top of this section. From another machine
(needs ssh/scp to the box and sudo on it):
scripts/deploy/install.sh --host pi@par6-boxIt stages a bundle (binary + lib/ — the shim and its runtime closure —
config/PAR6.toml+config/grippers/*.toml+assets/par6_description- the unit + a copy of itself), uploads it to
/tmp/par6-deploy-<timestamp>, and re-runs itself there with--local.PAR6_RUNTIME_LIB_SRCsays where the staged libraries come from —pack-bundle.shpasses--runtime-libs DIRinstead.--stage-only DIRbuilds the bundle without uploading anything, which is whatpixi run bundleuses.
On the box itself:
sudo scripts/deploy/install.sh --local --bundle /tmp/par6-deploy-<timestamp>Layout after install:
| Path | Contents |
|---|---|
/usr/local/bin/par6d |
the runtime binary |
/usr/local/lib/par6/*.so |
the Pinocchio shim + its runtime closure (rpath target) |
/etc/par6/PAR6.toml |
robot config (PAR6_CONFIG in the unit) |
/etc/par6/grippers/*.toml |
gripper configs |
/usr/share/par6/par6_description |
URDF/meshes — the kinematics and collision models |
/etc/systemd/system/par6d.service |
the unit |
/var/lib/par6 |
StateDirectory, the working directory |
An existing /etc/par6/*.toml is kept on re-install (tuning survives
upgrades); pass --force-config to overwrite. --no-restart installs without
touching the running service.
Restarting
par6dstops the arm and clears the queue.install.shstops the service before swapping the binary unless--no-restartis given.
par6d.service runs as the unprivileged system user par6 with two ambient
capabilities:
CAP_SYS_NICE— the RT thread asks forSCHED_FIFOpriority 99 and pins itself to CPU 3. Failure is loggedDEGRADEDand is not fatal, so a misconfigured box runs badly instead of not at all — check the journal forRT thread: SCHED_FIFO priority 99to confirm it took.LimitRTPRIO=99is set as well for boxes without the capability path.CAP_NET_ADMIN—par6dbringscan0up at the configured bitrate when it finds it down, and sets its txqueuelen through theSIOCSIFTXQLENioctl — the sysfs file is root-owned and refuses an unprivileged writer regardless of capabilities.
It also carries SupplementaryGroups=dialout: Raspberry Pi OS and Ubuntu
hand the header's gpiochip to that group (udev 60-gpio.rules), and without
it the e-stop line cannot be opened, which is a refusal to start. And
LimitMEMLOCK=infinity, because the RT thread calls mlockall — a page
fault inside the tick is an unbounded latency spike, and on a box with swap
a cold page is a disk read (logged DEGRADED if the call fails).
The unit deliberately sets no CPUAffinity=: par6d pins its own RT
thread, and a process-wide mask would trap the tokio command plane on the same
core. Isolate the RT core in the kernel cmdline instead
(/boot/firmware/cmdline.txt on RPi OS):
isolcpus=3 nohz_full=3 rcu_nocbs=3 irqaffinity=0-2
Logging goes to journald (SyslogIdentifier=par6d, RUST_LOG=info):
journalctl -u par6d -f
systemctl status par6dRestart=always with RestartSec=2, bounded by StartLimitBurst=5 per
StartLimitIntervalSec=60 so a genuinely broken install stops flapping.
sudo systemctl edit par6d # drop-in
[Service]
ExecStart=
ExecStart=/usr/local/bin/par6d --sim--sim runs unprivileged with no SCHED_FIFO and no CPU pin.
journalctl -u par6d -n 30
# loaded PAR6 (6 joints, tick 250 Hz) from /etc/par6/PAR6.toml
# command plane on 0.0.0.0:6001 (SocketCAN backend)
# RT thread: SCHED_FIFO priority 99
# RT thread pinned to CPU 3On the box (pass host= for another machine on the network):
from par6 import Robot
robot = Robot() # PINGs the running runtime, spawns nothing
robot.start()
print(robot.create_sync_client().angles())can0cannot be opened. The unit'sRestrictAddressFamiliesmust includeAF_CAN; relax it and confirm the bus opens before looking anywhere else.- Starts by hand but not under systemd.
ProtectSystem=strictleaves/usrreadable, so the rpath into/usr/local/lib/par6should resolve — runldd /usr/local/bin/par6dfrom inside the unit's namespace to see which library the sandbox is hiding.
The protocol-v2 command plane is deliberately unauthenticated: Waldo
Commander and par6d run on the same Raspberry Pi, so the 50 Hz
client↔daemon traffic is loopback. Anyone who can send UDP to port 6001
can move the arm — treat reachability as authorization, the way UR and
Franka deployments do:
- Keep the robot off routable networks. Put the box on a dedicated NIC, VLAN or physically separate segment shared only with the machines that operate it. Do not port-forward 6001 or the status ports.
- Remote access goes through the OS, not the protocol. For operating the arm from elsewhere, terminate a WireGuard (or SSH) tunnel on the box and keep the command plane bound behind it.
- A firewall rule that pins 6001 to the operator hosts is a fine belt — but it is a reachability control, not authentication, and it does not make the plane safe to expose.
Message authentication (HMAC-tagged datagrams with a pre-shared key) is planned for when the frontend and the runtime split across machines; until then the isolation above is the security boundary.
Deliberate, and unlikely to change:
- Three motion profiles, not five —
QUINTICandLINEARare absent, consistently on the runtime and in the preview. select_toolaccepts only the fitted tool. The runtime is built around one gripper and its URDF tree; the preview refuses the same set, so the two agree.- No tool variants. The vendor CAD fuses the gripper body into the arm's final link
mesh, so there are no per-variant mesh sets to swap.
variant_keystill rides through to STATUS because the runtime carries it, but it selects no geometry. - Error codes 52/53/54 mean different things than parol6's. They are frozen contract
data — read them by name (
ErrorCode.SYS_SELF_COLLISION), never by number.
Open gaps are tracked as issues.
- Restarting
par6dstops the arm and clears the queue.scripts/deploy/install.shstops the service before swapping the binary unless--no-restartis given. - A refused command is not a stopped arm. Fire-and-forget refusals latch as the
standing error; check
error()or the STATUS broadcast rather than assuming a send that returned 1 took effect. - The e-stop is a latch. Clearing it runs a multi-tick sequence on the RT thread;
reset()does not return until the RT has actually answered, because "the enable was queued" is not "the arm will move". - Homing references the arm. Planned motion is refused before it; jogging is not, so an arm can be driven clear of an obstruction before it is referenced.
- aarch64 kinematics are built but not validated — the shim's numerics have never been executed on that ISA (#31).
- The vendor runtime (RCB-Runtime) and the Spectral firmware are GPL: they are behavior-only reference. Port behavior and constants, never code.
Apache-2.0 (LICENSE). assets/par6_description/ derives from Source Robotics' PAR6
repository under a licence upstream states two ways — see assets/NOTICE, which records
what is verbatim, what par6 modified, and what par6 authored.