Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

franka-rs

Project home · Documentation

Send targets from your program. Let Rust handle the realtime control.

franka-rs controls a Franka FR3 or Panda directly from Rust or Python, without libfranka or C++. Your application can send goals at its own rate while the library runs the 1 kHz loop, or you can write your own controller.

Want to try the API before installing anything? Launch the interactive Binder notebook to move a simulated FR3, edit Python targets and explore motion limits in your browser. The guided simulation lab starts with small examples and builds up to longer experiments. Binder sessions are temporary, so download any edits you want to keep.

See the architecture: what runs where, how a Python goal becomes motion, and which components you actually need.

Start with your setup

I want to…Start here
Use Rust or Python on the machine connected to the armInstall the library, then prepare the realtime machine
Keep control on a Raspberry Pi 5 and use my laptop for the applicationSet up the Pi and remote Python client
Try it without hardwareTry the online notebook or run the simulator
Add cameras, a gripper, or recordingsPeripherals and recording
Drive a node-served arm from a Meta Quest controllerTeleoperate with a Quest

Not sure? Choose your setup explains what goes on each machine and which pieces you need.

Where the pieces fit

The library is the core. franka-rs communicates with the robot, evaluates the model, and turns targets into continuous commands within velocity, acceleration, and jerk limits. Its default target controller computes impedance torques; an alternative uses the robot’s own controller. Target control explains both.

The node is optional. franka-node wraps the library so another machine can send targets over Zenoh. A Pi 5 can run the trajectory generation and impedance controller beside the arm; your laptop runs the application. Zenoh carries messages between them. A direct connection needs no separate router.

Peripherals are optional too. Add the Franka Hand, camera streams with franka-cam, or recordings with Rerun when your application needs them. They are not prerequisites for the library.

The plot illustrates the trajectory generator under a 0.25 m/s, 0.5 m/s², 20 m/s³ budget. A policy, script, or teleoperation application supplies the targets; this repository does not supply a trained policy.

Go deeper when you need to

Status and scope

The changelog separates released features from development work. Published packages may trail this checkout.

The node docs report Pi 5 runs with one or two arms. Hardware validation is specific to each path: the core impedance reference still carries an FR3 validation caveat; the node’s gripper path is simulator-tested, and camera recording is tested with synthetic input. Live tuning is tested against the simulator and against a mock owner, and is not yet exercised on an arm; beyond that, no velocity-feedforward setting reachable through live tuning has been validated on hardware. The Quest stack is verified on a Quest 3, with motion scales and an axis mapping measured for one operator and one workspace; other headsets and room layouts are not verified. See peripheral status, the impedance reference, and the status sections of live tuning and Quest teleoperation. A ros2_control hardware interface, vacuum gripper, and published Panda simulator image are not available.

This is an unofficial Apache-2.0 project, not affiliated with or endorsed by Franka Robotics GmbH. Franka, Franka Emika, Panda, and FR3 identify the supported hardware.

How the pieces fit

Your program chooses where to move. franka-rs handles the control loop.

You can run everything on one Linux PC, or let a Raspberry Pi 5 control the robot while your program runs on your laptop.

Your program sends a goal to a Pi 5 or Linux PC. franka-rs turns it into smooth motion for the Franka robot.

What do I need?

On one computer: use franka-rs from Rust or Python on the Linux PC connected to the robot. The library runs the control loop inside your program.

With a Pi: run franka-node on the Pi and use franka-node-client in your laptop’s Python program. The node is a service that runs franka-rs beside the robot, so your laptop can send goals and read the robot’s state over the network.

The computer connected to a real robot needs realtime Linux and wired Ethernet. The setup guides walk through that preparation. Cameras and recording can be added later.

Start here

For the full component diagram, message flow and package reference, see Architecture in detail.

Architecture in detail

Back to the simple overview.

Your program chooses the goal. The machine beside the robot turns it into motion. For the simplest application interface, run franka-node on a Pi 5 or realtime Linux PC and use franka_node from Python. The same Python program works whether the node is on that PC or across the network on a Pi.

Open or download the full diagram.

Follow one command

Suppose your Python program asks to move the hand up by 5 cm:

  1. Python sets a goal. targets.move_by([0, 0, 0.05]) returns immediately. The franka-node-client background thread advances targets toward that goal, at 50 Hz by default. It handles the lease, message numbering and keepalive.
  2. Zenoh delivers messages. Targets go to the node; measured state comes back at 100 Hz by default. Acquire, enable and stop use request/reply. A direct connection needs no separate Zenoh router.
  3. The node decides whether to accept each target. It checks the arm’s owner, target validity, step and lead bounds, joint goals and any configured workspace box. It handles sessions and watches for a disconnected or silent client.
  4. The Rust library makes continuous motion locally. franka-rs generates a trajectory with velocity, acceleration and jerk limits and runs its impedance controller on a realtime thread. The node’s default backend sends joint torques at 1 kHz over wired FCI.
  5. Python observes the result. targets.wait(timeout=5.0) waits for the accepted goal and the measured pose to agree within the client’s tolerances. It is not an acknowledgement that every arbitrary goal is reachable.

The Python program does not send a torque every millisecond. Neither Python’s GIL nor Zenoh is on the 1 kHz path. On a Panda, the FCI v5 command also carries the required zero joint-velocity motion-generator field alongside the external torques; this is still torque control (Panda specifics).

Why each piece exists

PieceIts jobDo I install it?
franka-nodeOwn the robot connection, manage sessions and run control beside the armYes, on the control host for the node setup
franka-node-client / import franka_nodeTurn Python goals into a paced target stream; manage access and read stateYes, where your application runs
ZenohCarry goals, state and requests between application and nodeIncluded as dependencies; no separate router for a direct connection
franka-rsSpeak FCI, calculate the model, generate trajectories and torquesAlready compiled into the node
franka-py / pip install franka-rs / import frankaUse that library directly inside a Python processAn alternative to the node client; not required with it
Franka HandGrasp objects through its separate connection, exposed by the node when configuredOptional hardware; built-in node driver
franka-camCapture camera frames and publish them over ZenohOptional separate process near the cameras
franka-rerunRecord and replay motion; node recording is a build featureOptional; the Rerun viewer opens recordings
franka-descriptionSupply robot meshes for visualizationDependency of visualization tools, not a controller
franka-simReplace the robot’s FCI endpoint for developmentOptional separate simulator, with an optional MuJoCo desktop viewer

The simulator’s MuJoCo viewer shows a simulated robot. Rerun shows live or recorded data. They are different viewers with different jobs. See simulation with visualization and recording.

Pi or laptop: the same interface

LayoutWhere Python runsWhere the node and 1 kHz loop runPython endpoint
Pi beside the robotYour ordinary laptop or workstationPi 5 with realtime Linux, wired to robottcp/<pi-address>:7447
One computerThe same Linux PCThat PC with realtime Linux, wired to robottcp/127.0.0.1:7447
Try without hardwareYour development machineLinux host alongside the simulator; realtime enforcement can be disabled for simulationLocalhost or the Linux host’s address

An ordinary macOS or Windows laptop can be the remote client, not the realtime control host. Moving the node from a PC to a Pi changes the endpoint, not your application’s control logic. One node can serve multiple arms, each with its own loop; that alone does not make the arms’ motions synchronized or collision-aware.

Use the direct Rust library for your own 1 kHz callback or observer. Use import franka for high-level Python target control inside a local process; it does not expose Python callbacks at 1 kHz. Both run on the realtime machine and bypass the node and Zenoh. For an application that just sends goals, the node offers a consistent interface for both layouts.

Start it today

These install from a checkout; cargo install franka-node --locked and pip install franka-node-client of one release do the same. A real robot first needs the realtime host, wired network and FCI mode prepared. The Pi guide walks through that one-time setup.

On the control host:

cargo install --path crates/franka-node --locked

Create node.toml before starting:

name = "controller"

[zenoh]
listen = ["tcp/0.0.0.0:7447"]
multicast_scouting = false

[[arm]]
name = "arm"
host = "172.16.0.2"
realtime = "enforce"

Use the robot’s actual address. Plain TCP here assumes a trusted network. For simulation, use the simulator host (usually 127.0.0.1) and realtime = "ignore"; keep enforcement on for a physical arm. Start the node after saving the configuration:

franka-node node.toml

This connects and publishes state; it does not start motion.

On the application machine, inside a Python virtual environment:

python -m pip install ./crates/franka-node/python

Read state first, without acquiring control or moving the robot:

import franka_node

with franka_node.Node("tcp/<control-host>:7447") as node:
    print(node.arm("arm").state().position)

The following example moves the robot up by 5 cm. Run it only with that path clear and the robot ready for motion, as described in the first-motion guide.

import franka_node

with franka_node.Node("tcp/<control-host>:7447") as node:
    with node.arm("arm") as arm:
        with arm.cartesian_targets() as targets:
            targets.move_by([0.0, 0.0, 0.05])
            targets.wait(timeout=5.0)

Leaving the contexts stops the session and releases access. Once the host is provisioned, run the node as a service: everyday use then means opening your Python application, not rebuilding Rust or starting a router.

What is published?

From 0.4.0 one version covers everything a release publishes:

WhereWhat
crates.iofranka-rs, franka-description, franka-rerun, franka-node, franka-cam
PyPIfranka-rs (direct Python bindings), franka-node-client, franka-vr-teleop
GitHub releaseprebuilt franka-node and franka-cam tarballs for the Pi (aarch64) and x86_64 Linux

Use the node and franka-node-client of the same version. The simulator is a separate project with its own versions.

How mature is this?

The core control and messaging interfaces are implemented and tested. Installation and host preparation still require manual steps.

  • Implemented: trajectory generation, torque control, target guards, leases, watchdogs, paced Python streaming, state feedback, homing, recovery and optional recording.
  • Still bounded: the impedance reference retains an FR3 hardware-validation caveat; node gripper control and pushing the arm in a joint session are not hardware-validated. Camera recording is tested with synthetic input.
  • Missing polish: manual host setup, no setup wizard or doctor command, and no automatic exchange of the node’s configured motion limits with the Python client. Rejected targets are counted; detailed reasons live in the node’s debug log.

The defaults hide much of the continuity work already. They cannot guarantee every goal is feasible: joint limits, singularities, payload, contacts and network faults still matter. A configured workspace box is not obstacle planning or dual-arm collision avoidance. The goal should be to handle routine limits automatically and explain an impossible move clearly, rather than ask users to tune low-level control parameters.

A lost client triggers local stop handling, not an instantaneous emergency stop. Holding retains the accepted target and can still move toward it; a controlled stop may finish there. The Python streamer keeps a session alive even while user code is idle. See the precise watchdog and stop behavior.

The simpler experience to build next

These are proposed features, not commands available today:

PriorityUser experienceWork needed
1. InstallOne supported install path for Pi and Linux PC; one Python packagePublish matching binaries/packages, verify clean-machine installs, pin compatible versions
2. Set up onceName the robot, see “ready”, start automatically at bootGuided config and service installation; check kernel, scheduling, network and FCI without moving the arm
3. Send a goalDefaults adapt to the connected nodeExchange protocol/capabilities/limits; validate goals early; return structured refusal reasons and actionable errors
4. Handle interruptionsClear connection, ownership, holding and fault statesConnection diagnostics; deliberate recovery and re-enable; explain what stop does
5. Trust the resultA small, documented support matrixRepeatable Panda/FR3, Pi/PC, load and disconnect validation; explicit peripheral validation

The product direction is one application API, two places to run the controller. Expose “connect, move, grasp, observe” first. Keep budgets, gains, wire layouts and tuning in advanced pages. A prepared Pi can make daily use one or two commands; preparing a fresh realtime host is still a separate setup task.

Implementation evidence: node and hardware status, Python client, impedance validation, peripheral status, and release workflow.

Choose your setup

At the end of this page you know which of the two ways of running franka-rs fits your lab, and which pages to read next. Nothing on this page touches a robot.

What every setup has

Whatever you choose, one program somewhere runs the 1 kHz loop and talks to the robot’s control unit over a wired Ethernet link. That program is the crate franka-rs (in Rust or through the franka Python package) or the franka-node binary that wraps it. The machine it runs on is the realtime machine: it needs a PREEMPT_RT kernel, the right to schedule a thread as SCHED_FIFO and a cable to the robot. The realtime machine lists these requirements; they do not change with the setup.

Everything else, the policy, the notebook, the joystick, the cameras and the recording, is a choice.

Setup A: one machine runs everything

your program (Rust or Python) ── franka-rs ── wired Ethernet ── robot

The commander and the 1 kHz loop are one process on one machine, usually a PC on the robot’s network with a PREEMPT_RT kernel. Your program sets targets at its own rate, and the crate turns them into a continuous command on a realtime thread it owns.

Choose this when:

  • the machine next to the robot can run your program as well (a realtime PC with a GPU, say);
  • you want the RobotState at 1 kHz, an observer on the loop or your own 1 kHz callback;
  • you are starting out and want the shortest path from install to the first motion.

Read next: Install, The realtime machine, First program or From Python.

Setup B: a small box next to the arm, your program anywhere

laptop or workstation ── Zenoh (your network) ── Raspberry Pi 5 running franka-node ── wired Ethernet ── robot

A Raspberry Pi 5 with a PREEMPT_RT kernel sits on the robot’s network and runs franka-node. It owns the robot connection and the 1 kHz loop. Your program runs on any other machine, in any language with a Zenoh binding, and needs no realtime kernel, no franka-rs dependency and no cable to the robot. It publishes targets over Zenoh and reads the arm’s state back.

Choose this when:

  • your program lives on a laptop over Wi-Fi, a workstation without a realtime kernel, or in a notebook;
  • several programs take turns on one arm, or one small host should serve two arms to the lab;
  • you want the robot side to be a service that starts at boot and keeps running.

This adds one network hop per target and a service to run. The node has local watchdogs and lease handling for a disconnected commander; link delay still affects how responsive your application feels. Use a trusted network and read the node’s stop behavior before streaming targets.

Install the node and its Python client at the same version: both from one release, or both from one source revision.

Read next: Run the control loop on a Raspberry Pi 5, then Serve arms over Zenoh: franka-node for the full picture.

What Zenoh is here, and what it is not

Zenoh is the messaging layer between your program and the node: a publish/subscribe and request/reply protocol with client libraries for Rust, Python, C, C++ and others. It carries targets one way and state the other. It is not a second controller: nothing in Zenoh interprets a target, checks a limit or holds the arm. All of that happens in franka-node on the Pi, and the 1 kHz loop never sees Zenoh at all.

For a direct connection between your program and the Pi, on the same network or reachable by address, no separate Zenoh router is required. The node listens on a TCP endpoint and the client connects to it. A router only enters the picture when the node must dial out, for example from behind NAT, which the how-to page covers under mode = "client".

Optional parts, in either setup

partwhat it addswhere it is described
Franka Hand gripperopen, close and grasp over the hand’s own connection on port 1338; in setup B, served by the node under the arm’s leasePeripherals, Use the gripper
USB camerasframes with capture timestamps, published over Zenoh by franka-cam next to the node, so a frame can be lined up with the arm state of the same instantPeripherals
Recordingevery 1 kHz cycle, the commands and the accepted targets as a Rerun file per session; camera frames can join the same recordingPeripherals, Record and replay a run
The tuning panelgains, inverse-kinematics damping and the motion budgets of a running Cartesian session, changed from a browser within the node’s own bounds; in setup B it is the franka-tuning-panel command of franka-node-client, in setup A your program changes the same parameters through the libraryTune a running controller
Quest teleoperationa Meta Quest controller drives one arm, clutched on the grip, with every target clamped against the arm’s measured pose; setup B only, since it commands an arm served by the nodeTeleoperate with a Quest
The simulatora MuJoCo robot that speaks the same wire protocol, for trying either setup without an armWithout a robot: franka-sim

A decision in one table

questionsetup Asetup B
where does the 1 kHz loop run?in your program’s processin franka-node on the Pi
what needs PREEMPT_RT?the machine running your programthe Pi only
what does your program link?franka-rs or pip install franka-rsa Zenoh client; in Python franka-node-client
can it run on macOS or Windows?noyes, the client side
can a second program take over the arm?not while the first is connectedonce the first releases it or its lease lapses
1 kHz state, observer hook, own callbackyesno; state at 100 Hz

If in doubt and you have one PC on the robot’s network, start with setup A and the first program. Moving to setup B later changes the transport, not the control: the node runs the same target control the first program does.

Without a robot: franka-sim

Run a simulated FR3 locally, connect a Rust or Python client, and optionally watch it move in a MuJoCo window. Choose the desktop viewer to watch motion, or the headless server for scripts and CI.

For a browser-only start, use the online target-control lab. It runs a simulator inside your temporary Jupyter session and needs no local installation.

Start without a window

Prerequisites: Docker on Linux. Check that no other simulator is using ports 1337/1338.

docker run --rm --network host ghcr.io/barisyazici/franka-sim:1.1.7

franka-sim is a MuJoCo-based simulator that speaks the FCI wire protocol, so a client connects to it as it would to an arm. The container serves the robot on 127.0.0.1:1337 and a Franka Hand on port 1338. It does not run under PREEMPT_RT, so connect with RealtimeConfig::Ignore; for the examples that is

FRANKA_REALTIME=ignore cargo run --release --example echo_robot_state -- 127.0.0.1

and any other example the same way (Run the examples). The notebook defaults to 127.0.0.1 and FRANKA_REALTIME=ignore, so it runs against the container as it is.

Start with visualization

On a Linux desktop with X11 or XWayland, --vis opens a native MuJoCo window. It is not a browser page. Stop the headless container first: the viewer runs the same simulation server and uses the same ports.

The published viewer image includes the graphics libraries. No local image build or registry login is needed. This image targets Linux amd64 (x86-64); it is not an ARM64 image for the Raspberry Pi.

docker pull ghcr.io/barisyazici/franka-sim:1.1.7-viewer

Run it from a terminal in your desktop session:

SIM_XAUTHORITY="${XAUTHORITY:-$HOME/.Xauthority}"
test -n "$DISPLAY" && test -f "$SIM_XAUTHORITY"

docker run --rm -it --name franka-sim-viewer --network host \
  --user "$(id -u):$(id -g)" \
  -e DISPLAY -e XAUTHORITY=/tmp/sim.xauthority \
  -e LIBGL_ALWAYS_SOFTWARE=1 \
  -v /tmp/.X11-unix:/tmp/.X11-unix:ro \
  -v "$SIM_XAUTHORITY:/tmp/sim.xauthority:ro" \
  ghcr.io/barisyazici/franka-sim:1.1.7-viewer --physics mujoco --vis

The test command must succeed before running Docker. This passes your existing X11 session credentials into the container. Software rendering avoids needing GPU passthrough and is sufficient for this single-arm preview.

Keep that terminal running. In a second terminal, from this repository’s root:

FRANKA_REALTIME=ignore cargo run --release --example generate_joint_position_motion -- 127.0.0.1

Press Enter when the example prompts. The simulated arm moves to its starting pose, then performs a joint motion that you can watch in the window. Run one client example at a time. Use the mouse to orbit and zoom the view; Ctrl-C in the server terminal stops the simulator. To include moving gripper fingers, append --gripper-physics to the viewer command.

If the window does not open
  • Failed to load Xlib or a missing OpenGL library: use ghcr.io/barisyazici/franka-sim:1.1.7-viewer, rather than adding --vis to the headless image.
  • Failed to open display or an authorization error: check echo "$DISPLAY" and that SIM_XAUTHORITY points to your current desktop’s readable Xauthority file. On Wayland, XWayland must be available. Do not continue if the test command fails.
  • SSH or a server without a desktop: use the headless command. The desktop recipe requires a local graphical session; it has not been validated on macOS or Windows.
  • Port already in use: stop the other simulator before launching this one.
Optional: build the viewer image locally

From this repository’s root:

docker build -t franka-sim:docs-viewer tools/franka-sim-viewer

Use franka-sim:docs-viewer in place of the published image in the launch command. The local recipe adds the graphics libraries to the 1.1.7 headless image.

Server flags

Arguments after the image name go to the server:

flageffect
--vis / -vOpens the native visualization; requires the desktop setup above.
--gripper-physicsIncludes the physics gripper so its fingers move in the viewer.
--enforce-motion-limitsRuns the robot’s limit checks on every command, including (since franka-sim 1.1.5) the joint-side continuity check on Cartesian poses, scaled by --joint-discontinuity-scale (1.0 is the robot’s own limit). Without it the server accepts commands a robot would refuse.
--gripper-object-width <m>Places a graspable object of that width between the fingers.
--enforce-comm-constraintsEnables the communication-constraint checks.
--no-gripperNo server on port 1338.

One slot per host

The FCI ports are fixed by the protocol and one client holds them at a time, so run one container at a time and check before starting one:

ss -tlnp | grep -E '133[78]'   # nothing may be listening
docker ps                      # no franka-sim container may be running

--rm removes the container when you stop it with Ctrl-C.

What it is not

  • Not a realtime system: timing measured against it is a same-machine comparison, not an FCI qualification (Benchmarks).
  • Not the robot: it has documented divergences from real hardware, each pinned by a test that names the gap in its failure message (Simulator gaps).
  • Only the FR3 image is published; the FER / FCI v5 image (franka-sim:panda-v5) needs a local build, or FRANKA_SIM_ADDR pointed at a running v5 server.

Running the crate’s test suites against it, including the harness that starts the container for you, is described in Test against the simulator.

Build a Pi controller

Keep the control loop beside the robot. Use your laptop for Python, policies, and teleoperation. This guide builds a Pi 5 controller with an Intel dual-port Ethernet card, for one or two arms.

Pi 5 with active cooler, a PCIe adapter beneath it, and an upright dual-port Intel Ethernet card. Optional USB cameras are shown beside the assembly.
Reference assembly: Pi 5 + Active Cooler + P02 PCIe adapter + Intel I350-T2 V2. Cameras are optional. This is a CAD illustration.

Follow these steps

  1. Parts and assembly
    Shopping links, the cooler and PCIe board, and which cable goes where.
  2. System and network
    Install the realtime kernel, enable the Intel NIC, and connect the arms.
  3. Node and laptop client
    Install franka-node, read the first state, then try a small motion.
  4. Optional enclosure
    Explore the 3D assembly and download the printable case or editable CAD.

What runs where?

On your laptopOn the PiAt the robot
Your application sends targets over Zenohfranka-node runs franka-rs at 1 kHzOne wired Intel NIC port per arm

The Pi’s built-in Ethernet or Wi-Fi connects to your laptop’s network. The Intel card’s ports connect directly to the robot control units. Cameras plug into the Pi’s USB ports.

Have only one arm? Use one Intel port and omit the second arm’s configuration. You can also use a Pi’s built-in Ethernet for a single arm and Wi-Fi for the laptop; the Intel card and P02 are the illustrated dual-arm build, not a library requirement.

Already have a working realtime host? Go straight to step 3. For direct Rust or Python without a node, choose your setup.

1. Parts and assembly

Build the electronics first. The enclosure and cameras can come later.

Shopping list

These are the parts used in the reference model. Product links identify the part; prices and availability vary. P02 is the modeled reference; the enclosure is specific to that adapter. Check your board label before buying matching enclosure parts.

PartQtyWhat to look for / links
Raspberry Pi 51The reference BOM uses the 2 GB model. Official product and resellers. More RAM is useful for compiling locally.
Raspberry Pi Active Cooler1SC1148, fits the Pi 5 fan header and mounting holes. Product · installation brief.
Official 27 W USB-C power supply1Use the version for your mains socket and its attached cable. Product and resellers.
microSD card1Reference: 64 GB A2. The NIC occupies the Pi’s PCIe connection, so this build boots from microSD. Raspberry Pi cards.
PCIe adapter/baseboard152Pi / GeeekPi P02, EP-0219. Open-ended x1 slot, custom FFC, pillars and screws. 52Pi store · Pi Hut · assembly instructions.
Intel dual-port NIC1I350-T2 V2 / I350T2V2BLK, with the low-profile bracket for this enclosure. Intel specifications · supplier comparison.
Ethernet patch cables1 per armCat6 in a suitable length; reference BOM: Goobay 93571, 3 m. One extra cable if the Pi’s built-in Ethernet is your uplink.
Mechanical support1Support the card’s bracket on a mounting plate or use the printable enclosure.

The Intel card is physically x4; the P02’s open-ended slot accepts a longer card while providing one PCIe lane. This is why a closed x1 slot is not an interchangeable substitute. Follow the P02 manufacturer’s assembly drawing for the connector and power arrangement. Other adapters, including uPCIty Lite, use a different layout and power arrangement; the P02 case is not a universal Pi case.

Put the stack together

Exploded CAD view separating the Pi, active cooler, PCIe adapter, ribbon cable, Intel NIC and optional camera connections.
Exploded reference model. Rotate and inspect the assembly.
  1. Disconnect power. Fit the Active Cooler’s thermal pads and push pins as its instructions show, then plug its lead into the Pi’s four-pin fan connector.
  2. Mount the Pi on the P02 pillars. Use the supplied hardware and check the P02’s power contacts align. Keep the cooler’s inlet clear.
  3. Connect the supplied PCIe ribbon. Use the Pi’s PCIe connector and the P02 connector; follow the manufacturer’s contact orientation and close both latches. Do not force it into a camera/display connector.
  4. Seat and support the Intel NIC. Seat its edge connector in the open-ended slot, supporting the bracket so plugging in Ethernet cannot lever the card out.
  5. Insert the microSD card and attach cables. Supply Pi power through the official USB-C PSU after assembly. Follow the adapter instructions if using an external slot supply.

Which port goes where?

ConnectionDestination
Intel port labeled L in the enclosureLeft robot control unit
Intel port labeled R in the enclosureRight robot control unit; leave empty for one arm
Pi’s built-in Ethernet or Wi-FiYour laptop / lab network, carrying Zenoh and SSH
Pi USB portsOptional cameras
Pi USB-C power connector27 W supply

Labels are a wiring convention, not Linux interface names. Identify and label the two interfaces in step 2; do not assume port order from the name alone.

Optional cameras and input devices

The reference BOM lists two ELP-USBGS1200P01 / AR0234 USB cameras. The CAD models use generic camera housings; the sensor and lens are not verified by these models. Check a camera’s V4L2 formats before choosing it for franka-cam. Manufacturer example.

Cameras are not required for control, and a Quest headset is optional: franka-vr-teleop drives a node-served arm from one. An integration for any other input device is yours. See peripherals and recording.

2. System and network

The result of this step is a Pi with realtime scheduling and a separate wired link to each robot. No robot motion is needed here.

Install a 64-bit realtime system

A documented route is Ubuntu Server 24.04 LTS for Raspberry Pi + the Raspberry Pi real-time kernel. Write the image to your microSD with Raspberry Pi Imager using the Ubuntu Raspberry Pi image. Set up your account, SSH, and an uplink on the Pi’s built-in Ethernet or Wi-Fi.

The kernel route below requires an Ubuntu Pro entitlement. Follow Canonical’s enablement guide:

sudo apt update
sudo apt install ubuntu-advantage-tools pciutils ethtool
sudo pro attach
sudo pro enable realtime-kernel --variant=raspi
sudo reboot

Use --variant=raspi, not the generic real-time kernel. After reconnecting:

uname -m                    # aarch64
cat /sys/kernel/realtime     # 1

Already have a Pi-compatible PREEMPT_RT image? Keep it and check the same outputs. An ordinary Raspberry Pi OS install is not automatically a realtime system; custom kernel builds are an advanced alternative, not required by this guide.

Enable PCIe and identify the Intel ports

For the P02, edit /boot/firmware/config.txt and ensure this line is active under [all]:

dtparam=pciex1

Reboot. Keep the Pi’s default PCIe Gen 2 speed; no Gen 3 override is needed here. See Raspberry Pi’s PCIe instructions.

lspci -nnk
ip -br link

Find the Intel I350 functions in lspci; the driver in use should be igb. List interface names with ip -br link, then use ethtool -i <interface> to match each interface’s bus-info to its PCI address in lspci (replace the placeholder). If no card is listed, power down and recheck seating, ribbon orientation, and adapter power. If no driver is bound, resolve the kernel’s igb support before continuing.

Plug in one cable at a time and inspect ip -br link / ethtool <interface> to map the physical L and R ports. Write down which interface belongs to each arm. The built-in Ethernet is the uplink and is not one of these Intel interfaces.

For one arm without the Intel card, use the built-in Ethernet as LEFT_INTERFACE below, omit the right interface, and use Wi-Fi for the laptop uplink. Skip the PCIe checks.

Give each arm its own subnet

Example addresses below are a setup plan, not values discovered on your robot. Use subnets that do not overlap your uplink, VPN, or another interface. Confirm the robot-side address in its network configuration before applying the Pi-side settings.

LinkPi addressRobot address
Left Intel port172.16.0.1/24172.16.0.2
Right Intel port, optional172.16.2.1/24172.16.2.2
Built-in Ethernet or Wi-FiYour existing uplink addressLaptop reaches this address

For two arms, configure the second robot for its separate subnet through the robot’s network settings. Setting the Pi to 172.16.2.1 does not change the robot’s address. Do not put two default 172.16.0.2 robots on separate interfaces in the same routing table and expect automatic selection. Start with one arm if both still have the same address.

On Ubuntu Server using Netplan/networkd, add the following to /etc/netplan/60-franka.yaml. Replace LEFT_INTERFACE and RIGHT_INTERFACE with the actual Intel interface names. Remove the right entry for one arm. Keep your existing uplink config; if another Netplan file already configures these Intel ports, reconcile it first.

network:
  version: 2
  ethernets:
    LEFT_INTERFACE:
      renderer: networkd
      dhcp4: false
      dhcp6: false
      addresses: [172.16.0.1/24]
      optional: true
    RIGHT_INTERFACE:
      renderer: networkd
      dhcp4: false
      dhcp6: false
      addresses: [172.16.2.1/24]
      optional: true

No default gateway or DNS belongs on the robot links. Those stay on the uplink. Check and try the configuration with rollback rather than replacing your SSH connection:

sudo chmod 600 /etc/netplan/60-franka.yaml
sudo netplan generate
sudo netplan try
ip route get 172.16.0.2
ip route get 172.16.2.2       # only with a second arm

Each route should show the intended Intel interface and matching Pi source address. Netplan’s static-address guide explains the configuration. A NetworkManager-managed system needs its corresponding connection profiles instead.

Allow realtime scheduling

Create a group and add your login account:

sudo groupadd -f realtime
sudo usermod -aG realtime "$USER"

Create /etc/security/limits.d/99-franka-realtime.conf with:

@realtime - rtprio 99
@realtime - memlock unlimited

Log out and back in, then check ulimit -r reports 99. The service in the next step also sets these limits. See the realtime machine for details.

Before moving on

  • cat /sys/kernel/realtime reports 1 and ulimit -r reports 99.
  • Each Intel port is identified, and routing selects the expected port for each robot.
  • ping 172.16.0.2 works (and 172.16.2.2 for two arms).
  • The laptop can still reach the Pi’s uplink address. This is the address used for Zenoh.

Ping verifies basic reachability, not 1 kHz timing. The later communication test moves the robot and must follow the robot’s motion prerequisites. Do not run it alongside an active node.

3. Node and laptop client

The Pi has the kernel, the realtime limits and both network paths from System and network. This page puts franka-node on it and a Python client on your laptop, then reads the arm’s state. Motion is a separate, marked section at the end.

Install franka-node on the Pi

The node is released from 0.4.0 on. On a 64-bit Raspberry Pi OS or Ubuntu, take the prebuilt binary; no compiler is needed:

cargo binstall franka-node

It fetches franka-node-<version>-aarch64-unknown-linux-gnu.tar.gz, built with record and linked against glibc 2.31, so it runs on Raspberry Pi OS Bookworm; the how-to page has the static musl and x86_64 tarballs and the command for a Pi without cargo.

Only if no tarball matches your platform, compile the same release from crates.io instead (Rust 1.89 or newer). Run this or the command above, never both: it replaces a prebuilt binary with one built from source, which has no recording unless you add the feature below.

cargo install franka-node --locked

Either way the binary lands in Cargo’s binary directory, normally ~/.cargo/bin.

From a source checkout, for a platform the release does not build or a revision it does not contain yet. Install Rust 1.89 or newer with rustup, put a copy of this repository on the Pi and from its root run:

cargo install --path crates/franka-node --locked

Building on the Pi takes a while; Build for another machine builds it on a PC instead. A compiled install, from crates.io or from the checkout, records per session only with --features record and Rust 1.96, see Peripherals; the prebuilt tarballs already have it.

A minimal configuration

Save this as node.toml on the Pi:

name = "pi"                          # this node's name in franka/node/pi/status

[zenoh]
listen = ["tcp/0.0.0.0:7447"]        # clients connect to tcp/<pi-address>:7447
multicast_scouting = false           # the client names the Pi, no discovery needed

[[arm]]
name = "left"                        # the <arm> in every key: franka/left/state, ...
host = "172.16.0.2"                  # the robot on the wired link
realtime = "enforce"                 # refuse to start unless the Pi can run realtime

Every other key has a default. The arm’s name is a label you choose (letters, digits, - and _). Keep realtime on enforce for a robot; "ignore" is for the simulator. Plain TCP is for a trusted network; the node’s status section covers TLS and authentication.

A second arm is one more [[arm]] table, name = "right" and host = "172.16.2.2" on its own link; the addressing is on System and network. The complete two-arm example keeps the shipped control defaults, with optional per-arm CPU pinning and Franka Hands. Live tuning explains which TOML values can change during a session.

Start the node and check it

With FCI mode unlocked in Desk, run the node in the foreground:

franka-node node.toml

It connects to each arm and publishes the arm’s state at 100 Hz. Nothing moves: the node holds no session until a client asks for one. Ctrl-C stops every arm and exits; RUST_LOG=debug adds every refused target with its reason. Before going on, make sure the log shows no Realtime error and that port 7447 is open on any firewall the Pi runs.

Install the Python client on the laptop

The client is franka-node-client on PyPI, released with the node and of the same version. With Python 3.9 or newer:

python3 -m venv .venv
source .venv/bin/activate
python -m pip install franka-node-client
Windows PowerShell
py -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install franka-node-client

The package imports as franka_node and depends only on eclipse-zenoh and numpy. If you built the node from a checkout, install the client from the same revision instead, with python -m pip install ./crates/franka-node/python from the repository root. The Python bindings need the robot link on the machine they run on, so they are not what this setup wants.

The client also installs franka-tuning-panel, a browser panel that changes a running controller’s gains and budgets without restarting the node; pip install franka-vr-teleop adds a Meta Quest teleoperation stack that drives an arm through this node. Neither is needed for the rest of this page: Tune a running controller and Teleoperate with a Quest cover them.

Read the state, no motion

Replace <pi-address> with the Pi’s address on the laptop’s path. This takes no lease and cannot move the arm:

import franka_node

with franka_node.Node("tcp/<pi-address>:7447") as node:
    print(node.status("pi"))                # {"node": "pi", "arms": [{"name": "left", "phase": "idle", ...}]}
    state = node.arm("left").state()        # the latest state, waiting up to 1 s for the first
    print("phase", state.phase, "robot mode", state.robot_mode, "errors", state.has_errors)
    print("position [m]", state.position)   # end effector in the base frame
    print("q [rad]", state.q)

phase is idle on a fresh node, robot_mode is the robot’s own mode and has_errors says whether the robot reports an error. A NodeTimeout means nothing arrived: check the address, the port, the node name and that the node is running. The fields and arm.on_state(callback) are in Serve arms over Zenoh.

First motion (moves the arm)

This section moves the robot. Before running it: the workspace above the end effector is clear, a person is at the external activation device, the brakes are open and FCI mode is unlocked. The arm rises by 5 cm and stops there.

import franka_node

with franka_node.Node("tcp/<pi-address>:7447") as node:
    with node.arm("left") as arm:                    # takes the lease; released on exit
        with arm.cartesian_targets() as targets:     # starts the 1 kHz loop; stopped on exit
            targets.move_by([0.0, 0.0, 0.05])        # +5 cm in z from the current target
            state = targets.wait(timeout=5.0)
            print("arrived at", state.position, "refused targets:", state.refused)

move_by sets the goal and a client thread streams targets toward it at 50 Hz, paced under the node’s limits. A lost lease or the node’s watchdog requests a controlled stop, which may finish at the last accepted target; it is not an emergency stop. Refused with held by <id> means another client holds the arm. After a robot stop or fault, resolve its condition first, then arm.recover().

Run the node as a service

Once the foreground run works, stop it (only one FCI client can own the robot) and install the supplied systemd unit. It sets the realtime limits itself, restarts the node after a failure and stops it with SIGINT so every arm settles first; The systemd unit explains it.

Install the binary, the configuration and the unit

Run these on the Pi from the directory holding the node.toml you just tested. The unit is crates/franka-node/deploy/franka-node.service in the checkout and beside the binary in the release tarball; point the last line at whichever copy you have.

sudo install -m 755 "$(command -v franka-node)" /usr/local/bin/franka-node
sudo install -d /etc/franka-node
sudo install -m 644 node.toml /etc/franka-node/node.toml
sudo install -m 644 crates/franka-node/deploy/franka-node.service /etc/systemd/system/

Edit /etc/systemd/system/franka-node.service: replace User=CHANGE_ME with the account that runs the node. Group=realtime is the group from the system page; an unedited unit refuses to start. Then:

sudo systemctl daemon-reload
sudo systemctl enable --now franka-node
journalctl -u franka-node -f

4. Optional enclosure

The controller works without a printed case. This two-part enclosure supports the Pi 5 + Active Cooler + P02 + low-profile I350-T2 V2 reference assembly.

Rendered vented two-part enclosure with access to the Pi ports and a supported upright Intel NIC.
Revised bracket-fold design. Approx. 158.3 × 107.6 × 91 mm assembled, or 94 mm with feet. CAD illustration, not a photograph of a validated print.

The current downloads include a dedicated slot and reinforced supports for the Intel bracket’s folded edge. Use the base and lid from the same print bundle.

Look inside

Open the interactive 3D viewer to rotate the case, switch to the electronics, or inspect the exploded assembly. No plugin is required.

The Pi geometry comes from official CAD. The cooler, P02 and NIC are modeled from manufacturer drawings and photographs; some dimensions are estimates. Check your actual adapter, card and bracket before printing. Other boards or brackets may not fit.

Download the right file

You want to…Download
Print both partsPrint bundle: ZIP — two STLs and a 3MF
Open both parts in a slicerenclosure.3mf
Slice one partbase.stl · lid.stl
Edit the enclosure in CADbase.step · lid.step
View the enclosure with electronicsenclosure-scene.glb
View the electronics aloneassembly.glb · assembly-exploded.glb

STL / 3MF are for slicing; STEP is for editing; GLB is for viewing. The GLB includes electronics and cables and must not be treated as a printable case. The 3MF contains both meshes and their layout, not a tested printer profile.

Model sources and attribution · File sizes, revision timestamps and SHA-256 hashes.

Slice a fresh print

Use the STLs in their supplied print orientation, or open the 3MF and select your own printer and material. The design notes specify this starting point:

SettingDesign starting point
Nozzle / layer height0.6 mm / 0.3 mm
Walls / top and bottom4 perimeters / 5 layers
Infill25% gyroid
SupportsDesigned for no supports; inspect bridges in your slicer
BedBoth parts arranged on a 220 × 220 mm plate
Base / lid orientationBase floor on bed / lid roof on bed

Review the sliced result before printing; extrusion width, bridging, material and machine settings affect the fit. The earlier 0.4 mm G-code is stale and is intentionally not provided. Generate new G-code from this geometry for your printer’s 0.6 mm profile. Let the bed cool before removing the parts; flex the plate rather than prying at the port wall.

Fasteners and assembly

In addition to the P02’s pillars, the design calls for:

  • 7 M2.5 × 4 mm heat-set inserts (holes sized at 3.6 mm diameter).
  • 5 M2.5 × 8 mm socket-head screws: four lid screws and one bracket clamp screw.
  • 4 M2.5 × 10 mm floor screws and 2 M2.5 × 6 mm P02 mounting screws.
  • 4 adhesive rubber feet, 12.7 mm diameter × 3.5 mm.

Fit the inserts, connect the ribbon, and mount the P02/Pi stack in the base. Check screw engagement against the actual pillar threads. Seat the card through the bracket groove and lower guide, with the bracket’s folded edge in its dedicated slot; fit the lid so the card enters the upper guide, then secure the lid and bracket. Avoid forcing the card or overtightening the clamp.

The design has undergone CAD clearance and insertion checks, but bracket position and port heights still include estimates. Thermal performance and the revised printed fit have not been established by these model checks. Keep the Active Cooler and ventilation unobstructed.

Install

At the end of this page the crate builds in your project, or import franka works in your Python environment.

Rust

cargo add franka-rs

The library is named franka, so you write use franka::Robot;. Rust 1.89 or newer (edition 2021). Build with --release: a debug build of a stiff torque controller misses cycles. To follow main instead of a release, depend on the git repository: franka-rs = { git = "https://github.com/BarisYazici/franka-rs" }.

Both robots use the Rust model implementation; no separate Panda model library is needed. For JSON serialization, enable the optional serde feature; see Record and replay a run.

A Raspberry Pi or another remote control host

Use franka-node on the realtime host and franka-node-client (the same version) on your laptop. These are optional: a direct Rust or Python program needs neither. The Raspberry Pi guide explains prerequisites, installation, configuration, and the first connection.

cargo install franka-node --locked
# Optional camera process:
cargo install franka-cam --locked

From a checkout, cargo install --path crates/franka-node --locked (and crates/franka-cam). Prebuilt binaries and the full configuration: Serve arms over Zenoh.

Python

pip install franka-rs

The wheel is franka-rs, the import name franka; Python 3.9 or newer, numpy is the only dependency. See From Python.

Rerun viewer (optional)

Only needed for the flight recorder and the Rerun examples. The viewer must be 0.37.1, the SDK version crates/franka-rerun pins:

pip install rerun-sdk==0.37.1
# or
cargo install rerun-cli --locked --version 0.37.1

Both put a rerun binary on the path; check with rerun --version.

Next: The realtime machine, or Without a robot: franka-sim if you have no arm at hand.

The realtime machine

At the end of this page the PC that runs your program can hold the 1 kHz loop’s 1 ms deadline, and you have checked the link to the robot before the first motion.

  • A wired Ethernet connection to the robot’s control unit. 172.16.0.2 is the address in Franka’s documented default setup; the examples take it as argv[1].
  • FCI mode unlocked in Desk and the brakes open.
  • The ports are fixed by the protocol, 1337 for the robot and 1338 for the Franka Hand, and only one FCI client may be connected at a time. How the FCI works has the rest.

A PREEMPT_RT kernel

The loop has a hard 1 ms deadline. The crate checks /sys/kernel/realtime, as libfranka does:

cat /sys/kernel/realtime    # 1 on a PREEMPT_RT kernel

Permission for SCHED_FIFO

Raising the control thread to SCHED_FIFO needs CAP_SYS_NICE or a nonzero RLIMIT_RTPRIO. As an unprivileged user:

ulimit -r 99

This works only once an rtprio limit is configured for the user, for example /etc/security/limits.d/99-realtime.conf containing <user> - rtprio 99 (needs pam_limits and a fresh login). Ubuntu’s realtime-kernel variant sets up a realtime group that already has rtprio and memlock.

RealtimeConfig

Robot::new takes a RealtimeConfig:

  • Enforce, the default as in libfranka: checks that the calling thread can be raised to SCHED_FIFO and that the kernel advertises realtime capabilities, and fails with FrankaError::Realtime and libfranka’s message otherwise.
  • Ignore: skips both checks and runs the loop at whatever priority the process has.

Against the simulator Ignore costs nothing. Against a robot it means the loop can miss its deadline under load, which the robot reports as a falling control_command_success_rate and, eventually, a communication_constraints_violation reflex.

Every example reads the choice from FRANKA_REALTIME (enforce, the default, or ignore), so one binary serves the realtime PC and the simulator:

FRANKA_REALTIME=enforce cargo run --release --example echo_robot_state -- 172.16.0.2
FRANKA_REALTIME=ignore  cargo run --release --example echo_robot_state -- 127.0.0.1

Only the control thread should be SCHED_FIFO. Do not start the whole process with chrt -f 80: other busy threads in the same process (a Rerun recorder, for instance) then starve the loop. The crate raises the control thread itself; a target control loop takes realtime_priority when a lower priority is wanted next to other realtime threads. The realtime rules lists what the loop must and must not do.

Before any motion, run communication_test: after moving the arm to the ready pose it runs a zero-torque loop and reports how many robot states were lost and the min/avg/max control_command_success_rate, exiting non-zero when the average is below 0.9. It is the first thing to point at a new PC or network card.

cargo run --release --example communication_test -- 172.16.0.2

A degraded Ethernet cable does not show up in ping: it can appear as a communication_constraints_violation reflex with a clean ping, and a packet capture of the 1 kHz stream diagnoses it.

Details

RobotImpl::new raises its own thread’s priority unconditionally (libfranka’s Robot::Impl does the same) and under Ignore only swallows a failure, so on a machine where ulimit -r is nonzero the control thread ends up at SCHED_FIFO even with Ignore.

First program

Three steps on one page: connect and read a state, stream states, make the first motion. At the end the arm has moved 5 cm up under your program and come to rest there.

Prerequisites: Install and The realtime machine, or the simulator with RealtimeConfig::Ignore in place of Enforce below.

1. Connect and read the state

extern crate franka;
use franka::{RealtimeConfig, Robot};

fn main() -> franka::FrankaResult<()> {
    let robot = Robot::new("172.16.0.2", RealtimeConfig::Enforce)?;
    println!("FCI version {:?}, server version {}", robot.fci_version(), robot.server_version());

    let state = robot.read_once()?;
    println!("q      = {:?}", state.q);
    println!("O_T_EE = {:?}", state.O_T_EE);
    println!("mode   = {}", state.robot_mode);
    Ok(())
}

Robot::new opens the command channel, negotiates the protocol version (an FR3 speaks FCI v10, a Franka Emika Robot FCI v5; the same binary drives both) and, with Enforce, checks the realtime prerequisites. read_once waits for one state of the 1 kHz stream: q are the seven joint angles in radians, O_T_EE the end-effector pose as a column-major 4x4 matrix in the base frame, and robot_mode prints Idle on a robot that is ready to move.

2. Stream states

read takes a callback and keeps going until it returns false:

extern crate franka;
use franka::{RealtimeConfig, Robot};
fn main() -> franka::FrankaResult<()> {
let robot = Robot::new("172.16.0.2", RealtimeConfig::Enforce)?;
let mut count = 0;
robot.read(|state| {
    println!("{:?}", state.q);
    count += 1;
    count < 100
})?;
Ok(()) }

One state per millisecond, on the calling thread. examples/echo_robot_state.rs is this program, a port of libfranka’s example of the same name. The arm has not moved yet.

3. The first motion

Have the user stop button at hand and make sure there is free space above the end effector. The robot must be in FCI mode with the brakes open.

extern crate franka;
use std::sync::Arc;
use franka::{RealtimeConfig, Robot, TargetControlOptions};

fn main() -> franka::FrankaResult<()> {
let robot = Arc::new(Robot::new("172.16.0.2", RealtimeConfig::Enforce)?);
let control = robot.start_cartesian_target_control(TargetControlOptions::default())?;
let start = control.target();            // the start position, base frame, metres
control.set_position([start[0], start[1], start[2] + 0.05])?;   // 5 cm up
std::thread::sleep(std::time::Duration::from_secs(2));
control.stop()?;                         // settle, finish the motion, join the thread
Ok(()) }

What to expect on the first run: the arm rises 5 cm along the base z axis in about 0.9 s and stays there; stop() returns about a quarter of a second later, and the program exits.

The target was a step; the motion is not. start_cartesian_target_control spawns the 1 kHz loop on a realtime thread of its own and returns a handle. set_position writes the target into a slot that the loop reads every cycle, and an online trajectory generator in the loop re-plans a jerk-limited profile from the current command to the target on every cycle, under a budget of 0.3 m/s, 0.5 m/s² and 20 m/s³ by default (a norm; each axis gets 1/√3 of it). The loop tracks the resulting pose stream itself: every cycle it solves for the joint configuration of the pose and sends the torques of an impedance law that pulls the arm there, 750 N/m in translation by default, so the arm gives way when pushed and returns when let go. TargetControlOptions::default().with_backend(Backend::RobotController) has the robot’s own Cartesian impedance controller track the stream instead. stop() waits until the command has landed on the target, holds it for 250 cycles and then finishes the motion, because the robot refuses to finish on a moving command. set_position can be called from any thread at any rate, which is the point: a planner at 10 Hz, a script, a socket. Command from a low-rate program has the options and the guards; Three ways to control the arm places this next to the 1 kHz callback API and ActiveControl.

If the robot refuses the motion, stop() returns FrankaError::Control with the robot’s error text and the reflex reason, and robot.automatic_error_recovery() clears it; see State and errors.

The README’s quick example (examples/readme_joint_move.rs) is the 1 kHz-side equivalent of a first motion: a joint-space cosine on joint 4 written as a loop of your own, read_once then write_once, on start_joint_position_control (see Drive the loop yourself). CI runs it against the simulator.

Where next

From Python

At the end of this page a Python program running at its own rate drives the arm through the same target control as the first program, and you know where the notebook and the examples are.

To experiment without installing anything locally, open the online target-control lab: edit Python cells, change motion limits, and watch a simulated FR3 in the notebook.

pip install franka-rs

The wheel is franka-rs, the package franka (Python 3.9 or newer, numpy).

For an arm served by franka-node, see its Python client.

import franka

robot = franka.Robot("172.16.0.2")   # FRANKA_REALTIME=ignore for franka-sim

with robot.cartesian_targets(max_velocity=0.3, max_acceleration=0.5, max_jerk=20.0) as arm:
    while not done:
        obs = arm.state().flat()      # 1-D float64 in the order of franka.FLAT_LAYOUT
        action = policy(obs)
        arm.move_by(action[:3])       # metres, relative to the current target
        # or: arm.move_to([x, y, z]); arm.follow(chunk, dt=0.02)  # chunk: (N, 3) absolute

cartesian_targets starts the 1 kHz loop on a Rust thread, and the GIL is never on it: a move_* is one write into the loop’s slot, state() a copy out of a mutex. Leaving the with block stops the loop; an exception inside it still stops the motion and is re-raised. The keyword arguments are the Rust TargetControlOptions (max_deviation, max_angular_velocity, max_angular_acceleration, max_angular_jerk, max_angular_deviation, backend, cartesian_stiffness, cartesian_damping, joint_stiffness, joint_damping, torque_limits, posture, torque_cutoff, velocity_feedforward, leash, project_joint_gains, controller_mode, limit_rate, realtime_priority), and franka.Robot(address, realtime='ignore') overrides FRANKA_REALTIME.

Compliance

The loop tracks the targets with the crate’s own impedance torques by default (backend='impedance'), so the arm is a spring around the target: push it and it gives way, let go and it returns. cartesian_stiffness is that spring, 6 values (x, y, z in N/m, then three rotational in Nm/rad) or one float for the three translational entries with the rotational defaults kept; the defaults are 750 N/m and 15 Nm/rad.

with robot.cartesian_targets(cartesian_stiffness=400) as arm:   # softer than the default 750 N/m
    arm.move_by([0.03, 0.0, 0.0])

cartesian_damping (same shape), joint_stiffness and joint_damping (7 each, the joint term), torque_limits (7, Nm), posture (7 rad, the configuration the inverse kinematics prefers; default the start) and torque_cutoff (Hz, default 100) are the rest of the Rust ImpedanceOptions; None keeps each default, a wrong length is a ValueError. Three more:

  • velocity_feedforward=False: the damping acts on the velocity, DROID’s form, and with cartesian_damping=[37, 37, 37, 2, 2, 2] its law; True damps the velocity error instead (it made real arms vibrate, see the backends).
  • leash=(metres, radians), default (0.025, 0.15): how far the target may run ahead of an arm that is held back, so the spring’s pull plateaus (roughly 40 to 50 N at the defaults, a hard push briefly over 60 N; target control sets no collision thresholds, so set 40 N / 40 Nm for unattended runs and 60 N or more where people push, see Collision thresholds).
  • project_joint_gains=False: True confines the joint term to the nullspace, so the end effector feels cartesian_stiffness alone.

joint_targets takes backend, joint_stiffness, joint_damping, torque_limits, torque_cutoff, velocity_feedforward, leash (one float, rad per joint, default 0.1) and project_joint_gains. backend='robot' has the robot’s own controller (controller_mode) track the targets instead, as the bindings did before the impedance backend existed; it takes none of the gains. The law, the defaults and what differs between the backends are in Command from a low-rate program.

callwhat it does
arm.move_to(t)Absolute target in the base frame: 3 elements (x, y, z) in m keep the target orientation, 7 add a unit quaternion (x, y, z, w). Cancels a follow; never blocks.
arm.move_by(d)Moves the target (not the measured pose) by (dx, dy, dz) m; 6 elements append a rotation vector (axis times angle, rad) composed onto the target orientation in the base frame. Cancels a follow.
arm.follow(chunk, dt=0.02)Hands an (N, 3) or (N, 7) array of absolute targets to a Rust timer thread: row 0 now, row i at i * dt s. Returns at once; a new follow or move_* replaces it.
arm.target()The current target, shape (7,): position (m) and unit quaternion (x, y, z, w).
arm.target_pose()The same as a (4, 4) matrix, the convention of RobotState.O_T_EE.
arm.state()The latest franka.RobotState the loop received; never blocks on the network.
arm.runningFalse once the loop ended for any reason or stop() was called.
robot.joint_targets(fraction=0.2)The same over the seven joints (rad): move_to, move_by and the rows of follow take 7 values, target() is (7,), and the budget is fraction of the robot’s joint velocity, acceleration and jerk limits.

franka.rotated(q, r) composes a rotation vector onto a quaternion the way move_by does, for building move_to targets. robot.read_once(), robot.set_collision_behavior(...) (the Rust arguments), robot.automatic_error_recovery(), robot.stop(), robot.fci_version, robot.server_version and robot.gripper() (homing, move, grasp, stop, read_once, GIL released while they wait) complete the surface.

RobotState

Arrays are float64 numpy arrays, poses (4, 4) matrices with the translation in M[:3, 3]; robot_mode is a string such as 'idle', current_errors and last_motion_errors are lists of flag names. state.flat() is one array of length franka.FLAT_LEN (69) for a policy’s observation; the slices are franka.FLAT_LAYOUT:

slicefield
0:7q (rad)
7:14dq (rad/s)
14:21tau_J (Nm)
21:28tau_ext_hat_filtered (Nm)
28:31O_T_EE translation (m)
31:35O_T_EE unit quaternion (x, y, z, w), w >= 0
35:41O_F_ext_hat_K (N, Nm)
41:48q_d (rad)
48:51O_T_EE_c translation (m)
51:55O_T_EE_c quaternion (x, y, z, w)
55:62joint_contact
62:68cartesian_contact
68time (s)

Robot.model()

franka.Model is the crate’s model over numpy, native on both robots (an FR3 sends its URDF, an FER’s parameters are built in): pose(frame, q) is (4, 4), body_jacobian and zero_jacobian are (6, 7) with the linear rows first, mass(q) (7, 7), coriolis(q, dq) and gravity(q) (7,), link_poses(q) (8, 4, 4) and hand_pose(q) (4, 4). frame is 'joint1'..'joint7', 'flange', 'ee' or 'stiffness'; the optional F_T_EE, EE_T_K, I_total, m_total and F_x_Ctotal arguments default to the identity and no payload, so pass the state’s for the mounted tool. See Use the model.

Errors

Every Rust error is a franka.FrankaError; str(e) is the Rust message and e.kind names the variant ('network', 'command', 'realtime', …). A motion the robot aborted is a franka.ControlException, a subclass, with e.reason (the reflex reason), e.errors (its flag names) and e.move_status. See State and errors.

The notebook and the examples

crates/franka-py/examples/quickstart.ipynb connects, reads a state and the model, drives a 5 cm square with move_to at 10 Hz, yaws the tool 15° with a quaternion target and tilts it 10° with a move_by rotation vector, returns to the start with a follow chunk, then replays the motion inline in Rerun (Franka’s meshes if FRANKA_MESHES points at them, otherwise a skeleton) and plots target against measured position. It runs top to bottom against franka-sim: FRANKA_ADDRESS (default 127.0.0.1) picks the robot, FRANKA_REALTIME defaults to ignore, and the last cells need "rerun-sdk[notebook]==0.37.*" and matplotlib.

examples/policy_loop.py <hostname> [--yes] is a jittery 6-10 Hz policy loop: a 4 cm circle through move_to, yaw and tilt through move_by, a 20-row follow chunk back to the start. examples/rotate.py <hostname> is the rotation alone: a 20° yaw as a quaternion target, a 10° tilt as a rotation vector, back to the start, printing the measured angle after each.

Details

franka.Robot(...) raises the thread that calls it to the highest SCHED_FIFO priority when RLIMIT_RTPRIO allows, in both realtime modes, as libfranka’s constructor does. In a notebook that thread is the kernel’s main thread, so a long-running cell then competes with the control thread at realtime priority. The control thread of cartesian_targets / joint_targets is raised separately (realtime_priority).

To build from the source tree into a virtualenv, pip install maturin and maturin develop --release -m crates/franka-py/Cargo.toml (prefix with env -u CONDA_PREFIX under an active conda environment). crates/franka-py is publish = false; the wheel on PyPI is the artefact. Its tests run against the simulator; see Test against the simulator.

Try target control online

Experiment with Cartesian targets on a simulated FR3 in a Jupyter notebook, with no local installation. Edit Python code, change velocity, acceleration and jerk limits, then watch the arm and compare its measured motion.

Launch the notebook on Binder

This notebook launches the public repository’s main branch. You can also read the notebook source.

Run an experiment

  1. Open the launch link and wait for Jupyter. Binder builds the environment for the repository revision and caches it; the first launch can take longer.
  2. Run the import and stationary-preview cells. You should see the robot before sending any movement command.
  3. Make a 2 cm move, play the recording, then repeat with a lower velocity limit.
  4. Change acceleration or jerk, one at a time, and compare the measured response.
  5. Continue to the direct control API and optional sliders when ready to write a longer movement sequence.

The control calls are the same ones used in From Python:

import time

with robot.cartesian_targets(
    max_velocity=0.3,       # m/s
    max_acceleration=0.5,   # m/s²
    max_jerk=20.0,          # m/s³
) as arm:
    arm.move_by([0.04, 0.0, 0.0])
    time.sleep(3.0)         # allow the controller to move before leaving the block

move_by updates the target and returns immediately; leaving the with block stops control. Velocity limits constrain how fast the generated target moves, acceleration limits constrain changes in velocity, and jerk limits constrain changes in acceleration. A short move may never reach its maximum velocity. The measured arm can lag the generated target, so a higher limit does not guarantee proportionally faster motion.

Python sends targets to the native Rust control pipeline. A native franka-sim process runs in the same session and exchanges robot commands and state with that controller. Each experiment starts and stops its own simulator. The inline viewer replays recorded joint states as a robot skeleton, with Play/Pause, a time slider and view rotation. It requires no WebGL or external graphics assets. Optional Rerun mesh rendering in the quickstart needs WebGL2; a browser without it can use the default playback.

The accompanying quickstart.ipynb also starts its own simulator and demonstrates move_to, rotation targets and follow. See Command from a low-rate program for the full API and backends.

Keep your changes

Binder sessions are temporary. Download your edited notebook and any results you want to keep before closing the session. Opening the launch link again creates a fresh environment; it does not restore earlier edits.

The public service has limited shared CPU and memory, and may stop idle sessions. Treat these experiments as comparisons of simulated behavior: cloud timing is not a realtime qualification, and simulator motion does not establish physical FR3 performance. See Binder’s usage guidelines and the simulator gaps.

For longer experiments or consistent access, use the local simulator and run the notebook locally.

Peripherals: gripper, cameras, recording

At the end of this page you know what each optional part of a setup does, which process owns it, and where its full description is. Nothing here is required for moving the arm; the two setups are described in Choose your setup.

The Franka Hand

The gripper has its own connection on port 1338 of the robot’s host, separate from the arm’s 1 kHz link, and its own commands: homing (measures the finger stroke; needed after changing fingers), move to a width, grasp at a force, stop. Widths are metres.

Setup A, your program on the realtime machine: franka::Gripper in Rust, or robot.gripper() in Python, talks to the hand directly. Use the gripper is the walkthrough.

Setup B, through franka-node: the node owns the hand when the arm’s table names it:

[[arm]]
name = "fr3"
host = "172.16.0.2"
gripper = "hand"                       # the Franka Hand at host, port 1338
gripper_speed = 0.1                    # m/s of every move and grasp

The hand then answers to the arm’s lease holder in every phase; the arm does not have to be enabled. In the Python client:

with node.arm("fr3") as arm:
    arm.gripper.homing()                   # blocks until the homing is over
    print(arm.gripper.state().width)       # measured width, m
    arm.gripper.move(0.04)                 # to 4 cm; waits until it is there
    held = arm.gripper.grasp(0.02, 20.0)   # close on an object at 2 cm with 20 N; True when held

arm.gripper.stop() works from any client. Stopping the arm, a lost lease or the watchdog do not stop an ongoing gripper action; use arm.gripper.stop() explicitly. The status of the driver through the node is in the crate README: at the time of writing it is tested against the simulator’s gripper server and not yet validated on a hand. The keys, the wire layout and how to add a driver for another gripper are in Serve arms over Zenoh.

Cameras

franka-cam is a second Zenoh node, a separate process on purpose: USB is where the flakiness lives, and a camera that stalls must not take the robot’s process with it. It opens V4L2 cameras (USB webcams, MJPEG or raw), publishes every frame with the timestamp the driver captured it at, and reports each camera once a second. It belongs on the same host as franka-node, because the two then stamp frames and arm states with the same clock and a policy or a recording can line them up.

Install it as you installed the node: cargo binstall franka-cam fetches the prebuilt binary from the release, cargo install franka-cam --locked compiles it from crates.io, and from a checkout it is cargo build --release -p franka-cam. A minimal configuration:

name = "cameras"                       # distinct from the arm node name

[zenoh]
listen = ["tcp/0.0.0.0:7448"]          # 7448: the arm node has 7447

[[camera]]
name = "wrist"
device = "/dev/v4l/by-path/<your-camera>-video-index0"
width = 640
height = 480
fps = 30
preview_fps = 5                        # every 6th frame on franka/cam/wrist/preview
format = "mjpeg"

After the source build, run ./target/release/franka-cam cam.toml (or franka-cam cam.toml if installed). Replace the device placeholder with your camera’s actual V4L2 path. Frames are published on franka/cam/wrist/frame; the lower-bandwidth preview is on franka/cam/wrist/preview. Your application subscribes over Zenoh; this is a camera stream, not a browser video URL. The crate README documents decoding.

Publishing has been exercised with USB cameras beside the arm node on a Pi 5. Camera recording is tested with synthetic inputs and is not yet validated beside real arms. Recording supports MJPEG and requires the camera and arm node on the same host.

Prefer a by-path device name when identical cameras have duplicate serial numbers. Device options and frame decoding are documented in crates/franka-cam/README.md in your source revision; it also lists hardware validation.

Recording

Both the library and the node can write a Rerun recording: every 1 kHz cycle’s state, the command that was sent and, through the node, each target accepted or refused with its reason. That is the material for the question every reflex raises: what happened in the second before?

Setup A: Record and replay a run covers the control log the crate keeps, the franka-rerun tools that turn it into a recording, and the live Recorder.

Setup B: build the node with the record feature (Rust 1.96 or newer) and name a writable directory in the existing arm table (do not add a duplicate [[arm]]):

cargo build --release --locked -p franka-node --bin franka-node --features record
mkdir -p recordings
[[arm]]
name = "fr3"
host = "172.16.0.2"
record_dir = "./recordings"            # create this writable directory first

Every arm session, an enable or an arm home, becomes one <arm>-<UTC stamp>.rrd; a session enabled with an episode name, arm.cartesian_targets(episode="pick-0042") in Python, writes pick-0042-fr3.rrd instead, and two arms enabled with the same name load in the viewer as one recording. A camera whose config says record_with = "fr3", with a record_dir of its own and franka-cam built with the same feature, records its frames between that arm’s session start and end into a file the viewer loads alongside. In the camera config, record_dir is a top-level key before [zenoh], while record_with belongs inside [[camera]]. Connect the camera node to the arm node with connect = ["tcp/127.0.0.1:7447"] under [zenoh]. If you restricted the arm node to a network interface, also add "tcp/127.0.0.1:7447" to its listen list, or use its reachable interface address here. See the camera README for a complete recording configuration.

Open a file with the viewer of exactly the version the crates pin, 0.37.1:

pip install rerun-sdk==0.37.1
rerun recordings/pick-0042-fr3.rrd    # replace with your actual recording filename

The arm is drawn with the built-in meshes of franka-rerun; the recording holds the arm’s joints, the end effector, the external wrench and the commander’s targets on a shared time axis. The details, the two timelines every row carries and what is validated on hardware are in Serve arms over Zenoh.

Command from a low-rate program

At the end of this page you will have moved the end effector to targets set from an ordinary thread, at whatever rate your program runs, with the crate’s realtime thread doing the 1 kHz work. This is the interface for planners, policies, teleoperation and scripts, and the one the Python bindings use.

Prerequisites: First program works against your robot or the simulator, the arm is at the ready pose (move_to_ready), and the space around the end effector is free.

extern crate franka;
use std::sync::Arc;
use franka::{RealtimeConfig, Robot, TargetControlOptions};

fn main() -> franka::FrankaResult<()> {
let robot = Arc::new(Robot::new("172.16.0.2", RealtimeConfig::Enforce)?);
let control = robot.start_cartesian_target_control(TargetControlOptions::default())?;
let start = control.target();            // the start position, base frame, metres
control.set_position([start[0] + 0.05, start[1], start[2]])?;   // any thread, any rate
std::thread::sleep(std::time::Duration::from_secs(1));
let state = control.state();             // the latest RobotState, copied out
assert!(control.is_running());
control.stop()?;                         // settle, finish the motion, join: the loop's result
let _ = state; Ok(()) }

start_cartesian_target_control returns once the loop’s first cycle has run, so target() and state() are valid at once. A 5 cm step along one axis under the default budget becomes an S-curve that peaks at about 0.12 m/s and lands after about 0.85 s (the budget is a norm and each axis gets 1/√3 of it).

The API

impl Robot {
    pub fn start_cartesian_target_control(self: &Arc<Self>, options: TargetControlOptions)
        -> FrankaResult<CartesianTargetControl>;
    pub fn start_joint_target_control(self: &Arc<Self>, options: JointTargetControlOptions)
        -> FrankaResult<JointTargetControl>;
}
impl CartesianTargetControl {
    pub fn set_position(&self, position_in_base: [f64; 3]) -> FrankaResult<()>;
    pub fn set_orientation(&self, orientation_xyzw: [f64; 4]) -> FrankaResult<()>;
    pub fn set_target(&self, position_in_base: [f64; 3], orientation_xyzw: [f64; 4])
        -> FrankaResult<()>;
    pub fn set_pose(&self, pose: &[f64; 16]) -> FrankaResult<()>;   // column-major, as O_T_EE
    pub fn target(&self) -> [f64; 3];
    pub fn target_orientation(&self) -> [f64; 4];
    pub fn target_pose(&self) -> [f64; 16];
    pub fn state(&self) -> RobotState;
    pub fn tune(&self, update: &TuningUpdate) -> FrankaResult<Vec<&'static FieldBound>>;
    pub fn tuning(&self) -> FrankaResult<LiveTuning>;
    pub fn is_running(&self) -> bool;
    pub fn stop(self) -> FrankaResult<()>;
}
// JointTargetControl is the same with set_joints([f64; 7]) and target() -> [f64; 7],
// and without tune / tuning (below).

The Cartesian target is a pose, absolute in the base frame. set_position moves its position and keeps its orientation (the start orientation until something sets it), set_orientation the other way round, set_target and set_pose set both. Orientations are unit quaternions in [x, y, z, w] order, the scalar part last, or the rotation block of a column-major pose in the convention of O_T_EE. A quaternion or rotation block within 1e-3 of unit or orthonormal is normalised on the way in; one further off is refused. set_joints takes the seven joint positions in radians. Only the latest target counts.

Every setter returns FrankaError::InvalidArgument for a non-finite or malformed value and FrankaError::InvalidOperation once the loop has ended for any reason; is_running() is false then, and stop() has the reason. target() is the latest target, state() the latest RobotState the loop received. While the loop runs it holds the robot’s control lock, so robot.read() and the other loops fail with InvalidOperation.

stop() lands the command on the last target, holds it for 250 cycles (the robot’s echo of it in the robot-controller backend; the loop’s own last setpoint in the impedance backend), sets motion_finished on one more, joins the thread and returns the loop’s result. In the impedance backend the finish also waits for the arm: motion_finished goes out only once every joint moves slower than REST_JOINT_VELOCITY (0.01 rad/s), or after the 5 s timeout, the law kept on the held goal meanwhile, so that an arm still closing its lag is not handed to the robot’s controller short of the target. The result is Ok for a regular end, FrankaError::Control if the robot aborted the motion or the deviation guard fired. A target published just before stop() is not lost. Dropping the handle without stop() requests the stop and detaches: the loop settles and finishes on its own, holding its Arc<Robot> until it has. robot.stop() from elsewhere preempts the loop, and the handle’s stop() then returns the preemption as FrankaError::Control.

Options

TargetControlOptions (Cartesian)defaultJointTargetControlOptionsdefault
limits: OtgLimits, a norm budget0.3 m/s, 0.5 m/s², 20 m/s³limits: Option<[OtgLimits; 7]>None: 20 % of the negotiated version’s joint limits
rotation_limits: OtgLimits, a norm budget0.5 rad/s, 1.0 rad/s², 20 rad/s³
backend: BackendImpedance(ImpedanceOptions::cartesian())backendImpedance(ImpedanceOptions::joint())
controller_mode (robot-controller backend only)CartesianImpedancecontroller_modeJointImpedance
max_deviation0.30 mmax_deviation1.0 rad
max_angular_deviation0.5 rad
settle: Settle (landing tolerance, hold cycles)1 mm, 250 cyclessettle1 mrad, 250 cycles
limit_ratetruelimit_ratetrue
realtime_priority: Option<i32>None (highest)samesame
cpu: Option<usize>None (not pinned)samesame
observernoneobservernone

Every field is public and has a with_* builder; validate() checks the options without starting anything. A Cartesian budget is a norm and each axis gets 1/√3 of it. With the robot’s controller tracking, an FER’s joint-space continuity check refuses 2.5 m/s² near the ready pose and its collision threshold trips above about 1 m/s², so the default 0.5 m/s² sits below both. The continuity check applies to a pose stream, so to the robot-controller backend only; the collision thresholds apply to both. The joint default is slow on purpose; raise it with JointTargetControlOptions::scaled_limits(version, fraction) or explicit limits.

The observer is FnMut(&RobotState, &CartesianSent) (&JointSent for joints), called every cycle on the realtime thread with the state and what was sent: the pose or q after the backstop, the target, the generator’s velocity and acceleration (angular too for a pose), and by how much the backstop altered the command. In the impedance backend pose / q is the loop’s setpoint, the backstop alterations are 0, and the record also carries the joint goal q_goal, its velocity dq_goal, the fraction of the generator’s step the goal carried when a joint limit cut it (cap_scale, exactly 1 when none did) and the joints held on a bound (pinned), the clamped torques tau and the velocity and position envelopes’ shares of them (tau_envelope, tau_position), what the leash took off the desired state this cycle (leash_alteration, m for a pose and rad for joints, plus leash_angular_alteration, rad, for a pose; 0 while the arm follows) and, for a pose, the inverse kinematics residual ik_error (the norm of the position error, m, and the orientation error times rotation_weight), its active-set passes ik_passes and the stall at a joint limit (stall_pressure, stalled). It must not allocate or block; copying into a preallocated ring is what it is for, and how franka_rerun::Recorder::push and the commander example’s CSV log hook in.

realtime_priority sets the loop thread’s SCHED_FIFO priority; None is the highest, as Robot::new uses for its caller. A program with other realtime threads gives the loop a lower one. A failure to raise it is fatal under RealtimeConfig::Enforce and ignored under Ignore, so the simulator runs the loop on an ordinary kernel.

Pinning the loop thread. with_cpu(Some(n)) pins the loop thread to core n with sched_setaffinity right after the priority is raised (franka::realtime::pin_current_thread_to_cpu is the call); None, the default, leaves the placement to the scheduler. On a host that keeps a core free for the loop (isolcpus= on the kernel command line) the loop never migrates and never shares its core with the rest of the program; two arms in one process can each be given a core. A failure to pin is treated like a failure to raise the priority: fatal under Enforce, ignored under Ignore.

Backends

The generator produces a setpoint stream; backend decides who tracks it.

Backend::Impedance(ImpedanceOptions), the default, runs the loop through control_torques and sends, every cycle, the torques of the hybrid joint impedance law that DROID’s controller (polymetis HybridJointImpedanceControl) runs:

Kp  = Jᵀ Kx J + diag(Kq)
Kd  = Jᵀ Kxd J + diag(Kqd)
tau = Kp (q_goal − q) + Kd (g dq_goal − dq) + coriolis(q, dq)
      clamped to ±torque_limits, then low-pass filtered at cutoff_frequency

J is the zero Jacobian at the end-effector frame for the measured q, so the Cartesian gains act at the frame O_T_EE targets are in; q_goal is the generator’s output on the joint interface and, on the Cartesian interface, the joint configuration a differential inverse kinematics finds for the generator’s pose each cycle; dq_goal is that goal’s velocity. Gravity is the robot’s, as in every torque loop. g is 0 by default, DROID’s form: the damping acts on the velocity, and a goal moving at v is tracked Kd v / Kp behind. velocity_feedforward = true sets g to velocity_feedforward_gain (default 1), damping the velocity error instead. It is off because real arms vibrated with it on: dq_goal is a finite difference (of the IK solution on the Cartesian interface), and Kd carries its noise into the torque. velocity_feedforward_cutoff, a first-order low-pass on dq_goal, is the knob to try against that; no value has been validated on hardware. What differs from DROID’s law by default is that the generator is leashed to the arm (below).

ImpedanceOptionsCartesian interface (::cartesian())joint interface (::joint())
gains.cartesian_stiffness Kx (N/m, Nm/rad)750, 750, 750, 15, 15, 150
gains.cartesian_damping Kxd (Ns/m, Nms/rad)50, 50, 90, 2, 2, 20
gains.joint_stiffness Kq (Nm/rad)40, 30, 50, 25, 35, 25, 10600, 600, 600, 600, 250, 150, 50
gains.joint_damping Kqd (Nms/rad)4, 6, 5, 5, 3, 2, 150, 50, 50, 50, 30, 25, 15
torque_limits (Nm)86, 86, 86, 86, 11.5, 11.5, 11.5same
cutoff_frequency (Hz)100100
velocity_feedforwardfalsefalse
velocity_feedforward_gain g, in [0, 1]11
velocity_feedforward_cutoff (Hz)1000 (MAX_CUTOFF_FREQUENCY: off)same
leash: Leash0.025 m, 0.15 rad0.1 rad per joint (the torque clamp, not the leash, bounds the torque: 600 × 0.1 = 60 Nm on joints 1 to 4, under their 86 Nm clamp; on joints 5 and 6 the 11.5 Nm clamp binds first)
project_joint_gainsfalsefalse
posture (IK nullspace reference)None: the start configurationnot used
ik: IkOptionsλ 0.05, nullspace gain 1.0 /s, 3 iterations, tolerance 1e-6 (weighted), rotation weight 0.1 m/radnot used
joint_velocity_fraction (of max_joint_velocity(version))0.70.7
velocity_barrier_fraction0.850.85
joint_position_margin (rad)0.050.05

The Cartesian column, ImpedanceGains::CARTESIAN, is DROID’s preset with the translational damping raised from 37 to 50, 50, 90 Ns/m: a damping ratio of about 0.8 from the arm’s apparent masses at the ready pose (0.94 kg along x and y, 3.9 kg along z, computed from the model), where 37 leaves z at 0.34 and ringing. ImpedanceGains::DROID is the preset as DROID ran it, and with velocity_feedforward off it is the polymetis law for replaying policies trained on it. The joint column is the fer_joint_impedance example’s gains. What each knob does:

  • cartesian_stiffness: how hard the arm pulls back toward the target, per metre and per radian of end-effector error; the arm is a spring of this stiffness to anything that pushes it. cartesian_damping: the resistance to end-effector velocity error, per m/s and rad/s.
  • joint_stiffness, joint_damping: the same in joint space, on every joint including the one direction the Cartesian term cannot see (the elbow’s swing); on the Cartesian interface they regularise the nullspace and, unprojected, also stiffen the end effector (below); on the joint interface they are the whole law.
  • torque_limits: the per-joint clamp on the command, before the filter.
  • cutoff_frequency: the low-pass filter on the torques; MAX_CUTOFF_FREQUENCY turns it off.
  • velocity_feedforward: Kd (g dq_goal − dq) when on, −Kd dq when off, which is the default because real arms vibrated with it on.
  • velocity_feedforward_gain: g, the weight of the goal velocity, when the feedforward is on. At the default 1 the damping acts on the velocity error, so a goal at steady speed needs no lag; lower values trade that lag, (Kqd / Kq) v at 0, against how much of the goal’s own ripple the law forwards.
  • velocity_feedforward_cutoff: a first-order low-pass on dq_goal before it is fed forward. The default 1000 Hz is MAX_CUTOFF_FREQUENCY, the filter off; the floor validate() accepts is MIN_FEEDFORWARD_CUTOFF, 1 Hz. No value has been validated on hardware.
  • leash: how far the desired state may run ahead of the measured one. Every cycle the generator is anchored on the measured pose (the model’s, for the measured q) pulled toward the previous desired by at most the leash, the torque-mode form of the third generator rule: while the arm follows, that is exactly the previous desired and nothing changes; held back by a hand, an obstacle or an unreachable target, the desired stays within the leash of the arm, so the spring force on whoever holds it is limited by the leashed position error. On release the generator resumes from where the arm is, under its budget. The leash keeps acting during the stop’s hold. On the joint interface each joint’s goal stays within leash.joint of the measured joint; there the torque clamp bounds the torque (the JOINT preset’s 600 Nm/rad × 0.1 rad is 60 Nm on joints 1 to 4, under their 86 Nm clamp; on joints 5 and 6 the 11.5 Nm clamp binds before the leash does).
  • project_joint_gains: confines the joint gains to the Jacobian’s nullspace (N Kq N, N = I − J⁺ J), so that the stiffness you set is the stiffness felt at the end effector, to within the damping of the projector and away from singularities. Unprojected, the joint springs are felt through J on top of Kx: at the ready pose the default 750 N/m is felt as about 990 to 1180 N/m in translation (computed on the FER model), and two to three times Kx in rotation. With the projection on, zero Cartesian gains would leave the end effector free.
  • posture: the joint configuration the inverse kinematics drifts toward in the nullspace, at most 0.5 rad/s; None is the configuration the loop started in. A posture inside joint_position_margin of a joint limit is refused with InvalidArgument, as is a joint target there.
  • joint_velocity_fraction: no joint of the goal moves faster than this fraction of the arm’s velocity limit, which narrows toward the joint position limits on the FR3 and is flat on the FER (target_control::max_joint_velocity away from them: the FER’s MAX_JOINT_VELOCITY, the published 2.175 and 2.61 rad/s less libfranka’s tolerance, so 2.13 to 2.15 rad/s on joints 1 to 4 and 2.564 / 2.549 / 2.549 on 5 to 7, or the FR3’s 2.62, 5.26, 4.18, 5.26). A faster step is scaled down as a whole, so the goal keeps its direction and an unreachable or singular pose is approached at a bounded rate; the generator is then re-anchored on what went out, so it never plans from a velocity the arm was not sent. In (0, 1].
  • velocity_barrier_fraction: a joint measured faster than this fraction of its limit meets VELOCITY_BARRIER_GAIN (20 Nm per rad/s) of damping on the excess, at most its torque limit, added before the clamp. It catches what the command cap cannot, a spring-back or an arm overshooting its goal. Below it the law’s torque along a joint’s motion fades out linearly, from min(cap, onset − FADE_BAND × limit) (FADE_BAND 0.15) of measured speed to none at the onset, so a joint catching up on its lag cannot cancel the barrier. At least joint_velocity_fraction, at most 1.
  • joint_position_margin: how far the goal keeps from each joint position limit. Toward a limit the goal brakes to rest on the margin; a joint measured inside it keeps less and less of the law’s torque toward the limit and, 0.02 rad further in, meets a spring pushing it out. On the Cartesian interface the other joints make up what a joint held at its margin cannot, position before orientation (ik.rotation_weight; while the goal is stalled position comes first, to within micrometres): with several joints held, the tool stays put and the turn gets as far as the wall allows, and under a speed limit the orientation lags more. In [0.035, 0.5]. See The joint position limit guard.

Every gain must be finite and non-negative, the leash finite and positive (validate()). The law itself is public as franka::impedance_torques. The options are set through builders:

use franka::{Backend, ImpedanceGains, ImpedanceOptions, TargetControlOptions};

let gains = ImpedanceGains {
    cartesian_stiffness: [400.0, 400.0, 400.0, 15.0, 15.0, 15.0],
    ..ImpedanceGains::CARTESIAN
};
let options = TargetControlOptions::default()
    .with_backend(Backend::Impedance(ImpedanceOptions::cartesian().with_gains(gains)));
// DROID's law as it ran, for replaying policies trained on it: the feedforward the preset
// needs off is off by default.
let parity = ImpedanceOptions::cartesian().with_gains(ImpedanceGains::DROID);

Collision thresholds. Target control does not set collision thresholds; set_collision_behavior configures the robot’s reflexes. Choose thresholds for the task and payload. The leash limits position error, not total contact force: stiffness and velocity-dependent damping both contribute to the commanded torque. On the joint interface, the JOINT preset reaches a 20 Nm threshold at 0.033 rad of error. nonrealtime_commander takes --thresholds N for its collision thresholds.

Hand-guiding and the deviation guard. The guard measures the arm against the start pose (max_deviation 0.30 m, max_angular_deviation 0.5 rad). Compliance lets a person move the arm, and moving it past either bound ends the loop: the target freezes where the command is, the loop finishes at rest, and the robot’s own controller holds the arm where it was left, without a reflex. That is the right default against a runaway commander; a session where the arm is meant to be moved by hand raises both bounds.

Backend::RobotController sends the setpoint stream as poses (control_cartesian_pose) or joint positions (control_joint_positions) and the robot’s own controller, selected by controller_mode, tracks it: what target control did before the impedance backend existed.

let options = TargetControlOptions::default().with_backend(Backend::RobotController);

What changes between the two:

  • Compliance. The impedance backend is a spring: push the arm and it gives way by about force / stiffness (10 N against the default 750 N/m is about 1 cm, the unprojected joint term making the arm somewhat stiffer than Kx) and returns when released; the robot’s controller holds the pose stiffly. Hold the arm and the desired pose stops within the leash of it, limiting the position error that drives the spring. When released, the generator resumes from where the arm is.
  • No joint-side continuity refusals. The robot checks the inverse kinematics of a pose stream against its joint limits and refuses a stream whose joints would accelerate too fast (see Online trajectory generation). Torques are not checked that way, so the budget can be raised further in the impedance backend, within the collision thresholds.
  • The deviation guard applies to both. The measured pose is compared with the start in either backend; a compliant arm pushed past max_deviation ends the loop the same way.
  • Collision thresholds apply to both, and are yours to set. The robot’s contact and collision reflexes watch the external torques and forces whatever commands them; a compliant arm meeting an obstacle still trips them at the thresholds set_collision_behavior sets, and a spring reaches them by deflection (the paragraph above).
  • An unreachable or singular target lags in the impedance backend, because no joint of the goal moves faster than joint_velocity_fraction of its limit or closer to a limit than joint_position_margin, and the leash holds the desired pose near the arm; the robot’s controller refuses a pose stream it cannot follow.
  • The finish waits for the arm. stop() in the impedance backend sets motion_finished only once every joint is slower than REST_JOINT_VELOCITY, or after the 5 s timeout.

Tune the law while it runs

A Cartesian session on the impedance backend takes changes to its gains, its inverse kinematics and its two budgets while the arm moves:

use franka::robot::target_control::TuningUpdate;

let mut update = TuningUpdate::default();
update.cartesian_stiffness = Some(400.0);
update.budget = Some([0.2, 0.4, 20.0]);
let clamped = control.tune(&update)?;   // any thread, any rate; the bounds that had to clamp
let targets = control.tuning()?;        // the targets in force

TuningUpdate is #[non_exhaustive], so start from Default and assign what the update carries; a field it does not carry keeps its value and is not re-checked. The nine fields are joint_stiffness[7], joint_damping[7], cartesian_stiffness, ik_damping, ik_nullspace_gain, velocity_feedforward_gain, velocity_feedforward_cutoff, budget[3] and rotation_budget[3]: 25 scalars, together a LiveTuning. One Cartesian stiffness stands for all twelve Cartesian gains, the six stiffnesses and six dampings ImpedanceGains::scaled_cartesian derives from it, which is what holds the preset’s damping ratio. Only this kind of session has live tuning. tune and tuning return InvalidOperation with NO_TUNING_MESSAGE on the robot-controller backend, and with DERIVED_GAINS_MESSAGE on a Cartesian session whose gains one stiffness cannot rebuild (ImpedanceGains::DROID is such a preset). tune alone also returns ENDED_MESSAGE once the loop has ended; tuning has no such check and still reports the last published targets. JointTargetControl has neither method: a joint session runs the options it was started with for its whole life.

The bounds. LiveTuning::BOUNDS is one FieldBound per scalar: name, unit, min, max, group, the transition policy and the presentation hints (log_slider, slider_max, off_at, norm). It is a second and tighter gate than ImpedanceOptions::validate, which asks only whether a session can be built at all and has to stay permissive for every caller of the crate; BOUNDS asks how far a running session may be moved by hand. The table is the clamp itself, so a caller that publishes it — the node serves its schema from it — cannot drift from what is enforced.

An update is all or nothing and then clamped. A value that is not finite, or a zero where zero means something other than “softer” (cartesian_stiffness, ik_damping), is refused with InvalidArgument naming the field and nothing is written. Anything out of range is clamped instead, and the bounds that had to clamp come back so that a caller can show the value it will really get; read that value out of tuning(), not out of the bound. Raising a joint spring also raises any joint damping below LiveTuning::joint_damping_floor of it (0.25 √K), even one the update does not carry: the pair, not either half, is what has to stay damped. FieldBound::danger is advice for whoever publishes the table, not a gate this crate applies — TuningDanger::Advise on the fields that reach the torque directly, and TuningDanger::ConfirmAbove(v) on the ones that widen what the arm may do above v, both budgets and velocity_feedforward_gain above zero.

Reaching the loop. tune validates, clamps and publishes the whole set on the calling thread, allocating as it goes, into a single-writer seqlock beside the target slot. The loop takes a complete snapshot or keeps the previous one, never blocking and never seeing half an update; the slot holds the latest value rather than a queue, so two updates between cycles collapse into one. Live parameter protocol draws that slot and defines the node’s JSON layer on top of it.

Crossing to it. What is published are targets, and the loop moves its own copy toward them under each field’s TuningPolicy. A parameter that multiplies a generally-nonzero state would be a step in the torque, so the gains, the IK fields and the feedforward gain slew over SLEW_TAU, 0.3 s. The feedforward cutoff and both jerks step. A budget’s velocity and acceleration are raised on the cycle they arrive and lowered as a ramp at the next order’s limit (TuningPolicy::StepUpGateDown), because a narrower budget clamps the generator’s stored state rather than re-planning it; the fourth OTG rule is what Otg::set_limits owes a running generator. TuningPolicy::remaining reads how much of a crossing is still to come off the clock, so nothing has to be published back out of the realtime thread. The command never steps, but lowering an acceleration or jerk while the arm moves lengthens the stop toward v² / 2a: lower the velocity first, apply it, then the rest.

What the loop does every cycle

  1. Anchor. The first cycle takes the start, first target and first setpoint from the robot’s echo of its commanded pose or joints (O_T_EE_c, q_d) in the robot-controller backend, and from the measured state in the impedance backend, where no echo of the command exists: the measured q, and for a pose the model’s pose of that configuration rather than O_T_EE, so the inverse kinematics starts with a zero residual; q_goal starts at q. Every later cycle of the impedance backend anchors on the measured state pulled toward the previous desired by at most the leash.
  2. Read the slot. The latest target comes through a single-writer seqlock the loop polls without blocking; a torn read keeps the previous target for one cycle.
  3. Generate. One synchronised jerk-limited generator over all axes, per-axis limits, one nominal millisecond per command, re-anchored before every re-plan on the echo (robot-controller backend) or on the leashed anchor (impedance backend).
  4. Track. Impedance backend: the inverse kinematics step for a pose inside the joint limits’ box, or the joint goal’s step scaled into it; the law with its torque along a fast joint’s motion and toward a near limit faded, plus the velocity barrier and the position spring, the clamp, Torques. A goal that falls short restarts the generator from what went out. Robot-controller backend: the rate limiter under the same budget, then the loop’s own libfranka limiter; neither is meant to bind, and the observer sees when one does.
  5. Guard. If the measured pose strays past max_deviation or max_angular_deviation from the start, the target freezes and the loop ends with FrankaError::Control.
  6. Land, hold, finish. After stop(): run until every axis is within settle.tolerance of the target and at rest, hold the last command for settle.cycles, then motion_finished; if the generator has not landed within five seconds, hold from where the command is. The impedance backend finishes only once the arm itself is at rest (REST_JOINT_VELOCITY), or after five more seconds.

Why each step is what it is, and what goes wrong without it, is in Online trajectory generation; the impedance law, its inverse kinematics and its provenance are in The impedance backend.

Examples and Python

examples/nonrealtime_commander.rs drives this loop from a scripted commander that steps the target by ±5 cm with irregular holds, a 2 s stall and a burst of 20 targets in 100 ms (--stdin reads x y z lines instead; --rotate adds a ±15° yaw sweep through set_orientation; --log PATH writes one CSV row per cycle from the observer). Its --raw mode sends the same steps to a bare control_cartesian_pose so the robot refuses the first one, for contrast. crates/franka-rerun/examples/commander_live.rs is the same commander streamed into a Rerun viewer as it runs. Python’s move_to, move_by and follow are this loop; see From Python.

Target control works on the FR3 (FCI v10) and the FER (FCI v5) with either backend; the impedance backend is not yet validated on an FR3. See Backends.

Use the gripper

At the end of this page you have homed a Franka Hand, read its state, moved the fingers and grasped an object. The hand has its own connection on TCP port 1338 of the robot’s host, separate from the arm’s, with its own handshake and UDP state stream.

extern crate franka;
use franka::Gripper;

fn main() -> franka::FrankaResult<()> {
let gripper = Gripper::new("172.16.0.2")?;
println!("gripper server version {}", gripper.server_version());

gripper.homing()?;                       // estimates max_width; needed after changing fingers

let state = gripper.read_once()?;
println!("width {} of {}, {} C", state.width, state.max_width, state.temperature);

gripper.move_gripper(0.08, 0.1)?;        // width [m], speed [m/s]
let grasped = gripper.grasp(0.03, 0.1, 40.0, 0.005, 0.005)?;   // width, speed, force [N], eps
if grasped {
    println!("holding it");
}
gripper.stop()?;
Ok(()) }

Gripper::new takes "host" or "host:port"; port 1338 is used unless one is embedded. It fails with FrankaError::Network if the connection cannot be made and FrankaError::IncompatibleVersion if the server does not speak the crate’s gripper protocol version (3). The protocol is byte-identical on FCI v5 and v10, so the same code drives a Franka Emika Robot’s hand and an FR3’s.

The commands

methodwhat it does
homing()Homes the gripper and estimates the maximum grasping width. Required after changing the fingers.
move_gripper(width, speed)Moves the fingers to width metres at speed m/s. Named move_gripper because move is a Rust keyword.
grasp(width, speed, force, epsilon_inner, epsilon_outer)Grasps at force newtons. An object counts as grasped when the finger distance d satisfies (width - epsilon_inner) < d < (width + epsilon_outer). libfranka’s C++ overload defaults both epsilons to 0.005; Rust has no default arguments, so both are explicit.
stop()Stops a running move or grasp.
read_once()Drains stale datagrams, then blocks for one fresh GripperState.

The four commands return FrankaResult<bool>: false means the gripper reports the command did not achieve its goal (a grasp that closed on nothing); an error means the command failed or was aborted, or the connection was lost.

GripperState

fieldmeaning
width: f64Current opening width, m.
max_width: f64Maximum opening width, m, estimated by homing().
is_grasped: boolWhether an object is currently grasped.
temperature: u16Gripper temperature, °C.
time: DurationStrictly monotonic timestamp since the gripper server started.

The wire struct is 23 bytes; its size and every field offset are asserted against libfranka’s in tests/wire_sizes.rs.

Threading

Gripper’s network layer is Sync, matching libfranka’s documented guarantee for franka::Gripper. The one caveat: only one read_once() should be in flight at a time, since concurrent callers would race to claim the same UDP datagram.

Notes

  • Check that the hand is on the FCI. A hand not connected through the robot’s gripper server leaves port 1338 closed; Gripper::new then fails with FrankaError::Network (“Connection to FCI refused …”), even with FCI enabled.
  • examples/grasp_object.rs is the port of libfranka’s example of that name (grasp_object <robot-hostname> <object-width>, always homing first) and the quickest end-to-end check of a hand.
  • Against franka-sim a successful grasp needs an object between the fingers: the FR3 image takes --gripper-object-width; the FER image has no such option (see Simulator gaps).

Record and replay a run

At the end of this page you can answer the question every reflex raises: what happened in the seconds before? Which joint reported contact, how large was the external wrench, was the command running away from the measurement, which error fired. The control log of every ControlException holds the raw material; the workspace crate franka-rerun turns it into a Rerun recording, and records the same picture live while a loop runs.

Prerequisites: a rerun viewer of exactly the version the crate pins, 0.37.1 (cargo install rerun-cli --locked --version 0.37.1 or pip install rerun-sdk==0.37.1); RUST_LOG=warn rerun keeps its notification toasts to warnings. franka-rerun needs Rust 1.96: cargo install franka-rerun --locked for the binary, cargo add franka-rerun for the library.

Keep a longer control log

Robot::new keeps the last 50 cycles (franka::DEFAULT_LOG_SIZE, libfranka’s default) of state and command in a ring and hands them over as ControlException::log (Vec<Record>, newest last, Record { state, command: Option<RobotCommandLog> }) when a motion ends abnormally. Fifty milliseconds shows the step behind a rate-limit reflex, not the approach that ended in a collision. Make the ring longer:

extern crate franka;
use franka::{Robot, RealtimeConfig, RobotOptions};
fn main() -> franka::FrankaResult<()> {
// Three seconds at 1 kHz; each record is a RobotState plus a command, about 2.5 KiB.
let robot = Robot::new_with_log_size("172.16.0.2", RealtimeConfig::Enforce, 3000)?;
// Or, through the options builder:
let robot = Robot::with_options(
    "172.16.0.2",
    RobotOptions::new(RealtimeConfig::Enforce).with_log_size(3000),
)?;
Ok(()) }

The ring is sized once, so a large log costs memory, not cycle time.

Save it: the serde feature

The optional serde feature of franka-rs (off by default; franka-rs = { version = "0.4", features = ["serde"] }) derives Serialize and Deserialize for RobotState, RobotMode, Errors, Duration, Record, RobotCommandLog, MoveStatus and ControlException. Two representation choices: Errors serialises as the list of the set flags’ names in libfranka’s order (["joint_reflex", "cartesian_reflex"], [] when none is set), not as 41 booleans, and a name outside franka::ERROR_NAMES fails to deserialise; Duration (RobotState::time) serialises as the bare millisecond count. Everything else is field by field under the libfranka names. A Vec<Record> written with serde_json is what franka-rerun calls a saved control log.

Replay a reflex

use franka::FrankaError;
use franka_rerun::{flight, FlightOptions, RobotKind};

match robot.control_joint_positions(callback, mode, true, cutoff) {
    Err(FrankaError::Control(e)) => {
        let kind = RobotKind::from(robot.fci_version());
        let summary = flight::replay_exception(
            "reflex.rrd".as_ref(), &e, &model, kind, &FlightOptions::default())?;
        println!("{summary}");
        flight::save_records("reflex.json".as_ref(), &e.log)?;
        robot.automatic_error_recovery()?;
    }
    other => other?,
}

replay_exception writes e.log with e.last_motion_errors as the closing motion aborted: ... line and returns a flight::Summary (rising edges per flag family, error and mode changes, peak |F_ext| and |tau_ext|). save_records / load_records move the log through JSON, and the binary replays a saved one later:

cargo run --release -p franka-rerun -- log reflex.json --robot fer -o reflex.rrd
rerun reflex.rrd

(franka-rerun csv bridged.csv --robot fr3 -o bridged.rrd replays the CSV of nonrealtime_commander --log.) The recording opens with the 3D scene on the left, the plots on the right and the event log along the bottom, all on the robot_time timeline, the robot’s own clock in seconds. --prefix NAME puts every entity below under NAME, as a node does with an arm’s name, so that two replays can be routed into one recording:

entitycontent
joints/q, joints/q_dmeasured joint positions and the commanded ones (the sent q_c for a joint-position motion, else the robot’s q_d)
joints/dq, joints/tau_J, joints/tau_J_d, joints/tau_extvelocities, measured and desired torques, tau_ext_hat_filtered
joints/q_goal, joints/dq_goal, joints/cap_scale, joints/tau_envelopea live recording of target control’s torque backend (Recorder::push_torque_at, which franka-node uses): the joint goal, its velocity, the fraction of the generator’s step the goal carried when a joint limit cut it (exactly 1 when none did), the velocity envelope’s torque: the barrier’s, less the law’s torque faded out along a joint’s motion above the fade’s start
joints/pinned, joints/tau_position, ik/stall, ik/passesthe same recording’s joint position limit guard: per joint the bound the goal was held on (0 free, ∓1 lower / upper position, ∓2 velocity), the position envelope’s torque (the spring less the law’s torque faded toward a limit), the stall pressure and flag, the IK’s active-set passes (see the guard)
ik/step, ik/blend, ik/held, ik/errorthe same recording’s solve: the largest raw step norm of the cycle’s solves and how much of it the box clipped off (rad), the position-first blend, whether the goal was held (the IK stalled, or a wall standing) and how long since each of the two blocks was last pushed on a wall (−1 for none), and the weighted residual (see a self-motion the law cannot see)
ee/F_extO_F_ext_hat_K, force in N and torque in Nm
ee/positionmeasured O_T_EE against commanded O_T_EE_c, translation only; per-axis plots and the derivatives of the sent position when the command was a Cartesian pose
ee/orientationthe rotations of those two poses as quaternions xyzw, every cycle (the 3D scene’s pose is decimated by --every)
ee/orientation/commandedthe rotation of the pose the command carried, as a quaternion xyzw: the only orientation a torque-backend recording has, the robot’s own O_T_EE_c echo being zero there
ee/velocity, ee/leashthe generator’s end-of-cycle twist (m/s, rad/s) and what the leash took off the anchor (m, rad)
flags/*joint_contact, joint_collision (7 series each), cartesian_contact, cartesian_collision (6 each) as 0/1; contact amber, collision red
world/*the arm from Model::pose_q, a sphere per joint that turns amber on contact and red on collision and grows with |tau_ext|, the external force as an arrow from the end effector (1 cm per N by default), the end effector axes; Franka’s link meshes (built in; --meshes DIR for other files, --no-meshes for none)
world/contact/*, contact/linkwhere the seven external joint torques say the arm was touched, and the force there
eventsevery change of current_errors and robot_mode, the first rising edge of every flag (joint 4 contact, cartesian collision on Fz), and motion aborted: <names> at the end

Record live

franka_rerun::Recorder streams the same picture from inside a running loop:

use franka_rerun::{Recorder, RecorderOptions, RobotKind};

let recorder = Recorder::to_file("run.rrd".as_ref(), model, kind, RecorderOptions::default())?;
// or Recorder::to_viewer("127.0.0.1:9876", ...) with `rerun` already running,
// or Recorder::to_viewer_and_file(...), or Recorder::spawn(...) to start one from PATH.
robot.control_joint_positions(
    |state, period| {
        let output = /* ... */;
        recorder.push(state, Some(RobotCommandLog { q_c: output.q, ..Default::default() }));
        output
    },
    mode, true, cutoff,
)?;
let stats = recorder.finish()?;   // Stats { pushed, dropped, summary }

push runs on the realtime thread, so it must be cheap: it reads the host’s CLOCK_MONOTONIC and copies the record into a bounded std::sync::mpsc::sync_channel of 4096 records (four seconds at 1 kHz) with try_send, which neither blocks nor allocates (the ring is allocated once, with the recorder), and drops the record, counted in Stats::dropped, when the ring is full. Every row it writes carries that host stamp as a second timeline, host_time: two robots are two controllers, so their robot_times are unrelated, and host_time is the axis a recording of both is synchronised on. An offline replay has no host clock for a record and writes robot_time alone. A background thread drains the channel every 100 ms and does all the Rerun work, the 3D scene decimated to every 10th record. crates/franka-rerun/tests/flight.rs checks the no-allocation claim with a counting allocator around a 1 kHz producer. For a loop the crate runs for you, the observer of target control is the hook.

The examples

  • crates/franka-rerun/examples/reflex_replay.rs: a 3000-cycle log, collision thresholds from --force / --torque (default 10 N / 10 Nm, contact at half), a Recorder to <out>/run.rrd or --live ADDR, then joints 4 and 6 swing by 0.2 (1 - cos(2π t / 12)) for --seconds (60) while you push. On the reflex it writes reflex.rrd and reflex.json, recovers, and finishes the recorder.
  • crates/franka-rerun/examples/commander_live.rs: the commander of nonrealtime_commander on target control, streamed into an open viewer (--live ADDR, --out FILE, --meshes DIR, --bridged or --raw).

Status

Everything above is exercised against synthetic logs in crates/franka-rerun/tests/flight.rs.

Known limitation. Recorder::finish() joins the background thread, which ends with the stream’s flush_blocking(); neither has a timeout (crates/franka-rerun/src/recorder.rs, flight/logger.rs). With to_viewer and a viewer that is not reachable, finish() can therefore hang; Rerun’s Python SDK gives up after a few seconds instead. Dropping the recorder without finish() closes the channel and does not wait. Record to a file when the viewer may not be up.

Serve arms over Zenoh: franka-node

At the end of this page a small machine next to the robots runs franka-node, and a program on another machine, in any language with a Zenoh binding, takes an arm, streams pose or joint targets to it and reads its state, without linking franka-rs or running a realtime kernel itself. The crate is franka-node, under crates/franka-node in the repository; its README has the byte-exact wire tables, the verb table and every configuration key. This page is the guided tour.

Prerequisites: Command from a low-rate program, whose loop the node runs; a host on the robot network that meets The realtime machine (a Raspberry Pi 5 with a PREEMPT_RT kernel does); and the simulator or a robot.

What it is

franka-node is one process that owns one or more franka::Robots and, per arm, runs start_cartesian_target_control or start_joint_target_control with the impedance backend. Clients publish 80-byte targets at any rate on a Zenoh key; the node gates each one (lease holder, order, kind, finite values, step from the previous accepted target, joint limits, rate, and a workspace box if the config names one) and hands it to the library’s generator, which does the 1 kHz work on the realtime thread the library owns. The arm’s state goes back out at 100 Hz as a fixed-layout message, and acquire, enable, stop, release, recover and home are request/reply queryables with JSON payloads.

A client holds an arm through a lease: a Zenoh liveliness token it declares while it lives. The token going away, because the client exited, was killed or lost the link, stops the arm; so does silence: no accepted target for hold_after_ms (200 ms) flags the state as holding, no target at all for stop_after_ms (2 s) stops the loop, and the robot’s own controller holds the arm where it is. A second client is refused while the first holds the lease.

The data path

Zenoh targets pass through a bounded queue to one arm thread per robot. The arm thread checks targets and updates the local franka-rs 1 kHz control loop. Torques go to the robot; measured state returns through the arm thread to the client.
The network and arm-management threads stay outside the realtime loop. Open full-size diagram.

The Zenoh callbacks decode a fixed layout with zerocopy and send without blocking; the arm thread drains its channel, runs commands and lease events in arrival order and judges only the newest target of a drain (the rest count as dropped). The realtime loop never sees Zenoh, the channel or an allocation from the node; the node never sees the robot’s UDP stream. This is the same design as the library’s own target control, one process boundary further out.

When to use it, and when the library

Use the node when the commander is not on the realtime machine (a laptop over Wi-Fi, a workstation running a policy, a notebook), when it is not written in Rust or Python, when several programs take turns on one arm, or when one small host should serve two arms to the rest of the lab. Its costs are a network hop per target (the state echoes the accepted target’s t_send_ns, so a client measures its own round trip; the client example prints it) and one more process to run.

Use the library directly, target control or the Python bindings, when the commander runs on the realtime machine anyway, needs the RobotState at 1 kHz or an observer on the loop, or wants the robot’s controller (Backend::RobotController), which the node does not offer.

Configuration

One TOML file; every key has a default except an arm’s name and host:

name = "node1"

[zenoh]
listen = ["tcp/0.0.0.0:7447#iface=eth0"]
scouting_interface = "eth0"          # optional; "auto" when absent

[[arm]]
name = "fr3"
host = "172.16.0.2"
realtime = "enforce"                 # or "ignore" without a PREEMPT_RT kernel
collision_force = 40.0               # N; set_collision_behavior_simple(torque, force)
collision_torque = 40.0              # Nm
budget = [0.3, 0.5, 20.0]            # translation limits, a norm: m/s, m/s², m/s³
max_step = 0.05                      # m per target from the previous accepted one
max_lead = 0.05                      # m a target may lead the measured pose; 0 disables
# workspace = { min = [0.2, -0.5, 0.0], max = [0.8, 0.5, 0.8] }   # off unless set
# cpu = 2                            # pin the loop thread to this core (isolcpus)

name is the node’s, in its status key; the arm’s name is the <arm> of every key (franka/fr3/target, franka/fr3/state, …). A second [[arm]] table is a second arm in the same process, each with its own thread, loop and, with cpu, core. joint_position_margin (0.05 rad) is how far the arm’s joint goal keeps from its joint limits, and a joint target inside it is refused (the guard). The full key list with the stiffness, leash, deviation and joint keys is in the README and in crates/franka-node/config.example.toml, which the config tests parse.

Running it

cargo run --release -p franka-node -- node.toml
RUST_LOG=debug cargo run --release -p franka-node -- node.toml    # refusals, with their reasons

Transitions (idle -> acquired (acquired by 7)) are logged at info, refusals at debug. SIGINT or SIGTERM stops every arm through the library’s settle and exits. Under realtime = "enforce" the node constructs each robot on a scratch thread, so Robot::new’s SCHED_FIFO promotion lands nowhere that outlives it: the node’s own threads stay at normal priority and only the library’s loop thread is realtime.

The client

examples/client in the crate is a commander for the node: it declares its lease, acquires and enables the arm, moves the end effector along a sine in z (or joint 7, with --mode joints), prints the node’s report once a second and stops and releases at the end; client <arm> home drives the arm to the ready pose, client <arm> recover|stop|release sends one verb, and client <arm> gripper ... commands the arm’s gripper.

cargo run --release -p franka-node --example client -- fr3 --connect tcp/<node-host>:7447 \
    [--hz 20] [--dz 0.05] [--seconds 4] [--mode joints]
cargo run --release -p franka-node --example client -- fr3 home --connect tcp/<node-host>:7447

Its common.rs is the client side of the keys in about 200 lines; the simulator test tests/sim_node.rs shares it and is the reference sequence: token, acquire (retried while the token is still in flight), enable, targets, stop.

From Python

pip install franka-node-client installs the client of the node’s version (from a checkout, python -m pip install ./crates/franka-node/python). It imports as franka_node: pure Python over eclipse-zenoh and numpy, so it runs wherever those do, macOS and Windows included. This takes an arm, raises the end effector by 5 cm, waits until it is there and opens the gripper:

import franka_node

node = franka_node.Node("tcp/<node-host>:7447")         # or Node(session=my_zenoh_session)
with node.arm("fr3") as arm:                              # lease token + acquire, release on exit
    with arm.cartesian_targets() as t:                    # enable; stop on exit
        t.move_by([0.0, 0.0, 0.05])                       # +5 cm z, relative to the goal
        state = t.wait(timeout=5.0)                       # until the arm is there
        print(state.position, state.quaternion, state.refused)
    arm.gripper.move(0.04)

The simulator test crates/franka-node/python/tests/test_sim_node.py runs the same sequence in CI. with node.arm(...) declares the lease token under a random client id of the Node, acquires (retrying while the token is still in flight) and on exit stops this client’s session, releases the arm if this client still holds it and undeclares the token; a second client’s acquire raises Refused with the node’s "held by <id>". The verbs and conventions are those of the Python bindings: quaternions x, y, z, w, rotation vectors in the base frame, move_to, move_by, follow(chunk, dt), target(), state() and stop().

A session runs one streamer thread. Every 1 / rate s (50 Hz) it publishes one target, moved toward the goal by at most max_velocity / rate (0.1 m/s, 2 mm per message) and kept within lead (0.03 m) and angular_lead (0.15 rad) of the measured pose; at the goal it repeats the target, which is the keepalive hold_after_ms and stop_after_ms need. move_to, move_by and follow only set the goal and never block. wait() returns the state once the node has accepted a target equal to the goal and the arm is within 5 mm and 0.05 rad of it (0.01 rad per joint in a joints session). Every Cartesian target carries the anchor flag. When the node ends the session (phase no longer active, another holder after a lost lease or a node restart, or someone’s stop), every later call raises SessionEnded, a wait() already blocked included; the client never re-acquires on its own. A node that publishes no state for three state periods, and at least 2 s, while the streamer itself keeps running ends the session the same way, with phase "unknown". A rising refused count, targets and gripper commands together, is logged at WARNING on the franka_node logger at most once a second; the reasons are in the node’s debug log.

A streamer starved of the GIL for longer than stop_after_ms ends the session. Lower lead on a high-latency link: the node checks the lead against a newer pose than the client has. A Faulted arm needs arm.recover() before acquire or a new session.

with node.arm("fr3") as arm:
    arm.home(speed=0.2)                                   # blocks until the ready pose
    with arm.joint_targets(max_velocity=0.5) as t:        # rad/s per joint
        t.move_by([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1])    # joint 7 by 0.1 rad
        t.wait(timeout=5.0)
    arm.gripper.homing()
    held = arm.gripper.grasp(0.02, 20.0)                  # True when an object is held

A target is refused, counted and logged rather than executed when it steps more than max_step from the previous accepted one, leads the measured pose by more than max_lead, leaves the workspace box a config asked for, or comes from a client that does not hold the lease. The client’s defaults stay inside the node’s: 2 mm per message against max_step 0.05 and under the 0.173 m/s per-axis budget, lead 0.03 m under max_lead 0.05, and in a joints session at most 0.15 rad per message against max_step_joint 0.2; raise max_velocity or lead only with the node’s limits.

max_step is a distance per message, not a speed, so a commander faster than the arm would otherwise walk the target ahead of it without limit and the arm would coast the whole lead when the commander stopped; max_lead is what bounds that, and the client’s lead keeps its stream under it. The anchor flag is the way back in when a stream has jumped far enough that every later target is refused: that one target’s step is measured from the arm’s measured pose instead of from the previous accepted target. It lifts no limit – an anchored target must still be within max_lead of the arm – which is why the client sets it on every message, and with max_lead = 0 the flag is ignored rather than left unbounded. A joints session has no anchor, so the client re-seeds its target from the node’s last accepted one when a refusal comes back.

A commander whose targets are all being refused is held but not stopped: hold_after_ms measures from the last target the guard accepted, stop_after_ms from the last one that arrived from the lease holder. A commander outrunning the arm, parked at the workspace wall or pushing against a hand is alive, and stopping its session would cost it the loop for the length of the settle.

A client in another language speaks the byte layouts of the README’s Wire messages; crates/franka-node/tests/wire.json is the same layout, machine-readable.

Installing

The node is released from 0.4.0 on; the client on the application machine is pip install franka-node-client of the same version.

cargo binstall franka-node             # prebuilt, from the GitHub release; no compiler needed
cargo install franka-node --locked     # or compile it where it runs, a Raspberry Pi included

From a checkout, install both from the repository root with cargo install --path crates/franka-node --locked and python -m pip install ./crates/franka-node/python; the Pi guide’s Node and laptop client has the full source path.

cargo binstall fetches franka-node-<version>-<target>.tar.gz from the release: aarch64-unknown-linux-gnu (glibc 2.31 or newer, so Raspberry Pi OS Bookworm), the static aarch64-unknown-linux-musl and x86_64-unknown-linux-gnu, each built with record. A compiled cargo install needs Rust 1.89 and records only with --features record (Rust 1.96). Without cargo, unpack the tarball itself, which also holds config.example.toml and the systemd unit below:

tag=$(curl -fsSLo /dev/null -w '%{url_effective}' https://github.com/BarisYazici/franka-rs/releases/latest)
tag=${tag##*/}
curl -fsSL https://github.com/BarisYazici/franka-rs/releases/download/$tag/franka-node-${tag#v}-aarch64-unknown-linux-gnu.tar.gz | tar -xz

franka-cam is released the same way.

Building for a Raspberry Pi

From a checkout, the node cross-compiles like the rest of the workspace (Build for another machine):

cargo zigbuild --release --target aarch64-unknown-linux-gnu -p franka-node \
    --bin franka-node --example client

The result is target/aarch64-unknown-linux-gnu/release/franka-node and .../examples/client, dynamically linked against glibc; that page’s system cross compiler gives the same. On the Pi the same realtime prerequisites apply as for any program using the crate: a PREEMPT_RT kernel and the limits Franka’s realtime setup recommends (rtprio, memlock), which the unit below sets for the service.

The systemd unit

crates/franka-node/deploy/franka-node.service runs the node as a service: after network-online.target, restarted two seconds after a failure, with LimitRTPRIO=99 and LimitMEMLOCK=infinity (the limits Franka’s realtime setup recommends), stopped with SIGINT so every arm goes through its settle before the process exits (TimeoutStopSec=20: the arms stop one after the other, each within the library’s 5 s stop timeout). Set User= to the account that runs the node (Group= is realtime; an unedited copy refuses to start), install the binary at /usr/local/bin/franka-node and the config at /etc/franka-node/node.toml (from a release tarball, or deploy/README.md’s steps), then:

sudo systemctl enable --now franka-node
journalctl -u franka-node -f

deploy/README.md next to the unit has the copy steps. cargo binstall unpacks only the binary, so a binstall install has no unit on disk: take it from a checkout, or unpack the release tarball above for the copy beside the binary. The Pi guide’s service section installs the binary, the configuration and the unit in one block.

Status

A commander that is not on the robot’s network needs no inbound rule: set mode = "client" in the [zenoh] table and name a router in connect, and the node dials out instead of listening. Use plain TCP only on a trusted network. For other deployments, use a tls/ endpoint and set zenoh_config to a Zenoh configuration containing certificates, authentication, and access controls. Link delay still affects application responsiveness. The node handles a lost lease or a watchdog timeout locally with a controlled stop, which may finish at the last accepted target; this does not replace the physical stop device.

The node publishes its own health on franka/node/<name>/status once a second as JSON:

{"node": "node1", "version": "<node version>", "uptime_s": 61,
 "arms": [{"name": "fr3", "phase": "active", "holder": 7, "mode": "cartesian",
           "accepted": 1210, "refused": 0, "dropped": 0, "decode_failures": 0,
           "success_rate": 0.99}]}

The fields are in the crate README; a stalled arm thread shows as a frozen entry, a missing sample means the node is gone.

Live tuning

A Cartesian impedance session can change gains and motion budgets while it runs. Tune a running controller explains the optional panel, the feedforward bool/gain interaction and the lifetime of a change. Live parameter protocol is the reference for the params/* Zenoh keys. A two-arm configuration keeps the shipped defaults and shows optional CPU pinning and Franka Hands.

Grippers

An arm’s [[arm]] table may name a gripper, gripper = "hand" for the Franka Hand at the arm’s host. The node then takes 40-byte width or grasp commands on franka/<arm>/gripper/target from the arm’s lease holder, in any phase (the arm need not be enabled), publishes the gripper’s width, commanded width, stroke and flags on franka/<arm>/gripper/state at 20 Hz, and answers cmd/gripper_home (the holder’s, when the homing is over) and cmd/gripper_stop (anyone’s); the status gains a gripper entry. The arm’s own stop, watchdog and lease loss leave the gripper where it is. Widths are metres throughout; a 0..1 convention is the client’s. Inside, the driver is a small Gripper trait the arm thread owns, and franka_node::run takes a factory that maps the config’s name to a driver, so a binary of your own can add one for another gripper and keep the rest of the node. The README’s Grippers section has the wire tables, the verb rules and the Hand driver’s behaviour; client <arm> gripper 0.04, gripper grasp 0.02 20 and gripper home in the client example exercise them.

Recording

Built with --features record and given record_dir, the node writes every session (an enable, a home) as one Rerun .rrd, <arm>-<UTC stamp>.rrd: the flight recording of every 1 kHz cycle and the command sent, plus the targets the node accepted and refused, opened and closed with the loop and named in the status’s recording. Every entity is under the arm’s name, and every row carries robot_time and host_time, the host’s CLOCK_MONOTONIC where the record was taken.

enable and home take "episode": "<name>" ([A-Za-z0-9_-]{1,128}). With one, the session’s recording id is that name and the arm’s file is <name>-<arm>.rrd, so two arms enabled with the same name write two files a viewer loads as one recording, synchronised on host_time – the only clock the two controllers share. The client example passes it with --episode NAME. The README’s Recording section has the details and how to open a file.

Either way, recording or not, every session’s start and end go out on franka/<arm>/episode as one JSON line carrying the session’s recording_id and, when there is one, its file name. With a recording that id is the Rerun RecordingId of the arm’s .rrd – the episode name, or the file’s stem without one; without a recording it is generated in the same shape. A process that records something else at the same time, a camera for instance, writes its own file under that id and the application id franka_rs, and a viewer loads the two as one recording. Both samples are best effort, as every publisher here drops rather than blocks, so read whether a session is running from the state topic’s phase.

What runs on hardware and what is tested on franka-sim only is in the README’s Status section.

Teleoperate with a Quest: franka-vr-teleop

franka-vr-teleop drives an arm served by franka-node from a Meta Quest controller. It is two processes on the machine beside the robot: franka-vr-bridge reads the headset over adb and publishes each controller on its own ZMQ channel, and franka-vr-teleop follows one channel and drives one arm, with a clutch on the grip. A second franka-vr-teleop drives a second arm from the other hand. From a checkout, install both packages with pip install ./crates/franka-node/python ./tools/vr-teleop.

pip install franka-vr-teleop                 # the bridge and the teleop client
franka-vr-install-apk                        # the headset app, fetched and hash-checked
franka-vr-bridge --controller l,r --endpoint tcp://127.0.0.1:5560,tcp://127.0.0.1:5570
franka-vr-teleop --arm L --probe-limits --connect tcp/127.0.0.1:7447   # nothing acquired
franka-vr-teleop --arm L --client-id 7201 --connect tcp/127.0.0.1:7447

The client reads the node’s limits from the node before it acquires anything, clamps every target to 25 mm and 0.15 rad of the arm’s measured pose, and makes you squeeze and release the grip of the controller you named before the arm moves. The package README covers setup, the APK, the vr.sh launcher and the offline tests; its design notes give the measurement behind every threshold. Read them before changing one.

Status

Verified on a Quest 3. The motion scales, the axis remap and the freshness thresholds were measured with one headset, one operator and one workspace: the kind of number is general, the exact value is not. A different room orientation needs a different reorder matrix, and the scales are worth checking against the commanded peaks the summary prints before you raise them.

Tune a running controller

Live tuning changes how an existing Cartesian control session tracks its targets, without restarting the node. Use it to compare tracking, damping and motion limits. Ordinary motion does not require the panel or any live parameter changes: start with the shipped configuration.

The motion client sends goals and owns the session. The tuning panel sends parameter updates and receives accepted settings and robot state. The node validates both inputs; franka-rs runs the local 1 kHz torque loop on the Pi or realtime PC.
Motion goals and tuning are separate inputs. Both reach the same local controller. Open full-size diagram.

The motion client still owns the arm and keeps the session alive. The panel changes controller parameters; it does not acquire the arm, start motion or replace the motion client. Live tuning currently requires a running Cartesian impedance session. An idle arm or a joint-target session returns not_ready when asked to change parameters.

Open the optional panel

Start the node and your Cartesian client using Serve arms over Zenoh. The panel is the franka-tuning-panel command of the Python client, so pip install franka-node-client of the node’s version installs it (from a checkout, python -m pip install ./crates/franka-node/python; From Python covers the client itself). Then:

franka-tuning-panel --connect tcp/127.0.0.1:7447 --no-multicast \
  --presets ./tuning-presets.json

This example runs the bridge on the same machine as a node listening on port 7447. Open http://127.0.0.1:8765/ and select the arm.

The panel is a pure consumer, so it opens a Zenoh client session (--mode client, the default): it dials out and is never dialled, which is what works through a tunnel, and it still scouts by multicast, so a node on the same LAN needs no --connect. A client will not start with nothing to reach — start the node first, or pass --mode peer to hold a session open while you wait. --listen needs --mode peer; --zenoh-config wins over all of these flags. --timeout is the Zenoh query timeout in seconds, 1.0 by default; raise it on a slow link, where a query would otherwise give up before the node answers.

For a Pi-hosted bridge, tunnel it from the laptop:

ssh -N -L 8765:127.0.0.1:8765 <pi-user>@<pi-address>

Then open the same local URL on the laptop; the panel stays on loopback and only the tunnel reaches it. That is the default, and the one to keep while a robot runs unattended; to serve the panel to the network instead, see below. Access to the node’s Zenoh bus also permits parameter changes: tuning requests identify their sender but do not require the motion lease holder.

--presets names the panel’s preset file, by default ~/.local/state/franka-tuning/presets.json. Presets never change the node’s TOML. Each saved preset holds the owners’ values, a note, and the metrics snapshot at the moment of saving, stamped with the effective_hz it was measured at — snapshots taken at different effective rates are not comparable.

To explore the interface without a robot, run these in two terminals from the repository root instead; the mock is a test tool that stays in the repository. The mock is an owner, so it listens as a peer; the panel dials it as a client:

python crates/franka-node/python/tests/panel_tests/mock_owner.py --listen tcp/127.0.0.1:17447 --no-multicast
franka-tuning-panel --connect tcp/127.0.0.1:17447 --no-multicast \
  --presets ./tuning-presets.json

The mock’s teleoperation controls demonstrate an additional parameter owner; they do not mean that a teleoperation service is included in franka-node.

Reach it from another machine

A tunnel is not the only way. Exposure takes both flags: a --host beyond loopback, and --expose-to-network to say you mean it. Without the flag that --host is refused; with it, on the default --host 127.0.0.1, nothing changes. Together they serve the panel to the network it binds, so a laptop or a tablet opens it like any other service on the bench:

franka-tuning-panel --host 0.0.0.0 --expose-to-network --connect tcp/127.0.0.1:7447

There is no authentication. An exposed panel lets anyone who can reach that port — every machine, and every person, on that network — change any live parameter of a moving robot: its springs, its damping, its motion budgets. Serve it on a network you control, while you are at the bench, and stop it afterwards. The startup line names the URLs it has opened and who can reach them:

WARNING tuning-bridge: the panel is at http://203.0.113.5:8765/, reachable by anyone on that
network: it has no authentication, so any of them can set every live parameter of every arm on
the bus, while it moves

Exposed, the panel serves a request whose Host header is an IP address, alongside the loopback names, so http://203.0.113.5:8765/ works from another machine whatever the address is. A Host that is a DNS name is still refused — including one that merely contains an address, such as 203.0.113.5.evil.example — and that refusal is what keeps the panel’s one browser-side defence: any page on the internet can point its own name at this port (DNS rebinding), and the browser would then count it same-origin with the panel and let it drive the arm; a rebound name is never an IP literal. Bound to loopback, the loopback names stay the only ones served, whatever page asks.

To reach the panel by name, name it — exactly, and repeat the flag for more than one:

franka-tuning-panel --host 0.0.0.0 --expose-to-network --allowed-host <bench-host>.local

--allowed-host matches the whole name, never a suffix, so allowing <bench-host>.local does not allow <bench-host>.local.evil.example. It needs the exposed bind above: on a loopback bind the panel exits with an error instead of serving that name. It is only as trustworthy as whoever can answer for the name: .local names are answered by mDNS, which anyone on the network can claim, and a page served from a name the panel accepts is same-origin with the panel. Prefer the address.

What the Origin check does, and does not, protect: a browser that posts a change must send an Origin equal to the address in its own URL bar, so a page served from anywhere else cannot write even when it aims straight at the panel’s address. A script sends no Origin at all, and nothing here stops it — one curl from any machine on that network retunes a moving arm. The panel is also plain HTTP, so an exposed session is readable and injectable by anyone on the path. Exposure is a decision about who is on that network, not a permission the panel checks.

What the controls change

The panel gets ranges, units, defaults and confirmation thresholds from the running node’s schema, generated from the same LiveTuning::BOUNDS table that checks updates. There is no separate panel limits table. The library-level contract — the fields, what is refused and what is clamped, and how each value crosses into the loop — is in Tune the law while it runs.

FieldMeaning
joint_stiffness[7]Joint springs in the impedance law.
joint_damping[7]Joint damping; the node enforces a floor tied to each joint spring.
cartesian_stiffnessScales the Cartesian stiffness preset and its associated damping.
ik_dampingDamping of the inverse-kinematics solve near poorly conditioned poses.
ik_nullspace_gainPosture correction in the redundant degree of freedom; zero disables it.
velocity_feedforward_gainWeight of the goal velocity in the damping term; zero disables feedforward.
velocity_feedforward_cutoffFilter cutoff for that velocity; the schema marks the filter-off value.
budget[3]Translation velocity, acceleration and jerk norm limits.
rotation_budget[3]Rotation velocity, acceleration and jerk norm limits.

Change a small group of related settings, apply, and compare the same motion before and after. Read the accepted values: the node can clamp a request or raise damping when a spring is increased. Invalid requests change nothing. Crossing a budget’s confirmation threshold, or raising velocity_feedforward_gain above zero, requires an explicit confirmation; already being above it does not ask again. The wrist joints’ damping (joints 5 to 7) is capped lower than the others’, at 40 instead of 60 Nm·s/rad, to keep the velocity barrier stable on their lighter inertia.

How an accepted value reaches the arm depends on the field. The gains — the joint springs and dampers, the Cartesian stiffness, the two IK fields and the feedforward gain — slew in over 0.3 s. A budget’s velocity and acceleration rise at once but ramp down. Only the two jerks and the feedforward filter cutoff step immediately. So most changes show in the motion over the cycles after they are applied. This removes the need to design those transitions in the client; it does not make every combination or every robot pose feasible.

A lowered budget never steps the command, but lowering its acceleration or jerk while the arm moves lengthens the stop (toward v²/2a, plus the jerk’s ramp), so a near goal is overshot and returned to. For example, at the default 0.3 m/s (0.17 m/s per axis), an acceleration dragged from 0.5 to 0.1 m/s² with the goal 3 cm ahead overshoots it by about 20 cm; faster motion or a lower acceleration overshoots further. Lower the velocity first, apply it, then lower the acceleration or jerk.

The displayed params are accepted targets, not a measurement of the gains applied on each realtime cycle. slewing estimates the fraction of the latest change still to go: near 1 just after acceptance, approaching 0. It is calculated from elapsed time, not fed back from the controller; overlapping changes and missed cycles limit its accuracy.

Why feedforward reads zero

Velocity feedforward is off by default (velocity_feedforward = false); with it on, real arms vibrated. The boolean takes precedence at session start, so the live weight is 0 whatever velocity_feedforward_gain says. Setting velocity_feedforward_gain above zero live, which needs a confirmation, enables its contribution for that session; lowering velocity_feedforward_cutoff filters the goal velocity it feeds forward, and is the knob to try against vibration (no value has been validated on hardware). To enable it in future sessions, set velocity_feedforward = true and the desired gain in TOML, then restart the node to load the configuration.

What is saved

Live changes last only for the current session. On session end, values revert to the loaded TOML baseline, origin becomes null, and dirty becomes false. The next session starts from that baseline. A node restart also restores the configuration and changes boot_id.

The version counts accepted updates during a node run: session end does not reset it; node restart does. dirty compares accepted targets with the baseline, not the controller’s instantaneous gains. Panel presets are separate JSON files. They do not edit TOML, and there is no params/save endpoint. Put a chosen setup into TOML explicitly after reviewing it.

Read the diagnostics with their limits

Panel jitter, lag and tracking measurements are computed from published robot state at about 100 Hz, not from the 1 kHz loop, and they are not a complete trace of it. A node publishing faster is sampled 1 in N by the panel, which says so at startup and in the live header (metrics from 1 in 10 of a 1000 Hz stream); the metrics snapshot and every saved preset carry effective_hz. The numbers therefore mean the same thing at any state_hz: lag and the windows behind it are measured against the rate the panel really sees, so a state_hz that 1 in N cannot bring to exactly 100 (250 Hz gives 125 Hz) still times correctly. What does ride with that rate is the jitter filter’s 3 Hz corner (3.75 Hz at 125 Hz) and how a vibration above half the effective rate folds into the band, so compare jitter figures between snapshots at the same effective_hz. state_hz = 100 is the setting to run: above it the panel drops the extra messages before decoding them, but they still cross the Zenoh callback, which on a Pi is real CPU. The node does not yet publish leash-alteration counts or cap-active fractions. Joint inertia hints are unavailable, and the headroom display also needs a compatible teleoperation owner’s limits. A missing metric is not evidence that a limit was never reached.

Larger Cartesian budgets can still reach joint-side limits depending on pose, payload and trajectory; there is no universal translation speed that guarantees success. Robot reflexes, collision settings, session deviation guards and the client’s own target pacing remain in effect. Those guards are not loosened by tuning a budget.

For client implementations and precise message semantics, see the node parameters protocol.

Status

Live tuning is asserted against the simulator, down to the torque the loop produced, and the panel is tested against a mock owner. Neither has been exercised on an arm yet, and no velocity-feedforward setting reachable from here has been validated on hardware.

Run the examples

Every example takes the robot’s hostname as argv[1] and reads RealtimeConfig from FRANKA_REALTIME: enforce, the default, or ignore for the simulator.

cargo run --release --example <name> -- 172.16.0.2
FRANKA_REALTIME=ignore cargo run --release --example <name> -- 127.0.0.1

The examples that move the arm print a warning and wait for Enter (--yes skips it where offered). Have the user stop button at hand and free space around the arm.

examplewhat it does
echo_robot_statePrints RobotState for 100 cycles. The arm does not move.
communication_testMoves to the ready pose, then runs a zero-torque loop and reports lost states and the min/avg/max control_command_success_rate; exits non-zero when the average is below 0.9.
dual_communication_testThe same loop against two robots from one process, a Robot per thread, with per-robot accounting. Takes two hostnames, [--cycles N] [--pin CPU1,CPU2].

Motion generators

examplewhat it does
generate_joint_position_motionA cosine ramp on joints 4, 5 and 7.
generate_joint_velocity_motionA velocity profile on joints 4 to 7.
generate_cartesian_pose_motionA circle in the end effector’s x/z plane.
generate_cartesian_velocity_motionA diagonal x/z sweep.
readme_joint_moveThe README’s quick example, byte for byte; CI runs it against the simulator.
move_to_readyMoves to libfranka’s “ready” joint configuration. Takes [speed-factor] [--yes], default 0.2.

Torque and impedance

examplewhat it does
fer_joint_impedance1 kHz joint-impedance torque control on an FER, rate-limited by hand against the robot’s tau_J_d; refuses to run on an FR3.
cartesian_impedance_active_controlA Cartesian impedance controller whose equilibrium is the start pose, driven through ActiveControl’s read_once / write_once. Takes [--duration SEC] [--yes].
cartesian_impedance_figure_eightThe same loop with a moving equilibrium, a figure eight around the start pose, ramped in and out, with a nullspace joint spring and a one-sided virtual floor. Takes [--duration SEC] [--period SEC] [--amplitude M] [--floor M] [--yes].

Target control

examplewhat it does
nonrealtime_commanderA scripted (or stdin) commander sets stepped, bursty, stalling Cartesian targets through start_cartesian_target_control (--bridged, the default; --budget V,A,J; --rotate adds a yaw sweep), or hands them to a bare control_cartesian_pose so the robot refuses the first step (--raw). Takes [--bridged | --raw] [--stdin] [--log PATH] [--yes] [--budget V,A,J] [--rotate] [--backend robot|impedance] [--stiffness KX] [--damping KXD] [--feedforward] [--project-joint-gains] [--leash M] [--thresholds N]; --rotate, --backend and the impedance flags need --bridged.

Gripper and recovery

examplewhat it does
grasp_objectHomes the Franka Hand, then grasps an object of the given width. Takes <hostname> <object-width>.
automatic_error_recoveryA command-line automatic_error_recovery(): prints the robot mode and error flags before and after clearing a reflex.

Rerun examples (crates/franka-rerun/examples)

Run with cargo run --release -p franka-rerun --example <name> -- <hostname>; the viewer must be 0.37.1 (Install).

examplewhat it does
reflex_replayA slow joint swing with lowered collision thresholds, recorded live with Recorder; the control log of the reflex a push provokes is written as a Rerun recording. See Record and replay a run.
commander_livenonrealtime_commander streamed live into a viewer: the raw target, the sent and measured position per axis, the arm with Franka’s meshes, the derivatives against the limits, the commander’s events. Takes (--live ADDR | --out FILE) [--bridged | --raw] [--stdin] [--budget V,A,J] [--controller joint|cartesian] [--meshes DIR] [--yes]; start the viewer first (rerun --port 9876).

echo_robot_state, communication_test, the four generate_* examples and grasp_object are ports of libfranka’s examples of the same name (grasp_object always homes and takes no <homing> flag); cartesian_impedance_active_control is libfranka’s cartesian_impedance_control.cpp with the callback replaced by the ActiveControl loop. The Python examples are described in From Python.

How the FCI works

The Franka Control Interface (FCI) is the protocol a Franka control box speaks over Ethernet. This page is the part of it to keep in mind whenever you write a program against it. The byte-level detail is in FCI v10 and FCI v5 on the wire.

Two channels

A session has two sockets. A TCP channel carries commands and their replies: Move, which starts a motion, StopMove, the setters (set_collision_behavior, set_joint_impedance, set_load, …) and automatic_error_recovery. A UDP channel carries the 1 kHz exchange: the robot sends a RobotState every millisecond, and while a motion runs it expects one command back for every state.

That expectation is the deadline. The robot keeps score in control_command_success_rate, the fraction of the last 100 commands it accepted. A late or missing command lowers it, and a sustained fall ends the motion with a communication_constraints_violation reflex. Everything in The realtime rules follows from this one number.

A motion is a Move session

Nothing moves until the client sends a Move. The request names a motion generator (joint positions, joint velocities, Cartesian pose or Cartesian velocity) and a controller (the robot’s joint impedance or Cartesian impedance controller, or an external one, meaning the client sends torques). The robot answers that the motion started, the 1 kHz exchange begins, and the motion runs until the client sets motion_finished on a command or something else ends it: a reflex, a StopMove, the user stop, or missed deadlines.

A motion never finishes on a moving command. The command that carries motion_finished has to be one the robot can hold; the robot refuses a finish on a moving Cartesian pose with cartesian_motion_generator_velocity_discontinuity. All three control interfaces of this crate end a motion the same way, and target control’s stop() brings the command to rest before it finishes.

Who does the tracking

When you send positions, velocities or poses, the robot’s own controller does the tracking: the joint impedance or Cartesian impedance controller named in the Move, with the stiffness set by set_joint_impedance and set_cartesian_impedance. When you send torques, your controller does the tracking. Torques are joint torques without gravity and friction; the robot adds those, and echoes the last commanded torque as tau_J_d.

The robot checks every command

Positions, velocities and poses are checked against the joint and Cartesian velocity, acceleration and jerk limits, which are the same constants the client-side rate limiter uses; a Cartesian pose stream is also checked for the continuity of the joint motion it implies; torque commands are checked for continuity. All the while the robot compares the external forces and torques it estimates with the collision thresholds you set. A violation stops the motion with a reflex: the arm brakes, robot_mode becomes Reflex, the reason is in last_motion_errors, and the client’s control call returns FrankaError::Control. automatic_error_recovery() clears it. See Reflexes, limits and recovery.

One client, two ports

The FCI is reached at the robot’s address (172.16.0.2 in Franka’s default setup) once FCI mode is unlocked in Desk and the brakes are open. Port 1337 is the robot, port 1338 the Franka Hand, and only one FCI client may be connected to the robot at a time.

Two protocol versions

An FR3 speaks FCI v10 and a Franka Emika Robot / Panda FCI v5; Robot::new announces 10, takes the rejection an FER answers with, and reconnects as 5, so one binary drives either arm. The differences are listed in FCI v10 and FCI v5 on the wire and FER / Panda specifics.

Next: Three ways to control the arm.

Three ways to control the arm

The crate offers three ways to run a motion. Two of them, the callback and ActiveControl, are the two libfranka offers on an FR3, and both put your code on the 1 kHz path. The third, target control, keeps your code off it: the crate runs the loop and you set targets. This page is for choosing; each interface has a how-to of its own.

The callback

Robot::control_joint_positions, control_joint_velocities, control_cartesian_pose, control_cartesian_velocities, control_torques and the four control_torques_and_* methods take a closure FnMut(&RobotState, Duration) -> T. The crate runs the loop: it receives a state, calls your closure, low-pass filters and rate limits what it returns if you asked for that, sends it, and repeats until the closure returns a command with motion_finished set. The closure must return every millisecond and must not allocate, block or print. How-to: Write a 1 kHz callback.

ActiveControl

Robot::start_torque_control, start_joint_position_control, start_joint_velocity_control, start_cartesian_pose_control and start_cartesian_velocity_control return a handle. You own the loop: read_once blocks for the next state, write_once sends the command, and the motion ends when you write one with motion_finished. Nothing is filtered or rate limited on this path; smooth setpoints are your job. The handle stays on the thread that started it. How-to: Drive the loop yourself.

Target control

Robot::start_cartesian_target_control and start_joint_target_control spawn the loop on a realtime thread of the crate’s own and hand back a handle whose set_position, set_orientation, set_pose or set_joints any thread can call at any rate. The loop turns every new target into a jerk-limited profile within a budget and tracks it through one of two backends. The default, Backend::Impedance, sends torques from the crate’s impedance law: the arm is compliant, and pushing it away from the target meets a spring of cartesian_stiffness (750 N/m by default). Backend::RobotController sends the profile as a pose or joint-position stream and the robot’s own impedance controller tracks it, stiffly. stop() lands on the last target, then finishes the motion. The Python bindings move the arm through this interface only. How-to: Command from a low-rate program.

Which one

your programuse
runs at 1 kHz on a realtime machine and computes the next setpoint from the statecallback or ActiveControl
is a planner, policy, teleoperation or script that produces targets at its own ratetarget control
needs to command torques of its own lawcallback or ActiveControl; target control’s default backend sends torques from its impedance law, with the gains as options but the law fixed
wants the crate’s low-pass filter and rate limiter behind itcallback, with limit_rate and cutoff_frequency; target control has the rate limiter as a backstop under its own budget, and its impedance backend filters the torques at 100 Hz
is written in Pythontarget control: move_to, move_by, follow

Rate limiting and low-pass filtering exist on the callback path; target control keeps the rate limiter as a backstop, filters its torques at 100 Hz in the impedance backend and runs with the filter off in the robot-controller backend. ActiveControl has neither, in this crate as in libfranka.

What they share

  • Only one control or read operation may run on a Robot at a time; a second one returns FrankaError::InvalidOperation with libfranka’s message.
  • Robot is Send + Sync, so robot.stop() can be called from another thread while any of the three runs; the motion then ends with FrankaError::Control carrying "libfranka: Move command preempted!".
  • A motion that ends abnormally returns FrankaError::Control(ControlException) with the reflex reason and a log of the last cycles; see State and errors.
  • The realtime rules apply to the first two in full, and to the observer target control lets you install; see The realtime rules.

Reading without controlling is Robot::read_once for one state and Robot::read for a stream; neither commands anything.

The realtime rules

A control loop against the FCI answers a state every millisecond, and the robot ends the motion when the answers stop coming. These are the rules that keep them coming, each with what happens when it is broken. They apply in full to the callback and ActiveControl paths; under target control the crate’s thread follows them for you, and only the observer you may install is bound by them.

1. Answer every state within the millisecond

The robot publishes control_command_success_rate in every state: the fraction of the last 100 commands it accepted. Cycle 0 reads 0, because nothing has been acknowledged yet, so skip it when averaging. A rate below 1 means commands arrived late or not at all; a sustained fall ends the motion with a communication_constraints_violation reflex. examples/communication_test.rs runs a zero-torque loop and prints the minimum, average and maximum of the rate; it is the first thing to run on a new machine or network interface.

2. Build with --release

A debug build of a stiff torque controller misses cycles. Every command in this book uses --release.

3. Nothing on the control thread may allocate, block or do I/O

Inside a callback, an ActiveControl loop body or a target-control observer: no Vec that grows, no String formatting, no println!, no file or socket, no mutex another thread may be holding. The crate’s own loops allocate nothing after the start. franka_rerun::Recorder::push shows the pattern: it copies the record into a bounded channel created before the loop, with try_send, which never blocks and never allocates, and drops the record when the channel is full. A slow cycle costs success rate; a blocked one costs the motion.

4. Only the control thread is SCHED_FIFO

Robot::new raises the calling thread to the highest SCHED_FIFO priority; target control raises its own loop thread the same way, or to realtime_priority when set. Leave the rest of the process at normal priority. Running the whole process under chrt -f starves the control loop once the process has other busy threads, a Rerun recorder for instance.

5. Seed a motion from the commanded values, not the measured ones

Start a joint motion from state.q_d and a Cartesian one from state.O_T_EE_c, not from q or O_T_EE. Commanded and measured differ by the tracking error, and a first setpoint built on the measured value is a step of that size. On an FR3 the first command of a motion is its own filter and rate-limiter reference, so the step reaches the robot as it is; on an FER the first setpoint is rate limited against the robot’s own q_d / O_T_EE_c, and the limiter absorbs the step and distorts the start of the motion. Target control anchors on the echo for you. Details in FER / Panda specifics.

6. Count lost states with state.time

RobotState::time is the robot’s millisecond counter, not a host clock. Two consecutive states whose time differs by more than 1 ms mean states were lost or discarded in between; the Duration a callback receives is that difference, so integrate against it rather than assuming 1 ms. When several states arrive together, the client keeps the newest and discards the rest, and that is a gap too.

7. RealtimeConfig::Ignore is for the simulator

RealtimeConfig::Enforce, the default, requires /sys/kernel/realtime and the right to SCHED_FIFO, and fails with FrankaError::Realtime otherwise. Ignore skips both checks; it still raises the priority when it can and only swallows the failure. Against franka-sim on an ordinary kernel Ignore is right. Against a robot it means the loop runs at whatever priority it got, and rule 1 decides how long the motion lasts.

What a PREEMPT_RT kernel does and does not buy

It bounds the time between a datagram arriving and your SCHED_FIFO thread running, and it lets that thread pre-empt almost everything else on the machine. It does not make a slow callback fast, it does not shorten a println!, and it does not repair a bad network path: an adapter that coalesces interrupts for milliseconds loses cycles with either client. The benchmarks ran on a non-realtime kernel, which is why their tails are in the milliseconds.

Reflexes, limits and recovery

The robot protects itself and its surroundings on its own: it checks every command against its limits and every estimated external force against thresholds you set, and it stops with a reflex when either is crossed. This page says what those checks are, what a reflex looks like from the client, how to recover, and what the client adds on top, which is little.

Collision thresholds

Robot::set_collision_behavior takes eight arrays: lower and upper torque thresholds per joint (Nm) and lower and upper force thresholds per Cartesian axis (N, Nm), each for the acceleration phase and for constant velocity. An estimated external torque or force between the lower and the upper threshold is reported as a contact (joint_contact, cartesian_contact in the state); above the upper threshold it is a collision, and the robot stops the motion with a reflex. set_collision_behavior_simple takes one set of values for both phases.

#![allow(unused)]
fn main() {
extern crate franka;
use franka::Robot;
fn f(robot: &Robot) -> franka::FrankaResult<()> {
// The examples' `set_default_behavior`: 20 Nm / 20 N while accelerating, 10 Nm / 10 N at
// constant velocity, contact and collision thresholds equal.
robot.set_collision_behavior(
    [20.0; 7], [20.0; 7], [10.0; 7], [10.0; 7], [20.0; 6], [20.0; 6], [10.0; 6], [10.0; 6],
)?;
Ok(()) }
}

Those are the thresholds of libfranka’s examples_common.cpp, and the generate_* examples use them. nonrealtime_commander sets libfranka’s current example thresholds instead (12 to 20 Nm per joint, 20 N and 25 Nm at the end effector). Collision thresholds apply independently of kinematic limits: the robot’s external-force estimate can trigger a reflex even when commanded speed and acceleration remain within their budgets. The two Cartesian impedance examples set 100 Nm and 100 N so that the arm can be pushed around; keep the user stop in hand there. Target control’s default backend is a spring too and sets no thresholds itself. Choose collision thresholds and stiffness for the task and payload; the leash does not cap total contact force. See Collision thresholds.

What a reflex is, from the client

The robot brakes, robot_mode becomes Reflex, and the Move ends with ReflexAborted. The control call returns FrankaError::Control(ControlException): message is libfranka’s text with the error names and the success-rate line, move_status the terminal status, last_motion_errors the reason (cartesian_reflex, joint_velocity_violation, communication_constraints_violation, …), and log the last 50 states and the commands sent with them. robot.automatic_error_recovery() then clears the errors so a new motion can start; it fails with FrankaError::Command when the robot requires manual recovery. examples/automatic_error_recovery.rs prints the mode and the error flags before and after.

A motion can also be ended by the client: robot.stop() from another thread preempts it, and the control call returns FrankaError::Control carrying "libfranka: Move command preempted!".

The user stop and Desk

The user stop and Desk are outside the FCI, and the client cannot override them. A pressed user stop ends the motion (MoveStatus::EmergencyAborted, RobotMode::UserStopped); a motion that a safety function stops or refuses comes back as PreemptedDueToActivatedSafetyFunctions or CommandRejectedDueToActivatedSafetyFunctions (FCI v10). Unlocking FCI mode and opening the brakes happen in Desk as well.

The limits the robot enforces per command

Velocity, acceleration and jerk in joint space and in Cartesian space, the elbow, and the torque rate. The client-side rate limiter uses the same constants (the FR3 tables at the crate root, the FER tables under franka::rate_limiting::fer); see Rate limiting and filtering. One check is easy to miss: the robot runs inverse kinematics on every commanded Cartesian pose and checks the continuity of the joint motion it implies, which the Cartesian limits do not bound. A pose stream inside the Cartesian limits can still be refused with cartesian_motion_generator_joint_velocity_discontinuity when the implied joint motion exceeds the joint limits. The mapping depends on the arm’s configuration. Details in FER / Panda specifics and Online trajectory generation.

What the client adds

Little, by design. Target control has a deviation guard: if the measured position strays more than max_deviation from the start (0.30 m; 1.0 rad on any joint for joint targets) or the measured orientation turns more than max_angular_deviation (0.5 rad), the target is frozen where the command is and the motion finished from rest with FrankaError::Control. It is a coarse client-side check against a runaway target, not a safety function; the robot’s reflexes are. The simulator’s FR3 image runs the robot’s joint-side continuity check under --enforce-motion-limits; the FER image does not (see Simulator gaps).

Before you move a real arm

  • Start from the ready pose: cargo run --release --example move_to_ready -- <hostname>.
  • The space the motion needs is free; nonrealtime_commander, for one, moves inside a 24 cm cube around the start pose.
  • The user stop is in someone’s hand.
  • The collision thresholds are set by the program, for the task.
  • The program has run against the simulator first (Without a robot: franka-sim).
  • --release, and FRANKA_REALTIME=enforce on the realtime machine.

State and errors

Every millisecond the robot sends a RobotState; every failure the crate reports is a FrankaError. This page is the map of both.

RobotState

franka::RobotState is a plain Copy struct with the field names and semantics of libfranka’s franka::RobotState, whose robot_state.h documents every field. Every value is f64 on both protocol versions; where the wire carries f32, the wire layer converts.

groupfields
FramesO_T_EE, O_T_EE_d, O_T_EE_c, F_T_EE, F_T_NE, NE_T_EE, EE_T_K: 4x4 homogeneous transforms, column major
Loadm_ee, I_ee, F_x_Cee, m_load, I_load, F_x_Cload, m_total, I_total, F_x_Ctotal
Elbowelbow, elbow_d, elbow_c, delbow_c, ddelbow_c
Joint torquestau_J (measured), tau_J_d (last commanded; what the rate limiter compares against), dtau_J
Joint motionq, q_d, dq, dq_d, ddq_d, theta, dtheta
Contact and collisionjoint_contact, cartesian_contact, joint_collision, cartesian_collision
External wrenchtau_ext_hat_filtered, O_F_ext_hat_K, K_F_ext_hat_K
Cartesian commandsO_dP_EE_d, O_dP_EE_c, O_ddP_EE_c
Base accelerationO_ddP_O: the gravity vector as the robot measures it, about [0, 0, -9.81] on hardware
Accelerometersaccelerometer_top, accelerometer_bottom (FCI v10 only; zero on an FER)
Healthcurrent_errors, last_motion_errors, control_command_success_rate, robot_mode, time

q versus q_d. q is measured; q_d is the position the robot’s controller is commanding right now, and O_T_EE_c the commanded pose. Seed a motion from the commanded values, not the measured ones: the two differ by the tracking error, and on an FER the first setpoint of a motion is rate limited against the robot’s own q_d. This is rule 5 of The realtime rules.

time is a franka::Duration, a millisecond counter from the robot rather than a host clock; as_secs_f64() converts it. It advancing by more than 1 ms between two states is how lost states are counted.

control_command_success_rate is the fraction of the last 100 commands the robot accepted, and the health signal of a control loop; cycle 0 always reads 0. What a falling rate means is in The realtime rules.

RobotMode

The seven values of franka::RobotMode: Other, Idle, Move, Guiding, Reflex, UserStopped, AutomaticErrorRecovery. Display prints them with libfranka’s spelling ("User stopped", "Automatic error recovery"), because that text appears inside the robot’s error strings. A read-only probe before a motion should require Idle with no error flags.

Errors

franka::Errors is a set of 41 boolean flags, Errors(pub [bool; 41]), in libfranka’s order; franka::ERROR_NAMES holds the names.

#![allow(unused)]
fn main() {
extern crate franka;
use franka::Robot;
fn f(robot: &Robot) -> franka::FrankaResult<()> {
let state = robot.read_once()?;
if state.current_errors.any() {
    println!("errors: {:?}", state.current_errors.names());
}
if state.last_motion_errors.get("joint_velocity_violation") {
    println!("the previous motion tripped the joint velocity limit");
}
Ok(()) }
}

any() says whether anything is set, names() lists the set flags as &'static str, get(name) reads one flag by libfranka’s name (unknown names are false), and Errors::index_of(name) gives the bit index.

current_errors is what is wrong now. last_motion_errors is the reflex reason of the motion that last ended, the same set a ControlException carries; on a freshly connected robot it is the history, the reason the previous session’s motion ended, which is worth printing in a pre-flight probe. Two names are easy to misread: cartesian_motion_generator_joint_velocity_discontinuity and its joint_acceleration sibling are raised by a Cartesian pose motion, from the robot’s check of the joint motion the poses imply; see Reflexes, limits and recovery.

FrankaError

One enum with a variant per libfranka exception type, so a match covers what a catch chain would. FrankaResult<T> is Result<T, FrankaError>.

FrankaError variantlibfranka exceptionraised when
Network(String)NetworkExceptionconnection or socket failure, including a UDP timeout
Protocol(String)ProtocolExceptionmalformed or unexpected protocol data
IncompatibleVersion { server_version, library_version }IncompatibleVersionExceptionthe server speaks another FCI version
Command(String)CommandExceptiona TCP command was rejected by the robot
Control(ControlException)ControlExceptiona motion ended abnormally: reflex, preemption, discontinuity
Realtime(String)RealtimeExceptionSCHED_FIFO or /sys/kernel/realtime unavailable under Enforce
InvalidOperation(String)InvalidOperationExceptionnot allowed now: a second control loop, or a command the negotiated version does not have
InvalidArgument(String)std::invalid_argumentnon-finite values, an invalid transform, an invalid elbow configuration
Model(String)ModelExceptionmodel loading or evaluation failure

The message strings are libfranka’s, including the "libfranka: " prefix, so log lines are comparable with a C++ client’s. Match on the variant, not on the text. Control is the only variant with structured data: message, move_status, last_motion_errors and log, the last states and commands before the end (50 by default). How to read it is in Write a 1 kHz callback; the flight recorder replays it (Record and replay a run).

Two behaviours differ from libfranka on purpose, a stricter version handshake and control_* never failing with Realtime; both are listed under Compared with libfranka.

Write a 1 kHz callback

At the end of this page you will have run a motion from a closure the crate calls once per robot state, ended it with motion_finished, and read the control log a reflex leaves behind. Prerequisites: First program works, the arm is at the ready pose, and you have read The realtime rules: the closure runs on the realtime thread.

extern crate franka;
use franka::{ControllerMode, JointVelocities, RealtimeConfig, Robot, DEFAULT_CUTOFF_FREQUENCY};

fn main() -> franka::FrankaResult<()> {
let robot = Robot::new("172.16.0.2", RealtimeConfig::Enforce)?;
let mut time = 0.0;
robot.control_joint_velocities(
    |_state, period| {
        time += period.as_secs_f64();
        let omega = 0.5 * (std::f64::consts::PI * time / 2.0).sin();
        let mut out = JointVelocities::new([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, omega]);
        out.motion_finished = time >= 4.0;
        out
    },
    ControllerMode::JointImpedance,
    /* limit_rate  = */ false,
    DEFAULT_CUTOFF_FREQUENCY,
)?;
Ok(()) }

The closure is FnMut(&RobotState, Duration) -> T, T the command type of the method. The Duration is the robot time since the previous call (zero on the first); integrate against it rather than assuming 1 ms.

The methods

methodcallback returnscommands
control_joint_positionsJointPositionsq
control_joint_velocitiesJointVelocitiesdq
control_cartesian_poseCartesianPoseO_T_EE (+ optional elbow)
control_cartesian_velocitiesCartesianVelocitiesO_dP_EE (+ optional elbow)
control_torquesTorquestau_J
control_torques_and_joint_positionsTorques + JointPositionsboth
control_torques_and_joint_velocitiesTorques + JointVelocitiesboth
control_torques_and_cartesian_poseTorques + CartesianPoseboth
control_torques_and_cartesian_velocitiesTorques + CartesianVelocitiesboth

The four motion-only methods take a ControllerMode, JointImpedance or CartesianImpedance, which selects the robot’s internal controller; the control_torques* methods do not, there the controller is yours. Seed a position or pose motion from state.q_d / state.O_T_EE_c, not from the measured q / O_T_EE (realtime rule 5).

Ending a motion

A motion ends when the callback returns a command with motion_finished set. Set the field, or wrap the value:

#![allow(unused)]
fn main() {
extern crate franka;
use franka::JointVelocities;
fn f(out: JointVelocities, done: bool) -> JointVelocities {
if done {
    franka::motion_finished(out)   // sets the flag and returns the value
} else {
    out
}
}
}

The flag belongs to the Finishable trait all five command types implement. The last command is still sent, and the crate then closes the motion with the robot. Never return early or panic out of the callback to stop a motion: set the flag, or call stop() from another thread. A panic that unwinds out of the callback cancels the motion on the robot, as a thrown exception does in libfranka, but that is an abort, not a finished motion.

Rate limiting and the low-pass filter

Every method takes limit_rate: bool and cutoff_frequency: f64 explicitly, because Rust has no default arguments and libfranka’s limit_rate default differs by version: false in libfranka 0.21 for the FR3 (the robot does its own limiting; the client-side limiter can distort a motion), true in libfranka 0.9.2 for the FER. cutoff_frequency defaults to DEFAULT_CUTOFF_FREQUENCY in both; MAX_CUTOFF_FREQUENCY disables the first-order low-pass filter. Which constants the limiter uses follows from the negotiated version, not from you; they and the limit_rate_* functions are in Rate limiting and filtering.

ControlException and the control log

A motion that ends abnormally returns FrankaError::Control(ControlException), the port of franka::ControlException:

#![allow(unused)]
fn main() {
extern crate franka;
use franka::{FrankaError, Robot, RealtimeConfig};
fn f(robot: &Robot) {
let result: franka::FrankaResult<()> = Ok(());
match result {
    Err(FrankaError::Control(e)) => {
        println!("{}", e.message);                 // libfranka's exact text
        println!("{:?}", e.move_status);           // terminal Move status, if any
        println!("{:?}", e.last_motion_errors);    // the reflex reason
        for record in &e.log {                     // newest last
            println!("{:?} -> {:?}", record.state.q, record.command);
        }
    }
    _ => {}
}
}
}

message is libfranka’s full string, with the error names and the success-rate line when a reflex aborted the motion. log is Vec<Record>, Record { state, command: Option<RobotCommandLog> }, newest last: what was commanded in the 50 ms before the robot stopped. The default size is 50 cycles (franka::DEFAULT_LOG_SIZE); Robot::new_with_log_size or RobotOptions::new(..).with_log_size(n) changes it. The flight recorder replays the log (Record and replay a run); with the serde feature it saves as JSON. After a reflex, robot.automatic_error_recovery() clears the errors for the next motion.

Stopping from another thread

Robot is Send + Sync and every method takes &self: share it as an Arc<Robot> and call stop() from a second thread. The loop then fails with FrankaError::Control carrying "libfranka: Move command preempted!".

extern crate franka;
use std::sync::Arc;
use franka::{ControllerMode, JointVelocities, RealtimeConfig, Robot, DEFAULT_CUTOFF_FREQUENCY};

fn main() -> franka::FrankaResult<()> {
let robot = Arc::new(Robot::new("172.16.0.2", RealtimeConfig::Enforce)?);

let stopper = Arc::clone(&robot);
std::thread::spawn(move || {
    std::thread::sleep(std::time::Duration::from_secs(2));
    let _ = stopper.stop();
});

let result = robot.control_joint_velocities(
    |_state, _period| JointVelocities::new([0.0; 7]),
    ControllerMode::JointImpedance,
    false,
    DEFAULT_CUTOFF_FREQUENCY,
);
// `result` is Err(FrankaError::Control(..)) with "Move command preempted!"
let _ = result; Ok(()) }

Only one control or read operation may run at a time; a second one returns FrankaError::InvalidOperation with libfranka’s own message. The generate_* examples are ports of libfranka’s on this interface; see Run the examples.

Drive the loop yourself

At the end of this page you will have run a motion from a loop you wrote, reading a state with read_once and answering it with write_once, and seen a Cartesian impedance controller written that way. This is libfranka’s ActiveControl: startTorqueControl(), readOnce(), writeOnce().

Prerequisites: First program works, the arm is at the ready pose, and you have read The realtime rules: the loop body is on the realtime thread, and there is no filter or rate limiter behind it.

extern crate franka;
use franka::{JointVelocities, MoveControllerMode, RealtimeConfig, Robot};

fn main() -> franka::FrankaResult<()> {
let robot = Robot::new("172.16.0.2", RealtimeConfig::Enforce)?;
let mut active = robot.start_joint_velocity_control(MoveControllerMode::JointImpedance)?;

let mut time = 0.0;
loop {
    let (_state, period) = active.read_once()?;
    time += period.as_secs_f64();
    let mut out = JointVelocities::new([0.0; 7]);
    out.motion_finished = time >= 4.0;
    let finished = out.motion_finished;
    active.write_once(&out, None)?;
    if finished {
        break;
    }
}
Ok(()) }

The starters

Robot::start_*_control sends the Move and returns a handle that holds the robot’s control lock for its lifetime:

starterhandlewrite_once takes
start_torque_controlActiveTorqueControl&Torques
start_joint_position_controlActiveMotionGenerator<JointPositions>motion + Option<&Torques>
start_joint_velocity_controlActiveMotionGenerator<JointVelocities>motion + Option<&Torques>
start_cartesian_pose_controlActiveMotionGenerator<CartesianPose>motion + Option<&Torques>
start_cartesian_velocity_controlActiveMotionGenerator<CartesianVelocities>motion + Option<&Torques>

The motion-generator starters take a MoveControllerMode: JointImpedance, CartesianImpedance or ExternalController. The Option<&Torques> of write_once must be Some if and only if the mode is ExternalController; otherwise write_once fails with FrankaError::Control.

read_once blocks for the next state and returns it with the robot time elapsed since the previous read_once (zero on the first call); it fails with FrankaError::Control if the motion was aborted. write_once validates the command (finite values, a homogeneous transform, a valid elbow) and sends it. Setting motion_finished on either input ends the control process; a write_once after that fails with FrankaError::Control. Dropping a handle before the motion has finished cancels the motion.

Two things this path does not do. No rate limiting and no low-pass filtering are applied, in this crate as in libfranka: smooth setpoints are your job, and the robot refuses a discontinuous one with a reflex. And the handle is !Send: it holds the control lock’s guard, so read and write it from the thread that started it; robot.stop() from another thread still works through the Arc<Robot>.

Cartesian impedance with ActiveControl

examples/cartesian_impedance_active_control.rs is libfranka’s cartesian_impedance_control.cpp on this path: a spring-damper system without inertia shaping whose equilibrium is the pose the end effector had when the loop started. The callback becomes the loop body and the Eigen calls become nalgebra ones (the crate depends on nalgebra already):

extern crate franka;
extern crate nalgebra;
use franka::{motion_finished, Frame, RealtimeConfig, Robot, Torques};
use nalgebra::{Matrix4, Rotation3, SMatrix, SVector, UnitQuaternion, Vector3};
fn main() -> franka::FrankaResult<()> {
let robot = Robot::new("172.16.0.2", RealtimeConfig::Enforce)?;
let model = robot.load_model()?;
let (stiffness, damping) = (SMatrix::<f64, 6, 6>::zeros(), SMatrix::<f64, 6, 6>::zeros());
let (position_d, orientation_d) = (Vector3::zeros(), UnitQuaternion::identity());
let mut error = SVector::<f64, 6>::zeros();
let mut control = robot.start_torque_control()?;
loop {
    let (state, _period) = control.read_once()?;
    let coriolis = SVector::<f64, 7>::from_column_slice(&model.coriolis(&state));
    let jacobian =
        SMatrix::<f64, 6, 7>::from_column_slice(&model.zero_jacobian(Frame::EndEffector, &state));
    let dq = SVector::<f64, 7>::from_column_slice(&state.dq);

    let transform = Matrix4::from_column_slice(&state.O_T_EE);
    let rotation = transform.fixed_view::<3, 3>(0, 0).into_owned();
    let mut orientation =
        UnitQuaternion::from_rotation_matrix(&Rotation3::from_matrix_unchecked(rotation));
    if orientation_d.coords.dot(&orientation.coords) < 0.0 {
        // A unit quaternion and its negation are the same rotation: take the shorter way.
        orientation = UnitQuaternion::new_unchecked(-orientation.into_inner());
    }
    let position_error = transform.fixed_view::<3, 1>(0, 3) - position_d;
    let orientation_error = -(rotation * (orientation.inverse() * orientation_d).vector());
    error.fixed_view_mut::<3, 1>(0, 0).copy_from(&position_error);
    error.fixed_view_mut::<3, 1>(3, 0).copy_from(&orientation_error);

    let tau = jacobian.transpose() * (-(stiffness * error) - damping * (jacobian * dq)) + coriolis;
    control.write_once(&Torques::new(tau.into()))?;
  break;
}
control.write_once(&motion_finished(Torques::new([0.0; 7])))?;
Ok(()) }

Nothing in the body allocates: the nalgebra types are fixed-size and live on the stack, and the model methods return fixed arrays. The example adds the argument parsing, the compliance constants (150 N/m, 10 Nm/rad, D = 2 sqrt(K)), a SIGINT handler that lets the loop leave through a final motion_finished write, and the --duration bound. It sets the C++ example’s collision thresholds, 100 Nm and 100 N, so that the arm can be pushed; keep the user stop in hand.

examples/cartesian_impedance_figure_eight.rs is the same law with a moving equilibrium: a Lissajous figure eight in the base frame’s y-z plane (0.08 m amplitude, a 10 s lap by default), stiffness 200 N/m and 15 Nm/rad. A raised cosine ramps the amplitude and the stiffness in over 3 s and out over 2 s (1 s after Ctrl-C, or after the end effector is dragged more than 30 cm off its setpoint), so nothing steps at either end. It adds a nullspace joint spring (5 Nm/rad, damped-inverse projector) that keeps the elbow near its start configuration, a one-sided virtual floor 10 cm below the start (a 1500 N/m spring along +z, --floor), and a ±25 Nm clamp on each joint torque before the Coriolis feedforward is added. The loop stays allocation-free; the tracking error goes into a fixed-size histogram. It is the example to run with someone standing next to the robot.

On the FER

ActiveControl works on FCI v5 as well; libfranka 0.9.2 has no equivalent, its API for that robot generation is the callback only. start_torque_control() sends your commanded joint torques to the Panda, with the same public API as on the FR3.

FCI v5 requires an accompanying motion-generator command: the library supplies zero joint velocities alongside your torque commands, following libfranka 0.9.2. Those zeros are in the velocity fields, not the torque fields; they do not mean the measured joints must stay still. The missing protocol option is MotionGeneratorMode::None, not torque control itself. See FER / Panda specifics for the wire-level details.

Use the model

At the end of this page you have a Model for the connected arm and can evaluate poses, Jacobians, the mass matrix, Coriolis and gravity torques from a RobotState or from a joint configuration of your own, with no download and no C++ library.

Prerequisites: a connected Robot (First program).

extern crate franka;
use franka::{Frame, RealtimeConfig, Robot};

fn main() -> franka::FrankaResult<()> {
let robot = Robot::new("172.16.0.2", RealtimeConfig::Enforce)?;
let model = robot.load_model()?;
let state = robot.read_once()?;

let pose: [f64; 16] = model.pose(Frame::EndEffector, &state);       // column major
let jacobian: [f64; 42] = model.zero_jacobian(Frame::EndEffector, &state);
let body_j: [f64; 42] = model.body_jacobian(Frame::EndEffector, &state);
let mass: [f64; 49] = model.mass(&state);
let coriolis: [f64; 7] = model.coriolis(&state);
let gravity: [f64; 7] = model.gravity(&state);
Ok(()) }

The six calls

callreturnslayout
pose(frame, &state)[f64; 16]4x4 pose of frame in the base frame, column-major
zero_jacobian(frame, &state)[f64; 42]6x7, column-major; rows vx vy vz wx wy wz, base-frame aligned
body_jacobian(frame, &state)[f64; 42]6x7, column-major, expressed in frame
mass(&state)[f64; 49]7x7 mass matrix, column-major, symmetric, kg m²
coriolis(&state)[f64; 7]C(q, dq) dq, Nm
gravity(&state)[f64; 7]gravity torques, Nm

Frame has the ten libfranka values: Joint1 … Joint7, Flange (the URDF’s link8), EndEffector (flange post-multiplied by F_T_EE) and Stiffness (end effector post-multiplied by EE_T_K). Frame::ALL lists them in that order.

Supplying your own inputs: the *_q variants

Every call has a variant that takes the joint configuration and load parameters directly instead of reading them out of a RobotState: pose_q, zero_jacobian_q, body_jacobian_q, mass_q, coriolis_q, gravity_q. They are what you use offline, and gravity_q is the only way to supply your own gravity vector:

#![allow(unused)]
fn main() {
extern crate franka;
use franka::Model;
fn f(model: &Model, q: &[f64; 7]) {
let g = model.gravity_q(q, 0.73, &[0.0, 0.0, 0.1034], &[0.0, 0.0, -9.81]);
let _ = g; }
}

gravity(&state) uses state.O_ddP_O as Earth’s gravity, as libfranka does; coriolis(&state) uses the fixed [0, 0, -9.81] (franka::model::DEFAULT_GRAVITY_EARTH), also as libfranka does. franka-sim publishes O_ddP_O as [0, 0, 0], so on the simulator gravity(&state) is identically zero: use gravity_q with an explicit vector there (see Simulator gaps).

A Model can also be built without a robot: Model::from_urdf(&urdf) for an FR3’s URDF and Model::native_fer() for the Franka Emika Robot.

In a torque controller

Model is Send + Sync, so a control thread and a planner can share one. The robot adds gravity and friction to the torques you send, so a model-based controller typically adds the Coriolis term and uses a Jacobian for a Cartesian law:

#![allow(unused)]
fn main() {
extern crate franka;
use franka::{Model, RobotState, Torques};
fn f(model: &Model, state: &RobotState, tau_task: [f64; 7]) -> Torques {
let coriolis = model.coriolis(state);
let mut tau = tau_task;
for i in 0..7 {
    tau[i] += coriolis[i];
}
Torques::new(tau)
}
}

The full Cartesian impedance law (J^T (-K e - D J dq) + coriolis) is on Drive the loop yourself; the joint impedance variant for an FER is examples/fer_joint_impedance.rs.

Cost: evaluating all five dynamic and kinematic calls a model-based controller makes takes about 3 µs offline and about 11 µs inside a 1 kHz loop on a laptop-class CPU. The in-loop figure is higher because a duty-cycled loop starts each cycle on a core that has just idled; see Benchmarks for the numbers and that caveat.

Where the parameters come from

On an FR3 (FCI v10), load_model() fetches the arm’s URDF from the robot with GetRobotModel and evaluates it natively; robot.robot_model() returns the URDF text. On a Franka Emika Robot (FCI v5) there is no such command, and load_model() returns the crate’s built-in FER model (Model::native_fer(), the parameters in franka::model::FER_URDF, identified from a real FER’s own model library). On neither robot does load_model() download or dlopen anything, and on an FER it cannot fail. How the FER parameters were fitted, and how closely both models agree with libfranka’s, is on Model parameters and conformance.

load_model_from_robot(): the robot’s own library

libfranka 0.9.2 gets an FER’s model by downloading the robot’s closed-source libfcimodels.so over the command channel and dlopening it. That path is still available, opt-in:

#![allow(unused)]
fn main() {
extern crate franka;
use franka::Robot;
fn f(robot: &Robot) -> franka::FrankaResult<()> {
let model = robot.load_model_from_robot()?;   // LoadModelLibrary + dlopen, as libfranka does
let _ = model; Ok(()) }
}

It needs the opt-in model-library Cargo feature (cargo add franka-rs --features model-library), an x86-64 Linux host, and it executes code the robot served. This feature is off by default. On an FR3 it is load_model() exactly. Its caveats are listed on the reference page.

From Python

robot.model() returns the same model with the same six calls; see From Python.

Test against the simulator

At the end of this page you can run the crate’s offline checks, its FR3 and FER simulator suites and the Python tests locally, and you know how CI does the same. Nothing here needs a robot; the simulator itself is introduced in Without a robot: franka-sim.

The harness: crates/franka-sim-test

SimServer::start(config) launches a franka-sim container on Docker’s host network, waits until it serves the FCI, and removes the container when the returned SimServer is dropped:

#![allow(unused)]
fn main() {
extern crate franka_sim_test;
use franka_sim_test::{SimConfig, SimServer};

let sim = SimServer::start(SimConfig::nominal());
// connect a client to sim.host(), port 1337 (robot) / 1338 (gripper) ...
drop(sim);   // container removed here
}

SimConfig::nominal() is the permissive default (no motion-limit or communication-constraint checks, gripper enabled); .with_motion_limits(), .with_comm_constraints() and .with_gripper_object(width) add the corresponding server flags, and SimConfig::fer_v5() selects the FER / FCI v5 image and --protocol v5 --robot panda.

variabledefaultmeaning
FRANKA_SIM_IMAGEghcr.io/barisyazici/franka-sim:latestThe FR3 / FCI v10 image.
FRANKA_SIM_FER_IMAGEfranka-sim:panda-v5The FER / FCI v5 image, a local build (no FER image is published); the v5 tests fail rather than skip without it. A separate variable on purpose: FRANKA_SIM_IMAGE is not consulted for v5, or every v5 test would talk to a v10 server.
FRANKA_SIM_ADDRunsetAttach to a server already running at this address instead of starting a container. The harness then never touches Docker, and the SimConfig a test passes is informational only: the caller is responsible for the running server matching it.
FRANKA_SIM_KEEPunset=1 leaves the container up after the test for inspection.

One FCI slot per host

The ports 1337 and 1338 are fixed by the protocol and one client holds them at a time:

  • a process-wide mutex serialises the tests inside one test binary, and every test drops its Robot / Gripper before the next one connects;
  • SimServer::start refuses to start a second container while a franka-sim* container is running or the ports are bound;
  • two cargo test invocations, or two test binaries, against the same host are not supported. Always pass -- --test-threads=1, and take the repository-root lock so two shells cannot collide: flock .sim.lock <command>.

Never run cargo test --tests: it selects every integration binary in the workspace, including the sim_*.rs files, which start a container. Before starting anything, ss -tlnp | grep -E '133[78]' and docker ps must show nothing.

The offline checks

What CI’s check job runs, in order; none of it needs Docker, a network or a robot:

cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo clippy --workspace --all-targets --no-default-features -- -D warnings
cargo clippy -p franka-rs --all-targets --no-default-features -- -D warnings
cargo clippy -p franka-rs --all-targets --features serde -- -D warnings
cargo clippy -p franka-rs --all-targets --features model-library -- -D warnings
cargo test --workspace --lib
cargo test -p franka-rs --lib --features serde
cargo test -p franka-rs --lib --features model-library
cargo test -p franka-rs \
  --test wire_sizes --test model_conformance \
  --test wire_sizes_v5 --test fer_native_conformance \
  --test example_motion_generator
cargo test -p franka-rs --features model-library --test fer_model_conformance
cargo test --workspace --doc
cargo test -p franka-rs --doc
cargo test -p franka-rerun
cargo build -p franka-rerun --examples
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps
RUSTDOCFLAGS="-D warnings" cargo doc -p franka-rs --no-deps

Standalone -p franka-rs checks exercise the native-model default, including the path used by static musl builds. Workspace builds can enable the optional model-library feature through fer-model-fit; CI checks that opt-in separately. The --lib tests include the README-sync test (the README’s quick example must stay byte-identical to main in examples/readme_joint_move.rs). fer_model_conformance drives an FER’s shared object, which is not committed; without FRANKA_FER_MODEL_SO it prints SKIP: lines and passes.

The FR3 suite

flock .sim.lock env FRANKA_SIM_IMAGE=franka-sim:dev cargo test --release -p franka-rs \
  --test sim_handshake --test sim_commands --test sim_motions \
  --test sim_gripper --test sim_stop_and_reflex --test sim_target_control \
  -- --test-threads=1

--release, as for a robot: the loops under test answer the server’s 1 kHz state stream, and unoptimised the impedance loop of target control takes about a millisecond per cycle (35 µs optimised), so on a loaded machine it falls behind until the server’s continuity checks abort the motion.

sim_target_control needs franka-sim 1.1.6 or later. Its tests, and some of those in sim_stop_and_reflex, request --enforce-motion-limits through the SimConfig they build; when attaching to a server through FRANKA_SIM_ADDR that server must have the flag. Since 1.1.5 the FR3 image’s --enforce-motion-limits also runs the robot’s joint-side continuity check on every commanded Cartesian pose, against the FR3’s published 10 rad/s² acceleration limit.

The FER suite

flock .sim.lock env FRANKA_SIM_FER_IMAGE=franka-sim:panda-v5 cargo test --release -p franka-rs \
  --test sim_v5_handshake --test sim_v5_commands --test sim_v5_motions \
  --test sim_v5_stop_and_reflex -- --test-threads=1

The default command checks the native Panda model. Add --features model-library before --test to also exercise the optional shared-library download; the FER CI job enables it.

The panda-v5 image has no joint-side check (FER / Panda specifics). A full cargo test --release --workspace needs both images, both variables set, and the lock.

The harness self-test

crates/franka-sim-test/tests/harness.rs starts and tears down a real container and asserts on docker ps, so it needs Docker, cannot attach to FRANKA_SIM_ADDR, and is in no CI job:

flock .sim.lock env FRANKA_SIM_IMAGE=franka-sim:dev cargo test -p franka-sim-test --test harness

The Python tests

flock .sim.lock env FRANKA_SIM_IMAGE=franka-sim:dev pytest crates/franka-py/tests

conftest.py starts the container with --enforce-motion-limits (or attaches to FRANKA_SIM_ADDR) and sets FRANKA_REALTIME=ignore. test_notebook.py executes the notebook the same way when nbclient, ipykernel, matplotlib and rerun-sdk[notebook] are installed, is skipped otherwise, and is not part of CI.

How CI gets its server

CI never talks to a robot, and neither should any test: a test that would need an arm becomes a simulator test plus a characterisation assertion for the gap (Simulator gaps). .github/workflows/ci.yml has four FR3 simulator jobs, each getting its server from the BarisYazici/libfranka-sim@v1 GitHub Action with FRANKA_SIM_ADDR: 127.0.0.1, so the harness attaches rather than shelling out to Docker:

jobserver configurationruns
sim-nominaldefaultsim_handshake, sim_commands, sim_motions, sim_gripper (skipping the one test that needs an object), then readme_joint_move against the simulator with FRANKA_REALTIME=ignore
sim-motion-limits--enforce-motion-limitssim_stop_and_reflex (reflex, recovery, the rate-limiting envelope) and sim_target_control (the Cartesian and joint target loops under a stepped, bursting, stalling commander)
sim-gripper-object--gripper-object-width 0.04the one sim_gripper test that needs something between the fingers
python-bindings--enforce-motion-limitscrates/franka-py/tests/test_sim.py: the wheel built with maturin, driven by pytest

A fifth job, sim-fer-v5, runs the four sim_v5_* binaries. It cannot use the action: there is no published FER image for it to pull, and the FER image lacks the franka-sim-check binary the action’s readiness probe needs. Instead the harness starts and stops the container itself, driven by FRANKA_SIM_FER_IMAGE. The job is gated on that name existing as a repository variable (Settings, Secrets and variables, Actions, Variables): unset, the job is skipped and the workflow stays green; set to a docker pull-able reference, the FCI v5 suite runs against it. Until then the v5 protocol is covered in CI by the check job only (wire layout, model conformance, the offline unit tests) and the simulator half runs locally.

Build for another machine

At the end of this page you have an aarch64 build of the crate and its examples for a Raspberry Pi 4 or 5 or another 64-bit ARM Linux machine, made and smoke-tested on an x86-64 host. The target needs a 64-bit system (Raspberry Pi OS 64-bit or Ubuntu arm64); 32-bit armhf systems are not supported.

Validation status: the aarch64 binaries of both targets below have run the crate’s test suite under qemu-aarch64-static against the simulator, with byte-identical model numbers. The Raspberry Pi 5 is the reference board for franka-node.

The two targets

targetlibclinking
aarch64-unknown-linux-gnuglibcdynamic
aarch64-unknown-linux-muslmuslfully static

The default build evaluates both robots’ models in Rust. Robot::load_model() works on either target, including for the Panda.

Option A: cargo-zigbuild, no root

rustup target add aarch64-unknown-linux-gnu
python3 -m venv ~/.venvs/zigbuild && source ~/.venvs/zigbuild/bin/activate
pip install ziglang
cargo install cargo-zigbuild

cargo zigbuild --release --target aarch64-unknown-linux-gnu -p franka-rs --examples

The bundled zig cc is linker and sysroot; the result is an ordinary dynamically linked aarch64 binary with the default features.

Option B: a system cross compiler

sudo apt install gcc-aarch64-linux-gnu   # Debian/Ubuntu
rustup target add aarch64-unknown-linux-gnu
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
  cargo build --release --target aarch64-unknown-linux-gnu -p franka-rs --examples

This is what CI’s aarch64-cross-build job does; the linker can also go in .cargo/config.toml.

Option C: a static musl binary, no cross linker

Rust’s musl target ships a self-contained linker (rust-lld) with rustup:

rustup target add aarch64-unknown-linux-musl
cargo build --release --target aarch64-unknown-linux-musl -p franka-rs \
  --examples

The repository’s .cargo/config.toml sets linker = "rust-lld" for this target only; outside the checkout pass RUSTFLAGS="-C linker=rust-lld". The result is one statically linked binary per example with no runtime dependencies.

Smoke test without hardware

qemu-aarch64-static (package qemu-user-static) runs the binaries on the x86-64 host. A static musl binary needs nothing else:

qemu-aarch64-static target/aarch64-unknown-linux-musl/release/examples/communication_test
# Usage: .../communication_test <robot-hostname>

A dynamically linked glibc binary also needs an aarch64 sysroot, for example the /lib and /usr of an arm64 base image:

cid=$(docker create --platform linux/arm64 arm64v8/ubuntu:24.04)
mkdir sysroot && docker cp $cid:/lib sysroot/ && docker cp $cid:/usr sysroot/
docker rm $cid
qemu-aarch64-static -L sysroot target/aarch64-unknown-linux-gnu/release/examples/communication_test

The crate’s test suite has been run this way, one binary at a time with --test-threads=1 and FRANKA_SIM_ADDR=127.0.0.1 pointing at a container started on the host (a binary under QEMU cannot exec the host’s docker, so the harness must attach). Everything that passes natively passed on aarch64 with byte-identical model numbers, except the optional tests calling load_model_from_robot: disabled in the default build, and unavailable with model-library enabled because the robot’s x86-64 library cannot be loaded on ARM.

Deploy and run

scp target/aarch64-unknown-linux-gnu/release/examples/communication_test pi@<pi-host>:~/
ssh pi@<pi-host> chmod +x ~/communication_test

# on the target
FRANKA_REALTIME=ignore ./communication_test 172.16.0.2

The same realtime prerequisites apply on the target: a PREEMPT_RT kernel (cat /sys/kernel/realtime reads 1) and the right to SCHED_FIFO. For a Pi 5, follow System and network for the Ubuntu Raspberry Pi real-time kernel route and NIC setup. An ordinary Raspberry Pi OS image does not by itself establish these realtime prerequisites.

Building natively on the target instead (rustup, then cargo build --release -p franka-rs --examples) needs none of the above.

Compared with libfranka

libfranka is Franka Robotics’ own C++ client and the reference this crate is checked against. This page is for someone choosing between the two. Everything on it is either read from the two source trees or measured; the measurements are on Benchmarks.

Where the two are the same

  • Protocol and semantics. Struct sizes and field offsets are asserted against libfranka’s headers for both FCI versions; the rate limiter and the low-pass filter are ports of rate_limiting.cpp, joint_velocity_limits.cpp and lowpass_filter.cpp; the error strings are byte-identical, "libfranka: " prefix included; the control log holds the same 50 cycles by default. A motion refused by the robot fails the same way from both clients.
  • Loop timing. Measured back to back on the same PC against franka-sim: median cycle time 1000 µs for both, p99 within the run-to-run spread, lost cycles the same. Neither client is better at holding the 1 ms deadline. franka-rs used slightly less CPU (0.6 percentage points, and 2.9 against 5.7 % in the model-in-the-loop variant); part of that gap is a per-call allocation in libfranka’s kinematics path, for which patches/ holds a fix that closes most of it.
  • The model. Poses, Jacobians, mass, Coriolis and gravity agree with libfranka’s Pinocchio backend to 5e-14 on the FR3 and with an FER’s libfcimodels.so to 5e-14 on gravity and 4e-16 on kinematics. Details on Model parameters and conformance.

What franka-rs has and libfranka does not

  • One client for both robot generations. libfranka 0.18 and later speak FCI v10 (FR3 on system 5.9.0 or later; this crate ports the 0.21.2 semantics); 0.9.2 speaks FCI v5 (Panda); the two are incompatible and a program is built against one. Here Robot::new negotiates the version and the same binary drives both.
  • Target control. libfranka gives you the 1 kHz callback and, on the FR3, the ActiveControl loop; since 0.18 it also has an asynchronous joint-position mode in which the robot itself moves point to point under maximum velocities (marked experimental, FR3 only, exposed in pylibfranka). Bridging a stream of Cartesian or joint targets onto the 1 kHz loop is otherwise left to the user or to software built on top of libfranka. Here start_cartesian_target_control / start_joint_target_control run the loop on a realtime thread, take Cartesian poses or joint targets at any rate on both robot generations, track them with the crate’s impedance torques (compliant, the gains as options) or with the robot’s own controller, and the Python bindings are built on it.
  • ActiveControl on an FER. libfranka 0.9.2 has only the callback API for that generation.
  • The model without a download. libfranka evaluates the FR3 model with Pinocchio and, on an FER, downloads and dlopens the robot’s libfcimodels.so. Here both are evaluated natively from parameters; nothing is fetched at runtime unless you call the opt-in load_model_from_robot().
  • Build footprint. No C++ toolchain, no Poco, Eigen, Pinocchio or urdfdom, no system packages; rustup target add cross-compiles it.
  • Rust things. Robot is Send + Sync and stops from another thread through an Arc; errors are one enum with a variant per libfranka exception; a serde feature makes the control log serialisable; crates/franka-rerun replays it in Rerun.

What libfranka has and franka-rs does not

  • Vendor support. libfranka is maintained by Franka Robotics, versioned against the robot system software, and is what Franka’s documentation describes. This crate is an unofficial project.
  • franka_ros2 and the ros2_control hardware interface. Those are C++ plugins on libfranka; there is no ROS 2 integration here.
  • VacuumGripper. Not implemented; only the Franka Hand is.
  • History. libfranka’s history starts in January 2017 and it has driven Franka arms since the Panda shipped. This crate is young.

Deliberate differences

  • A kSuccess handshake that reports a different version than the client announced is an IncompatibleVersion error here. libfranka only inspects the status byte; everything after the handshake is decoded against the announced version, so such a session would be unusable.
  • limit_rate and cutoff_frequency are explicit arguments of every control_* method because Rust has no default arguments, and libfranka’s defaults differ between the two versions (limit_rate is false in 0.21 and true in 0.9.2). See Rate limiting and filtering.
  • control_* never fails with FrankaError::Realtime; the priority is raised in Robot::new, as in libfranka’s Robot::Impl constructor, so the question is settled before a loop starts.

Other clients

Other Rust implementations exist. Marco Boneberger’s libfranka-rs (2021, EUPL-1.2) is a pure-Rust port of libfranka 0.9 for the Panda, FCI v5 only, and the project that informed this crate’s API shape; franka_rust is a separate Rust FCI driver whose README lists Panda and FR3 types, not evaluated here. On the Python side, Franka’s own pylibfranka (libfranka 0.16 and later), franky and panda-py all wrap libfranka: franky and panda-py run a C++ control thread with online trajectory generation, so “send a target any time from Python” is available there too. What they inherit from libfranka is one protocol version per install; that, not the interface, is the difference to this crate. Surveyed 2026-09-10.

FCI v10 and FCI v5 on the wire

The crate speaks two versions of the Franka Control Interface. This page is the byte-level comparison and the negotiation between them. What the difference means for a program on a Franka Emika Robot is on FER / Panda specifics; the rate-limit envelopes are on Rate limiting and filtering.

Supported robots

FCI versionlibfranka semanticsRobotSystemRobotStateRobotCommandModel source
v100.20 – 0.21 (ported from 0.21.2)Franka Research 35.9.0 or later (server version 10)1377 B371 BURDF from GetRobotModel, evaluated natively
v50.9.2Franka Emika Robot / Panda (FER)4.2.1 or later (server version 5)2373 B370 Bshipped identified parameters, evaluated natively

Both versions share the framing: a TCP command channel on port 1337 with a 12-byte CommandHeader (command, command id, total size) and u8 status bytes, and a UDP channel on which the robot sends one RobotState per millisecond and receives one RobotCommand per state while a motion runs. The Franka Hand has its own protocol on port 1338 (10-byte header, u16 statuses), version 3 and byte-identical on both robots.

Version negotiation

Robot::new connects announcing library version 10. An FR3 accepts. An FER answers kIncompatibleLibraryVersion and reports version 5; the client then closes both sockets, opens a fresh TCP+UDP session and connects again as version 5. robot.fci_version() says which one you got:

extern crate franka;
use franka::{FciVersion, RealtimeConfig, Robot};

fn main() -> franka::FrankaResult<()> {
let robot = Robot::new("172.16.0.2", RealtimeConfig::Enforce)?;   // FR3 or FER
match robot.fci_version() {
    FciVersion::V10 => println!("FR3"),
    FciVersion::V5 => println!("FER"),
}
Ok(()) }

Auto-detection costs an FER one full connect, a rejected handshake and a disconnect, i.e. two FCI client slots in sequence. A supervisor that reconnects in a loop, or any program that knows its arm, pins the version with VersionPolicy::Exact:

extern crate franka;
use franka::{FciVersion, RealtimeConfig, Robot, RobotOptions, VersionPolicy};

fn main() -> franka::FrankaResult<()> {
let robot = Robot::with_options(
    "172.16.0.2",
    RobotOptions::new(RealtimeConfig::Enforce).with_version(VersionPolicy::Exact(FciVersion::V5)),
)?;
Ok(()) }

VersionPolicy::Exact(FciVersion::V10) against an FER never retries and fails with FrankaError::IncompatibleVersion { server_version: 5, library_version: 10 }, which is what a v10 libfranka reports when pointed at an FER. Any server version other than 5 or 10 is returned as the same error.

One deliberate divergence from libfranka, which only inspects the handshake’s status byte: a Connect reply of kSuccess that reports a version other than the one the client announced is refused with IncompatibleVersion. Everything after the handshake is decoded against the announced version, so such a session would fail on the first state with Protocol("libfranka: incorrect object size") instead of naming the problem. franka-sim’s FER build answers kSuccess with version 5 whatever the client announced, and this rule is what lets auto-detection recognise it.

After the handshake, an FCI v10 session fetches the URDF (GetRobotModel) and derives the position-dependent joint velocity limits from it, as libfranka 0.21.2 does; a v5 session stops after the first state, as 0.9.2 does.

What differs on the wire

FCI v5 (FER)FCI v10 (FR3)
libfranka0.9.20.21.2
RobotState datagram2373 bytes, every field f64, no accelerometer arrays1377 bytes, mixed f64/f32
RobotCommand datagram370 bytes: MotionGeneratorCommand 306 + ControllerCommand 56 (tau_J_d alone)371 bytes: ControllerCommand 57, also carries torque_command_finished
tau_J_d in the statef64f32
command numberingConnect 0, Move 1, StopMove 2, GetCartesianLimit 3, SetCollisionBehavior 4, SetJointImpedance 5, SetCartesianImpedance 6, SetGuidingMode 7, SetEEToK 8, SetNEToEE 9, SetLoad 10, SetFilters 11, AutomaticErrorRecovery 12, LoadModelLibrary 13Connect 0, Move 1, StopMove 2, SetCollisionBehavior 3, SetJointImpedance 4, SetCartesianImpedance 5, SetGuidingMode 6, SetEEToK 7, SetNEToEE 8, SetLoad 9, AutomaticErrorRecovery 10, GetRobotModel 11
Move request56 bytes113 bytes: adds use_async_motion_generator and seven maximum_velocity values, always transmitted
Move generator modesfour, no Nonethe same four plus None (4)
Move::Status10 values12: PreemptedDueToActivatedSafetyFunctions (3) and CommandRejectedDueToActivatedSafetyFunctions (4) inserted after Preempted (2), so ReflexAborted is 6 on v5 and 8 on v10
torque control (no user motion generator)user torques + joint-velocity generator commanding zerosuser torques + MotionGeneratorMode::None
SetFiltersyes (Robot::set_filters)removed from the protocol
GetCartesianLimityes (Robot::virtual_wall)removed from the protocol
GetRobotModel (URDF)no such commandyes (Robot::robot_model)
LoadModelLibraryyes (Robot::load_model_from_robot)no such command
joint velocity limitsnone served; libfranka 0.9.2 limits flat: 2.175 rad/s (J1–4), 2.610 rad/s (J5–7)position-dependent envelope from the URDF
kTolNumberPacketsLost3.00.0
joint position limits (JOINT_POSITION_LIMITS, the <limit> of the URDFs the crate compiles in; the FR3’s is libfranka’s test/fr3.urdf, a few mrad wider than franka_description’s datasheet values)J1 ±2.8973, J2 ±1.7628, J3 ±2.8973, J4 [−3.0718, −0.0698], J5 ±2.8973, J6 [−0.0175, 3.7525], J7 ±2.8973J1 ±2.7501, J2 ±1.7918, J3 ±2.9065, J4 [−3.0481, −0.1458], J5 ±2.8101, J6 [0.5409, 4.5205], J7 ±3.0196
gripper protocolidentical (version 3, port 1338)identical

The status enums other than Move::Status are shorter on v5 in the same way (no safety-function values); the crate maps every v5 byte onto the v10 name set by name, and a byte that is not valid for the negotiated version is FrankaError::Protocol("libfranka: Unexpected response while handling <name> command!").

Commands a version does not have

Calling one fails client-side, before a byte is sent, with libfranka’s own text:

FrankaError::InvalidOperation("libfranka: Get Robot Model is not available on FCI version 5.")
FrankaError::InvalidOperation("libfranka: Set Filters is not available on FCI version 10.")

Two public items carry the version: CommandName::command(version) -> Option<u32> is the wire id of a command under a version (None where it does not exist), and the FER’s rate-limit constants live only at franka::rate_limiting::fer::*, with the FR3’s at the crate root.

Two IP fragments per state

The v5 state datagram, 2373 bytes, exceeds the 1500-byte Ethernet MTU, so every FER state arrives as exactly two IP fragments: 2000 packets per second for the host to service instead of 1000.

How the layout is pinned

Every wire struct is #[repr(C, packed)], and its size and every field offset are asserted against the declarations in libfranka’s service_types.h, rbk_types.h and the gripper’s types.h: crates/franka-rs/tests/wire_sizes.rs for FCI v10 (libfranka 0.21.2, including the 23-byte gripper state) and tests/wire_sizes_v5.rs for FCI v5 (libfranka 0.9.2, including the command numbering and the status tables). Both run offline, in CI’s check job. The mock-FCI unit tests under robot/mock_tests additionally pin the v5 Move payload, the torque-only datagrams and the LoadModelLibrary request byte for byte.

Wire parsing, the state demultiplexer, the rate limiter, the trajectory generator and the control loops are safe Rust; the crate’s unsafe is confined to the libc scheduler-priority calls in realtime.rs, the libc socket calls in network/udp.rs and network/tcp.rs, and the opt-in dlopen model loader (model/model_library.rs, model/so_backend/).

FER / Panda specifics

Everything in this book applies to a Franka Emika Robot (FER, also sold as Panda). This page lists the places where the robot, not the API, is different. An FER is Franka system 4.2.x, FCI version 5, and the reference client is libfranka 0.9.2.

The byte-level differences and the version negotiation are on FCI v10 and FCI v5 on the wire; the FER’s rate-limit constants on Rate limiting and filtering; the divergences of the FER simulator image on Simulator gaps.

Torque control on FCI v5

The Panda supports external joint-torque control. Both control_torques and start_torque_control send your commanded torques in tau_J_d. The difference below is how FCI v5 packages those commands with a required motion generator.

libfranka 0.9.2 has no MotionGeneratorMode::kNone, and its finishMotion always requires a motion-generator command. So franka::Robot::control(ControlCallback) runs a joint-velocity motion generator commanding zero velocity next to the external controller. The crate reproduces that, so on an FER:

  • control_torques and start_torque_control send Move { controller_mode: ExternalController, motion_generator_mode: JointVelocity };
  • every 370-byte command datagram carries your tau_J_d and a zero dq_c, which goes through the same low-pass filter and rate limiter a user’s velocity would;
  • the motion ends with motion_generation_finished: a v5 ControllerCommand has no torque_command_finished field to set;
  • the two callbacks run in 0.9.2’s order (motion first, controller short-circuited) rather than 0.21.2’s, because the order decides which cycle’s commands finishMotion sends.

None of that reaches the public API. control_torques_and_joint_positions and its siblings behave the same on both versions: there you supply the motion generator, so nothing is substituted.

The q_d seed rule

Seed a motion from state.q_d, not state.q.

On an FER the first setpoint of a motion is rate limited against the robot’s own q_d / O_T_EE_c like every other one, because libfranka 0.9.2’s convertMotion has no initialized_filter_ special case for the first command. On an FR3 the first command is its own reference and passes the limiter unchanged. Seed from q and you start one tracking error away from what the robot is commanding, and the limiter has to absorb that step, which at best distorts the start of your motion and at worst saturates.

This is why the README’s joint-move example reads robot.read_once()?.q_d rather than .q, and why the examples’ shared MotionGenerator seeds from q_d on an FER.

Joint-space continuity of Cartesian pose commands

The robot runs inverse kinematics on every pose a Cartesian pose motion generator sends and checks the continuity of the result in joint space, on top of the Cartesian velocity, acceleration and jerk checks. The client-side rate limiter, the crate’s with limit_rate = true or libfranka’s, only bounds the Cartesian side: its constants (rate_limiting::fer::MAX_TRANSLATIONAL_ACCELERATION = 13 m/s², jerk 6500 m/s³) are what the robot accepts there, not what the joint-side check accepts.

A Cartesian pose stream can therefore trigger cartesian_motion_generator_joint_velocity_discontinuity or cartesian_motion_generator_joint_acceleration_discontinuity even when its Cartesian velocity, acceleration and jerk are within their limits. The criterion includes the per-joint acceleration limits (rate_limiting::fer::MAX_JOINT_ACCELERATION, including 7.5 rad/s² on joint 2), applied to the joint motion implied by the poses. The mapping varies with configuration, so a Cartesian budget that works at one pose may fail at another.

These checks also apply to the FR3, whose nominal joint acceleration limit is 10 rad/s² (franka::MAX_JOINT_ACCELERATION).

A stream of stepped targets therefore needs its own, smaller budget with the loop’s limiter left on as the backstop. That is what target control does with 0.3 m/s, 0.5 m/s² and 20 m/s³ by default; the reasoning is on Online trajectory generation.

v5-only commands

#![allow(unused)]
fn main() {
extern crate franka;
use franka::Robot;
fn f(robot: &Robot) -> franka::FrankaResult<()> {
// Robot-side first-order low-pass cut-off frequencies in Hz: joint position,
// joint velocity, Cartesian position, Cartesian velocity, controller.
robot.set_filters(100.0, 100.0, 100.0, 100.0, 100.0)?;

// The virtual wall (`GetCartesianLimit`) with the given id.
let wall = robot.virtual_wall(1)?;
println!("{:?} {:?} {}", wall.object_world_size, wall.p_frame, wall.active);
Ok(()) }
}

set_filters is libfranka 0.9.2’s Robot::setFilters, dropped in 0.10. Franka’s own advice is to leave the robot-side filters at their defaults and filter in the client instead, which the crate’s control loops already do, so this is here for parity, not because you need it. On an FR3 both calls fail with InvalidOperation before anything is sent. The simulator’s GetCartesianLimit is a stub; only a real FER can confirm the field mapping of VirtualWallCuboid.

The model on an FER

Robot::load_model() on an FER needs no download: it returns the crate’s built-in model, whose parameters were identified from a real FER’s own libfcimodels.so, evaluated by the same native backend as the FR3’s URDF. Robot::robot_model() fails with InvalidOperation because an FER has no URDF to serve; Robot::load_model_from_robot() is the opt-in download-and-dlopen path. The fit, the agreement figures and the payload caveat are on Model parameters and conformance.

The joint-impedance example

examples/fer_joint_impedance.rs runs

tau = K (q_d - q) - D dq + coriolis(state)
K = [600, 600, 600, 600, 250, 150,  50]   Nm/rad
D = [ 50,  50,  50,  50,  30,  25,  15]   Nm s/rad

rate-limited by hand against the robot’s own tau_J_d with limit_rate_torques(&rate_limiting::fer::MAX_TORQUE_RATE, &tau, &state.tau_J_d), and run through control_torques with limit_rate = false and MAX_CUTOFF_FREQUENCY: the controller does its own limiting.

ActiveControl on the FER

Robot::start_torque_control() and the start_*_control motion-generator starters work on FCI v5. libfranka 0.9.2 has no equivalent: its API for that generation is the callback franka::Robot::control() only, with no startTorqueControl() / readOnce() / writeOnce(). The public API is identical on both versions; underneath, start_torque_control() on an FER starts the zero-velocity joint generator described above. The how-to is Drive the loop yourself.

Rate limiting and filtering

Every Robot::control_* method takes limit_rate: bool and cutoff_frequency: f64. This page is what those two arguments do, which constants they use on each robot, and where the same functions are available for a controller that limits by hand. The implementation is a port of libfranka’s rate_limiting.cpp, joint_velocity_limits.cpp and lowpass_filter.cpp (0.21.2 for the FR3, 0.9.2 for the FER’s constants), with libfranka’s unit tests ported alongside.

On the callback path

Each cycle, the command your callback returned is converted before it is sent:

  1. Low-pass filter. If cutoff_frequency < MAX_CUTOFF_FREQUENCY (1000 Hz), every commanded value is filtered first order against the robot’s echo of the last command (q_d, dq_d, O_T_EE_c with the rotation interpolated by slerp, O_dP_EE_c, elbow_c; tau_J_d for torques) with gain dt / (dt + 1 / (2π f_c)). DEFAULT_CUTOFF_FREQUENCY is 100 Hz; pass MAX_CUTOFF_FREQUENCY to skip the filter.
  2. Rate limiter. If limit_rate, the filtered command is clamped so that its velocity, acceleration and jerk relative to that same echo (q_d, dq_d, ddq_d; O_T_EE_c, O_dP_EE_c, O_ddP_EE_c; the elbow’s) stay within the version’s constants, and a torque so that its rate against tau_J_d stays within MAX_TORQUE_RATE.
  3. Validation. A non-finite value is FrankaError::InvalidArgument with libfranka’s text.

The first command of a motion is its own reference on an FR3 (libfranka 0.21.2’s initialized_filter_), and is limited against the echo like every other on an FER; that is the q_d seed rule.

libfranka’s defaults differ between the versions, and since Rust has no default arguments you choose explicitly:

limit_rate default in libfrankanote
FCI v10 (libfranka 0.21)falseThe FR3 does its own limiting; the client-side limiter can distort a motion.
FCI v5 (libfranka 0.9.2)trueThe FER-era default.

cutoff_frequency defaults to DEFAULT_CUTOFF_FREQUENCY in both.

Neither the filter nor the limiter runs on the ActiveControl path (read_once / write_once), here or in libfranka: there smooth setpoints are the caller’s job. Target control runs its loop with limit_rate on (default) and the filter off, and additionally calls limit_rate_cartesian_pose / limit_rate_joint_positions under its own, smaller budget as a backstop that is not meant to bind; see Online trajectory generation.

The constants

Which table the loop uses is decided by the negotiated version, not by you. The FR3’s live at the crate root (franka::MAX_JOINT_JERK and neighbours, mirroring libfranka’s franka:: namespace); the FER’s only at franka::rate_limiting::fer::*, so the two constant tables cannot be confused at a glance. Nominal values, from crates/franka-rs/src/rate_limiting/:

constantFR3, franka::*FER, rate_limiting::fer::*
TOL_NUMBER_PACKETS_LOST0.03.0
MAX_TORQUE_RATE (Nm/s, per joint)10001000
MAX_JOINT_JERK (rad/s³)5000, all joints7500, 3750, 5000, 6250, 7500, 10000, 10000
MAX_JOINT_ACCELERATION (rad/s²)10, all joints15, 7.5, 10, 12.5, 15, 20, 20
joint velocity (rad/s)position-dependent envelope (below), saturating at 2.62, 2.62, 2.62, 2.62, 5.26, 4.18, 5.26MAX_JOINT_VELOCITY, flat: 2.175 (J1–4), 2.61 (J5–7); MIN_JOINT_VELOCITY is its negation
MAX_TRANSLATIONAL_VELOCITY (m/s)3.02.0
MAX_TRANSLATIONAL_ACCELERATION (m/s²)9.013.0
MAX_TRANSLATIONAL_JERK (m/s³)45006500
MAX_ROTATIONAL_VELOCITY (rad/s)2.52.5
MAX_ROTATIONAL_ACCELERATION (rad/s²)1725
MAX_ROTATIONAL_JERK (rad/s³)850012500
MAX_ELBOW_VELOCITY (rad/s)1.52.175
MAX_ELBOW_ACCELERATION (rad/s²)1010
MAX_ELBOW_JERK (rad/s³)50005000

Every constant is the nominal value less LIMIT_EPS (1e-3), and every velocity limit is further reduced by TOL_NUMBER_PACKETS_LOST · DELTA_T · a_max, the velocity the axis could pick up over that many lost 1 ms cycles at its maximum acceleration: nothing on the FR3, and on the FER 0.039 m/s off the translational velocity and 0.045 rad/s off joint 1’s, for instance. DELTA_T is 1e-3, NORM_EPS is f64::EPSILON, and FACTOR_CARTESIAN_ROTATION_POSE_INTERFACE (0.99) multiplies the three rotational limits inside limit_rate_cartesian_pose, on both versions.

Two rows follow libfranka’s rate limiter rather than Franka’s published specifications: the FR3’s MAX_ELBOW_VELOCITY is 1.5 rad/s where the specification gives 2.620, and the FER’s MAX_TRANSLATIONAL_VELOCITY is 2.0 m/s where the specification gives 1.7. The table is what the limiting functions apply, and these functions are a port of libfranka’s.

The FR3’s joint velocity envelope

On an FR3 the joint velocity limit depends on the joint position: it shrinks towards a joint limit so that the joint can still decelerate before reaching it,

upper(q) = min(v_max, max(0, -v_offset + sqrt(max(0, 2 a_dec (q_upper - q))))) - tolerance
lower(q) = max(-v_max, min(0,  v_offset - sqrt(max(0, 2 a_dec (q - q_lower))))) + tolerance

with the parameters read from the <position_based_velocity_limits> element of the URDF the robot serves (joint_velocity_limits module: JointVelocityLimitsConfig::from_urdf, upper_limits(&q), lower_limits(&q)). Robot::upper_joint_velocity_limits(&q) and lower_joint_velocity_limits(&q) return the envelope of the connected robot; on an FER they return the flat fer::MAX_JOINT_VELOCITY / MIN_JOINT_VELOCITY and ignore q. franka::compute_upper_limits_joint_velocity / compute_lower_limits_joint_velocity are the deprecated libfranka versions with the FR3 parameters hardcoded; their offsets and position limits are not those of Franka’s FR3 specifications (joint 1: 0.30 and 2.7501 against 0.6599 and 2.9007). Franka publishes no corresponding envelope for the FER, and neither libfranka nor franka_description provides one; the FER path uses the flat limits. Target control’s torque backend follows the FR3’s envelope and brakes toward the position limits on both arms, see the joint position limit guard.

The torque-rate margin on FCI v10

The robot judges the rate of a torque command against the f64 it last received, but publishes tau_J_d as an f32 on FCI v10. At |τ| ≥ 64 Nm one f32 ULP is 2⁻¹⁷ Nm, and the half-ULP the echo can be off by is worth up to about 3.8e-3 Nm/s over a 1 ms cycle, more than the 1e-3 Nm/s MAX_TORQUE_RATE keeps below the nominal 1000. A saturated command limited against the quantised echo could therefore read as up to about 1000.0028 Nm/s at the robot. The control loop shrinks each joint’s maximum torque rate per cycle by f32::EPSILON · |tau_J_d| / 2 / DELTA_T on v10 only; limit_rate_torques itself stays an exact port of franka::limitRate, and on v5, where tau_J_d is an f64, the limiting is exact and the margin is off. The margin is zero at tau_J_d == 0.

Limiting by hand

The functions are public, at the crate root and under franka::rate_limiting, and take their bounds as arguments, so they serve either robot’s constants:

Rustlibfranka overload
limit_rate_torques(max_derivatives, commanded, last)limitRate(max_derivatives, ...)
limit_rate_joint_velocity(...), limit_rate_joint_position(...)the scalar overloads
limit_rate_joint_velocities(...), limit_rate_joint_positions(...)the array<double, 7> overloads
limit_rate_cartesian_velocity(...)the O_dP_EE_c overload
limit_rate_cartesian_pose(...)the O_T_EE_c overload
low_pass_filter(dt, y, y_last, f_c), cartesian_low_pass_filter(dt, y, y_last, f_c)lowpassFilter, cartesianLowpassFilter

Where the C++ throws std::invalid_argument, these return FrankaError::InvalidArgument with the same message. examples/fer_joint_impedance.rs is the pattern: control_torques with limit_rate = false and MAX_CUTOFF_FREQUENCY, and limit_rate_torques(&rate_limiting::fer::MAX_TORQUE_RATE, &tau, &state.tau_J_d) inside the callback.

Online trajectory generation

The otg module turns a stream of stepped targets into a 1 kHz command whose velocity, acceleration and jerk never exceed a budget. It is what target control runs on its thread, and it is public (Otg, MultiOtg<N>, CartesianOtg, OtgLimits) for a loop of your own. This page is the reasoning: why a generator and not a filter, how it plans, the three rules for putting it in a control loop, why its budget is smaller than the rate limiter’s, and what the loop does every cycle.

The problem

Most programs that want to move the arm are not 1 kHz programs: a planner, a vision loop, a script on a socket, a person at a keyboard. What they produce is a stream of targets, irregular, sometimes bursty, sometimes silent for seconds, and every one of them a step. Sent as is, a 5 cm step reaches the robot as a 50 m/s jump and the motion generator refuses it with cartesian_motion_generator_velocity_discontinuity (that is what nonrealtime_commander --raw demonstrates). Something has to turn the steps into a motion, causally, with only the latest target in hand.

The alternatives each fail one requirement. A spline needs future knots, and a commander at 10 Hz, or silent for two seconds, cannot supply them. A quintic re-fitted from the current state to the target every cycle is smooth but has no notion of the limits: its peak velocity and acceleration scale with the step and shrink with the chosen duration, and no duration is right for both a 1 mm and a 10 cm step. A first-order low-pass filter is causal and cheap, but its first-cycle demand is the step times the gain (0.31 m/s for 5 cm at 1 Hz), so the rate limiter behind it does the real work, the peak speed still scales with the step, and the approach is exponential, never quite arriving.

What the generator does

An online trajectory generator is a small state machine that owns the commanded position, velocity and acceleration and, every cycle, re-plans the time-optimal jerk-limited profile from that state to rest at whatever the latest target is, then follows it for one cycle. It is causal (only the latest target), limit-respecting by construction (velocity, acceleration and jerk within the budget on every cycle, including a 2 ms one after a lost packet), C2 (the acceleration is continuous; only the jerk switches), time-optimal within its profile family, and it lands exactly on the target and stays there.

The plan is the seven-segment profile: a jerk-limited transfer from the current (v, a) to a peak velocity v_p with zero acceleration (three segments with closed-form durations), a cruise at v_p, and the mirror transfer to rest. The one free parameter is v_p: ±v_max with a cruise when the target is far enough, otherwise a root of the displacement function, which is increasing in v_p except for a hump next to v_rd = v + a|a| / (2 j_max), the velocity reached by ramping the acceleration straight to zero. So the domain is split at v_rd and v_rd ± a_max² / j_max, every piece whose ends bracket the target is bisected, and the shortest plan wins. This is the profile structure of Haschke, Weitnauer and Ritter (On-line planning of time-optimal, jerk-limited trajectories, IROS 2008), evaluated one cycle at a time the way Ruckig (Berscheid and Kröger, RSS 2021) does. No dependencies, no allocation.

A target that moves mid-motion is re-planned from the current velocity and acceleration. A target closer than the braking distance gives a root of the opposite sign: the profile passes the target, brakes, and comes back, within the limits and without a jerk spike. A target that stays for seconds is reached and held exactly; the target velocity is always zero, so this is a generator for positional targets, not for velocity tracking.

#![allow(unused)]
fn main() {
extern crate franka;
use franka::otg::{Otg, OtgLimits};
let limits = OtgLimits { max_velocity: 0.3, max_acceleration: 0.5, max_jerk: 20.0 };
let mut otg = Otg::new(0.0, limits).unwrap();
otg.set_target(0.05).unwrap();
let mut t = 0.0f64;
while otg.position() != 0.05 {
    otg.step(0.001);
    t += 0.001;
}
// The full norm budget on one axis; the target-control loop gives each axis 1/sqrt(3)
// of it, which stretches the same step to about 0.85 s.
assert!((t - 0.658).abs() < 0.01, "a 5 cm S-curve under these limits takes 0.658 s");
}

MultiOtg<N> runs one generator per axis (with_limits gives each axis limits of its own, which seven joints need) and, when synchronised, stretches the faster axes to the slowest one’s duration by lowering their peak velocity (a second bisection); an axis that is exactly braking to its target has no peak to lower and keeps its minimum duration. CartesianOtg is MultiOtg<3>. Synchronised axes arrive together, so a diagonal target moves along a straight line.

The property test

crates/franka-rs/src/otg/tests.rs simulates two thousand random target sequences (every third with random limits): steps at random times, holds up to three seconds, bursts of twenty targets 5 ms apart, gaps. Every cycle it asserts that the velocity, acceleration and finite-difference jerk stay within the limits; that a target beyond the braking distance is never overshot; that every target is reached within 10 % of the time an admissible brake-then-move profile would take; and, on the synchronised three-axis variant that every eighth sequence also runs, that an axis at rest whose target did not change stays bit-exact.

The three rules

The generator and the rate limiter must use compatible budgets and state. Otherwise the limiter can alter the command while the generator continues planning from an unsent position. The resulting lag can produce oscillation: the limiter bounds derivatives but does not plan braking to a target. The module’s regression tests cover this interaction. Three rules keep the two in sync.

  1. The limits are per axis. Two axes at full acceleration have a vector norm √2 above it, and a Cartesian budget is a norm (that is what limit_rate_cartesian_pose bounds). So the generator gets OtgLimits::per_axis_for_norm(3): each limit divided by √3, which is also what keeps a synchronised diagonal move inside the budget.
  2. Step one nominal cycle per command (DELTA_T, 1 ms), not the measured period. The robot and the rate limiter check every packet against a 1 ms budget, so a 2 ms step after a lost packet is a doubled velocity to them.
  3. Re-anchor on the robot’s echo of the position every cycle with set_position(O_T_EE_c) (or q_d), so that whatever runs behind the generator can shape one command but never accumulate a lag it plans against. The position only: the echoed twist is a mean over the cycle, half an acceleration step behind the generator’s end-of-cycle state, and re-anchoring on it throttles the plan to a crawl.

A fourth rule applies only to a caller that moves the limits of a running generator, with Otg::set_limits or MultiOtg::set_limits. Those replace the limits the generator plans under, keep its position, velocity, acceleration and target exactly as they are, and move nothing themselves; a limit that is not finite and positive is refused with the limits unchanged, MultiOtg’s all or nothing across the axes. Every step plans afresh from the state, so there is no segment in flight to invalidate: the new limits are in force from the next command. Raising one may therefore step. Lowering one below the state the generator is already in may not: nothing in set_limits clamps, but set_state — which rule 3’s set_position runs every cycle — clamps the stored velocity and acceleration into the limits, and the end of every step clamps them again. A velocity truncated by dv in one cycle is dv / dt of acceleration in the command, a thousand times dv at 1 kHz. So a lowered limit is walked down: max_velocity no faster than the max_acceleration in force, max_acceleration no faster than max_jerk. Each clamp is then at most one cycle of the next order, which is the generator’s own bound rather than an impulse. max_jerk is not stored in the state and clamps nothing, so it may step either way.

Per-axis budgets and nominal stepping prevent the backstop from binding under normal conditions. Re-anchoring prevents any remaining alteration from accumulating as position error. Re-anchoring alone does not ensure exact landing: a vector-norm clamp can distort one axis’s corrections while another saturates.

Why the budget is smaller than the limiter’s

The crate’s rate limiter with limit_rate = true is a port of libfranka’s, and its constants (13 m/s² and 6500 m/s³ on an FER, 9 m/s² and 4500 m/s³ on an FR3) are what the robot accepts in Cartesian space. The robot also runs inverse kinematics on every commanded pose and checks the continuity of the result in joint space, and that is the check a stepped target stream can trip. A Cartesian acceleration within the limiter’s budget can still exceed a joint’s acceleration limit, depending on the pose and the inverse-kinematics mapping. See FER / Panda specifics.

Collision thresholds also apply independently of the kinematic limits. The external-force estimate O_F_ext_hat_K can cross a configured threshold during motion and raise cartesian_reflex, even when the commanded derivatives remain within their budgets. Configure thresholds for the task; the commander example sets libfranka’s example thresholds explicitly.

Hence the defaults: a translational budget of 0.3 m/s, 0.5 m/s², 20 m/s³, under which a 5 cm step along one axis becomes an S-curve that peaks at about 0.12 m/s and lands after about 0.85 s (each axis gets 1/√3 of the norm budget); a rotational budget of 0.5 rad/s, 1.0 rad/s², 20 rad/s³ (a fifth of the FR3’s rotational velocity limit and a seventeenth of its acceleration limit); and for joints 20 % of the negotiated version’s joint limits (JointTargetControlOptions::scaled_limits(version, fraction)).

The backstop behind the generator, limit_rate_cartesian_pose under the same budget, must never bind, and to keep it from binding on noise it references the twist and acceleration it sent, not the echoed O_dP_EE_c / O_ddP_EE_c: on FCI v10 the echo is float32, and the rounding of a rotation matrix is worth about 200 rad/s³ of jerk against a 20 rad/s³ budget. The rotational axes of the generator also carry libfranka’s pose-interface factor (0.99) that the backstop applies to its rotational limits; without it the backstop binds on every synchronised jerk.

What the loop does every cycle

start_cartesian_target_control / start_joint_target_control spawn a named thread that runs control_cartesian_pose / control_joint_positions with the loop’s own rate limiter on and the low-pass filter off. Every cycle, on that thread:

  1. Anchor. The first cycle takes the robot’s echo of its commanded position (O_T_EE_c, q_d) as the start, the initial target and the first setpoint, so the first command of the motion is the echo itself: on FCI v10 the first command is its own filter reference and would otherwise go out as a jump. start_* returns only once that cycle has run, so target() and state() are valid from the first call.
  2. Read the slot. The latest target comes through a single-writer seqlock (robot::target_control::TargetSlot) the loop polls without blocking; a torn read keeps the previous target for that one cycle. set_* serialises its callers with a mutex on the user side only. The stop flag is read before the slot, so a target published just before stop() is not lost.
  3. Generate under the three rules. Per-axis limits (the Cartesian budgets are norms and get per_axis_for_norm(3); the joint limits are per joint already), one DELTA_T per command whatever the measured period, set_position on the echo before every re-plan, axes synchronised. The orientation runs on three more axes of the same generator, on the rotation vector of the orientation error in the base frame (log(R_target R_echo^T)), re-anchored at zero every cycle and composed back as exp(step) R_echo: a constant-axis turn under rotation_limits, arriving together with the translation.
  4. Backstop. limit_rate_cartesian_pose / limit_rate_joint_positions under the same budget (the joint one tightened to the robot’s velocity envelope at q), against the echo, with the loop’s libfranka limiter behind it. Neither is meant to bind; the observer is told by how much the backstop moved the command (backstop_alteration).
  5. Guard. If the measured position strays more than max_deviation from the start (0.30 m Cartesian, 1.0 rad joint by default) or the measured orientation turns more than max_angular_deviation (0.5 rad), the target freezes where the command is, the generator brings it to rest, and the loop ends with FrankaError::Control carrying target_control::DEVIATION_MESSAGE.
  6. Land, hold, finish. After stop() the generator runs on until every axis has landed: within Settle::tolerance of the target (1e-3: 1 mm or 1 mrad), slower than REST_VELOCITY (1e-4 m/s or rad/s) and accelerating less than REST_ACCELERATION (0.05). The rest thresholds limit the discontinuity when entering the hold while allowing for the small residual motion caused by the FR3’s float32 command echo. Then the loop stops stepping the generator and sends the robot’s echo of the last command, bit for bit and past the backstop, for Settle::cycles cycles (250), and sets motion_finished on one more of it. A motion never finishes on a moving command: the robot refuses exactly that with cartesian_motion_generator_velocity_discontinuity. If the generator has not landed within STOP_TIMEOUT_CYCLES (5000, five seconds) the same hold starts from wherever the command is.

Nothing allocates on the realtime thread after the start; the observer, called every cycle with the state and what was sent, must keep it that way. Dropping a handle without stop() requests the stop and detaches: the loop settles and finishes on its own, holding its Arc<Robot> until it has.

The impedance backend

Target control tracks the generator’s setpoints with torques of its own by default. This page is the derivation: where the law comes from, why it is a joint law with a Cartesian term rather than the other way round, what the joint gains do to the stiffness you feel, how the Cartesian interface gets a joint goal, how the goal and the arm are kept under the joint velocity limits and inside the joint position limits, how the generator is anchored without an echo, what happens at the handover, and how it differs from the operational-space law of the cartesian_impedance_active_control example. The options and defaults are on the how-to page; the code is robot/target_control/impedance.rs, torque/, ik/, position.rs and velocity.rs.

The law and its provenance

Kp  = Jᵀ Kx J + diag(Kq)          Kd = Jᵀ Kxd J + diag(Kqd)
tau = Kp (q_goal − q) + Kd (g dq_goal − dq) + coriolis(q, dq),   clamped to ±torque_limits

This is the structure of HybridJointImpedanceControl from polymetis (its feedback module HybridJointSpacePD forms Kp and Kd exactly so), the controller that DROID (Khazatsky et al., 2024, DROID: A Large-Scale In-The-Wild Robot Manipulation Dataset, arXiv:2403.12945) ran on its Panda arms for 76k teleoperated trajectories. ImpedanceGains::DROID is its gains as they were, and with velocity_feedforward off (g = 0, the damping on the absolute velocity, the default) the law is polymetis’s. ImpedanceGains::CARTESIAN differs from it: it raises the translational damping from 37 to 50, 50, 90 Ns/m: the arm’s apparent mass at the end effector near the ready pose is about 0.94 kg along x and y and 3.9 kg along z (from the model’s mass matrix), so 37 Ns/m against 750 N/m leaves z at a damping ratio of 0.34, which rings; the defaults bring all three to about 0.8. Damping the absolute velocity resists the motion the goal asks for, and a goal moving at v is tracked Kd v / Kp behind it. velocity_feedforward = true makes g the velocity_feedforward_gain (default 1), with dq_goal the goal’s velocity: the generator’s on the joint interface, the finite difference of the IK solution on the Cartesian one, zero while holding. It is off by default because real arms vibrated with it on: Kd carries the finite difference’s noise into the torque. velocity_feedforward_cutoff low-pass filters dq_goal and is the knob to try against it; no value has been validated on hardware. J is Model::zero_jacobian(Frame::EndEffector, state), the 6x7 base-frame Jacobian at the measured q, so Kx acts at the configured end-effector frame, the frame the O_T_EE targets are in. Gravity is compensated by the robot; coriolis is the model’s, as in every torque example of the crate. The clamp comes before the low-pass filter (cutoff_frequency, 100 Hz by default) and the torque-rate limiter that control_torques applies, in that order.

Why a joint law with a Cartesian term

Jᵀ Kx J is the Cartesian spring expressed in joint coordinates: for a small joint error Δq, J Δq is the end-effector error and Jᵀ Kx J Δq the joint torque of the spring Kx pulling on it. It dominates the stiffness: a joint whose lever arm to the end effector is 0.5 m sees 750 N/m as 750 × 0.5² ≈ 190 Nm/rad, against 25 to 50 Nm/rad in Kq. But on a seven-joint arm the 6x7 Jacobian has a one-dimensional nullspace, the motion that leaves the end effector where it is (the elbow’s swing), and Jᵀ Kx J is zero along it. diag(Kq) regularises that direction, and near a singularity, where the Cartesian term collapses in more directions, it keeps every joint held. So the Cartesian gains set the compliance you feel at the tool and the joint gains set how firmly the arm keeps its shape. The joint interface uses the same code with Kx = Kxd = 0, which leaves a joint PD with Coriolis feed-forward, the fer_joint_impedance example’s law and gains.

What the joint gains do to the stiffness you feel

diag(Kq) is not confined to the nullspace: it acts on every joint, and the part of it that J maps to the end effector adds to Kx. The stiffness felt at the tool is (J Kp⁻¹ Jᵀ)⁻¹, and with the default gains at the ready pose that is about 990 to 1180 N/m in translation against the 750 N/m set, and two to three times Kx in rotation (computed from the model). DROID ran the law this way. ImpedanceOptions::project_joint_gains replaces diag(Kq) and diag(Kqd) by N Kq N and N Kqd N, with N = I − J⁺ J the nullspace projector of the damped pseudoinverse J⁺ = Jᵀ (J Jᵀ + λ² I)⁻¹ (λ the IK’s damping, 0.05): the joint springs then hold the elbow’s swing and nothing else, and the end effector feels Kx to within the damping of the projector, away from singularities, where N widens and the projected springs take over the collapsing directions. The price is that with the projection on the Cartesian gains are all that holds the tool, so zero Kx would leave it free. It is off by default to keep the default law DROID’s in structure.

Payload

Gravity is not in the law: the robot compensates it from the load configured in Desk or by Robot::set_load. The robot’s own controller corrects a wrong load as a position error; a spring does not. An unconfigured tool becomes a constant sag of its weight over the felt stiffness, 0.5 kg → 5 N → 6.7 mm at 750 N/m (less at the felt ~1000 N/m), in every pose, and a load heavier than configured pulls the same way. set_load matters more here than in position mode.

The differential inverse kinematics

The joint interface feeds the generator’s q in as q_goal. The Cartesian interface needs a joint goal for a pose, and gets one incrementally: from the previous q_goal, up to IkOptions::iterations (3) damped-least-squares steps toward the desired pose per cycle,

ẽ   = W [ p_des − p(q_goal) ;  log(R_des R(q_goal)ᵀ) ],   W = diag(I₃, w_r I₃),   J̃ = W J_g
δ   = argmin ½ ‖J̃ δ − ẽ‖² + ½ λ² ‖δ‖²   subject to   lb ≤ δ ≤ ub
      + (I − J̃_F⁺ J̃_F) k_null (posture − q_goal) dt       on the free joints F

with J_g the zero Jacobian at q_goal (not at the measured q), λ = 0.05 the damping that keeps the step finite at a singularity, w_r = IkOptions::rotation_weight (0.1 m/rad), [lb, ub] the cycle’s box of the joint position limit guard and the last term a drift toward posture (the start configuration unless set) at k_null = 1 /s, capped at 0.5 rad/s so that a far posture is approached rather than jumped at, projected into the free joints’ nullspace so it never moves the end effector. Iteration stops once ‖ẽ‖ is below tolerance (1e-6, so 1 µm or 10 µrad). The limits are rate_limiting::JOINT_POSITION_LIMITS for the FR3 and rate_limiting::fer::JOINT_POSITION_LIMITS for the FER, from the URDFs in the repository. The generator moves the pose by at most 0.3 mm a cycle under the default budget, so a step or three from the previous solution keeps the residual near zero; the observer sees ‖ẽ‖ as CartesianSent::ik_error. A pose out of reach, through a singularity or behind a joint limit leaves a residual and q_goal lags instead of jumping, so the impedance never gets a step to track.

The joint velocity envelope

Nothing in the law bounds a joint’s velocity: near a wrist singularity a modest turn of the hand asks joints 5 and 7 to spin in opposite directions, about 2 rad/s of joint per rad/s of hand once their axes are 30° apart. Teleoperated near such a pose, an FER can end in joint_velocity_violation while the generator is well inside its own budget and the arm follows the goal it is given.

The cap. No joint of the goal’s step Δq = q_goal − q_goal_prev exceeds cap_i · 1 ms, cap_i = joint_velocity_fraction × limit_i (0.7 by default), before the finite difference that is dq_goal. On the joint interface the step is scaled as a whole by s = min(1, min_i cap_i · 1 ms / |Δq_i|), which keeps the joint-space direction. On the Cartesian interface the cap is a bound of the IK’s box, so the free joints make up what a capped one cannot; a whole-step scale behind the IK remains as a backstop that does not bind but for rounding. When the goal falls short the generator’s next cycle starts from what went out, not from its own plan: its velocity is its end-of-cycle velocity scaled by s, not the step’s mean (which sits above the end velocity whenever the plan brakes inside the step, and would replan past the target), and its acceleration is kept only where it brakes – zeroed where it would speed the axis back up (against a position limit, see the restart). The leash’s next reference is the capped goal too: the model’s forward kinematics of q_goal on the Cartesian interface, q_goal itself on the joint one. Without the re-anchor the generator would keep planning from the velocity it wanted and arrive late, then decelerate from a speed the arm never had. A stop’s hold on the joint interface is not capped: that goal only moves with an arm moved by hand, a leash ahead of it.

The fade. A joint lagging its capped goal catches up faster than the goal moves, and unchecked that catch-up pull cancels the barrier below. So above min(cap_i, onset_i − FADE_BAND × limit_i) of measured speed (never below rest), the law’s torque along the joint’s motion – the Coriolis term included – fades out linearly to none at the barrier’s onset; torque against the motion is never scaled. Away from a position limit the min floors the start FADE_BAND (0.15) under the onset regardless of the cap, because a narrower band adds more gain than the loop tolerates: at the default cap 0.7 the band is exactly [0.70, 0.85] of the limit, the same band a cap of 0.8 gets once floored, and both settle; fading instead from a cap of 0.8 straight to the onset – a third of that band – rings on a 0.03 kg m² wrist (tests/velocity.rs). Inside the position margin the band is [0, onset] instead, which on an arm whose limit is flat – the FER’s – is the narrower of the two; what bounds the gain there is the position fade below.

The barrier. Above velocity_barrier_fraction × limit_i (0.85 by default) of measured velocity the loop adds −20 (|dq_i| − onset_i) sign(dq_i) Nm, at most the joint’s torque limit, after the fade and before the clamp, the low-pass and the rate limiter. The gain is set by stability, not by strength: the torque is computed from the velocity measured one cycle before it acts and low-passed at 100 Hz, so a joint of effective inertia I damped with a total K – the law’s own joint damping plus the barrier’s, where it is active – is stable while K × 1 ms / I stays under 1; the rate limiter only slows the barrier’s onset. The link alone is light (0.003 kg m² on the FER’s joint 7) but the drive’s reflected inertia is not (MuJoCo’s FR3 carries 0.074 kg m² of armature on the wrist), and the JOINT preset’s 15 Nm s/rad on joint 7 is stable on an FER; the Cartesian preset puts at most about 6 Nm s/rad on a wrist joint at 1200 N/m, so the barrier’s 20 keeps K under 26. The offline velocity tests exercise the barrier behind the filter and rate limiter (tests/velocity.rs). The stability bound assumes that one cycle of delay; it says nothing about the push itself and does not claim to bound it – the velocity still overshoots the limit while the push is applied, before the barrier settles it back under.

Tests. tests/replay.rs provides an optional replay check for the velocity cap, tracking lag and error. tests/sim_target_control/velocity_cap.rs exercises the cap in the simulator. These tests do not establish a bound on externally imposed motion.

The barrier’s gain is lowered in proportion where torque_limits clamps a joint below its preset (86 Nm on joints 1 to 4, 11.5 Nm on 5 to 7), so a lighter clamp does not leave a stiffer barrier; at the presets it is 20 on every joint.

The joint position limit guard

Clamping a goal inside a joint limit does not ensure that the moving arm can stop there. The guard uses braking bounds in the IK, a stall flag that re-anchors the generator, and a torque fade and spring near the measured joint limit. Three rules keep it independent of the tool:

  • Joint space only. Margin, box, pins, fade and spring are per joint; there is no workspace or link geometry.
  • The tool is the robot’s. F_T_EE and EE_T_K come from the state, the frame the targets are in. The rotation weight acts at that point: a 0.25 m tool with the end effector left at the flange turns 0.1 rad of rotation shortfall into 25 mm at the tip, so configure the tip.
  • No tool-specific constant. Flange, Franka Hand, a third-party gripper and a long tool run the same numbers; the IK tests run all four.

The box. Per joint and side, with s the previous goal’s distance to that limit, m = joint_position_margin (0.05 rad), f = joint_velocity_fraction and x = s − m:

E(x)  = min(v_flat, sqrt(k x + c²) − c)  for x > 0, else 0     braking envelope
u     = min(f · v_σ · Δt,  max(0, min(x, f · E(x) · Δt)))       step allowed toward that side

v_σ is the arm’s velocity limit toward that side. On the FR3 it is position-dependent: min(dq_max, max(0, sqrt(2 ddq_dec d) − dq_offset)) at distance d from the limit, less libfranka’s tolerance, using Franka’s robot-specification parameters. On the FER it uses the flat MAX_JOINT_VELOCITY; no published position-dependent coefficients are available for that arm.

E is the guard’s own braking profile, sqrt(k x + c²) − c with k = 2a, held under v_flat, the joint’s velocity limit away from the position limits (the FER’s flat MAX_JOINT_VELOCITY, the FR3’s dq_max): it stays under sqrt(2 a x), the speed a joint decelerating at a still stops from in x, and c (0.2 to 0.35 rad/s, libfranka’s FR3 offsets) keeps its slope at the stop finite. a is the FR3’s published ddq_dec (2.585 to 17 rad/s²) on that arm and half the FER’s published joint acceleration limit (3.75 to 10 rad/s²) on the FER, which leaves the other half to the arm following the goal and, unlike the FR3 constants, stays inside the FER’s own rating on every joint — 10 mrad before the stop it allows 0.19 rad/s on joint 1 and 0.17 on joint 5, against 0.16 and 0.33 for the FR3’s. v_σ and E are both taken where the step ends (u ≤ f · v_σ(from ± u) · Δt, u ≤ f · E(x − u) · Δt), and the loop’s onset and fade start below at the arm and a cycle on. The box always contains 0: a goal started inside the margin stays where it is rather than jumping out.

The solve. A primal active set, warm-started from the last cycle: each pass pins the free joint that violates its bound most, or releases the pinned joint whose multiplier points inward, and re-solves the free joints with the pinned ones on their bounds, at most 12 passes per solve (CartesianSent::ik_passes, over the cycle’s solves). The result is the exact box-constrained least-squares step, so the free joints take over what a pinned one cannot give, and it is continuous however the set changes, with no dwell. A solve that does not converge is clipped into the box; a non-finite one keeps the previous goal. Fixed-size, nothing allocates. The posture is clamped m + 0.3 rad inside the limits and its pull fades out as the nearest joint closes from m + 0.3 to m, so it cannot drive a joint along the margin.

Pins and the stall. pinned reports per joint 0 free, ∓1 on the lower / upper position bound, ∓2 on a velocity bound. A pin’s pressure is side · ĵ_iᵀ ẽ, the residual along its unit column and only into its bound, over the whole weighted task or its translation rows alone, whichever pushes harder; above 2e-5 (weighted m per cycle) on a position pin the goal is stalled, cleared on 20 of the last 40 cycles in which no position pin pushes at all. Position first takes the goal as far as the pins allow, which lowers the very pressure that set the flag, so a flag cleared on a magnitude can cycle between the two modes. The generator is re-anchored on its own anchor moved by the goal’s own step, and never by more than it planned, which keeps the IK’s lag behind it without a goal closing a lag dragging the desired along. What went out is always the goal’s own step. While held, every cycle, its velocity and acceleration into the wall are removed per block (translation, rotation), the lead into the wall with them, and what is left is cut to the speed the goal’s step carried, but for a cycle of its acceleration: the generator neither winds up behind a wall nor runs ahead of a goal that follows only in part. The wall is the direction of the motion the pins withhold and the free joints cannot make up, refreshed by every push and dropped 20 cycles after the last. On a velocity pin alone each block restarts at the fraction of its step that went out, which cap_scale reports (exactly 1 on every other cycle). The removal is one-sided, so a reversed target leaves the generator as fast as a start from rest. A stop() held at a wall the target lies beyond counts as landed once the goal is within Settle::tolerance of the target on every other axis and has come no tolerance closer to it for the time a generator needs from rest to cover two of them (0.1 s at the default budget). On the joint interface the step is scaled as a whole into the box, and a joint target, like a posture, is refused inside max(JOINT_LIMIT_INSET, m), so a joint goal always reaches its target. The one goal not taken through the box is the joint hold: it is the leashed measured anchor, which lies between the frozen goal and the measured q and so is never further out than the arm.

At the measured arm. With b = m − POSITION_FADE_BAND (0.03 rad), the law’s torque toward a limit fades out linearly as the measured joint closes from m to b; torque away from it is never scaled. Inside b a spring of POSITION_BARRIER_STIFFNESS × torque_limits_i per rad (1075 Nm/rad at 86 Nm, 144 at 11.5) pushes the joint out, at most its clamp, ramped in over the loop’s first 500 cycles so an arm started inside eases out. The velocity barrier’s onset toward the limit drops to velocity_barrier_fraction × E(s − b) and the velocity fade’s cap to f × E(s − m), both at most the arm’s limit there. A 0.02 rad overshoot of the margin reaches b; 10 mrad from the limit the spring gives 21.5 Nm on joints 1 to 4 and 2.9 Nm on the wrist. tau_position is the spring less the faded torque, so tau = clamp(law + tau_envelope + tau_position).

The two fades multiply, and inside the margin the velocity fade has no band of its own: its cap is f × E(s − m), which is 0 at and past the margin, so it starts at rest and runs to the onset rather than over FADE_BAND of the limit. On the FER, whose limit is flat, that is the narrower band, so what bounds the gain there is this position fade taking the same push to nothing over the same rad. Both vanish linearly at b, so the product stays finite; it rises across the margin and peaks at m, at |law| / (velocity_barrier_fraction × E(POSITION_FADE_BAND)). At the 86 Nm clamp and the default fraction that is 429 Nm s/rad on joint 2 and 357 on joint 4, 1.61 and 1.33 times the |law| / (FADE_BAND × limit) the band alone would give (266 and 268) (tests/torque_position.rs); across the arm the factor runs 1.16 to 1.61, and the cycle’s lookahead adds about another 1 %. On the FR3 the limit has itself come down by the margin, so the velocity band is the wider of the two there and FADE_BAND does bound the gain.

The rotation weight. When several joints are pinned at their bounds, the pose may be unreachable and the IK must trade position against orientation. The default rotation_weight is 0.1 m/rad: 1 mm of position error has the same weight as 10 mrad of orientation error. The weight is fixed rather than derived from cartesian_stiffness, which may be zero in rotation.

Position first at a wall. While the goal is stalled, each iteration first solves translation within the joint bounds, then solves the full task with translation weighted 30 times more strongly than orientation. Orientation follows as far as the remaining freedom allows. The solve can settle at a local optimum. The switch eases in and out over 0.5 s so the goal velocity has no step; away from a wall the weighted solve is unchanged. Under a speed limit, translation takes priority within the velocity bounds too.

constantvaluewhy
ImpedanceOptions::joint_position_margin0.05 rad, in [0.035, 0.5]an overshoot of 0.02, the FR3 envelope’s zero up to 12 mrad in, and the fade band
IkOptions::rotation_weight0.1 m/rad, in (0, 1]the trade above; 1 weighs 1 m as 1 rad
POSITION_FADE_BAND0.02 radb ≥ 0.015 keeps E(s − b) under the arm’s velocity limit near the limit
POSITION_BARRIER_STIFFNESS12.5 /rad × torque clampstable on the wrist behind the 1 ms delay and the low-pass
active-set passes12bounds the work per solve; position first solves twice per iteration
stall on / quiet cycles2e-5 weighted m per cycle / 20 of the last 40above the damped solve’s lag; the flag holds while a pin pushes at all, and a pin brushed now and then still clears it
posture distance0.3 rad beyond the marginwhere the posture is clamped and its pull starts to fade
spring ramp500 cycles from the loop’s startan arm started inside b eases out

Recorded. CartesianSent and JointSent carry pinned and tau_position, CartesianSent also stall_pressure, stalled and ik_passes; ik_error is the weighted norm. CartesianSent reports the solve itself too: ik_step and ik_step_clipped are the largest raw step norm of the cycle’s solves and how much of it the box took off, ik_blend is the position-first blend, and held with wall_age say whether a wall was standing and how long since it was last pushed on. The flight recorder’s entities are on Record and replay a run.

Limits. Self-collision is not modelled; a joint position margin does not prevent it. The spring is sized for an overshoot of the margin by 0.02 rad; a push that drives a joint further meets at most its torque clamp, and the robot’s own limit reflex remains the last stop. The guard is exercised offline, on the model with a simulated plant (tests/position.rs, tests/active_set.rs, tests/ik/, tests/torque_position/), not yet on a robot.

Near full stretch: a self-motion the law cannot see

Near a singular configuration, the joints can move substantially while the tool’s position barely changes. Cartesian position gains cannot damp motion in that direction; joint gains and, where orientation changes, rotational gains remain relevant.

Oscillation near full stretch is an unresolved limitation. The initiating mechanism is not yet identified, and no specific remedy is implemented. A small end-effector error does not establish that joint motion is well controlled. Use the recorded joint states, IK diagnostics and commanded orientation when investigating this behavior.

Anchoring without an echo: the leash

In the robot-controller backend the third rule of the generator re-anchors the plan every cycle on the robot’s echo of the last command (O_T_EE_c, q_d), so that the backstop behind the generator can shape a command but never accumulate a lag. There is no echo of a torque command: the robot reports tau_J_d, not a pose or joint goal. Neither extreme works as a substitute. Anchoring on the measured pose would fold the spring’s deflection into the plan every cycle, so that a load would stall the plan short of the target and a push on the arm would move it, since a compliant arm is meant to sit off its setpoint under a force. Anchoring on the generator’s own last output runs it open loop: held back by a hand, an obstacle or an unreachable pose, the arm falls arbitrarily far behind a plan that goes on without it, the spring force grows with the distance until a collision reflex ends the session, and on release the arm springs to wherever the plan has got to.

The Leash is the middle: each cycle the anchor is the measured pose (the model’s, for the measured q, the frame the desired pose and the IK live in) pulled toward the previous desired pose by at most leash.translation (0.025 m) and leash.rotation (0.15 rad),

e_t = p_des − p_meas        e_r = log(R_des R_measᵀ)
s   = min(1, leash.translation / |e_t|, leash.rotation / |e_r|)
anchor = ( p_meas + s e_t,  exp(s e_r) R_meas )

and on the joint interface each joint’s goal clamped to within leash.joint (0.1 rad) of the measured joint, the feedforward velocity being the finite difference of that leashed goal. While the arm follows, s = 1 and the anchor is the previous desired, so the generator runs from its own output and its budget is the whole budget, as before. Held back, the desired stays within the leash of the arm, so the spring force on whoever holds it is limited by the leashed position error. This is not a limit on total reaction force, which also includes damping. On the joint interface it is the torque clamp, not the leash, that bounds the torque: the JOINT preset’s 600 Nm/rad × 0.1 rad is 60 Nm on joints 1 to 4, under their 86 Nm clamp but far over the 20 Nm joint threshold the examples set, which such a joint reaches at 0.033 rad of error; on the wrist (250 / 150 / 50 Nm/rad) joints 5 and 6 meet the 11.5 Nm clamp before the leash does. Target control sets no collision thresholds; see Collision thresholds. When the arm is let go the generator resumes from where the arm is, under its budget. The leash keeps acting during the stop’s hold, so an arm held during stop() is not pulled harder either. A deflection under load smaller than the leash costs nothing, and the from-start deviation guard is unchanged. What the leash took off is reported to the observer as leash_alteration (m, or rad on the joint interface) and leash_angular_alteration (rad), zero in normal tracking. The backstop role otherwise passes to the torque clamp, the filter and the torque-rate limiter, which shape the torque and leave the setpoint alone.

The handover at start and stop

At the start, the desired pose is the model’s pose of the measured configuration and q_goal = q. The IK therefore starts with zero residual. The first command is −Kd dq + coriolis(q, dq).

At stop(), the generator lands on the last target and holds the setpoint for Settle::cycles (250). It sets motion_finished once every joint moves slower than REST_JOINT_VELOCITY (0.01 rad/s), or after STOP_TIMEOUT_CYCLES (5 s) more cycles. The impedance law continues to act on the held goal until then. The velocity check allows the arm to settle before control returns to the robot’s controller.

The law itself, impedance_torques, is public at the crate root for a loop of your own.

What a spring does not do

A spring has no integrator. The tracking error at a hold is the arm’s residual force (load or friction) over the stiffness, as it is under the robot’s own impedance controller, so it halves when the stiffness doubles; millimetre placement needs a higher stiffness. And the leash bounds the position error, not the force: a fast push adds the damping term (50 to 90 N s/m times the speed), so there is no dedicated cap on the reaction force, spring and damper together, beyond the torque clamp. The backend is not yet validated on an FR3.

Tuning the law while it runs

A Cartesian impedance session accepts changes to its gains, its inverse kinematics and its budgets while the arm moves; the API, the clamping and how a change crosses into the loop are in Command from a low-rate program. Two of the limits in LiveTuning::BOUNDS are this page’s. The joint damping ceiling is 60 Nm s/rad on joints 1 to 4 and 40 on the wrist, below the reference stack’s 80, because near a joint’s velocity limit the barrier’s 20 Nm per rad/s is added to the law’s own damping and it is the sum the stability bound K × 1 ms / I < 1 applies to (The joint velocity envelope). And each joint damping has a floor of MIN_JOINT_DAMPING_RATIO √K, the ratio 0.25, under every shipped preset’s own D / √K: without it one change reaches a spring of up to 1200 Nm/rad with no damping at all, and the barrier is no help there, since it engages only past velocity_barrier_fraction of the joint’s velocity limit and contributes nothing at working speed.

Compared with the operational-space law

examples/cartesian_impedance_active_control.rs is libfranka’s Cartesian impedance example:

tau = Jᵀ (−K e − D J dq) + coriolis,   e = [ p − p_d ; −R vec(q_ee⁻¹ q_d) ]

with K = diag(150, 150, 150, 10, 10, 10) and D = 2√K. The two are first-order the same in the six task directions, Jᵀ Kx J (q_goal − q) ≈ Jᵀ Kx (x_goal − x), and differ in three ways. The operational-space law measures the error in the task space, orientation through a quaternion, and needs no inverse kinematics; the hybrid law measures it in joint space and needs the joint goal, which is why the Cartesian interface runs the IK above. The operational-space law leaves the nullspace free unless a separate term fills it, as cartesian_impedance_figure_eight does with a nullspace joint spring through a projector; the hybrid law fills it with Kq in the same expression. And a joint goal makes the two interfaces one code path, with a joint target the degenerate case Kx = 0. Both add the model’s Coriolis term and leave gravity to the robot.

Node parameters protocol

franka-node exposes live Cartesian impedance tuning through JSON over Zenoh. This page defines the node’s params/* surface; Tune a running controller describes its use. Motion targets, state and command messages remain separate.

From request to controller

Read the schema and current version, send a partial parameter update, then validate the session, version and values. A refusal changes nothing. An accepted update returns the stored targets and reaches the realtime loop through the seqlock, where transition policies are applied.
Acceptance confirms a new target set. It does not mean the controller has already finished transitioning to it. Open full-size diagram.

Keys and discovery

KeyKindPayload
franka/<arm>/params/schemaQueryableNo request payload; schema reply.
franka/<arm>/params/getQueryableNo request payload; current accepted targets.
franka/<arm>/params/setQueryablePartial update and acceptance/refusal reply.
franka/<arm>/params/currentPublisherSame body as get; on accepted updates, session reseeding and approximately every second.

<arm> is the configured arm name. Wildcard queries such as franka/*/params/schema discover arms; replies use each concrete arm key. Schema and get work without a running session. Set requires a live Cartesian impedance session; idle and joint sessions return not_ready.

Tuning uses a positive client_id for attribution, not authorization: it does not check that the sender owns the motion lease. Bus access must therefore be limited to trusted operators. One set is atomic for one arm; no transaction spans arms or other parameter owners.

Schema

The envelope contains owner: "node", arm, boot_id, schema_version: 1, params, relations and derived. Reject unsupported schema versions in a client.

Each params entry describes one field:

PropertyMeaning
typeCurrently f64, f64[3] or f64[7].
min, max, defaultScalar or matching-length arrays; defaults are this arm’s configured session baseline.
unitOptional scalar string or array of unit strings.
policyslew, step or step_up_gate_down; a budget field reports the strongest policy among its elements.
slew_tau_sPresent for slewed fields.
group, scaleUI grouping and linear or log display.
dangerAbsent, "advise" or "confirm_above". "advise" marks a field that reaches the torque directly and asks for no confirmation.
confirm_abovePresent exactly with danger: "confirm_above": the threshold, in the field’s scalar/array shape.
slider_max, off_at, normOptional presentation hints; the text entry can still use the full range. norm: true marks a velocity/acceleration/jerk budget, whose per-axis share is the norm divided by √3.

Ranges and policies come directly from LiveTuning::BOUNDS, the library’s own gate (Tune the law while it runs); clients must query them rather than maintain a copy. The nine fields are joint_stiffness, joint_damping, cartesian_stiffness, ik_damping, ik_nullspace_gain, velocity_feedforward_gain, velocity_feedforward_cutoff, budget and rotation_budget (25 scalar values total).

relations contains human-readable {"rule": "..."} entries. Currently the joint damping floor depends on the corresponding joint stiffness. The node enforces that relation; clients should show the rule and the accepted values rather than independently interpreting its text.

derived is read-only: leash{translation,rotation}, max_lead, max_lead_rotation, max_step, max_step_rotation, max_step_joint, rate_hz, state_hz, stop_after_ms, delta_t, dq_limit[7], cutoff_frequency, and cartesian_preset{stiffness[6],damping[6],reference}. Joint velocity limits depend on the connected robot’s FCI version. There is no joint_inertia value or node metrics endpoint.

Get and current

The body contains:

PropertyMeaning
owner, arm, boot_idOwner, arm and node-run identity.
versionNumber of accepted sets in this node run.
t_node_nsNode-host monotonic timestamp in nanoseconds.
originnull after initialization or session reseeding; otherwise {version, by, at_ns} for the last accepted set. by echoes its numeric client_id.
paramsComplete accepted tuning targets, or the TOML-derived baseline without a tunable session.
slewingField name to estimated fraction of the latest accepted change still remaining.
dirtyWhether accepted targets differ from the loaded configuration’s baseline.

params does not expose the controller’s per-cycle interpolated values. For slewed fields, remaining fraction decays with the published time constant. For downward budget ramps it is estimated from the previous and new targets and elapsed time. Array fields report the largest remaining fraction; fractions below 0.005 are omitted. Stepped, unchanged or settled fields are omitted. This is a wall-clock estimate of the latest update, not realtime feedback, and does not track all overlapping transitions.

Session start/end restores configuration values and clears origin and slewing, while keeping version. Node restart creates a new boot_id and resets version to zero. A client should refresh after either a boot change or a reseed; base_version alone cannot detect session reseeding or a restart that happens to reach the same version again.

The live feedforward gain is seeded as zero when TOML has velocity_feedforward = false, the default, regardless of velocity_feedforward_gain. There is no separate live boolean field; its confirm_above is 0, so switching it on live needs a confirmation.

Set request and validation

For example, after fetching the current version:

{
  "client_id": 7,
  "base_version": 2,
  "params": {"ik_damping": 0.1}
}

client_id is a nonzero u32. base_version is optional optimistic concurrency control: if present, it must match the current version. Optional confirm is an array of field names. Omitted parameter fields retain their values; an included array must contain every element. Unknown top-level request properties are rejected; unknown names in confirm are ignored.

The node processes a request in this order:

  1. Deserialize the envelope and validate client_id.
  2. Require a tunable session and check base_version.
  3. Validate field names, number types, lengths and finite values; merge into a scratch copy.
  4. Reject invalid domains before clamping: notably nonpositive ik_damping or cartesian_stiffness. A request for zero is refused, not converted to the minimum.
  5. Clamp requested fields using the library bounds, including the stiffness-dependent damping floor. Increasing a spring may also raise damping that the request omitted.
  6. Check confirmation crossings against these proposed stored values.
  7. Store the complete accepted target set, increment version, reply and publish current.

Any refusal leaves the targets and version unchanged. An accepted empty/no-op update still increments the version. Configuration seeds and unchanged fields are not automatically re-clamped by a partial update; schema bounds describe the live update gate.

confirm_above means crossing from at or below the threshold to above it, using current stored targets and the clamped proposed targets. For arrays, any element crossing requires the field name in confirm. Already-high values can be increased further or lowered without another confirmation. danger: "advise" does not require confirmation.

Replies

Accepted replies contain ok: true, the new version, the complete stored params, clamped and slewing. Each clamp is:

{"field": "joint_damping", "index": 0, "requested": 0.0, "stored": 1.0}

This is the record’s shape, not a recommended gain. index is zero-based for array fields and null for scalars. If the damping relation changed an omitted field, requested is that field’s previous value. Always use the reply’s stored values.

Refused replies contain ok: false, reason, field (or null), human-readable error and the unchanged version. The protocol vocabulary is type, non_finite, length, unknown_field, relation, needs_confirm, stale, not_ready, busy, invalid. The current node maps library gate errors to invalid with field: null and the field named in error; relation and busy are reserved vocabulary rather than promised outcomes. Malformed JSON, including JSON representations that cannot encode a finite number, can be rejected as type before field decoding.

The optional HTTP bridge adds its own failures, such as unreachable, aborted, bad_schema, no_state and stale_state. These are not node parameter refusals.

How the seqlock reaches the control loop

A seqlock is a shared slot with a sequence counter. It lets the realtime loop read a complete set of 25 tuning values while another thread writes, without taking the writer’s mutex. That matters because the controller must not combine new stiffness with an old damping value from a half-finished update.

The writer marks the sequence odd, writes all 25 values, then marks it even. The realtime reader checks the sequence before and after copying. It makes at most three attempts, retains the previous target if none succeeds, and continues the control cycle.
The realtime loop accepts a complete snapshot or keeps its previous target. Open full-size diagram.

The library serializes writers outside the realtime loop. A writer increments the sequence to an odd number, stores all 25 values, then increments it to an even number. The reader accepts its copy only when the sequence is nonzero, even and unchanged across the copy.

Each control cycle first checks whether the sequence changed. If it did, the reader makes at most three attempts to obtain a consistent snapshot. If all fail, it keeps the previous complete target, continues any transition toward that target, and tries again next cycle. It does not wait for the writer. An unchanged slot costs just the initial sequence check.

This is a latest-value slot, not a queue: several accepted updates between control cycles can collapse into one observed target. Acceptance therefore does not mean each intermediate target was applied to the robot. The slot’s internal sequence counter is separate from the JSON protocol’s version, which counts accepted requests.

Application and persistence

The arm thread validates and publishes tuning through a seqlock slot. The realtime loop reads it without waiting and applies transitions locally; no JSON, Zenoh or panel work runs on that loop. Gains slew with the schema’s time constant. Budget velocity lowers no faster than the acceleration limit, and acceleration lowers no faster than jerk; increases take effect immediately. Jerk and feedforward cutoff step. This bounds parameter transitions, not the feasibility of every requested robot motion.

There is no params/save. Changes last only until the session ends. Edit TOML and restart the node for a persistent baseline; panel preset storage is separate.

t_node_ns and origin.at_ns use the node host’s CLOCK_MONOTONIC, as does robot state telemetry. They are not Unix time and are not directly comparable across hosts. State remains the packed binary StateMsg at configured state_hz; node status is node-scoped JSON at franka/node/<name>/status. Neither is a per-cycle report of applied tuning. See Serve arms over Zenoh for those channels.

Model parameters and conformance

Use the model covers the API. This page is what is behind it: the native backend, where the parameters of each robot come from, how closely the results agree with libfranka’s, the caveats of the opt-in download path, and what the evaluation costs.

The native backend

The default backend (crates/franka-rs/src/model/native_backend.rs) is a serial-chain implementation of forward kinematics, geometric Jacobians, CRBA (mass matrix) and RNEA (Coriolis, gravity), written against the conventions Pinocchio uses, because libfranka’s franka::RobotModel is built on Pinocchio: poses are 4x4 column-major, Jacobians 6x7 column-major with the linear rows first (Pinocchio’s Motion layout), the body Jacobian is Pinocchio’s LOCAL frame and the zero Jacobian its LOCAL_WORLD_ALIGNED. It allocates nothing per call and needs no C++ library.

Against the conformance fixture generated from libfranka 0.20.4 itself (tests/data/model_reference_fr3.json, produced by tools/model-reference, a C++ program linked against libfranka that drives the real franka::Model: 128 joint configurations × 3 load configurations, all ten frames):

quantitytolerancemax abs error
pose1e-97.216e-16
body_jacobian1e-99.992e-16
zero_jacobian1e-97.216e-16
mass1e-63.109e-15
coriolis1e-62.864e-14
gravity1e-62.132e-14
gravity, g = {0.1, -0.2, -9.7}1e-62.842e-14

That is double-precision round-off: the two implementations differ only in summation order. cargo test -p franka-rs --test model_conformance runs it offline; the fixture carries the SHA-256 of the URDF it was generated from, so tests/data/fr3.urdf and the fixture cannot drift apart unnoticed. The same agreement holds on 10 000 random FR3 states in the benchmark harness (largest difference anywhere 4.97e-14; see Benchmarks).

Where the parameters come from

FR3 (FCI v10): the robot’s own URDF

FCI v10 has a GetRobotModel command that serves the arm’s URDF over the TCP channel. Robot::load_model() fetches it and hands it to the native backend; Robot::robot_model() returns the URDF text itself. Nothing is downloaded that is not data, and nothing is dlopened.

FER (FCI v5): shipped identified parameters

An FER has no GetRobotModel. libfranka 0.9.2 instead issues LoadModelLibrary, which streams Franka’s closed-source libfcimodels.so (329 592 bytes on system 4.2.1) down the command channel, writes it to a temporary file and dlopens it.

The crate does not need that. It ships the FER’s model as parameters, crates/franka-rs/tests/data/fer.urdf, exposed as franka::model::FER_URDF, and evaluates them with the same native backend the FR3 uses. On an FER, Robot::load_model() is exactly Model::native_fer(): it cannot fail, needs no network, no model-library feature and no x86-64 Linux host.

The kinematics were never in doubt: franka_description‘s joint origins and axes reproduce the shared object’s poses and both Jacobians to 4e-16. The dynamics differ, and by a lot: feeding the FR3’s URDF to the backend puts gravity 1.6 Nm out, the mass matrix 0.16 kg m² out and Coriolis 0.8 Nm out (2.7 %, 3.9 %, 3.8 %). So the seven links’ inertial parameters were identified from the shared object itself by tools/fer-model-fit: rigid-body dynamics is linear in the ten inertial parameters per link, so with the kinematics fixed the problem is 70 unknowns, solved by truncated SVD against the library’s answers at 208 joint configurations. The regressor has rank 45, the classical number of base parameters, and the worst residual is 3.6e-14.

Agreement against a real FER’s libfcimodels_x64.so, over 208 joint configurations × 4 load configurations, all ten frames:

quantitypayloadmax abs differencerelative
pose, all framesany4.441e-163.5e-16
body Jacobianany8.882e-168.9e-16
zero Jacobianany4.441e-164.4e-16
gravityany4.974e-148.4e-16
mass matrixnone3.553e-151.3e-15
Coriolisnone3.553e-142.4e-15
mass matrix1.23 kg2.980e-37.5e-4
Coriolis1.23 kg5.008e-22.4e-3

The payload caveat

The last two rows are the whole residual, and they are the shared object’s doing, not the fit’s: its M_NE is not affine in m_load. For a rigid payload M(2m) − M(0) must be exactly 2 (M(m) − M(0)); the library misses that by 7e-4, behaving as though the payload were up to 18 g lighter than it was told (0.7 % of a 1 kg payload, saturating). No rigid-body model can follow it there, libfranka’s own included if it were reimplemented. g_NE is affine, and the payload’s rotational inertia I_load enters exactly as a rigid body’s does, so gravity, the quantity a gravity-compensation loop needs, agrees to 5e-14 whatever the payload. The mass matrix and Coriolis agree to 4e-14 with no payload and carry that 1e-3-scale offset with one.

One more property of the identified set: some moments of inertia come out negative. That is a property of the robot’s own model, not of the fit: alternating projections between the affine set of exact fits and the cone of realisable rigid bodies converge with the smallest pseudo-inertia eigenvalue still at −1.5e-2, so the shared object’s parameters are not those of any set of real bodies. Use the file to reproduce libfranka’s numbers, not to seed a physics simulation.

The tests

  • tests/model_conformance.rs: the FR3 table above, against the libfranka 0.20.4 fixture.
  • tests/fer_native_conformance.rs: the FER table above, against a committed fixture (tests/data/model_reference_fer.json, 40 joint configurations × 4 load configurations, dumped from the shared object), so it runs in CI with no .so present. With $FRANKA_FER_MODEL_SO set, two further tests drive the library live: one checks the fixture is still what the library answers, the other pins the non-affine M_NE.
  • tests/fer_model_conformance.rs: drives the shared object itself through Model::from_model_library_path / from_model_library_bytes at eight fixed configurations, checks the invariants franka::Model guarantees, and reports the native backend’s differences against it. Needs the model-library feature and the .so; skips loudly without it.

load_model_from_robot: the opt-in download path

The v5 download path is available with the opt-in model-library feature. Enable it with cargo add franka-rs --features model-library only when you need this comparison path:

#![allow(unused)]
fn main() {
extern crate franka;
use franka::Robot;
fn f(robot: &Robot) -> franka::FrankaResult<()> {
let model = robot.load_model_from_robot()?;   // LoadModelLibrary + dlopen, as libfranka does
let _ = model; Ok(()) }
}

Use it when you want the robot’s own binary in the loop, for a conformance check, say. On an FR3 it is load_model() exactly. On an FER its consequences are those of a closed-source blob:

  • x86-64 Linux in practice. The request encodes the host architecture, but the FER control unit only ships libfcimodels_x64.so, so anywhere else the robot answers kError or dlopen refuses the object. load_model() has no such limitation.
  • It goes to a temporary file. dlopen needs a path, so the bytes are written to a private 0600 temp file removed when the Model is dropped. The directory must be executable (a noexec /tmp breaks it). Removal covers a normal drop and an unwind but not SIGINT/SIGTERM/abort, so a loop stopped with Ctrl-C leaves one ~330 KB file in $TMPDIR.
  • dlopen executes code the robot chose, in your process, over an unauthenticated plaintext TCP socket, with no validation. This is what libfranka 0.9.2 does, and it stays a safe fn here for the same reason: the FCI peer is already fully trusted, because it is the thing that commands the arm. Give the FCI its own isolated link, as Franka’s setup guide requires.
  • Off by default. Without model-library, the FER path of load_model_from_robot() returns a FrankaError::Model; this crate does not pull in libloading, and load_model() is unaffected. The offline entry points Model::from_model_library_bytes and Model::from_model_library_path, which take the bytes from the caller, are unsafe fn and carry the corresponding # Safety contract.
  • The library needs libm in the loading process. libfcimodels_x64.so imports sin, cos and sincos and carries no DT_NEEDED entries of its own, so it resolves them from the global scope of whichever process dlopens it. C++ clients get libm transitively through libstdc++; a Rust binary only links it if something in the binary uses it. A lean binary fails with undefined symbol: sincos at the first call; name the dependency explicitly if you rely on this path.

Cost

Evaluating the five model calls a model-based controller makes costs about 3 µs offline (2.97 µs p50 on 10 000 random FR3 states) and about 11 µs inside a 1 kHz loop on a laptop-class CPU, about 1.5 % of the cycle. The in-loop figure is higher than the offline one in every implementation measured, because a duty-cycled loop starts each cycle on a core that has just idled and the measurement prices the CPU’s post-idle frequency ramp, not the arithmetic. The per-call numbers, the same comparison for libfranka, and that frequency-ramp caveat are on Benchmarks.

Benchmarks

Measured against libfranka 0.20.4 and libfranka main, with the harness in bench/: loop jitter against franka-sim over loopback, the model inside the 1 kHz loop, and the model calls offline.

Every measurement ran on one machine: a laptop-class x86-64 CPU with 12 logical cores, Linux 6.8, not PREEMPT_RT. That matters for how to read the tail: every multi-millisecond max in these tables is a scheduling event on a desktop kernel, and these numbers are a same-machine A/B, not an FCI qualification of either client.

The fairness protocol: a cell is one (condition, variant, repetition), both clients run back to back inside it, the order alternates per repetition, and the simulator container is restarted per cell. The paired within-cell difference is the primary evidence everywhere below; pooled tables are context.

Simulator: loop jitter

Joint-velocity motion, 30 s per run, 3 repetitions, chrt -f 80 + mlockall.

variantclientinterval p50p99lost cyclessuccess avgCPU %
control (callback)C++1000 µs1071 µs0.31.0002.7
control (callback)Rust1000 µs1050 µs0.31.0002.4
active (readOnce/writeOnce)C++1000 µs1046 µs0.01.0003.0
active (readOnce/writeOnce)Rust1000 µs1054 µs0.01.0002.4

Paired over all 18 cells:

statisticC++ minus Rustverdict
CPUmean +0.59 pp, C++ higher in 18/18 cellsreal
interval p99mean −15 µs, median −3 µs, C++ higher in 8/18noise
active recv→send latency p50 / p9917 / 34 µs vs 11 / 23 µs (9 cells)real

The CPU gap holds equally whether C++ ran first (+0.57) or Rust ran first (+0.63), so it is not an ordering artefact. Loop timing is indistinguishable: median cycle time is exactly 1000 µs everywhere and the p99 difference changes sign between variants. Under a 12-core CPU hog, p99 rises to 1.3–1.4 ms (control) or ~1.1 ms (active) for both clients, p50 stays at 1000 µs, and neither client lost a cycle.

The model in the loop

The same operational-space impedance controller in both languages, with all five franka::Model calls evaluated inside every 1 kHz cycle. Simulator, chrt -f 80 + mlockall, 3 repetitions of 30 s.

buildmodel p50p99compute p50interval p50p99lostCPU %
libfranka 0.20.430.8 µs67.033.3 µs1000 µs108405.7
libfranka main (0.21.3)31.2664.4433.961000104905.59
libfranka main + Data-reuse patch11.0727.1513.881000104303.49
franka-rs10.832.912.51000104702.9

Both clients produce the same motion (peak |τ| 1.925–1.940 Nm, peak end-effector excursion 0.04785–0.04789 m), and neither lost a cycle in any of the 12 runs.

The five calls measured offline, on 10 000 identical random FR3 states:

calllibfranka 0.20.4libfranka main+ patch commit 1+ commit 2franka-rs
mass0.670.630.620.610.68
coriolis (state overload)1.931.961.911.861.13
gravity0.570.600.570.580.57
zeroJacobian6.977.170.810.500.32
pose6.346.550.360.370.27
all five16.4816.934.283.922.97

p50 microseconds. The 21–24× gap on pose and zeroJacobian was not a C++/Rust difference: libfranka’s Pinocchio wrapper constructed a whole fresh pinocchio::Data on every kinematics call — 143 heap allocations. A two-commit patch (see patches/) takes that to zero allocations per call and closes essentially the whole gap; the remaining ratio is about 1.3× offline and 1.0× in the loop, and it is not all in the same direction (C++ ahead on mass, franka-rs on coriolis and the Jacobians). Every output is byte-identical before and after the patch, and libfranka’s own 805/805 tests pass on both.

Numerical agreement over the same 10 000 inputs: the largest absolute difference anywhere between the two backends is 4.97e-14 (zeroJacobian 9.99e-16, pose 1.06e-15, mass 3.55e-15). That is double-precision round-off.

Reading in-loop model timings

An in-loop model_us figure prices the CPU’s post-idle frequency ramp as much as the arithmetic: a duty-cycled loop starts each cycle on a core that has just idled. The five FER model-library calls in C++ (bench/so-micro), timed after increasing amounts of untimed filler work, swing from 21.37 µs to 3.98 µs on identical inputs. So a client whose receive path does less work can read slower in the loop; compare the arithmetic with the offline table.

Reproducing

cmake -S bench/cpp -B bench/cpp/build -DCMAKE_BUILD_TYPE=Release && cmake --build bench/cpp/build -j
cargo build --release --manifest-path bench/rust/Cargo.toml

bench/run.sh --duration 30 --reps 3      # takes the simulator lock, one container per cell

bench/README.md documents the harness, the --hardware mode and its guards, and bench/fer-capture/README.md the wire-capture tooling. Raw per-run JSON of the simulator runs lives under bench/results/.

Simulator gaps

franka-sim speaks the real FCI wire protocol over a MuJoCo model, and the crate’s integration tests drive it as they would an arm. It is not an arm. This page lists the known divergences from a real robot, so that a result on the simulator is read for what it is. How to run the tests is on Test against the simulator.

What the simulator is not

  • It does not run under PREEMPT_RT, and neither does CI, so every test and example connects with RealtimeConfig::Ignore. Timing measured against it is a same-host A/B, not an FCI qualification; see Benchmarks.
  • There are two images. The FR3 / FCI v10 image is published (ghcr.io/barisyazici/franka-sim:latest). The FER / FCI v5 image, franka-sim:panda-v5, is a local build and not published; CI’s sim-fer-v5 job is gated on the FRANKA_SIM_FER_IMAGE repository variable and skipped until someone sets it, so the v5 simulator suite otherwise runs locally, and CI covers the v5 protocol offline through wire_sizes_v5.rs, fer_native_conformance.rs and the mock-FCI unit tests.

Known gaps

Every gap is pinned by a characterisation assertion that names the stub in its failure message, so a rebuilt image tightens the test instead of breaking it silently.

gapimagesconsequencepinned in
O_ddP_O is [0, 0, 0]bothModel::gravity(&state) is identically zero; use gravity_q with an explicit vector.sim_v5_commands.rs
O_T_EE is the joint-7 frameboth0.107 m short of the flange along the tool z axis, with F_T_EE and NE_T_EE both identity. franka-node’s lead check measures against O_T_EE, so on the simulator every target leads by the offset: its tests run with max_lead off or raised by 0.107 m.sim_commands.rs, sim_v5_commands.rs, franka-node/tests/sim_node.rs
--enforce-motion-limits uses the FR3’s limit tablespanda-v5The FER’s jerk and acceleration limits are larger on most joints (MAX_JOINT_JERK = [7500, 3750, 5000, 6250, 7500, 10000, 10000] against a flat 5000), so whenever the client’s FER rate limiter saturates it emits a command a real FER accepts and this image rejects. Do not home with MotionGenerator on this image with limits enforced, and read a reflex threshold as the FR3’s.sim_v5_stop_and_reflex.rs
joint 1 ignores velocity commandspanda-v5The velocity servo uses FR3 gains; on the Menagerie franka_emika_panda model joint 1 oscillates at the 500 Hz Nyquist frequency, so the clipped torque averages to zero and velocity commands produce little net motion. Position commands on joint 1 work.sim_v5_motions.rs (active_control module)
GetCartesianLimit is a stubpanda-v5kSuccess with an all-zero 154-byte body, so virtual_wall(1) returns zeros and active: false. The framing is real, the content is not; only a real FER can confirm the field mapping.sim_v5_commands.rs
StopMove answers kSuccesspanda-v5A real robot, and the FR3 image, answer kPreempted. control_torques then returns Protocol("Unexpected reply to a Move command") instead of the usual preemption ControlException.sim_v5_stop_and_reflex.rs
no graspable objectpanda-v5The image predates --gripper-object-width, so a successful grasp is only covered on the FR3 image (--gripper-object-width 0.04). The gripper protocol is byte-identical on both versions anyway.sim_gripper.rs (FR3)
no franka-sim-check binarypanda-v5The test harness falls back to its own readiness probe: a version-5 Connect handshake plus one 2373-byte UDP datagram.crates/franka-sim-test/src/docker.rs

The joint-side continuity check

A real robot runs inverse kinematics on every commanded Cartesian pose and checks the continuity of the result in joint space (see FER / Panda specifics). Since franka-sim 1.1.5 the FR3 image does the same under --enforce-motion-limits, scaled by --joint-discontinuity-scale (1.0 is the robot’s own limit); its FR3 acceleration table, 10 rad/s², is the FR3’s published limit at scale 1.0. The panda-v5 image has no joint-side check and accepts what a real FER refuses. sim_target_control needs franka-sim 1.1.6 or later.

API reference

The generated rustdoc for the franka crate is published alongside this book:

→ API reference (api/franka/index.html)

It is built by .github/workflows/docs.yml with

cargo doc --no-deps -p franka-rs

under RUSTDOCFLAGS="-D warnings", so a broken intra-doc link fails the build, and copied into the site at api/. The library target is named franka, which is why the path is api/franka/ rather than api/franka-rs/.

Good places to start:

itemwhat it is
franka::RobotThe connection. Setters, read_once, the control_* loops, start_*_control, stop, automatic_error_recovery.
franka::RobotStateEvery field the robot publishes each millisecond.
franka::ModelPoses, both Jacobians, mass, Coriolis, gravity.
franka::GripperThe Franka Hand on port 1338.
franka::FrankaErrorThe error taxonomy, one variant per libfranka exception.
franka::rate_limitingThe FR3 constants and limit_rate_*; the FER’s live in the fer submodule.

Building it locally:

cargo doc --no-deps -p franka-rs --open

Changelog

All notable changes to this project are documented here.

The format follows Keep a Changelog, and this project adheres to Semantic Versioning.

0.4.0 - 2026-09-21

Added

  • franka-vr-teleop: drive a node-served arm from a Meta Quest controller (tools/vr-teleop, on PyPI with the other packages). franka-vr-bridge reads the headset over adb and publishes each controller on its own ZMQ channel; franka-vr-teleop follows one channel with a clutch on the grip, clamps every target against the arm’s measured pose and reads the node’s limits from the node before it acquires anything. The node’s bytes are franka-node-client’s, which it pins to its own version. The headset APK is fetched and hash-checked by franka-vr-fetch-apk into the user’s data directory, never shipped.

  • A live-tuning guide and node parameter protocol reference, including accepted-versus-applied values, feedforward configuration, confirmation gates and session lifetime. A portable two-arm node example retains the shipped defaults and shows optional CPU pinning and Hands.

  • The tuning panel ships in franka-node-client as the franka-tuning-panel command (pip install franka-node-client, then franka-tuning-panel --connect tcp/<host>:7447): a browser page that discovers the arms on the bus, draws every slider from the node’s own schema, and moves the parameters of a running session through the node’s bounds, refusals and confirmation gates, with the live estimates, markers and presets beside them. It decodes state with the client’s own wire module instead of a copy, and binds to loopback.

  • Live tuning: a running Cartesian session takes a change to the law and the plan, and franka-node serves it over Zenoh. LiveTuning is the set of parameters an operator may move while the arm is engaged – the joint springs and dampers, one Cartesian stiffness the twelve-word preset is derived from, the IK’s damping and nullspace gain, the velocity feedforward’s weight and cutoff, and the two budgets – and LiveTuning::BOUNDS is how far each of its twenty-five scalars may be moved. CartesianTargetControl::tune applies a partial TuningUpdate all or nothing, clamps it to that table and returns the bounds that bit; tuning reads the targets back. A change is never a step in the torque or in the command: the fields that multiply a generally-nonzero state cross over SLEW_TAU (0.3 s), the budget’s velocity and acceleration are raised whole and lowered as a ramp at the next order’s limit (TuningPolicy::StepUpGateDown, whose rate_word names which word that limit is), and the rest step. TuningPolicy::remaining and descent say what is left of a crossing from the clock alone, so nothing is published back out of the realtime thread; the cycle itself holds no exp, no allocation and no match on a policy. A session whose gains one Cartesian stiffness cannot rebuild, or whose tracking is the robot’s own, has no live tuning rather than a partial one. Otg::set_limits and MultiOtg::set_limits are what carry a budget to a running generator, with the caller’s obligation – never lower a limit below the state – in the OTG’s rules as a fourth.

  • franka/<arm>/params/{schema,get,set} and params/current on the node. The schema is serialised from LiveTuning::BOUNDS: every bound, default, unit, policy, danger flag, slider range and log scale a panel renders is the row the arm is actually clamped to, so a client cannot draw a slider the arm would refuse, and the damping floor no per-field row can express is published beside them as a relations rule. derived adds what the library cannot know and a client must not guess – the leash, the gate’s lead and step limits, the rates, the connected arm’s joint velocity limits and the Cartesian preset the one stiffness slider scales. A set is partial and all or nothing, checked for shape by the node and for domain by the library, refused with stale against a base_version that has moved on and with needs_confirm where a value crosses a threshold that widens what the arm may do; the reply echoes the clamped values so an operator learns a bound exists. The values are the session’s own: with no session there is nothing to tune and get answers with what the next one will start at, and a session ending puts them back to the TOML’s, deliberately, because an experiment nobody saved is not one a new session should inherit. cargo run -p franka-node --example params_schema dumps that schema without a robot, which is what the tuning panel’s mock owner serves instead of a copy of the numbers.

  • A continuous weight and a bandwidth bound on the impedance law’s velocity feedforward (ImpedanceOptions::velocity_feedforward_gain, velocity_feedforward_cutoff, MIN_FEEDFORWARD_CUTOFF): the damping term is now Kd (g dq_goal - dq), and dq_goal may pass a first-order low-pass before it is fed forward. velocity_feedforward off is exactly g = 0, and the default cutoff leaves the path bit-identical to before, so neither changes an existing configuration. The feedforward is what carries the joint reference’s own ripple into the torque; bounding its bandwidth keeps the lead without the ripple, where the boolean could only drop both. With the feedforward off the damping acts on the absolute velocity, so holding speed v costs a standing error of (Kqd / Kq) v – which a leash on the command then turns into a speed limit, the reason the weight is continuous rather than a switch.

  • franka-node config keys for the impedance law and the IK: ik_damping, ik_nullspace_gain, velocity_feedforward, velocity_feedforward_gain, velocity_feedforward_cutoff, cutoff_frequency, joint_stiffness and joint_damping, all defaulting to the library’s values and applied to both session kinds. A Cartesian session’s joint gains are soft by design – the Cartesian spring does the work – which is wrong for an arm driven mostly by its joint law, and there was no way to say so without a rebuild.

  • franka-description (crates/franka-description, no dependencies, no_std): the FR3 and FER link meshes, the Franka Hand and its finger from franka_description (Apache-2.0, commit 7aeeddc) as built-in glTF binaries, decimated to about 15 % of their triangles (30 % for the hand, the finger whole) by tools/franka-meshes/convert.py --decimate (meshoptimizer’s quadric collapse; p99 surface deviation 0.21 to 1.00 mm across the decimated meshes, the worst the FER’s link3). FR3 734 kB, FER 464 kB. Robot, MeshSet (links, hand, finger), FR3, FER, MeshSet::for_robot, SOURCE_COMMIT, TRIANGLE_RATIO; NOTICE carries franka_description’s notice and LICENSE-franka_description its LICENSE; the root NOTICE carries the notice too and points at that LICENSE.

  • Built-in meshes in franka-rerun, feature builtin-meshes (default): replays and recordings draw the arm with the robot’s franka-description meshes out of the box, about 0.7 MB of static mesh per arm in an .rrd. franka-rerun csv|log --meshes DIR still draws other files and --no-meshes none; FlightOptions::meshes is a MeshChoice (Builtin, Dir, Off), and Meshes::builtin. franka-node’s record_meshes is an optional override, "none" turns the meshes off.

  • franka-node-client, the Python client of franka-node (crates/franka-node/python, import franka_node): pure Python over eclipse-zenoh and numpy, one wheel released with the crates under the workspace version. Node, Arm (lease on with, home, stop, recover, on_state), cartesian_targets/joint_targets sessions with the franka verbs (move_to, move_by, follow, target, state, wait, stop) whose streamer thread paces goals under the node’s max_step and max_lead and keeps the session alive, Gripper (move, grasp, homing, stop, state), and NodeError, Refused, NodeTimeout, SessionEnded, ProtocolError. crates/franka-node/tests/wire.json is the wire layout machine-readable, checked against the Rust types and the client’s; the client runs against the node binary and franka-sim in CI.

  • franka-description, franka-rerun, franka-node and franka-cam on crates.io, released with franka-rs under one workspace version. A v* tag also attaches prebuilt franka-node and franka-cam tarballs (aarch64 gnu and static musl, x86_64 gnu; built with record) to the GitHub release, which cargo binstall franka-node installs.

  • Joint velocity cap, fade and barrier of target control’s torque backend. No joint of the goal moves faster than ImpedanceOptions::joint_velocity_fraction (0.7) of the arm’s velocity limit (target_control::max_joint_velocity, by the negotiated FCI version): a faster step is scaled as a whole, keeping its direction, and the generator is re-anchored on its own end-of-cycle velocity scaled the same way (keeping only the acceleration that brakes) so it does not wind up. A joint measured faster than velocity_barrier_fraction (0.85) of its limit meets VELOCITY_BARRIER_GAIN (20 Nm per rad/s) of damping on the excess, at most its torque limit, before the clamp; between the cap and the barrier’s onset (FADE_BAND, 0.15 of the limit) the law’s own torque along a joint’s motion fades out, so a joint catching up on its lag cannot cancel the barrier. CartesianSent and JointSent carry dq_goal, cap_scale and tau_envelope (the velocity envelope’s share of tau). The goal is capped before a fast turn of the hand near a wrist singularity reaches joint_velocity_violation; an ignored test replays a recorded teleoperation session with the cap on and off (target_control/tests/replay.rs), and a simulator test turns a nearly aligned wrist fast (tests/sim_target_control/velocity_cap.rs). franka_rerun::TorqueLog and Recorder::push_torque_at record joints/q_goal, joints/dq_goal, joints/cap_scale and joints/tau_envelope; franka-node takes joint_velocity_fraction and velocity_barrier_fraction per arm and records them every cycle.

  • Joint position limit guard of target control’s torque backend. The Cartesian IK solves each step inside a per-joint box instead of clamping it: the joint velocity cap, and toward each limit a braking envelope, per arm, that brings the goal to rest a margin inside it (ImpedanceOptions::joint_position_margin, 0.05 rad, in [0.035, 0.5]). A primal active set, warm-started, at most 12 passes, fixed-size and allocation-free, gives the exact box-constrained damped least-squares step, so the free joints take over what a pinned one cannot. The posture is clamped 0.3 rad beyond the margin and its pull fades out as the nearest joint closes on the margin. The orientation rows are weighted by the new IkOptions::rotation_weight (0.1 m/rad): with several joints pinned the tool’s position is held, the rotation stops at the wall and the elbow does not swing, where the clamp stopped the rotation and walked the elbow. While the goal is stalled, position comes first, µm off (the translation solved alone, then the orientation with it held, the translation rows weighed 30 times against it), eased in and out over 0.5 s. A stall flag on the pins’ pressure (on above 2e-5 weighted m per cycle, off on 20 of the last 40 cycles in which no position pin pushes at all) restarts the generator with the velocity into the wall removed per block and the rest cut to what the goal’s step carried, so it neither winds up behind a wall nor lags a reversal. What went out is the goal’s own step, and the generator is re-anchored as far ahead of the goal as it was, never further than its own step: the IK’s lag behind it stays, so the goal velocity does not saw at the cap, and a goal closing a lag does not drag the desired along. A stop() held at a wall the target lies beyond counts as landed once the goal is within the settle tolerance of the target on every other axis and has come no tolerance closer to it for the time a generator needs from rest to cover two (0.1 s at the default budget). The joint interface scales its step into the same box. At the measured arm the law’s torque toward a limit fades out over POSITION_FADE_BAND (0.02 rad) inside the margin, and beyond it a spring of POSITION_BARRIER_STIFFNESS (12.5 /rad × the torque clamp, at most the clamp, ramped in over 500 cycles) pushes the joint out; the velocity barrier’s onset and fade start drop to the braking envelope there. On the FR3 the box, the backstop, the restart, the fade and the barrier use the robot specifications’ position-dependent joint velocity envelope instead of the flat limits, the step’s limits taken where it ends and the loop’s at the arm and a cycle on (the robot checks the specifications’ parameters, not those of the deprecated rate_limiting::compute_{lower,upper}_limits_joint_velocity, which are unchanged); Franka publishes no such envelope for the FER, so there the robot’s limit stays the published flat one and only the guard’s braking narrows toward a position limit. That braking profile is sqrt(k x + c²) − c with k = 2a, under sqrt(2 a x): a is the FR3’s published ddq_dec on that arm and half the FER’s published joint acceleration limit on the FER, which unlike the FR3 constants stays inside the FER’s own rating on every joint. The velocity barrier’s gain is lowered in proportion under a torque clamp below the preset. CartesianSent and JointSent carry pinned (0 free, ∓1 position, ∓2 velocity bound) and tau_position (the position envelope’s share of tau), CartesianSent also stall_pressure, stalled and ik_passes; franka_rerun::TorqueLog records them as joints/pinned, joints/tau_position, ik/stall and ik/passes, and franka-node takes joint_position_margin per arm, its joint gate refusing targets inside it. Self-collision is not modelled. The spring is sized for a 0.02 rad overshoot of the margin; the guard is tested on the model and a simulated plant, not yet on a robot.

  • franka-node (crates/franka-node): a Zenoh node in front of Cartesian target control. One process owns one or more robots and, per arm, runs the impedance backend on the library’s realtime thread; clients publish 80-byte pose or joint targets at any rate on franka/<arm>/target, read the arm’s state at 100 Hz on franka/<arm>/state and drive acquire/enable/stop/release/recover/home through queryables. A per-arm thread is the single writer of the target seqlock; targets are gated (lease holder, order, finite, unit quaternion, step, lead, rate, and a workspace box when a config names one – there is none by default) before they reach it; a client’s liveliness token and a target-staleness watchdog stop the arm when the commander goes away. Config in TOML, a client example, unit tests and a simulator test (tests/sim_node.rs).

    • Joint targets and home: enable takes "mode": "joints" for a session on start_joint_target_control (targets are q, gated per joint by max_step_joint and the arm’s joint limits; the state’s target carries q with the joints flag), and home drives the arm to the ready pose at a speed of the limits, answering when it has arrived; an early end (stop, lost lease, timeout, shutdown) decelerates in place. Config keys max_step_joint, joint_budget_fraction, joint_max_deviation; the library’s target_control::joint_position_limits is public.
    • A status topic, franka/node/<name>/status: JSON once a second with the node’s name, version and uptime and, per arm, the phase, holder, mode and the accepted, refused, dropped and decode-failure counters, read from atomics the arm thread stores into with every state tick (ArmStats, transport::status_publisher). A systemd unit and install notes in crates/franka-node/deploy/, and a book page, Serve arms over Zenoh, with a Python commander and the Raspberry Pi build.
    • Episode recording behind the cargo feature record (pulls franka-rerun, Rust 1.96): with record_dir set, every session (enable in either mode, home) is one <arm>-<UTC stamp>.rrd written by a franka_rerun::Recorder in the loop’s observer, with the accepted and refused targets logged from the arm thread under commander/target/* and events; the status gains recording, the open file’s name. Recorder’s constructors take impl Into<Arc<Model>> so one model serves many recordings, and franka_rerun re-exports rerun.
    • The gripper is recorded with the arm: width, commanded width, grasped, moving and fault go into the session’s .rrd under gripper/* at the 20 Hz the gripper state is published, so a replay has the hand beside the arm that carried it. The flight recorder’s default layout gained a tab for them and one for <arm>/cam/*, the entities a camera node records into the same episode; an explicit blueprint turns the viewer’s automatic layout off, so an entity no view names would otherwise be in the file and not on screen.
    • An episode topic, franka/<arm>/episode: one JSON line at a session’s start and end with the arm, the session’s recording_id, the .rrd file name and the node’s clock, and the id again in the status as episode. With the record feature the id is the Rerun RecordingId of the arm’s own file (its stem), so a recorder in another process writes a file under the same id and a viewer loads them as one recording.
    • The target watchdog is fed by arrival, not acceptance: stop_after_ms now measures from the last target that reached the guard’s content checks from the lease holder in order (Reason::from_the_commander), and hold_after_ms still measures from the last accepted one, so holding keeps its meaning. A commander whose targets are all being refused – parked at the workspace wall, rate limited, outrunning the arm, or pushing against a hand on it – was treated exactly like one that had died: the session stopped, which blocks the arm thread for the library’s settle and lands in Idle needing a fresh enable. Targets that are not the commander’s (wrong kind, not the holder, out of order) are still no sign of life, so a silent commander is stopped whatever else is on the topic.
    • A lead limit and an anchor flag, for a commander a human drives. max_step bounds a distance per message and never looks at the arm, so a commander faster than the generator walks the target ahead without limit and the arm coasts the whole accumulated lead when the commander stops. max_lead (0.05 m) and max_lead_rotation (0.26 rad) bound how far an accepted Cartesian target may be from the measured pose, 0 disabling either, with Reason::Lead and Reason::LeadRotation counted and logged like every other refusal; a refusal still spends no token and advances nothing. TargetMsg.flags bit 0 (TARGET_ANCHOR) has that target’s step measured from the arm instead of from the previous accepted target, which is how a stream that jumped — a re-latched clutch, a moved tracking frame, an arm a hand has pushed off its target — gets back in; it lifts no limit, so an anchored target is bounded by the smaller of max_step and max_lead of where the arm is and anchoring every message is the most conservative way to command the node, not a way around it; with either lead limit at 0 the flag is ignored rather than left unbounded. A lead limit must be 0 or clear of leash, below which it would refuse the tracking error a healthy commander already has. The message’s size and layout are unchanged and an unknown flag bit is ignored.
    • node and cam are refused as arm names (RESERVED_ARM_NAMES): franka/node/* is the status topic and franka/cam/* belongs to a camera node.
    • [zenoh] mode = "client" dials the routers in connect and listens on nothing, which is how a node behind NAT serves a commander that is not on its network; peer, the default, is unchanged. A client with no router named is a config error. The TLS transport is compiled in, and [zenoh] zenoh_config names a Zenoh file to start from, so the certificates of a tls/ endpoint, authentication and access control are configuration rather than code; the table’s own keys are applied on top of that file.
    • Grippers: a Gripper trait (command, grasp, home, stop, state; metres) and FrankaHand, the Franka Hand over franka::Gripper on a worker and a reader thread. Per arm, franka/<arm>/gripper/target takes a 40-byte GripperMsg (width or grasp) from the lease holder in any phase, franka/<arm>/gripper/state carries a 40-byte GripperStateMsg at 20 Hz, and cmd/gripper_home (the holder’s, answered when homed) and cmd/gripper_stop (anyone’s) join the verbs; the status gains gripper. Config keys gripper = "hand" and gripper_speed. The node is now franka_node::run(config, factory) with a GripperFactory, so a binary of its own can add drivers; the client example gains gripper <width>, gripper grasp <width> <force> and gripper home.
  • franka-cam (crates/franka-cam): the wire and the configuration of a Zenoh camera node that publishes V4L2 frames with their capture timestamps next to the arm node. CameraMsg is a 36-byte header ("<BBHHHIQQQ") carrying the format, the frame size, the driver’s sequence number and the capture, dequeue and wall clocks, followed by the frame; a frame of a format whose length the header fixes is refused when it does not match. CamConfig reads [zenoh] and one [[camera]] table per camera. The capture path is there too: raw V4L2 ioctls with the struct layouts and ioctl numbers asserted against videodev2.h, an mmap stream that refuses a queue stamped with anything but CLOCK_MONOTONIC, one thread per camera at normal priority pinned to the config’s cpu, one allocation per frame handed to Zenoh, sequence gaps counted as dropped by the driver, and a reopen with backoff when the device disappears. Per camera, franka/cam/<name>/frame at the camera’s rate and, with preview_fps, every nth frame on franka/cam/<name>/preview for a consumer that must not pull the full rate; franka/cam/<name>/state and franka/node/<name>/status once a second. [zenoh] takes the arm node’s keys, including mode = "client" and zenoh_config, so a camera behind NAT can feed a consumer that is not on its network over TLS. A camera’s cpu may not name an isolated core, because those belong to the realtime loops.

    Frames go into the arm’s episode behind the record feature: a camera with a record_with follows that arm’s franka/<arm>/episode, and on a session’s start opens <record_dir>/<recording_id>-<arm>-cam.rrd under the same Rerun RecordingId and application id as the arm’s own file, so a viewer loads the two as one recording. Frames are logged as encoded images under <arm>/cam/<name>, where that arm’s camera tab is rooted, at the robot time the arm’s state topic maps them to, through a bounded channel and a writer thread, so the capture thread neither blocks on Rerun nor copies a frame between episodes. All the cameras of one arm share its file. A camera on another host does not record, because two monotonic clocks have no common origin; a format other than MJPEG is refused; and what an open episode could not record is counted in the state topic’s record_dropped.

  • One recording for two arms. A collector names an episode and every arm of it writes into one Rerun recording, synchronised on the host’s clock.

    • enable and home take "episode": "<name>", [A-Za-z0-9_-]{1,128}: the session’s recording_id is that name, the arm’s file is <name>-<arm>.rrd, and the episode topic publishes it, so two arms enabled with one name write two files a viewer loads as one recording – no coordination and no timing heuristic in the node. Without a name nothing changes. A name of another shape is refused with the reason and starts nothing. The client example takes --episode NAME.
    • franka_rerun::HOST_TIMELINE, host_time: every row of a live recording carries the host’s CLOCK_MONOTONIC beside robot_time, stamped in Recorder::push where the record is taken (still no allocation there, which tests/flight.rs proves). Two arms are two controllers, so their robot_times are unrelated; host_time is the axis they agree on, and the layout of a named episode opens on it. An offline replay carries robot_time alone.
    • franka_rerun::Prefix, FlightOptions::prefix: every entity of a recording goes under a name (L/joints/q), the node’s being the arm’s. scene, meshes, the styles and the blueprints take it; franka-rerun log --prefix NAME does the same for a replay.
    • flight::send_blueprint takes a Layout, the prefixes of every robot in the recording and the timeline the time panel opens on, and lays out those views per robot; every arm of a node sends the same one, so a shared recording is laid out whichever arm’s blueprint the viewer reads first. RecorderOptions::layout carries it.
    • franka-cam’s file is <recording_id>-<arm>-cam.rrd, so two cameras following different arms of one episode do not collide, and a frame is logged on host_time (its own exposure stamp) as well as on the arm’s robot_time. A frame the arm’s clock pairs cannot map is now written on host_time alone rather than dropped.
    • ee/orientation: the measured and the commanded end effector rotation as quaternions xyzw at every cycle, each continued in sign from the last, for an exporter that needs the orientation at the rate the rest of the state is at (the 3D scene’s pose stays decimated).
    • Recorder::push_at takes a host stamp the caller has already read, so the node’s observer reads the clock once per cycle and stamps the recorder’s row and its own commander/target/* and gripper/* rows with the same instant.
  • franka::realtime::monotonic_ns, the host’s CLOCK_MONOTONIC in nanoseconds. The node’s monotonic_ns, and with it every t_node_ns on the wire, now reads that clock instead of an Instant measured from the process’s start, so a second process on the same host puts its own samples on the same timeline. franka_rerun::APPLICATION_ID names the Rerun application id every stream of that crate opens, and Recorder::to_file_with_id opens a file under a given RecordingId, which is what lets two processes write one recording.

  • CPU pinning of the target-control loop. TargetControlOptions / JointTargetControlOptions gain cpu: Option<usize> (with_cpu): the loop thread pins itself to that core with sched_setaffinity right after raising its priority, through the new franka::realtime::pin_current_thread_to_cpu; a failure is FrankaError::Realtime under RealtimeConfig::Enforce and ignored under Ignore. franka-node passes the arm’s cpu key through.

  • Seven diagnostic channels of the torque backend. CartesianSent gains ik_step and ik_step_clipped (the cycle’s largest raw IK step norm and what the per-joint box clipped off it, rad), ik_blend (the priority blend, 0 the weighted solve, 1 position first), held (the goal held at a wall) and wall_age (cycles since each block’s wall was last pushed on, -1 for no wall). franka_rerun::TorqueLog carries them beside ee_velocity, ik_error and leash, and the recorder writes ik/{step, blend, held, error} and ee/{velocity, leash}, plus ee/orientation/commanded (flight::COMMANDED_ORIENTATION), the rotation of the pose the command carries, which is what the torque backend fills where state.O_T_EE_c is zero. The default layout names every one of them. The addition is additive: an older recording opens unchanged, and a record pushed without a TorqueLog writes none of the new series.

Changed

  • The tuning panel’s metrics are computed at about 100 Hz, and its Zenoh session is a client. The panel keeps 1 in N of a faster state stream, chosen from the measured rate and held through a deadband so it never flaps; before this, every metric was computed as if the stream were 100 Hz whatever state_hz said, so at 1 kHz the “3 Hz” jitter high-pass was really a 30 Hz one — it filtered out the jitter the panel exists to show, reported a contaminant instead (measured 3-6x too high), and collapsed lag_ms to zero. The 60 s ring is bounded, survives a node reboot with the page open, and a reflex no longer walks it. The page, the snapshot (source_hz, decimation, effective_hz) and every saved preset say which rate the numbers came from, and everything counted in samples — lag_ms, the lag window — is measured against the rate really achieved, so a state_hz the decimation cannot bring to exactly 100 (250 Hz gives 125) still times correctly; the jitter filter’s 3 Hz corner does ride with that rate (3.75 Hz at 125), so compare jitter between snapshots at the same effective_hz. At state_hz = 1000 on two arms the panel no longer pegs a Pi core but still costs roughly half of one, so state_hz = 100 remains the configuration to run. franka-tuning-panel now takes --mode {peer,client} in the node’s own vocabulary, defaulting to client: it dials out and is never dialled, which is what works behind a tunnel, and it still scouts by multicast. --listen needs --mode peer, --zenoh-config still wins over the flags, and the resolved session is printed at startup. A URL path can no longer create an arm the bridge then polls.

  • Live tuning caps the wrist’s joint_damping at 40 Nm s/rad (joints 5 to 7; 60 on 1 to 4): with the velocity barrier’s 20 added, K × 1 ms / I on the wrist’s 0.074 kg m² is 0.81 there and would be 1.08 at 60, past the stability rule in the impedance reference.

  • Raising velocity_feedforward_gain above 0 needs the operator’s confirmation (confirm_above 0), as crossing a budget’s threshold does; the tuning panel puts the feedforward group behind its lock.

  • Lowering a budget’s acceleration or jerk while the arm moves never steps the command but lengthens the stop (toward v² / 2a, plus the jerk’s ramp): at the default 0.3 m/s (0.17 m/s per axis), an acceleration dragged from 0.5 to 0.1 m/s² with the goal 3 cm ahead overshoots it by about 20 cm, and faster motion or a lower acceleration overshoots further. CartesianTargetControl::tune, the live-tuning guide and the tuning panel’s envelope advisory now say to lower the velocity first.

  • Velocity feedforward now defaults to off: ImpedanceOptions::velocity_feedforward is false on both interfaces, and with it the node’s velocity_feedforward key and the live weight it seeds (0). Real arms vibrated with it on: dq_goal is a finite difference (of the IK solution on the Cartesian interface) and Kd carries its noise into the torque. Off, a goal moving at v is tracked (Kd / Kp) v behind, which the leash turns into a speed cap. velocity_feedforward_gain stays 1, so switching it on means all of it; velocity_feedforward_cutoff is the knob to try against the ripple. A node config that sets only velocity_feedforward_gain now runs without feedforward and needs velocity_feedforward = true to keep it. Python’s velocity_feedforward keyword now defaults to None (the Rust default), and backend='robot' refuses any explicit value, True included. nonrealtime_commander’s --no-feedforward became --feedforward.

  • The model-library feature is now off by default. Both robots use the native Rust model through Robot::load_model(). Applications that explicitly download the Panda’s shared library must enable model-library; the optional comparison path remains available.

  • The guard’s workspace box is off unless a config asks for one. GuardOptions::workspace is now an Option<Workspace> defaulting to None, and ArmConfig::workspace is optional in the TOML. The retired default – x 0.2..0.8, y -0.5..0.5, z 0..0.8 in the base frame – assumed a forward-facing bench and refused good poses anywhere else, and the box had no off switch: max_lead and max_lead_rotation are disabled by a zero, but a zero is a legitimate coordinate, so “no box” could only be written as an absurdly large one. A config that names a box still gets exactly that box, and an inverted or non-finite one is still refused. With no box a target is still bounded by the step and lead limits, the rate bucket, the leash and the session’s own deviation guard. Workspace moved from config to guard, beside the check that enforces it.

  • The minimum Rust version is 1.89, what nalgebra 0.35 already required; 1.85 was stale.

  • The torque backend caps the goal at 0.7 of each joint’s velocity limit by default. IkOptions::max_step (10 rad/s) is removed; the cap bounds the IK’s step instead.

  • New public fields break struct literals: ImpedanceOptions gains joint_velocity_fraction and velocity_barrier_fraction; CartesianSent and JointSent gain dq_goal, cap_scale and tau_envelope.

  • A joint_velocity_fraction above velocity_barrier_fraction (0.85) fails validate unless the barrier fraction is raised too.

  • IkOptions::limit_margin is removed; ImpedanceOptions::joint_position_margin (0.05 rad) replaces the 0.02 rad clamp. ImpedanceOptions gains that field, IkOptions gains rotation_weight, CartesianSent gains pinned, tau_position, ik_passes, stall_pressure and stalled, JointSent gains pinned and tau_position, and franka_rerun::TorqueLog the recorded ones: struct literals break.

  • The Cartesian IK weighs orientation by rotation_weight 0.1 m/rad instead of 1, also in free space; set it to 1 for the previous trade. ik_error and IkOptions::tolerance are the weighted norm (1e-6 is 1 µm or 10 µrad).

  • cap_scale is the fraction of the generator’s step the goal carried on a cycle a joint limit, position or velocity, cut it, and exactly 1 on every other cycle.

  • With the impedance backend a joint target or posture is refused inside joint_position_margin of a joint limit (JOINT_LIMIT_INSET, 0.02 rad, with the robot’s controller).

  • CartesianSent gains ik_step, ik_step_clipped, ik_blend, held and wall_age, and franka_rerun::TorqueLog gains ee_velocity, ik_step, ik_blend, held, wall_age, ik_error and leash: adding a field is semver-visible because neither is #[non_exhaustive], and both stay exhaustive on purpose so that a downstream test can still build one field by field. TorqueLog::default() leaves wall_age at 0, which reads as a wall; a producer with no walls sets [-1; 2] itself.

Fixed

  • franka-node: enable, release and home refuse client_id 0 ("client 0 is not the holder"), as acquire and gripper_home already did; a free arm’s holder is 0, so an enable from client 0 used to start a session nobody held.
  • franka-node: every stop of a joints session (the stop verb, the watchdog, a lost lease, shutdown) first re-targets the loop to the measured configuration, clamped inside the joint limits less the guard’s margin, as an early end of home already did, so the arm decelerates where it is instead of travelling on to the last accepted target.
  • Tuning panel: an <arm> path segment that is not [A-Za-z0-9_-]+ is a 404 before it reaches a Zenoh key expression, so * or ** can no longer fan one request out to every arm; a Content-Length that is missing, not a byte count or over 64 KiB is refused (422, or 413 for the size) and the connection closed, and a stalled request times out after 30 s.
  • Tuning panel: --expose-to-network now serves the panel to the network it binds. The Host guard used to allow the loopback names whatever the flag said, so every request from another machine was a 403; exposed, it also serves a Host that is an IP literal (a rebound DNS name never is one, so the rebinding defence stands), --allowed-host NAME adds an exact DNS name where the panel is exposed, and the startup line names the URLs that are open and who can reach them. A loopback bind still serves the loopback names and nothing else. An exposed panel has no authentication: anyone on that network can change any live parameter of a moving arm.
  • Tuning panel: a refused request closes the connection. A 403 was sent before the body was read, so on a kept-alive connection the unread body was parsed as the next request — a cross-origin page could put a whole request in the body of a text/plain POST, which needs no preflight, and have it served with a Host the bind accepts and no Origin. A request with no Host or several, an absolute-form target, or a body on a GET or DELETE (declared by length or by chunking), is now a 400 and closes too, and every response carries X-Frame-Options: DENY, frame-ancestors 'none' and nosniff, so the page that drives an arm cannot be framed.
  • VR teleop: a fresh arm state with a non-finite pose or joint velocity is treated as no state: the clutch releases, nothing is driven, and the client says so once. Before, a NaN passed both leash checks and a latch on it made every target NaN.
  • The Python node client gives queued state callbacks up to 250 ms to catch up after a long local streamer stall, preventing a false disconnect on the next tick. Repeated stalls cannot extend that deadline; normal node-silence detection and explicit session-end handling remain unchanged.
  • The franka-rs wheel carries LICENSE and NOTICE, libfranka’s notice included, under its dist-info/licenses/; only the sdist had them.

0.3.0 - 2026-09-10

Added

  • An impedance backend for target control, the default. TargetControlOptions / JointTargetControlOptions gain backend: Backend (with_backend): Backend::Impedance(ImpedanceOptions) runs the loop through control_torques and sends, every cycle, the torques of the hybrid joint impedance law of DROID’s controller (polymetis HybridJointImpedanceControl), tau = (Jᵀ Kx J + Kq)(q_goal − q) + (Jᵀ Kxd J + Kqd)(dq_goal − dq) + coriolis, clamped to torque_limits and low-pass filtered at cutoff_frequency (100 Hz); the arm is compliant around the target. The damping acts on the velocity error (velocity_feedforward, default on; off is DROID’s form). Without an echo of a torque command the generator is anchored every cycle on the measured state pulled toward the previous desired by at most the Leash (0.025 m, 0.15 rad; 0.1 rad per joint), also during the stop’s hold, so an arm that is held back never meets more than the felt stiffness times the leash (roughly 25 to 30 N at the default gains at the ready pose, 18.75 N with project_joint_gains; on the joint interface the torque clamp bounds the torque) and the generator resumes from the arm on release. Target control sets no collision thresholds: with the default gains set at least 40 N / 40 Nm, or lower the stiffness. On the Cartesian interface q_goal comes from a differential inverse kinematics (damped least squares, nullspace drift toward posture capped at 0.5 rad/s, a step cap max_step of 0.01 rad per cycle, clamp to the joint position limits; IkOptions) that follows the generator one cycle at a time, so an unreachable target lags rather than jumps; on the joint interface it is the generator’s output. A posture or joint target outside the joint limits (inset 0.02 rad) is refused with InvalidArgument. project_joint_gains (default off) confines the joint gains to the Jacobian’s nullspace so the end effector feels Kx alone (unprojected, the joint springs make the default 750 N/m about 990 to 1180 N/m at the ready pose). The finish waits for the arm to rest (REST_JOINT_VELOCITY, 0.01 rad/s, or the 5 s timeout). ImpedanceGains (CARTESIAN: 750 N/m and 15 Nm/rad with damping 50, 50, 90 Ns/m, about ζ 0.8 at the ready pose, and a small joint term; DROID: DROID’s gains as they were, damping 37; JOINT: the fer_joint_impedance example’s), ImpedanceOptions::cartesian() / ::joint() with with_* builders, franka::impedance_torques (the law, public at the crate root) and rate_limiting::JOINT_POSITION_LIMITS (FR3) / rate_limiting::fer::JOINT_POSITION_LIMITS (FER), from the URDFs in the repository. nonrealtime_commander gains --no-feedforward, --project-joint-gains, --leash M and --thresholds N, and its CSV leash_alteration. CartesianSent / JointSent gain q_goal, the clamped tau, leash_alteration (and leash_angular_alteration for a pose) and, for a pose, the IK residual ik_error. The Python cartesian_targets and joint_targets take backend ('impedance' | 'robot'), cartesian_stiffness, cartesian_damping (6 values, or one float for the translational three), joint_stiffness, joint_damping, torque_limits, posture, torque_cutoff, velocity_feedforward, leash and project_joint_gains. See The impedance backend.

Changed

  • Target control is compliant by default. Both interfaces now send the impedance backend’s torques; the robot’s own controller tracking a pose or joint-position stream, the only behaviour before, is Backend::RobotController (TargetControlOptions::default().with_backend(Backend::RobotController), Python backend="robot"), and controller_mode applies to that backend only. The robot’s joint-side continuity check no longer refuses a fast Cartesian budget in the default backend; the deviation guard and the collision thresholds apply to both. A change of default behaviour, hence 0.3.0 rather than 0.2.1.
  • The book is reorganised into Getting started, Things to keep in mind, How-to and Reference; the 0.2.0 page names redirect. The README is the front door only, with the quick example on ActiveControl.

0.2.0 - 2026-09-09

Added

  • Target control (robot::target_control): Robot::start_cartesian_target_control and Robot::start_joint_target_control run the crate’s control loop on a named thread of their own and return a CartesianTargetControl / JointTargetControl handle whose set_position([f64; 3]) / set_joints([f64; 7]) any low-rate commander can call from any thread at any rate; the loop bridges the steps with the online trajectory generator under the three rules of the otg module (per-axis limits from the norm budget, one nominal DELTA_T per command, re-anchoring on the robot’s echo), the rate limiter under the same budget as the backstop, a measured-deviation guard and a settle-then-finish stop() that returns the loop’s result. TargetControlOptions / JointTargetControlOptions carry the budget (joint default: 20 % of the negotiated version’s limits), the controller mode, the guard, the settle criterion, an optional SCHED_FIFO priority for the loop thread and an observer called every cycle on the realtime thread with what was sent (the flight recorder’s hook). TargetSlot<N> is the seqlock underneath, public. MultiOtg::with_limits builds a generator with per-axis limits and realtime::set_current_thread_scheduler_priority raises a thread to a chosen priority. Tested on franka-sim (tests/sim_target_control.rs).
  • Cartesian target control carries an orientation. CartesianTargetControl::set_pose (column-major, as O_T_EE; a rotation block within 1e-3 of orthonormal is repaired, one further off refused), set_target(position, quaternion) and set_orientation(quaternion) with unit quaternions in [x, y, z, w] order, target_orientation() and target_pose(); set_position keeps the target orientation. The orientation runs on three more axes of the same synchronised generator, on the base-frame rotation vector of the orientation error re-anchored on the echo every cycle, under TargetControlOptions::rotation_limits (0.5 rad/s, 1.0 rad/s², 20 rad/s³ by default) with with_rotation_limits, and an angular deviation guard max_angular_deviation (0.5 rad). CartesianSent gains the sent orientation, the angular velocity and acceleration and the rotational backstop alteration. The Cartesian backstop now references the twist and acceleration it sent rather than the echoed ones, whose float32 rounding had its jerk clamp firing at noise level (on the rotation that was an orbit of 5 mrad around the target), and REST_VELOCITY / REST_ACCELERATION drop to 1e-4 and 0.05 so the hold’s freeze stays under the joint-side jerk limits. OtgLimits::scaled is new. The commander example’s --rotate adds a slow yaw sweep of ±15° (bridged mode only).
  • otg module: an online trajectory generator (Otg, MultiOtg<N>, CartesianOtg, OtgLimits) that re-plans a time-optimal, jerk-limited seven-segment profile every cycle from the commanded state to rest at the latest target, so a stream of stepped, bursty or stalled targets becomes a C2 command that never exceeds its velocity, acceleration and jerk limits, does not overshoot a reachable target, lands exactly and stays there; optional axis synchronisation, set_position to re-anchor on the robot’s echo, per_axis_for_norm for a budget that is a norm. Dependency-free and allocation-free. The module documentation gives the rules that keep a rate limiter behind it from orbiting at the velocity cap, and two replay tests pin them down.
  • nonrealtime_commander example: a scripted (or stdin) commander sets Cartesian targets through start_cartesian_target_control (--bridged, --budget V,A,J), or hands them to a bare control_cartesian_pose to provoke a reflex (--raw); --log writes one CSV row per cycle, from the loop’s observer, with the joint angles, the external wrench and the generator’s velocity and acceleration. The budget exists because the robot also checks the joint-space continuity of a Cartesian pose stream, which the rate limiter does not bound; see Target control. franka-rerun’s commander_live example is on the same API, with the Recorder in the observer.
  • Python bindings (crates/franka-py, import franka; pip install franka-rs, or maturin develop from the source tree): Robot, RobotState (numpy fields and a 69-float flat() observation), robot.cartesian_targets() / robot.joint_targets() as context managers over the target control loops with move_to, move_by, follow(chunk, dt), target(), state() and stop(), Gripper, Model over numpy (robot.model()), franka.rotated, and FrankaError / ControlException. A Cartesian target carries an optional unit quaternion, a delta an optional rotation vector. The 1 kHz loop stays on its Rust thread and never takes the GIL. PyO3 0.29, abi3 for Python 3.9+; tested against franka-sim in CI’s python-bindings job. Examples: crates/franka-py/examples/policy_loop.py, rotate.py and the quickstart.ipynb notebook. See Python. .github/workflows/release.yml builds the wheels and publishes them and the crate on a v* tag.
  • automatic_error_recovery example: command-line recovery that prints the robot mode before and after. move_to_ready example: the examples’ motion generator to libfranka’s ready pose at a fraction of full speed.
  • serde feature (off by default): Serialize / Deserialize for RobotState, RobotMode, Errors, Duration, Record, RobotCommandLog, MoveStatus and ControlException. Errors serialises as the list of the set flags’ names.
  • franka-rerun, a workspace crate (publish = false, Rust 1.96) that replays logs in Rerun: csv turns the commander example’s log into target, commanded and measured positions, the command’s derivatives against the rate limits and a 3D replay of the arm; log replays a saved ControlException control log as a flight recording (contact and collision flags, external wrench, commanded versus measured, errors, the arm), through flight::{log_records, replay_exception, save_records, load_records}; and Recorder streams the same live from inside a control loop with a non-blocking, non-allocating push. examples/reflex_replay.rs puts the last two together; examples/commander_live.rs streams the commander into a viewer. Both replays draw Franka’s link meshes with --meshes DIR, csv has the screen-capture --layout demo and the commander’s --budget lines, and log locates a contact on the arm from the external joint torques (flight::contact). See the flight recorder page.

0.1.0 - 2026-09-07

Initial release. franka-rs is a pure-Rust libfranka client speaking both generations of the Franka Control Interface: FCI v10 (Franka Research 3, ported from libfranka 0.21.2) and FCI v5 (Franka Emika Robot, FER, ported from libfranka 0.9.2).

There is no earlier release to compare against, so “Added” describes the release itself and “Changed” records the places where this crate deliberately differs from libfranka.

Added

  • Franka Emika Robot (FER) support (FCI v5), alongside the FR3 (FCI v10); see the FER specifics page. Robot::new negotiates the version automatically (connects announcing v10, retries as v5 if the robot reports kIncompatibleLibraryVersion); RobotOptions::with_version(VersionPolicy:: Exact(FciVersion::V5)) skips the extra round trip when the generation is already known. New public API: FciVersion, VersionPolicy, RobotOptions, VirtualWallCuboid, Robot::{with_options, fci_version, set_filters, virtual_wall}, Model::{from_model_library_bytes, from_model_library_path}, model::{load_from_robot, SoModelBackend, model_library, so_backend}, RobotState::from_wire_v5, rate_limiting::fer (selected automatically from the negotiated version), wire::robot::{v5, codec}. New example fer_joint_impedance.
  • The FER model no longer needs a download. Robot::load_model() on an FER now returns the native model — franka::model::FER_URDF, evaluated by the same backend the FR3 uses — instead of downloading and dlopening the robot’s shared object. Needs no network, no model-library feature and no x86-64 Linux host; also reachable without a robot as Model::native_fer(). Its link inertial parameters were identified from a real FER’s own model library by the new tools/fer-model-fit (a regressor from the crate’s own dynamics backend, solved with a truncated SVD), agreeing with the library to 9e-16 on kinematics and 4e-14 on dynamics with no payload, and characterising the one known gap, a payload non-linearity in the library itself (see the model page). Robot::load_model_from_robot() keeps the previous behaviour (download + dlopen on v5, unchanged GetRobotModel URDF on v10). The native model is checked in CI via tests/fer_native_conformance.rs against a committed fixture, with no robot or shared object present.
  • aarch64 builds, via cargo-zigbuild or a cross gcc, or as a fully static aarch64-unknown-linux-musl binary with --no-default-features (a static binary can’t dlopen, and the FER’s model path is x86-64-only anyway). CI cross-builds for aarch64-unknown-linux-gnu on every push; verified under qemu-aarch64 against both simulators, identical to native.
  • New example cartesian_impedance_active_control — libfranka’s cartesian_impedance_control.cpp (a spring-damper system whose equilibrium is the initial end-effector pose) driven through the ActiveControl API’s read_once / write_once instead of a control callback, allocation-free and running unchanged on an FR3 (FCI v10) and an FER (FCI v5).
  • New example cartesian_impedance_figure_eight — the same ActiveControl impedance loop with a moving equilibrium: a Lissajous figure eight around the start pose, raised-cosine ramps on both the path amplitude and the stiffness at either end (and after Ctrl-C), a nullspace joint spring toward the initial configuration through a damped-inverse projector, a one-sided virtual floor, a ±25 Nm torque clamp and a deviation cut-out. Allocation-free, on both generations. The pieces it shares with cartesian_impedance_active_control moved to examples/common/cartesian.rs.
  • New example dual_communication_test — the zero-torque communication-test loop against two robots at once from one process, with per-robot loop timing, lost-state and success-rate accounting.
  • bench/fer-capture/ — offline tooling that reassembles the FCI v5 state datagrams from a pcap/pcapng capture and reports inter-arrival gaps, drift and a stall verdict, to tell whether an FER loop’s lost cycles come from the robot/network or the host.
  • bench/so-micro/ — an offline per-call microbenchmark of the FCI v5 model path, Rust vs C++, against the same captured shared object, needing no robot or simulator.

Changed

  • CommandName::command() is now CommandName::command(self, version: FciVersion) -> Option<u32>, since the TCP command numbering differs between the two protocol versions (None means the command does not exist in that version); CommandName gained two v5-only variants, GetCartesianLimit and SetFilters.
  • SoModelBackend::open, SoModelBackend::from_bytes, Model::from_model_library_bytes and Model::from_model_library_path are now unsafe fn: they dlopen the file or bytes given to them, so the caller must assert it is a trusted model library. Robot::load_model() stays a safe fn, as in libfranka.
  • A Connect reply with status kSuccess reporting a version other than the one the client announced is now FrankaError::IncompatibleVersion rather than being decoded against the wrong layout — what lets automatic version negotiation recognise an FER on the simulator; no effect against a real FR3 or FER.

Contributing

The checks

Everything CI’s check job runs, in order — none of it needs Docker, a network or a robot:

cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo clippy --workspace --all-targets --no-default-features -- -D warnings
cargo clippy -p franka-rs --all-targets --no-default-features -- -D warnings
cargo clippy -p franka-rs --all-targets --features serde -- -D warnings
cargo clippy -p franka-rs --all-targets --features model-library -- -D warnings
cargo test --workspace --lib
cargo test -p franka-rs --lib --features serde
cargo test -p franka-rs --lib --features model-library
cargo test -p franka-rs \
  --test wire_sizes --test model_conformance \
  --test wire_sizes_v5 --test fer_native_conformance \
  --test example_motion_generator
cargo test -p franka-rs --features model-library --test fer_model_conformance
cargo test --workspace --doc
cargo test -p franka-rs --doc
cargo test -p franka-rerun
cargo build -p franka-rerun --examples
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps
RUSTDOCFLAGS="-D warnings" cargo doc -p franka-rs --no-deps

The standalone -p franka-rs checks cover the default native model without the optional model-library loader. Workspace builds can enable that feature through the fer-model-fit development tool, so CI also checks each path explicitly.

cargo test --workspace --lib includes the README-sync unit tests: the README’s “Quick example” block must stay byte-identical to the body of main in crates/franka-rs/examples/readme_joint_move.rs, and at most 20 lines of code. Edit the example and the README together, or the test fails.

Do not run cargo test --tests: it selects every integration binary in the workspace, including the sim_*.rs files, which start a Docker container.

The simulator lock

Only one franka-sim may run per host — the FCI ports (1337 robot, 1338 gripper) are fixed by the protocol and only one client may hold them at a time. Take the machine-wide lock for anything that touches the simulator:

flock .sim.lock env FRANKA_SIM_IMAGE=franka-sim:dev cargo test --release -p franka-rs \
  --test sim_handshake --test sim_commands --test sim_motions \
  --test sim_gripper --test sim_stop_and_reflex --test sim_target_control -- --test-threads=1

--release because the loops under test answer a 1 kHz state stream; unoptimised, the impedance loop of target control needs about a millisecond per cycle.

The container harness has its own test, which starts and tears down a real container and asserts on docker ps. No CI job runs it, because it cannot share a server with anything else; run it by hand:

flock .sim.lock env FRANKA_SIM_IMAGE=franka-sim:dev cargo test -p franka-sim-test --test harness

Always --test-threads=1. Before starting anything, check that ports 1337 and 1338 are free and no franka-sim* container is running. SimServer::start refuses to start a second container, but two concurrent cargo test invocations will still fight over the ports of an already-running server. See Test against the simulator.

No robot in CI

CI never talks to a robot, and neither should any test. Every automated job runs against the simulator or against nothing at all. Hardware runs are deliberate and manual: they go through bench/’s --hardware harness, with a read-only Idle precondition probe, a return-to-ready move before each run, and torque and end-effector deviation guards checked outside the timed region. See bench/README.md.

If you are adding a test that would need an arm, add a simulator test and a characterisation assertion for the gap instead.

Working on the book

cargo install mdbook --locked      # no root needed
mdbook build docs/book             # output goes to target/book
mdbook serve docs/book             # live reload at http://localhost:3000

The build output is target/book/, which is already gitignored, so a local build leaves nothing untracked behind.

The book has four parts, and a page belongs to exactly one of them. Getting started is the shortest path to a moving arm; Things to keep in mind is what every user must know before the first motion; How-to is one task per page, code first; Reference holds the protocol tables, constants, measurements and the reasoning behind design decisions. Wire-format and version differences, benchmark numbers and the long explanations go to Reference and are linked from the other parts, not repeated there. Every claim must be traceable to the code, a test or a measurement, with no marketing adjectives. book.toml keeps redirects from the 0.2.0 page names, so a renamed page gets a redirect entry.

Every Rust snippet in the book is compiled by mdbook test. It needs the crate on rustdoc’s search path, and rustdoc fails with E0464 if that path holds more than one libfranka-<hash>.rlib — which an incrementally used ./target usually does. So build into a scratch directory:

export CARGO_TARGET_DIR=/tmp/franka-booktest
cargo build -p franka-rs -p franka-sim-test
mdbook test docs/book -L "$CARGO_TARGET_DIR/debug,$CARGO_TARGET_DIR/debug/deps"
unset CARGO_TARGET_DIR

.github/workflows/docs.yml does exactly that. Snippets carry a hidden # extern crate franka; line for the same reason rustdoc needs it: mdbook test runs them at edition 2015.

The changelog page is an mdBook include of the repository’s CHANGELOG.md, so it is single-sourced — edit the changelog, not the page. The benchmarks page summarises the measurements and should stay in sync with the harness’s results.

Snippets are marked no_run: they are type-checked but never executed, because every one of them would otherwise try to open a socket to a robot.

Publishing

.github/workflows/docs.yml builds the book and cargo doc --no-deps -p franka-rs (with RUSTDOCFLAGS="-D warnings", so a broken intra-doc link fails the build), places the rustdoc under book/api/, and deploys the result to GitHub Pages on every push to main. It can also be run by hand from the Actions tab (workflow_dispatch).

Repository setting required. The workflow uses actions/deploy-pages, which only works when Settings → Pages → Build and deployment → Source is set to “GitHub Actions” (not “Deploy from a branch”). This has to be set once per repository; until then the deploy step fails with a “Pages site not found” style error even though the build succeeds.

The site lands at https://barisyazici.github.io/franka-rs/ and the API reference at https://barisyazici.github.io/franka-rs/api/franka/index.html.

Releasing

.github/workflows/release.yml runs on a v* tag: it builds the franka-rs wheels (x86_64 and aarch64 manylinux, plus the sdist) with PyO3/maturin-action, uploads them to PyPI through trusted publishing (pypa/gh-action-pypi-publish), attaches the franka-node and franka-cam tarballs (cargo-zigbuild, aarch64 gnu and musl, x86_64 gnu) to the tag’s GitHub release, and publishes those of franka-rs, franka-description, franka-rerun, franka-cam and franka-node whose version crates.io does not have yet, with a short-lived token from crates.io’s trusted publishing (rust-lang/crates-io-auth-action). No secret is stored anywhere. From the Actions tab (workflow_dispatch) it builds the wheels and tarballs and dry-runs the crate publish, uploading nothing.

Trusted publishing is configured on both registries for the GitHub repository BarisYazici/franka-rs, workflow release.yml, environment pypi; no API token is stored in the repository or its secrets. Trusted publishing only updates a crate that exists, so a release that brings a new crate (0.4.0: franka-description, franka-rerun, franka-node, franka-cam) is published by hand from the release commit with an API token and a current stable cargo (multi-package publish needs 1.90), cargo publish -p franka-rs -p franka-description -p franka-rerun -p franka-cam -p franka-node (cargo publishes them in that dependency order), before the tag is pushed; then each new crate gets the same trusted publisher on crates.io, and the tag’s run skips the versions crates.io has. The Python client franka-node-client (crates/franka-node/python, job node-client) and the VR teleop package franka-vr-teleop (tools/vr-teleop, job vr-teleop) go to PyPI in the same upload. Before the first tag that ships each, register a pending trusted publisher for it on PyPI with the same repository, workflow release.yml and environment pypi, since trusted publishing cannot create the project otherwise.

A release is: bump version in the root Cargo.toml’s [workspace.package] (all five crates and the wheel share it; crates/franka-node/python/pyproject.toml and tools/vr-teleop/pyproject.toml repeat it, the latter also in its franka-node-client== pin, which tests check; on a minor bump also the version = "0.x" of franka, franka-description and franka-rerun in [workspace.dependencies]), date the section in CHANGELOG.md, and push a v* tag matching it (the tarball job refuses another, since cargo binstall looks for v<version>).

Commit conventions

Plain commit messages, no trailers: a subject line in the imperative mood saying what the commit does, and a body explaining why when that is not obvious from the diff.