Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3. Node and laptop client

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

Install franka-node on the Pi

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

cargo binstall franka-node

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

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

cargo install franka-node --locked

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

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

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

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

A minimal configuration

Save this as node.toml on the Pi:

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

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

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

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

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

Start the node and check it

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

franka-node node.toml

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

Install the Python client on the laptop

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

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

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

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

Read the state, no motion

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

import franka_node

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

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

First motion (moves the arm)

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

import franka_node

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

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

Run the node as a service

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

Install the binary, the configuration and the unit

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

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

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

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