Skip to content

Architecture

Two processes plus the contract between them.

┌─────────────────────────┐        ZMQ (CONFLATE=1)         ┌──────────────────────────┐
│   Workstation (Python)  │   ─── Cap'n Proto Command ──→   │     NUC (C++ daemon)     │
│                         │                                 │                          │
│   fr3_stack.Robot       │   ←── Cap'n Proto State    ───  │   libfranka @ 1 kHz RT   │
│   fr3_stack.Arm         │                                 │   chosen controller      │
└─────────────────────────┘                                 └──────────────────────────┘
  • Daemon (C++) runs on the FR3 NUC. Owns libfranka's 1 kHz real-time control loop, the optional Bota EtherCAT F/T worker, and two ZMQ sockets (command in, state out).
  • Client (Python) runs on a workstation. Sends commands; reads state at ~200 Hz.
  • Wire is proto/fr3.capnp — included by both sides at build/import time. Edits propagate to both.

The motion/state ZMQ sockets use CONFLATE=1 (latest-wins), so a slow consumer never backs up the RT loop and a slow producer never freezes the controller.

Recording uses a separate, acknowledged REQ/REP service on port 5557 and a non-RT service thread. Robot.start_recording, stop_recording, and recording_status do not enter the motion dispatcher. The manager owns a permanent SPSC ring; a writer thread owns each CSV. Stop disables capture and waits for an in-flight push outside RT before draining/closing. The RT callback only checks atomic flags and, when enabled, captures/pushes a fixed-size frame. The same daemon can record multiple files without a controller reset or restart. See Recording.

Repo layout

fr3-stack/
├── proto/fr3.capnp                       # wire contract
├── include/fr3_stack/                    # C++ headers
│   ├── controllers/                      #   one .hpp per controller
│   ├── utils/                            #   RT-safe inline helpers
│   ├── sensors/                          #   WrenchSource + Bota + payload-calib decorator
│   └── motion_generator.hpp              #   min-jerk planner (MoveTo)
├── src/                                  # C++ implementations
│   ├── main.cpp                          #   daemon entry — ZMQ + RT loop
│   ├── controllers/*.cpp                 #   controller bodies → fr3_controllers lib
│   ├── bin/*.cpp                         #   standalone demo binaries
│   └── sensors/                          #   Bota glue + payload_calib.cpp
├── fr3_stack/                            # Python client package
│   ├── robot.py                          #   streaming wire (Robot, send_*)
│   ├── client.py                         #   Arm — pose-centric facade
│   ├── configs/*.yaml                    #   per-controller default gains
│   └── sensors/bota/                     #   FT calibration + publish tools
├── tests/                                # pytest + g++ math mock
└── examples/                             # smoke tests on real FR3

Two API layers

Class Module Role
Robot fr3_stack.robot Streaming wire — one send_* per command. Direct access to every wire field.
Arm fr3_stack.client Pose-centric facade for inference / teleop loops. Composes a Robot; arm.robot is the escape hatch.

Arm exposes the small surface inference scripts actually use (observe / send / move_to / hold / set_stiffness / use_profile). Anything richer — explicit hybrid wrenches, raw nullspace tuning — drops through to arm.robot.

RT loop, in one paragraph

src/main.cpp opens the libfranka control channel and calls robot.control(callback) with a 1 kHz callback. Each tick the callback (a) grabs the latest command from a lock-free queue fed by the ZMQ receive thread, (b) calls the active controller's update(state) to produce τ, (c) returns τ to libfranka. A separate state-publisher thread reads the cached RobotState and emits State messages at ~200 Hz. Switching controllers calls reset(state) on the new one so it re-anchors at the live pose — no jump.

Conventions

  • Wire strings are stable. The capnp union arm names are the contract — internal C++ class names can move freely.
  • include/ = declarations; src/ = implementations. Templates and small inline helpers in headers are the exceptions.
  • Templated, RT-safe utilities. Anything called inside the libfranka callback uses fixed-size Eigen and templates — no heap allocations per tick.
  • Torque rate limit. The final stage clamps \(|\Delta\tau| \le 1\;\text{N·m/ms}\) regardless of which controller is active, so target steps and controller switches never hit the motor driver as raw discontinuities.