Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

franka-rs

franka-rs is a Rust client for the Franka Control Interface, the network protocol a Franka Research 3 or a Franka Emika Robot (Panda) is controlled through. It speaks that protocol itself: a TCP channel for commands and the 1 kHz UDP loop for state and commands, with the robot’s kinematics and dynamics evaluated in the crate. There is no libfranka and no C++ underneath, so cargo add franka-rs or pip install franka-rs is the whole installation, and one program drives both robot generations because the protocol version is negotiated when it connects.

The 1 kHz loop is the library’s job. Your program can be a 1 kHz controller if you want, or a policy at 10 Hz, a script, a notebook cell or a person at a keyboard: it sends targets at its own rate and the crate turns them into a continuous command the robot accepts.

Stepped, bursty, stalling targets in; one continuous command out. Illustration of the generator’s profile for a scripted target sequence under a 0.25 m/s, 0.5 m/s², 20 m/s³ budget.

What you get

  • The realtime loop as a library. Write the loop yourself as a callback or with read_once / write_once, or let the crate run it on a realtime thread and set targets from any thread at any rate. Stepped, bursty or stalled targets become a smooth command under a velocity, acceleration and jerk budget, tracked by the crate’s own impedance torques (the arm stays compliant) or by the robot’s controller; stop() settles and finishes the motion.
  • A small, safe footprint. One crate. Parsing, rate limiting, trajectory generation and the control loops are safe Rust and allocate nothing once a motion has started; the unsafe in the crate is confined to the scheduler and socket system calls and to the opt-in loader for the model an FER serves. It cross-compiles to aarch64.
  • Checked against the reference. Wire layouts, rate limiting, low-pass filtering and error text are checked against libfranka’s sources. Loop timing, measured side by side on real FR3 and FER arms, is the same as libfranka’s; the model agrees to 1e-14.

Start here

You want to…Where to look
install it and move the arm for the first timeInstall, The realtime machine, First program
drive it from Python or a notebookFrom Python
try it without a robotWithout a robot: franka-sim
know what can stop you before it doesThings to keep in mind
do one specific thingthe How-to pages, starting with Command from a low-rate program
see the protocol, the constants and the measurementsthe Reference pages, starting with Compared with libfranka
read the rustdocAPI reference

Status

Version 0.3. Both protocol versions, every control interface, target control from Rust and Python, the gripper and the flight recorder have run on real arms; the dates and figures are in Benchmarks and hardware validation. The impedance backend of target control, new in 0.3.0 and the default, has run on franka-sim and on two real FERs, not yet on an FR3. Not there yet: a ros2_control hardware interface, the vacuum gripper, and a published simulator image for the FER. The changelog lists what changed in each release.

franka-rs is an unofficial project and is not affiliated with Franka Robotics GmbH; Franka, Franka Emika, Panda and FR3 are their trademarks. The crate is Apache-2.0, like libfranka. Its API shape was informed by Marco Boneberger’s libfranka-rs; no code from it is used.

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

or in Cargo.toml:

[dependencies]
franka-rs = "0.3"

The library is named franka, so you write use franka::Robot;. Rust 1.85 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" }.

featuredefaultwhat it does
model-libraryonCompiles Robot::load_model_from_robot, the dlopen path for the model library an FER serves (pulls in libloading). Turn it off for a static musl build or to keep dlopen out of your process; Robot::load_model() works either way, on both robots.
serdeoffSerialize / Deserialize on RobotState, RobotMode, Errors, Duration, Record, RobotCommandLog, MoveStatus and ControlException, so a control log can be saved as JSON and replayed; see Record and replay a run.

Python

pip install franka-rs

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

Rerun viewer (optional)

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

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

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

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

The realtime machine

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

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

A PREEMPT_RT kernel

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

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

Permission for SCHED_FIFO

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

ulimit -r 99

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

RealtimeConfig

Robot::new takes a RealtimeConfig:

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

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

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

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

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

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

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

A degraded Ethernet cable does not show up in ping: on a real FER (2026-09-09) it appeared as a communication_constraints_violation reflex with a clean ping, and a packet capture of the 1 kHz stream is what diagnosed it.

Details

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

First program

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

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

1. Connect and read the state

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

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

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

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

2. Stream states

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

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

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

3. The first motion

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

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

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

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

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

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

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

Where next

From Python

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

pip install franka-rs

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

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=True: the damping acts on the velocity error, not the velocity; False is DROID’s form, and with cartesian_damping=[37, 37, 37, 2, 2, 2] its law.
  • 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 never pulls harder than the felt stiffness times the leash (roughly 25 to 30 N at the defaults at the ready pose; target control sets no collision thresholds, so set at least 40 N / 40 Nm with the default gains, see Collision thresholds).
  • project_joint_gains=False: True confines the joint term to the nullspace, so the end effector feels cartesian_stiffness alone.

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

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

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

RobotState

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

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

Robot.model()

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

Errors

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

The notebook and the examples

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

examples/policy_loop.py <hostname> [--yes] is a jittery 6-10 Hz policy loop: a 4 cm circle through move_to, yaw and tilt through move_by, a 20-row follow chunk back to the start. It drove a real FER on 2026-09-09, with the robot’s controller tracking. 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.

Without a robot: franka-sim

At the end of this page every example, the Python notebook and your own program run against a simulated FR3 on your machine.

Prerequisites: Docker.

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

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.

Server flags

Arguments after the image name go to the server:

flageffect
--enforce-motion-limitsRuns the robot’s limit checks on every command, including (since franka-sim 1.1.5) the joint-side continuity check on Cartesian poses, scaled by --joint-discontinuity-scale (1.0 is the robot’s own limit). Without it the server accepts commands a robot would refuse.
--gripper-object-width <m>Places a graspable object of that width between the fingers.
--enforce-comm-constraintsEnables the communication-constraint checks.
--no-gripperNo server on port 1338.

One slot per host

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

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

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

What it is not

  • Not a realtime system: timing measured against it is a same-machine comparison, not an FCI qualification (Benchmarks and hardware validation).
  • 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) is a local build whose recipe is kept outside this repository.

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

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; a real FER refused a finish on a moving Cartesian pose with cartesian_motion_generator_velocity_discontinuity. All three control interfaces of this crate end a motion the same way, and target control’s stop() brings the command to rest before it finishes.

Who does the tracking

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

The robot checks every command

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

One client, two ports

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

Two protocol versions

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

Next: Three ways to control the arm.

Three ways to control the arm

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

The callback

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

ActiveControl

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

Target control

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

Which one

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

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

What they share

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

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

The realtime rules

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

1. Answer every state within the millisecond

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

2. Build with --release

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

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

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

4. Only the control thread is SCHED_FIFO

Robot::new raises the calling thread to the highest SCHED_FIFO priority; target control raises its own loop thread the same way, or to realtime_priority when set. Leave the rest of the process at normal priority. Running the whole process under chrt -f while it also had Rerun threads starved the control loop; do not do that to a process with other threads.

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. Not every gap is yours: on an FER the control box’s own transmit path stalls for a few milliseconds a few times per second. Packet captures at the network card show every state present and arriving in a burst after the stall; the client keeps the newest and discards the rest, and that is the gap it sees. Measured in one campaign, not a specification; see Benchmarks and hardware validation.

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: in the FER benchmark a USB Ethernet adapter with 15 ms of interrupt coalescing lost about three times as many cycles as the onboard card, for both clients. The benchmarks themselves 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), because on one FER the robot’s force estimate O_F_ext_hat_K crossed 10 N at about 0.25 m/s of commanded speed, and above roughly 1 m/s² of commanded acceleration it crossed 20 N: for fast target steps the collision thresholds, not the kinematic limits, were the binding constraint. Measured on one arm, not a specification. 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: at its default stiffness a 2.7 cm push reaches 20 N, so set at least 40 N and 40 Nm there or lower the stiffness (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: near the ready pose joint 2 moves about 3.2 rad per metre of x, so 2.5 m/s² was refused and 1.5 m/s² passed on an FER, and on an FR3 the refusal came in the cycle a joint crossed 10 rad/s². Details in FER / Panda specifics and Online trajectory generation.

What the client adds

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

Before you move a real arm

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

State and errors

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

RobotState

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

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

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

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

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

RobotMode

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

Errors

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

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

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

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

FrankaError

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

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

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

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

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 is_running(&self) -> bool;
    pub fn stop(self) -> FrankaResult<()>;
}
// JointTargetControl is the same with set_joints([f64; 7]) and target() -> [f64; 7].

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

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

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

Options

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

