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
unsafein 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 time | Install, The realtime machine, First program |
| drive it from Python or a notebook | From Python |
| try it without a robot | Without a robot: franka-sim |
| know what can stop you before it does | Things to keep in mind |
| do one specific thing | the How-to pages, starting with Command from a low-rate program |
| see the protocol, the constants and the measurements | the Reference pages, starting with Compared with libfranka |
| read the rustdoc | API 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" }.
| feature | default | what it does |
|---|---|---|
model-library | on | Compiles 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. |
serde | off | Serialize / 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.
The link to the robot
- A wired Ethernet connection to the robot’s control unit.
172.16.0.2is the address in Franka’s documented default setup; the examples take it asargv[1]. - FCI mode unlocked in Desk and the brakes open.
- The ports are fixed by the protocol, 1337 for the robot and 1338 for the Franka Hand, and only one FCI client may be connected at a time. How the FCI works has the rest.
A PREEMPT_RT kernel
The loop has a hard 1 ms deadline. The crate checks /sys/kernel/realtime, as libfranka
does:
cat /sys/kernel/realtime # 1 on a PREEMPT_RT kernel
Permission for SCHED_FIFO
Raising the control thread to SCHED_FIFO needs CAP_SYS_NICE or a nonzero
RLIMIT_RTPRIO. As an unprivileged user:
ulimit -r 99
This works only once an rtprio limit is configured for the user, for example
/etc/security/limits.d/99-realtime.conf containing <user> - rtprio 99 (needs
pam_limits and a fresh login). Ubuntu’s realtime-kernel variant sets up a realtime group
that already has rtprio and memlock.
RealtimeConfig
Robot::new takes a RealtimeConfig:
Enforce, the default as in libfranka: checks that the calling thread can be raised toSCHED_FIFOand that the kernel advertises realtime capabilities, and fails withFrankaError::Realtimeand libfranka’s message otherwise.Ignore: skips both checks and runs the loop at whatever priority the process has.
Against the simulator Ignore costs nothing. Against a robot it means the loop can miss its
deadline under load, which the robot reports as a falling control_command_success_rate
and, eventually, a communication_constraints_violation reflex.
Every example reads the choice from FRANKA_REALTIME (enforce, the default, or
ignore), so one binary serves the realtime PC and the simulator:
FRANKA_REALTIME=enforce cargo run --release --example echo_robot_state -- 172.16.0.2
FRANKA_REALTIME=ignore cargo run --release --example echo_robot_state -- 127.0.0.1
Only the control thread should be SCHED_FIFO. Do not start the whole process with
chrt -f 80: 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.
Check the link first
Before any motion, run communication_test: after moving the arm to the ready pose it runs
a zero-torque loop and reports how many robot states were lost and the min/avg/max
control_command_success_rate, exiting non-zero when the average is below 0.9. It is the
first thing to point at a new PC or network card.
cargo run --release --example communication_test -- 172.16.0.2
A degraded Ethernet cable does not show up in ping: 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
- Command from a low-rate program: orientation and joint targets, the deviation guards, the observer.
- Write a 1 kHz callback and Drive the loop yourself: the two 1 kHz interfaces.
- Use the model, Use the gripper, Record and replay a run.
- Run the examples: the runnable programs in the repository.
- From Python: the same target control as
move_to,move_byandfollow.
From Python
At the end of this page a Python program running at its own rate drives the arm through the same target control as the first program, and you know where the notebook and the examples are.
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;Falseis DROID’s form, and withcartesian_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:Trueconfines the joint term to the nullspace, so the end effector feelscartesian_stiffnessalone.
joint_targets takes backend, joint_stiffness, joint_damping, torque_limits,
torque_cutoff, velocity_feedforward, leash (one float, rad per joint, default 0.1) and
project_joint_gains. backend='robot' has the robot’s own controller (controller_mode) track
the targets instead, as the bindings did before the impedance backend existed; it takes none
of the gains. The law, the defaults and what differs between the backends are in
Command from a low-rate program, with what two real
FERs measured on 2026-09-10.
| call | what it does |
|---|---|
arm.move_to(t) | Absolute target in the base frame: 3 elements (x, y, z) in m keep the target orientation, 7 add a unit quaternion (x, y, z, w). Cancels a follow; never blocks. |
arm.move_by(d) | Moves the target (not the measured pose) by (dx, dy, dz) m; 6 elements append a rotation vector (axis times angle, rad) composed onto the target orientation in the base frame. Cancels a follow. |
arm.follow(chunk, dt=0.02) | Hands an (N, 3) or (N, 7) array of absolute targets to a Rust timer thread: row 0 now, row i at i * dt s. Returns at once; a new follow or move_* replaces it. |
arm.target() | The current target, shape (7,): position (m) and unit quaternion (x, y, z, w). |
arm.target_pose() | The same as a (4, 4) matrix, the convention of RobotState.O_T_EE. |
arm.state() | The latest franka.RobotState the loop received; never blocks on the network. |
arm.running | False once the loop ended for any reason or stop() was called. |
robot.joint_targets(fraction=0.2) | The same over the seven joints (rad): move_to, move_by and the rows of follow take 7 values, target() is (7,), and the budget is fraction of the robot’s joint velocity, acceleration and jerk limits. |
franka.rotated(q, r) composes a rotation vector onto a quaternion the way move_by does,
for building move_to targets. robot.read_once(), robot.set_collision_behavior(...)
(the Rust arguments), robot.automatic_error_recovery(), robot.stop(),
robot.fci_version, robot.server_version and robot.gripper() (homing, move,
grasp, stop, read_once, GIL released while they wait) complete the surface.
RobotState
Arrays are float64 numpy arrays, poses (4, 4) matrices with the translation in
M[:3, 3]; robot_mode is a string such as 'idle', current_errors and
last_motion_errors are lists of flag names. state.flat() is one array of length
franka.FLAT_LEN (69) for a policy’s observation; the slices are franka.FLAT_LAYOUT:
| slice | field |
|---|---|
0:7 | q (rad) |
7:14 | dq (rad/s) |
14:21 | tau_J (Nm) |
21:28 | tau_ext_hat_filtered (Nm) |
28:31 | O_T_EE translation (m) |
31:35 | O_T_EE unit quaternion (x, y, z, w), w >= 0 |
35:41 | O_F_ext_hat_K (N, Nm) |
41:48 | q_d (rad) |
48:51 | O_T_EE_c translation (m) |
51:55 | O_T_EE_c quaternion (x, y, z, w) |
55:62 | joint_contact |
62:68 | cartesian_contact |
68 | time (s) |
Robot.model()
franka.Model is the crate’s model over numpy, native on both robots (an FR3 sends its URDF,
an FER’s parameters are built in): pose(frame, q) is (4, 4), body_jacobian and
zero_jacobian are (6, 7) with the linear rows first, mass(q) (7, 7),
coriolis(q, dq) and gravity(q) (7,), link_poses(q) (8, 4, 4) and hand_pose(q)
(4, 4). frame is 'joint1'..'joint7', 'flange', 'ee' or 'stiffness'; the
optional F_T_EE, EE_T_K, I_total, m_total and F_x_Ctotal arguments default to the
identity and no payload, so pass the state’s for the mounted tool. See
Use the model.
Errors
Every Rust error is a franka.FrankaError; str(e) is the Rust message and e.kind names
the variant ('network', 'command', 'realtime', …). A motion the robot aborted is a
franka.ControlException, a subclass, with e.reason (the reflex reason), e.errors (its
flag names) and e.move_status. See State and errors.
The notebook and the examples
crates/franka-py/examples/quickstart.ipynb connects, reads a state and the model, drives a
5 cm square with move_to at 10 Hz, yaws the tool 15° with a quaternion target and tilts it
10° with a move_by rotation vector, returns to the start with a follow chunk, then
replays the motion inline in Rerun (Franka’s meshes if FRANKA_MESHES points at them,
otherwise a skeleton) and plots target against measured position. It runs top to bottom
against franka-sim: FRANKA_ADDRESS (default 127.0.0.1) picks the
robot, FRANKA_REALTIME defaults to ignore, and the last cells need
"rerun-sdk[notebook]==0.37.*" and matplotlib.
examples/policy_loop.py <hostname> [--yes] is a jittery 6-10 Hz policy loop: a 4 cm circle
through move_to, yaw and tilt through move_by, a 20-row follow chunk back to the start.
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:
| flag | effect |
|---|---|
--enforce-motion-limits | Runs the robot’s limit checks on every command, including (since franka-sim 1.1.5) the joint-side continuity check on Cartesian poses, scaled by --joint-discontinuity-scale (1.0 is the robot’s own limit). Without it the server accepts commands a robot would refuse. |
--gripper-object-width <m> | Places a graspable object of that width between the fingers. |
--enforce-comm-constraints | Enables the communication-constraint checks. |
--no-gripper | No server on port 1338. |
One slot per host
The FCI ports are fixed by the protocol and one client holds them at a time, so run one container at a time and check before starting one:
ss -tlnp | grep -E '133[78]' # nothing may be listening
docker ps # no franka-sim container may be running
--rm removes the container when you stop it with Ctrl-C.
What it is not
- Not a realtime system: timing measured against it is a same-machine comparison, not an FCI qualification (Benchmarks 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 program | use |
|---|---|
| runs at 1 kHz on a realtime machine and computes the next setpoint from the state | callback or ActiveControl |
| is a planner, policy, teleoperation or script that produces targets at its own rate | target control |
| needs to command torques of its own law | callback or ActiveControl; target control’s default backend sends torques from its impedance law, with the gains as options but the law fixed |
| wants the crate’s low-pass filter and rate limiter behind it | callback, with limit_rate and cutoff_frequency; target control has the rate limiter as a backstop under its own budget, and its impedance backend filters the torques at 100 Hz |
| is written in Python | target control: move_to, move_by, follow |
Rate limiting and low-pass filtering exist on the callback path; target control keeps the
rate limiter as a backstop, filters its torques at 100 Hz in the impedance backend and runs
with the filter off in the robot-controller backend. ActiveControl has neither, in this
crate as in libfranka.
What they share
- Only one control or read operation may run on a
Robotat a time; a second one returnsFrankaError::InvalidOperationwith libfranka’s message. RobotisSend + Sync, sorobot.stop()can be called from another thread while any of the three runs; the motion then ends withFrankaError::Controlcarrying"libfranka: Move command preempted!".- A motion that ends abnormally returns
FrankaError::Control(ControlException)with the reflex reason and a log of the last cycles; see State and errors. - The realtime rules apply to the first two in full, and to the observer target control lets you install; see The realtime rules.
Reading without controlling is Robot::read_once for one state and Robot::read for a
stream; neither commands anything.
The realtime rules
A control loop against the FCI answers a state every millisecond, and the robot ends the
motion when the answers stop coming. These are the rules that keep them coming, each with
what happens when it is broken. They apply in full to the callback and ActiveControl
paths; under target control the crate’s thread follows them for you, and only the observer
you may install is bound by them.
1. Answer every state within the millisecond
The robot publishes control_command_success_rate in every state: the fraction of the last
100 commands it accepted. Cycle 0 reads 0, because nothing has been acknowledged yet, so skip
it when averaging. A rate below 1 means commands arrived late or not at all; a sustained fall
ends the motion with a communication_constraints_violation reflex.
examples/communication_test.rs runs a zero-torque loop and prints the minimum, average and
maximum of the rate; it is the first thing to run on a new machine or network interface.
2. Build with --release
A debug build of a stiff torque controller misses cycles. Every command in this book uses
--release.
3. Nothing on the control thread may allocate, block or do I/O
Inside a callback, an ActiveControl loop body or a target-control observer: no Vec that
grows, no String formatting, no println!, no file or socket, no mutex another thread may
be holding. The crate’s own loops allocate nothing after the start.
franka_rerun::Recorder::push shows the pattern: it copies the record into a bounded channel
created before the loop, with try_send, which never blocks and never allocates, and drops
the record when the channel is full. A slow cycle costs success rate; a blocked one costs the
motion.
4. Only the control thread is SCHED_FIFO
Robot::new raises the calling thread to the highest SCHED_FIFO priority; target control
raises its own loop thread the same way, or to realtime_priority when set. Leave the rest
of the process at normal priority. Running the whole process under chrt -f 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, andFRANKA_REALTIME=enforceon the realtime machine.
State and errors
Every millisecond the robot sends a RobotState; every failure the crate reports is a
FrankaError. This page is the map of both.
RobotState
franka::RobotState is a plain Copy struct with the field names and semantics of
libfranka’s franka::RobotState, whose robot_state.h documents every field. Every value is
f64 on both protocol versions; where the wire carries f32, the wire layer converts.
| group | fields |
|---|---|
| Frames | O_T_EE, O_T_EE_d, O_T_EE_c, F_T_EE, F_T_NE, NE_T_EE, EE_T_K: 4x4 homogeneous transforms, column major |
| Load | m_ee, I_ee, F_x_Cee, m_load, I_load, F_x_Cload, m_total, I_total, F_x_Ctotal |
| Elbow | elbow, elbow_d, elbow_c, delbow_c, ddelbow_c |
| Joint torques | tau_J (measured), tau_J_d (last commanded; what the rate limiter compares against), dtau_J |
| Joint motion | q, q_d, dq, dq_d, ddq_d, theta, dtheta |
| Contact and collision | joint_contact, cartesian_contact, joint_collision, cartesian_collision |
| External wrench | tau_ext_hat_filtered, O_F_ext_hat_K, K_F_ext_hat_K |
| Cartesian commands | O_dP_EE_d, O_dP_EE_c, O_ddP_EE_c |
| Base acceleration | O_ddP_O: the gravity vector as the robot measures it, about [0, 0, -9.81] on hardware |
| Accelerometers | accelerometer_top, accelerometer_bottom (FCI v10 only; zero on an FER) |
| Health | current_errors, last_motion_errors, control_command_success_rate, robot_mode, time |
q versus q_d. q is measured; q_d is the position the robot’s controller is
commanding right now, and O_T_EE_c the commanded pose. Seed a motion from the commanded
values, not the measured ones: the two differ by the tracking error, and on an FER the first
setpoint of a motion is rate limited against the robot’s own q_d. This is rule 5 of
The realtime rules.
time is a franka::Duration, a millisecond counter from the robot rather than a host
clock; as_secs_f64() converts it. It advancing by more than 1 ms between two states is how
lost states are counted.
control_command_success_rate is the fraction of the last 100 commands the robot
accepted, and the health signal of a control loop; cycle 0 always reads 0. What a falling
rate means is in The realtime rules.
RobotMode
The seven values of franka::RobotMode: Other, Idle, Move, Guiding, Reflex,
UserStopped, AutomaticErrorRecovery. Display prints them with libfranka’s spelling
("User stopped", "Automatic error recovery"), because that text appears inside the
robot’s error strings. A read-only probe before a motion should require Idle with no error
flags.
Errors
franka::Errors is a set of 41 boolean flags, Errors(pub [bool; 41]), in libfranka’s
order; franka::ERROR_NAMES holds the names.
#![allow(unused)]
fn main() {
extern crate franka;
use franka::Robot;
fn f(robot: &Robot) -> franka::FrankaResult<()> {
let state = robot.read_once()?;
if state.current_errors.any() {
println!("errors: {:?}", state.current_errors.names());
}
if state.last_motion_errors.get("joint_velocity_violation") {
println!("the previous motion tripped the joint velocity limit");
}
Ok(()) }
}
any() says whether anything is set, names() lists the set flags as &'static str,
get(name) reads one flag by libfranka’s name (unknown names are false), and
Errors::index_of(name) gives the bit index.
current_errors is what is wrong now. last_motion_errors is the reflex reason of the
motion that last ended, the same set a ControlException carries; on a freshly connected
robot it is the history, the reason the previous session’s motion ended, which is worth
printing in a pre-flight probe. Two names are easy to misread:
cartesian_motion_generator_joint_velocity_discontinuity and its joint_acceleration
sibling are raised by a Cartesian pose motion, from the robot’s check of the joint motion
the poses imply; see Reflexes, limits and recovery.
FrankaError
One enum with a variant per libfranka exception type, so a match covers what a catch
chain would. FrankaResult<T> is Result<T, FrankaError>.
FrankaError variant | libfranka exception | raised when |
|---|---|---|
Network(String) | NetworkException | connection or socket failure, including a UDP timeout |
Protocol(String) | ProtocolException | malformed or unexpected protocol data |
IncompatibleVersion { server_version, library_version } | IncompatibleVersionException | the server speaks another FCI version |
Command(String) | CommandException | a TCP command was rejected by the robot |
Control(ControlException) | ControlException | a motion ended abnormally: reflex, preemption, discontinuity |
Realtime(String) | RealtimeException | SCHED_FIFO or /sys/kernel/realtime unavailable under Enforce |
InvalidOperation(String) | InvalidOperationException | not allowed now: a second control loop, or a command the negotiated version does not have |
InvalidArgument(String) | std::invalid_argument | non-finite values, an invalid transform, an invalid elbow configuration |
Model(String) | ModelException | model loading or evaluation failure |
The message strings are libfranka’s, including the "libfranka: " prefix, so log lines are
comparable with a C++ client’s. Match on the variant, not on the text. Control is the only
variant with structured data: message, move_status, last_motion_errors and log, the
last states and commands before the end (50 by default). How to read it is in
Write a 1 kHz callback; the flight recorder replays it
(Record and replay a run).
Two behaviours differ from libfranka on purpose, a stricter version handshake and
control_* never failing with Realtime; both are listed under
Compared with libfranka.
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) | default | JointTargetControlOptions | default |
|---|---|---|---|
limits: OtgLimits, a norm budget | 0.3 m/s, 0.5 m/s², 20 m/s³ | limits: Option<[OtgLimits; 7]> | None: 20 % of the negotiated version’s joint limits |
rotation_limits: OtgLimits, a norm budget | 0.5 rad/s, 1.0 rad/s², 20 rad/s³ | ||
backend: Backend | Impedance(ImpedanceOptions::cartesian()) | backend | Impedance(ImpedanceOptions::joint()) |
controller_mode (robot-controller backend only) | CartesianImpedance | controller_mode | JointImpedance |
max_deviation | 0.30 m | max_deviation | 1.0 rad |
max_angular_deviation | 0.5 rad | ||
settle: Settle (landing tolerance, hold cycles) | 1 mm, 250 cycles | settle | 1 mrad, 250 cycles |
limit_rate | true | limit_rate | true |
realtime_priority: Option<i32> | None (highest) | same | same |
observer | none | observer | none |
Every field is public and has a with_* builder; validate() checks the options without
starting anything. A Cartesian budget is a norm and each axis gets 1/√3 of it. 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).
ImpedanceOptions | Cartesian interface (::cartesian()) | joint interface (::joint()) |
|---|---|---|
gains.cartesian_stiffness Kx (N/m, Nm/rad) | 750, 750, 750, 15, 15, 15 | 0 |
gains.cartesian_damping Kxd (Ns/m, Nms/rad) | 50, 50, 90, 2, 2, 2 | 0 |
gains.joint_stiffness Kq (Nm/rad) | 40, 30, 50, 25, 35, 25, 10 | 600, 600, 600, 600, 250, 150, 50 |
gains.joint_damping Kqd (Nms/rad) | 4, 6, 5, 5, 3, 2, 1 | 50, 50, 50, 50, 30, 25, 15 |
torque_limits (Nm) | 86, 86, 86, 86, 11.5, 11.5, 11.5 | same |
cutoff_frequency (Hz) | 100 | 100 |
velocity_feedforward | true | true |
leash: Leash | 0.025 m, 0.15 rad | 0.1 rad per joint (the torque clamp, not the leash, bounds the torque: 600 × 0.1 = 60 Nm on joints 1 to 4, under their 86 Nm clamp; on joints 5 and 6 the 11.5 Nm clamp binds first) |
project_joint_gains | false | false |
posture (IK nullspace reference) | None: the start configuration | not used |
ik: IkOptions | λ 0.05, nullspace gain 1.0 /s, 3 iterations, tolerance 1e-6, limit margin 0.02 rad, max step 0.01 rad | not 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_FREQUENCYturns it off.velocity_feedforward:Kd (dq_goal − dq)when on,−Kd dqwhen 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 measuredq) pulled toward the previous desired by at most the leash, the torque-mode form of the third generator rule: while the arm follows, that is exactly the previous desired and nothing changes; held back by a hand, an obstacle or an unreachable target, the desired stays within the leash of the arm, so the spring force on whoever holds it is bounded by the felt stiffness times the leash (roughly 25 to 30 N at the default gains at the ready pose, 18.75 N withproject_joint_gains; 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 withinleash.jointof the measured joint; there the torque clamp bounds the torque (theJOINTpreset’s 600 Nm/rad × 0.1 rad is 60 Nm on joints 1 to 4, under their 86 Nm clamp; on joints 5 and 6 the 11.5 Nm clamp binds before the leash does).project_joint_gains: confines the joint gains to the Jacobian’s nullspace (N Kq N,N = I − J⁺ J), so that the stiffness you set is the stiffness felt at the end effector, to within the damping of the projector and away from singularities. Unprojected, the joint springs are felt throughJon top ofKx: at the ready pose the default 750 N/m is felt as about 990 to 1180 N/m in translation (computed on the FER model), and two to three timesKxin rotation. With the projection on, zero Cartesian gains would leave the end effector free.posture: the joint configuration the inverse kinematics drifts toward in the nullspace, at most 0.5 rad/s;Noneis the configuration the loop started in. A posture outside the joint limits (inset 0.02 rad) is refused withInvalidArgument, 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_deviationends the loop the same way. - Collision thresholds apply to both, and are yours to set. The robot’s contact and
collision reflexes watch the external torques and forces whatever commands them; a
compliant arm meeting an obstacle still trips them at the thresholds
set_collision_behaviorsets, and a spring reaches them by deflection (the paragraph above). - An unreachable or singular target lags in the impedance backend, because the inverse
kinematics never jumps (at most
ik.max_stepper 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 setsmotion_finishedonly once every joint is slower thanREST_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
- Anchor. The first cycle takes the start, first target and first setpoint from the
robot’s echo of its commanded pose or joints (
O_T_EE_c,q_d) in the robot-controller backend, and from the measured state in the impedance backend, where no echo of the command exists: the measuredq, and for a pose the model’s pose of that configuration rather thanO_T_EE, so the inverse kinematics starts with a zero residual;q_goalstarts atq. Every later cycle of the impedance backend anchors on the measured state pulled toward the previous desired by at most the leash. - Read the slot. The latest target comes through a single-writer seqlock the loop polls without blocking; a torn read keeps the previous target for one cycle.
- Generate. One synchronised jerk-limited generator over all axes, per-axis limits, one nominal millisecond per command, re-anchored before every re-plan on the echo (robot-controller backend) or on the leashed anchor (impedance backend).
- Track. Impedance backend: the inverse kinematics step for a pose, 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. - Guard. If the measured pose strays past
max_deviationormax_angular_deviationfrom the start, the target freezes and the loop ends withFrankaError::Control. - Land, hold, finish. After
stop(): run until every axis is withinsettle.toleranceof the target and at rest, hold the last command forsettle.cycles, thenmotion_finished; if the generator has not landed within five seconds, hold from where the command is. The impedance backend finishes only once the arm itself is at rest (REST_JOINT_VELOCITY), or after five more seconds.
Why each step is what it is, and what 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
| method | callback returns | commands |
|---|---|---|
control_joint_positions | JointPositions | q |
control_joint_velocities | JointVelocities | dq |
control_cartesian_pose | CartesianPose | O_T_EE (+ optional elbow) |
control_cartesian_velocities | CartesianVelocities | O_dP_EE (+ optional elbow) |
control_torques | Torques | tau_J |
control_torques_and_joint_positions | Torques + JointPositions | both |
control_torques_and_joint_velocities | Torques + JointVelocities | both |
control_torques_and_cartesian_pose | Torques + CartesianPose | both |
control_torques_and_cartesian_velocities | Torques + CartesianVelocities | both |
The four motion-only methods take a ControllerMode, JointImpedance or
CartesianImpedance, which selects the robot’s internal controller; the control_torques*
methods do not, there the controller is yours. Seed a position or pose motion from
state.q_d / state.O_T_EE_c, not from the measured q / O_T_EE (realtime rule 5).
Ending a motion
A motion ends when the callback returns a command with motion_finished set. Set the field,
or wrap the value:
#![allow(unused)]
fn main() {
extern crate franka;
use franka::JointVelocities;
fn f(out: JointVelocities, done: bool) -> JointVelocities {
if done {
franka::motion_finished(out) // sets the flag and returns the value
} else {
out
}
}
}
The flag belongs to the Finishable trait all five command types implement. The last command
is still sent, and the crate then closes the motion with the robot. Never return early or
panic out of the callback to stop a motion: set the flag, or call stop() from another
thread. A panic that unwinds out of the callback cancels the motion on the robot, as a thrown
exception does in libfranka, but that is an abort, not a finished motion.
Rate limiting and the low-pass filter
Every method takes limit_rate: bool and cutoff_frequency: f64 explicitly, because Rust
has no default arguments and libfranka’s limit_rate default differs by version: false in
libfranka 0.21 for the FR3 (the robot does its own limiting; the client-side limiter can
distort a motion), true in libfranka 0.9.2 for the FER. cutoff_frequency defaults to
DEFAULT_CUTOFF_FREQUENCY in both; MAX_CUTOFF_FREQUENCY disables the first-order low-pass
filter. Which constants the limiter uses follows from the negotiated version, not from you;
they and the limit_rate_* functions are in
Rate limiting and filtering.
ControlException and the control log
A motion that ends abnormally returns FrankaError::Control(ControlException), the port of
franka::ControlException:
#![allow(unused)]
fn main() {
extern crate franka;
use franka::{FrankaError, Robot, RealtimeConfig};
fn f(robot: &Robot) {
let result: franka::FrankaResult<()> = Ok(());
match result {
Err(FrankaError::Control(e)) => {
println!("{}", e.message); // libfranka's exact text
println!("{:?}", e.move_status); // terminal Move status, if any
println!("{:?}", e.last_motion_errors); // the reflex reason
for record in &e.log { // newest last
println!("{:?} -> {:?}", record.state.q, record.command);
}
}
_ => {}
}
}
}
message is libfranka’s full string, with the error names and the success-rate line when a
reflex aborted the motion. log is Vec<Record>, Record { state, command: Option<RobotCommandLog> }, newest last: what was commanded in the 50 ms before the robot
stopped. The default size is 50 cycles (franka::DEFAULT_LOG_SIZE); Robot::new_with_log_size
or RobotOptions::new(..).with_log_size(n) changes it. The flight recorder replays the log
(Record and replay a run); with the serde feature it saves as JSON.
After a reflex, robot.automatic_error_recovery() clears the errors for the next motion.
Stopping from another thread
Robot is Send + Sync and every method takes &self: share it as an Arc<Robot> and call
stop() from a second thread. The loop then fails with FrankaError::Control carrying
"libfranka: Move command preempted!".
extern crate franka;
use std::sync::Arc;
use franka::{ControllerMode, JointVelocities, RealtimeConfig, Robot, DEFAULT_CUTOFF_FREQUENCY};
fn main() -> franka::FrankaResult<()> {
let robot = Arc::new(Robot::new("172.16.0.2", RealtimeConfig::Enforce)?);
let stopper = Arc::clone(&robot);
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(2));
let _ = stopper.stop();
});
let result = robot.control_joint_velocities(
|_state, _period| JointVelocities::new([0.0; 7]),
ControllerMode::JointImpedance,
false,
DEFAULT_CUTOFF_FREQUENCY,
);
// `result` is Err(FrankaError::Control(..)) with "Move command preempted!"
let _ = result; Ok(()) }
Only one control or read operation may run at a time; a second one returns
FrankaError::InvalidOperation with libfranka’s own message. The generate_* examples are
ports of libfranka’s on this interface; see Run the examples.
Drive the loop yourself
At the end of this page you will have run a motion from a loop you wrote, reading a state
with read_once and answering it with write_once, and seen a Cartesian impedance
controller written that way. This is libfranka’s ActiveControl: startTorqueControl(),
readOnce(), writeOnce().
Prerequisites: First program works, the arm is at the ready pose, and you have read The realtime rules: the loop body is on the realtime thread, and there is no filter or rate limiter behind it.
extern crate franka;
use franka::{JointVelocities, MoveControllerMode, RealtimeConfig, Robot};
fn main() -> franka::FrankaResult<()> {
let robot = Robot::new("172.16.0.2", RealtimeConfig::Enforce)?;
let mut active = robot.start_joint_velocity_control(MoveControllerMode::JointImpedance)?;
let mut time = 0.0;
loop {
let (_state, period) = active.read_once()?;
time += period.as_secs_f64();
let mut out = JointVelocities::new([0.0; 7]);
out.motion_finished = time >= 4.0;
let finished = out.motion_finished;
active.write_once(&out, None)?;
if finished {
break;
}
}
Ok(()) }
The starters
Robot::start_*_control sends the Move and returns a handle that holds the robot’s control
lock for its lifetime:
| starter | handle | write_once takes |
|---|---|---|
start_torque_control | ActiveTorqueControl | &Torques |
start_joint_position_control | ActiveMotionGenerator<JointPositions> | motion + Option<&Torques> |
start_joint_velocity_control | ActiveMotionGenerator<JointVelocities> | motion + Option<&Torques> |
start_cartesian_pose_control | ActiveMotionGenerator<CartesianPose> | motion + Option<&Torques> |
start_cartesian_velocity_control | ActiveMotionGenerator<CartesianVelocities> | motion + Option<&Torques> |
The motion-generator starters take a MoveControllerMode: JointImpedance,
CartesianImpedance or ExternalController. The Option<&Torques> of write_once must be
Some if and only if the mode is ExternalController; otherwise write_once fails with
FrankaError::Control.
read_once blocks for the next state and returns it with the robot time elapsed since the
previous read_once (zero on the first call); it fails with FrankaError::Control if the
motion was aborted. write_once validates the command (finite values, a homogeneous
transform, a valid elbow) and sends it. Setting motion_finished on either input ends the
control process; a write_once after that fails with FrankaError::Control. Dropping a
handle before the motion has finished cancels the motion.
Two things this path does not do. No rate limiting and no low-pass filtering are applied,
in this crate as in libfranka: smooth setpoints are your job, and the robot refuses a
discontinuous one with a reflex. And the handle is !Send: it holds the control lock’s
guard, so read and write it from the thread that started it; robot.stop() from another
thread still works through the Arc<Robot>.
Cartesian impedance with ActiveControl
examples/cartesian_impedance_active_control.rs is libfranka’s
cartesian_impedance_control.cpp on this path: a spring-damper system without inertia
shaping whose equilibrium is the pose the end effector had when the loop started. The
callback becomes the loop body and the Eigen calls become nalgebra ones (the crate depends on
nalgebra already):
extern crate franka;
extern crate nalgebra;
use franka::{motion_finished, Frame, RealtimeConfig, Robot, Torques};
use nalgebra::{Matrix4, Rotation3, SMatrix, SVector, UnitQuaternion, Vector3};
fn main() -> franka::FrankaResult<()> {
let robot = Robot::new("172.16.0.2", RealtimeConfig::Enforce)?;
let model = robot.load_model()?;
let (stiffness, damping) = (SMatrix::<f64, 6, 6>::zeros(), SMatrix::<f64, 6, 6>::zeros());
let (position_d, orientation_d) = (Vector3::zeros(), UnitQuaternion::identity());
let mut error = SVector::<f64, 6>::zeros();
let mut control = robot.start_torque_control()?;
loop {
let (state, _period) = control.read_once()?;
let coriolis = SVector::<f64, 7>::from_column_slice(&model.coriolis(&state));
let jacobian =
SMatrix::<f64, 6, 7>::from_column_slice(&model.zero_jacobian(Frame::EndEffector, &state));
let dq = SVector::<f64, 7>::from_column_slice(&state.dq);
let transform = Matrix4::from_column_slice(&state.O_T_EE);
let rotation = transform.fixed_view::<3, 3>(0, 0).into_owned();
let mut orientation =
UnitQuaternion::from_rotation_matrix(&Rotation3::from_matrix_unchecked(rotation));
if orientation_d.coords.dot(&orientation.coords) < 0.0 {
// A unit quaternion and its negation are the same rotation: take the shorter way.
orientation = UnitQuaternion::new_unchecked(-orientation.into_inner());
}
let position_error = transform.fixed_view::<3, 1>(0, 3) - position_d;
let orientation_error = -(rotation * (orientation.inverse() * orientation_d).vector());
error.fixed_view_mut::<3, 1>(0, 0).copy_from(&position_error);
error.fixed_view_mut::<3, 1>(3, 0).copy_from(&orientation_error);
let tau = jacobian.transpose() * (-(stiffness * error) - damping * (jacobian * dq)) + coriolis;
control.write_once(&Torques::new(tau.into()))?;
break;
}
control.write_once(&motion_finished(Torques::new([0.0; 7])))?;
Ok(()) }
Nothing in the body allocates: the nalgebra types are fixed-size and live on the stack, and
the model methods return fixed arrays. The example adds the argument parsing, the compliance
constants (150 N/m, 10 Nm/rad, D = 2 sqrt(K)), a SIGINT handler that lets the loop leave
through a final motion_finished write, and the --duration bound. It sets the C++
example’s collision thresholds, 100 Nm and 100 N, so that the arm can be pushed; keep the
user stop in hand.
examples/cartesian_impedance_figure_eight.rs is the same law with a moving equilibrium: a
Lissajous figure eight in the base frame’s y-z plane (0.08 m amplitude, a 10 s lap by
default), stiffness 200 N/m and 15 Nm/rad. A raised cosine ramps the amplitude and the
stiffness in over 3 s and out over 2 s (1 s after Ctrl-C, or after the end effector is
dragged more than 30 cm off its setpoint), so nothing steps at either end. It adds a
nullspace joint spring (5 Nm/rad, damped-inverse projector) that keeps the elbow near its
start configuration, a one-sided virtual floor 10 cm below the start (a 1500 N/m spring
along +z, --floor), and a ±25 Nm clamp on each joint torque before the Coriolis
feedforward is added. The loop stays allocation-free; the tracking error goes into a
fixed-size histogram. It is the example to run with someone standing next to the robot.
On the FER
ActiveControl works on FCI v5 as well; libfranka 0.9.2 has no equivalent, its API for that
robot generation is the callback only. 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
| call | returns | layout |
|---|---|---|
pose(frame, &state) | [f64; 16] | 4x4 pose of frame in the base frame, column-major |
zero_jacobian(frame, &state) | [f64; 42] | 6x7, column-major; rows vx vy vz wx wy wz, base-frame aligned |
body_jacobian(frame, &state) | [f64; 42] | 6x7, column-major, expressed in frame |
mass(&state) | [f64; 49] | 7x7 mass matrix, column-major, symmetric, kg m² |
coriolis(&state) | [f64; 7] | C(q, dq) dq, Nm |
gravity(&state) | [f64; 7] | gravity torques, Nm |
Frame has the ten libfranka values: Joint1 … Joint7, Flange (the URDF’s link8),
EndEffector (flange post-multiplied by F_T_EE) and Stiffness (end effector
post-multiplied by EE_T_K). Frame::ALL lists them in that order.
Supplying your own inputs: the *_q variants
Every call has a variant that takes the joint configuration and load parameters directly
instead of reading them out of a RobotState: pose_q, zero_jacobian_q,
body_jacobian_q, mass_q, coriolis_q, gravity_q. They are what you use offline, and
gravity_q is the only way to supply your own gravity vector:
#![allow(unused)]
fn main() {
extern crate franka;
use franka::Model;
fn f(model: &Model, q: &[f64; 7]) {
let g = model.gravity_q(q, 0.73, &[0.0, 0.0, 0.1034], &[0.0, 0.0, -9.81]);
let _ = g; }
}
gravity(&state) uses state.O_ddP_O as Earth’s gravity, as libfranka does; coriolis(&state)
uses the fixed [0, 0, -9.81] (franka::model::DEFAULT_GRAVITY_EARTH), also as libfranka
does. franka-sim publishes O_ddP_O as [0, 0, 0], so on the simulator gravity(&state)
is identically zero: use gravity_q with an explicit vector there (see Simulator
gaps).
A Model can also be built without a robot: Model::from_urdf(&urdf) for an FR3’s URDF
and Model::native_fer() for the Franka Emika Robot.
In a torque controller
Model is Send + Sync, so a control thread and a planner can share one. The robot adds
gravity and friction to the torques you send, so a model-based controller typically adds
the Coriolis term and uses a Jacobian for a Cartesian law:
#![allow(unused)]
fn main() {
extern crate franka;
use franka::{Model, RobotState, Torques};
fn f(model: &Model, state: &RobotState, tau_task: [f64; 7]) -> Torques {
let coriolis = model.coriolis(state);
let mut tau = tau_task;
for i in 0..7 {
tau[i] += coriolis[i];
}
Torques::new(tau)
}
}
The full Cartesian impedance law (J^T (-K e - D J dq) + coriolis) is on Drive the loop
yourself; the joint impedance variant for an FER is
examples/fer_joint_impedance.rs.
Cost: evaluating all five dynamic and kinematic calls a model-based controller makes takes about 3 µs offline and 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
| method | what it does |
|---|---|
homing() | Homes the gripper and estimates the maximum grasping width. Required after changing the fingers. |
move_gripper(width, speed) | Moves the fingers to width metres at speed m/s. Named move_gripper because move is a Rust keyword. |
grasp(width, speed, force, epsilon_inner, epsilon_outer) | Grasps at force newtons. An object counts as grasped when the finger distance d satisfies (width - epsilon_inner) < d < (width + epsilon_outer). libfranka’s C++ overload defaults both epsilons to 0.005; Rust has no default arguments, so both are explicit. |
stop() | Stops a running move or grasp. |
read_once() | Drains stale datagrams, then blocks for one fresh GripperState. |
The four commands return FrankaResult<bool>: false means the gripper reports the
command did not achieve its goal (a grasp that closed on nothing); an error means the
command failed or was aborted, or the connection was lost.
GripperState
| field | meaning |
|---|---|
width: f64 | Current opening width, m. |
max_width: f64 | Maximum opening width, m, estimated by homing(). |
is_grasped: bool | Whether an object is currently grasped. |
temperature: u16 | Gripper temperature, °C. |
time: Duration | Strictly monotonic timestamp since the gripper server started. |
The wire struct is 23 bytes; its size and every field offset are asserted against
libfranka’s in tests/wire_sizes.rs.
Threading
Gripper’s network layer is Sync, matching libfranka’s documented guarantee for
franka::Gripper. The one caveat: only one read_once() should be in flight at a time,
since concurrent callers would race to claim the same UDP datagram.
Notes 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::newwill connect. examples/grasp_object.rsis the port of libfranka’s example of that name (grasp_object <robot-hostname> <object-width>, always homing first) and the quickest end-to-end check of a hand.- Against franka-sim a successful grasp needs an object between the fingers: the FR3
image takes
--gripper-object-width; the FER image has no such option (see Simulator gaps).
Record and replay a run
At the end of this page you can answer the question every reflex raises: what happened in
the seconds before? Which joint reported contact, how large was the external wrench, was the
command running away from the measurement, which error fired. The control log of every
ControlException holds the raw material; the workspace crate franka-rerun turns it into
a Rerun recording, and records the same picture live while a loop runs.
Prerequisites: a rerun viewer of exactly the version the crate pins, 0.37.1
(cargo install rerun-cli --locked --version 0.37.1 or pip install rerun-sdk==0.37.1);
RUST_LOG=warn rerun keeps its notification toasts to warnings. franka-rerun needs
Rust 1.96 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:
| entity | content |
|---|---|
joints/q, joints/q_d | measured joint positions and the commanded ones (the sent q_c for a joint-position motion, else the robot’s q_d) |
joints/dq, joints/tau_J, joints/tau_J_d, joints/tau_ext | velocities, measured and desired torques, tau_ext_hat_filtered |
ee/F_ext | O_F_ext_hat_K, force in N and torque in Nm |
ee/position | measured O_T_EE against commanded O_T_EE_c, translation only; per-axis plots and the derivatives of the sent position when the command was a Cartesian pose |
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/link | where the seven external joint torques say the arm was touched, and the force there |
events | every change of current_errors and robot_mode, the first rising edge of every flag (joint 4 contact, cartesian collision on Fz), and motion aborted: <names> at the end |
Record live
franka_rerun::Recorder streams the same picture from inside a running loop:
use franka_rerun::{Recorder, RecorderOptions, RobotKind};
let recorder = Recorder::to_file("run.rrd".as_ref(), model, kind, RecorderOptions::default())?;
// or Recorder::to_viewer("127.0.0.1:9876", ...) with `rerun` already running,
// or Recorder::to_viewer_and_file(...), or Recorder::spawn(...) to start one from PATH.
robot.control_joint_positions(
|state, period| {
let output = /* ... */;
recorder.push(state, Some(RobotCommandLog { q_c: output.q, ..Default::default() }));
output
},
mode, true, cutoff,
)?;
let stats = recorder.finish()?; // Stats { pushed, dropped, summary }
push runs on the realtime thread, so it must be cheap: it 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), aRecorderto<out>/run.rrdor--live ADDR, then joints 4 and 6 swing by0.2 (1 - cos(2π t / 12))for--seconds(60) while you push. On the reflex it writesreflex.rrdandreflex.json, recovers, and finishes the recorder.crates/franka-rerun/examples/commander_live.rs: the commander ofnonrealtime_commanderon target control, streamed into an open viewer (--live ADDR,--out FILE,--meshes DIR,--bridgedor--raw).
Status
Everything above is exercised against synthetic logs in crates/franka-rerun/tests/flight.rs.
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.
State and link
| example | what it does |
|---|---|
echo_robot_state | Prints RobotState for 100 cycles. The arm does not move. |
communication_test | Moves to the ready pose, then runs a zero-torque loop and reports lost states and the min/avg/max control_command_success_rate; exits non-zero when the average is below 0.9. |
dual_communication_test | The same loop against two robots from one process, a Robot per thread, with per-robot accounting. Takes two hostnames, [--cycles N] [--pin CPU1,CPU2]. |
Motion generators
| example | what it does |
|---|---|
generate_joint_position_motion | A cosine ramp on joints 4, 5 and 7. |
generate_joint_velocity_motion | A velocity profile on joints 4 to 7. |
generate_cartesian_pose_motion | A circle in the end effector’s x/z plane. |
generate_cartesian_velocity_motion | A diagonal x/z sweep. |
readme_joint_move | The README’s quick example, byte for byte; CI runs it against the simulator. |
move_to_ready | Moves to libfranka’s “ready” joint configuration. Takes [speed-factor] [--yes], default 0.2. |
Torque and impedance
| example | what it does |
|---|---|
fer_joint_impedance | 1 kHz joint-impedance torque control on an FER, rate-limited by hand against the robot’s tau_J_d; refuses to run on an FR3. |
cartesian_impedance_active_control | A Cartesian impedance controller whose equilibrium is the start pose, driven through ActiveControl’s read_once / write_once. Takes [--duration SEC] [--yes]. |
cartesian_impedance_figure_eight | The same loop with a moving equilibrium, a figure eight around the start pose, ramped in and out, with a nullspace joint spring and a one-sided virtual floor. Takes [--duration SEC] [--period SEC] [--amplitude M] [--floor M] [--yes]. |
Target control
| example | what it does |
|---|---|
nonrealtime_commander | A scripted (or stdin) commander sets stepped, bursty, stalling Cartesian targets through start_cartesian_target_control (--bridged, the default; --budget V,A,J; --rotate adds a yaw sweep), or hands them to a bare control_cartesian_pose so the robot refuses the first step (--raw). Takes [--bridged | --raw] [--stdin] [--log PATH] [--yes] [--budget V,A,J] [--rotate]. |
Gripper and recovery
| example | what it does |
|---|---|
grasp_object | Homes the Franka Hand, then grasps an object of the given width. Takes <hostname> <object-width>. |
automatic_error_recovery | A command-line automatic_error_recovery(): prints the robot mode and error flags before and after clearing a reflex. |
Rerun examples (crates/franka-rerun/examples)
Run with cargo run --release -p franka-rerun --example <name> -- <hostname>; the viewer
must be 0.37.1 (Install).
| example | what it does |
|---|---|
reflex_replay | A slow joint swing with lowered collision thresholds, recorded live with Recorder; the control log of the reflex a push provokes is written as a Rerun recording. See Record and replay a run. |
commander_live | nonrealtime_commander streamed live into a viewer: the raw target, the sent and measured position per axis, the arm (with --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.
| variable | default | meaning |
|---|---|---|
FRANKA_SIM_IMAGE | ghcr.io/barisyazici/franka-sim:latest | The FR3 / FCI v10 image. |
FRANKA_SIM_FER_IMAGE | franka-sim:panda-v5 | The FER / FCI v5 image, a local build 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_ADDR | unset | Attach to a server already running at this address instead of starting a container. The harness then never touches Docker, and the SimConfig a test passes is informational only: the caller is responsible for the running server matching it. |
FRANKA_SIM_KEEP | unset | =1 leaves the container up after the test for inspection. |
One FCI slot per host
The ports 1337 and 1338 are fixed by the protocol and one client holds them at a time:
- a process-wide mutex serialises the tests inside one test binary, and every test drops its
Robot/Gripperbefore the next one connects; SimServer::startrefuses to start a second container while afranka-sim*container is running or the ports are bound;- two
cargo testinvocations, or two test binaries, against the same host are not supported. Always pass-- --test-threads=1, and take the repository-root lock so two shells cannot collide:flock .sim.lock <command>.
Never run cargo test --tests: it selects every integration binary in the workspace,
including the sim_*.rs files, which start a container. Before starting anything,
ss -tlnp | grep -E '133[78]' and docker ps must show nothing.
The offline checks
What CI’s check job runs, in order; none of it needs Docker, a network or a robot:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo clippy --workspace --all-targets --no-default-features -- -D warnings
cargo clippy -p franka-rs --all-targets --no-default-features -- -D warnings
cargo clippy -p franka-rs --all-targets --features serde -- -D warnings
cargo 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:
| job | server configuration | runs |
|---|---|---|
sim-nominal | default | sim_handshake, sim_commands, sim_motions, sim_gripper (skipping the one test that needs an object), then readme_joint_move against the simulator with FRANKA_REALTIME=ignore |
sim-motion-limits | --enforce-motion-limits | sim_stop_and_reflex (reflex, recovery, the rate-limiting envelope) and sim_target_control (the Cartesian and joint target loops under a stepped, bursting, stalling commander) |
sim-gripper-object | --gripper-object-width 0.04 | the one sim_gripper test that needs something between the fingers |
python-bindings | --enforce-motion-limits | crates/franka-py/tests/test_sim.py: the wheel built with maturin, driven by pytest |
A fifth job, sim-fer-v5, runs the four sim_v5_* binaries. It cannot use the action: there
is no published FER image for it to pull, and the FER image lacks the franka-sim-check
binary the action’s readiness probe needs. Instead the harness starts and stops the container
itself, driven by FRANKA_SIM_FER_IMAGE. The job is gated on that name existing as a
repository variable (Settings, Secrets and variables, Actions, Variables): unset, the job is
skipped and the workflow stays green; set to a docker pull-able reference, the FCI v5 suite
runs against it. Until then the v5 protocol is covered in CI by the check job only (wire
layout, model conformance, the offline unit tests) and the simulator half runs locally.
Build for another machine
At the end of this page you have an aarch64 build of the crate and its examples for a
Raspberry Pi 4 or 5 or another 64-bit ARM Linux machine, made and smoke-tested on an x86-64 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
| target | libc | linking | model-library |
|---|---|---|---|
aarch64-unknown-linux-gnu | glibc | dynamic | compiles |
aarch64-unknown-linux-musl | musl | fully static | off (--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.cppandlowpass_filter.cpp; the error strings are byte-identical,"libfranka: "prefix included; the control log holds the same 50 cycles by default. A motion refused by the robot fails the same way from both clients. - Loop timing. Measured back to back on the same PC against franka-sim, 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.soto 5e-14 on gravity and 4e-16 on kinematics. Details on Model parameters and conformance.
What franka-rs has and libfranka does not
- One client for both robot generations. libfranka 0.18 and later speak FCI v10 (FR3
on system 5.9.0 or later; this crate ports the 0.21.2 semantics); 0.9.2 speaks FCI v5
(Panda); the two are incompatible and a program is built against one. Here
Robot::newnegotiates the version and the same binary drives both. - Target control. libfranka gives you the 1 kHz callback and, on the FR3, the
ActiveControlloop; since 0.18 it also has an asynchronous joint-position mode in which the robot itself moves point to point under maximum velocities (marked experimental, FR3 only, exposed inpylibfranka). Bridging a stream of Cartesian or joint targets onto the 1 kHz loop is otherwise left to the user or to software built on top of libfranka. Herestart_cartesian_target_control/start_joint_target_controlrun the loop on a realtime thread, take Cartesian poses or joint targets at any rate on both robot generations, track them with the crate’s impedance torques (compliant, the gains as options) or with the robot’s own controller, and the Python bindings are built on it. ActiveControlon an FER. libfranka 0.9.2 has only the callback API for that generation.- The model without a download. libfranka evaluates the FR3 model with Pinocchio and,
on an FER, downloads and
dlopens the robot’slibfcimodels.so. Here both are evaluated natively from parameters; nothing is fetched at runtime unless you call the opt-inload_model_from_robot(). - Build footprint. No C++ toolchain, no Poco, Eigen, Pinocchio or urdfdom, no system
packages;
rustup target addcross-compiles it. - Rust things.
RobotisSend + Syncand stops from another thread through anArc; errors are one enum with a variant per libfranka exception; aserdefeature makes the control log serialisable;crates/franka-rerunreplays it in Rerun.
What libfranka has and franka-rs does not
- Vendor support. libfranka is maintained by Franka Robotics, versioned against the robot system software, and is what Franka’s documentation describes. This crate is an unofficial project.
franka_ros2and theros2_controlhardware interface. Those are C++ plugins on libfranka; there is no ROS 2 integration here.VacuumGripper. Not implemented; only the Franka Hand is.- History. libfranka’s history starts in January 2017 and it has driven Franka arms since the Panda shipped. This crate’s hardware record is the campaigns and runs listed in the README, all from September 2026.
Deliberate differences
- A
kSuccesshandshake that reports a different version than the client announced is anIncompatibleVersionerror here. libfranka only inspects the status byte; everything after the handshake is decoded against the announced version, so such a session would be unusable. limit_rateandcutoff_frequencyare explicit arguments of everycontrol_*method because Rust has no default arguments, and libfranka’s defaults differ between the two versions (limit_rateisfalsein 0.21 andtruein 0.9.2). See Rate limiting and filtering.control_*never fails withFrankaError::Realtime; the priority is raised inRobot::new, as in libfranka’sRobot::Implconstructor, so the question is settled before a loop starts.
Other clients
Other Rust implementations exist. Marco Boneberger’s
libfranka-rs (2021, EUPL-1.2) is a pure-Rust
port of libfranka 0.9 for the Panda, FCI v5 only, and the project that informed this
crate’s API shape; franka_rust is a separate Rust
FCI driver whose README lists Panda and FR3 types, not evaluated here. On the Python side,
Franka’s own pylibfranka (libfranka 0.16 and later),
franky and
panda-py all wrap libfranka: franky and panda-py
run a C++ control thread with online trajectory generation, so “send a target any time from
Python” is available there too. What they inherit from libfranka is one protocol version
per install 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 version | libfranka semantics | Robot | System | RobotState | RobotCommand | Model source |
|---|---|---|---|---|---|---|
| v10 | 0.20 – 0.21 (ported from 0.21.2) | Franka Research 3 | 5.9.0 or later (server version 10) | 1377 B | 371 B | URDF from GetRobotModel, evaluated natively |
| v5 | 0.9.2 | Franka Emika Robot / Panda (FER) | 4.2.1 or later (server version 5) | 2373 B | 370 B | shipped identified parameters, evaluated natively |
Both versions share the framing: a TCP command channel on port 1337 with a 12-byte
CommandHeader (command, command id, total size) and u8 status bytes, and a UDP channel on
which the robot sends one RobotState per millisecond and receives one RobotCommand per
state while a motion runs. The Franka Hand has its own protocol on port 1338 (10-byte header,
u16 statuses), version 3 and byte-identical on both robots.
Version negotiation
Robot::new connects announcing library version 10. An FR3 accepts. An FER answers
kIncompatibleLibraryVersion and reports version 5; the client then closes both sockets,
opens a fresh TCP+UDP session and connects again as version 5. robot.fci_version() says
which one you got:
extern crate franka;
use franka::{FciVersion, RealtimeConfig, Robot};
fn main() -> franka::FrankaResult<()> {
let robot = Robot::new("172.16.0.2", RealtimeConfig::Enforce)?; // FR3 or FER
match robot.fci_version() {
FciVersion::V10 => println!("FR3"),
FciVersion::V5 => println!("FER"),
}
Ok(()) }
Auto-detection costs an FER one full connect, a rejected handshake and a disconnect, i.e. two
FCI client slots in sequence. A supervisor that reconnects in a loop, or any program that
knows its arm, pins the version with VersionPolicy::Exact:
extern crate franka;
use franka::{FciVersion, RealtimeConfig, Robot, RobotOptions, VersionPolicy};
fn main() -> franka::FrankaResult<()> {
let robot = Robot::with_options(
"172.16.0.2",
RobotOptions::new(RealtimeConfig::Enforce).with_version(VersionPolicy::Exact(FciVersion::V5)),
)?;
Ok(()) }
VersionPolicy::Exact(FciVersion::V10) against an FER never retries and fails with
FrankaError::IncompatibleVersion { server_version: 5, library_version: 10 }, which is what
a v10 libfranka reports when pointed at an FER. Any server version other than 5 or 10 is
returned as the same error.
One deliberate divergence from libfranka, which only inspects the handshake’s status byte: a
Connect reply of kSuccess that reports a version other than the one the client announced
is refused with IncompatibleVersion. Everything after the handshake is decoded against the
announced version, so such a session would fail on the first state with
Protocol("libfranka: incorrect object size") instead of naming the problem. franka-sim’s
FER build answers kSuccess with version 5 whatever the client announced, and this rule is
what lets auto-detection recognise it.
After the handshake, an FCI v10 session fetches the URDF (GetRobotModel) and derives the
position-dependent joint velocity limits from it, as libfranka 0.21.2 does; a v5 session
stops after the first state, as 0.9.2 does.
What differs on the wire
| FCI v5 (FER) | FCI v10 (FR3) | |
|---|---|---|
| libfranka | 0.9.2 | 0.21.2 |
RobotState datagram | 2373 bytes, every field f64, no accelerometer arrays | 1377 bytes, mixed f64/f32 |
RobotCommand datagram | 370 bytes: MotionGeneratorCommand 306 + ControllerCommand 56 (tau_J_d alone) | 371 bytes: ControllerCommand 57, also carries torque_command_finished |
tau_J_d in the state | f64 | f32 |
| command numbering | Connect 0, Move 1, StopMove 2, GetCartesianLimit 3, SetCollisionBehavior 4, SetJointImpedance 5, SetCartesianImpedance 6, SetGuidingMode 7, SetEEToK 8, SetNEToEE 9, SetLoad 10, SetFilters 11, AutomaticErrorRecovery 12, LoadModelLibrary 13 | Connect 0, Move 1, StopMove 2, SetCollisionBehavior 3, SetJointImpedance 4, SetCartesianImpedance 5, SetGuidingMode 6, SetEEToK 7, SetNEToEE 8, SetLoad 9, AutomaticErrorRecovery 10, GetRobotModel 11 |
Move request | 56 bytes | 113 bytes: adds use_async_motion_generator and seven maximum_velocity values, always transmitted |
Move generator modes | four, no None | the same four plus None (4) |
Move::Status | 10 values | 12: PreemptedDueToActivatedSafetyFunctions (3) and CommandRejectedDueToActivatedSafetyFunctions (4) inserted after Preempted (2), so ReflexAborted is 6 on v5 and 8 on v10 |
| torque-only control | joint-velocity generator commanding zeros | MotionGeneratorMode::None |
SetFilters | yes (Robot::set_filters) | removed from the protocol |
GetCartesianLimit | yes (Robot::virtual_wall) | removed from the protocol |
GetRobotModel (URDF) | no such command | yes (Robot::robot_model) |
LoadModelLibrary | yes (Robot::load_model_from_robot) | no such command |
| joint velocity limits | flat: 2.175 rad/s (J1–4), 2.610 rad/s (J5–7) | position-dependent envelope from the URDF |
kTolNumberPacketsLost | 3.0 | 0.0 |
joint position limits (JOINT_POSITION_LIMITS, 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.8973 | J1 ±2.7501, J2 ±1.7918, J3 ±2.9065, J4 [−3.0481, −0.1458], J5 ±2.8101, J6 [0.5409, 4.5205], J7 ±3.0196 |
| gripper protocol | identical (version 3, port 1338) | identical |
The status enums other than Move::Status are shorter on v5 in the same way (no
safety-function values); the crate maps every v5 byte onto the v10 name set by name, and a
byte that is not valid for the negotiated version is
FrankaError::Protocol("libfranka: Unexpected response while handling <name> command!").
Commands a version does not have
Calling one fails client-side, before a byte is sent, with libfranka’s own text:
FrankaError::InvalidOperation("libfranka: Get Robot Model is not available on FCI version 5.")
FrankaError::InvalidOperation("libfranka: Set Filters is not available on FCI version 10.")
Two public items carry the version: CommandName::command(version) -> Option<u32> is the
wire id of a command under a version (None where it does not exist), and the FER’s
rate-limit constants live only at franka::rate_limiting::fer::*, with the FR3’s at the
crate root.
Two IP fragments per state
The v5 state datagram, 2373 bytes, exceeds the 1500-byte Ethernet MTU, so every FER state
arrives as exactly two IP fragments: 2000 packets per second for the host to service instead
of 1000. 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_torquesandstart_torque_controlsendMove { controller_mode: ExternalController, motion_generator_mode: JointVelocity };- every 370-byte command datagram carries your
tau_J_dand a zerodq_c, which goes through the same low-pass filter and rate limiter a user’s velocity would; - the motion ends with
motion_generation_finished: a v5ControllerCommandhas notorque_command_finishedfield to set; - the two callbacks run in 0.9.2’s order (motion first, controller short-circuited) rather
than 0.21.2’s, because the order decides which cycle’s commands
finishMotionsends.
None of that reaches the public API. control_torques_and_joint_positions and its siblings
behave the same on both versions: there you supply the motion generator, so nothing is
substituted.
The q_d seed rule
Seed a motion from state.q_d, not state.q.
On an FER the first setpoint of a motion is rate limited against the robot’s own q_d /
O_T_EE_c like every other one, because libfranka 0.9.2’s convertMotion has no
initialized_filter_ special case for the first command. On an FR3 the first command is its
own reference and passes the limiter unchanged. Seed from q and you start one tracking
error away from what the robot is commanding, and the limiter has to absorb that step, which
at best distorts the start of your motion and at worst saturates.
This is why the README’s joint-move example reads robot.read_once()?.q_d rather than .q.
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:
- Low-pass filter. If
cutoff_frequency < MAX_CUTOFF_FREQUENCY(1000 Hz), every commanded value is filtered first order against the robot’s echo of the last command (q_d,dq_d,O_T_EE_cwith the rotation interpolated by slerp,O_dP_EE_c,elbow_c;tau_J_dfor torques) with gaindt / (dt + 1 / (2π f_c)).DEFAULT_CUTOFF_FREQUENCYis 100 Hz; passMAX_CUTOFF_FREQUENCYto skip the filter. - Rate limiter. If
limit_rate, the filtered command is clamped so that its velocity, acceleration and jerk relative to that same echo (q_d,dq_d,ddq_d;O_T_EE_c,O_dP_EE_c,O_ddP_EE_c; the elbow’s) stay within the version’s constants, and a torque so that its rate againsttau_J_dstays withinMAX_TORQUE_RATE. - Validation. A non-finite value is
FrankaError::InvalidArgumentwith libfranka’s text.
The first command of a motion is its own reference on an FR3 (libfranka 0.21.2’s
initialized_filter_), and is limited against the echo like every other on an FER; that is
the q_d seed rule.
libfranka’s defaults differ between the versions, and since Rust has no default arguments you choose explicitly:
limit_rate default in libfranka | note | |
|---|---|---|
| FCI v10 (libfranka 0.21) | false | The FR3 does its own limiting; the client-side limiter can distort a motion. |
| FCI v5 (libfranka 0.9.2) | true | The FER-era default. |
cutoff_frequency defaults to DEFAULT_CUTOFF_FREQUENCY in both.
Neither the filter nor the limiter runs on the ActiveControl path (read_once /
write_once), here or in libfranka: there smooth setpoints are the caller’s job.
Target control runs its loop with limit_rate on (default) and
the filter off, and additionally calls limit_rate_cartesian_pose /
limit_rate_joint_positions under its own, smaller budget as a backstop that is not meant to
bind; see Online trajectory generation.
The constants
Which table the loop uses is decided by the negotiated version, not by you. The FR3’s live at
the crate root (franka::MAX_JOINT_JERK and neighbours, mirroring libfranka’s franka::
namespace); the FER’s only at franka::rate_limiting::fer::*, so the two envelopes cannot be
confused at a glance. Nominal values, from crates/franka-rs/src/rate_limiting/:
| constant | FR3, franka::* | FER, rate_limiting::fer::* |
|---|---|---|
TOL_NUMBER_PACKETS_LOST | 0.0 | 3.0 |
MAX_TORQUE_RATE (Nm/s, per joint) | 1000 | 1000 |
MAX_JOINT_JERK (rad/s³) | 5000, all joints | 7500, 3750, 5000, 6250, 7500, 10000, 10000 |
MAX_JOINT_ACCELERATION (rad/s²) | 10, all joints | 15, 7.5, 10, 12.5, 15, 20, 20 |
| joint velocity (rad/s) | position-dependent envelope (below), saturating at 2.62, 2.62, 2.62, 2.62, 5.26, 4.18, 5.26 | MAX_JOINT_VELOCITY, flat: 2.175 (J1–4), 2.61 (J5–7); MIN_JOINT_VELOCITY is its negation |
MAX_TRANSLATIONAL_VELOCITY (m/s) | 3.0 | 2.0 |
MAX_TRANSLATIONAL_ACCELERATION (m/s²) | 9.0 | 13.0 |
MAX_TRANSLATIONAL_JERK (m/s³) | 4500 | 6500 |
MAX_ROTATIONAL_VELOCITY (rad/s) | 2.5 | 2.5 |
MAX_ROTATIONAL_ACCELERATION (rad/s²) | 17 | 25 |
MAX_ROTATIONAL_JERK (rad/s³) | 8500 | 12500 |
MAX_ELBOW_VELOCITY (rad/s) | 1.5 | 2.175 |
MAX_ELBOW_ACCELERATION (rad/s²) | 10 | 10 |
MAX_ELBOW_JERK (rad/s³) | 5000 | 5000 |
Every constant is the nominal value less LIMIT_EPS (1e-3), and every velocity limit is
further reduced by TOL_NUMBER_PACKETS_LOST · DELTA_T · a_max, the velocity the axis could
pick up over that many lost 1 ms cycles at its maximum acceleration: nothing on the FR3, and
on the FER 0.039 m/s off the translational velocity and 0.045 rad/s off joint 1’s, for
instance. DELTA_T is 1e-3, NORM_EPS is f64::EPSILON, and
FACTOR_CARTESIAN_ROTATION_POSE_INTERFACE (0.99) multiplies the three rotational limits
inside limit_rate_cartesian_pose, on both versions.
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:
| Rust | libfranka overload |
|---|---|
limit_rate_torques(max_derivatives, commanded, last) | limitRate(max_derivatives, ...) |
limit_rate_joint_velocity(...), limit_rate_joint_position(...) | the scalar overloads |
limit_rate_joint_velocities(...), limit_rate_joint_positions(...) | the array<double, 7> overloads |
limit_rate_cartesian_velocity(...) | the O_dP_EE_c overload |
limit_rate_cartesian_pose(...) | the O_T_EE_c overload |
low_pass_filter(dt, y, y_last, f_c), cartesian_low_pass_filter(dt, y, y_last, f_c) | lowpassFilter, cartesianLowpassFilter |
Where the C++ throws std::invalid_argument, these return FrankaError::InvalidArgument
with the same message. examples/fer_joint_impedance.rs is the pattern: control_torques
with limit_rate = false and MAX_CUTOFF_FREQUENCY, and
limit_rate_torques(&rate_limiting::fer::MAX_TORQUE_RATE, &tau, &state.tau_J_d) inside the
callback.
Online trajectory generation
The otg module turns a stream of stepped targets into a 1 kHz command whose velocity,
acceleration and jerk never exceed a budget. It is what target
control runs on its thread, and it is public (Otg,
MultiOtg<N>, CartesianOtg, OtgLimits) for a loop of your own. This page is the
reasoning: why a generator and not a filter, how it plans, the three rules for putting it in
a control loop, why its budget is smaller than the rate limiter’s, and what the loop does
every cycle.
The problem
Most programs that want to move the arm are not 1 kHz programs: a planner, a vision loop, a
script on a socket, a person at a keyboard. What they produce is a stream of targets,
irregular, sometimes bursty, sometimes silent for seconds, and every one of them a step. Sent
as is, a 5 cm step reaches the robot as a 50 m/s jump and the motion generator refuses it with
cartesian_motion_generator_velocity_discontinuity (that is what nonrealtime_commander --raw
demonstrates). Something has to turn the steps into a motion, causally, with only the latest
target in hand.
The alternatives each fail one requirement. A spline needs future knots, and a commander at 10 Hz, or silent for two seconds, cannot supply them. A quintic re-fitted from the current state to the target every cycle is smooth but has no notion of the limits: its peak velocity and acceleration scale with the step and shrink with the chosen duration, and no duration is right for both a 1 mm and a 10 cm step. A first-order low-pass filter is causal and cheap, but its first-cycle demand is the step times the gain (0.31 m/s for 5 cm at 1 Hz), so the rate limiter behind it does the real work, the peak speed still scales with the step, and the approach is exponential, never quite arriving.
What the generator does
An online trajectory generator is a small state machine that owns the commanded position, velocity and acceleration and, every cycle, re-plans the time-optimal jerk-limited profile from that state to rest at whatever the latest target is, then follows it for one cycle. It is causal (only the latest target), limit-respecting by construction (velocity, acceleration and jerk within the budget on every cycle, including a 2 ms one after a lost packet), C2 (the acceleration is continuous; only the jerk switches), time-optimal within its profile family, and it lands exactly on the target and stays there.
The plan is the seven-segment profile: a jerk-limited transfer from the current (v, a) to a
peak velocity v_p with zero acceleration (three segments with closed-form durations), a
cruise at v_p, and the mirror transfer to rest. The one free parameter is v_p: ±v_max
with a cruise when the target is far enough, otherwise a root of the displacement function,
which is increasing in v_p except for a hump next to v_rd = v + a|a| / (2 j_max), the
velocity reached by ramping the acceleration straight to zero. So the domain is split at
v_rd and v_rd ± a_max² / j_max, every piece whose ends bracket the target is bisected,
and the shortest plan wins. This is the profile structure of Haschke, Weitnauer and Ritter
(On-line planning of time-optimal, jerk-limited trajectories, IROS 2008), evaluated one
cycle at a time the way Ruckig (Berscheid and Kröger, RSS 2021) does. No dependencies, no
allocation.
A target that moves mid-motion is re-planned from the current velocity and acceleration. A target closer than the braking distance gives a root of the opposite sign: the profile passes the target, brakes, and comes back, within the limits and without a jerk spike. A target that stays for seconds is reached and held exactly; the target velocity is always zero, so this is a generator for positional targets, not for velocity tracking.
#![allow(unused)]
fn main() {
extern crate franka;
use franka::otg::{Otg, OtgLimits};
let limits = OtgLimits { max_velocity: 0.3, max_acceleration: 0.5, max_jerk: 20.0 };
let mut otg = Otg::new(0.0, limits).unwrap();
otg.set_target(0.05).unwrap();
let mut t = 0.0f64;
while otg.position() != 0.05 {
otg.step(0.001);
t += 0.001;
}
// The full norm budget on one axis; the target-control loop gives each axis 1/sqrt(3)
// of it, which stretches the same step to about 0.85 s.
assert!((t - 0.658).abs() < 0.01, "a 5 cm S-curve under these limits takes 0.658 s");
}
MultiOtg<N> runs one generator per axis (with_limits gives each axis limits of its own,
which seven joints need) and, when synchronised, stretches the faster axes to the slowest
one’s duration by lowering their peak velocity (a second bisection); an axis that is exactly
braking to its target has no peak to lower and keeps its minimum duration. CartesianOtg is
MultiOtg<3>. Synchronised axes arrive together, so a diagonal target moves along a straight
line.
The property test
crates/franka-rs/src/otg/tests.rs simulates two thousand random target sequences (every
third with random limits): steps at random times, holds up to three seconds, bursts of twenty
targets 5 ms apart, gaps. Every cycle it asserts that the velocity, acceleration and
finite-difference jerk stay within the limits; that a target beyond the braking distance is
never overshot; that every target is reached within 10 % of the time an admissible
brake-then-move profile would take; and, on the synchronised three-axis variant that every
eighth sequence also runs, that an axis at rest whose target did not change stays bit-exact.
The three rules, 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.
- The limits are per axis. Two axes at full acceleration have a vector norm √2 above it,
and a Cartesian budget is a norm (that is what
limit_rate_cartesian_posebounds). So the generator getsOtgLimits::per_axis_for_norm(3): each limit divided by √3, which is also what keeps a synchronised diagonal move inside the budget. - Step one nominal cycle per command (
DELTA_T, 1 ms), not the measured period. The robot and the rate limiter check every packet against a 1 ms budget, so a 2 ms step after a lost packet is a doubled velocity to them; the 52 cycles of 2–4 ms in that log each doubled an increment. - Re-anchor on the robot’s echo of the position every cycle with
set_position(O_T_EE_c)(orq_d), so that whatever runs behind the generator can shape one command but never accumulate a lag it plans against. The position only: the echoed twist is a mean over the cycle, half an acceleration step behind the generator’s end-of-cycle state, and re-anchoring on it throttles the plan to a crawl.
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:
- Anchor. The first cycle takes the robot’s echo of its commanded position (
O_T_EE_c,q_d) as the start, the initial target and the first setpoint, so the first command of the motion is the echo itself: on FCI v10 the first command is its own filter reference and would otherwise go out as a jump.start_*returns only once that cycle has run, sotarget()andstate()are valid from the first call. - Read the slot. The latest target comes through a single-writer seqlock
(
robot::target_control::TargetSlot) the loop polls without blocking; a torn read keeps the previous target for that one cycle.set_*serialises its callers with a mutex on the user side only. The stop flag is read before the slot, so a target published just beforestop()is not lost. - Generate under the three rules. Per-axis limits (the Cartesian budgets are norms and get
per_axis_for_norm(3); the joint limits are per joint already), oneDELTA_Tper command whatever the measured period,set_positionon the echo before every re-plan, axes synchronised. The orientation runs on three more axes of the same generator, on the rotation vector of the orientation error in the base frame (log(R_target R_echo^T)), re-anchored at zero every cycle and composed back asexp(step) R_echo: a constant-axis turn underrotation_limits, arriving together with the translation. - Backstop.
limit_rate_cartesian_pose/limit_rate_joint_positionsunder the same budget (the joint one tightened to the robot’s velocity envelope atq), against the echo, with the loop’s libfranka limiter behind it. Neither is meant to bind; the observer is told by how much the backstop moved the command (backstop_alteration). - Guard. If the measured position strays more than
max_deviationfrom the start (0.30 m Cartesian, 1.0 rad joint by default) or the measured orientation turns more thanmax_angular_deviation(0.5 rad), the target freezes where the command is, the generator brings it to rest, and the loop ends withFrankaError::Controlcarryingtarget_control::DEVIATION_MESSAGE. - Land, hold, finish. After
stop()the generator runs on until every axis has landed: withinSettle::toleranceof the target (1e-3: 1 mm or 1 mrad), slower thanREST_VELOCITY(1e-4 m/s or rad/s) and accelerating less thanREST_ACCELERATION(0.05). The hold freezes a velocity step of at mostREST_VELOCITYin 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 thefloat32echo 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, forSettle::cyclescycles (250), and setsmotion_finishedon one more of it. A motion never finishes on a moving command: a real FER refused exactly that withcartesian_motion_generator_velocity_discontinuity. If the generator has not landed withinSTOP_TIMEOUT_CYCLES(5000, five seconds) the same hold starts from wherever the command is.
Nothing allocates on the realtime thread after the start; the observer, called every cycle
with the state and what was sent, must keep it that way. Dropping a handle without stop()
requests the stop and detaches: the loop settles and finishes on its own, holding its
Arc<Robot> until it has. 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.
| run | result |
|---|---|
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 sequence | no 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 sweep | no 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 thresholds | two 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 thresholds | 24.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):
| quantity | tolerance | max abs error |
|---|---|---|
pose | 1e-9 | 7.216e-16 |
body_jacobian | 1e-9 | 9.992e-16 |
zero_jacobian | 1e-9 | 7.216e-16 |
mass | 1e-6 | 3.109e-15 |
coriolis | 1e-6 | 2.864e-14 |
gravity | 1e-6 | 2.132e-14 |
gravity, g = {0.1, -0.2, -9.7} | 1e-6 | 2.842e-14 |
That is double-precision round-off: the two implementations differ only in summation order.
cargo test -p franka-rs --test model_conformance runs it offline; the fixture carries the
SHA-256 of the URDF it was generated from, so tests/data/fr3.urdf and the fixture cannot
drift apart unnoticed. The same agreement holds on 10 000 random FR3 states in the
benchmark harness (largest difference anywhere 4.97e-14; see Benchmarks).
Where the parameters come from
FR3 (FCI v10): the robot’s own URDF
FCI v10 has a GetRobotModel command that serves the arm’s URDF over the TCP channel.
Robot::load_model() fetches it and hands it to the native backend; Robot::robot_model()
returns the URDF text itself. Nothing is downloaded that is not data, and nothing is
dlopened.
FER (FCI v5): shipped identified parameters
An FER has no GetRobotModel. libfranka 0.9.2 instead issues LoadModelLibrary, which
streams Franka’s closed-source libfcimodels.so (329 592 bytes on system 4.2.1) down the
command channel, writes it to a temporary file and dlopens it.
The crate does not need that. It ships the FER’s model as parameters,
crates/franka-rs/tests/data/fer.urdf, exposed as franka::model::FER_URDF, and evaluates
them with the same native backend the FR3 uses. On an FER, Robot::load_model() is exactly
Model::native_fer(): it cannot fail, needs no network, no model-library feature and no
x86-64 Linux host.
The kinematics were never in doubt: franka_description‘s joint origins and axes reproduce
the shared object’s poses and both Jacobians to 4e-16. The dynamics differ, and by a lot:
feeding the FR3’s URDF to the backend puts gravity 1.6 Nm out, the mass matrix 0.16 kg m²
out and Coriolis 0.8 Nm out (2.7 %, 3.9 %, 3.8 %). So the seven links’ inertial parameters
were identified from the shared object itself by tools/fer-model-fit: rigid-body dynamics
is linear in the ten inertial parameters per link, so with the kinematics fixed the problem
is 70 unknowns, solved by truncated SVD against the library’s answers at 208 joint
configurations. The regressor has rank 45, the classical number of base parameters, and the
worst residual is 3.6e-14.
Agreement against a real FER’s libfcimodels_x64.so, over 208 joint configurations × 4
load configurations, all ten frames:
| quantity | payload | max abs difference | relative |
|---|---|---|---|
| pose, all frames | any | 4.441e-16 | 3.5e-16 |
| body Jacobian | any | 8.882e-16 | 8.9e-16 |
| zero Jacobian | any | 4.441e-16 | 4.4e-16 |
| gravity | any | 4.974e-14 | 8.4e-16 |
| mass matrix | none | 3.553e-15 | 1.3e-15 |
| Coriolis | none | 3.553e-14 | 2.4e-15 |
| mass matrix | 1.23 kg | 2.980e-3 | 7.5e-4 |
| Coriolis | 1.23 kg | 5.008e-2 | 2.4e-3 |
The payload caveat
The last two rows are the whole residual, and they are the shared object’s doing, not the
fit’s: its M_NE is not affine in m_load. For a rigid payload M(2m) − M(0) must be
exactly 2 (M(m) − M(0)); the library misses that by 7e-4, behaving as though the payload
were up to 18 g lighter than it was told (0.7 % of a 1 kg payload, saturating). No rigid-body
model can follow it there, libfranka’s own included if it were reimplemented. g_NE is
affine, and the payload’s rotational inertia I_load enters exactly as a rigid body’s does,
so gravity, the quantity a gravity-compensation loop needs, agrees to 5e-14 whatever the
payload. The mass matrix and Coriolis agree to 4e-14 with no payload and carry that
1e-3-scale offset with one.
One more property of the identified set: some moments of inertia come out negative. That is a property of the robot’s own model, not of the fit: alternating projections between the affine set of exact fits and the cone of realisable rigid bodies converge with the smallest pseudo-inertia eigenvalue still at −1.5e-2, so the shared object’s parameters are not those of any set of real bodies. Use the file to reproduce libfranka’s numbers, not to seed a physics simulation.
The tests
tests/model_conformance.rs: the FR3 table above, against the libfranka 0.20.4 fixture.tests/fer_native_conformance.rs: the FER table above, against a committed fixture (tests/data/model_reference_fer.json, 40 joint configurations × 4 load configurations, dumped from the shared object), so it runs in CI with no.sopresent. With$FRANKA_FER_MODEL_SOset, two further tests drive the library live: one checks the fixture is still what the library answers, the other pins the non-affineM_NE.tests/fer_model_conformance.rs: drives the shared object itself throughModel::from_model_library_path/from_model_library_bytesat eight fixed configurations, checks the invariantsfranka::Modelguarantees, and reports the native backend’s differences against it. Needs themodel-libraryfeature and the.so; skips loudly without it.
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 answerskErrorordlopenrefuses the object.load_model()has no such limitation. - It goes to a temporary file.
dlopenneeds a path, so the bytes are written to a private0600temp file removed when theModelis dropped. The directory must be executable (anoexec/tmpbreaks it). Removal covers a normal drop and an unwind but notSIGINT/SIGTERM/abort, so a loop stopped with Ctrl-C leaves one ~330 KB file in$TMPDIR. dlopenexecutes code the robot chose, in your process, over an unauthenticated plaintext TCP socket, with no validation. This is what libfranka 0.9.2 does, and it stays a safefnhere for the same reason: the FCI peer is already fully trusted, because it is the thing that commands the arm. Give the FCI its own isolated link, as Franka’s setup guide requires.- Opt out entirely by building without the default
model-libraryfeature:load_model_from_robot()then returns aFrankaError::Model,libloadingis not linked at all, andload_model()is unaffected. The offline entry pointsModel::from_model_library_bytesandModel::from_model_library_path, which take the bytes from the caller, areunsafe fnand carry the corresponding# Safetycontract. - The library needs libm in the loading process.
libfcimodels_x64.soimportssin,cosandsincosand carries noDT_NEEDEDentries of its own, so it resolves them from the global scope of whichever processdlopens it. C++ clients get libm transitively through libstdc++; a Rust binary only links it if something in the binary uses it. A lean binary fails withundefined symbol: sincosat the first call; name the dependency explicitly if you rely on this path.
Cost
Evaluating the five model calls a model-based controller makes costs about 3 µs offline (2.97 µs p50 on 10 000 random FR3 states) and 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-04 —
franka-simover loopback plus a real FR3 at172.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.
| variant | client | interval p50 | p99 | lost cycles | success avg | CPU % |
|---|---|---|---|---|---|---|
control (callback) | C++ | 1000 µs | 1071 µs | 0.3 | 1.000 | 2.7 |
control (callback) | Rust | 1000 µs | 1050 µs | 0.3 | 1.000 | 2.4 |
active (readOnce/writeOnce) | C++ | 1000 µs | 1046 µs | 0.0 | 1.000 | 3.0 |
active (readOnce/writeOnce) | Rust | 1000 µs | 1054 µs | 0.0 | 1.000 | 2.4 |
Paired over all 18 cells:
| statistic | C++ minus Rust | verdict |
|---|---|---|
| CPU | mean +0.59 pp, C++ higher in 18/18 cells | real |
| interval p99 | mean −15 µs, median −3 µs, C++ higher in 8/18 | noise |
active recv→send latency p50 / p99 | 17 / 34 µs vs 11 / 23 µs (9 cells) | real |
The CPU gap holds equally whether C++ ran first (+0.57) or Rust ran first (+0.63), so it is
not an ordering artefact. Loop timing is indistinguishable: median cycle time is exactly
1000 µs everywhere and the p99 difference changes sign between variants. Under a 12-core
CPU hog, p99 rises to 1.3–1.4 ms (control) or ~1.1 ms (active) for both clients, p50
stays at 1000 µs, and neither client lost a cycle.
The model in the loop
The same operational-space impedance controller in both languages, with all five
franka::Model calls evaluated inside every 1 kHz cycle. Simulator, chrt -f 80 +
mlockall, 3 repetitions of 30 s.
| build | model p50 | p99 | compute p50 | interval p50 | p99 | lost | CPU % |
|---|---|---|---|---|---|---|---|
| libfranka 0.20.4 | 30.8 µs | 67.0 | 33.3 µs | 1000 µs | 1084 | 0 | 5.7 |
libfranka main (0.21.3) | 31.26 | 64.44 | 33.96 | 1000 | 1049 | 0 | 5.59 |
libfranka main + Data-reuse patch | 11.07 | 27.15 | 13.88 | 1000 | 1043 | 0 | 3.49 |
| franka-rs | 10.8 | 32.9 | 12.5 | 1000 | 1047 | 0 | 2.9 |
Both clients produce the same motion (peak |τ| 1.925–1.940 Nm, peak end-effector excursion 0.04785–0.04789 m), and neither lost a cycle in any of the 12 runs.
The five calls measured offline, on 10 000 identical random FR3 states:
| call | libfranka 0.20.4 | libfranka main | + patch commit 1 | + commit 2 | franka-rs |
|---|---|---|---|---|---|
mass | 0.67 | 0.63 | 0.62 | 0.61 | 0.68 |
coriolis (state overload) | 1.93 | 1.96 | 1.91 | 1.86 | 1.13 |
gravity | 0.57 | 0.60 | 0.57 | 0.58 | 0.57 |
zeroJacobian | 6.97 | 7.17 | 0.81 | 0.50 | 0.32 |
pose | 6.34 | 6.55 | 0.36 | 0.37 | 0.27 |
| all five | 16.48 | 16.93 | 4.28 | 3.92 | 2.97 |
p50 microseconds. The 21–24× gap on pose and zeroJacobian was not a C++/Rust
difference: libfranka’s Pinocchio wrapper constructed a whole fresh pinocchio::Data on
every kinematics call — 143 heap allocations. A two-commit patch (see patches/)
takes that to zero allocations per call and closes essentially the whole gap; the
remaining ratio is about 1.3× offline and 1.0× in the loop, and it is not all in the
same direction (C++ ahead on mass, franka-rs on coriolis and the Jacobians). Every
output is byte-identical before and after the patch, and libfranka’s own 805/805 tests pass
on both.
Numerical agreement over the same 10 000 inputs: the largest absolute difference
anywhere between the two backends is 4.97e-14 (zeroJacobian 9.99e-16, pose
1.06e-15, mass 3.55e-15). That is double-precision round-off.
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 p99 | 1079–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.
| arm | client | interval p50 | p99 | lost / 30 000 | success avg |
|---|---|---|---|---|---|
| L (onboard NIC) | franka-rs | 999.4 µs | 1171.7 µs | 27.5 | 0.9862 |
| L (onboard NIC) | libfranka 0.9.2 | 999.5 µs | 1186.8 µs | 29.5 | 0.9872 |
| R (USB Ethernet) | franka-rs | 999.1 µs | 1181.1 µs | 91.7 | 0.9873 |
| R (USB Ethernet) | libfranka 0.9.2 | 999.1 µs | 1183.9 µs | 87.0 | 0.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_idthe 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_FIFOstall 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 whymax_consecutiveis 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:
| configuration | L lost | R lost | success avg |
|---|---|---|---|
two independent processes (taskset -c 3 / -c 5) | 39 | 29 | 0.99 / 0.99 |
one process, one Robot per thread, 3 runs | 24–32 | 43–55 | 0.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:
| variant | cycles | lost | interval p50 | p99 | success avg | CPU % |
|---|---|---|---|---|---|---|
active (read_once/write_once) | 9957 | 36 | 999.2 µs | 1236.6 | 0.981 | 2.55 |
control (callback) | 9979 | 20 | 999.1 µs | 1271.6 | 0.985 | 2.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 aRobotper thread, with no cross-interference; andread_once/write_onceexercised 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_controlandcartesian_impedance_figure_eightran on a real FER throughActiveControl(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_commanderran 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 withautomatic_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 liveRecorderpushed 23 941 records at 1 kHz with none dropped, and on every logged cycle the native FER model’s end effector matched the measuredO_T_EEto under 0.01 mm. - Rotation targets on target control (2026-09-09).
nonrealtime_commander --rotateran its ±15° yaw sweep on a real FER throughstart_cartesian_target_control, a clean run to the end. - Python bindings on hardware (2026-09-09).
crates/franka-py/examples/policy_loop.pydrove a real FER from an irregular 6-10 Hz policy loop –move_to,move_byandfollowchunks – andstop()settled within about 0.3 s of the call. A degraded Ethernet cable shows up ascommunication_constraints_violationwith a cleanping; 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_commanderran 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 withcartesian_motion_generator_joint_velocity_discontinuityin 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_RThost,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 withRealtimeConfig::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’ssim-fer-v5job is gated on theFRANKA_SIM_FER_IMAGErepository variable and skipped until someone sets it, so the v5 simulator suite otherwise runs locally, and CI covers the v5 protocol offline throughwire_sizes_v5.rs,fer_native_conformance.rsand the mock-FCI unit tests.
Known gaps
Every gap is pinned by a characterisation assertion that names the stub in its failure message, so a rebuilt image tightens the test instead of breaking it silently.
| gap | images | consequence | pinned in |
|---|---|---|---|
O_ddP_O is [0, 0, 0] | both | Model::gravity(&state) is identically zero; use gravity_q with an explicit vector. | sim_v5_commands.rs |
O_T_EE is the joint-7 frame | both | 0.107 m short of the flange along the tool z axis (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 tables | panda-v5 | The FER’s jerk and acceleration limits are larger on most joints (MAX_JOINT_JERK = [7500, 3750, 5000, 6250, 7500, 10000, 10000] against a flat 5000), so whenever the client’s FER rate limiter saturates it emits a command a real FER accepts and this image rejects. Do not home with MotionGenerator on this image with limits enforced, and read a reflex threshold as the FR3’s. | sim_v5_stop_and_reflex.rs |
| joint 1 ignores velocity commands | panda-v5 | The velocity servo uses FR3 gains; on the Menagerie franka_emika_panda model joint 1 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 stub | panda-v5 | kSuccess with an all-zero 154-byte body, so virtual_wall(1) returns zeros and active: false. The framing is real, the content is not; only a real FER can confirm the field mapping. | sim_v5_commands.rs |
StopMove answers kSuccess | panda-v5 | A real robot, and the FR3 image, answer kPreempted. control_torques then returns Protocol("Unexpected reply to a Move command") instead of the usual preemption ControlException. | sim_v5_stop_and_reflex.rs |
| no graspable object | panda-v5 | The image predates --gripper-object-width, so a successful grasp is only covered on the FR3 image (--gripper-object-width 0.04). The gripper protocol is byte-identical on both versions anyway. | sim_gripper.rs (FR3) |
no franka-sim-check binary | panda-v5 | The test harness falls back to its own readiness probe: a version-5 Connect handshake plus one 2373-byte UDP datagram. | crates/franka-sim-test/src/docker.rs |
The joint-side continuity check
A real robot runs inverse kinematics on every commanded Cartesian pose and checks the
continuity of the result in joint space (see FER / Panda specifics). Since
franka-sim 1.1.5 the FR3 image does the same under --enforce-motion-limits, scaled by
--joint-discontinuity-scale (1.0 is the robot’s own limit); its FR3 acceleration table,
10 rad/s², 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:
| item | what it is |
|---|---|
franka::Robot | The connection. Setters, read_once, the control_* loops, start_*_control, stop, automatic_error_recovery. |
franka::RobotState | Every field the robot publishes each millisecond. |
franka::Model | Poses, both Jacobians, mass, Coriolis, gravity. |
franka::Gripper | The Franka Hand on port 1338. |
franka::FrankaError | The error taxonomy, one variant per libfranka exception. |
franka::rate_limiting | The FR3 constants and limit_rate_*; the FER’s live in the fer submodule. |
Building it locally:
cargo doc --no-deps -p franka-rs --open
Changelog
All notable changes to this project are documented here.
The format follows Keep a Changelog, and this project adheres to Semantic Versioning.
0.3.0 - 2026-09-10
Added
- An impedance backend for target control, the default.
TargetControlOptions/JointTargetControlOptionsgainbackend: Backend(with_backend):Backend::Impedance(ImpedanceOptions)runs the loop throughcontrol_torquesand sends, every cycle, the torques of the hybrid joint impedance law of DROID’s controller (polymetisHybridJointImpedanceControl),tau = (Jᵀ Kx J + Kq)(q_goal − q) + (Jᵀ Kxd J + Kqd)(dq_goal − dq) + coriolis, clamped totorque_limitsand low-pass filtered atcutoff_frequency(100 Hz); the arm is compliant around the target. The damping acts on the velocity error (velocity_feedforward, default on; off is DROID’s form). Without an echo of a torque command the generator is anchored every cycle on the measured state pulled toward the previous desired by at most theLeash(0.025 m, 0.15 rad; 0.1 rad per joint), also during the stop’s hold, so an arm that is held back never meets more than the felt stiffness times the leash (roughly 25 to 30 N at the default gains at the ready pose, 18.75 N withproject_joint_gains; on the joint interface the torque clamp bounds the torque) and the generator resumes from the arm on release. Target control sets no collision thresholds: with the default gains set at least 40 N / 40 Nm, or lower the stiffness. On the Cartesian interfaceq_goalcomes from a differential inverse kinematics (damped least squares, nullspace drift towardposturecapped at 0.5 rad/s, a step capmax_stepof 0.01 rad per cycle, clamp to the joint position limits;IkOptions) that follows the generator one cycle at a time, so an unreachable target lags rather than jumps; on the joint interface it is the generator’s output. Apostureor joint target outside the joint limits (inset 0.02 rad) is refused withInvalidArgument.project_joint_gains(default off) confines the joint gains to the Jacobian’s nullspace so the end effector feelsKxalone (unprojected, the joint springs make the default 750 N/m about 990 to 1180 N/m at the ready pose). The finish waits for the arm to rest (REST_JOINT_VELOCITY, 0.01 rad/s, or the 5 s timeout).ImpedanceGains(CARTESIAN: 750 N/m and 15 Nm/rad with damping 50, 50, 90 Ns/m, about ζ 0.8 at the ready pose, and a small joint term;DROID: DROID’s gains as they were, damping 37;JOINT: thefer_joint_impedanceexample’s),ImpedanceOptions::cartesian()/::joint()withwith_*builders,franka::impedance_torques(the law, public at the crate root) andrate_limiting::JOINT_POSITION_LIMITS(FR3) /rate_limiting::fer::JOINT_POSITION_LIMITS(FER), from the URDFs in the repository.nonrealtime_commandergains--no-feedforward,--project-joint-gains,--leash Mand--thresholds N, and its CSVleash_alteration.CartesianSent/JointSentgainq_goal, the clampedtau,leash_alteration(andleash_angular_alterationfor a pose) and, for a pose, the IK residualik_error. The Pythoncartesian_targetsandjoint_targetstakebackend('impedance'|'robot'),cartesian_stiffness,cartesian_damping(6 values, or one float for the translational three),joint_stiffness,joint_damping,torque_limits,posture,torque_cutoff,velocity_feedforward,leashandproject_joint_gains. 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), Pythonbackend="robot"), andcontroller_modeapplies to that backend only. The robot’s joint-side continuity check no longer refuses a fast Cartesian budget in the default backend; the deviation guard and the collision thresholds apply to both. A change of default behaviour, hence 0.3.0 rather than 0.2.1. - The book is reorganised into Getting started, Things to keep in mind, How-to and
Reference; the 0.2.0 page names redirect. The README is the front door only, with the
quick example on
ActiveControl.
0.2.0 - 2026-09-09
Added
- Target control (
robot::target_control):Robot::start_cartesian_target_controlandRobot::start_joint_target_controlrun the crate’s control loop on a named thread of their own and return aCartesianTargetControl/JointTargetControlhandle whoseset_position([f64; 3])/set_joints([f64; 7])any low-rate commander can call from any thread at any rate; the loop bridges the steps with the online trajectory generator under the three rules of theotgmodule (per-axis limits from the norm budget, one nominalDELTA_Tper command, re-anchoring on the robot’s echo), the rate limiter under the same budget as the backstop, a measured-deviation guard and a settle-then-finishstop()that returns the loop’s result.TargetControlOptions/JointTargetControlOptionscarry the budget (joint default: 20 % of the negotiated version’s limits), the controller mode, the guard, the settle criterion, an optionalSCHED_FIFOpriority for the loop thread and an observer called every cycle on the realtime thread with what was sent (the flight recorder’s hook).TargetSlot<N>is the seqlock underneath, public.MultiOtg::with_limitsbuilds a generator with per-axis limits andrealtime::set_current_thread_scheduler_priorityraises a thread to a chosen priority. Tested on franka-sim (tests/sim_target_control.rs) and run on a real FER and an FR3. - Cartesian target control carries an orientation.
CartesianTargetControl::set_pose(column-major, asO_T_EE; a rotation block within 1e-3 of orthonormal is repaired, one further off refused),set_target(position, quaternion)andset_orientation(quaternion)with unit quaternions in[x, y, z, w]order,target_orientation()andtarget_pose();set_positionkeeps the target orientation. The orientation runs on three more axes of the same synchronised generator, on the base-frame rotation vector of the orientation error re-anchored on the echo every cycle, underTargetControlOptions::rotation_limits(0.5 rad/s, 1.0 rad/s², 20 rad/s³ by default) withwith_rotation_limits, and an angular deviation guardmax_angular_deviation(0.5 rad).CartesianSentgains the sent orientation, the angular velocity and acceleration and the rotational backstop alteration. The Cartesian backstop now references the twist and acceleration it sent rather than the echoed ones, whose float32 rounding had its jerk clamp firing at noise level (on the rotation that was an orbit of 5 mrad around the target), andREST_VELOCITY/REST_ACCELERATIONdrop to 1e-4 and 0.05 so the hold’s freeze stays under the joint-side jerk limits.OtgLimits::scaledis new. The commander example’s--rotateadds a slow yaw sweep of ±15° (bridged mode only). otgmodule: an online trajectory generator (Otg,MultiOtg<N>,CartesianOtg,OtgLimits) that re-plans a time-optimal, jerk-limited seven-segment profile every cycle from the commanded state to rest at the latest target, so a stream of stepped, bursty or stalled targets becomes a C2 command that never exceeds its velocity, acceleration and jerk limits, does not overshoot a reachable target, lands exactly and stays there; optional axis synchronisation,set_positionto re-anchor on the robot’s echo,per_axis_for_normfor a budget that is a norm. Dependency-free and allocation-free. 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_commanderexample: a scripted (or stdin) commander sets Cartesian targets throughstart_cartesian_target_control(--bridged,--budget V,A,J), or hands them to a barecontrol_cartesian_poseto provoke a reflex (--raw);--logwrites one CSV row per cycle, from the loop’s observer, with the joint angles, the external wrench and the generator’s velocity and acceleration. The budget exists because the robot also checks the joint-space continuity of a Cartesian pose stream, which the rate limiter does not bound; see Target control.franka-rerun’scommander_liveexample is on the same API, with theRecorderin the observer.- Python bindings (
crates/franka-py,import franka;pip install franka-rs, ormaturin developfrom the source tree):Robot,RobotState(numpy fields and a 69-floatflat()observation),robot.cartesian_targets()/robot.joint_targets()as context managers over the target control loops withmove_to,move_by,follow(chunk, dt),target(),state()andstop(),Gripper,Modelover numpy (robot.model()),franka.rotated, andFrankaError/ControlException. A Cartesian target carries an optional unit quaternion, a delta an optional rotation vector. The 1 kHz loop stays on its Rust thread and never takes the GIL. PyO3 0.29, abi3 for Python 3.9+; tested against franka-sim in CI’spython-bindingsjob and run on a Panda (crates/franka-py/examples/policy_loop.py;rotate.pyand thequickstart.ipynbnotebook are the other two examples). See Python..github/workflows/release.ymlbuilds the wheels and publishes them and the crate on av*tag. automatic_error_recoveryexample: command-line recovery that prints the robot mode before and after.move_to_readyexample: the examples’ motion generator to libfranka’s ready pose at a fraction of full speed.serdefeature (off by default):Serialize/DeserializeforRobotState,RobotMode,Errors,Duration,Record,RobotCommandLog,MoveStatusandControlException.Errorsserialises as the list of the set flags’ names.franka-rerun, a workspace crate (publish = false, Rust 1.96) that replays logs in Rerun:csvturns the commander example’s log into target, commanded and measured positions, the command’s derivatives against the rate limits and a 3D replay of the arm;logreplays a savedControlExceptioncontrol log as a flight recording (contact and collision flags, external wrench, commanded versus measured, errors, the arm), throughflight::{log_records, replay_exception, save_records, load_records}; andRecorderstreams the same live from inside a control loop with a non-blocking, non-allocatingpush.examples/reflex_replay.rsputs the last two together;examples/commander_live.rsstreams the commander into a viewer. Both replays draw Franka’s link meshes with--meshes DIR,csvhas the screen-capture--layout demoand the commander’s--budgetlines, andloglocates a contact on the arm from the external joint torques (flight::contact). See the flight recorder page.
0.1.0 - 2026-09-07
Initial release. franka-rs is a pure-Rust libfranka client speaking both
generations of the Franka Control Interface: FCI v10 (Franka Research 3,
ported from libfranka 0.21.2) and FCI v5 (Franka Emika Robot, FER, ported from
libfranka 0.9.2).
There is no earlier release to compare against, so “Added” describes the release itself and “Changed” records the places where this crate deliberately differs from libfranka.
Added
- Franka Emika Robot (FER) support (FCI v5), alongside the FR3 (FCI v10); see
the FER specifics page.
Robot::newnegotiates the version automatically (connects announcing v10, retries as v5 if the robot reportskIncompatibleLibraryVersion);RobotOptions::with_version(VersionPolicy:: Exact(FciVersion::V5))skips the extra round trip when the generation is already known. New public API:FciVersion,VersionPolicy,RobotOptions,VirtualWallCuboid,Robot::{with_options, fci_version, set_filters, virtual_wall},Model::{from_model_library_bytes, from_model_library_path},model::{load_from_robot, SoModelBackend, model_library, so_backend},RobotState::from_wire_v5,rate_limiting::fer(selected automatically from the negotiated version),wire::robot::{v5, codec}. New examplefer_joint_impedance. - The FER model no longer needs a download.
Robot::load_model()on an FER now returns the native model —franka::model::FER_URDF, evaluated by the same backend the FR3 uses — instead of downloading anddlopening the robot’s shared object. Needs no network, nomodel-libraryfeature and no x86-64 Linux host; also reachable without a robot asModel::native_fer(). Its link inertial parameters were identified from a real FER’s own model library by the newtools/fer-model-fit(a regressor from the crate’s own dynamics backend, solved with a truncated SVD), agreeing with the library to 9e-16 on kinematics and 4e-14 on dynamics with no payload, and characterising the one known gap, a payload non-linearity in the library itself (see the model page).Robot::load_model_from_robot()keeps the previous behaviour (download +dlopenon v5, unchangedGetRobotModelURDF on v10). The native model is checked in CI viatests/fer_native_conformance.rsagainst a committed fixture, with no robot or shared object present. - aarch64 builds, via
cargo-zigbuildor a crossgcc, or as a fully staticaarch64-unknown-linux-muslbinary with--no-default-features(a static binary can’tdlopen, and the FER’s model path is x86-64-only anyway). CI cross-builds foraarch64-unknown-linux-gnuon every push; verified underqemu-aarch64against both simulators, identical to native. - New example
cartesian_impedance_active_control— libfranka’scartesian_impedance_control.cpp(a spring-damper system whose equilibrium is the initial end-effector pose) driven through theActiveControlAPI’sread_once/write_onceinstead of a control callback, allocation-free and running unchanged on an FR3 (FCI v10) and an FER (FCI v5). - New example
cartesian_impedance_figure_eight— the sameActiveControlimpedance loop with a moving equilibrium: a Lissajous figure eight around the start pose, raised-cosine ramps on both the path amplitude and the stiffness at either end (and after Ctrl-C), a nullspace joint spring toward the initial configuration through a damped-inverse projector, a one-sided virtual floor, a ±25 Nm torque clamp and a deviation cut-out. Allocation-free, on both generations. The pieces it shares withcartesian_impedance_active_controlmoved toexamples/common/cartesian.rs. - New example
dual_communication_test— the zero-torque communication-test loop against two robots at once from one process, with per-robot loop timing, lost-state and success-rate accounting. bench/fer-capture/— offline tooling that reassembles the FCI v5 state datagrams from a pcap/pcapng capture and reports inter-arrival gaps, drift and a stall verdict, to tell whether an FER loop’s lost cycles come from the robot/network or the host.bench/so-micro/— an offline per-call microbenchmark of the FCI v5 model path, Rust vs C++, against the same captured shared object, needing no robot or simulator.
Changed
CommandName::command()is nowCommandName::command(self, version: FciVersion) -> Option<u32>, since the TCP command numbering differs between the two protocol versions (Nonemeans the command does not exist in that version);CommandNamegained two v5-only variants,GetCartesianLimitandSetFilters.SoModelBackend::open,SoModelBackend::from_bytes,Model::from_model_library_bytesandModel::from_model_library_pathare nowunsafe fn: theydlopenthe file or bytes given to them, so the caller must assert it is a trusted model library.Robot::load_model()stays a safefn, as in libfranka.- A
Connectreply with statuskSuccessreporting a version other than the one the client announced is nowFrankaError::IncompatibleVersionrather than being decoded against the wrong layout — what lets automatic version negotiation recognise an FER on the simulator; no effect against a real FR3 or FER.
Contributing
The checks
Everything CI’s check job runs, in order — none of it needs Docker, a network or a robot:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo clippy --workspace --all-targets --no-default-features -- -D warnings
cargo clippy -p franka-rs --all-targets --no-default-features -- -D warnings
cargo clippy -p franka-rs --all-targets --features serde -- -D warnings
cargo 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.