Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,30 @@ for the single-threaded OTA flow, which is the only user.
└─────────────────────────────────────────────────────────────────────────┘
```

### 1.1 Components by role and command-set version

Not every component exists on both roles, and several only arrived with a
particular command-set revision. Calling one the firmware does not implement
fails loudly with `ProtocolError(InvalidCmd)` rather than misbehaving.

| Component | Leader | Follower | Command set | Notes |
| --- | --- | --- | --- | --- |
| `IMU` / `Encoder` — `read_once()`, `on_data(cb)` | yes | no stream | base | Follower firmware streams motor status only; see [USAGE.md](USAGE.md) §1.2 |
| `Key` | yes | yes | base | |
| `Led` (WS2812) | yes | yes | V1.9 | |
| `SensorErrors` | yes | yes | V1.6 | decoded fault words |
| `OtaSession` | yes | yes | V1.3 | `ota_update.py` drives it through `LeaderGripper` whatever the role |
| `Motor` | — | yes | V1.7 | Python reaches it only through `ControlLoop` / `ForcePositionController` |
| `Calibration` — `read_fisheye()` / `write_fisheye()` / `resolve_fisheye()` | yes | yes | V2.0 | |
| `Calibration` — `read_encoder_max_rad()` / `write_encoder_max_rad()` | yes | NACK (`InvalidCmd`) | V2.1 | |
| Normalized position `[0, 1]` | yes — from the encoder-max record, or `Config::encoder_max_rad` supplied by the host for pre-V2.1 firmware | yes — from `GripperConfig` | — | see [CALIBRATION.md](CALIBRATION.md) |
| `Camera` (wrist UVC) | opt-in | opt-in | — | its own capture thread, never the serial transport; `open_cameras=true` or a standalone `Camera` |

A record that was never written normally reads back as an empty `optional`
(Python `None`) — but not always: the fisheye read can answer with an all-zero
record instead. [CALIBRATION.md](CALIBRATION.md) covers how `resolve_fisheye()`
handles that.

---

## 2. Module map
Expand Down Expand Up @@ -540,6 +564,15 @@ will be implemented later:
| Master→slave follow / teleop loop, grasp state machine (contact/latch), episode orchestration | downstream apps / `taccap_gripper_ros2` — this SDK gives the realtime primitives (`ControlLoop`, `submit_*`, normalized position), not the policy |
| Higher-level orchestration (episode controller, replay, visualisation) | downstream applications |

One concrete consequence of that boundary: the lerobot adapter
(`xense-taccap-lerobot`) consumes this SDK for the MCU only and does not use
the SDK `Camera` at all. Its wrist-camera frames come from LeRobot's own
`OpenCVCamera`, and its visuotactile images from `XenseTactileCamera` on top
of the `xensesdk` wheel. That is also why `LeaderGripper.open()` /
`FollowerGripper.open()` never touch a V4L2 device unless constructed with
`open_cameras=true` and a device path — whoever already owns `/dev/video*`
keeps it.

The follower motor stack **is** in this repo now (`Motor`, `FollowerGripper`,
`GripperPosition`, `ControlLoop`, `Led`) and hardware-validated — what stays
out is the *policy* layer above the primitives.
Expand Down
8 changes: 6 additions & 2 deletions docs/CALIBRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ absorbs this two ways:
pointing at calibration or mechanical issues.

To calibrate a gripper, run `calibrate.py` against the side you want to fix
(or its SN, if you'd rather be explicit):
(or its SN, if you'd rather be explicit). It is a **once-per-leader** job: both
results go to MCU flash (see below), so nothing here needs repeating after a
power cycle:

```bash
python python/examples/calibrate.py left # by side
Expand All @@ -30,7 +32,9 @@ The script:
1. Resolves `left`/`right` (or the SN you passed) to one `mcu_device`, and
prints the firmware SN it picked plus every gripper it can see, so the
pick is verifiable. Side comes from the firmware-burned SN read over the
wire (`Cmd::GetSn`), not the CH343 chip serial.
wire (`Cmd::GetSn`), not the CH343 chip serial. If two grippers resolve to
the same side it refuses and lists both firmware SNs rather than guessing
for you.
2. **Pre-flight:** checks the firmware implements `Cmd::EncoderMaxCal`
(0x2C). This runs *before* anything is written — step 4 persists a new
zero, so a pre-V2.1 gripper is refused while still untouched rather than
Expand Down
63 changes: 62 additions & 1 deletion docs/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,71 @@ python python/examples/ota_update.py --get-status right
| `fisheye_cal.py` | Read/write the flash-persisted calibration records (V2.0/V2.1): `show`, `set-fisheye` (flags or an OpenCV `.npz` holding `K`/`D`), `set-encoder-max`, and `measure-encoder-max` — the guided close-zero → open-sample → store flow that unlocks normalized leader position. |
| `wrist_camera.py` | Stand-alone wrist-camera viewer, selected by `left` / `right` (or an XC serial) like every other example. **XC wrist cameras only** — a GSPS visuotactile serial or a raw `/dev/videoN` path is refused, since those sensors belong to `xensesdk`. Fisheye undistortion on a switch, **off by default like the SDK itself**: `--undistort` / `--compare` (raw \| rectified side by side), `--balance`, and `u` / `[` `]` to cycle live. Intrinsics come from the same-side gripper via `resolve_fisheye()`, from a `.npz`, or from the SDK reference values with `--no-mcu`. Headless with `--no-display` (+ `--save-dir` for one PNG/s). |
| `leader_normalized_position.py` | Streams a leader gripper's opening as `0..1` via `normalize_position=True`, with a live bar. Needs the encoder-max record (or `--encoder-max-rad` to bypass the firmware read). |
| `ota_update.py` | Firmware OTA flashing CLI with progress + post-flash status probe. **Risky — wrong artefact bricks the MCU.** |
| `ota_update.py` | Firmware OTA flashing CLI with progress + post-flash status probe. The released images ship in `firmware/`; pass a bare filename, a role (`master` / `slave`) or `--all` and the script finds the matching image. Every image is identified by CRC32 against `firmware/manifest.json` and a **role-mismatched flash is refused** (`--force` overrides — only if you really mean it). **Risky — wrong artefact bricks the MCU.** Power-cycle after every flash, see [FIRMWARE.md](FIRMWARE.md). |
| `leader_demo` (C++) | Reports streaming rates for a single leader gripper over 5 seconds. |


