Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .git-blame-ignore-revs
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
# reformat below was verified by comparing the syntax tree of every touched
# file before and after (ast.dump, 19 files, zero differences).

# style: 全仓跑一遍 ruff format,并在 CI 加上格式门
# style: run ruff format over the whole repo and add a format gate to CI
51853b9edc5aa8a0fcb8a4537e86b01a6aade49b
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Motor firmware ships in `firmware/`.** `EL05_1.0.5.0.4.bin` is listed under
a new `motors` section of `firmware/manifest.json`. `motor_ota_update.py`
takes the image as optional: without one it picks the shipped image for the
motor model recorded on the gripper (refusing when only the compile-time
default is known), and a shipped image is checked against the manifest's
CRC32. A bare image name also resolves under `firmware/`.

### Changed

- **Source code is English-only.** Comments, docstrings, pybind11 docstrings
and runtime text (log and exception messages, example output, argparse help)
across `cpp/`, `python/` and the examples were translated from Chinese.
Scripts or log filters that match on the old Chinese text need updating, for
example the follower's below-1.2.11 warning now reads `is below 1.2.11`.
Example table headers use short English labels at the same column widths.

## [0.4.3] - 2026-10-10

_The `v0.4.3` tag was moved on 2026-10-18 to include the EL05 motor OTA
Expand Down
7 changes: 4 additions & 3 deletions cpp/examples/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# 示例覆盖:主爪读流、从爪读状态、两个控制器。
# 刻意不提供任何电机原语(submit_position / submit_velocity / submit_torque /
# submit_impedance)的示例 —— 它们不经过主机侧的误差钳位、力矩天花板和堵转保护。
# Examples cover: leader streaming, follower status reads, and the two controllers.
# There are deliberately no examples for any motor primitive (submit_position / submit_velocity /
# submit_torque / submit_impedance) — they bypass the host-side error clamp, torque ceiling and
# stall protection.
foreach(_ex leader_demo follower_status follower_impedance follower_force_position)
add_executable(${_ex} ${_ex}.cpp)
target_link_libraries(${_ex} PRIVATE taccap_core)
Expand Down
22 changes: 13 additions & 9 deletions cpp/examples/example_target.hpp
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
// Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0
//
// 示例共用的目标选择:`left` / `right` / 明确的 SN,和 Python 示例同一套约定。
// Target selection shared by the examples: `left` / `right` / an explicit SN, the same
// convention as the Python examples.
//
// 存在的理由很具体:工位上常年插着四台(两主两从),而 FollowerGripper::open()
// 走的是 find_one(),多于一台就抛 IoError。没有选择器的示例在真实台架上一次
// 都跑不起来 —— 而且「拿到的是哪一台」不能靠猜,刷错、标定错的代价太高。
// The reason it exists is concrete: the workstation permanently has four units plugged in (two
// leaders, two followers), and FollowerGripper::open() goes through find_one(), which throws
// IoError if there is more than one. An example without a selector cannot run even once on a
// real bench — and "which unit did I get" must not be guessed; flashing or calibrating the wrong
// one costs too much.

#pragma once

