FrankaController

The controller API, which NativeFrankaController inherits. FrankaController itself runs the 1 kHz loop in Python, on the event loop: that loop is legacy, so use NativeFrankaController (see NativeFrankaController).

class aiofranka.controller.FrankaController(robot)[source]

Bases: object

High-level asyncio controller for Franka robots with multiple control modes.

Legacy: its 1 kHz loop runs in Python, on the event loop, where anything else that runs (a planner, garbage collection, a thread holding the GIL) delays the next torque command. Use NativeFrankaController, which has this API and runs the loop in C++; FrankaController keeps working for existing code, and for a subclass’s step().

This controller runs a 1kHz torque control loop in the background using asyncio, while allowing you to send high-level commands asynchronously. Supports three control modes:

  1. Impedance Control: Joint-space spring-damper control

  2. Operational Space Control (OSC): Task-space control with null-space

  3. Direct Torque Control: Raw torque commands

The controller automatically handles: - Background control loop at 1kHz - Torque rate limiting for safety - Thread-safe state updates - Rate-limited command updates - Smooth trajectory generation

Parameters:

robot (RobotInterface)

robot

Robot interface instance

Type:

RobotInterface

type

Current controller type (“impedance”, “osc”, “torque”)

Type:

str

running

Whether control loop is active

Type:

bool

state

Current robot state (updated at 1kHz)

Type:

dict

# Impedance control gains
kp

Joint position stiffness [Nm/rad] (7,)

Type:

np.ndarray

kd

Joint damping [Nm⋅s/rad] (7,)

Type:

np.ndarray

# OSC gains
ee_kp

EE stiffness as an acceleration gain [1/s²] (6,): the OSC commands the wrench Λ·(ee_kp·e − ee_kd·v), with Λ the task-space inertia, so ee_kp = ω² and ee_kd = 2ζω for a natural frequency ω and damping ratio ζ

Type:

np.ndarray

ee_kd

EE damping as an acceleration gain [1/s] (6,)

Type:

np.ndarray

null_kp

Null-space stiffness [Nm/rad] (7,)

Type:

np.ndarray

null_kd

Null-space damping [Nm⋅s/rad] (7,)

Type:

np.ndarray

# Target states
q_desired

Desired joint positions [rad] (7,)

Type:

np.ndarray

ee_desired

Desired pose of the OSC control frame as 4x4 transform

Type:

np.ndarray

torque

Direct torque command [Nm] (7,)

Type:

np.ndarray

control_transform

Tool center point that the OSC controls, in the flange frame, 4x4 (default: identity, i.e. the flange); see set_tcp()

Type:

np.ndarray

last_command

Last torque sent to the robot, after rate limiting and clipping [Nm] (7,)

Type:

np.ndarray

# Safety
clip

Enable torque rate limiting (default: True)

Type:

bool

torque_diff_limit

Max torque rate [Nm/s] (default: 990)

Type:

float

Examples

Basic usage:
>>> robot = RobotInterface("172.16.0.2")
>>> controller = FrankaController(robot)
>>> await controller.start()
>>> controller.switch("impedance")
>>> controller.set_freq(50)
>>> await controller.set("q_desired", target_joints)
>>> await controller.stop()
OSC control:
>>> controller.switch("osc")
>>> controller.ee_kp = np.array([300, 300, 300, 1000, 1000, 1000])
>>> controller.set_freq(50)
>>> desired_ee = np.eye(4)
>>> desired_ee[:3, 3] = [0.4, 0.0, 0.5]
>>> await controller.set("ee_desired", desired_ee)
Direct torque:
>>> controller.switch("torque")
>>> controller.torque = np.zeros(7)  # Zero torques
Caveats:
  • Must await controller.start() before sending commands

  • Use set_freq() before set() to enforce timing

  • High gains can cause instability or safety triggers

  • Switching controllers resets initial state

  • State access is thread-safe but copy if modifying

  • Control loop must run continuously at ~1kHz

__init__(robot)[source]

