C++17 / Python SDK for the TacCap-Gripper — XenseRobotics' multimodal
tactile data-collection gripper. One namespace, xense::taccap:: in C++ and
xense.taccap in Python.
One implementation, two languages. The whole SDK is C++17; the Python package is a pybind11 binding over that same code, not a second port. A fix lands once and both sides get it, and a behaviour you measure from Python is the behaviour C++ has.
The host never touches the motor directly: it speaks the TC-GU-01 serial
protocol to the gripper's MCU, and the MCU relays to the RobStride motor over
FDCAN.
Every Motor call in this SDK is therefore a command to the MCU, not a CAN
write — which is why the MCU can enforce limits the host cannot bypass.
Four layers, each usable on its own. L1 is the TC-GU-01 wire protocol —
byte-stuffed framing, CRC, typed payload codecs. L2 is the async transport that
owns the serial port, matches ACKs to commands by sequence number, and fans
DATA frames out to per-command subscribers. L3 is the typed components
(Motor, Encoder, IMU, Camera, Led, Calibration, Diagnostics). L4
aggregates them into LeaderGripper / FollowerGripper and adds the
background controllers. You can open a Transport and talk frames, or open a
gripper and never see one. See docs/ARCHITECTURE.md.
Two threads, and user code runs on neither of the critical ones. The
transport's reader thread does nothing but read, parse and hand off; a separate
dispatcher thread runs subscriber callbacks. That split is load-bearing: a
Python callback takes the GIL, and a callback that stalls the reader stalls
read() and overflows the kernel tty buffer. Controllers add one more thread,
which submits exactly one command per motor-status frame — writing in the
window the MCU is known to be idle, rather than on a free-running clock.
The firmware owns real-time safety; the host does not keep a second copy. The MCU runs the motion-safety envelope and the stall test at 500 Hz, and it is the only layer on the MIT command path that nothing can bypass. So this SDK has no host-side contact detection and no second stall guard — saturating the controller's torque budget is contact. Where the two could disagree, the device wins. See docs/CONTROL_LAYERING.md.
Drive the motor through a controller. ImpedanceController follows a
position, ForcePositionController grasps; both expose the same two
non-blocking calls, set_target(0..1) and snapshot(). The raw submit_*
motor primitives are still there, but they are bare MIT frames with none of the
host-side protection on their path.
Nothing is configured per unit. Sides, roles and calibration come off the device: discovery reads the firmware-burned serial (never the CH343 USB-chip serial), and the fisheye intrinsics and travel span live in MCU flash, so a gripper carries its own identity and calibration between benches.
What is deliberately out of scope. Visuotactile (OG) capture belongs to the
xensesdk wheel, not here — xense.taccap is gripper protocol plus wrist
camera. Teleoperation loops, grasp policy and episode recording live in the
consuming application; this SDK provides the real-time primitives, not the
policy. The wrist Camera is opt-in for the same reason: an external camera
service usually owns the V4L2 devices, so the gripper aggregates do not open it
unless you pass open_cameras=True.
A fork of lerobot-xense consumes this SDK through a taccap_gripper robot
class. It only imports xense.taccap, reimplements no device access, and is
not required to use this SDK.
- TC-GU-01 protocol — async transport, ACK matching, per-command DATA subscribers, byte-stuffed framing.
Motor— enable / disable / clear-fault, four control modes, blockingset_*and no-ACKsubmit_*.ImpedanceController— follow a position (teleoperation, leader-follower).ForcePositionController— grasp, with the force and the speed as the two knobs.- Normalized position on both roles:
[0, 1], 0 = closed, 1 = open, on one-shot reads and on every streamed sample. - IMU, encoder, LEDs, button, and power-on auto-calibration.
- Wrist camera with fisheye undistortion from the unit's own stored intrinsics.
Calibration— the flash-persisted fisheye and encoder-max records. See docs/CALIBRATION.md.Diagnostics— the firmware's own UART counters, which tell a frame the MCU never sent from one lost on the way.- Motor model and motor OTA (follower) —
Motor.get_model/set_modelrecord which RobStride motor is installed (firmware 1.2.7+),Motor.can_ext_xferrelays extended CAN frames to it (1.2.8+), andMotorOtaSessionuses that relay to flash the motor's own firmware. - OTA and zero-config discovery by firmware-burned SN.
Visuotactile (OG) capture lives at the Python level via the xensesdk wheel —
xense.taccap is the gripper-protocol + wrist-camera surface only.
Full per-commit history in CHANGELOG.md.
A gripper runs its own firmware, and this SDK requires a recent one.
FollowerGripper refuses to open a follower below 1.2.5 — it throws rather
than warns, because older firmware lacks stall protection on the control path
and puts normalized 0.0 somewhere other than the closed stop.
Config::allow_outdated_firmware=True inspects such a device without driving
it. Leaders are not gated. The motor inside has a floor of its own; see
RobStride motor firmware.
Flashable images ship in firmware/ — the firmware source
does not. Pick the image by the gripper's role, which is the last character of
its firmware SN (m master, s slave), not by which hand it is on:
python python/examples/ota_update.py slave left # role selector picks the image
python python/examples/ota_update.py --all # every attached gripperFlashing the wrong role's image bricks the MCU and needs an SWD probe to recover, so check the SN before you flash.
Power-cycle after any flash. On a follower, unplug the 24 V power
cable, wait ~2 s and plug it back; the USB cable can stay in. The follower's
MCU and motor run on 24 V, so this restarts both — and pulling only USB does
not reset a follower. A leader has no 24 V rail: unplug and replug its USB. gripper.device.heartbeat().uptime_ms restarting near 0 confirms it
worked. The bank-swap reboot is a soft reset that leaves the device looking healthy
while quietly dropping status frames.
The two roles carry independent version numbers. At the time of writing the
leader is 1.2.6 and the follower 1.2.14; neither is behind the other, and
gripper.firmware_version returning different numbers for the two halves of a
pair is normal. Compare versions only within a role — the floors above are
follower numbers. A leader reporting 1.2.6 may be either of two images: the
current one, with the control-UART receive fix, or one from the period when the
roles were forced onto one number, which is 1.2.4 code without it. The number
cannot tell them apart; g.diagnostics.uart_stats() answering 44 bytes (so
rx_errors / rx_rearms are meaningful) is the current one — reflash when in
doubt. On the follower, 1.2.7 added the motor model record, 1.2.8 the CAN
extended-frame relay used by motor OTA, 1.2.9 makes the motor ranges and limits
follow the recorded model, 1.2.10 records the model by itself from the motor's
firmware version the first time a new motor boots, 1.2.11 fixes a command
channel that could go silent for good after one UART overrun (the SDK warns when
it opens anything older), and 1.2.12/1.2.13 make the calibration torque, the
open direction and a default motion envelope follow the model.
firmware/README.md has the image table, CRC32 values and
the rest of the flashing detail.
This is the RobStride motor inside the follower — an EL05 or an RS00 — not
the gripper MCU. The SDK itself does not enforce a motor version (the
factory test tool does, against the floors below). Reading it needs follower
firmware 1.2.6+ (motor.motor_version(), command 0x58), and under the MIT
protocol the motor was measured not to answer extended frames at all, so before
follower 1.2.14 the version is readable only under the private protocol.
Since follower 1.2.14 the version is recorded in the follower's flash, so it
reads under MIT too: motor.motor_version() returns the record when the motor
cannot answer, with from_flash / source saying so (1 = written by a host,
2 = taken by the firmware on a private-protocol boot). The firmware records it
by itself whenever the motor boots on the private protocol (a new motor always
does). For units already on MIT, record it as a factory SOP step with
motor.set_motor_fw_version(...), typing what the nameplate says -- "1.0.5.0.4"
on an EL05, "0.0.3.32" on an RS00 -- and again after every motor OTA.
MotorVersion.vendor_str shows it the same way. The write echoes the flash record and raises on a mismatch; reading never
writes flash. Older follower firmware keeps reporting valid == 0 under MIT.
Required motor firmware: EL05 1.0.5.0.4 or newer, RS00 0.0.3.32 or newer.
The two lines are numbered independently and are not comparable. Below the
floor a unit fails factory test.
Measured before and after upgrading the same unit: on 1.0.5.0.2 the velocity
feedback read as a constant that did not track motion, with 117% motion ripple;
on 1.0.5.0.4 the same unit behaved normally. Because it is the same device
before and after, this is not a unit-to-unit difference — but causation is not
proven, and the corrupted velocity feedback is the part worth remembering:
snapshot().observation.velocity and anything derived from it goes wrong while
looking merely odd rather than broken.
Flashing the motor over the gripper's USB-C. Follower firmware 1.2.8+ relays the motor's own OTA through the MCU, so the motor stays mounted:
python python/examples/motor_ota_update.py rs00-0.0.3.32.bin TCGU01A28Z0086s(MotorOtaSession from code.) The motor must be on the private protocol
first — motor.switch_protocol(MotorProtocol.Private), then cut 24 V for
~2 s (USB can stay in). RobStride's OTA protocol does no model check, so
preflight() refuses an image whose model does not match the model recorded on
the gripper. Afterwards the motor restarts on MIT, so its version is readable
only after switching to private again. A gripper OTA also switches the motor
back to MIT, so flash the gripper first and the motor second. Measured on an
RS00 unit: 0.0.3.22 → 0.0.3.32 removed the motion jitter, leaving
ForcePositionController ripple around 5% at 1.1 rad/s — which is why 0.0.3.32
is the RS00 floor (one unit, before/after; not reproduced by flashing back).
mamba env create -f environment.yml && mamba activate taccap
uv pip install -e . --no-build-isolation
python -c "import xense.taccap as t; print(t.__version__)"uv ships in the env and targets the activated conda env on its own. pip
works too — the flags are the same.
Then wire up the checks once, so they run at commit time rather than only in CI:
pre-commit installWhy --no-build-isolation. environment.yml pins the build dependencies
(pybind11, scikit-build-core) next to the C++ ones (libopencv, spdlog),
and this flag says "build against what the env pins". Without it the installer
builds in a throwaway environment against a pybind11 fetched from PyPI —
verified: pybind11_DIR then points into ~/.cache/uv/builds-v0/... instead of
the env. pybind11 is header-only, so whichever copy is present at build time is
the one compiled into the extension, and this codebase is version-sensitive
there (see python/tests/test_numpy_views.py).
Two gotchas that cost people an afternoon each:
- Install from an activated env, not by absolute path. Otherwise cmake finds
a
ninjaonPATHthat is actually GNU Make and fails with a confusing version error. - A C++ or bindings change is not live in a consumer env until you reinstall
there — the editable install redirects Python sources to the checkout but
keeps serving the compiled extension from
site-packages.
Prerequisites, device permissions, C++-only builds, rebuild/clean and the
PYTHONPATH / LD_LIBRARY_PATH traps: docs/INSTALL.md.
The thirteen scripts in python/examples/ are the fastest way to learn this SDK:
each is the smallest program that exercises one capability, and they are also
the tools we bring hardware up with. Run them first, then write your own against
the same calls.
Every script takes the same positional argument:
python python/examples/follower_status.py left # by side
python python/examples/follower_status.py right
python python/examples/follower_status.py TCGU01A28Z0015s # by firmware SN
python python/examples/follower_status.py # omit when exactly one is plugged inThere is no --side / --sn / --device flag — a rig has a left and a right
of everything, and the side is the only handle that means the same thing for the
gripper, its wrist camera and its tactile pair. Side comes from the
firmware-burned SN (Cmd::GetSn), never the CH343 USB-chip SN. With two
grippers plugged in and no argument, a script refuses rather than guessing which
half of the rig you meant; calibrate.py always requires it, because it writes.
# 1. Is it there, and what is it? (read-only)
python -c "from xense.taccap import scan_grippers
for g in scan_grippers(): print(g.side, g.role, g.firmware_sn, g.mcu_device)"
# 2. Read its state. Every field explained in the script's header. (read-only)
python python/examples/follower_status.py left
# 3. Write the firmware motion-safety envelope. Follower >= 1.2.12 enforces a
# model default when none is stored; older firmware enforces nothing.
python python/examples/impedance_control.py left --show-envelope
python python/examples/impedance_control.py left --set-envelope
# 4. First motion, interactively, with j/k/o/c keys.
python python/examples/gripper_console.py left
# 5. Grasp something.
python python/examples/force_position_control.py left --grasp-torque 1.1Step 3 before step 4 is the load-bearing order — see The motion-safety envelope for what it protects against, and what a follower enforces before you write it.
The motor model (EL05 or RS00) is recorded by the follower itself since
firmware 1.2.10: a new motor boots on the private protocol, and on that boot the
firmware reads the motor's firmware version, records the model and switches the
motor to MIT. Check with g.motor.get_model(). A unit that was already on MIT
before 1.2.10 keeps whatever was recorded; if it reports the compile-time
default (the SDK warns on open), run g.motor.set_model(1) once (0 = EL05,
1 = RS00) and cut 24 V for ~2 s. Since 1.2.12 a change of the recorded model
also resets 0x700B, the auto-calibration torque and the open direction to the
new model's defaults. An RS00 whose record was already RS00 before that keeps
whatever 0x700B it stored (6.0 on units upgraded from 1.2.8 or earlier); raise
it with g.motor.set_startup_limit_torque(14.0) and cut 24 V again. Details in
firmware/README.md.
Control. Both controllers take the same two non-blocking calls,
set_target(0..1) and snapshot():
python python/examples/impedance_control.py left # follow a position
python python/examples/impedance_control.py left --targets 1.0,0.5,0.0
python python/examples/force_position_control.py left # grasp: bounded torque
python python/examples/gripper_console.py left --mode force-position
python python/examples/control_and_read.py left # read state WHILE controlling
python python/examples/control_ripple.py left --controller both # measure tracking qualitycontrol_and_read.py answers the question everyone hits second: during control,
read snapshot() — never motor.read_status(), whose ACK collides with the
control frames on the same serial link.
Calibration — required before normalized 0..1 position means anything:
python python/examples/calibrate.py left # leader: encoder zero + travel span
python python/examples/fisheye_cal.py show left # inspect the flash-persisted records
python python/examples/read_intrinsics.py left --out cal.json # read-only, JSON outCamera and leader:
python python/examples/wrist_camera.py left --undistort
python python/examples/leader_normalized_position.py leftFirmware:
python python/examples/ota_update.py slave left # then power-cycle (follower: cut 24 V)
python python/examples/motor_ota_update.py rs00-0.0.3.32.bin left # the motor itself; private protocolCheck this column before running anything on a rig that matters.
| scripts | |
|---|---|
| Read-only | follower_status, read_intrinsics, wrist_camera, leader_normalized_position, fisheye_cal show |
| Moves the motor | impedance_control, force_position_control, control_and_read, control_ripple, gripper_console |
| Writes flash | calibrate, fisheye_cal set-*, --set-envelope on impedance_control / gripper_console |
| Flashes firmware | ota_update — destructive; afterwards power-cycle — follower: cut 24 V for ~2 s (USB may stay in); leader: replug USB |
| Flashes motor firmware | motor_ota_update — flashes the RobStride motor's own firmware; needs the motor on the private protocol and follower firmware 1.2.8+ |
_target.py is the selector above plus version/colour helpers, imported by
every script but wrist_camera.py. _calib_flow.py is the guided calibration
walkthrough, used by the three scripts that can meet an uncalibrated unit. Both
stay out of xense.taccap deliberately: they prompt on stdin, and a library
that blocks on stdin breaks every headless consumer.
Per-script detail, including what each flag measures, is in
docs/EXAMPLES.md. C++ examples build by default into
build/cpp/examples/ (-DTACCAP_BUILD_EXAMPLES=OFF to skip them); the wheel
build turns them off on its own.
The examples above are thin wrappers over the calls below. Which half you have
decides the API: a leader is the hand-held master you read — encoder,
IMU, opening angle. A follower is the actuated gripper you drive — a motor
behind a controller. The role is the last character of the firmware SN (m
master, s slave), not which hand it is on.
import xense.taccap as t
g = t.LeaderGripper.open() # the one attached gripper; MCU only, camera off
g.start_streaming(imu_hz=100, encoder_hz=100)
enc = g.encoder.on_data(lambda s: print("enc", s.position_rad, s.position))
imu = g.imu.on_data(lambda s: print(s))
# ... do work ...
g.stop_streaming()LeaderGripper.open() throws IoError unless exactly one gripper is attached —
name the device explicitly for a bilateral rig (below). For s.position to mean
anything the unit needs its travel span calibrated; see
Normalized leader position.
Never drive the motor with the raw submit_* primitives: they are bare MIT
frames with none of the host-side protection on their path. Use a controller.
import xense.taccap as t
eps = t.find_follower() # or t.find_left() / t.find_right()
g = t.FollowerGripper(eps.mcu_device)
cfg = t.ForcePositionConfig.for_spec(g.motor.get_spec()) # this device's motor
c = t.ForcePositionController(g, cfg) # grasping: bounded torque
g.motor.clear_fault()
c.start() # START BEFORE ENABLE — start()
g.motor.enable() # validates the motor's stored limit
try:
c.set_target(0.0) # 0 = closed, 1 = open; non-blocking
while True:
s = c.snapshot() # one consistent view, no bus traffic
print(s.state, s.observation.position, s.commanded_torque_nm)
if s.holding or s.arrived:
break
finally:
c.stop() # zero torque, then DISABLES the motorTwo calls are the whole interface: set_target(0..1) and snapshot(), both
non-blocking, safe to stream every frame. Swap in
t.ImpedanceController(g, t.ImpedanceConfig.for_spec(g.motor.get_spec())) —
same two calls — when you want to follow a position rather than grasp.
with t.ForcePositionController(g, cfg) as c: does the start() / stop()
pair for you.
Always build the config with for_spec(). A bare default config carries EL05
numbers: on an RS00 it caps the grip at 1.1 of the 3.6 N·m available, and once
the RS00's 0x700B limit is 14 ForcePositionController.start() raises.
Before driving a follower for the first time, write the firmware motion-safety envelope once (follower 1.2.12+ enforces a model default until you do; older firmware enforces nothing). See The motion-safety envelope and Follower gripper control.
from xense.taccap import FollowerGripper, scan_grippers, Side
eps = scan_grippers() # one USB sweep, no re-probe race
left = next(e for e in eps if e.side == Side.Left)
right = next(e for e in eps if e.side == Side.Right)
g_left = FollowerGripper(left.mcu_device) # or LeaderGripper, per role
g_right = FollowerGripper(right.mcu_device)Each gripper owns its own serial link and background threads, so the two are independent — but give each its own controller; never point two controllers at one gripper.
open() does not touch the camera: an external camera service usually owns the
V4L2 devices. Ask for it explicitly, or open it standalone with t.Camera.
g = t.LeaderGripper(mcu_device, wrist_video="/dev/video2", open_cameras=True)
g.wrist_camera.start(lambda f: print("wrist", f.frame_index))Visuotactile (OG) sensors are read through the xensesdk wheel, not this SDK.
Runnable versions of all of the above are the scripts in Usage above.
The firmware-burned SN encodes both the side and the leader/follower role:
TCGU01 A24 Z 0001 m gripper GSPS01 A24 Z 0001 visuotactile
└─┬──┘ └┬┘ │ └┬─┘ │ (no patch suffix)
product batch│ seq patch product : TCGU01 gripper / GSPS01 sensor
line line : Z = R&D/test, A = production
seq : last digit odd → Left, even → Right
patch : m = Master (leader), s = Slave (follower)
scan_grippers() parses this for every gripper; each GripperEndpoints
carries .side (Side.Left/Right) and .role (Role.Leader/Follower/ Unknown). Pick a unit by side or role:
from xense.taccap import find_left, find_right, find_leader, find_follower, parse_serial
eps = find_leader() # the gripper whose SN patch suffix is 'm'
p = parse_serial("TCGU01A24Z0001m")
print(p.side, p.role, p.valid) # Side.Left Role.Leader Trueparse_serial() degrades gracefully: a legacy (SN000002) or empty SN
still yields a best-effort side (last digit) with role = Role.Unknown
and valid = False.
# Hold the gripper at the desired zero pose (usually fully closed) first.
g_right.encoder.set_zero() # throws ProtocolError on NACK
s = g_right.encoder.read_once()
print(s.position_rad, s.raw_position_rad) # cooked (clamped >= 0) vs rawSee python/examples/calibrate.py for the full interactive walkthrough
(side selection by SN, pre/post drift display, full-open angle sanity
check, live readout).
normalize_position=True reads the firmware's encoder-max calibration
(Cmd::EncoderMaxCal, firmware ≥ V2.1) at open() time and installs the
converter on the encoder, so every sample carries .position in [0,1]:
g = LeaderGripper(mcu_device=dev, normalize_position=True)
s = g.encoder.read_once()
s.position_rad # 0.65 — always radians, meaning never changes
s.position # 0.50 — normalized; float('nan') when the flag is off
g.position() # 0.50 — one-shot read + convert
g.pos_to_rad(1.0) # 1.30 — full open, in raw radians
g.rad_to_pos(0.325) # 0.25
# Streamed samples are normalized too, including subscribers registered
# before the map existed.
g.encoder.on_data(lambda s: print(s.position))
g.start_streaming(imu_hz=0, encoder_hz=100)The travel span is the encoder shaft angle at full open, measured from the
encoder zero (fully closed) — so zero the encoder first, then store the
span. measure-encoder-max walks both steps:
python python/examples/fisheye_cal.py measure-encoder-maxConstruction raises ProtocolError when the span has never been calibrated
(the firmware answers CalNotSet rather than returning a bogus zero) or when
the firmware predates V2.1. Pass encoder_max_rad=<rad> to supply the span
from the host and skip the firmware read entirely.
position() / pos_to_rad() / rad_to_pos() / position_map work without
the flag — it only controls whether EncoderSample.position gets filled in.
Call reload_position_map() after re-calibrating.
Fisheye intrinsics + distortion live in MCU flash and are readable from both leader and follower:
from xense.taccap import CameraFisheyeCal, FisheyeUndistorter
cal = g.calibration.read_fisheye() # None when never calibrated
if cal is not None:
# Prefer FisheyeUndistorter over calling cv2.fisheye.undistortImage with
# cal.K / cal.D by hand: it builds the remap tables once, resamples with
# INTER_CUBIC and applies the same focal-length balance as the PC
# calibration tool, so a frame rectified here matches one rectified there.
# `cal.K` / `cal.D` remain exposed for code that must do its own thing.
undistorter = FisheyeUndistorter(cal, width=640, height=480, balance=0.0)
undistorted = undistorter.apply(img)
g.calibration.write_fisheye(CameraFisheyeCal(
fx=320.5, fy=321.0, cx=319.5, cy=240.2,
k1=-0.031, k2=0.0072, k3=-0.0013, k4=0.0002))The firmware stores the values verbatim — no unit conversion, no clamping,
only NaN/Inf rejection. python/examples/fisheye_cal.py show prints both
records; set-fisheye --from-npz loads K/D straight from an OpenCV
calibration file.
The follower drives a FDCAN motor. Control is the MIT impedance frame — the
force-position hybrid primitive: the kp/kd terms track a target position,
the feed-forward torque adds a force component. The SDK exposes it three ways.
import xense.taccap as t
g = t.FollowerGripper.open()
g.motor.clear_fault()
g.motor.enable() # required before anything moves
# Motion goes through a controller. The raw `submit_*` primitives are exposed
# too, but they write a control frame straight to the wire with no error clamp
# and no torque ceiling — the firmware envelope is then your only protection.
c = t.ImpedanceController(g, t.ImpedanceConfig.for_spec(g.motor.get_spec()))
c.start() # seeds the target with the current position
c.set_target(0.35) # normalized [0,1], 0 = closed
s = c.snapshot() # state + observation + command, non-blocking
c.stop()Normalized position — work in [0, 1] (0 = closed, 1 = open) instead of raw
radians. Requires a calibrated gripper (GripperConfig Valid); throws otherwise.
print(g.position()) # -> 0.97 (nearly open)
g.pos_to_rad(0.5), g.rad_to_pos(-0.59) # explicit conversionsThe one-shot FollowerGripper::set_position(0..1) is C++ only — from Python
a normalized target goes through a controller's set_target(). The raw
Motor.submit_* primitives are exposed to Python and do take raw radians, but
nothing on the host side clamps them; see the warning above.
ImpedanceController — use this one to follow a position. A C++
background thread submits the latest normalized target in phase with the
motor-status stream while that stream keeps a thread-safe observation fresh.
Your policy only touches set_target(0..1) and snapshot(), both non-blocking
(no GIL fights, no status polling).
cfg = t.ImpedanceConfig.for_spec(g.motor.get_spec()) # tune kp/kd/budget on cfg
c = t.ImpedanceController(g, cfg)
c.start() # seeds target = current pos (no jump)
try:
while running:
s = c.snapshot() # one lock, one consistent view
if s.state == t.ImpedanceState.FAULT:
break # s.fault_reason says why; c.reset() resumes
obs = s.observation # .position [0,1], .velocity, .torque, .age_ms
c.set_target(policy(obs)) # your action, 0..1
finally:
c.stop() # zero torque, then leaves the motor disabledA blocked jaw is not a fault here: the error clamp saturates at
max_position_torque_nm (from for_spec(): the device's continuous stall
rating, 1.1 Nm on an EL05, 3.6 on an RS00)
and holds there. Contact needs no detecting; saturation is what it looks like.
ForcePositionController — the one for grasping. It takes the grip
force with each command (set_target(p, grasp_torque_nm)) and reports
holding, so a blocked jaw settles at exactly the force you asked for.
fp = t.ForcePositionController(g, t.ForcePositionConfig.for_spec(g.motor.get_spec()))
fp.start()
try:
fp.set_target(0.0) # close; 0 = closed, 1 = open
while True:
s = fp.snapshot()
if s.holding: break # gripping the object
if s.arrived: break # reached the target, nothing there
finally:
fp.stop() # commands zero torque and disablesTwo parameters, and only these two are task-specific:
| default | what it is | |
|---|---|---|
grasp_torque_nm |
the device's continuous stall rating | the grip force. Free travel does not use it; a blocked jaw settles here. 1.1 N·m on an EL05, 3.6 on an RS00 — take it from ForcePositionConfig.for_spec(g.motor.get_spec()) rather than hard-coding it |
close_speed_radps |
1.1 rad/s via for_spec() (the bare struct says 0.5) |
travel speed |
cfg = t.ForcePositionConfig.for_spec(g.motor.get_spec())
cfg.grasp_torque_nm = 0.4 # gentler grip
fp = t.ForcePositionController(g, cfg)Everything else has one right answer for this gripper and is not exposed: the position gain, the travel damping, the arrival radius. They were measured on this hardware, not guessed, and changing them is far likelier to make the gripper judder than to improve it.
Two observations, not states. snapshot().holding means the setpoint has run
as far ahead of the jaw as the force budget allows and the jaw is not following —
that is what gripping is. snapshot().arrived means it reached the commanded
position. Nothing else needs interpreting.
How hard, and for how long. 1.1 Nm is the EL05's continuous stall rating (RS00: 3.6); the thermal figures below were measured on an EL05 only. Measured on a real workpiece at 24 V: a 600 s hold took the winding 36 → 70 °C with the rate decaying 8 → 1 °C/min, which fits a plateau near 75 °C, about 15 °C under the firmware's temperature wall — no fault, no link loss, no drift. Grips of seconds to minutes are comfortably inside that. For a hold measured in tens of minutes, or a warm cabinet, drop the force: 0.6 Nm settles flat at 49 °C.
start()refuses (raisesValueError) a grasp torque above the device's continuous stall rating.
LEDs and power-on auto-calibration (V1.9):
g.led.set(t.Ws2812Mode.Override, 0, 255, 0, brightness=120) # solid green
g.led.effect(t.Ws2812EffectType.ColorBreathe, 0, 0, 255) # blue breathe
g.led.off()
cfg = g.get_auto_cal_config() # if enabled, the firmware self-zeros
g.set_auto_cal_config(cfg) # (close-to-stall) + captures max_open
# (open-to-stall) on power-upWrite the motion-safety envelope first. It is the firmware's torque and
thermal protection. A factory device stores none (GripperConfig.reserved all
zero). What happens then depends on the firmware: follower 1.2.12+ enforces a
model default (cont = stall rating, peak = rated torque, temperature wall
90/100 °C; g.motor.get_model().default_envelope == 1, and audit_envelope()
reports it as effective with firmware_default), while older firmware
enforces nothing at all. Write an explicit record either way:
python python/examples/impedance_control.py right --show-envelope # read, never writes
python python/examples/impedance_control.py right --set-envelope # repair, writes flashThere is no number to pick. The device knows its own motor's ratings and the
firmware clamps against them whatever you write, so the SDK reads them (0x56)
and derives the record:
| Field | Value | Meaning |
|---|---|---|
peak_torque_nm |
the motor's rated torque (1.8 N·m on an EL05, 5.0 on an RS00) | Transient ceiling during motion, applied as a position-error clamp. It also sets the approach speed, roughly peak/kd |
cont_torque_nm |
the motor's continuous stall rating (1.1 N·m on an EL05, 3.6 on an RS00) | What a blocked jaw may hold indefinitely, and the floor of the I²t derate. Not the rated torque — that is the rotating rating, and a gripper's main duty cycle is the blocked hold |
temp_derate_start_c |
0 → firmware 90 °C | Where the thermal derate begins |
temp_wall_c |
0 → firmware 100 °C | Temperature wall; above it only 0.30 N·m remains |
What a device stores is not what it enforces. The firmware clamps cont
down to the stall rating and says so only on a UART that is not wired to USB,
while a read-back returns flash. A unit on our bench stored cont=1.800 and ran
at 1.100 for weeks. audit_envelope() reports stored, effective and
recommended separately; effective is None when the firmware enforces
nothing at all. ensure_envelope() repairs it and writes only when needed. When
the device reports its spec it sets cont to the stall rating and peak to the
rated torque exactly, lowering or raising whatever is stored
(GRIPPER_ENVELOPE_ISSUE_NOT_AT_SPEC); only on a device that cannot report its
spec does it keep a stored record that is stricter than the recommendation.
The envelope lives in the GripperConfig record (commands 0x66/0x67, no
protocol change), survives power loss, and is written once per device. It is
enforced in the firmware's MIT branch — the one point every motion command must
pass — so it holds even against MIT frames sent without this SDK. That is
precisely why it belongs in firmware rather than here.
Without it, a jaw driven at kp=20 into a rigid object asks for around 12 N·m;
at 24 V that current demand collapses the rail, the motor's undervoltage
protection fires and the grip drops. With the envelope enabled the same test
holds for 25 s. See
docs/CONTROL_LAYERING.md.
Two behaviours worth knowing:
- Grip force is not constant. The I²t derate and the temperature wall apply
to a pure-torque hold as well.
grasp_torque_nmis what you ask for; the firmware lowers what actually comes out as the motor heats. - Do not poll
read_status()while controlling. The stream-locked phase protects telemetry frames, not ACKs: the request can land in the window the MCU is transmitting and destroy a telemetry frame, and its own reply can be corrupted by the control loop's submit. Position, velocity, torque, status bits and temperature are all insnapshot()— nothing needs the bus.
Runnable demos: python/examples/impedance_control.py,
python/examples/force_position_control.py, and
python/examples/gripper_console.py (interactive, both controllers).
g.diagnostics wraps the firmware's own UART counters (firmware 1.1.3+) and a
runtime log switch (1.1.4+). Both work on either gripper role.
s = g.diagnostics.uart_stats()
# tx_calls_ok / tx_bytes_ok — what the firmware's transmit call accepted,
# i.e. what actually reached the MCU's TX register
# tx_fail_timeout — the firmware truncated frames itself
# rx_overflow — the firmware's command task fell behind the host
# debug_tx_bytes — bytes out the DEBUG UART; quantifies logging costThe point of tx_calls_ok is attribution. Compare it against what the host
decoded over the same window: if the counts agree but bytes are missing, the
loss happened after the bytes left the MCU (cable or USB-serial bridge) and
no firmware change reaches it.
from xense.taccap import LogLevel, LOG_OUTPUT_UART
g.diagnostics.set_log_config(LogLevel.DEBUG, LOG_OUTPUT_UART) # on
g.diagnostics.disable_logging() # off againLogging is a diagnostic lever, not a setting. Firmware 1.1.4 ships with it off because the log sink is a blocking polled UART write (~0.5 ms per line at 921600) that stalls whichever task emitted the line — logging on every received command is what livelocked the command channel. Output also goes to the MCU's DEBUG UART, which is not routed over USB: without a probe on that pin you pay the realtime cost and see nothing.
The SDK uses one singleton logger named "xense.taccap" —
registered with spdlog, shared by every C++ TU and the Python
binding. Don't construct your own std::make_shared<spdlog::logger>
elsewhere, and don't reach for std::cout / print / printf
for diagnostic output — they bypass the file sink.
- C++:
#include <taccap/log.hpp>, thenxense::taccap::logger()->info(...). - Python:
from xense.taccap import log; log.info(...)/log.set_level("debug")/log.set_pattern(...).set_levelandset_patternaffect the console sink only — the file sink keeps its archive format for grep stability.
Two sinks attached by default:
| Sink | Level | Pattern |
|---|---|---|
| stderr (colour) | user-controllable, default INFO |
[%D %T.%e] [%n] [%^%l%$] %v |
| file (per-session) | always DEBUG |
[%Y-%m-%d %H:%M:%S.%e] [%n] [%l] %v |
File-sink behaviour:
- Directory:
$TACCAP_LOG_DIRif set, else~/.taccaplogs/. - Filename:
session_YYYYMMDD_HHMMSS.log— one new file per process start. - At most 10 session logs retained; oldest mtime pruned at startup.
- File-sink creation failures (disk full / permission denied) are not fatal — the console sink keeps working.
taccap-gripper/
├── cpp/
│ ├── include/taccap/ # Public C++ headers
│ ├── src/ # SDK implementation (protocol, bus, components, ...)
│ ├── examples/ # C++ example programs (leader_demo, follower_status,
│ │ # follower_impedance, follower_force_position)
│ └── tests/ # gtest unit tests
├── python/
│ ├── bindings/ # pybind11 module sources
│ ├── examples/ # Python examples
│ └── xense/taccap/ # Python package (PEP 420 namespace under `xense`)
├── third_party/
│ └── firmware/ # Clone-on-demand reference repos (gitignored)
│ ├── tc-gu-01/ # STM32 firmware that runs on the gripper
│ └── tc-gu-01-pc/ # PyQt debug GUI (operator-side)
├── docs/ # Architecture & API docs
├── environment.yml # mamba env (Python 3.12, conda-forge only)
├── pyproject.toml # scikit-build-core wheel config
└── CMakeLists.txt # Top-level build orchestrator
- docs/USAGE.md — end-to-end usage: bringing up all three data paths — tactile (OG), wrist camera, and gripper readout/control. Also in Chinese as docs/USAGE_CN.md.
- docs/INSTALL.md — prerequisites, C++-only builds, device permissions, rebuild/clean, environment traps.
- docs/CALIBRATION.md — encoder zero + travel span, the flash-persisted records, drift handling.
- docs/FIRMWARE.md — reference repos, building the firmware, and flashing over OTA. Read the power-cycle note before you measure anything after a flash.
- docs/EXAMPLES.md — what each example script does.
- docs/ARCHITECTURE.md — layered stack, module map, threading model, and the boundary between this SDK and downstream consumers.
If this SDK supports published work, please cite the release you actually used — the behaviour it documents is version-specific, and several defaults have moved between releases.
@software{taccap_gripper,
title = {taccap-gripper: an SDK for the TacCap multimodal tactile gripper},
author = {{XenseRobotics Co., Ltd.}},
year = {2026},
url = {https://github.com/XenseRobotics-AI/TacCap-Gripper},
version = {VERSION},
license = {Apache-2.0}
}Replace VERSION with the tag you built against — python -c "import xense.taccap as t; print(t.__version__)" reports what is installed. Firmware
matters too: a measurement depends on the gripper firmware as much as on this
SDK, so state that version as well (g.firmware_version).
Apache-2.0. Copyright (c) 2026 XenseRobotics Co., Ltd.