Expand All @@ -20,7 +23,7 @@
namespace example {

inline void print_candidates(const std::vector<xense::taccap::discovery::GripperEndpoints>& all) {
std::printf("当前接着的:\n");
std::printf("Currently connected:\n");
for (const auto& e : all) {
const char* side =
e.side == xense::taccap::discovery::Side::Left ? "left "
Expand All @@ -35,7 +38,8 @@ inline void print_candidates(const std::vector<xense::taccap::discovery::Gripper
}
}

// argv[1] 可以是 "left" / "right" / 一个完整 SN;省略则要求恰好接着一台从爪。
// argv[1] can be "left" / "right" / a full SN; if omitted, exactly one follower must be
// connected.
inline std::unique_ptr<xense::taccap::FollowerGripper>
open_follower(int argc, char** argv) {
using namespace xense::taccap;
Expand All @@ -62,10 +66,10 @@ open_follower(int argc, char** argv) {

if (hits.size() != 1) {
std::printf("%s\n",
hits.empty() ? "没有匹配的从爪。"
: "匹配到不止一台从爪,请用 SN 指定。");
hits.empty() ? "No matching follower."
: "More than one follower matched; specify one by SN.");
print_candidates(all);
std::printf("用法: %s [left|right|SN]\n", argv[0]);
std::printf("Usage: %s [left|right|SN]\n", argv[0]);
throw std::runtime_error("target selection failed");
}

Expand Down
58 changes: 33 additions & 25 deletions cpp/examples/follower_force_position.cpp
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
// Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0
//
// 力位混合控制。
// Force-position hybrid control.
//
// **全程只有一条控制律**,没有接触检测:
// **There is a single control law throughout**, with no contact detection:
//
// command(target, kp, kd, torque_budget = grasp_torque_nm)
//
// PD 请求对预算做误差钳位。自由行程只付摩擦,被挡住就钳在恰好的预算上并保持 ——
// 不检测接触,**饱和本身就是接触**。SDK 0.2.0 删掉了主机侧的接触状态机:MCU 已经
// 在 500Hz 跑同一个堵转判据并且是权威,主机再跑一份,同一个物理事件的两份副本
// 会漂开,比只在任一侧跑都糟。
// The PD request is error-clamped to the budget. Free travel only pays friction; when blocked,
// it clamps at exactly the budget and holds — contact is not detected, **saturation is the
// contact**. SDK 0.2.0 removed the host-side contact state machine: the MCU already runs the
// same stall criterion at 500Hz and is authoritative; running a second copy on the host means
// two copies of the same physical event drift apart, which is worse than running it on either
// side alone.
//
// 闭合端点额外带一个有界前馈预压(close_preload_nm,默认 0.25 Nm),把爪子坐死在
// 机械止点上。没有它,kp*(target-actual) 在目标处恰好归零,爪子到了就不再施力,
// 齿隙留在那儿 —— 实测闭合位保持力矩只有 0.001 Nm。预压是从预算里**预留**而非
// 叠加,所以「总请求不超过 grasp_torque_nm」这条不变量仍然成立。
// The closed endpoint additionally carries a bounded feed-forward preload (close_preload_nm,
// default 0.25 Nm) that seats the jaw firmly against the mechanical stop. Without it,
// kp*(target-actual) goes exactly to zero at the target, the jaw stops applying force once it
// arrives, and the backlash stays there — measured closed-position holding torque was only
// 0.001 Nm. The preload is **reserved** out of the budget rather than added on top, so the
// invariant "total request never exceeds grasp_torque_nm" still holds.
//
// holding 是观测量,不是状态跳转:命令用满了预算而爪子仍然没在走。在命令位置上
// 报 holding 意味着预算被一次本该到位的移动用满了 —— 那是标定或映射出了问题。
// holding is an observation, not a state transition: the command has used up the budget and
// the jaw is still not moving. Reporting holding at the commanded position means the budget
// was used up by a move that should have arrived — that is a calibration or mapping problem.

#include "example_target.hpp"

Expand All @@ -40,7 +45,7 @@ int main(int argc, char** argv) {
}

ForcePositionConfig cfg;
cfg.grasp_torque_nm = 1.1f; // 预算:自由行程用不到,被挡住时就停在这个值
cfg.grasp_torque_nm = 1.1f; // budget: unused in free travel, settles here when blocked
std::printf("[cfg] grasp=%.2f Nm close_preload=%.2f Nm\n",
cfg.grasp_torque_nm, cfg.close_preload_nm);

Expand All @@ -52,13 +57,14 @@ int main(int argc, char** argv) {

for (float tgt : targets) {
c.set_target(tgt);
// 等停:等「到位」或「被挡住」,而不是固定睡一段 —— 固定等待既会截断慢
// 的运动,又会在快的运动上白等。
// Wait for settle: wait for "arrived" or "blocked" instead of sleeping a fixed time —
// a fixed wait both truncates slow moves and wastes time on fast ones.
//
// 但不能直接等 arrived 变真:**set_target() 不会立刻清掉它**,要到下一个
// 控制拍才更新。上一次运动留下的 arrived=true 会让这个循环在爪子还没
// 开始动的时候就退出,于是量到的是行进中的位置。所以先等它变假(命令
// 被采纳),再等它变真。
// But you cannot just wait for arrived to become true: **set_target() does not clear it
// immediately**; it only updates on the next control tick. arrived=true left over from
// the previous move would make this loop exit before the jaw has even started moving,
// so the measured position would be mid-travel. So first wait for it to go false (the
// command has been accepted), then wait for it to go true.
const auto t_accept = std::chrono::steady_clock::now() + 500ms;
while (std::chrono::steady_clock::now() < t_accept && c.snapshot().arrived) {
std::this_thread::sleep_for(5ms);
Expand All @@ -71,29 +77,31 @@ int main(int argc, char** argv) {
if (s.arrived || s.holding) break;
std::this_thread::sleep_for(10ms);
}
std::this_thread::sleep_for(300ms); // 让保持稳下来再采样
std::this_thread::sleep_for(300ms); // let the hold settle before sampling
s = c.snapshot();

const float err = s.observation.position - tgt;
std::printf(" target=%.2f -> %-17s pos=%.4f err=%+.4f cmd=%.3f "
"实测=%+.3f Nm\n",
"measured=%+.3f Nm\n",
tgt, to_string(s.state), s.observation.position, err,
s.commanded_torque_nm, s.observation.torque);

if (s.state == ForcePositionState::Fault) {
std::printf(" FAULT: %s\n", s.fault_reason.c_str());
++failures;
} else if (s.holding && std::abs(err) < 0.02f) {
// 上面那条不变式:在命令位置上报 holding 是标定/映射的问题。
std::printf(" **在命令位置上报 holding —— 检查标定与归一化映射**\n");
// The invariant above: holding at the commanded position is a calibration/mapping
// problem.
std::printf(" **holding reported at the commanded position — check calibration "
"and the normalization mapping**\n");
++failures;
} else if (!s.arrived && !s.holding) {
std::printf(" **既没到位也没被挡住:超时**\n");
std::printf(" **neither arrived nor blocked: timeout**\n");
++failures;
}
}
c.stop();

std::printf("\n%zu 步,%d 个失败\n", sizeof(targets) / sizeof(targets[0]), failures);
std::printf("\n%zu steps, %d failures\n", sizeof(targets) / sizeof(targets[0]), failures);
return failures ? 1 : 0;
}
45 changes: 26 additions & 19 deletions cpp/examples/follower_impedance.cpp
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
// Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0
//
// 阻抗控制,以及**边控制边读状态**。
// Impedance control, and **reading status while controlling**.
//
// 控制器在后台按状态流的相位提交 MIT 帧,策略侧只碰两个非阻塞调用:
// The controller submits MIT frames in the background, phased to the status stream; the policy
// side touches only two non-blocking calls:
//
// c.set_target(p); // p in [0,1],0=闭合 1=张开
// auto s = c.snapshot(); // 状态 + 观测 + 保护标志,一把锁一致视图
// c.set_target(p); // p in [0,1], 0=closed 1=open
// auto s = c.snapshot(); // state + observation + protection flags, one-lock consistent view
//
// 状态是白拿的:控制器已经持有电机状态流,所以**控制期间不要再调 read_status()**
// —— 那是一次 ACK 往返,它的请求会和控制帧在串口上撞车。observation 里连电机
// 温度都有,就是为了让运行中的回路不需要任何额外命令。
// Status comes for free: the controller already owns the motor status stream, so **do not call
// read_status() during control** — that is an ACK round trip, and its request collides with the
// control frames on the serial link. observation even includes the motor temperature, precisely
// so a running loop never needs any extra command.
//
// snapshot() 在一把锁下取出整份视图,所以 state、observation、命令力矩描述的是
// 同一时刻;分字段去读会拼出一个从没存在过的状态。
// snapshot() takes the whole view under one lock, so state, observation and the commanded torque
// describe the same instant; reading field by field stitches together a state that never
// existed.
//
// observation.age_ms 是判断「流还活着」的正确依据 —— 见 follower_status.cpp。
// observation.age_ms is the correct basis for judging "the stream is still alive" — see
// follower_status.cpp.

#include "example_target.hpp"

Expand All @@ -37,45 +41,48 @@ int main(int argc, char** argv) {
}

ImpedanceConfig cfg;
cfg.kp = 20.0f; // 刚度 Nm/rad
cfg.kd = 1.0f; // 阻尼 Nm·s/rad,同时决定接近速度
cfg.kp = 20.0f; // stiffness Nm/rad
cfg.kd = 1.0f; // damping Nm·s/rad, also sets the approach speed
ImpedanceController c(*g, cfg);
c.start();

const float targets[] = {1.0f, 0.5f, 0.0f, 1.0f};
int stale = 0;

std::printf("\n %5s %5s %8s %9s %8s %7s %s\n",
"t", "目标", "位置", "力矩", "命令", "年龄", "状态");
"t", "tgt", "pos", "torque", "cmd", "age", "state");
const auto t0 = std::chrono::steady_clock::now();
for (float tgt : targets) {
c.set_target(tgt);
const auto deadline = std::chrono::steady_clock::now() + 2s;
while (std::chrono::steady_clock::now() < deadline) {
const auto s = c.snapshot(); // 非阻塞,不发任何命令
const auto s = c.snapshot(); // non-blocking, sends no command
const auto& o = s.observation;
if (s.state == ImpedanceState::Fault) {
std::printf(" FAULT: %s\n", s.fault_reason.c_str());
c.stop();
return 1;
}
// 观测太旧说明流断了,而不是爪子不动 —— 两者的处理完全不同。
// A stale observation means the stream broke, not that the jaw is not moving — the
// two are handled completely differently.
if (o.valid && o.age_ms > 100.0f) ++stale;
std::printf(" %5.1f %5.2f %8.4f %+9.4f %8.4f %6.0fms %s\n",
std::chrono::duration<double>(
std::chrono::steady_clock::now() - t0).count(),
tgt, o.position, o.torque, s.commanded_torque_nm,
o.age_ms, to_string(s.state));
std::this_thread::sleep_for(200ms); // 读侧频率和控制无关
std::this_thread::sleep_for(200ms); // read-side rate is independent of control
}
}
c.stop();

std::printf("\n观测过期(age>100ms)次数: %d\n", stale);
std::printf("\nstale observations (age>100ms): %d\n", stale);
if (stale) {
std::printf(" **流有断续** —— 控制器还在发帧,但它看到的状态是旧的。\n");
std::printf(" **stream is intermittent** — the controller is still sending frames, but "
"the status it sees is stale.\n");
return 1;
}
std::printf(" 状态全程新鲜:控制和读互不干扰,读侧不需要发任何命令。\n");
std::printf(" status fresh throughout: control and reads do not interfere; the read side "
"needs no commands.\n");
return 0;
}
Loading
Loading