## C++ 冒烟程序 `leader_demo`

打开 C++ 示例后,直接跑官方 `leader_demo` 就能验证单只主夹爪的 IMU 与编码器流:

```bash
cmake -B build -G Ninja \
-DTACCAP_BUILD_PYTHON=OFF \
-DTACCAP_BUILD_EXAMPLES=ON
cmake --build build -j
./build/cpp/examples/leader_demo
```

它用 `LeaderGripper::open()` 自动发现**当前唯一连接**的夹爪并采样 5 秒,结束时报告各路流速率。
同时插了多只夹爪时 `open()` 会抛错,应改成先扫描端点再显式构造 —— 最小的 C++17 写法如下;
工程里通过 `add_subdirectory()` 引入 SDK 并链接 `taccap_core`(见 [INSTALL.md](INSTALL.md)):

```cpp
#include <taccap/discovery.hpp>
#include <taccap/leader_gripper.hpp>

#include <chrono>
#include <iostream>
#include <thread>

int main() {
namespace tc = xense::taccap;

const auto ep = tc::discovery::find_leader(); // 按固件 SN 的 m 后缀选 Leader
tc::LeaderGripper::Config cfg;
cfg.mcu_device = ep.mcu_device;
tc::LeaderGripper gripper(cfg);

gripper.encoder().on_data([](const auto& sample) {
std::cout << "encoder=" << sample.position_rad << "\n";
});
gripper.start_streaming(100, 100);
std::this_thread::sleep_for(std::chrono::seconds(5));
gripper.stop_streaming();
return 0;
}
```

## 从夹爪电机控制示例(会驱动真实电机)