Initialize controller with robot interface.

Parameters:

robot (RobotInterface) – Initialized robot interface

Note

Controller is initialized in “impedance” mode with conservative gains. Call start() to begin the control loop, then switch() to change modes.

Default Gains:
  • Impedance: kp=80, kd=4 (per joint)

  • OSC: ee_kp=[100]*6, ee_kd=[4]*6

  • Null-space: null_kp=1, null_kd=1

async test_connection()[source]

Test control loop timing and diagnose connection quality.

Runs for 5 seconds and prints statistics about control loop performance: - Actual frequency (should be ~1000 Hz) - Mean/std/min/max loop time - Jitter (max - min)

Use this to verify your setup is working correctly before running experiments.

Example Output:
Control loop stats (last 1000 iterations):

Frequency: 1000.2 Hz (target: 1000 Hz) Mean dt: 1.000 ms, Std: 0.015 ms Min dt: 0.985 ms, Max dt: 1.025 ms Jitter (max-min): 0.040 ms

Caveats:
  • High jitter (>0.5ms) indicates system load or network issues

  • Frequency <990 Hz suggests performance problems

  • Run on a dedicated realtime system for best results

initialize()[source]
set_freq(freq)[source]

Set the update frequency for rate-limited set() calls.

This enforces strict timing for subsequent set() calls, automatically sleeping to maintain the specified frequency. Prevents sending commands too fast and ensures smooth, consistent control.

Parameters:

freq (float) – Desired update frequency in Hz (typically 10-100 Hz)

Note

Must be called BEFORE the first set() call to take effect. Each attribute tracked by set() has independent timing.

Examples

>>> controller.set_freq(50)  # 50 Hz updates
>>> for i in range(100):
...     await controller.set("q_desired", compute_target())
...     # Automatically sleeps to maintain 50 Hz
Caveats:
  • Don’t set freq > 200 Hz (unnecessary and may cause timing issues)

  • Lower freq = smoother motion but slower response

  • Higher freq = faster response but requires more computation

  • Timing is per-attribute (q_desired and ee_desired tracked separately)

async set(attr, value)[source]

Rate-limited setter that enforces strict timing for control updates.

This method ensures updates to controller attributes happen at the frequency specified by set_freq(). It compensates for drift by tracking the target time for each update, guaranteeing consistent timing even if your computation time varies.

Parameters:
  • attr (str) – Attribute name to set. Common values: - “q_desired”: Joint position target (impedance mode) - “ee_desired”: End-effector pose target (OSC mode) - “torque”: Direct torque command (torque mode)

  • value – Value to set. Type depends on attr: - q_desired: np.ndarray (7,) [rad] - ee_desired: np.ndarray (4, 4) homogeneous transform - torque: np.ndarray (7,) [Nm]

Note

  • Automatically sleeps to maintain frequency set by set_freq()

  • First call for an attribute initializes timing

  • Each attribute has independent timing tracking

  • Thread-safe update of shared state

Examples

Impedance control:
>>> controller.set_freq(50)
>>> for i in range(100):
...     target = initial_q + np.sin(i / 50.0 * np.pi) * 0.1
...     await controller.set("q_desired", target)
OSC control:
>>> controller.set_freq(100)
>>> desired_ee = np.eye(4)
>>> desired_ee[:3, 3] = [0.5, 0.0, 0.3]
>>> await controller.set("ee_desired", desired_ee)
Caveats:
  • Must call set_freq() before first set() call

  • If computation takes longer than 1/freq, timing will slip

  • Don’t mix set() and direct attribute assignment

  • Don’t call set() faster than the specified frequency

async start()[source]

Start the 1kHz background control loop.

This creates an asyncio task that runs the control loop continuously at ~1000 Hz. The loop reads robot state, computes control torques based on the current controller type, and sends torque commands.

Returns:

The background control loop task

Return type:

asyncio.Task

Note

  • Blocks for 1 second to ensure loop starts successfully

  • Starts robot torque control mode automatically

  • Control loop runs until stop() is called

  • You can send commands via set() while loop is running