Every field is public and has a with_* builder; validate() checks the options without starting anything. A Cartesian budget is a norm and each axis gets 1/√3 of it. The default was measured on a real FER with the robot’s controller tracking: its joint-space continuity check refuses 2.5 m/s² near the ready pose and its collision threshold trips above about 1 m/s², so 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, the clamped torques tau, 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 (m plus rad). 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.

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 (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. Two things differ from DROID’s law by default: the damping acts on the velocity error, not the velocity (DROID’s form is velocity_feedforward = false, under which a goal moving at v is tracked Kd v / Kp behind), and the generator is leashed to the arm (below).

ImpedanceOptionsCartesian interface (::cartesian())joint interface (::joint())
gains.cartesian_stiffness Kx (N/m, Nm/rad)750, 750, 750, 15, 15, 150
gains.cartesian_damping Kxd (Ns/m, Nms/rad)50, 50, 90, 2, 2, 20
gains.joint_stiffness Kq (Nm/rad)40, 30, 50, 25, 35, 25, 10600, 600, 600, 600, 250, 150, 50
gains.joint_damping Kqd (Nms/rad)4, 6, 5, 5, 3, 2, 150, 50, 50, 50, 30, 25, 15
torque_limits (Nm)86, 86, 86, 86, 11.5, 11.5, 11.5same
cutoff_frequency (Hz)100100
velocity_feedforwardtruetrue
leash: Leash0.025 m, 0.15 rad0.1 rad per joint (the torque clamp, not the leash, bounds the torque: 600 × 0.1 = 60 Nm on joints 1 to 4, under their 86 Nm clamp; on joints 5 and 6 the 11.5 Nm clamp binds first)
project_joint_gainsfalsefalse
posture (IK nullspace reference)None: the start configurationnot used
ik: IkOptionsλ 0.05, nullspace gain 1.0 /s, 3 iterations, tolerance 1e-6, limit margin 0.02 rad, max step 0.01 radnot used

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, which have run on a real FER. What each knob does:

  • cartesian_stiffness: how hard the arm pulls back toward the target, per metre and per radian of end-effector error; the arm is a spring of this stiffness to anything that pushes it. cartesian_damping: the resistance to end-effector velocity error, per m/s and rad/s.
  • joint_stiffness, joint_damping: the same in joint space, on every joint including the one direction the Cartesian term cannot see (the elbow’s swing); on the Cartesian interface they regularise the nullspace and, unprojected, also stiffen the end effector (below); on the joint interface they are the whole law.
  • torque_limits: the per-joint clamp on the command, before the filter.
  • cutoff_frequency: the low-pass filter on the torques; MAX_CUTOFF_FREQUENCY turns it off.
  • velocity_feedforward: Kd (dq_goal − dq) when on, −Kd dq when off. On the simulator (franka-sim 1.1.6) the peak in-motion lag of a 5 cm step is 3.7 mm with it on and 12.3 mm off.
  • leash: how far the desired state may run ahead of the measured one. Every cycle the generator is anchored on the measured pose (the model’s, for the measured q) pulled toward the previous desired by at most the leash, the torque-mode form of the third generator rule: while the arm follows, that is exactly the previous desired and nothing changes; held back by a hand, an obstacle or an unreachable target, the desired stays within the leash of the arm, so the spring force on whoever holds it is bounded by the felt stiffness times the leash (roughly 25 to 30 N at the default gains at the ready pose, 18.75 N with project_joint_gains; see the thresholds below), and on release the generator resumes from where the arm is, under its budget. The leash keeps acting during the stop’s hold. On the joint interface each joint’s goal stays within leash.joint of the measured joint; there the torque clamp bounds the torque (the JOINT preset’s 600 Nm/rad × 0.1 rad is 60 Nm on joints 1 to 4, under their 86 Nm clamp; on joints 5 and 6 the 11.5 Nm clamp binds before the leash does).
  • project_joint_gains: confines the joint gains to the Jacobian’s nullspace (N Kq N, N = I − J⁺ J), so that the stiffness you set is the stiffness felt at the end effector, to within the damping of the projector and away from singularities. Unprojected, the joint springs are felt through J on top of Kx: at the ready pose the default 750 N/m is felt as about 990 to 1180 N/m in translation (computed on the FER model), and two to three times Kx in rotation. With the projection on, zero Cartesian gains would leave the end effector free.
  • posture: the joint configuration the inverse kinematics drifts toward in the nullspace, at most 0.5 rad/s; None is the configuration the loop started in. A posture outside the joint limits (inset 0.02 rad) is refused with InvalidArgument, as is a joint target outside them.
  • ik.max_step: the most any joint of the IK solution moves per cycle (0.01 rad, 10 rad/s); a larger step is scaled down whole, so an unreachable or singular pose is approached at a bounded rate rather than jumped at.

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:
let parity = ImpedanceOptions::cartesian()
    .with_gains(ImpedanceGains::DROID)
    .with_velocity_feedforward(false);

Collision thresholds. Target control sets none; set_collision_behavior is yours, and the robot’s reflexes watch the external forces whatever commands the torques. A spring meets them by deflection: at the default gains a push of 2.7 cm reaches the examples’ 20 N threshold (750 N/m; sooner at the felt stiffness), and an arm held at the leash pulls with roughly 25 to 30 N at the ready pose. Measured on a real FER (2026-09-10): a slow push read 725 N/m along one direction and about 1090 N/m from the other side, the leash held the error at exactly 2.5 cm, and a fast push at the leash (0.25 m/s) reached 45 to 50 N, because the damping adds to the spring: with 40 N thresholds that push ended in a cartesian_reflex, with 60 N it did not. So set the thresholds to at least 40 N and 40 Nm for a commander that only sends targets, and to 60 N or more where someone will push the arm; or lower the stiffness. On the joint interface the JOINT preset reaches the examples’ 20 Nm joint 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 (measured on the FER: a push that dragged the hand 12 cm and turned the wrist past 0.5 rad ended the session that way, cleanly, 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, so the spring never pulls harder than the felt stiffness times the leash (roughly 25 to 30 N at the defaults at the ready pose) however far the target has moved on; let go and it 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 (2.5 m/s² near the ready pose on an FER, 10 rad/s² on a joint of an FR3; see Online trajectory generation). Torques are not checked that way, so the budget can be raised further in the impedance backend, within the collision thresholds.
  • The deviation guard applies to both. The measured pose is compared with the start in either backend; a compliant arm pushed past max_deviation ends the loop the same way.
  • Collision thresholds apply to both, and are yours to set. The robot’s contact and collision reflexes watch the external torques and forces whatever commands them; a compliant arm meeting an obstacle still trips them at the thresholds set_collision_behavior sets, and a spring reaches them by deflection (the paragraph above).
  • An unreachable or singular target lags in the impedance backend, because the inverse kinematics never jumps (at most ik.max_step per cycle) and the leash holds the desired pose near the arm; the robot’s controller refuses a pose stream it cannot follow.
  • The finish waits for the arm. stop() in the impedance backend sets motion_finished only once every joint is slower than REST_JOINT_VELOCITY, or after the 5 s timeout.

On franka-sim 1.1.6 the arm did not move at the start of a session (measured change 0 to within floating point over the first 500 cycles), a 5 cm step lands 0.5 to 0.8 mm from the target and the peak lag during the motion is 3.7 mm with velocity feedforward and 12.3 mm without. On two real FERs (2026-09-10, PREEMPT_RT host, thresholds 40 N) the first torque of a session was that of rest (under 0.04 Nm), the commander’s stepped sequence and the ±15° yaw sweep ran with no reflex and an IK residual under 1e-6, joint targets landed within 0.6 mrad (a 0.2 rad step) to 4 mrad, stop() at rest took 0.44 s and mid-motion 0.9 s, and the tracking error at the holds was 4.6 mm (arm L) and 2.8 mm (arm R) at 750 N/m, 2.7 mm at 1500 N/m, 8 to 10 mm along a slow circle: a constant residual force of about 4 N on these arms that the robot’s own controller also shows (3.7 mm at the same holds) and that scales with 1/K. The push tests are under Collision thresholds above. The full record is in Benchmarks and hardware validation.

What the loop does every cycle

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

Why each step is what it is, and what happened on the arm 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 with the robot’s controller tracking has run on franka-sim, on a real FER (2026-09-09: the commander’s translation and rotation sequences, and the Python policy loop) and on a real FR3 (2026-09-09: the same sequences, with the robot’s joint-side acceleration check bracketed at 10 rad/s²); see Benchmarks and hardware validation. The impedance backend ran on both FERs on 2026-09-10 (the FR3 was not reachable that day); see Backends.

Write a 1 kHz callback

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

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

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

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

The methods

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

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

Ending a motion

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

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

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

Rate limiting and the low-pass filter

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

ControlException and the control log

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

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

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

Stopping from another thread

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

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

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

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

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

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

Drive the loop yourself

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

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

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

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

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

The starters

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

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

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

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

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

Cartesian impedance with ActiveControl

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

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

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

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

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

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

On the FER

ActiveControl works on FCI v5 as well; libfranka 0.9.2 has no equivalent, its API for that robot generation is the callback only. The public API is identical on both versions; underneath, start_torque_control() on an FER runs a joint-velocity generator commanding zeros alongside the external controller, because FCI v5 has no torque-only mode (see FER / Panda specifics). Measured on a real FER, ActiveControl is equivalent to the callback API within noise: interval p50 999.2 against 999.1 µs over 10 s runs, comparable p99, maximum and CPU; see Benchmarks and hardware validation. Both impedance examples ran on a real FER through this path on 2026-09-07 with no reflex.

Use the model

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

Prerequisites: a connected Robot (First program).

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

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

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

The six calls

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

Frame has the ten libfranka values: Joint1Joint7, 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 11–15 µ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 default model-library cargo feature (which links libloading), an x86-64 Linux host, and it executes code the robot served. 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.

Use the gripper

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

extern crate franka;
use franka::Gripper;

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

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

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

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

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

The commands

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

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

GripperState

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

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

Threading

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

Notes from the field

  • An FER’s hand may not be on the FCI at all. On the two FERs used for the hardware validation the Franka Hands were wired to direct CAN rather than the TCP gripper protocol, and port 1338 was closed. Check before assuming Gripper::new will connect.
  • examples/grasp_object.rs is the port of libfranka’s example of that name (grasp_object <robot-hostname> <object-width>, always homing first) and the quickest end-to-end check of a hand.
  • Against franka-sim a successful grasp needs an object between the fingers: the FR3 image takes --gripper-object-width; the FER image has no such option (see Simulator gaps).

Record and replay a run

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

Prerequisites: a rerun viewer of exactly the version the crate pins, 0.37.1 (cargo install rerun-cli --locked --version 0.37.1 or pip install rerun-sdk==0.37.1); RUST_LOG=warn rerun keeps its notification toasts to warnings. franka-rerun needs Rust 1.96 and is publish = false; build it from the repository.

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.3", 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:

entitycontent
joints/q, joints/q_dmeasured joint positions and the commanded ones (the sent q_c for a joint-position motion, else the robot’s q_d)
joints/dq, joints/tau_J, joints/tau_J_d, joints/tau_extvelocities, measured and desired torques, tau_ext_hat_filtered
ee/F_extO_F_ext_hat_K, force in N and torque in Nm
ee/positionmeasured O_T_EE against commanded O_T_EE_c, translation only; per-axis plots and the derivatives of the sent position when the command was a Cartesian pose
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; with --meshes DIR the link meshes
world/contact/*, contact/linkwhere the seven external joint torques say the arm was touched, and the force there
eventsevery change of current_errors and robot_mode, the first rising edge of every flag (joint 4 contact, cartesian collision on Fz), and motion aborted: <names> at the end

Record live

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

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

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

push runs on the realtime thread, so it must be cheap: it 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. A background thread drains the channel every 100 ms and does all the Rerun work, the 3D scene decimated to every 10th record. crates/franka-rerun/tests/flight.rs checks the no-allocation claim with a counting allocator around a 1 kHz producer. For a loop the crate runs for you, the observer of target control is the hook.

The examples

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

Status

Everything above is exercised against synthetic logs in crates/franka-rerun/tests/flight.rs. On a real FER (2026-09-08) reflex_replay recorded 24 s at 1 kHz without a push: 23 941 records pushed, 0 dropped, peak |F_ext| 4.5 N, no flags raised. The pushed run on the same arm the same day raised cartesian_reflex 3.9 s in (3928 records pushed live, 0 dropped); in the replay of its last 3000 records the Cartesian contact flag on Fy rises at 5 N, the joint 3 contact flag 13 ms later, and the Cartesian collision flag at 10.6 N in the cycle before the robot stopped.

Open item. 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, which was observed on 2026-09-09; Rerun’s Python SDK gives up after a few seconds instead. Dropping the recorder without finish() closes the channel and does not wait. Until this is fixed, record to a file when the viewer is not certainly up.

Run the examples

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

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

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

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

Motion generators

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

Torque and impedance

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

Target control

examplewhat it does
nonrealtime_commanderA scripted (or stdin) commander sets stepped, bursty, stalling Cartesian targets through start_cartesian_target_control (--bridged, the default; --budget V,A,J; --rotate adds a yaw sweep), or hands them to a bare control_cartesian_pose so the robot refuses the first step (--raw). Takes [--bridged | --raw] [--stdin] [--log PATH] [--yes] [--budget V,A,J] [--rotate].

Gripper and recovery

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

Rerun examples (crates/franka-rerun/examples)

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

examplewhat it does
reflex_replayA slow joint swing with lowered collision thresholds, recorded live with Recorder; the control log of the reflex a push provokes is written as a Rerun recording. See Record and replay a run.
commander_livenonrealtime_commander streamed live into a viewer: the raw target, the sent and measured position per axis, the arm (with --meshes DIR), 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.

Test against the simulator

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

The harness: crates/franka-sim-test

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

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

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

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

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

One FCI slot per host

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

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

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

The offline checks

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

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

The --no-default-features invocations keep the crate building without model-library, as it cross-compiles to static musl; the -p franka-rs one is needed because --workspace unifies the other crates’ default features back on. 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; its 10 rad/s² acceleration table was confirmed on a real FR3 on 2026-09-09 (Benchmarks and hardware validation).

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 panda-v5 image has no joint-side check (FER / Panda specifics). A full cargo test --release --workspace needs both images, both variables set, and the lock.

The harness self-test

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

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

The Python tests

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

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

How CI gets its server

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

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

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

Build for another machine

At the end of this page you have an aarch64 build of the crate and its examples for a Raspberry Pi 4 or 5 or another 64-bit ARM Linux machine, made and smoke-tested on an x86-64 box. 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. They have not yet run on a physical Raspberry Pi and not against a robot.

The two targets

targetlibclinkingmodel-library
aarch64-unknown-linux-gnuglibcdynamiccompiles
aarch64-unknown-linux-muslmuslfully staticoff (--no-default-features)

The default model-library feature compiles Robot::load_model_from_robot, which dlopens the model library an FER serves. That library is an x86-64 build (libfcimodels_x64.so) and of no use on aarch64 either way; load_model() evaluates both robots’ models natively and is the path to use. Turning the feature off drops libloading, which a static musl binary cannot use, and changes nothing else.

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 \
  --no-default-features --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 tests calling load_model_from_robot: compiled out under --no-default-features, and failing with the feature on because the x86-64 library cannot be loaded.

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, or FRANKA_REALTIME=ignore at the cost described there. Raspberry Pi OS 64-bit has a PREEMPT_RT kernel package (sudo apt install linux-image-rpi-v8-rt, selected with kernel=kernel8_rt.img in /boot/firmware/config.txt) and Ubuntu 24.04 for Raspberry Pi has one through Ubuntu Pro (sudo pro enable realtime-kernel --variant=raspi); neither has been exercised with this crate yet.

Building natively on the target instead (rustup, then cargo build --release -p franka-rs --examples) needs none of the above; it has not been tried on a Pi yet.

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 and hardware validation.

Where the two are the same

  • Protocol and semantics. Struct sizes and field offsets are asserted against libfranka’s headers for both FCI versions; the rate limiter and the low-pass filter are ports of rate_limiting.cpp, joint_velocity_limits.cpp and lowpass_filter.cpp; the error strings are byte-identical, "libfranka: " prefix included; the control log holds the same 50 cycles by default. A motion refused by the robot fails the same way from both clients.
  • Loop timing. Measured back to back on the same PC against franka-sim, a real FR3 and two real FERs: 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 on the simulator, 2.6 on the FR3 in the model-in-the-loop variant); part of the FR3 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 a real FER’s libfcimodels.so to 5e-14 on gravity and 4e-16 on kinematics. Details on Model parameters and conformance.

What franka-rs has and libfranka does not

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

What libfranka has and franka-rs does not

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

Deliberate differences

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

Other clients

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

FCI v10 and FCI v5 on the wire

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

Supported robots

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

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

Version negotiation

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

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

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

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

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

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

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

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

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

What differs on the wire

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

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

Commands a version does not have

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

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

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

Two IP fragments per state

The v5 state datagram, 2373 bytes, exceeds the 1500-byte Ethernet MTU, so every FER state arrives as exactly two IP fragments: 2000 packets per second for the host to service instead of 1000. The FER hardware campaign saw no reassembly failures or drops (ReasmFails, ReasmTimeout, Udp.InErrors and NIC drops all zero); the doubled packet rate is the most plausible reason the FER lost occasional cycles where the FR3 lost none, as a load effect. See Benchmarks.

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

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

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

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

The q_d seed rule

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

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

This is why the README’s joint-move example reads robot.read_once()?.q_d rather than .q. The same defect in the examples’ shared MotionGenerator was found and fixed during the 2026-09-05 hardware campaign.

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.

Measured on a real FER near the ready pose (2026-09-08): a translational ramp at 2.5 m/s² with 500 m/s³ of jerk was refused within six cycles as cartesian_motion_generator_joint_velocity_discontinuity; a ramp at libfranka’s own Cartesian limits tripped both that and cartesian_motion_generator_joint_acceleration_discontinuity; 1.5 m/s² with 200 m/s³ passed. The criterion is the per-joint acceleration limit (rate_limiting::fer::MAX_JOINT_ACCELERATION, 7.5 rad/s² on joint 2) applied to the joint motion the poses imply. At the ready pose a metre of end-effector travel in x costs about 3.2 rad on joint 2, so 2.5 m/s² is 8 rad/s² there, over its limit, while 1.5 m/s² is 4.8 rad/s². The same budget at a more extended pose, where that lever is larger, can still trip. libfranka behaves identically, and its Cartesian examples pass because their trajectories start with near-zero acceleration.

The check is not v5-specific. Both error names are in libfranka’s error list for the FR3 too, and on an FR3 (2026-09-09) the bracket was repeated: a run whose IK peaked at 9.3 rad/s² passed, two runs were refused with cartesian_motion_generator_joint_velocity_discontinuity in the cycle a joint crossed 10 rad/s² (franka::MAX_JOINT_ACCELERATION), with joint jerk under 1400 rad/s³, so the published limit is applied as is (see Benchmarks and hardware validation).

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, the payload caveat and the check against a real FER’s measured O_T_EE 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 measurement against the callback API is in Benchmarks and the how-to is Drive the loop yourself.

Rate limiting and filtering

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

On the callback path

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

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

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

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

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

cutoff_frequency defaults to DEFAULT_CUTOFF_FREQUENCY in both.

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

The constants

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

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

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

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.

The torque-rate margin on FCI v10

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

Limiting by hand

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

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

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

Online trajectory generation

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

The problem

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

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

What the generator does

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

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

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

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

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

The property test

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

The three rules, learnt on the arm

The first version of the bridge in examples/nonrealtime_commander.rs ran this generator on a real FER (2026-09-08) with limit_rate_cartesian_pose behind it, and the run did not go cleanly: the generator stayed on its targets, but the limiter clamped it on the first two-axis move and from then on the command orbited at the velocity cap until the robot refused it. The per-cycle log of that run is a fixture in the module’s tests. Replayed through the generator alone, it ends exactly on the last target with every per-axis limit respected. Replayed through the generator and the limiter, the limiter clamps the command by nanometres at 1.601 s (y braking at −0.5 m/s² while z starts at +0.5 m/s² is a norm of 0.71 m/s² and 28 m/s³ of jerk), and 300 ms later the command is millimetres behind the generator’s own state, which never hears of it. libfranka’s limiter has no braking logic: tracking a pose it has fallen behind, it saturates at the budget, passes the pose, and reverses, and the replay reproduces the ±10 cm orbit in the log. Three rules follow.

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

With the first two rules the replayed backstop never touches a command (worst alteration below 1e-9 m) and every target is met exactly. With the third rule alone it binds by up to 50 µm and the run stays bounded, millimetres from the targets and no orbit, but does not land exactly, because a vector-norm clamp distorts one axis’s corrections while another saturates. Both replays are regression tests in the module.

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 trips. On a real FER near the ready pose a ramp at 2.5 m/s² was refused as cartesian_motion_generator_joint_velocity_discontinuity, 1.5 m/s² passed, and the cause is the ordinary per-joint acceleration limit: joint 2 moves about 3.2 rad per metre of x travel there, so 2.5 m/s² is 8 rad/s² against its 7.5 rad/s² limit. On an FR3 the same refusal came in the cycle a joint crossed its 10 rad/s². The full account, with dates, is on FER / Panda specifics.

A second limit appeared on the same FER above roughly 1 m/s² of commanded acceleration: the robot’s external-force estimate O_F_ext_hat_K crossed 20 N at about 0.25 m/s and raised cartesian_reflex, so for fast target steps the collision thresholds, not the kinematic limits, were the binding constraint (the examples’ 10 N nominal thresholds were crossed at 0.25 m/s, which is why the commander example sets libfranka’s example thresholds explicitly). Both figures are an observation on one arm, not a specification.

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

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

What the loop does every cycle

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

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

Nothing allocates on the realtime thread after the start; the observer, called every cycle with the state and what was sent, must keep it that way. Dropping a handle without stop() requests the stop and detaches: the loop settles and finishes on its own, holding its Arc<Robot> until it has. This loop has run on franka-sim, on a real FER and on a real FR3; the hardware runs are listed in Benchmarks and hardware validation.

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 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.rs and ik.rs.

The law and its provenance

Kp  = Jᵀ Kx J + diag(Kq)          Kd = Jᵀ Kxd J + diag(Kqd)
tau = Kp (q_goal − q) + Kd (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 (dq_goal = 0, the damping on the absolute velocity) the law is polymetis’s. The default differs in two places. ImpedanceGains::CARTESIAN 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. And the damping acts on the velocity error by default, 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. Damping the absolute velocity resists the motion the goal asks for, and a goal moving at v is tracked Kd v / Kp behind it; on franka-sim 1.1.6 a 5 cm step peaks 12.3 mm behind without the feedforward and 3.7 mm with it. 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,

e   = [ p_des − p(q_goal) ;  log(R_des R(q_goal)ᵀ) ]
dq  = J_gᵀ (J_g J_gᵀ + λ² I)⁻¹ e  +  (I − J_g⁺ J_g) k_null (posture − q_goal) dt

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, and the second 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 nullspace so it never moves the end effector. If any joint of the step exceeds max_step (0.01 rad, so 10 rad/s) the whole step is scaled so that the largest component equals it; then the result is clamped to the joint position limits (rate_limiting::JOINT_POSITION_LIMITS for the FR3, rate_limiting::fer::JOINT_POSITION_LIMITS for the FER, from the URDFs in the repository) inset by limit_margin (0.02 rad), and iteration stops below tolerance (1e-6). A posture outside those inset limits, like a joint target outside them, is refused with InvalidArgument before anything starts. 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 (tolerance ends the iteration early on a landed target); the observer sees the residual as CartesianSent::ik_error. A pose out of reach or through a singularity leaves a residual and q_goal moves toward it at most max_step a cycle instead of jumping, so the impedance never gets a step to track.

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 bounded by the felt stiffness times the leash: roughly 25 to 30 N at the default gains at the ready pose (990 to 1180 N/m × 0.025 m on the FER model), 18.75 N with project_joint_gains (750 × 0.025). 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, not O_T_EE (the two differ by the model’s accuracy on a robot, and by 0.107 m on franka-sim, whose O_T_EE is the joint-7 frame), so the IK’s residual is zero and q_goal = q: the first command is −Kd dq + coriolis(q, dq), near zero on an arm at rest, and the robot’s controller hands over without a step. On franka-sim 1.1.6 the arm did not move over the first 500 cycles of a session (measured change 0 to within floating point). At stop() the generator lands on the last target; the loop then holds the landed setpoint for Settle::cycles (250) and 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 law kept on the held goal until then. The generator’s rest is not the arm’s: an arm still closing its lag, handed to the robot’s controller at the moment of the finish, is held wherever it was (0.048 rad short on joint 7 on the simulator, before the gate). With the gate the arm is at rest on the setpoint and the torques are near zero, so the robot’s controller takes over from rest, as it does after the cartesian_impedance_active_control example’s final zero-torque command. On franka-sim 1.1.6 a 5 cm step lands 0.5 to 0.8 mm from the target. The law itself, impedance_torques, is public at the crate root for a loop of your own.

Measured on two FERs (2026-09-10)

Both Pandas of the earlier campaigns, system 4.2.1, PREEMPT_RT host, FRANKA_REALTIME=enforce, default gains, collision thresholds 40 N unless stated. No run ended in a reflex except the one that was meant to find the threshold.

runresult
5 s at rest, then stop()first-cycle torque under 0.04 Nm, peak 0.22 Nm, tracking 0.13 mm (L) / 0.23 mm (R), stop() 0.44 s
the commander’s 19 s stepped sequenceno reflex, IK residual under 1e-6, leash never bound, peak torque 4.4 Nm; tracking error at the holds 4.6 mm (L) / 2.8 mm (R), moving p95 9.6 / 8.7 mm; the robot’s own controller on the same sequence: 3.7 mm at the holds, 4.4 mm moving
the same at Kx 1500 N/m (damping 75)2.7 mm at the holds, 6.3 mm moving
the sequence with the ±15° yaw sweepno reflex, same tracking figures
joint targets (Python, JOINT preset, 20 % budget)a 0.2 rad step on joint 1 landed within 0.6 mrad, a three-joint step within 4 mrad (joint 6), stop() mid-motion 0.9 s, arm Idle
a 4 cm circle at 5, 10 and 30 Hz (Python)rate-independent, 8 to 10 mm p50 along the slow circle, back at the start within 7 to 9 mm
push tests, 40 N thresholdstwo light pushes: 16.6 mm for 12 N, felt stiffness 725 N/m along the push, back within 2 mm in 0.3 s; a fast push reached 50 N in 250 ms at 25 mm and tripped cartesian_reflex
push tests from the other side, 60 N thresholds24.8 N at 22.8 mm quasi-static (about 1090 N/m felt), the leash held the error at exactly 25.0 mm under 45 to 47 N at 0.26 m/s, no reflex; a push that dragged the hand 12 cm and turned the wrist past 0.5 rad ended the loop through the deviation guard, the arm held in place

Two things the numbers settle. The tracking error at rest scales with 1/K (4.6 mm at 750, 2.7 mm at 1500) and the robot’s own external-force estimate reads 3 to 4 N at those holds: a constant residual force of the arm (load or friction) that the robot’s own impedance controller deflects under as well; a spring has no integrator, so users who need millimetre placement raise the 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), which is why 45 to 50 N appeared at 0.25 m/s. A cap on the reaction force, spring and damper together, is the follow-up. The FR3 was not reachable that day.

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.

Model parameters and conformance

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

The native backend

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

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

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

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

Where the parameters come from

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

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

FER (FCI v5): shipped identified parameters

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

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

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

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

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

The payload caveat

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

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

The tests

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

The kinematics have also been checked against the robot rather than its library: converting the 1 kHz logs of the 2026-09-08 hardware runs with franka-rerun csv --robot fer, the native model’s end effector, from the logged q and the tool offset identified from the first row, matched the measured O_T_EE to under 0.01 mm on every row.

load_model_from_robot: the opt-in download path

The v5 download path is still there and still supported:

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

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

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

Cost

Evaluating the five model calls a model-based controller makes costs about 3 µs offline (2.97 µs p50 on 10 000 random FR3 states) and 11–15 µ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 and hardware validation

Two measurement campaigns, both reproduced from the harness in bench/:

  • FR3 benchmark, 2026-09-04franka-sim over loopback plus a real FR3 at 172.16.0.2, against libfranka 0.20.4.
  • Franka Emika Robot (FER) hardware run, 2026-09-05 — two real FERs on system 4.2.1, against libfranka 0.9.2.

The full measurement records for both campaigns are kept privately and are not part of this repository; this page is the public summary of what they found. The hardware runs that followed — the impedance examples, target control, rotation targets and the Python bindings on an FER, target control on an FR3 — are functional checks, not timing campaigns; they are listed in Hardware validation record at the end.

Both were run on the same box: 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-box A/B, not an FCI qualification of either client.

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

Simulator: loop jitter

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

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

Paired over all 18 cells:

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

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

The model in the loop

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

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

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

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

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

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

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

FR3 hardware

A real FR3 at 172.16.0.2, model variant, chrt -f 80 + mlockall. Three interleaved repetitions of both clients (stage c) plus one C++ shakedown run (stage b), a read-only Idle-with-no-errors probe before every run, a return-to-ready move before and a 20 s cool pause after, and torque/deviation guards checked outside the timed region.

No guard tripped, no run ended in a ControlException, and 0 reflex events across all 8 runs. The arm reported Idle with no error flags immediately before and after every one.

statistic (stage c, paired, 3 reps)C++ minus Rust
model p50+10.67 µs mean (+9.20 median), C++ higher in 3/3
CPU+2.61 pp mean (+2.01 median), C++ higher in 3/3
interval p991079–1106 µs for every run, both clients — no difference

Representative per-run figures: Rust model p50 12.1–13.8 µs at 2.7–3.6 % CPU against C++’s 21.3–27.6 µs at 4.7–7.4 %; interval p99 1079–1106 µs; success rate average 0.9996–1.0000. The C++ side is unpatched libfranka 0.20.4 — the Data-reuse patch was never built for hardware, so every C++ row still pays the allocation, and would be expected to close the same way it did on the simulator.

FER hardware

Two FERs — L on an onboard NIC, R on a USB Ethernet adapter — on robot system 4.2.1, against libfranka 0.9.2. Thirteen 30 s runs of 1 kHz model-in-the-loop torque control, chrt -f 80 + mlockall.

No reflex, no guard trip, no ControlException, no communication_constraints_violation, and max_consecutive lost cycles was 1 in every run. Both arms were Idle and error-free afterwards.

armclientinterval p50p99lost / 30 000success avg
L (onboard NIC)franka-rs999.4 µs1171.7 µs27.50.9862
L (onboard NIC)libfranka 0.9.2999.5 µs1186.8 µs29.50.9872
R (USB Ethernet)franka-rs999.1 µs1181.1 µs91.70.9873
R (USB Ethernet)libfranka 0.9.2999.1 µs1183.9 µs87.00.9882

Every gap there is smaller than the run-to-run spread of either client. Verdict: for running an FER, neither client is meaningfully better at the job.

Model agreement on the robot, both clients evaluating the same libfcimodels.so at bit-identical inputs: gravity 2.31e−14, coriolis 7.68e−22, mass 1.09e−14, pose 5.27e−15, zeroJacobian 5.27e−15 max absolute difference. Double round-off — the v5 model path is numerically identical to libfranka 0.9.2 on real hardware.

A measurement trap worth knowing about

The campaign’s model_us p50 column read 15.45 µs (Rust) against 5.68 µs (C++) on arm L, and that is not a valid comparison. Offline, on the same shared object and the same inputs, the two wrappers are within 1.2 % of each other (4.011 vs 3.964 µs for all five calls). What the in-loop measurement priced is the CPU’s post-idle frequency ramp: the same C++ code, timed after increasing amounts of untimed filler work, swings from 21.37 µs to 3.98 µs — a 5.4× spread on identical inputs. The tell is that model_us min is 4.0 µs in all thirteen runs for both clients.

The client that does more work between the datagram arriving and the callback starting reaches the model calls on a warmer core. franka-rs’s receive path retires ~1200–1500 instructions where libfranka 0.9.2 retires ~3500–4000, copies 13.4 KB where libfranka copies ~19 KB, and allocates nothing where libfranka does two malloc/free pairs. The crate’s model region measured slower because the crate’s receive path is leaner. No crate change was made; there is no defect. The deadline, lost-cycle, success-rate and guard results are unaffected — those are not sub-10-microsecond measurements.

Where the FER’s lost cycles come from

Wire captures settle this, and the answer is not the client. From tcpdump on the link, including one run stamped with the NIC’s hardware receive timestamps (before the kernel’s interrupt path):

  • Nothing is lost on the network. Every message_id the robot emitted appears in every capture — 0 missing ids in 141 000 states across six runs — and IP reassembly, socket and NIC counters are all zero.
  • The gaps are already on the wire. ~70 inter-arrival gaps ≥ 1.5 ms per 10 s run (about 7 per second), max 4.77–6.91 ms, present in the PHY timestamps. Median inter-arrival is 999.3–999.4 µs, so this is a rare event, not a shifted distribution.
  • The robot’s control loop keeps running. Across each gap the millisecond counter advances by exactly one, and 63–87 % of gaps are followed by a burst draining the backlog: the control box’s transmit path stalls for 2–7 ms a few times per second.
  • The host is excluded. Two SCHED_FIFO stall probes on the NIC IRQ core and another core saw 1 late wakeup in 93 s (and 0 in 13 s during which the wire showed 97 gaps). Disabling the NIC’s Energy-Efficient Ethernet changed nothing.
  • Both clients are excluded. Both drain the socket keeping the newest message_id, so when two states arrive together the older is discarded by design. A “lost cycle” here is a discarded queued state, not packet loss — which is why max_consecutive is never above 1 and the robot’s own packet-loss watchdog is never approached.

The remaining lever is the control box, not this repository.

Arm R loses ~3× more than L on both clients, which tracks its interface, not its client: the USB Ethernet adapter has an RX ring of 100 (vs 256) and rx-usecs of 15000 (vs 3 on the onboard NIC). 15 ms of interrupt coalescing is pathological for a 1 kHz loop.

The v5 state datagram is 2373 bytes, over the 1500-byte MTU, so every state arrives as exactly 2.0000 IP fragments — 2000 packets/s instead of 1000 — with zero ReasmFails, ReasmTimeout, Udp.InErrors or NIC drops. That doubles the packet rate the host must service, which is the most plausible reason the FER loses cycles where the FR3 lost none, but it is a load effect, not a reassembly failure.

Two arms at once

Both FERs driven simultaneously, per arm, 10 000 cycles each:

configurationL lostR lostsuccess avg
two independent processes (taskset -c 3 / -c 5)39290.99 / 0.99
one process, one Robot per thread, 3 runs24–3243–550.98–0.99

No cross-interference. Running both at once does not raise either arm’s loss rate, success rate or interval spread beyond what it shows alone, and both figures match the single-arm campaign. The single-process example (dual_communication_test) gives each thread its own Robot, its own TCP and UDP sockets, and a Barrier that releases both timed loops together; from that point the two robots share no state at all, and if one errors the other runs its cycles to completion.

ActiveControl on the FER

First real-hardware exercise of the read_once/write_once API on an FER. Robot L, 10 s per run:

variantcycleslostinterval p50p99success avgCPU %
active (read_once/write_once)995736999.2 µs1236.60.9812.55
control (callback)997920999.1 µs1271.60.9852.65

No errors, no reflex, Idle after both. Equivalent within noise — the lost-cycle gap is well inside the run-to-run spread seen throughout the campaign. libfranka 0.9.2 has no ActiveControl at all, so on an FER this is a control style the C++ client cannot offer.

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 lives under bench/results/.

Hardware validation record

The functional runs on real arms after the two campaigns, in order. Each completed or failed exactly as described; none is a timing measurement.

  • Dual-arm and ActiveControl. Both FERs driven at once, as two processes and as one process with a Robot per thread, with no cross-interference; and read_once/write_once exercised on a real FER, matching the callback API within noise – a control style libfranka 0.9.2 does not offer for that robot generation.
  • Cartesian impedance examples on hardware. cartesian_impedance_active_control and cartesian_impedance_figure_eight ran on a real FER through ActiveControl (2026-09-07), holding and tracking the pose while being pushed, with no reflex.
  • Cartesian pose bridging and the flight recorder on hardware (2026-09-08). nonrealtime_commander ran its full 19 s bridged sequence on a real FER with no reflex (peak commanded speed 0.25 m/s, measured pose within a few millimetres of the command), and its raw mode was refused at the first step and cleared with automatic_error_recovery(); the runs also showed that the robot checks the joint-space continuity of a Cartesian pose stream, which the client-side rate limiter does not bound (see FER / Panda specifics). reflex_replay’s live Recorder pushed 23 941 records at 1 kHz with none dropped, and on every logged cycle the native FER model’s end effector matched the measured O_T_EE to under 0.01 mm.
  • Rotation targets on target control (2026-09-09). nonrealtime_commander --rotate ran its ±15° yaw sweep on a real FER through start_cartesian_target_control, a clean run to the end.
  • Python bindings on hardware (2026-09-09). crates/franka-py/examples/policy_loop.py drove a real FER from an irregular 6-10 Hz policy loop – move_to, move_by and follow chunks – and stop() settled within about 0.3 s of the call. A degraded Ethernet cable shows up as communication_constraints_violation with a clean ping; a packet capture of the 1 kHz stream is what diagnoses it.
  • Target control on an FR3 (FCI v10, 2026-09-09). The bridged and rotation sequences of nonrealtime_commander ran clean, stop() settled and the rate-limiter backstop never bound. The robot’s joint-side acceleration check was bracketed on the same arm: a run whose IK peaked at 9.3 rad/s² passed, two runs were refused with cartesian_motion_generator_joint_velocity_discontinuity in the cycle a joint crossed 10 rad/s² (joint jerk stayed under 1400 rad/s³), so the published 10 rad/s² limit is applied as is.
  • Torque backend of target control on both FERs (2026-09-10). First hardware run of the impedance backend (PREEMPT_RT host, FRANKA_REALTIME=enforce, thresholds 40 N): the commander’s stepped and rotating sequences, joint targets and stops ran with no reflex, the first torque of every session under 0.04 Nm, IK residual under 1e-6, tracking error at the holds 4.6 mm (L) and 2.8 mm (R) at 750 N/m against 3.7 mm for the robot’s own controller on the same sequence. Push tests measured 725 to 1090 N/m felt stiffness, the leash holding the error at 25.0 mm, and 45 to 50 N under a fast push, which trips 40 N thresholds and not 60 N. The numbers are on The impedance backend. The FR3 was not reachable that day.

Simulator gaps

franka-sim speaks the real FCI wire protocol over a MuJoCo model, and the crate’s integration tests drive it as they would an arm. It is not an arm. This page lists the known divergences from a real robot, so that a result on the simulator is read for what it is. How to run the tests is on Test against the simulator.

What the simulator is not

  • It does not run under PREEMPT_RT, and neither does CI, so every test and example connects with RealtimeConfig::Ignore. Timing measured against it is a same-box A/B, not an FCI qualification; see Benchmarks.
  • There are two images. The FR3 / FCI v10 image is published (ghcr.io/barisyazici/franka-sim:latest). The FER / FCI v5 image, franka-sim:panda-v5, is a local build and not published; CI’s sim-fer-v5 job is gated on the FRANKA_SIM_FER_IMAGE repository variable and skipped until someone sets it, so the v5 simulator suite otherwise runs locally, and CI covers the v5 protocol offline through wire_sizes_v5.rs, fer_native_conformance.rs and the mock-FCI unit tests.

Known gaps

Every gap is pinned by a characterisation assertion that names the stub in its failure message, so a rebuilt image tightens the test instead of breaking it silently.

gapimagesconsequencepinned in
O_ddP_O is [0, 0, 0]bothModel::gravity(&state) is identically zero; use gravity_q with an explicit vector.sim_v5_commands.rs
O_T_EE is the joint-7 frameboth0.107 m short of the flange along the tool z axis (measured 0.10700011 m), with F_T_EE and NE_T_EE both identity.sim_commands.rs, sim_v5_commands.rs
--enforce-motion-limits uses the FR3’s limit tablespanda-v5The FER’s jerk and acceleration limits are larger on most joints (MAX_JOINT_JERK = [7500, 3750, 5000, 6250, 7500, 10000, 10000] against a flat 5000), so whenever the client’s FER rate limiter saturates it emits a command a real FER accepts and this image rejects. Do not home with MotionGenerator on this image with limits enforced, and read a reflex threshold as the FR3’s.sim_v5_stop_and_reflex.rs
joint 1 ignores velocity commandspanda-v5The velocity servo uses FR3 gains; on the Menagerie franka_emika_panda model joint 1 limit-cycles at the 500 Hz Nyquist frequency with ±0.36 rad/s, so the clipped torque averages to zero. Commanding 0.1 rad/s for 1 s moved joint 1 by −0.00189 rad against about 0.099 on joints 2, 4 and 7. Position commands on joint 1 are fine.sim_v5_motions.rs (active_control module)
GetCartesianLimit is a stubpanda-v5kSuccess with an all-zero 154-byte body, so virtual_wall(1) returns zeros and active: false. The framing is real, the content is not; only a real FER can confirm the field mapping.sim_v5_commands.rs
StopMove answers kSuccesspanda-v5A real robot, and the FR3 image, answer kPreempted. control_torques then returns Protocol("Unexpected reply to a Move command") instead of the usual preemption ControlException.sim_v5_stop_and_reflex.rs
no graspable objectpanda-v5The image predates --gripper-object-width, so a successful grasp is only covered on the FR3 image (--gripper-object-width 0.04). The gripper protocol is byte-identical on both versions anyway.sim_gripper.rs (FR3)
no franka-sim-check binarypanda-v5The test harness falls back to its own readiness probe: a version-5 Connect handshake plus one 2373-byte UDP datagram.crates/franka-sim-test/src/docker.rs

The joint-side continuity check

A real robot runs inverse kinematics on every commanded Cartesian pose and checks the continuity of the result in joint space (see FER / Panda specifics). Since franka-sim 1.1.5 the FR3 image does the same under --enforce-motion-limits, scaled by --joint-discontinuity-scale (1.0 is the robot’s own limit); its FR3 acceleration table, 10 rad/s², was confirmed at scale 1.0 on a real FR3 on 2026-09-09. The panda-v5 image has no joint-side check and accepts what a real FER refuses. sim_target_control needs franka-sim 1.1.6 or later.

API reference

The generated rustdoc for the franka crate is published alongside this book:

→ API reference (api/franka/index.html)

It is built by .github/workflows/docs.yml with

cargo doc --no-deps -p franka-rs

under RUSTDOCFLAGS="-D warnings", so a broken intra-doc link fails the build, and copied into the site at api/. The library target is named franka, which is why the path is api/franka/ rather than api/franka-rs/.

Good places to start:

itemwhat it is
franka::RobotThe connection. Setters, read_once, the control_* loops, start_*_control, stop, automatic_error_recovery.
franka::RobotStateEvery field the robot publishes each millisecond.
franka::ModelPoses, both Jacobians, mass, Coriolis, gravity.
franka::GripperThe Franka Hand on port 1338.
franka::FrankaErrorThe error taxonomy, one variant per libfranka exception.
franka::rate_limitingThe FR3 constants and limit_rate_*; the FER’s live in the fer submodule.

Building it locally:

cargo doc --no-deps -p franka-rs --open

Changelog

All notable changes to this project are documented here.

The format follows Keep a Changelog, and this project adheres to Semantic Versioning.

0.3.0 - 2026-09-10

Added

  • An impedance backend for target control, the default. TargetControlOptions / JointTargetControlOptions gain backend: Backend (with_backend): Backend::Impedance(ImpedanceOptions) runs the loop through control_torques and sends, every cycle, the torques of the hybrid joint impedance law of DROID’s controller (polymetis HybridJointImpedanceControl), tau = (Jᵀ Kx J + Kq)(q_goal − q) + (Jᵀ Kxd J + Kqd)(dq_goal − dq) + coriolis, clamped to torque_limits and low-pass filtered at cutoff_frequency (100 Hz); the arm is compliant around the target. The damping acts on the velocity error (velocity_feedforward, default on; off is DROID’s form). Without an echo of a torque command the generator is anchored every cycle on the measured state pulled toward the previous desired by at most the Leash (0.025 m, 0.15 rad; 0.1 rad per joint), also during the stop’s hold, so an arm that is held back never meets more than the felt stiffness times the leash (roughly 25 to 30 N at the default gains at the ready pose, 18.75 N with project_joint_gains; on the joint interface the torque clamp bounds the torque) and the generator resumes from the arm on release. Target control sets no collision thresholds: with the default gains set at least 40 N / 40 Nm, or lower the stiffness. On the Cartesian interface q_goal comes from a differential inverse kinematics (damped least squares, nullspace drift toward posture capped at 0.5 rad/s, a step cap max_step of 0.01 rad per cycle, clamp to the joint position limits; IkOptions) that follows the generator one cycle at a time, so an unreachable target lags rather than jumps; on the joint interface it is the generator’s output. A posture or joint target outside the joint limits (inset 0.02 rad) is refused with InvalidArgument. project_joint_gains (default off) confines the joint gains to the Jacobian’s nullspace so the end effector feels Kx alone (unprojected, the joint springs make the default 750 N/m about 990 to 1180 N/m at the ready pose). The finish waits for the arm to rest (REST_JOINT_VELOCITY, 0.01 rad/s, or the 5 s timeout). ImpedanceGains (CARTESIAN: 750 N/m and 15 Nm/rad with damping 50, 50, 90 Ns/m, about ζ 0.8 at the ready pose, and a small joint term; DROID: DROID’s gains as they were, damping 37; JOINT: the fer_joint_impedance example’s), ImpedanceOptions::cartesian() / ::joint() with with_* builders, franka::impedance_torques (the law, public at the crate root) and rate_limiting::JOINT_POSITION_LIMITS (FR3) / rate_limiting::fer::JOINT_POSITION_LIMITS (FER), from the URDFs in the repository. nonrealtime_commander gains --no-feedforward, --project-joint-gains, --leash M and --thresholds N, and its CSV leash_alteration. CartesianSent / JointSent gain q_goal, the clamped tau, leash_alteration (and leash_angular_alteration for a pose) and, for a pose, the IK residual ik_error. The Python cartesian_targets and joint_targets take backend ('impedance' | 'robot'), cartesian_stiffness, cartesian_damping (6 values, or one float for the translational three), joint_stiffness, joint_damping, torque_limits, posture, torque_cutoff, velocity_feedforward, leash and project_joint_gains. Run on franka-sim and on two real FERs (2026-09-10: stepped and rotating targets, joint targets, stops, and push tests that measured the felt stiffness, the leash and the force a fast push reaches); not yet on an FR3. See The impedance backend.

Changed

  • Target control is compliant by default. Both interfaces now send the impedance backend’s torques; the robot’s own controller tracking a pose or joint-position stream, the only behaviour before, is Backend::RobotController (TargetControlOptions::default().with_backend(Backend::RobotController), Python backend="robot"), and controller_mode applies to that backend only. The robot’s joint-side continuity check no longer refuses a fast Cartesian budget in the default backend; the deviation guard and the collision thresholds apply to both. A change of default behaviour, hence 0.3.0 rather than 0.2.1.
  • The book is reorganised into Getting started, Things to keep in mind, How-to and Reference; the 0.2.0 page names redirect. The README is the front door only, with the quick example on ActiveControl.

0.2.0 - 2026-09-09

Added

  • Target control (robot::target_control): Robot::start_cartesian_target_control and Robot::start_joint_target_control run the crate’s control loop on a named thread of their own and return a CartesianTargetControl / JointTargetControl handle whose set_position([f64; 3]) / set_joints([f64; 7]) any low-rate commander can call from any thread at any rate; the loop bridges the steps with the online trajectory generator under the three rules of the otg module (per-axis limits from the norm budget, one nominal DELTA_T per command, re-anchoring on the robot’s echo), the rate limiter under the same budget as the backstop, a measured-deviation guard and a settle-then-finish stop() that returns the loop’s result. TargetControlOptions / JointTargetControlOptions carry the budget (joint default: 20 % of the negotiated version’s limits), the controller mode, the guard, the settle criterion, an optional SCHED_FIFO priority for the loop thread and an observer called every cycle on the realtime thread with what was sent (the flight recorder’s hook). TargetSlot<N> is the seqlock underneath, public. MultiOtg::with_limits builds a generator with per-axis limits and realtime::set_current_thread_scheduler_priority raises a thread to a chosen priority. Tested on franka-sim (tests/sim_target_control.rs) and run on a real FER and an FR3.
  • Cartesian target control carries an orientation. CartesianTargetControl::set_pose (column-major, as O_T_EE; a rotation block within 1e-3 of orthonormal is repaired, one further off refused), set_target(position, quaternion) and set_orientation(quaternion) with unit quaternions in [x, y, z, w] order, target_orientation() and target_pose(); set_position keeps the target orientation. The orientation runs on three more axes of the same synchronised generator, on the base-frame rotation vector of the orientation error re-anchored on the echo every cycle, under TargetControlOptions::rotation_limits (0.5 rad/s, 1.0 rad/s², 20 rad/s³ by default) with with_rotation_limits, and an angular deviation guard max_angular_deviation (0.5 rad). CartesianSent gains the sent orientation, the angular velocity and acceleration and the rotational backstop alteration. The Cartesian backstop now references the twist and acceleration it sent rather than the echoed ones, whose float32 rounding had its jerk clamp firing at noise level (on the rotation that was an orbit of 5 mrad around the target), and REST_VELOCITY / REST_ACCELERATION drop to 1e-4 and 0.05 so the hold’s freeze stays under the joint-side jerk limits. OtgLimits::scaled is new. The commander example’s --rotate adds a slow yaw sweep of ±15° (bridged mode only).
  • otg module: an online trajectory generator (Otg, MultiOtg<N>, CartesianOtg, OtgLimits) that re-plans a time-optimal, jerk-limited seven-segment profile every cycle from the commanded state to rest at the latest target, so a stream of stepped, bursty or stalled targets becomes a C2 command that never exceeds its velocity, acceleration and jerk limits, does not overshoot a reachable target, lands exactly and stays there; optional axis synchronisation, set_position to re-anchor on the robot’s echo, per_axis_for_norm for a budget that is a norm. Dependency-free and allocation-free. Its first outing on a real FER ended in the rate limiter behind it orbiting at the velocity cap, which the module documentation explains and two replay tests pin down.
  • nonrealtime_commander example: a scripted (or stdin) commander sets Cartesian targets through start_cartesian_target_control (--bridged, --budget V,A,J), or hands them to a bare control_cartesian_pose to provoke a reflex (--raw); --log writes one CSV row per cycle, from the loop’s observer, with the joint angles, the external wrench and the generator’s velocity and acceleration. The budget exists because the robot also checks the joint-space continuity of a Cartesian pose stream, which the rate limiter does not bound; see Target control. franka-rerun’s commander_live example is on the same API, with the Recorder in the observer.
  • Python bindings (crates/franka-py, import franka; pip install franka-rs, or maturin develop from the source tree): Robot, RobotState (numpy fields and a 69-float flat() observation), robot.cartesian_targets() / robot.joint_targets() as context managers over the target control loops with move_to, move_by, follow(chunk, dt), target(), state() and stop(), Gripper, Model over numpy (robot.model()), franka.rotated, and FrankaError / ControlException. A Cartesian target carries an optional unit quaternion, a delta an optional rotation vector. The 1 kHz loop stays on its Rust thread and never takes the GIL. PyO3 0.29, abi3 for Python 3.9+; tested against franka-sim in CI’s python-bindings job and run on a Panda (crates/franka-py/examples/policy_loop.py; rotate.py and the quickstart.ipynb notebook are the other two examples). See Python. .github/workflows/release.yml builds the wheels and publishes them and the crate on a v* tag.
  • automatic_error_recovery example: command-line recovery that prints the robot mode before and after. move_to_ready example: the examples’ motion generator to libfranka’s ready pose at a fraction of full speed.
  • serde feature (off by default): Serialize / Deserialize for RobotState, RobotMode, Errors, Duration, Record, RobotCommandLog, MoveStatus and ControlException. Errors serialises as the list of the set flags’ names.
  • franka-rerun, a workspace crate (publish = false, Rust 1.96) that replays logs in Rerun: csv turns the commander example’s log into target, commanded and measured positions, the command’s derivatives against the rate limits and a 3D replay of the arm; log replays a saved ControlException control log as a flight recording (contact and collision flags, external wrench, commanded versus measured, errors, the arm), through flight::{log_records, replay_exception, save_records, load_records}; and Recorder streams the same live from inside a control loop with a non-blocking, non-allocating push. examples/reflex_replay.rs puts the last two together; examples/commander_live.rs streams the commander into a viewer. Both replays draw Franka’s link meshes with --meshes DIR, csv has the screen-capture --layout demo and the commander’s --budget lines, and log locates a contact on the arm from the external joint torques (flight::contact). See the flight recorder page.

0.1.0 - 2026-09-07

Initial release. franka-rs is a pure-Rust libfranka client speaking both generations of the Franka Control Interface: FCI v10 (Franka Research 3, ported from libfranka 0.21.2) and FCI v5 (Franka Emika Robot, FER, ported from libfranka 0.9.2).

There is no earlier release to compare against, so “Added” describes the release itself and “Changed” records the places where this crate deliberately differs from libfranka.

Added

  • Franka Emika Robot (FER) support (FCI v5), alongside the FR3 (FCI v10); see the FER specifics page. Robot::new negotiates the version automatically (connects announcing v10, retries as v5 if the robot reports kIncompatibleLibraryVersion); RobotOptions::with_version(VersionPolicy:: Exact(FciVersion::V5)) skips the extra round trip when the generation is already known. New public API: FciVersion, VersionPolicy, RobotOptions, VirtualWallCuboid, Robot::{with_options, fci_version, set_filters, virtual_wall}, Model::{from_model_library_bytes, from_model_library_path}, model::{load_from_robot, SoModelBackend, model_library, so_backend}, RobotState::from_wire_v5, rate_limiting::fer (selected automatically from the negotiated version), wire::robot::{v5, codec}. New example fer_joint_impedance.
  • The FER model no longer needs a download. Robot::load_model() on an FER now returns the native model — franka::model::FER_URDF, evaluated by the same backend the FR3 uses — instead of downloading and dlopening the robot’s shared object. Needs no network, no model-library feature and no x86-64 Linux host; also reachable without a robot as Model::native_fer(). Its link inertial parameters were identified from a real FER’s own model library by the new tools/fer-model-fit (a regressor from the crate’s own dynamics backend, solved with a truncated SVD), agreeing with the library to 9e-16 on kinematics and 4e-14 on dynamics with no payload, and characterising the one known gap, a payload non-linearity in the library itself (see the model page). Robot::load_model_from_robot() keeps the previous behaviour (download + dlopen on v5, unchanged GetRobotModel URDF on v10). The native model is checked in CI via tests/fer_native_conformance.rs against a committed fixture, with no robot or shared object present.
  • aarch64 builds, via cargo-zigbuild or a cross gcc, or as a fully static aarch64-unknown-linux-musl binary with --no-default-features (a static binary can’t dlopen, and the FER’s model path is x86-64-only anyway). CI cross-builds for aarch64-unknown-linux-gnu on every push; verified under qemu-aarch64 against both simulators, identical to native.
  • New example cartesian_impedance_active_control — libfranka’s cartesian_impedance_control.cpp (a spring-damper system whose equilibrium is the initial end-effector pose) driven through the ActiveControl API’s read_once / write_once instead of a control callback, allocation-free and running unchanged on an FR3 (FCI v10) and an FER (FCI v5).
  • New example cartesian_impedance_figure_eight — the same ActiveControl impedance loop with a moving equilibrium: a Lissajous figure eight around the start pose, raised-cosine ramps on both the path amplitude and the stiffness at either end (and after Ctrl-C), a nullspace joint spring toward the initial configuration through a damped-inverse projector, a one-sided virtual floor, a ±25 Nm torque clamp and a deviation cut-out. Allocation-free, on both generations. The pieces it shares with cartesian_impedance_active_control moved to examples/common/cartesian.rs.
  • New example dual_communication_test — the zero-torque communication-test loop against two robots at once from one process, with per-robot loop timing, lost-state and success-rate accounting.
  • bench/fer-capture/ — offline tooling that reassembles the FCI v5 state datagrams from a pcap/pcapng capture and reports inter-arrival gaps, drift and a stall verdict, to tell whether an FER loop’s lost cycles come from the robot/network or the host.
  • bench/so-micro/ — an offline per-call microbenchmark of the FCI v5 model path, Rust vs C++, against the same captured shared object, needing no robot or simulator.

Changed

  • CommandName::command() is now CommandName::command(self, version: FciVersion) -> Option<u32>, since the TCP command numbering differs between the two protocol versions (None means the command does not exist in that version); CommandName gained two v5-only variants, GetCartesianLimit and SetFilters.
  • SoModelBackend::open, SoModelBackend::from_bytes, Model::from_model_library_bytes and Model::from_model_library_path are now unsafe fn: they dlopen the file or bytes given to them, so the caller must assert it is a trusted model library. Robot::load_model() stays a safe fn, as in libfranka.
  • A Connect reply with status kSuccess reporting a version other than the one the client announced is now FrankaError::IncompatibleVersion rather than being decoded against the wrong layout — what lets automatic version negotiation recognise an FER on the simulator; no effect against a real FR3 or FER.

Contributing

The checks

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

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

The --no-default-features clippy invocations keep the crate building without the default model-library feature, which is how it cross-compiles to static musl; the -p franka-rs one is needed because --workspace unifies the other crates’ default features back on.

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, manual, and recorded: they go through bench/’s --hardware harness, with a read-only Idle precondition probe, a return-to-ready move before each run, torque and end-effector deviation guards checked outside the timed region, and a written record in docs/ with the raw JSON under bench/results/. See Benchmarks.

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; write “measured on one arm” when that is what it is, and 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 full FR3 and FER measurement records are kept privately, outside this repository; the benchmarks page is the public summary and should stay in sync with them.

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) and runs cargo publish -p franka-rs 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 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. A release is: bump the versions in crates/franka-rs/Cargo.toml and crates/franka-py/Cargo.toml (the wheel takes its version from the latter), date the section in CHANGELOG.md, and push a v* tag.

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.