> **以下脚本只适用于从夹爪(Follower),而且会让真实电机动起来。** 运行前清空夹爪运动
> 范围内的东西,确认急停 / 断电手段就在手边,并让手指远离夹爪。主夹爪没有电机,不要在
> 主夹爪上执行 —— 用错角色不会自动纠正,只会在发命令时 NACK。

- `impedance_control.py`:`ControlLoop` 的归一化开度 `[0,1]` 控制,跑一遍开合序列。
- `force_position_control.py`:`ForcePositionController`,被挡住后切纯力矩保持,带夹持力。
- `gripper_console.py`:键盘控制台,两种控制器共用一个 UI。

三者都要求从夹爪的 `GripperConfig` 已完成闭合零点与最大开度标定,并且**先写好运动安全
包络**(见下一节第 0 步)。设备选择器和其它示例一样是位置参数:

```bash
python python/examples/impedance_control.py right
python python/examples/force_position_control.py right --grasp-torque 0.35
python python/examples/gripper_console.py right --mode force-position
```

## 力位混合控制器

`ForcePositionController` 和纯阻抗的区别在被挡住之后:
Expand Down
52 changes: 48 additions & 4 deletions docs/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ to build.

| | Required |
| --------------------- | --------------------------------------------------------------------------------------------------- |
| OS | Linux (Ubuntu 22.04+ tested). The capture path is V4L2 + UVC XU; macOS / Windows are not supported. |
| OS | Linux (Ubuntu 22.04 / 24.04 tested). The capture path is V4L2 + UVC XU; macOS / Windows are not supported. |
| Toolchain | gcc/g++ ≥ 13, CMake ≥ 3.20, Ninja, pkg-config |
| Python (for bindings) | CPython 3.12 |
| Python (for bindings) | CPython ≥ 3.10 (`requires-python` in `pyproject.toml`); `environment.yml` pins 3.12 as the recommended development interpreter |
| Recommended | `mamba` / `conda` — `environment.yml` pins the entire toolchain & C++ deps to a known-good set |

> **Why mamba is recommended.** `environment.yml` ships gcc-14, OpenCV
Expand All @@ -32,6 +32,13 @@ cd taccap-gripper

There are no git submodules — the SDK builds standalone.

Every command below assumes the SDK root as the working directory. If you
consume the SDK as a vendored submodule of a downstream repo instead (e.g.
`xense-taccap-lerobot` carries it at `third_party/taccap-gripper`), skip the
clone and `cd third_party/taccap-gripper` first — the steps are otherwise the
same. If all you do is collect data with such a repo you normally never need
this page: its `setup_env.sh` builds the SDK as part of that environment.

### 3. Create the development environment

```bash
Expand Down Expand Up @@ -72,8 +79,10 @@ core and the pybind11 extension, then co-locates them inside the wheel
under `xense/taccap/`:

```bash
# Editable / development install (re-runs CMake on every `pip install -e .`):
pip install -e .
# Editable / development install (re-runs CMake on every `pip install -e .`).
# --no-build-isolation builds against the env's pinned pybind11 /
# scikit-build-core instead of pulling fresh copies into a temp venv:
pip install -e . --no-build-isolation

# Or a regular install (builds a wheel, installs it):
pip install .
Expand Down Expand Up @@ -127,6 +136,22 @@ CMake options (top-level `CMakeLists.txt:19-21`):
| `TACCAP_BUILD_EXAMPLES` | `OFF` | Build the `leader_demo` smoke binary |
| `TACCAP_BUILD_TESTS` | `OFF` | Build the gtest suite under `cpp/tests/` |

### 5c. Integrate into another CMake / ROS 2 project

The SDK currently **installs no headers and exports no CMake package config**,
so `find_package(taccap-gripper)` does not work. Consume it as a source
subdirectory instead:

```cmake
add_subdirectory(path/to/taccap-gripper taccap-gripper-build)
target_link_libraries(my_target PRIVATE taccap_core)
```

`taccap_core` propagates its public include directory and the OpenCV / spdlog
dependencies to `my_target`. Copying `libtaccap_core.so` on its own is not
enough for a C++ integration — you would also need the matching public headers
and dependencies — so that route is not recommended.

