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 arm | Install the library, then prepare the realtime machine |
| Keep control on a Raspberry Pi 5 and use my laptop for the application | Set up the Pi and remote Python client |
| Try it without hardware | Try the online notebook or run the simulator |
| Add cameras, a gripper, or recordings | Peripherals and recording |
| Drive a node-served arm from a Meta Quest controller | Teleoperate 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
- First program and Python.
- How the FCI works and realtime requirements.
- Node configuration, recording, and tuning a running controller from the browser panel.
- Benchmarks, API reference, and contributing.
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.
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
- Have a Pi? Set up the Pi and your laptop.
- Using one Linux PC? Install the library.
- No robot yet? Try the simulator and viewer.
For the full component diagram, message flow and package reference, see Architecture in detail.
Architecture in detail
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:
- Python sets a goal.
targets.move_by([0, 0, 0.05])returns immediately. Thefranka-node-clientbackground thread advances targets toward that goal, at 50 Hz by default. It handles the lease, message numbering and keepalive. - 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.
- 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.
- The Rust library makes continuous motion locally.
franka-rsgenerates 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. - 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
| Piece | Its job | Do I install it? |
|---|---|---|
franka-node | Own the robot connection, manage sessions and run control beside the arm | Yes, on the control host for the node setup |
franka-node-client / import franka_node | Turn Python goals into a paced target stream; manage access and read state | Yes, where your application runs |
| Zenoh | Carry goals, state and requests between application and node | Included as dependencies; no separate router for a direct connection |
franka-rs | Speak FCI, calculate the model, generate trajectories and torques | Already compiled into the node |
franka-py / pip install franka-rs / import franka | Use that library directly inside a Python process | An alternative to the node client; not required with it |
| Franka Hand | Grasp objects through its separate connection, exposed by the node when configured | Optional hardware; built-in node driver |
franka-cam | Capture camera frames and publish them over Zenoh | Optional separate process near the cameras |
franka-rerun | Record and replay motion; node recording is a build feature | Optional; the Rerun viewer opens recordings |
franka-description | Supply robot meshes for visualization | Dependency of visualization tools, not a controller |
franka-sim | Replace the robot’s FCI endpoint for development | Optional 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
| Layout | Where Python runs | Where the node and 1 kHz loop run | Python endpoint |
|---|---|---|---|
| Pi beside the robot | Your ordinary laptop or workstation | Pi 5 with realtime Linux, wired to robot | tcp/<pi-address>:7447 |
| One computer | The same Linux PC | That PC with realtime Linux, wired to robot | tcp/127.0.0.1:7447 |
| Try without hardware | Your development machine | Linux host alongside the simulator; realtime enforcement can be disabled for simulation | Localhost 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:
| Where | What |
|---|---|
| crates.io | franka-rs, franka-description, franka-rerun, franka-node, franka-cam |
| PyPI | franka-rs (direct Python bindings), franka-node-client, franka-vr-teleop |
| GitHub release | prebuilt 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
doctorcommand, 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:
| Priority | User experience | Work needed |
|---|---|---|
| 1. Install | One supported install path for Pi and Linux PC; one Python package | Publish matching binaries/packages, verify clean-machine installs, pin compatible versions |
| 2. Set up once | Name the robot, see “ready”, start automatically at boot | Guided config and service installation; check kernel, scheduling, network and FCI without moving the arm |
| 3. Send a goal | Defaults adapt to the connected node | Exchange protocol/capabilities/limits; validate goals early; return structured refusal reasons and actionable errors |
| 4. Handle interruptions | Clear connection, ownership, holding and fault states | Connection diagnostics; deliberate recovery and re-enable; explain what stop does |
| 5. Trust the result | A small, documented support matrix | Repeatable 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
RobotStateat 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
| part | what it adds | where it is described |
|---|---|---|
| Franka Hand gripper | open, close and grasp over the hand’s own connection on port 1338; in setup B, served by the node under the arm’s lease | Peripherals, Use the gripper |
| USB cameras | frames 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 instant | Peripherals |
| Recording | every 1 kHz cycle, the commands and the accepted targets as a Rerun file per session; camera frames can join the same recording | Peripherals, Record and replay a run |
| The tuning panel | gains, 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 library | Tune a running controller |
| Quest teleoperation | a 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 node | Teleoperate with a Quest |
| The simulator | a MuJoCo robot that speaks the same wire protocol, for trying either setup without an arm | Without a robot: franka-sim |
A decision in one table
| question | setup A | setup B |
|---|---|---|
| where does the 1 kHz loop run? | in your program’s process | in franka-node on the Pi |
what needs PREEMPT_RT? | the machine running your program | the Pi only |
| what does your program link? | franka-rs or pip install franka-rs | a Zenoh client; in Python franka-node-client |
| can it run on macOS or Windows? | no | yes, the client side |
| can a second program take over the arm? | not while the first is connected | once the first releases it or its lease lapses |
| 1 kHz state, observer hook, own callback | yes | no; 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 Xlibor a missing OpenGL library: useghcr.io/barisyazici/franka-sim:1.1.7-viewer, rather than adding--visto the headless image.Failed to open displayor an authorization error: checkecho "$DISPLAY"and thatSIM_XAUTHORITYpoints to your current desktop’s readable Xauthority file. On Wayland, XWayland must be available. Do not continue if thetestcommand 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:
| flag | effect |
|---|---|
--vis / -v | Opens the native visualization; requires the desktop setup above. |
--gripper-physics | Includes the physics gripper so its fingers move in the viewer. |
--enforce-motion-limits | Runs 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-constraints | Enables the communication-constraint checks. |
--no-gripper | No 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, orFRANKA_SIM_ADDRpointed 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.
Follow these steps
- Parts and assembly
Shopping links, the cooler and PCIe board, and which cable goes where. - System and network
Install the realtime kernel, enable the Intel NIC, and connect the arms. - Node and laptop client
Install franka-node, read the first state, then try a small motion. - Optional enclosure
Explore the 3D assembly and download the printable case or editable CAD.
What runs where?
| On your laptop | On the Pi | At the robot |
|---|---|---|
| Your application sends targets over Zenoh | franka-node runs franka-rs at 1 kHz | One 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.
| Part | Qty | What to look for / links |
|---|---|---|
| Raspberry Pi 5 | 1 | The reference BOM uses the 2 GB model. Official product and resellers. More RAM is useful for compiling locally. |
| Raspberry Pi Active Cooler | 1 | SC1148, fits the Pi 5 fan header and mounting holes. Product · installation brief. |
| Official 27 W USB-C power supply | 1 | Use the version for your mains socket and its attached cable. Product and resellers. |
| microSD card | 1 | Reference: 64 GB A2. The NIC occupies the Pi’s PCIe connection, so this build boots from microSD. Raspberry Pi cards. |
| PCIe adapter/baseboard | 1 | 52Pi / GeeekPi P02, EP-0219. Open-ended x1 slot, custom FFC, pillars and screws. 52Pi store · Pi Hut · assembly instructions. |
| Intel dual-port NIC | 1 | I350-T2 V2 / I350T2V2BLK, with the low-profile bracket for this enclosure. Intel specifications · supplier comparison. |
| Ethernet patch cables | 1 per arm | Cat6 in a suitable length; reference BOM: Goobay 93571, 3 m. One extra cable if the Pi’s built-in Ethernet is your uplink. |
| Mechanical support | 1 | Support 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
- 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.
- 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.
- 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.
- 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.
- 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?
| Connection | Destination |
|---|---|
| Intel port labeled L in the enclosure | Left robot control unit |
| Intel port labeled R in the enclosure | Right robot control unit; leave empty for one arm |
| Pi’s built-in Ethernet or Wi-Fi | Your laptop / lab network, carrying Zenoh and SSH |
| Pi USB ports | Optional cameras |
| Pi USB-C power connector | 27 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.
| Link | Pi address | Robot address |
|---|---|---|
| Left Intel port | 172.16.0.1/24 | 172.16.0.2 |
| Right Intel port, optional | 172.16.2.1/24 | 172.16.2.2 |
| Built-in Ethernet or Wi-Fi | Your existing uplink address | Laptop 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/realtimereports1andulimit -rreports99.- Each Intel port is identified, and routing selects the expected port for each robot.
ping 172.16.0.2works (and172.16.2.2for 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.
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 parts | Print bundle: ZIP — two STLs and a 3MF |
| Open both parts in a slicer | enclosure.3mf |
| Slice one part | base.stl · lid.stl |
| Edit the enclosure in CAD | base.step · lid.step |
| View the enclosure with electronics | enclosure-scene.glb |
| View the electronics alone | assembly.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:
| Setting | Design starting point |
|---|---|
| Nozzle / layer height | 0.6 mm / 0.3 mm |
| Walls / top and bottom | 4 perimeters / 5 layers |
| Infill | 25% gyroid |
| Supports | Designed for no supports; inspect bridges in your slicer |
| Bed | Both parts arranged on a 220 × 220 mm plate |
| Base / lid orientation | Base 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.
The link to the robot
- A wired Ethernet connection to the robot’s control unit.
172.16.0.2is the address in Franka’s documented default setup; the examples take it asargv[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 toSCHED_FIFOand that the kernel advertises realtime capabilities, and fails withFrankaError::Realtimeand 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.
Check the link first
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
- Command from a low-rate program: orientation and joint targets, the deviation guards, the observer.
- Write a 1 kHz callback and Drive the loop yourself: the two 1 kHz interfaces.
- Use the model, Use the gripper, Record and replay a run.
- Run the examples: the runnable programs in the repository.
- From Python: the same target control as
move_to,move_byandfollow.
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 withcartesian_damping=[37, 37, 37, 2, 2, 2]its law;Truedamps 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:Trueconfines the joint term to the nullspace, so the end effector feelscartesian_stiffnessalone.
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.
| call | what 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.running | False 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:
| slice | field |
|---|---|
0:7 | q (rad) |
7:14 | dq (rad/s) |
14:21 | tau_J (Nm) |
21:28 | tau_ext_hat_filtered (Nm) |
28:31 | O_T_EE translation (m) |
31:35 | O_T_EE unit quaternion (x, y, z, w), w >= 0 |
35:41 | O_F_ext_hat_K (N, Nm) |
41:48 | q_d (rad) |
48:51 | O_T_EE_c translation (m) |
51:55 | O_T_EE_c quaternion (x, y, z, w) |
55:62 | joint_contact |
62:68 | cartesian_contact |
68 | time (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.
This notebook launches the public repository’s main branch. You can also
read the notebook source.
Run an experiment
- 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.
- Run the import and stationary-preview cells. You should see the robot before sending any movement command.
- Make a 2 cm move, play the recording, then repeat with a lower velocity limit.
- Change acceleration or jerk, one at a time, and compare the measured response.
- 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) | default | JointTargetControlOptions | default |
|---|---|---|---|
limits: OtgLimits, a norm budget | 0.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 budget | 0.5 rad/s, 1.0 rad/s², 20 rad/s³ | ||
backend: Backend | Impedance(ImpedanceOptions::cartesian()) | backend | Impedance(ImpedanceOptions::joint()) |
controller_mode (robot-controller backend only) | CartesianImpedance | controller_mode | JointImpedance |
max_deviation | 0.30 m | max_deviation | 1.0 rad |
max_angular_deviation | 0.5 rad | ||
settle: Settle (landing tolerance, hold cycles) | 1 mm, 250 cycles | settle | 1 mrad, 250 cycles |
limit_rate | true | limit_rate | true |
realtime_priority: Option<i32> | None (highest) | same | same |
cpu: Option<usize> | None (not pinned) | same | same |
observer | none | observer | none |
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).
ImpedanceOptions | Cartesian interface (::cartesian()) | joint interface (::joint()) |
|---|---|---|
gains.cartesian_stiffness Kx (N/m, Nm/rad) | 750, 750, 750, 15, 15, 15 | 0 |
gains.cartesian_damping Kxd (Ns/m, Nms/rad) | 50, 50, 90, 2, 2, 2 | 0 |
gains.joint_stiffness Kq (Nm/rad) | 40, 30, 50, 25, 35, 25, 10 | 600, 600, 600, 600, 250, 150, 50 |
gains.joint_damping Kqd (Nms/rad) | 4, 6, 5, 5, 3, 2, 1 | 50, 50, 50, 50, 30, 25, 15 |
torque_limits (Nm) | 86, 86, 86, 86, 11.5, 11.5, 11.5 | same |
cutoff_frequency (Hz) | 100 | 100 |
velocity_feedforward | false | false |
velocity_feedforward_gain g, in [0, 1] | 1 | 1 |
velocity_feedforward_cutoff (Hz) | 1000 (MAX_CUTOFF_FREQUENCY: off) | same |
leash: Leash | 0.025 m, 0.15 rad | 0.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_gains | false | false |
posture (IK nullspace reference) | None: the start configuration | not used |
ik: IkOptions | λ 0.05, nullspace gain 1.0 /s, 3 iterations, tolerance 1e-6 (weighted), rotation weight 0.1 m/rad | not used |
joint_velocity_fraction (of max_joint_velocity(version)) | 0.7 | 0.7 |
velocity_barrier_fraction | 0.85 | 0.85 |
joint_position_margin (rad) | 0.05 | 0.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_FREQUENCYturns it off.velocity_feedforward:Kd (g dq_goal − dq)when on,−Kd dqwhen 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) vat 0, against how much of the goal’s own ripple the law forwards.velocity_feedforward_cutoff: a first-order low-pass ondq_goalbefore it is fed forward. The default 1000 Hz isMAX_CUTOFF_FREQUENCY, the filter off; the floorvalidate()accepts isMIN_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 measuredq) 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 withinleash.jointof the measured joint; there the torque clamp bounds the torque (theJOINTpreset’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 throughJon top ofKx: 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 timesKxin 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;Noneis the configuration the loop started in. A posture insidejoint_position_marginof a joint limit is refused withInvalidArgument, 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_velocityaway from them: the FER’sMAX_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 meetsVELOCITY_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, frommin(cap, onset − FADE_BAND × limit)(FADE_BAND0.15) of measured speed to none at the onset, so a joint catching up on its lag cannot cancel the barrier. At leastjoint_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_deviationends 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_behaviorsets, 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_fractionof its limit or closer to a limit thanjoint_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 setsmotion_finishedonly once every joint is slower thanREST_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
- 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 measuredq, and for a pose the model’s pose of that configuration rather thanO_T_EE, so the inverse kinematics starts with a zero residual;q_goalstarts atq. Every later cycle of the impedance backend anchors on the measured state pulled toward the previous desired by at most the leash. - 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.
- 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).
- 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. - Guard. If the measured pose strays past
max_deviationormax_angular_deviationfrom the start, the target freezes and the loop ends withFrankaError::Control. - Land, hold, finish. After
stop(): run until every axis is withinsettle.toleranceof the target and at rest, hold the last command forsettle.cycles, thenmotion_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
| method | what 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
| field | meaning |
|---|---|
width: f64 | Current opening width, m. |
max_width: f64 | Maximum opening width, m, estimated by homing(). |
is_grasped: bool | Whether an object is currently grasped. |
temperature: u16 | Gripper temperature, °C. |
time: Duration | Strictly 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::newthen fails withFrankaError::Network(“Connection to FCI refused …”), even with FCI enabled. examples/grasp_object.rsis 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:
| entity | content |
|---|---|
joints/q, joints/q_d | measured 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_ext | velocities, measured and desired torques, tau_ext_hat_filtered |
joints/q_goal, joints/dq_goal, joints/cap_scale, joints/tau_envelope | a 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/passes | the 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/error | the 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_ext | O_F_ext_hat_K, force in N and torque in Nm |
ee/position | measured 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/orientation | the rotations of those two poses as quaternions xyzw, every cycle (the 3D scene’s pose is decimated by --every) |
ee/orientation/commanded | the 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/leash | the 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/link | where the seven external joint torques say the arm was touched, and the force there |
events | every 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), aRecorderto<out>/run.rrdor--live ADDR, then joints 4 and 6 swing by0.2 (1 - cos(2π t / 12))for--seconds(60) while you push. On the reflex it writesreflex.rrdandreflex.json, recovers, and finishes the recorder.crates/franka-rerun/examples/commander_live.rs: the commander ofnonrealtime_commanderon target control, streamed into an open viewer (--live ADDR,--out FILE,--meshes DIR,--bridgedor--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
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 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.
| Field | Meaning |
|---|---|
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_stiffness | Scales the Cartesian stiffness preset and its associated damping. |
ik_damping | Damping of the inverse-kinematics solve near poorly conditioned poses. |
ik_nullspace_gain | Posture correction in the redundant degree of freedom; zero disables it. |
velocity_feedforward_gain | Weight of the goal velocity in the damping term; zero disables feedforward. |
velocity_feedforward_cutoff | Filter 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.
State and link
| example | what it does |
|---|---|
echo_robot_state | Prints RobotState for 100 cycles. The arm does not move. |
communication_test | Moves 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_test | The 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
| example | what it does |
|---|---|
generate_joint_position_motion | A cosine ramp on joints 4, 5 and 7. |
generate_joint_velocity_motion | A velocity profile on joints 4 to 7. |
generate_cartesian_pose_motion | A circle in the end effector’s x/z plane. |
generate_cartesian_velocity_motion | A diagonal x/z sweep. |
readme_joint_move | The README’s quick example, byte for byte; CI runs it against the simulator. |
move_to_ready | Moves to libfranka’s “ready” joint configuration. Takes [speed-factor] [--yes], default 0.2. |
Torque and impedance
| example | what it does |
|---|---|
fer_joint_impedance | 1 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_control | A Cartesian impedance controller whose equilibrium is the start pose, driven through ActiveControl’s read_once / write_once. Takes [--duration SEC] [--yes]. |
cartesian_impedance_figure_eight | The 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
| example | what it does |
|---|---|
nonrealtime_commander | A 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
| example | what it does |
|---|---|
grasp_object | Homes the Franka Hand, then grasps an object of the given width. Takes <hostname> <object-width>. |
automatic_error_recovery | A 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).
| example | what it does |
|---|---|
reflex_replay | A 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_live | nonrealtime_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 program | use |
|---|---|
| runs at 1 kHz on a realtime machine and computes the next setpoint from the state | callback or ActiveControl |
| is a planner, policy, teleoperation or script that produces targets at its own rate | target control |
| needs to command torques of its own law | callback 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 it | callback, 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 Python | target 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
Robotat a time; a second one returnsFrankaError::InvalidOperationwith libfranka’s message. RobotisSend + Sync, sorobot.stop()can be called from another thread while any of the three runs; the motion then ends withFrankaError::Controlcarrying"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, andFRANKA_REALTIME=enforceon 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.
| group | fields |
|---|---|
| Frames | O_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 |
| Load | m_ee, I_ee, F_x_Cee, m_load, I_load, F_x_Cload, m_total, I_total, F_x_Ctotal |
| Elbow | elbow, elbow_d, elbow_c, delbow_c, ddelbow_c |
| Joint torques | tau_J (measured), tau_J_d (last commanded; what the rate limiter compares against), dtau_J |
| Joint motion | q, q_d, dq, dq_d, ddq_d, theta, dtheta |
| Contact and collision | joint_contact, cartesian_contact, joint_collision, cartesian_collision |
| External wrench | tau_ext_hat_filtered, O_F_ext_hat_K, K_F_ext_hat_K |
| Cartesian commands | O_dP_EE_d, O_dP_EE_c, O_ddP_EE_c |
| Base acceleration | O_ddP_O: the gravity vector as the robot measures it, about [0, 0, -9.81] on hardware |
| Accelerometers | accelerometer_top, accelerometer_bottom (FCI v10 only; zero on an FER) |
| Health | current_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 variant | libfranka exception | raised when |
|---|---|---|
Network(String) | NetworkException | connection or socket failure, including a UDP timeout |
Protocol(String) | ProtocolException | malformed or unexpected protocol data |
IncompatibleVersion { server_version, library_version } | IncompatibleVersionException | the server speaks another FCI version |
Command(String) | CommandException | a TCP command was rejected by the robot |
Control(ControlException) | ControlException | a motion ended abnormally: reflex, preemption, discontinuity |
Realtime(String) | RealtimeException | SCHED_FIFO or /sys/kernel/realtime unavailable under Enforce |
InvalidOperation(String) | InvalidOperationException | not allowed now: a second control loop, or a command the negotiated version does not have |
InvalidArgument(String) | std::invalid_argument | non-finite values, an invalid transform, an invalid elbow configuration |
Model(String) | ModelException | model 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
| method | callback returns | commands |
|---|---|---|
control_joint_positions | JointPositions | q |
control_joint_velocities | JointVelocities | dq |
control_cartesian_pose | CartesianPose | O_T_EE (+ optional elbow) |
control_cartesian_velocities | CartesianVelocities | O_dP_EE (+ optional elbow) |
control_torques | Torques | tau_J |
control_torques_and_joint_positions | Torques + JointPositions | both |
control_torques_and_joint_velocities | Torques + JointVelocities | both |
control_torques_and_cartesian_pose | Torques + CartesianPose | both |
control_torques_and_cartesian_velocities | Torques + CartesianVelocities | both |
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:
| starter | handle | write_once takes |
|---|---|---|
start_torque_control | ActiveTorqueControl | &Torques |
start_joint_position_control | ActiveMotionGenerator<JointPositions> | motion + Option<&Torques> |
start_joint_velocity_control | ActiveMotionGenerator<JointVelocities> | motion + Option<&Torques> |
start_cartesian_pose_control | ActiveMotionGenerator<CartesianPose> | motion + Option<&Torques> |
start_cartesian_velocity_control | ActiveMotionGenerator<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
| call | returns | layout |
|---|---|---|
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.
| variable | default | meaning |
|---|---|---|
FRANKA_SIM_IMAGE | ghcr.io/barisyazici/franka-sim:latest | The FR3 / FCI v10 image. |
FRANKA_SIM_FER_IMAGE | franka-sim:panda-v5 | The 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_ADDR | unset | Attach 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_KEEP | unset | =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/Gripperbefore the next one connects; SimServer::startrefuses to start a second container while afranka-sim*container is running or the ports are bound;- two
cargo testinvocations, 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:
| job | server configuration | runs |
|---|---|---|
sim-nominal | default | sim_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-limits | sim_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.04 | the one sim_gripper test that needs something between the fingers |
python-bindings | --enforce-motion-limits | crates/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
| target | libc | linking |
|---|---|---|
aarch64-unknown-linux-gnu | glibc | dynamic |
aarch64-unknown-linux-musl | musl | fully 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.cppandlowpass_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.soto 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::newnegotiates the version and the same binary drives both. - Target control. libfranka gives you the 1 kHz callback and, on the FR3, the
ActiveControlloop; 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 inpylibfranka). 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. Herestart_cartesian_target_control/start_joint_target_controlrun 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. ActiveControlon 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’slibfcimodels.so. Here both are evaluated natively from parameters; nothing is fetched at runtime unless you call the opt-inload_model_from_robot(). - Build footprint. No C++ toolchain, no Poco, Eigen, Pinocchio or urdfdom, no system
packages;
rustup target addcross-compiles it. - Rust things.
RobotisSend + Syncand stops from another thread through anArc; errors are one enum with a variant per libfranka exception; aserdefeature makes the control log serialisable;crates/franka-rerunreplays 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_ros2and theros2_controlhardware 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
kSuccesshandshake that reports a different version than the client announced is anIncompatibleVersionerror 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_rateandcutoff_frequencyare explicit arguments of everycontrol_*method because Rust has no default arguments, and libfranka’s defaults differ between the two versions (limit_rateisfalsein 0.21 andtruein 0.9.2). See Rate limiting and filtering.control_*never fails withFrankaError::Realtime; the priority is raised inRobot::new, as in libfranka’sRobot::Implconstructor, 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 version | libfranka semantics | Robot | System | RobotState | RobotCommand | Model source |
|---|---|---|---|---|---|---|
| v10 | 0.20 – 0.21 (ported from 0.21.2) | Franka Research 3 | 5.9.0 or later (server version 10) | 1377 B | 371 B | URDF from GetRobotModel, evaluated natively |
| v5 | 0.9.2 | Franka Emika Robot / Panda (FER) | 4.2.1 or later (server version 5) | 2373 B | 370 B | shipped 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) | |
|---|---|---|
| libfranka | 0.9.2 | 0.21.2 |
RobotState datagram | 2373 bytes, every field f64, no accelerometer arrays | 1377 bytes, mixed f64/f32 |
RobotCommand datagram | 370 bytes: MotionGeneratorCommand 306 + ControllerCommand 56 (tau_J_d alone) | 371 bytes: ControllerCommand 57, also carries torque_command_finished |
tau_J_d in the state | f64 | f32 |
| command numbering | Connect 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 13 | Connect 0, Move 1, StopMove 2, SetCollisionBehavior 3, SetJointImpedance 4, SetCartesianImpedance 5, SetGuidingMode 6, SetEEToK 7, SetNEToEE 8, SetLoad 9, AutomaticErrorRecovery 10, GetRobotModel 11 |
Move request | 56 bytes | 113 bytes: adds use_async_motion_generator and seven maximum_velocity values, always transmitted |
Move generator modes | four, no None | the same four plus None (4) |
Move::Status | 10 values | 12: 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 zeros | user torques + MotionGeneratorMode::None |
SetFilters | yes (Robot::set_filters) | removed from the protocol |
GetCartesianLimit | yes (Robot::virtual_wall) | removed from the protocol |
GetRobotModel (URDF) | no such command | yes (Robot::robot_model) |
LoadModelLibrary | yes (Robot::load_model_from_robot) | no such command |
| joint velocity limits | none 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 |
kTolNumberPacketsLost | 3.0 | 0.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.8973 | J1 ±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 protocol | identical (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_torquesandstart_torque_controlsendMove { controller_mode: ExternalController, motion_generator_mode: JointVelocity };- every 370-byte command datagram carries your
tau_J_dand a zerodq_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 v5ControllerCommandhas notorque_command_finishedfield 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
finishMotionsends.
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:
- 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_cwith the rotation interpolated by slerp,O_dP_EE_c,elbow_c;tau_J_dfor torques) with gaindt / (dt + 1 / (2π f_c)).DEFAULT_CUTOFF_FREQUENCYis 100 Hz; passMAX_CUTOFF_FREQUENCYto skip the filter. - 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 againsttau_J_dstays withinMAX_TORQUE_RATE. - Validation. A non-finite value is
FrankaError::InvalidArgumentwith 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 libfranka | note | |
|---|---|---|
| FCI v10 (libfranka 0.21) | false | The FR3 does its own limiting; the client-side limiter can distort a motion. |
| FCI v5 (libfranka 0.9.2) | true | The 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/:
| constant | FR3, franka::* | FER, rate_limiting::fer::* |
|---|---|---|
TOL_NUMBER_PACKETS_LOST | 0.0 | 3.0 |
MAX_TORQUE_RATE (Nm/s, per joint) | 1000 | 1000 |
MAX_JOINT_JERK (rad/s³) | 5000, all joints | 7500, 3750, 5000, 6250, 7500, 10000, 10000 |
MAX_JOINT_ACCELERATION (rad/s²) | 10, all joints | 15, 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.26 | MAX_JOINT_VELOCITY, flat: 2.175 (J1–4), 2.61 (J5–7); MIN_JOINT_VELOCITY is its negation |
MAX_TRANSLATIONAL_VELOCITY (m/s) | 3.0 | 2.0 |
MAX_TRANSLATIONAL_ACCELERATION (m/s²) | 9.0 | 13.0 |
MAX_TRANSLATIONAL_JERK (m/s³) | 4500 | 6500 |
MAX_ROTATIONAL_VELOCITY (rad/s) | 2.5 | 2.5 |
MAX_ROTATIONAL_ACCELERATION (rad/s²) | 17 | 25 |
MAX_ROTATIONAL_JERK (rad/s³) | 8500 | 12500 |
MAX_ELBOW_VELOCITY (rad/s) | 1.5 | 2.175 |
MAX_ELBOW_ACCELERATION (rad/s²) | 10 | 10 |
MAX_ELBOW_JERK (rad/s³) | 5000 | 5000 |
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:
| Rust | libfranka 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.
- 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_posebounds). So the generator getsOtgLimits::per_axis_for_norm(3): each limit divided by √3, which is also what keeps a synchronised diagonal move inside the budget. - 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. - Re-anchor on the robot’s echo of the position every cycle with
set_position(O_T_EE_c)(orq_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:
- 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, sotarget()andstate()are valid from the first call. - 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 beforestop()is not lost. - 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), oneDELTA_Tper command whatever the measured period,set_positionon 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 asexp(step) R_echo: a constant-axis turn underrotation_limits, arriving together with the translation. - Backstop.
limit_rate_cartesian_pose/limit_rate_joint_positionsunder the same budget (the joint one tightened to the robot’s velocity envelope atq), 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). - Guard. If the measured position strays more than
max_deviationfrom the start (0.30 m Cartesian, 1.0 rad joint by default) or the measured orientation turns more thanmax_angular_deviation(0.5 rad), the target freezes where the command is, the generator brings it to rest, and the loop ends withFrankaError::Controlcarryingtarget_control::DEVIATION_MESSAGE. - Land, hold, finish. After
stop()the generator runs on until every axis has landed: withinSettle::toleranceof the target (1e-3: 1 mm or 1 mrad), slower thanREST_VELOCITY(1e-4 m/s or rad/s) and accelerating less thanREST_ACCELERATION(0.05). The rest thresholds limit the discontinuity when entering the hold while allowing for the small residual motion caused by the FR3’sfloat32command 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, forSettle::cyclescycles (250), and setsmotion_finishedon one more of it. A motion never finishes on a moving command: the robot refuses exactly that withcartesian_motion_generator_velocity_discontinuity. If the generator has not landed withinSTOP_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_EEandEE_T_Kcome 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.
| constant | value | why |
|---|---|---|
ImpedanceOptions::joint_position_margin | 0.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_weight | 0.1 m/rad, in (0, 1] | the trade above; 1 weighs 1 m as 1 rad |
POSITION_FADE_BAND | 0.02 rad | b ≥ 0.015 keeps E(s − b) under the arm’s velocity limit near the limit |
POSITION_BARRIER_STIFFNESS | 12.5 /rad × torque clamp | stable on the wrist behind the 1 ms delay and the low-pass |
| active-set passes | 12 | bounds the work per solve; position first solves twice per iteration |
| stall on / quiet cycles | 2e-5 weighted m per cycle / 20 of the last 40 | above 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 distance | 0.3 rad beyond the margin | where the posture is clamped and its pull starts to fade |
| spring ramp | 500 cycles from the loop’s start | an 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
Keys and discovery
| Key | Kind | Payload |
|---|---|---|
franka/<arm>/params/schema | Queryable | No request payload; schema reply. |
franka/<arm>/params/get | Queryable | No request payload; current accepted targets. |
franka/<arm>/params/set | Queryable | Partial update and acceptance/refusal reply. |
franka/<arm>/params/current | Publisher | Same 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:
| Property | Meaning |
|---|---|
type | Currently f64, f64[3] or f64[7]. |
min, max, default | Scalar or matching-length arrays; defaults are this arm’s configured session baseline. |
unit | Optional scalar string or array of unit strings. |
policy | slew, step or step_up_gate_down; a budget field reports the strongest policy among its elements. |
slew_tau_s | Present for slewed fields. |
group, scale | UI grouping and linear or log display. |
danger | Absent, "advise" or "confirm_above". "advise" marks a field that reaches the torque directly and asks for no confirmation. |
confirm_above | Present exactly with danger: "confirm_above": the threshold, in the field’s scalar/array shape. |
slider_max, off_at, norm | Optional 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:
| Property | Meaning |
|---|---|
owner, arm, boot_id | Owner, arm and node-run identity. |
version | Number of accepted sets in this node run. |
t_node_ns | Node-host monotonic timestamp in nanoseconds. |
origin | null after initialization or session reseeding; otherwise {version, by, at_ns} for the last accepted set. by echoes its numeric client_id. |
params | Complete accepted tuning targets, or the TOML-derived baseline without a tunable session. |
slewing | Field name to estimated fraction of the latest accepted change still remaining. |
dirty | Whether 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:
- Deserialize the envelope and validate
client_id. - Require a tunable session and check
base_version. - Validate field names, number types, lengths and finite values; merge into a scratch copy.
- Reject invalid domains before clamping: notably nonpositive
ik_dampingorcartesian_stiffness. A request for zero is refused, not converted to the minimum. - Clamp requested fields using the library bounds, including the stiffness-dependent damping floor. Increasing a spring may also raise damping that the request omitted.
- Check confirmation crossings against these proposed stored values.
- 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 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):
| quantity | tolerance | max abs error |
|---|---|---|
pose | 1e-9 | 7.216e-16 |
body_jacobian | 1e-9 | 9.992e-16 |
zero_jacobian | 1e-9 | 7.216e-16 |
mass | 1e-6 | 3.109e-15 |
coriolis | 1e-6 | 2.864e-14 |
gravity | 1e-6 | 2.132e-14 |
gravity, g = {0.1, -0.2, -9.7} | 1e-6 | 2.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:
| quantity | payload | max abs difference | relative |
|---|---|---|---|
| pose, all frames | any | 4.441e-16 | 3.5e-16 |
| body Jacobian | any | 8.882e-16 | 8.9e-16 |
| zero Jacobian | any | 4.441e-16 | 4.4e-16 |
| gravity | any | 4.974e-14 | 8.4e-16 |
| mass matrix | none | 3.553e-15 | 1.3e-15 |
| Coriolis | none | 3.553e-14 | 2.4e-15 |
| mass matrix | 1.23 kg | 2.980e-3 | 7.5e-4 |
| Coriolis | 1.23 kg | 5.008e-2 | 2.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.sopresent. With$FRANKA_FER_MODEL_SOset, two further tests drive the library live: one checks the fixture is still what the library answers, the other pins the non-affineM_NE.tests/fer_model_conformance.rs: drives the shared object itself throughModel::from_model_library_path/from_model_library_bytesat eight fixed configurations, checks the invariantsfranka::Modelguarantees, and reports the native backend’s differences against it. Needs themodel-libraryfeature 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 answerskErrorordlopenrefuses the object.load_model()has no such limitation. - It goes to a temporary file.
dlopenneeds a path, so the bytes are written to a private0600temp file removed when theModelis dropped. The directory must be executable (anoexec/tmpbreaks it). Removal covers a normal drop and an unwind but notSIGINT/SIGTERM/abort, so a loop stopped with Ctrl-C leaves one ~330 KB file in$TMPDIR. dlopenexecutes 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 safefnhere 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 ofload_model_from_robot()returns aFrankaError::Model; this crate does not pull inlibloading, andload_model()is unaffected. The offline entry pointsModel::from_model_library_bytesandModel::from_model_library_path, which take the bytes from the caller, areunsafe fnand carry the corresponding# Safetycontract. - The library needs libm in the loading process.
libfcimodels_x64.soimportssin,cosandsincosand carries noDT_NEEDEDentries of its own, so it resolves them from the global scope of whichever processdlopens 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 withundefined symbol: sincosat 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.
| variant | client | interval p50 | p99 | lost cycles | success avg | CPU % |
|---|---|---|---|---|---|---|
control (callback) | C++ | 1000 µs | 1071 µs | 0.3 | 1.000 | 2.7 |
control (callback) | Rust | 1000 µs | 1050 µs | 0.3 | 1.000 | 2.4 |
active (readOnce/writeOnce) | C++ | 1000 µs | 1046 µs | 0.0 | 1.000 | 3.0 |
active (readOnce/writeOnce) | Rust | 1000 µs | 1054 µs | 0.0 | 1.000 | 2.4 |
Paired over all 18 cells:
| statistic | C++ minus Rust | verdict |
|---|---|---|
| CPU | mean +0.59 pp, C++ higher in 18/18 cells | real |
| interval p99 | mean −15 µs, median −3 µs, C++ higher in 8/18 | noise |
active recv→send latency p50 / p99 | 17 / 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.
| build | model p50 | p99 | compute p50 | interval p50 | p99 | lost | CPU % |
|---|---|---|---|---|---|---|---|
| libfranka 0.20.4 | 30.8 µs | 67.0 | 33.3 µs | 1000 µs | 1084 | 0 | 5.7 |
libfranka main (0.21.3) | 31.26 | 64.44 | 33.96 | 1000 | 1049 | 0 | 5.59 |
libfranka main + Data-reuse patch | 11.07 | 27.15 | 13.88 | 1000 | 1043 | 0 | 3.49 |
| franka-rs | 10.8 | 32.9 | 12.5 | 1000 | 1047 | 0 | 2.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:
| call | libfranka 0.20.4 | libfranka main | + patch commit 1 | + commit 2 | franka-rs |
|---|---|---|---|---|---|
mass | 0.67 | 0.63 | 0.62 | 0.61 | 0.68 |
coriolis (state overload) | 1.93 | 1.96 | 1.91 | 1.86 | 1.13 |
gravity | 0.57 | 0.60 | 0.57 | 0.58 | 0.57 |
zeroJacobian | 6.97 | 7.17 | 0.81 | 0.50 | 0.32 |
pose | 6.34 | 6.55 | 0.36 | 0.37 | 0.27 |
| all five | 16.48 | 16.93 | 4.28 | 3.92 | 2.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 withRealtimeConfig::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’ssim-fer-v5job is gated on theFRANKA_SIM_FER_IMAGErepository variable and skipped until someone sets it, so the v5 simulator suite otherwise runs locally, and CI covers the v5 protocol offline throughwire_sizes_v5.rs,fer_native_conformance.rsand 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.
| gap | images | consequence | pinned in |
|---|---|---|---|
O_ddP_O is [0, 0, 0] | both | Model::gravity(&state) is identically zero; use gravity_q with an explicit vector. | sim_v5_commands.rs |
O_T_EE is the joint-7 frame | both | 0.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 tables | panda-v5 | The 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 commands | panda-v5 | The 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 stub | panda-v5 | kSuccess 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 kSuccess | panda-v5 | A 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 object | panda-v5 | The 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 binary | panda-v5 | The 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:
| item | what it is |
|---|---|
franka::Robot | The connection. Setters, read_once, the control_* loops, start_*_control, stop, automatic_error_recovery. |
franka::RobotState | Every field the robot publishes each millisecond. |
franka::Model | Poses, both Jacobians, mass, Coriolis, gravity. |
franka::Gripper | The Franka Hand on port 1338. |
franka::FrankaError | The error taxonomy, one variant per libfranka exception. |
franka::rate_limiting | The 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-bridgereads the headset over adb and publishes each controller on its own ZMQ channel;franka-vr-teleopfollows 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 arefranka-node-client’s, which it pins to its own version. The headset APK is fetched and hash-checked byfranka-vr-fetch-apkinto 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-clientas thefranka-tuning-panelcommand (pip install franka-node-client, thenfranka-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-nodeserves it over Zenoh.LiveTuningis 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 – andLiveTuning::BOUNDSis how far each of its twenty-five scalars may be moved.CartesianTargetControl::tuneapplies a partialTuningUpdateall or nothing, clamps it to that table and returns the bounds that bit;tuningreads 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 overSLEW_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, whoserate_wordnames which word that limit is), and the rest step.TuningPolicy::remaininganddescentsay what is left of a crossing from the clock alone, so nothing is published back out of the realtime thread; the cycle itself holds noexp, 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_limitsandMultiOtg::set_limitsare 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}andparams/currenton the node. The schema is serialised fromLiveTuning::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 arelationsrule.derivedadds 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. Asetis partial and all or nothing, checked for shape by the node and for domain by the library, refused withstaleagainst abase_versionthat has moved on and withneeds_confirmwhere 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 andgetanswers 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_schemadumps 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 nowKd (g dq_goal - dq), anddq_goalmay pass a first-order low-pass before it is fed forward.velocity_feedforwardoff is exactlyg = 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 speedvcosts 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-nodeconfig keys for the impedance law and the IK:ik_damping,ik_nullspace_gain,velocity_feedforward,velocity_feedforward_gain,velocity_feedforward_cutoff,cutoff_frequency,joint_stiffnessandjoint_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, commit7aeeddc) as built-in glTF binaries, decimated to about 15 % of their triangles (30 % for the hand, the finger whole) bytools/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;NOTICEcarries franka_description’s notice andLICENSE-franka_descriptionits LICENSE; the rootNOTICEcarries the notice too and points at that LICENSE. -
Built-in meshes in
franka-rerun, featurebuiltin-meshes(default): replays and recordings draw the arm with the robot’sfranka-descriptionmeshes out of the box, about 0.7 MB of static mesh per arm in an.rrd.franka-rerun csv|log --meshes DIRstill draws other files and--no-meshesnone;FlightOptions::meshesis aMeshChoice(Builtin,Dir,Off), andMeshes::builtin. franka-node’srecord_meshesis an optional override,"none"turns the meshes off. -
franka-node-client, the Python client of franka-node (crates/franka-node/python, importfranka_node): pure Python overeclipse-zenohand numpy, one wheel released with the crates under the workspace version.Node,Arm(lease onwith,home,stop,recover,on_state),cartesian_targets/joint_targetssessions with thefrankaverbs (move_to,move_by,follow,target,state,wait,stop) whose streamer thread paces goals under the node’smax_stepandmax_leadand keeps the session alive,Gripper(move,grasp,homing,stop,state), andNodeError,Refused,NodeTimeout,SessionEnded,ProtocolError.crates/franka-node/tests/wire.jsonis 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-nodeandfranka-camon crates.io, released withfranka-rsunder one workspace version. Av*tag also attaches prebuiltfranka-nodeandfranka-camtarballs (aarch64 gnu and static musl, x86_64 gnu; built withrecord) to the GitHub release, whichcargo binstall franka-nodeinstalls. -
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 thanvelocity_barrier_fraction(0.85) of its limit meetsVELOCITY_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.CartesianSentandJointSentcarrydq_goal,cap_scaleandtau_envelope(the velocity envelope’s share oftau). The goal is capped before a fast turn of the hand near a wrist singularity reachesjoint_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::TorqueLogandRecorder::push_torque_atrecordjoints/q_goal,joints/dq_goal,joints/cap_scaleandjoints/tau_envelope; franka-node takesjoint_velocity_fractionandvelocity_barrier_fractionper 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 newIkOptions::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. Astop()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 overPOSITION_FADE_BAND(0.02 rad) inside the margin, and beyond it a spring ofPOSITION_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 deprecatedrate_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 issqrt(k x + c²) − cwithk = 2a, undersqrt(2 a x):ais the FR3’s publishedddq_decon 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.CartesianSentandJointSentcarrypinned(0 free, ∓1 position, ∓2 velocity bound) andtau_position(the position envelope’s share oftau),CartesianSentalsostall_pressure,stalledandik_passes;franka_rerun::TorqueLogrecords them asjoints/pinned,joints/tau_position,ik/stallandik/passes, and franka-node takesjoint_position_marginper 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 onfranka/<arm>/target, read the arm’s state at 100 Hz onfranka/<arm>/stateand driveacquire/enable/stop/release/recover/homethrough 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, aclientexample, unit tests and a simulator test (tests/sim_node.rs).- Joint targets and
home:enabletakes"mode": "joints"for a session onstart_joint_target_control(targets areq, gated per joint bymax_step_jointand the arm’s joint limits; the state’stargetcarriesqwith the joints flag), andhomedrives the arm to the ready pose at aspeedof the limits, answering when it has arrived; an early end (stop, lost lease, timeout, shutdown) decelerates in place. Config keysmax_step_joint,joint_budget_fraction,joint_max_deviation; the library’starget_control::joint_position_limitsis 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 incrates/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(pullsfranka-rerun, Rust 1.96): withrecord_dirset, every session (enablein either mode,home) is one<arm>-<UTC stamp>.rrdwritten by afranka_rerun::Recorderin the loop’s observer, with the accepted and refused targets logged from the arm thread undercommander/target/*andevents; the status gainsrecording, the open file’s name.Recorder’s constructors takeimpl Into<Arc<Model>>so one model serves many recordings, andfranka_rerunre-exportsrerun. - The gripper is recorded with the arm: width, commanded width, grasped, moving and fault go
into the session’s
.rrdundergripper/*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
episodetopic,franka/<arm>/episode: one JSON line at a session’s start and end with the arm, the session’srecording_id, the.rrdfile name and the node’s clock, and the id again in the status asepisode. With therecordfeature the id is the RerunRecordingIdof 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_msnow measures from the last target that reached the guard’s content checks from the lease holder in order (Reason::from_the_commander), andhold_after_msstill measures from the last accepted one, soholdingkeeps 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 freshenable. 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_stepbounds 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) andmax_lead_rotation(0.26 rad) bound how far an accepted Cartesian target may be from the measured pose,0disabling either, withReason::LeadandReason::LeadRotationcounted and logged like every other refusal; a refusal still spends no token and advances nothing.TargetMsg.flagsbit 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 ofmax_stepandmax_leadof 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 ofleash, 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. nodeandcamare refused as arm names (RESERVED_ARM_NAMES):franka/node/*is the status topic andfranka/cam/*belongs to a camera node.[zenoh] mode = "client"dials the routers inconnectand 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_confignames a Zenoh file to start from, so the certificates of atls/endpoint, authentication and access control are configuration rather than code; the table’s own keys are applied on top of that file.- Grippers: a
Grippertrait (command,grasp,home,stop,state; metres) andFrankaHand, the Franka Hand overfranka::Gripperon a worker and a reader thread. Per arm,franka/<arm>/gripper/targettakes a 40-byteGripperMsg(width or grasp) from the lease holder in any phase,franka/<arm>/gripper/statecarries a 40-byteGripperStateMsgat 20 Hz, andcmd/gripper_home(the holder’s, answered when homed) andcmd/gripper_stop(anyone’s) join the verbs; the status gainsgripper. Config keysgripper = "hand"andgripper_speed. The node is nowfranka_node::run(config, factory)with aGripperFactory, so a binary of its own can add drivers; the client example gainsgripper <width>,gripper grasp <width> <force>andgripper home.
- Joint targets and
-
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.CameraMsgis 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.CamConfigreads[zenoh]and one[[camera]]table per camera. The capture path is there too: raw V4L2 ioctls with the struct layouts and ioctl numbers asserted againstvideodev2.h, an mmap stream that refuses a queue stamped with anything butCLOCK_MONOTONIC, one thread per camera at normal priority pinned to the config’scpu, 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>/frameat the camera’s rate and, withpreview_fps, every nth frame onfranka/cam/<name>/previewfor a consumer that must not pull the full rate;franka/cam/<name>/stateandfranka/node/<name>/statusonce a second.[zenoh]takes the arm node’s keys, includingmode = "client"andzenoh_config, so a camera behind NAT can feed a consumer that is not on its network over TLS. A camera’scpumay not name an isolated core, because those belong to the realtime loops.Frames go into the arm’s episode behind the
recordfeature: a camera with arecord_withfollows that arm’sfranka/<arm>/episode, and on a session’s start opens<record_dir>/<recording_id>-<arm>-cam.rrdunder the same RerunRecordingIdand 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’sstatetopic 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’srecord_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.
enableandhometake"episode": "<name>",[A-Za-z0-9_-]{1,128}: the session’srecording_idis 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’sCLOCK_MONOTONICbesiderobot_time, stamped inRecorder::pushwhere the record is taken (still no allocation there, whichtests/flight.rsproves). Two arms are two controllers, so theirrobot_times are unrelated;host_timeis the axis they agree on, and the layout of a named episode opens on it. An offline replay carriesrobot_timealone.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 NAMEdoes the same for a replay.flight::send_blueprinttakes aLayout, 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::layoutcarries 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 onhost_time(its own exposure stamp) as well as on the arm’srobot_time. A frame the arm’s clock pairs cannot map is now written onhost_timealone rather than dropped.ee/orientation: the measured and the commanded end effector rotation as quaternionsxyzwat 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_attakes 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 owncommander/target/*andgripper/*rows with the same instant.
-
franka::realtime::monotonic_ns, the host’sCLOCK_MONOTONICin nanoseconds. The node’smonotonic_ns, and with it everyt_node_nson the wire, now reads that clock instead of anInstantmeasured from the process’s start, so a second process on the same host puts its own samples on the same timeline.franka_rerun::APPLICATION_IDnames the Rerun application id every stream of that crate opens, andRecorder::to_file_with_idopens a file under a givenRecordingId, which is what lets two processes write one recording. -
CPU pinning of the target-control loop.
TargetControlOptions/JointTargetControlOptionsgaincpu: Option<usize>(with_cpu): the loop thread pins itself to that core withsched_setaffinityright after raising its priority, through the newfranka::realtime::pin_current_thread_to_cpu; a failure isFrankaError::RealtimeunderRealtimeConfig::Enforceand ignored underIgnore.franka-nodepasses the arm’scpukey through. -
Seven diagnostic channels of the torque backend.
CartesianSentgainsik_stepandik_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) andwall_age(cycles since each block’s wall was last pushed on, -1 for no wall).franka_rerun::TorqueLogcarries them besideee_velocity,ik_errorandleash, and the recorder writesik/{step, blend, held, error}andee/{velocity, leash}, plusee/orientation/commanded(flight::COMMANDED_ORIENTATION), the rotation of the pose the command carries, which is what the torque backend fills wherestate.O_T_EE_cis zero. The default layout names every one of them. The addition is additive: an older recording opens unchanged, and a record pushed without aTorqueLogwrites 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_hzsaid, 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 collapsedlag_msto 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 astate_hzthe 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 sameeffective_hz. Atstate_hz = 1000on two arms the panel no longer pegs a Pi core but still costs roughly half of one, sostate_hz = 100remains the configuration to run.franka-tuning-panelnow 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.--listenneeds--mode peer,--zenoh-configstill 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_dampingat 40 Nm s/rad (joints 5 to 7; 60 on 1 to 4): with the velocity barrier’s 20 added,K × 1 ms / Ion 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_gainabove 0 needs the operator’s confirmation (confirm_above0), 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_feedforwardisfalseon both interfaces, and with it the node’svelocity_feedforwardkey and the live weight it seeds (0). Real arms vibrated with it on:dq_goalis a finite difference (of the IK solution on the Cartesian interface) andKdcarries its noise into the torque. Off, a goal moving atvis tracked(Kd / Kp) vbehind, which the leash turns into a speed cap.velocity_feedforward_gainstays 1, so switching it on means all of it;velocity_feedforward_cutoffis the knob to try against the ripple. A node config that sets onlyvelocity_feedforward_gainnow runs without feedforward and needsvelocity_feedforward = trueto keep it. Python’svelocity_feedforwardkeyword now defaults toNone(the Rust default), andbackend='robot'refuses any explicit value,Trueincluded.nonrealtime_commander’s--no-feedforwardbecame--feedforward. -
The
model-libraryfeature is now off by default. Both robots use the native Rust model throughRobot::load_model(). Applications that explicitly download the Panda’s shared library must enablemodel-library; the optional comparison path remains available. -
The guard’s workspace box is off unless a config asks for one.
GuardOptions::workspaceis now anOption<Workspace>defaulting toNone, andArmConfig::workspaceis 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_leadandmax_lead_rotationare 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.Workspacemoved fromconfigtoguard, beside the check that enforces it. -
The minimum Rust version is 1.89, what
nalgebra0.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:
ImpedanceOptionsgainsjoint_velocity_fractionandvelocity_barrier_fraction;CartesianSentandJointSentgaindq_goal,cap_scaleandtau_envelope. -
A
joint_velocity_fractionabovevelocity_barrier_fraction(0.85) failsvalidateunless the barrier fraction is raised too. -
IkOptions::limit_marginis removed;ImpedanceOptions::joint_position_margin(0.05 rad) replaces the 0.02 rad clamp.ImpedanceOptionsgains that field,IkOptionsgainsrotation_weight,CartesianSentgainspinned,tau_position,ik_passes,stall_pressureandstalled,JointSentgainspinnedandtau_position, andfranka_rerun::TorqueLogthe recorded ones: struct literals break. -
The Cartesian IK weighs orientation by
rotation_weight0.1 m/rad instead of 1, also in free space; set it to 1 for the previous trade.ik_errorandIkOptions::toleranceare the weighted norm (1e-6 is 1 µm or 10 µrad). -
cap_scaleis 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_marginof a joint limit (JOINT_LIMIT_INSET, 0.02 rad, with the robot’s controller). -
CartesianSentgainsik_step,ik_step_clipped,ik_blend,heldandwall_age, andfranka_rerun::TorqueLoggainsee_velocity,ik_step,ik_blend,held,wall_age,ik_errorandleash: 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()leaveswall_ageat 0, which reads as a wall; a producer with no walls sets[-1; 2]itself.
Fixed
- franka-node:
enable,releaseandhomerefuseclient_id0 ("client 0 is not the holder"), asacquireandgripper_homealready did; a free arm’s holder is 0, so anenablefrom client 0 used to start a session nobody held. - franka-node: every stop of a joints session (the
stopverb, 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 ofhomealready 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; aContent-Lengththat 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-networknow serves the panel to the network it binds. TheHostguard used to allow the loopback names whatever the flag said, so every request from another machine was a 403; exposed, it also serves aHostthat is an IP literal (a rebound DNS name never is one, so the rebinding defence stands),--allowed-host NAMEadds 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/plainPOST, which needs no preflight, and have it served with aHostthe bind accepts and noOrigin. A request with noHostor several, an absolute-form target, or a body on aGETorDELETE(declared by length or by chunking), is now a 400 and closes too, and every response carriesX-Frame-Options: DENY,frame-ancestors 'none'andnosniff, 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-rswheel carriesLICENSEandNOTICE, libfranka’s notice included, under itsdist-info/licenses/; only the sdist had them.
0.3.0 - 2026-09-10
Added
- An impedance backend for target control, the default.
TargetControlOptions/JointTargetControlOptionsgainbackend: Backend(with_backend):Backend::Impedance(ImpedanceOptions)runs the loop throughcontrol_torquesand sends, every cycle, the torques of the hybrid joint impedance law of DROID’s controller (polymetisHybridJointImpedanceControl),tau = (Jᵀ Kx J + Kq)(q_goal − q) + (Jᵀ Kxd J + Kqd)(dq_goal − dq) + coriolis, clamped totorque_limitsand low-pass filtered atcutoff_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 theLeash(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 withproject_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 interfaceq_goalcomes from a differential inverse kinematics (damped least squares, nullspace drift towardposturecapped at 0.5 rad/s, a step capmax_stepof 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. Apostureor joint target outside the joint limits (inset 0.02 rad) is refused withInvalidArgument.project_joint_gains(default off) confines the joint gains to the Jacobian’s nullspace so the end effector feelsKxalone (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: thefer_joint_impedanceexample’s),ImpedanceOptions::cartesian()/::joint()withwith_*builders,franka::impedance_torques(the law, public at the crate root) andrate_limiting::JOINT_POSITION_LIMITS(FR3) /rate_limiting::fer::JOINT_POSITION_LIMITS(FER), from the URDFs in the repository.nonrealtime_commandergains--no-feedforward,--project-joint-gains,--leash Mand--thresholds N, and its CSVleash_alteration.CartesianSent/JointSentgainq_goal, the clampedtau,leash_alteration(andleash_angular_alterationfor a pose) and, for a pose, the IK residualik_error. The Pythoncartesian_targetsandjoint_targetstakebackend('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,leashandproject_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), Pythonbackend="robot"), andcontroller_modeapplies 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_controlandRobot::start_joint_target_controlrun the crate’s control loop on a named thread of their own and return aCartesianTargetControl/JointTargetControlhandle whoseset_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 theotgmodule (per-axis limits from the norm budget, one nominalDELTA_Tper 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-finishstop()that returns the loop’s result.TargetControlOptions/JointTargetControlOptionscarry the budget (joint default: 20 % of the negotiated version’s limits), the controller mode, the guard, the settle criterion, an optionalSCHED_FIFOpriority 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_limitsbuilds a generator with per-axis limits andrealtime::set_current_thread_scheduler_priorityraises 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, asO_T_EE; a rotation block within 1e-3 of orthonormal is repaired, one further off refused),set_target(position, quaternion)andset_orientation(quaternion)with unit quaternions in[x, y, z, w]order,target_orientation()andtarget_pose();set_positionkeeps 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, underTargetControlOptions::rotation_limits(0.5 rad/s, 1.0 rad/s², 20 rad/s³ by default) withwith_rotation_limits, and an angular deviation guardmax_angular_deviation(0.5 rad).CartesianSentgains 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), andREST_VELOCITY/REST_ACCELERATIONdrop to 1e-4 and 0.05 so the hold’s freeze stays under the joint-side jerk limits.OtgLimits::scaledis new. The commander example’s--rotateadds a slow yaw sweep of ±15° (bridged mode only). otgmodule: 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_positionto re-anchor on the robot’s echo,per_axis_for_normfor 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_commanderexample: a scripted (or stdin) commander sets Cartesian targets throughstart_cartesian_target_control(--bridged,--budget V,A,J), or hands them to a barecontrol_cartesian_poseto provoke a reflex (--raw);--logwrites 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’scommander_liveexample is on the same API, with theRecorderin the observer.- Python bindings (
crates/franka-py,import franka;pip install franka-rs, ormaturin developfrom the source tree):Robot,RobotState(numpy fields and a 69-floatflat()observation),robot.cartesian_targets()/robot.joint_targets()as context managers over the target control loops withmove_to,move_by,follow(chunk, dt),target(),state()andstop(),Gripper,Modelover numpy (robot.model()),franka.rotated, andFrankaError/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’spython-bindingsjob. Examples:crates/franka-py/examples/policy_loop.py,rotate.pyand thequickstart.ipynbnotebook. See Python..github/workflows/release.ymlbuilds the wheels and publishes them and the crate on av*tag. automatic_error_recoveryexample: command-line recovery that prints the robot mode before and after.move_to_readyexample: the examples’ motion generator to libfranka’s ready pose at a fraction of full speed.serdefeature (off by default):Serialize/DeserializeforRobotState,RobotMode,Errors,Duration,Record,RobotCommandLog,MoveStatusandControlException.Errorsserialises as the list of the set flags’ names.franka-rerun, a workspace crate (publish = false, Rust 1.96) that replays logs in Rerun:csvturns 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;logreplays a savedControlExceptioncontrol log as a flight recording (contact and collision flags, external wrench, commanded versus measured, errors, the arm), throughflight::{log_records, replay_exception, save_records, load_records}; andRecorderstreams the same live from inside a control loop with a non-blocking, non-allocatingpush.examples/reflex_replay.rsputs the last two together;examples/commander_live.rsstreams the commander into a viewer. Both replays draw Franka’s link meshes with--meshes DIR,csvhas the screen-capture--layout demoand the commander’s--budgetlines, andloglocates 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::newnegotiates the version automatically (connects announcing v10, retries as v5 if the robot reportskIncompatibleLibraryVersion);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 examplefer_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 anddlopening the robot’s shared object. Needs no network, nomodel-libraryfeature and no x86-64 Linux host; also reachable without a robot asModel::native_fer(). Its link inertial parameters were identified from a real FER’s own model library by the newtools/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 +dlopenon v5, unchangedGetRobotModelURDF on v10). The native model is checked in CI viatests/fer_native_conformance.rsagainst a committed fixture, with no robot or shared object present. - aarch64 builds, via
cargo-zigbuildor a crossgcc, or as a fully staticaarch64-unknown-linux-muslbinary with--no-default-features(a static binary can’tdlopen, and the FER’s model path is x86-64-only anyway). CI cross-builds foraarch64-unknown-linux-gnuon every push; verified underqemu-aarch64against both simulators, identical to native. - New example
cartesian_impedance_active_control— libfranka’scartesian_impedance_control.cpp(a spring-damper system whose equilibrium is the initial end-effector pose) driven through theActiveControlAPI’sread_once/write_onceinstead 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 sameActiveControlimpedance 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 withcartesian_impedance_active_controlmoved toexamples/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 nowCommandName::command(self, version: FciVersion) -> Option<u32>, since the TCP command numbering differs between the two protocol versions (Nonemeans the command does not exist in that version);CommandNamegained two v5-only variants,GetCartesianLimitandSetFilters.SoModelBackend::open,SoModelBackend::from_bytes,Model::from_model_library_bytesandModel::from_model_library_pathare nowunsafe fn: theydlopenthe file or bytes given to them, so the caller must assert it is a trusted model library.Robot::load_model()stays a safefn, as in libfranka.- A
Connectreply with statuskSuccessreporting a version other than the one the client announced is nowFrankaError::IncompatibleVersionrather 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.