Example

>>> await controller.start()
>>> # Control loop now running in background
>>> controller.set_freq(50)
>>> await controller.set("q_desired", target)
>>> await controller.stop()
Caveats:
  • Must be awaited (async function)

  • Don’t call start() multiple times without stop()

  • If loop crashes, robot will trigger safety stop

  • Check terminal for error messages if robot stops unexpectedly

async stop()[source]

Stop the background control loop and robot.

Gracefully terminates the control loop task and stops robot torque control. Robot will hold position briefly then release brakes.

Note

  • Blocks for 1 second to ensure clean shutdown

  • Cancels asyncio control loop task

  • Stops robot torque control mode

  • Safe to call multiple times

Example

>>> await controller.start()
>>> # ... do control ...
>>> await controller.stop()
Caveat:

Ensure robot is in a safe configuration before stopping. Robot will briefly hold position then release brakes.

switch(controller_type)[source]

Switch between control modes at runtime.

Changes the active controller without stopping the control loop. Resets the initial state (initial_qpos, initial_ee) to current robot state and clears rate-limiting timing.

Parameters:

controller_type (str) – Controller type to switch to: - “impedance”: Joint-space impedance control - “pid”: Joint-space PID control with integral term - “osc”: Operational space control (task space) - “torque”: Direct torque control

Example

>>> controller.switch("impedance")
>>> controller.kp = np.ones(7) * 80
>>> # ... run impedance control ...
>>>
>>> controller.switch("osc")
>>> controller.ee_kp = np.array([300, 300, 300, 1000, 1000, 1000])
>>> # ... run OSC control ...

Note

  • Can be called while control loop is running

  • Resets q_desired to current position

  • Resets ee_desired to current pose of the control frame

  • Clears timing state from previous set() calls

  • Resets integral term when switching to/from PID

Caveats:
  • Switching causes brief discontinuity in control

  • Adjust gains after switching for smooth transition

  • Don’t switch rapidly (< 1 Hz) as it resets state

set_tcp(transform)[source]

Set the tool center point (TCP) that the OSC controls, relative to the flange.

The OSC then tracks ee_desired with this frame instead of the flange. Express it in the flange frame (the attachment_site of the MuJoCo model): its origin is the center of the flange face, z points out of the flange, and the box on the side of link 7 points between +x and +y. The docs show it on the robot (Controllers > Operational Space Control > Tool Center Point). ee_desired becomes the current pose of the new TCP, so the arm holds still.

The TCP of the end-effector profile in Desk is not used here.

Parameters:

transform (array-like) – Pose of the TCP in the flange frame as a 4x4 transform, or its translation [m] (3,) for the flange’s orientation

Raises:

ValueError – If transform is not a translation or a 4x4 pose

Example

>>> controller.switch("osc")
>>> controller.set_tcp([0, 0, 0.1034])  # Franka Hand fingertips
>>> target = controller.ee_desired.copy()
>>> target[2, 3] -= 0.05  # 5 cm down, in the base frame
>>> await controller.set("ee_desired", target)
check_tool(config)[source]

Check that the tool a configuration needs is the end-effector profile active in Desk.

The profile is the one RobotInterface read from Desk when it connected. A configuration without a tool, and MuJoCo, which has no Desk, pass.

Parameters:

config (str | Path | dict) – Controller configuration (see aiofranka.config)

Raises:

RuntimeError – If another tool is active, or Desk could not be read

activate(config, check_tool=True, check_null_target=True)[source]

Apply a controller configuration: its mode, gains and policy rate.

For impedance it sets kp and kd; for osc the TCP, ee_kp, ee_kd, null_kp, null_kd and the null-space target. It then sets the rate of set() to the configuration’s frequency. Like switch(), it holds the current joint positions (impedance) or TCP pose (osc). See aiofranka.config for the file format.