### 6. Verify

```bash
Expand All @@ -142,6 +167,25 @@ env -u PYTHONPATH pytest python/tests
ctest --test-dir build --output-on-failure
```

**Hardware self-check.** With a gripper plugged in, the scan should print one
line per connected unit:

```bash
python -c "from xense.taccap import scan_grippers
for g in scan_grippers():
print(f'side={g.side.name} role={g.role.name} ch343={g.mcu_serial} fw_sn={g.firmware_sn!r}')"
```

A single line with only one gripper attached is normal — left and right do not
have to be present together. Under the current SN scheme `firmware_sn` is
non-empty and `role` is `Leader` / `Follower`. A legacy SN, a unit whose SN was
never burned, a failed SN read on a cold start, or old firmware can all show an
empty SN / `Unknown` — do not read that alone as "firmware < V1.6". An empty
scan usually means the serial permissions from step 4 have not taken effect
yet, or ModemManager has grabbed the `/dev/ttyACM*` node. The equivalent checks
for the wrist camera and the visuotactile sensors are in
[USAGE.md](USAGE.md) §0.

> **If `xense.taccap` resolves somewhere unexpected, check `PYTHONPATH`.**
> Stacked conda activations can export another env's `site-packages` into
> *every* interpreter, which then shadows this repo with whatever editable
Expand Down
14 changes: 8 additions & 6 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ python python/examples/wrist_camera.py --list
python -c "from xensesdk import Sensor; print(Sensor.scanSerialNumber())"
```

健康的输出:夹爪那条打出 `[L]` / `[R]` 且 `fw_sn` 非空;`--list` 那条把腕相机
(`XC…`)和视触觉(`GSPS01…`)分开列出;OG 那条打出形如 `{'OG000352': 10}` 的
字典。`fw_sn` 为空说明固件还没烧 SN(或固件早于 V1.6),此时侧别会是
`Side.Unknown`。
健康的输出:夹爪那条每台一行,打出 `[L]` / `[R]` 且 `fw_sn` 非空、`role` 是
`Leader` / `Follower`(只插一台时只有一行,这是正常的,不要求左右同时在);`--list`
那条把腕相机(`XC…`)和视触觉(`GSPS01…`)分开列出;OG 那条打出形如
`{'OG000352': 10}` 的字典。`fw_sn` 为空或 `role` 为 `Unknown` 有好几种来源:固件还没
烧 SN、旧格式序列号、冷启动时那一次 SN 读取失败、或固件早于 V1.6 —— 别只凭空 SN 就
断定固件旧;这时侧别也可能是 `Side.Unknown`。

> **三类设备靠序列号区分,不靠设备号。** `/dev/videoN` 的编号随插拔顺序变,而且
> 腕相机和视触觉挨着枚举 —— 认错了就会把触觉传感器当相机打开。序列号语法和
Expand Down Expand Up @@ -483,8 +485,8 @@ finally:

| 现象 | 多半是 |
| --- | --- |
| `scan_grippers()` 返回空 | 串口权限(见 [INSTALL.md](INSTALL.md))或线没插好 |
| `fw_sn` 为空 / `Side.Unknown` | 固件没烧 SN,或固件早于 V1.6 |
| `scan_grippers()` 返回空 | 串口权限(见 [INSTALL.md](INSTALL.md))、ModemManager 抢占了 `/dev/ttyACM*`,或线没插好 |
| `fw_sn` 为空 / `Side.Unknown` | 固件没烧 SN、旧格式序列号、冷启动那次 SN 读取失败,或固件早于 V1.6 —— 别只凭这个断定固件旧 |
| 矫正后画面全黑 | 用了 `read_fisheye()` 的全零记录 —— 改用 `resolve_fisheye()` |
| 矫正后画面偏心 | 未必是错的,主点本来就不一定在画面中心 |
| 录下来的颜色是反的 | 裸 `Camera` 是 BGR、`wrist_camera` 是 RGB,搞混了 |
Expand Down