An OSC configuration without a null_target keeps the joint positions at activation as the null-space target, as switch() does. One with a null_target (e.g. a policy’s) is only activated with the arm already there, every joint within arrival_tolerance, the criterion move() stops at; otherwise the null space would swing the arm toward it at once. Move there first:

>>> config = aiofranka.load_config("configs/pocky/lv1_osc.yaml")
>>> await controller.move(config["null_target"])
>>> controller.activate(config)

Reading a file takes a few milliseconds, which delays the 1 kHz loop that long; while it runs, pass a configuration read before with aiofranka.load_config().

Parameters:
  • config (str | Path | dict) – YAML file, or its contents

  • check_tool (bool) – Refuse a configuration whose tool is not the end effector active in Desk (see check_tool())

  • check_null_target (bool) – Refuse an OSC configuration whose null_target the arm is not at. Only skip it where the null space is known to be at rest, e.g. at a pose where the TCP Jacobian’s null space holds no error toward the target.

Returns:

The configuration, as aiofranka.load_config() reads it

Return type:

dict

Raises:
  • ValueError – If the configuration is not valid

  • RuntimeError – If its tool is not the one active in Desk, or the arm is not at its null_target

Example

>>> controller.activate("configs/osc.yaml")
>>> target = controller.ee_desired.copy()
>>> target[2, 3] -= 0.05
>>> await controller.set("ee_desired", target)  # at the configured frequency
step()[source]
async move(qpos=[0, 0, 0.0, -1.57079, 0, 1.57079, -0.7853])[source]

Move robot to target joint position using smooth trajectory.

Generates a time-optimal, jerk-limited trajectory using Ruckig online trajectory generation, then executes it at 50 Hz. Automatically switches to impedance mode if not already active.

Parameters:

qpos (list | np.ndarray) – Target joint positions [rad] (7,) Default: Home position

Note

  • Uses Ruckig for smooth, time-optimal trajectories

  • Respects velocity, acceleration, and jerk limits

  • Switches to impedance control automatically

  • Executes trajectory at 50 Hz (20ms updates)

  • Duration depends on distance and limits

Trajectory Limits:
  • Max velocity: 10 rad/s per joint

  • Max acceleration: 5 rad/s² per joint

  • Max jerk: 1 rad/s³ per joint

Examples

Move to home position:
>>> await controller.move()
Move to custom position:
>>> target = [0, -0.785, 0, -2.356, 0, 1.571, 0.785]
>>> await controller.move(target)
Move to current position + offset:
>>> current = controller.state['qpos']
>>> await controller.move(current + np.array([0.1, 0, 0, 0, 0, 0, 0]))
Caveats:
  • Large motions take longer (trajectory is time-optimal)

  • Don’t call while other control is active

  • Blocks until motion completes: until every joint is within arrival_tolerance (0.03 rad by default) of the target, or 3 s after the trajectory if friction keeps it farther (it then prints how far)

  • Switches to impedance mode (resets controller state)

  • May fail if target is at joint limits or in collision

async identify_payload(tool_length=0.2, tool_radius=0.1, floor=0.0, **kwargs)[source]

Identify the mass and center of mass of the tool on the flange.

Moves through poses around the current one, measures the joint torques at rest that the robot does not compensate, and fits the tool to them. It only measures: save the result with aiofranka.save_tool() and activate it with aiofranka.load_tool(). See aiofranka.payload.identify_payload() for the details and further arguments.

Parameters:
  • tool_length (float) – Length of the tool from the flange [m], for the collision checks (default: 0.2)

  • tool_radius (float) – Radius of the tool [m] (default: 0.1)

  • floor (float) – Height of the floor or table in the base frame [m] (default: 0.0)

  • **kwargs – Further arguments of aiofranka.payload.identify_payload()

Returns:

Estimated tool, with standard errors

Return type:

PayloadEstimate

Example

>>> estimate = await controller.identify_payload(tool_length=0.25)
>>> aiofranka.save_tool("gripper", estimate.mass, estimate.com)
>>> aiofranka.load_tool("gripper")